Files
the-nexus/app.js
manus 5e4134de97
Some checks failed
CI / validate (pull_request) Failing after 12s
feat: implement Three.js scene foundation (#4)
- Core Three.js scene, renderer, and camera rig
- Multi-mode navigation (Walk, Orbit, Fly) with smooth state sync
- Performance-tiered lighting and shadow system
- Procedural nebula skybox and ambient environment
- Batcave terminal and portal visual foundation
- Performance budget detection for mobile and desktop

Fixes #4
2026-03-23 21:58:20 -04:00

1031 lines
33 KiB
JavaScript

import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
// ═══════════════════════════════════════════
// NEXUS v1 — Timmy's Sovereign Home
// ═══════════════════════════════════════════
const NEXUS = {
colors: {
primary: 0x4af0c0,
secondary: 0x7b5cff,
bg: 0x050510,
panelBg: 0x0a0f28,
nebula1: 0x1a0a3e,
nebula2: 0x0a1a3e,
gold: 0xffd700,
danger: 0xff4466,
gridLine: 0x1a2a4a,
}
};
// ═══ STATE ═══
let camera, scene, renderer, composer;
let clock, playerPos, playerRot;
let keys = {};
let mouseDown = false;
let batcaveTerminals = [];
let portalMesh, portalGlow;
let particles, dustParticles;
let debugOverlay;
let frameCount = 0, lastFPSTime = 0, fps = 0;
let chatOpen = true;
let loadProgress = 0;
let performanceTier = 'high'; // 'high' | 'medium' | 'low'
// ═══ NAVIGATION SYSTEM ═══
const NAV_MODES = ['walk', 'orbit', 'fly'];
let navModeIdx = 0; // default: walk
// Orbit state
const orbitState = {
target: new THREE.Vector3(0, 2, 0),
radius: 14,
theta: Math.PI, // azimuthal (horizontal rotation)
phi: Math.PI / 6, // polar (vertical tilt, 0=top)
minR: 3,
maxR: 40,
lastX: 0,
lastY: 0,
};
// Fly state — separate Y so walk and fly share XZ history
let flyY = 2;
// ═══ INIT ═══
function init() {
clock = new THREE.Clock();
playerPos = new THREE.Vector3(0, 2, 12);
playerRot = new THREE.Euler(0, 0, 0, 'YXZ');
// Renderer
const canvas = document.getElementById('nexus-canvas');
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// Performance budget — must run before scene objects are created
performanceTier = detectPerformanceTier();
updateLoad(20);
// Scene
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x050510, 0.012);
// Camera
camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.copy(playerPos);
updateLoad(30);
// Build world
createSkybox();
updateLoad(40);
createLighting();
updateLoad(50);
createFloor();
updateLoad(55);
createBatcaveTerminal();
updateLoad(70);
createPortal();
updateLoad(80);
createParticles();
createDustParticles();
updateLoad(85);
createAmbientStructures();
updateLoad(90);
// Post-processing
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.6, 0.4, 0.85
);
composer.addPass(bloom);
composer.addPass(new SMAAPass(window.innerWidth, window.innerHeight));
updateLoad(95);
// Events
setupControls();
window.addEventListener('resize', onResize);
// Debug overlay ref
debugOverlay = document.getElementById('debug-overlay');
updateLoad(100);
// Transition from loading to enter screen
setTimeout(() => {
document.getElementById('loading-screen').classList.add('fade-out');
const enterPrompt = document.getElementById('enter-prompt');
enterPrompt.style.display = 'flex';
enterPrompt.addEventListener('click', () => {
enterPrompt.classList.add('fade-out');
document.getElementById('hud').style.display = 'block';
setTimeout(() => { enterPrompt.remove(); }, 600);
}, { once: true });
setTimeout(() => { document.getElementById('loading-screen').remove(); }, 900);
}, 600);
// Start loop
requestAnimationFrame(gameLoop);
}
function updateLoad(pct) {
loadProgress = pct;
const fill = document.getElementById('load-progress');
if (fill) fill.style.width = pct + '%';
}
// ═══ PERFORMANCE BUDGET ═══
function detectPerformanceTier() {
const isMobile = /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent) || window.innerWidth < 768;
const cores = navigator.hardwareConcurrency || 4;
if (isMobile) {
renderer.setPixelRatio(1);
renderer.shadowMap.enabled = false;
renderer.toneMappingExposure = 1.0;
return 'low';
} else if (cores < 8) {
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.shadowMap.type = THREE.BasicShadowMap;
return 'medium';
} else {
// M3 Max / high-end desktop — full quality
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
return 'high';
}
}
function particleCount(base) {
if (performanceTier === 'low') return Math.floor(base * 0.25);
if (performanceTier === 'medium') return Math.floor(base * 0.6);
return base;
}
// ═══ SKYBOX ═══
function createSkybox() {
// Procedural nebula skybox using shader
const skyGeo = new THREE.SphereGeometry(400, 64, 64);
const skyMat = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uColor1: { value: new THREE.Color(0x0a0520) },
uColor2: { value: new THREE.Color(0x1a0a3e) },
uColor3: { value: new THREE.Color(0x0a1a3e) },
uStarDensity: { value: 0.97 },
},
vertexShader: `
varying vec3 vPos;
void main() {
vPos = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform vec3 uColor1;
uniform vec3 uColor2;
uniform vec3 uColor3;
uniform float uStarDensity;
varying vec3 vPos;
// Hash and noise
float hash(vec3 p) {
p = fract(p * vec3(443.897, 441.423, 437.195));
p += dot(p, p.yzx + 19.19);
return fract((p.x + p.y) * p.z);
}
float noise(vec3 p) {
vec3 i = floor(p);
vec3 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(
mix(mix(hash(i), hash(i + vec3(1,0,0)), f.x),
mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),
mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),
mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y),
f.z
);
}
float fbm(vec3 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 5; i++) {
v += a * noise(p);
p *= 2.0;
a *= 0.5;
}
return v;
}
void main() {
vec3 dir = normalize(vPos);
// Nebula clouds
float n1 = fbm(dir * 3.0 + uTime * 0.02);
float n2 = fbm(dir * 5.0 - uTime * 0.015 + 100.0);
float n3 = fbm(dir * 2.0 + uTime * 0.01 + 200.0);
vec3 col = uColor1;
col = mix(col, uColor2, smoothstep(0.3, 0.7, n1));
col = mix(col, uColor3, smoothstep(0.4, 0.8, n2) * 0.5);
// Nebula glow regions
float glow = pow(n1 * n2, 2.0) * 1.5;
col += vec3(0.15, 0.05, 0.25) * glow;
col += vec3(0.05, 0.15, 0.25) * pow(n3, 3.0);
// Stars
float starField = hash(dir * 800.0);
float stars = step(uStarDensity, starField) * (0.5 + 0.5 * hash(dir * 1600.0));
// Twinkling
float twinkle = 0.7 + 0.3 * sin(uTime * 2.0 + hash(dir * 400.0) * 6.28);
col += vec3(stars * twinkle);
// Big bright stars
float bigStar = step(0.998, starField);
col += vec3(0.8, 0.9, 1.0) * bigStar * twinkle;
gl_FragColor = vec4(col, 1.0);
}
`,
side: THREE.BackSide,
});
const sky = new THREE.Mesh(skyGeo, skyMat);
sky.name = 'skybox';
scene.add(sky);
}
// ═══ LIGHTING ═══
function createLighting() {
// Ambient
const ambient = new THREE.AmbientLight(0x1a1a3a, 0.4);
scene.add(ambient);
// Main directional (moonlight feel)
const dirLight = new THREE.DirectionalLight(0x4466aa, 0.6);
dirLight.position.set(10, 20, 10);
dirLight.castShadow = renderer.shadowMap.enabled;
const shadowRes = performanceTier === 'high' ? 2048 : performanceTier === 'medium' ? 1024 : 512;
dirLight.shadow.mapSize.set(shadowRes, shadowRes);
dirLight.shadow.camera.near = 0.5;
dirLight.shadow.camera.far = 80;
dirLight.shadow.camera.left = -30;
dirLight.shadow.camera.right = 30;
dirLight.shadow.camera.top = 30;
dirLight.shadow.camera.bottom = -30;
scene.add(dirLight);
// Teal accent from below terminal
const tealLight = new THREE.PointLight(NEXUS.colors.primary, 2, 30, 1.5);
tealLight.position.set(0, 1, -5);
scene.add(tealLight);
// Purple accent
const purpleLight = new THREE.PointLight(NEXUS.colors.secondary, 1.5, 25, 1.5);
purpleLight.position.set(-8, 3, -8);
scene.add(purpleLight);
// Portal glow light
const portalLight = new THREE.PointLight(0xff6600, 2, 20, 1.5);
portalLight.position.set(15, 4, -10);
scene.add(portalLight);
}
// ═══ FLOOR ═══
function createFloor() {
// Main hexagonal-feel platform using a flat circle
const platGeo = new THREE.CylinderGeometry(25, 25, 0.3, 6);
const platMat = new THREE.MeshStandardMaterial({
color: 0x0a0f1a,
roughness: 0.8,
metalness: 0.3,
});
const platform = new THREE.Mesh(platGeo, platMat);
platform.position.y = -0.15;
platform.receiveShadow = true;
scene.add(platform);
// Grid lines on the floor
const gridHelper = new THREE.GridHelper(50, 50, NEXUS.colors.gridLine, NEXUS.colors.gridLine);
gridHelper.material.opacity = 0.15;
gridHelper.material.transparent = true;
gridHelper.position.y = 0.02;
scene.add(gridHelper);
// Glowing edge ring
const ringGeo = new THREE.RingGeometry(24.5, 25.2, 6);
const ringMat = new THREE.MeshBasicMaterial({
color: NEXUS.colors.primary,
transparent: true,
opacity: 0.4,
side: THREE.DoubleSide,
});
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = Math.PI / 2;
ring.position.y = 0.05;
scene.add(ring);
}
// ═══ BATCAVE TERMINAL ═══
function createBatcaveTerminal() {
const terminalGroup = new THREE.Group();
terminalGroup.position.set(0, 0, -8);
const panelData = [
{ title: 'NEXUS COMMAND', color: NEXUS.colors.primary, rot: -0.4, x: -6, y: 3, lines: ['> STATUS: NOMINAL', '> UPTIME: 142.4h', '> HARNESS: STABLE', '> MODE: SOVEREIGN'] },
{ title: 'DEV QUEUE', color: NEXUS.colors.gold, rot: -0.2, x: -3, y: 3, lines: ['> ISSUE #4: CORE', '> ISSUE #5: PORTAL', '> ISSUE #6: TERMINAL', '> ISSUE #7: TIMMY'] },
{ title: 'METRICS', color: NEXUS.colors.secondary, rot: 0, x: 0, y: 3, lines: ['> CPU: 12% [||....]', '> MEM: 4.2GB', '> COMMITS: 842', '> ACTIVE LOOPS: 5'] },
{ title: 'THOUGHTS', color: NEXUS.colors.primary, rot: 0.2, x: 3, y: 3, lines: ['> ANALYZING WORLD...', '> SYNCING MEMORY...', '> WAITING FOR INPUT', '> SOUL ON BITCOIN'] },
{ title: 'AGENT STATUS', color: NEXUS.colors.gold, rot: 0.4, x: 6, y: 3, lines: ['> TIMMY: ● RUNNING', '> KIMI: ○ STANDBY', '> CLAUDE: ● ACTIVE', '> PERPLEXITY: ○'] },
];
panelData.forEach(data => {
createTerminalPanel(terminalGroup, data.x, data.y, data.rot, data.title, data.color, data.lines);
});
scene.add(terminalGroup);
}
function createTerminalPanel(parent, x, y, rot, title, color, lines) {
const w = 2.8, h = 3.5;
const group = new THREE.Group();
group.position.set(x, y, 0);
group.rotation.y = rot;
// Panel background (glassy)
const bgGeo = new THREE.PlaneGeometry(w, h);
const bgMat = new THREE.MeshPhysicalMaterial({
color: NEXUS.colors.panelBg,
transparent: true,
opacity: 0.6,
roughness: 0.1,
metalness: 0.5,
side: THREE.DoubleSide,
});
const bg = new THREE.Mesh(bgGeo, bgMat);
group.add(bg);
// Border
const borderMat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.3, side: THREE.DoubleSide });
const border = new THREE.Mesh(new THREE.PlaneGeometry(w + 0.05, h + 0.05), borderMat);
border.position.z = -0.01;
group.add(border);
// Canvas for text
const textCanvas = document.createElement('canvas');
textCanvas.width = 512;
textCanvas.height = 640;
const ctx = textCanvas.getContext('2d');
// Header
ctx.fillStyle = '#' + new THREE.Color(color).getHexString();
ctx.font = 'bold 32px "Orbitron", sans-serif';
ctx.fillText(title, 20, 45);
ctx.fillRect(20, 55, 472, 2);
// Lines
ctx.font = '20px "JetBrains Mono", monospace';
ctx.fillStyle = '#a0b8d0';
lines.forEach((line, i) => {
// Color active indicators
let fillColor = '#a0b8d0';
if (line.includes('● RUNNING') || line.includes('● ACTIVE')) fillColor = '#4af0c0';
else if (line.includes('○ STANDBY')) fillColor = '#5a6a8a';
else if (line.includes('NOMINAL')) fillColor = '#4af0c0';
ctx.fillStyle = fillColor;
ctx.fillText(line, 20, 100 + i * 40);
});
const textTexture = new THREE.CanvasTexture(textCanvas);
textTexture.minFilter = THREE.LinearFilter;
const textMat = new THREE.MeshBasicMaterial({
map: textTexture,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
});
const textMesh = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.95, h * 0.95), textMat);
textMesh.position.z = 0.01;
group.add(textMesh);
// Scanline effect overlay
const scanGeo = new THREE.PlaneGeometry(w, h);
const scanMat = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
uniforms: {
uTime: { value: 0 },
uColor: { value: new THREE.Color(color) },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform vec3 uColor;
varying vec2 vUv;
void main() {
float scanline = sin(vUv.y * 200.0 + uTime * 2.0) * 0.5 + 0.5;
scanline = pow(scanline, 8.0);
float sweep = smoothstep(0.0, 0.02, abs(fract(vUv.y - uTime * 0.1) - 0.5));
sweep = 1.0 - (1.0 - sweep) * 0.3;
float alpha = scanline * 0.04 + (1.0 - sweep) * 0.08;
gl_FragColor = vec4(uColor, alpha);
}
`,
side: THREE.DoubleSide,
});
const scanMesh = new THREE.Mesh(scanGeo, scanMat);
scanMesh.position.z = 0.02;
group.add(scanMesh);
// Glow behind panel
const glowGeo = new THREE.PlaneGeometry(w + 0.5, h + 0.5);
const glowMat = new THREE.MeshBasicMaterial({
color: color,
transparent: true,
opacity: 0.06,
side: THREE.DoubleSide,
});
const glowMesh = new THREE.Mesh(glowGeo, glowMat);
glowMesh.position.z = -0.05;
group.add(glowMesh);
parent.add(group);
batcaveTerminals.push({ group, scanMat, borderMat });
}
// ═══ PORTAL ═══
function createPortal() {
const portalGroup = new THREE.Group();
portalGroup.position.set(15, 0, -10);
portalGroup.rotation.y = -0.5;
// Portal ring
const torusGeo = new THREE.TorusGeometry(3, 0.15, 16, 64);
const torusMat = new THREE.MeshStandardMaterial({
color: 0xff6600,
emissive: 0xff4400,
emissiveIntensity: 1.5,
roughness: 0.2,
metalness: 0.8,
});
portalMesh = new THREE.Mesh(torusGeo, torusMat);
portalMesh.position.y = 3.5;
portalGroup.add(portalMesh);
// Inner swirl
const swirlGeo = new THREE.CircleGeometry(2.8, 64);
const swirlMat = new THREE.ShaderMaterial({
transparent: true,
side: THREE.DoubleSide,
uniforms: {
uTime: { value: 0 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 c = vUv - 0.5;
float r = length(c);
float a = atan(c.y, c.x);
float swirl = sin(a * 3.0 + r * 10.0 - uTime * 3.0) * 0.5 + 0.5;
float swirl2 = sin(a * 5.0 - r * 8.0 + uTime * 2.0) * 0.5 + 0.5;
float mask = smoothstep(0.5, 0.1, r);
vec3 col = mix(vec3(1.0, 0.3, 0.0), vec3(1.0, 0.6, 0.1), swirl);
col = mix(col, vec3(1.0, 0.8, 0.3), swirl2 * 0.3);
float alpha = mask * (0.5 + 0.3 * swirl);
gl_FragColor = vec4(col, alpha);
}
`,
});
portalGlow = new THREE.Mesh(swirlGeo, swirlMat);
portalGlow.position.y = 3.5;
portalGroup.add(portalGlow);
// Label
const labelCanvas = document.createElement('canvas');
labelCanvas.width = 512;
labelCanvas.height = 64;
const lctx = labelCanvas.getContext('2d');
lctx.font = 'bold 32px "Orbitron", sans-serif';
lctx.fillStyle = '#ff8844';
lctx.textAlign = 'center';
lctx.fillText('◈ MORROWIND', 256, 42);
const labelTex = new THREE.CanvasTexture(labelCanvas);
const labelMat = new THREE.MeshBasicMaterial({ map: labelTex, transparent: true, side: THREE.DoubleSide });
const labelMesh = new THREE.Mesh(new THREE.PlaneGeometry(4, 0.5), labelMat);
labelMesh.position.y = 7;
portalGroup.add(labelMesh);
// Base pillars
for (let side of [-1, 1]) {
const pillarGeo = new THREE.CylinderGeometry(0.2, 0.3, 7, 8);
const pillarMat = new THREE.MeshStandardMaterial({
color: 0x1a1a2e,
roughness: 0.5,
metalness: 0.7,
emissive: 0xff4400,
emissiveIntensity: 0.1,
});
const pillar = new THREE.Mesh(pillarGeo, pillarMat);
pillar.position.set(side * 3, 3.5, 0);
pillar.castShadow = true;
portalGroup.add(pillar);
}
scene.add(portalGroup);
}
// ═══ PARTICLES ═══
function createParticles() {
const count = particleCount(1500);
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
const sizes = new Float32Array(count);
const c1 = new THREE.Color(NEXUS.colors.primary);
const c2 = new THREE.Color(NEXUS.colors.secondary);
const c3 = new THREE.Color(NEXUS.colors.gold);
for (let i = 0; i < count; i++) {
positions[i * 3] = (Math.random() - 0.5) * 60;
positions[i * 3 + 1] = Math.random() * 20;
positions[i * 3 + 2] = (Math.random() - 0.5) * 60;
const t = Math.random();
const col = t < 0.5 ? c1.clone().lerp(c2, t * 2) : c2.clone().lerp(c3, (t - 0.5) * 2);
colors[i * 3] = col.r;
colors[i * 3 + 1] = col.g;
colors[i * 3 + 2] = col.b;
sizes[i] = 0.02 + Math.random() * 0.06;
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
const mat = new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0 } },
vertexShader: `
attribute float size;
attribute vec3 color;
varying vec3 vColor;
uniform float uTime;
void main() {
vColor = color;
vec3 pos = position;
pos.y += sin(uTime * 0.5 + position.x * 0.5) * 0.3;
pos.x += sin(uTime * 0.3 + position.z * 0.4) * 0.2;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = size * 300.0 / -mv.z;
gl_Position = projectionMatrix * mv;
}
`,
fragmentShader: `
varying vec3 vColor;
void main() {
float d = length(gl_PointCoord - 0.5);
if (d > 0.5) discard;
float alpha = smoothstep(0.5, 0.1, d);
gl_FragColor = vec4(vColor, alpha * 0.7);
}
`,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
particles = new THREE.Points(geo, mat);
scene.add(particles);
}
function createDustParticles() {
const count = particleCount(500);
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
positions[i * 3] = (Math.random() - 0.5) * 40;
positions[i * 3 + 1] = Math.random() * 15;
positions[i * 3 + 2] = (Math.random() - 0.5) * 40;
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color: 0x8899bb,
size: 0.03,
transparent: true,
opacity: 0.3,
depthWrite: false,
});
dustParticles = new THREE.Points(geo, mat);
scene.add(dustParticles);
}
// ═══ AMBIENT STRUCTURES ═══
function createAmbientStructures() {
// Crystal formations around the edges
const crystalMat = new THREE.MeshPhysicalMaterial({
color: 0x3355aa,
roughness: 0.1,
metalness: 0.2,
transmission: 0.6,
thickness: 2,
emissive: 0x1122aa,
emissiveIntensity: 0.3,
});
const positions = [
{ x: -18, z: -15, s: 1.5, ry: 0.3 },
{ x: -20, z: -10, s: 1, ry: 0.8 },
{ x: -15, z: -18, s: 2, ry: 1.2 },
{ x: 18, z: -15, s: 1.8, ry: 2.1 },
{ x: 20, z: -12, s: 1.2, ry: 0.5 },
{ x: -12, z: 18, s: 1.3, ry: 1.8 },
{ x: 14, z: 16, s: 1.6, ry: 0.9 },
];
positions.forEach(p => {
const geo = new THREE.ConeGeometry(0.4 * p.s, 2.5 * p.s, 5);
const crystal = new THREE.Mesh(geo, crystalMat.clone());
crystal.position.set(p.x, 1.25 * p.s, p.z);
crystal.rotation.y = p.ry;
crystal.rotation.z = (Math.random() - 0.5) * 0.3;
crystal.castShadow = true;
scene.add(crystal);
});
// Floating rune stones
for (let i = 0; i < 5; i++) {
const angle = (i / 5) * Math.PI * 2;
const r = 10;
const geo = new THREE.OctahedronGeometry(0.4, 0);
const mat = new THREE.MeshStandardMaterial({
color: NEXUS.colors.primary,
emissive: NEXUS.colors.primary,
emissiveIntensity: 0.5,
});
const stone = new THREE.Mesh(geo, mat);
stone.position.set(Math.cos(angle) * r, 5 + Math.sin(i * 1.3) * 1.5, Math.sin(angle) * r);
stone.name = 'runestone_' + i;
scene.add(stone);
}
// Central pedestal / nexus core
const coreGeo = new THREE.IcosahedronGeometry(0.6, 2);
const coreMat = new THREE.MeshPhysicalMaterial({
color: 0x4af0c0,
emissive: 0x4af0c0,
emissiveIntensity: 2,
roughness: 0,
metalness: 1,
transmission: 0.3,
thickness: 1,
});
const core = new THREE.Mesh(coreGeo, coreMat);
core.position.set(0, 2.5, 0);
core.name = 'nexus-core';
scene.add(core);
// Core pedestal
const pedGeo = new THREE.CylinderGeometry(0.8, 1.2, 1.5, 8);
const pedMat = new THREE.MeshStandardMaterial({
color: 0x0a0f1a,
roughness: 0.4,
metalness: 0.8,
emissive: 0x1a2a4a,
emissiveIntensity: 0.3,
});
const pedestal = new THREE.Mesh(pedGeo, pedMat);
pedestal.position.set(0, 0.75, 0);
pedestal.castShadow = true;
scene.add(pedestal);
}
// ═══ NAVIGATION MODE ═══
function cycleNavMode() {
navModeIdx = (navModeIdx + 1) % NAV_MODES.length;
const mode = NAV_MODES[navModeIdx];
// Sync orbit target/radius from current camera when switching into orbit
if (mode === 'orbit') {
const dir = new THREE.Vector3(0, 0, -1).applyEuler(playerRot);
orbitState.target.copy(playerPos).addScaledVector(dir, orbitState.radius);
orbitState.target.y = Math.max(0, orbitState.target.y);
// Recompute angles from current camera → target vector
const toCamera = new THREE.Vector3().subVectors(playerPos, orbitState.target);
orbitState.radius = toCamera.length();
orbitState.theta = Math.atan2(toCamera.x, toCamera.z);
orbitState.phi = Math.acos(Math.max(-1, Math.min(1, toCamera.y / orbitState.radius)));
}
// Sync fly Y from current walk position
if (mode === 'fly') flyY = playerPos.y;
updateNavModeUI(mode);
}
function updateNavModeUI(mode) {
const el = document.getElementById('nav-mode-label');
if (el) el.textContent = mode.toUpperCase();
}
// ═══ CONTROLS ═══
function setupControls() {
document.addEventListener('keydown', (e) => {
keys[e.key.toLowerCase()] = true;
if (e.key === 'Enter') {
e.preventDefault();
const input = document.getElementById('chat-input');
if (document.activeElement === input) {
sendChatMessage();
} else {
input.focus();
}
}
if (e.key === 'Escape') {
document.getElementById('chat-input').blur();
}
// V cycles navigation modes
if (e.key.toLowerCase() === 'v' && document.activeElement !== document.getElementById('chat-input')) {
cycleNavMode();
}
});
document.addEventListener('keyup', (e) => {
keys[e.key.toLowerCase()] = false;
});
// Mouse look / orbit drag
const canvas = document.getElementById('nexus-canvas');
canvas.addEventListener('mousedown', (e) => {
if (e.target === canvas) {
mouseDown = true;
orbitState.lastX = e.clientX;
orbitState.lastY = e.clientY;
}
});
document.addEventListener('mouseup', () => { mouseDown = false; });
document.addEventListener('mousemove', (e) => {
if (!mouseDown) return;
if (document.activeElement === document.getElementById('chat-input')) return;
const mode = NAV_MODES[navModeIdx];
if (mode === 'orbit') {
const dx = e.clientX - orbitState.lastX;
const dy = e.clientY - orbitState.lastY;
orbitState.lastX = e.clientX;
orbitState.lastY = e.clientY;
orbitState.theta -= dx * 0.005;
orbitState.phi = Math.max(0.05, Math.min(Math.PI * 0.85, orbitState.phi + dy * 0.005));
} else {
// Walk and Fly: mouse look
playerRot.y -= e.movementX * 0.003;
playerRot.x -= e.movementY * 0.003;
playerRot.x = Math.max(-Math.PI / 3, Math.min(Math.PI / 3, playerRot.x));
}
});
// Scroll to zoom in orbit mode
canvas.addEventListener('wheel', (e) => {
if (NAV_MODES[navModeIdx] === 'orbit') {
orbitState.radius = Math.max(orbitState.minR, Math.min(orbitState.maxR, orbitState.radius + e.deltaY * 0.02));
}
}, { passive: true });
// Chat toggle
document.getElementById('chat-toggle').addEventListener('click', () => {
chatOpen = !chatOpen;
document.getElementById('chat-panel').classList.toggle('collapsed', !chatOpen);
});
document.getElementById('chat-header')?.addEventListener('click', () => {
chatOpen = !chatOpen;
document.getElementById('chat-panel').classList.toggle('collapsed', !chatOpen);
});
// Chat send
document.getElementById('chat-send').addEventListener('click', sendChatMessage);
}
function sendChatMessage() {
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text) return;
addChatMessage('user', text);
input.value = '';
// Simulate Timmy response
setTimeout(() => {
const responses = [
'Processing your request through the harness...',
'I have noted this in my thought stream.',
'Acknowledged. Routing to appropriate agent loop.',
'The sovereign space recognizes your command.',
'Running analysis. Results will appear on the main terminal.',
'My crystal ball says... yes. Implementing.',
'Understood, Alexander. Adjusting priorities.',
];
const resp = responses[Math.floor(Math.random() * responses.length)];
addChatMessage('timmy', resp);
}, 500 + Math.random() * 1000);
input.blur();
}
function addChatMessage(type, text) {
const container = document.getElementById('chat-messages');
const div = document.createElement('div');
div.className = `chat-msg chat-msg-${type}`;
const prefixes = { user: '[ALEXANDER]', timmy: '[TIMMY]', system: '[NEXUS]', error: '[ERROR]' };
div.innerHTML = `<span class="chat-msg-prefix">${prefixes[type] || '[???]'}</span> ${text}`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
// ═══ GAME LOOP ═══
function gameLoop() {
requestAnimationFrame(gameLoop);
const delta = Math.min(clock.getDelta(), 0.1);
const elapsed = clock.elapsedTime;
// ─── Navigation update ───────────────────────────────────────────
const mode = NAV_MODES[navModeIdx];
const chatActive = document.activeElement === document.getElementById('chat-input');
if (mode === 'walk') {
if (!chatActive) {
const speed = 6 * delta;
const dir = new THREE.Vector3();
if (keys['w']) dir.z -= 1;
if (keys['s']) dir.z += 1;
if (keys['a']) dir.x -= 1;
if (keys['d']) dir.x += 1;
if (dir.length() > 0) {
dir.normalize().multiplyScalar(speed);
dir.applyAxisAngle(new THREE.Vector3(0, 1, 0), playerRot.y);
playerPos.add(dir);
// Clamp to platform
const maxR = 24;
const dist = Math.sqrt(playerPos.x * playerPos.x + playerPos.z * playerPos.z);
if (dist > maxR) { playerPos.x *= maxR / dist; playerPos.z *= maxR / dist; }
}
}
playerPos.y = 2; // fixed eye height in walk mode
camera.position.copy(playerPos);
camera.rotation.set(playerRot.x, playerRot.y, 0, 'YXZ');
} else if (mode === 'orbit') {
// Pan target with WASD
if (!chatActive) {
const speed = 8 * delta;
const pan = new THREE.Vector3();
if (keys['w']) pan.z -= 1;
if (keys['s']) pan.z += 1;
if (keys['a']) pan.x -= 1;
if (keys['d']) pan.x += 1;
if (pan.length() > 0) {
pan.normalize().multiplyScalar(speed);
pan.applyAxisAngle(new THREE.Vector3(0, 1, 0), orbitState.theta);
orbitState.target.add(pan);
orbitState.target.y = Math.max(0, Math.min(20, orbitState.target.y));
}
}
// Position camera on sphere around target
const r = orbitState.radius;
camera.position.set(
orbitState.target.x + r * Math.sin(orbitState.phi) * Math.sin(orbitState.theta),
orbitState.target.y + r * Math.cos(orbitState.phi),
orbitState.target.z + r * Math.sin(orbitState.phi) * Math.cos(orbitState.theta)
);
camera.lookAt(orbitState.target);
// Keep playerPos in sync so switching back to walk is smooth
playerPos.copy(camera.position);
playerRot.y = orbitState.theta;
} else if (mode === 'fly') {
if (!chatActive) {
const speed = 8 * delta;
const forward = new THREE.Vector3(-Math.sin(playerRot.y), 0, -Math.cos(playerRot.y));
const right = new THREE.Vector3( Math.cos(playerRot.y), 0, -Math.sin(playerRot.y));
if (keys['w']) playerPos.addScaledVector(forward, speed);
if (keys['s']) playerPos.addScaledVector(forward, -speed);
if (keys['a']) playerPos.addScaledVector(right, -speed);
if (keys['d']) playerPos.addScaledVector(right, speed);
if (keys['q'] || keys[' ']) flyY += speed;
if (keys['e'] || keys['shift']) flyY -= speed;
flyY = Math.max(0.5, Math.min(30, flyY));
playerPos.y = flyY;
}
camera.position.copy(playerPos);
camera.rotation.set(playerRot.x, playerRot.y, 0, 'YXZ');
}
// Animate skybox
const sky = scene.getObjectByName('skybox');
if (sky) sky.material.uniforms.uTime.value = elapsed;
// Animate terminal scanlines
batcaveTerminals.forEach(t => {
if (t.scanMat?.uniforms) t.scanMat.uniforms.uTime.value = elapsed;
});
// Animate portal
if (portalMesh) {
portalMesh.rotation.z = elapsed * 0.3;
portalMesh.rotation.x = Math.sin(elapsed * 0.5) * 0.1;
}
if (portalGlow?.material?.uniforms) {
portalGlow.material.uniforms.uTime.value = elapsed;
}
// Animate particles
if (particles?.material?.uniforms) {
particles.material.uniforms.uTime.value = elapsed;
}
// Animate dust
if (dustParticles) {
dustParticles.rotation.y = elapsed * 0.01;
}
// Animate runestones
for (let i = 0; i < 5; i++) {
const stone = scene.getObjectByName('runestone_' + i);
if (stone) {
stone.position.y = 5 + Math.sin(elapsed * 0.8 + i * 1.3) * 0.8;
stone.rotation.y = elapsed * 0.5 + i;
stone.rotation.x = elapsed * 0.3 + i * 0.7;
}
}
// Animate nexus core
const core = scene.getObjectByName('nexus-core');
if (core) {
core.position.y = 2.5 + Math.sin(elapsed * 1.2) * 0.3;
core.rotation.y = elapsed * 0.4;
core.rotation.x = elapsed * 0.2;
core.material.emissiveIntensity = 1.5 + Math.sin(elapsed * 2) * 0.5;
}
// Render
composer.render();
// Debug overlay (read AFTER render so counts are populated)
frameCount++;
const now = performance.now();
if (now - lastFPSTime >= 1000) {
fps = frameCount;
frameCount = 0;
lastFPSTime = now;
}
if (debugOverlay) {
const info = renderer.info;
debugOverlay.textContent =
`FPS: ${fps} Draw: ${info.render?.calls} Tri: ${info.render?.triangles} [${performanceTier}]\n` +
`Pos: ${playerPos.x.toFixed(1)}, ${playerPos.y.toFixed(1)}, ${playerPos.z.toFixed(1)} NAV: ${NAV_MODES[navModeIdx]}`;
}
renderer.info.reset();
}
// ═══ RESIZE ═══
function onResize() {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
composer.setSize(w, h);
}
// ═══ START ═══
init();