Files
the-nexus/app.js
Alexander Whitestone 5577b74bbc feat: Batcave workshop terminal — Hermes WS, session persistence, tool output
- Add 3D Workshop Console panel (left of main terminal arc) that renders
  live tool output history and Hermes connection status as a canvas texture
- Connect chat to Hermes backend via WebSocket (/api/world/ws) with
  automatic reconnect (5s backoff); falls back to simulated responses offline
- Session persistence via localStorage (last 60 messages restored on reload,
  including tool output blocks)
- Tool output rendering: addToolOutput() creates <pre class="tool-output">
  blocks with call/result direction indicators, CSS styled, max-height scroll
- Workshop 3D panel refreshes every 5s in game loop to show connection state
- HUD status indicator (● / ○ Hermes) updates on connect/disconnect
- WebSocket status dot in chat header changes color on connect/disconnect

Fixes #6
2026-03-23 18:40:30 -04:00

1315 lines
40 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;
// ═══ HERMES WS STATE ═══
let hermesWs = null;
let wsReconnectTimer = null;
let wsConnected = false;
let recentToolOutputs = [];
let workshopPanelCtx = null;
let workshopPanelTexture = null;
let workshopPanelCanvas = null;
let workshopScanMat = null;
let workshopPanelRefreshTimer = 0;
// ═══ 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.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
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(65);
createWorkshopTerminal();
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);
// Session + Hermes
loadSession();
connectHermes();
// Start loop
requestAnimationFrame(gameLoop);
}
function updateLoad(pct) {
loadProgress = pct;
const fill = document.getElementById('load-progress');
if (fill) fill.style.width = pct + '%';
}
// ═══ 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 = true;
dirLight.shadow.mapSize.set(1024, 1024);
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.15,
side: THREE.DoubleSide,
});
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.05;
scene.add(ring);
// Inner ring
const innerRingGeo = new THREE.RingGeometry(14.5, 15, 32);
const innerRingMat = new THREE.MeshBasicMaterial({
color: NEXUS.colors.secondary,
transparent: true,
opacity: 0.08,
side: THREE.DoubleSide,
});
const innerRing = new THREE.Mesh(innerRingGeo, innerRingMat);
innerRing.rotation.x = -Math.PI / 2;
innerRing.position.y = 0.03;
scene.add(innerRing);
}
// ═══ BATCAVE TERMINAL ═══
function createBatcaveTerminal() {
const termGroup = new THREE.Group();
termGroup.position.set(0, 0, -8);
// Main large screen
createHoloPanel(termGroup, {
x: 0, y: 4, z: 0, w: 8, h: 5,
title: '◈ NEXUS COMMAND',
lines: [
'┌─────────────────────────────────┐',
'│ SYSTEM STATUS NOMINAL │',
'│ HERMES HARNESS ACTIVE │',
'│ AGENT LOOPS 3/3 RUN │',
'│ MEMORY BANKS 2.4 GB │',
'│ THOUGHT CYCLES 14,892 │',
'├─────────────────────────────────┤',
'│ ACTIVE PROCESSES │',
'│ ▸ triage-daemon ● RUNNING │',
'│ ▸ code-review-loop ● RUNNING │',
'│ ▸ world-builder ○ STANDBY │',
'│ ▸ matrix-renderer ● RUNNING │',
'└─────────────────────────────────┘',
],
color: NEXUS.colors.primary,
});
// Left panel — Dev Items
createHoloPanel(termGroup, {
x: -6, y: 3.5, z: 1, w: 4, h: 4, rotY: 0.3,
title: '⚡ DEV QUEUE',
lines: [
'#1090 Nexus v1 Build',
'#1079 Code Hygiene Epic',
'#1080 Showcase Epic',
'#864 PR Pending Merge',
'#1076 Deep Triage Gov.',
'',
'Open Issues: 293',
'Closed Today: 19',
],
color: NEXUS.colors.secondary,
});
// Right panel — Metrics
createHoloPanel(termGroup, {
x: 6, y: 3.5, z: 1, w: 4, h: 4, rotY: -0.3,
title: '📊 METRICS',
lines: [
'Uptime: 23d 14h 22m',
'Commits: 1,847',
'Agents: 5 active',
'Worlds: 1 (Nexus)',
'Portals: 1 staging',
'',
'CPU: ████████░░ 78%',
'MEM: ██████░░░░ 62%',
],
color: 0x44aaff,
});
// Far left — Thought Stream
createHoloPanel(termGroup, {
x: -10, y: 2.5, z: 3, w: 3.5, h: 3, rotY: 0.5,
title: '💭 THOUGHTS',
lines: [
'Considering portal arch.',
'Morrowind integration is',
'next priority after the',
'Nexus shell is stable.',
'',
'The harness is the core',
'product. Focus there.',
],
color: NEXUS.colors.gold,
});
// Far right — Agents
createHoloPanel(termGroup, {
x: 10, y: 2.5, z: 3, w: 3.5, h: 3, rotY: -0.5,
title: '🤖 AGENTS',
lines: [
'Claude Code ● ACTIVE',
'Kimi ● ACTIVE',
'Gemini ○ STANDBY',
'Hermes ● ACTIVE',
'Perplexity ● ACTIVE',
],
color: 0xff8844,
});
scene.add(termGroup);
}
// ═══ WORKSHOP TERMINAL ═══
function createWorkshopTerminal() {
const group = new THREE.Group();
group.position.set(-14, 0, 0);
group.rotation.y = Math.PI / 4;
const w = 8, h = 5;
const panelY = h / 2 + 0.5;
// Background
const panelGeo = new THREE.PlaneGeometry(w, h);
const panelMat = new THREE.MeshBasicMaterial({
color: 0x000510, transparent: true, opacity: 0.85, side: THREE.DoubleSide,
});
const panel = new THREE.Mesh(panelGeo, panelMat);
panel.position.y = panelY;
group.add(panel);
// Border
const borderMat = new THREE.LineBasicMaterial({ color: 0x44ff88, transparent: true, opacity: 0.7 });
const border = new THREE.LineSegments(new THREE.EdgesGeometry(panelGeo), borderMat);
border.position.y = panelY;
group.add(border);
// Canvas texture
workshopPanelCanvas = document.createElement('canvas');
workshopPanelCanvas.width = 1024;
workshopPanelCanvas.height = 640;
workshopPanelCtx = workshopPanelCanvas.getContext('2d');
workshopPanelTexture = new THREE.CanvasTexture(workshopPanelCanvas);
workshopPanelTexture.minFilter = THREE.LinearFilter;
const textMat = new THREE.MeshBasicMaterial({
map: workshopPanelTexture, transparent: true, side: THREE.DoubleSide, depthWrite: false,
});
const textMesh = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.95, h * 0.95), textMat);
textMesh.position.set(0, panelY, 0.01);
group.add(textMesh);
// Scanline overlay
workshopScanMat = new THREE.ShaderMaterial({
transparent: true, depthWrite: false,
uniforms: { uTime: { value: 0 }, uColor: { value: new THREE.Color(0x44ff88) } },
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 s = pow(sin(vUv.y * 200.0 + uTime * 2.0) * 0.5 + 0.5, 8.0);
float sweep = 1.0 - (1.0 - smoothstep(0.0, 0.02, abs(fract(vUv.y - uTime * 0.1) - 0.5))) * 0.3;
gl_FragColor = vec4(uColor, s * 0.04 + (1.0 - sweep) * 0.07);
}`,
side: THREE.DoubleSide,
});
const scanMesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), workshopScanMat);
scanMesh.position.set(0, panelY, 0.02);
group.add(scanMesh);
// Glow behind
const glowMat = new THREE.MeshBasicMaterial({ color: 0x44ff88, transparent: true, opacity: 0.05, side: THREE.DoubleSide });
const glowMesh = new THREE.Mesh(new THREE.PlaneGeometry(w + 0.5, h + 0.5), glowMat);
glowMesh.position.set(0, panelY, -0.05);
group.add(glowMesh);
// Point light
const wLight = new THREE.PointLight(0x44ff88, 1.5, 15, 1.5);
wLight.position.set(0, panelY, 0.5);
group.add(wLight);
// Label
const lc = document.createElement('canvas');
lc.width = 512; lc.height = 64;
const lx = lc.getContext('2d');
lx.font = 'bold 28px "Orbitron", sans-serif';
lx.fillStyle = '#44ff88';
lx.textAlign = 'center';
lx.fillText('⚙ WORKSHOP', 256, 42);
const lt = new THREE.CanvasTexture(lc);
const labelMat = new THREE.MeshBasicMaterial({ map: lt, transparent: true, side: THREE.DoubleSide });
const labelMesh = new THREE.Mesh(new THREE.PlaneGeometry(3.5, 0.5), labelMat);
labelMesh.position.set(0, panelY + h / 2 + 0.5, 0);
group.add(labelMesh);
// Support column
const colGeo = new THREE.CylinderGeometry(0.15, 0.2, panelY, 8);
const colMat = new THREE.MeshStandardMaterial({ color: 0x0a0f1a, roughness: 0.4, metalness: 0.8, emissive: 0x44ff88, emissiveIntensity: 0.05 });
const col = new THREE.Mesh(colGeo, colMat);
col.position.y = panelY / 2;
col.castShadow = true;
group.add(col);
scene.add(group);
batcaveTerminals.push({ group, scanMat: workshopScanMat, borderMat });
refreshWorkshopPanel();
}
function refreshWorkshopPanel() {
if (!workshopPanelCtx) return;
const ctx = workshopPanelCtx;
const W = 1024, H = 640;
ctx.clearRect(0, 0, W, H);
// Title bar
ctx.font = 'bold 26px "JetBrains Mono", monospace';
ctx.fillStyle = '#44ff88';
ctx.fillText('⚙ WORKSHOP CONSOLE', 20, 40);
ctx.strokeStyle = '#44ff88';
ctx.globalAlpha = 0.3;
ctx.beginPath(); ctx.moveTo(20, 52); ctx.lineTo(W - 20, 52); ctx.stroke();
ctx.globalAlpha = 1;
if (recentToolOutputs.length === 0) {
ctx.font = '18px "JetBrains Mono", monospace';
ctx.fillStyle = '#5a6a8a';
ctx.fillText('Awaiting tool activity...', 20, 90);
ctx.fillText('Connect to Hermes to begin.', 20, 115);
} else {
let y = 76;
for (const item of recentToolOutputs.slice(0, 8)) {
if (y > H - 50) break;
const arrow = item.kind === 'call' ? '▶' : '◀';
const color = item.kind === 'call' ? '#ffaa44' : '#44ff88';
const ts = new Date(item.time).toLocaleTimeString();
ctx.font = '15px "JetBrains Mono", monospace';
ctx.fillStyle = color;
ctx.fillText(`${arrow} [${(item.tool || 'TOOL').toUpperCase()}] ${ts}`, 20, y);
y += 20;
ctx.font = '13px "JetBrains Mono", monospace';
ctx.fillStyle = '#8899bb';
const lines = String(item.content || '').replace(/\n+/g, ' ↩ ').slice(0, 110);
ctx.fillText(lines, 28, y);
y += 22;
}
}
// Status bar
ctx.fillStyle = wsConnected ? '#4af0c0' : '#ff4466';
ctx.font = 'bold 14px "JetBrains Mono", monospace';
ctx.fillText(`● HERMES ${wsConnected ? 'CONNECTED' : 'OFFLINE'} — session: ${getSessionId().slice(0, 16)}`, 20, H - 16);
if (workshopPanelTexture) workshopPanelTexture.needsUpdate = true;
}
function createHoloPanel(parent, opts) {
const { x, y, z, w, h, title, lines, color, rotY } = opts;
const group = new THREE.Group();
group.position.set(x, y, z);
if (rotY) group.rotation.y = rotY;
// Background panel
const panelGeo = new THREE.PlaneGeometry(w, h);
const panelMat = new THREE.MeshBasicMaterial({
color: 0x000815,
transparent: true,
opacity: 0.7,
side: THREE.DoubleSide,
});
const panel = new THREE.Mesh(panelGeo, panelMat);
group.add(panel);
// Border frame
const borderGeo = new THREE.EdgesGeometry(panelGeo);
const borderMat = new THREE.LineBasicMaterial({
color: color,
transparent: true,
opacity: 0.6,
});
const border = new THREE.LineSegments(borderGeo, borderMat);
group.add(border);
// Text content via CanvasTexture
const textCanvas = document.createElement('canvas');
const ctx = textCanvas.getContext('2d');
const res = 512;
textCanvas.width = res * (w / h);
textCanvas.height = res;
ctx.fillStyle = 'transparent';
ctx.clearRect(0, 0, textCanvas.width, textCanvas.height);
// Title
const cHex = '#' + new THREE.Color(color).getHexString();
ctx.font = 'bold 28px "JetBrains Mono", monospace';
ctx.fillStyle = cHex;
ctx.fillText(title, 20, 40);
// Separator
ctx.strokeStyle = cHex;
ctx.globalAlpha = 0.3;
ctx.beginPath();
ctx.moveTo(20, 52);
ctx.lineTo(textCanvas.width - 20, 52);
ctx.stroke();
ctx.globalAlpha = 1;
// 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, 80 + i * 30);
});
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 = 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 = 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,
roughness: 0.3,
metalness: 0.7,
});
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);
}
// ═══ 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();
}
});
document.addEventListener('keyup', (e) => {
keys[e.key.toLowerCase()] = false;
});
// Mouse look
const canvas = document.getElementById('nexus-canvas');
canvas.addEventListener('mousedown', (e) => {
if (e.target === canvas) mouseDown = true;
});
document.addEventListener('mouseup', () => { mouseDown = false; });
document.addEventListener('mousemove', (e) => {
if (!mouseDown) return;
if (document.activeElement === document.getElementById('chat-input')) return;
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));
});
// 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 = '';
saveSession();
if (hermesWs && hermesWs.readyState === WebSocket.OPEN) {
// Send to real Hermes backend
hermesWs.send(JSON.stringify({
type: 'message',
role: 'user',
content: text,
session_id: getSessionId(),
}));
} else {
// Offline fallback
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.',
'[OFFLINE] Hermes is unreachable. Message queued for next connection.',
];
const resp = responses[Math.floor(Math.random() * responses.length)];
addChatMessage('timmy', resp);
saveSession();
}, 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;
}
// ═══ SESSION PERSISTENCE ═══
const SESSION_KEY = 'nexus_v1_session';
const SESSION_ID_KEY = 'nexus_v1_session_id';
function getSessionId() {
let id = localStorage.getItem(SESSION_ID_KEY);
if (!id) {
id = 'sess_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2);
localStorage.setItem(SESSION_ID_KEY, id);
}
return id;
}
function saveSession() {
const msgs = [...document.querySelectorAll('#chat-messages .chat-msg')].map(el => {
const prefix = el.querySelector('.chat-msg-prefix')?.textContent || '';
const pre = el.querySelector('.tool-output');
return {
classes: el.className,
prefix,
content: pre ? pre.textContent : el.textContent.replace(prefix, '').trim(),
isTool: !!pre,
};
}).slice(-60);
try { localStorage.setItem(SESSION_KEY, JSON.stringify(msgs)); } catch (_) {}
}
function loadSession() {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return;
const msgs = JSON.parse(raw);
if (!msgs.length) return;
const container = document.getElementById('chat-messages');
container.innerHTML = '';
msgs.forEach(m => {
const div = document.createElement('div');
div.className = m.classes;
if (m.isTool) {
div.innerHTML = `<span class="chat-msg-prefix">${m.prefix}</span><pre class="tool-output">${escapeHtml(m.content)}</pre>`;
} else {
div.innerHTML = `<span class="chat-msg-prefix">${m.prefix}</span> ${m.content}`;
}
container.appendChild(div);
});
container.scrollTop = container.scrollHeight;
addChatMessage('system', 'Session restored from localStorage.');
} catch (_) {}
}
// ═══ HERMES WEBSOCKET ═══
function connectHermes() {
if (hermesWs && (hermesWs.readyState === WebSocket.CONNECTING || hermesWs.readyState === WebSocket.OPEN)) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/world/ws`;
try {
hermesWs = new WebSocket(url);
} catch (_) {
scheduleWsReconnect();
return;
}
hermesWs.addEventListener('open', () => {
wsConnected = true;
updateWsStatusDot(true);
addChatMessage('system', 'Hermes backend connected.');
hermesWs.send(JSON.stringify({ type: 'session_init', session_id: getSessionId() }));
refreshWorkshopPanel();
saveSession();
});
hermesWs.addEventListener('message', (evt) => {
try { handleHermesMessage(JSON.parse(evt.data)); }
catch (_) { addChatMessage('timmy', evt.data); saveSession(); }
});
hermesWs.addEventListener('close', () => {
wsConnected = false;
updateWsStatusDot(false);
refreshWorkshopPanel();
scheduleWsReconnect();
});
hermesWs.addEventListener('error', () => hermesWs.close());
}
function scheduleWsReconnect() {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = setTimeout(connectHermes, 5000);
}
function updateWsStatusDot(connected) {
const dot = document.querySelector('.chat-status-dot');
if (dot) {
dot.style.background = connected ? 'var(--color-primary)' : 'var(--color-danger)';
dot.style.boxShadow = `0 0 6px ${connected ? 'var(--color-primary)' : 'var(--color-danger)'}`;
}
const hudStatus = document.getElementById('ws-hud-status');
if (hudStatus) {
hudStatus.textContent = connected ? '● Hermes' : '○ Hermes';
hudStatus.style.color = connected ? 'var(--color-primary)' : 'var(--color-danger)';
}
}
function handleHermesMessage(data) {
const { type, role, content, tool, output } = data;
switch (type) {
case 'message':
if (role === 'assistant' || !role) addChatMessage('timmy', content || '');
break;
case 'tool_call':
addChatMessage('system', `Running tool: ${tool || 'unknown'}...`);
addToolOutput(tool, data.args ? JSON.stringify(data.args) : '', 'call');
break;
case 'tool_result':
addToolOutput(tool, content || output || '', 'result');
break;
case 'error':
addChatMessage('error', content || 'An error occurred.');
break;
default:
if (content) addChatMessage('timmy', content);
}
saveSession();
}
// ═══ TOOL OUTPUT ═══
function escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function addToolOutput(tool, content, kind) {
const container = document.getElementById('chat-messages');
const div = document.createElement('div');
div.className = 'chat-msg chat-msg-tool';
const label = kind === 'call'
? `[${(tool || 'TOOL').toUpperCase()} ▶]`
: `[${(tool || 'TOOL').toUpperCase()} ◀]`;
div.innerHTML = `<span class="chat-msg-prefix tool-prefix-${kind}">${label}</span><pre class="tool-output">${escapeHtml(String(content || '').slice(0, 2000))}</pre>`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
recentToolOutputs.unshift({ tool, content: String(content || '').slice(0, 200), kind, time: Date.now() });
recentToolOutputs = recentToolOutputs.slice(0, 10);
refreshWorkshopPanel();
saveSession();
}
// ═══ GAME LOOP ═══
function gameLoop() {
requestAnimationFrame(gameLoop);
const delta = Math.min(clock.getDelta(), 0.1);
const elapsed = clock.elapsedTime;
// Movement
if (document.activeElement !== document.getElementById('chat-input')) {
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;
}
}
}
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;
});
// Refresh workshop panel connection status every 5s
workshopPanelRefreshTimer += delta;
if (workshopPanelRefreshTimer > 5) {
workshopPanelRefreshTimer = 0;
refreshWorkshopPanel();
}
// 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}\n` +
`Pos: ${playerPos.x.toFixed(1)}, ${playerPos.y.toFixed(1)}, ${playerPos.z.toFixed(1)}`;
}
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();