diff --git a/public/nexus/app.js b/public/nexus/app.js new file mode 100644 index 0000000..a9926d5 --- /dev/null +++ b/public/nexus/app.js @@ -0,0 +1,2280 @@ +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.1 — Portal System Update +// ═══════════════════════════════════════════ + +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 portals = []; // Registry of active portals +let visionPoints = []; // Registry of vision points +let agents = []; // Registry of agent presences +let activePortal = null; // Portal currently in proximity +let activeVisionPoint = null; // Vision point currently in proximity +let portalOverlayActive = false; +let visionOverlayActive = false; +let atlasOverlayActive = false; +let thoughtStreamMesh; +let harnessPulseMesh; +let powerMeterBars = []; +let particles, dustParticles, ambientLight; +let debugOverlay; +let frameCount = 0, lastFPSTime = 0, fps = 0; +let chatOpen = true; +let loadProgress = 0; +let performanceTier = 'high'; + +// ═══ 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; +let lastFocusedPortal = null; + +// ═══ NAVIGATION SYSTEM ═══ +const NAV_MODES = ['walk', 'orbit', 'fly']; +let navModeIdx = 0; + +const orbitState = { + target: new THREE.Vector3(0, 2, 0), + radius: 14, + theta: Math.PI, + phi: Math.PI / 6, + minR: 3, + maxR: 40, + lastX: 0, + lastY: 0, +}; + +let flyY = 2; + +// ═══ SOVEREIGN SYMBOLIC ENGINE (GOFAI) ═══ +class SymbolicEngine { + constructor() { + this.facts = new Map(); + this.rules = []; + this.reasoningLog = []; + } + + addFact(key, value) { + this.facts.set(key, value); + } + + addRule(condition, action, description) { + this.rules.push({ condition, action, description }); + } + + reason() { + this.rules.forEach(rule => { + if (rule.condition(this.facts)) { + const result = rule.action(this.facts); + if (result) { + this.logReasoning(rule.description, result); + } + } + }); + } + + logReasoning(ruleDesc, outcome) { + const entry = { + timestamp: Date.now(), + rule: ruleDesc, + outcome: outcome + }; + this.reasoningLog.unshift(entry); + if (this.reasoningLog.length > 5) this.reasoningLog.pop(); + + // Update HUD if available + const container = document.getElementById('symbolic-log-content'); + if (container) { + const logDiv = document.createElement('div'); + logDiv.className = 'symbolic-log-entry'; + logDiv.innerHTML = `[RULE] ${ruleDesc}→ ${outcome}`; + container.prepend(logDiv); + if (container.children.length > 5) container.lastElementChild.remove(); + } + } +} + +class AgentFSM { + constructor(agentId, initialState) { + this.agentId = agentId; + this.state = initialState; + this.transitions = {}; + } + + addTransition(fromState, toState, condition) { + if (!this.transitions[fromState]) this.transitions[fromState] = []; + this.transitions[fromState].push({ toState, condition }); + } + + update(facts) { + const possibleTransitions = this.transitions[this.state] || []; + for (const transition of possibleTransitions) { + if (transition.condition(facts)) { + console.log(`[FSM] Agent ${this.agentId} transitioning: ${this.state} -> ${transition.toState}`); + this.state = transition.toState; + return true; + } + } + return false; + } +} + +let symbolicEngine; +let agentFSMs = {}; + +// ═══ INIT ═══ +async function init() { + clock = new THREE.Clock(); + playerPos = new THREE.Vector3(0, 2, 12); + playerRot = new THREE.Euler(0, 0, 0, 'YXZ'); + + // Initialize Symbolic Engine + symbolicEngine = new SymbolicEngine(); + setupSymbolicRules(); + + 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; + + performanceTier = detectPerformanceTier(); + updateLoad(10); + + scene = new THREE.Scene(); + scene.fog = new THREE.FogExp2(0x050510, 0.012); + + camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1000); + camera.position.copy(playerPos); + + updateLoad(20); + + createSkybox(); + updateLoad(30); + createLighting(); + updateLoad(40); + createFloor(); + updateLoad(50); + createBatcaveTerminal(); + updateLoad(60); + + // Load Portals from Registry + try { + const response = await fetch('./portals.json'); + const portalData = await response.json(); + createPortals(portalData); + } catch (e) { + console.error('Failed to load portals.json:', e); + addChatMessage('error', 'Portal registry offline. Check logs.'); + } + + // Load Vision Points + try { + const response = await fetch('./vision.json'); + const visionData = await response.json(); + createVisionPoints(visionData); + } catch (e) { + console.error('Failed to load vision.json:', e); + } + + updateLoad(80); + createParticles(); + createDustParticles(); + updateLoad(85); + createAmbientStructures(); + createAgentPresences(); + createThoughtStream(); + createHarnessPulse(); + createSessionPowerMeter(); + createWorkshopTerminal(); + createAshStorm(); + updateLoad(90); + + loadSession(); + connectHermes(); + fetchGiteaData(); + setInterval(fetchGiteaData, 30000); // Refresh every 30s + + 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); + + setupControls(); + window.addEventListener('resize', onResize); + debugOverlay = document.getElementById('debug-overlay'); + + updateLoad(100); + + 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); + + requestAnimationFrame(gameLoop); +} + +function updateLoad(pct) { + loadProgress = pct; + const fill = document.getElementById('load-progress'); + if (fill) fill.style.width = pct + '%'; +} + +function setupSymbolicRules() { + // Facts: Energy, Stability, Portal Status + symbolicEngine.addFact('energy', 100); + symbolicEngine.addFact('stability', 1.0); + symbolicEngine.addFact('activePortals', 0); + + // Rule: Low Energy Recovery + symbolicEngine.addRule( + (facts) => facts.get('energy') < 20, + (facts) => { + facts.set('mode', 'RECOVERY'); + return 'Diverting power to core systems'; + }, + 'Low Energy Protocol' + ); + + // Rule: Stability Alert + symbolicEngine.addRule( + (facts) => facts.get('stability') < 0.5, + (facts) => { + facts.set('alert', 'CRITICAL'); + return 'Initiating matrix stabilization'; + }, + 'Stability Safeguard' + ); + + // FSMs for Agents + agentFSMs['timmy'] = new AgentFSM('timmy', 'IDLE'); + agentFSMs['timmy'].addTransition('IDLE', 'ANALYZING', (facts) => facts.get('activePortals') > 0); + agentFSMs['timmy'].addTransition('ANALYZING', 'IDLE', (facts) => facts.get('activePortals') === 0); + agentFSMs['timmy'].addTransition('IDLE', 'ALERT', (facts) => facts.get('stability') < 0.7); + agentFSMs['timmy'].addTransition('ALERT', 'IDLE', (facts) => facts.get('stability') >= 0.7); +} + +function updateSymbolicAI(delta, elapsed) { + // Sync facts from world state + const terminal = batcaveTerminals.find(t => t.title === 'NEXUS COMMAND'); + if (terminal && terminal.lastState) { + symbolicEngine.addFact('energy', terminal.lastState.tower.energy); + symbolicEngine.addFact('stability', terminal.lastState.matrix.stability); + symbolicEngine.addFact('activePortals', portals.filter(p => p.config.status === 'online').length); + } + + // Run reasoning engine + if (Math.floor(elapsed * 2) > Math.floor((elapsed - delta) * 2)) { // Every 0.5s + symbolicEngine.reason(); + Object.values(agentFSMs).forEach(fsm => fsm.update(symbolicEngine.facts)); + } +} + +// ═══ 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; + return 'low'; + } else if (cores < 8) { + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); + renderer.shadowMap.type = THREE.BasicShadowMap; + return 'medium'; + } else { + 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() { + 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; + + 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); + 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); + + 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); + + float starField = hash(dir * 800.0); + float stars = step(uStarDensity, starField) * (0.5 + 0.5 * hash(dir * 1600.0)); + float twinkle = 0.7 + 0.3 * sin(uTime * 2.0 + hash(dir * 400.0) * 6.28); + col += vec3(stars * twinkle); + + 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() { + ambientLight = new THREE.AmbientLight(0x1a1a3a, 0.4); + scene.add(ambientLight); + + 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); + scene.add(dirLight); + + const tealLight = new THREE.PointLight(NEXUS.colors.primary, 2, 30, 1.5); + tealLight.position.set(0, 1, -5); + scene.add(tealLight); + + const purpleLight = new THREE.PointLight(NEXUS.colors.secondary, 1.5, 25, 1.5); + purpleLight.position.set(-8, 3, -8); + scene.add(purpleLight); +} + +// ═══ FLOOR ═══ +function createFloor() { + 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); + + 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); + + 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: 'SOVEREIGNTY', color: NEXUS.colors.gold, rot: 0.2, x: 3, y: 3, lines: ['REPLIT: GRADE: A', 'PERPLEXITY: GRADE: A-', 'HERMES: GRADE: B+', 'KIMI: GRADE: B', 'CLAUDE: GRADE: B+'] }, + { title: 'AGENT STATUS', color: NEXUS.colors.primary, rot: 0.4, x: 6, y: 3, lines: ['> TIMMY: ● RUNNING', '> KIMI: ○ STANDBY', '> CLAUDE: ● ACTIVE', '> PERPLEXITY: ○'] }, + ]; + + panelData.forEach(data => { + const terminal = createTerminalPanel(terminalGroup, data.x, data.y, data.rot, data.title, data.color, data.lines); + batcaveTerminals.push(terminal); + }); + + scene.add(terminalGroup); +} + +// ═══ WORKSHOP TERMINAL ═══ +function createWorkshopTerminal() { + const w = 6, h = 4; + const group = new THREE.Group(); + group.position.set(-14, 3, 0); + group.rotation.y = Math.PI / 4; + + workshopPanelCanvas = document.createElement('canvas'); + workshopPanelCanvas.width = 1024; + workshopPanelCanvas.height = 512; + workshopPanelCtx = workshopPanelCanvas.getContext('2d'); + + workshopPanelTexture = new THREE.CanvasTexture(workshopPanelCanvas); + workshopPanelTexture.minFilter = THREE.LinearFilter; + + const panelGeo = new THREE.PlaneGeometry(w, h); + const panelMat = new THREE.MeshBasicMaterial({ + map: workshopPanelTexture, + transparent: true, + opacity: 0.9, + side: THREE.DoubleSide + }); + const panel = new THREE.Mesh(panelGeo, panelMat); + group.add(panel); + + const scanGeo = new THREE.PlaneGeometry(w + 0.1, h + 0.1); + workshopScanMat = new THREE.ShaderMaterial({ + transparent: true, + 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() { + float scan = sin(vUv.y * 200.0 + uTime * 10.0) * 0.05; + float noise = fract(sin(dot(vUv, vec2(12.9898, 78.233))) * 43758.5453) * 0.05; + gl_FragColor = vec4(0.0, 0.1, 0.2, scan + noise); + } + ` + }); + const scan = new THREE.Mesh(scanGeo, workshopScanMat); + scan.position.z = 0.01; + group.add(scan); + + scene.add(group); + refreshWorkshopPanel(); +} + +function refreshWorkshopPanel() { + if (!workshopPanelCtx) return; + const ctx = workshopPanelCtx; + const w = 1024, h = 512; + + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = 'rgba(10, 15, 40, 0.8)'; + ctx.fillRect(0, 0, w, h); + + ctx.fillStyle = '#4af0c0'; + ctx.font = 'bold 40px "Orbitron", sans-serif'; + ctx.fillText('WORKSHOP TERMINAL v1.0', 40, 60); + ctx.fillRect(40, 80, 944, 4); + + ctx.font = '24px "JetBrains Mono", monospace'; + ctx.fillStyle = wsConnected ? '#4af0c0' : '#ff4466'; + ctx.fillText(`HERMES STATUS: ${wsConnected ? 'ONLINE' : 'OFFLINE'}`, 40, 120); + + ctx.fillStyle = '#7b5cff'; + const contextName = activePortal ? activePortal.name.toUpperCase() : 'NEXUS CORE'; + ctx.fillText(`CONTEXT: ${contextName}`, 40, 160); + + ctx.fillStyle = '#a0b8d0'; + ctx.font = 'bold 20px "Orbitron", sans-serif'; + ctx.fillText('TOOL OUTPUT STREAM', 40, 220); + ctx.fillRect(40, 230, 400, 2); + + ctx.font = '16px "JetBrains Mono", monospace'; + recentToolOutputs.slice(-10).forEach((out, i) => { + ctx.fillStyle = out.type === 'call' ? '#ffd700' : '#4af0c0'; + const text = `[${out.agent}] ${out.content.substring(0, 80)}${out.content.length > 80 ? '...' : ''}`; + ctx.fillText(text, 40, 260 + i * 24); + }); + + workshopPanelTexture.needsUpdate = true; +} + +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; + + 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); + + 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); + + const textCanvas = document.createElement('canvas'); + textCanvas.width = 512; + textCanvas.height = 640; + const ctx = textCanvas.getContext('2d'); + + const textTexture = new THREE.CanvasTexture(textCanvas); + textTexture.minFilter = THREE.LinearFilter; + + function updatePanelText(newLines) { + ctx.clearRect(0, 0, 512, 640); + 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); + ctx.font = '20px "JetBrains Mono", monospace'; + ctx.fillStyle = '#a0b8d0'; + const displayLines = newLines || lines; + displayLines.forEach((line, i) => { + let fillColor = '#a0b8d0'; + if (line.includes('● RUNNING') || line.includes('● ACTIVE') || line.includes('ONLINE')) fillColor = '#4af0c0'; + else if (line.includes('○ STANDBY') || line.includes('OFFLINE')) fillColor = '#5a6a8a'; + else if (line.includes('NOMINAL')) fillColor = '#4af0c0'; + ctx.fillStyle = fillColor; + ctx.fillText(line, 20, 100 + i * 40); + }); + textTexture.needsUpdate = true; + } + + updatePanelText(); + + 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); + + 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); + + parent.add(group); + return { group, scanMat, borderMat, updatePanelText, title }; +} + +// ═══ GITEA DATA INTEGRATION ═══ +async function fetchGiteaData() { + try { + const [issuesRes, stateRes] = await Promise.all([ + fetch('/api/gitea/repos/admin/timmy-tower/issues?state=all'), + fetch('/api/gitea/repos/admin/timmy-tower/contents/world_state.json') + ]); + + if (issuesRes.ok) { + const issues = await issuesRes.json(); + updateDevQueue(issues); + updateAgentStatus(issues); + } + + if (stateRes.ok) { + const content = await stateRes.json(); + const worldState = JSON.parse(atob(content.content)); + updateNexusCommand(worldState); + } + } catch (e) { + console.error('Failed to fetch Gitea data:', e); + } +} + +function updateAgentStatus(issues) { + const terminal = batcaveTerminals.find(t => t.title === 'AGENT STATUS'); + if (!terminal) return; + + // Check for Morrowind issues + const morrowindIssues = issues.filter(i => i.title.toLowerCase().includes('morrowind') && i.state === 'open'); + const perplexityStatus = morrowindIssues.length > 0 ? '● MORROWIND' : '○ STANDBY'; + + const lines = [ + '> TIMMY: ● RUNNING', + '> KIMI: ○ STANDBY', + '> CLAUDE: ● ACTIVE', + `> PERPLEXITY: ${perplexityStatus}` + ]; + terminal.updatePanelText(lines); +} + +function updateDevQueue(issues) { + const terminal = batcaveTerminals.find(t => t.title === 'DEV QUEUE'); + if (!terminal) return; + + const lines = issues.slice(0, 4).map(issue => `> #${issue.number}: ${issue.title.substring(0, 15)}...`); + while (lines.length < 4) lines.push('> [EMPTY SLOT]'); + terminal.updatePanelText(lines); +} + +function updateNexusCommand(state) { + const terminal = batcaveTerminals.find(t => t.title === 'NEXUS COMMAND'); + if (!terminal) return; + + terminal.lastState = state; // Store for symbolic engine + + const lines = [ + `> STATUS: ${state.tower.status.toUpperCase()}`, + `> ENERGY: ${state.tower.energy}%`, + `> STABILITY: ${(state.matrix.stability * 100).toFixed(1)}%`, + `> AGENTS: ${state.matrix.active_agents.length}` + ]; + terminal.updatePanelText(lines); +} + +// ═══ AGENT PRESENCE SYSTEM ═══ +function createAgentPresences() { + const agentData = [ + { id: 'timmy', name: 'TIMMY', color: NEXUS.colors.primary, pos: { x: -4, z: -4 }, station: { x: -4, z: -4 } }, + { id: 'kimi', name: 'KIMI', color: NEXUS.colors.secondary, pos: { x: 4, z: -4 }, station: { x: 4, z: -4 } }, + { id: 'claude', name: 'CLAUDE', color: NEXUS.colors.gold, pos: { x: 0, z: -6 }, station: { x: 0, z: -6 } }, + { id: 'perplexity', name: 'PERPLEXITY', color: 0x4488ff, pos: { x: -6, z: -2 }, station: { x: -6, z: -2 } }, + ]; + + agentData.forEach(data => { + const group = new THREE.Group(); + group.position.set(data.pos.x, 0, data.pos.z); + + const color = new THREE.Color(data.color); + + // Agent Orb + const orbGeo = new THREE.SphereGeometry(0.4, 32, 32); + const orbMat = new THREE.MeshPhysicalMaterial({ + color: color, + emissive: color, + emissiveIntensity: 2, + roughness: 0, + metalness: 1, + transmission: 0.8, + thickness: 0.5, + }); + const orb = new THREE.Mesh(orbGeo, orbMat); + orb.position.y = 3; + group.add(orb); + + // Halo + const haloGeo = new THREE.TorusGeometry(0.6, 0.02, 16, 64); + const haloMat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.4 }); + const halo = new THREE.Mesh(haloGeo, haloMat); + halo.position.y = 3; + halo.rotation.x = Math.PI / 2; + group.add(halo); + + // Label + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 64; + const ctx = canvas.getContext('2d'); + ctx.font = 'bold 24px "Orbitron", sans-serif'; + ctx.fillStyle = '#' + color.getHexString(); + ctx.textAlign = 'center'; + ctx.fillText(data.name, 128, 40); + const tex = new THREE.CanvasTexture(canvas); + const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide }); + const label = new THREE.Mesh(new THREE.PlaneGeometry(2, 0.5), mat); + label.position.y = 3.8; + group.add(label); + + scene.add(group); + agents.push({ + id: data.id, + group, + orb, + halo, + color, + station: data.station, + targetPos: new THREE.Vector3(data.pos.x, 0, data.pos.z), + wanderTimer: 0 + }); + }); +} + +function createThoughtStream() { + const geo = new THREE.CylinderGeometry(8, 8, 12, 32, 1, true); + const mat = new THREE.ShaderMaterial({ + transparent: true, + side: THREE.BackSide, + depthWrite: false, + uniforms: { + uTime: { value: 0 }, + uColor: { value: new THREE.Color(NEXUS.colors.primary) }, + }, + 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; + + float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } + + void main() { + float flow = fract(vUv.y - uTime * 0.1); + float lines = step(0.98, fract(vUv.x * 20.0 + uTime * 0.05)); + float dots = step(0.99, hash(vUv * 50.0 + floor(uTime * 10.0) * 0.01)); + + float alpha = (lines * 0.1 + dots * 0.5) * smoothstep(0.0, 0.2, vUv.y) * smoothstep(1.0, 0.8, vUv.y); + gl_FragColor = vec4(uColor, alpha * 0.3); + } + `, + }); + thoughtStreamMesh = new THREE.Mesh(geo, mat); + thoughtStreamMesh.position.y = 6; + scene.add(thoughtStreamMesh); +} + +function createHarnessPulse() { + const geo = new THREE.RingGeometry(0.1, 0.2, 64); + const mat = new THREE.MeshBasicMaterial({ + color: NEXUS.colors.primary, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + }); + harnessPulseMesh = new THREE.Mesh(geo, mat); + harnessPulseMesh.rotation.x = -Math.PI / 2; + harnessPulseMesh.position.y = 0.1; + scene.add(harnessPulseMesh); +} + +function createSessionPowerMeter() { + const group = new THREE.Group(); + group.position.set(0, 0, 3); + + const barCount = 12; + const barGeo = new THREE.BoxGeometry(0.2, 0.1, 0.1); + + for (let i = 0; i < barCount; i++) { + const mat = new THREE.MeshStandardMaterial({ + color: NEXUS.colors.primary, + emissive: NEXUS.colors.primary, + emissiveIntensity: 0.2, + transparent: true, + opacity: 0.6 + }); + const bar = new THREE.Mesh(barGeo, mat); + bar.position.y = 0.2 + i * 0.2; + group.add(bar); + powerMeterBars.push(bar); + } + + const labelCanvas = document.createElement('canvas'); + labelCanvas.width = 256; + labelCanvas.height = 64; + const ctx = labelCanvas.getContext('2d'); + ctx.font = 'bold 24px "Orbitron", sans-serif'; + ctx.fillStyle = '#4af0c0'; + ctx.textAlign = 'center'; + ctx.fillText('POWER LEVEL', 128, 40); + const tex = new THREE.CanvasTexture(labelCanvas); + const labelMat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide }); + const label = new THREE.Mesh(new THREE.PlaneGeometry(2, 0.5), labelMat); + label.position.y = 3; + group.add(label); + + scene.add(group); +} + +// ═══ VISION SYSTEM ═══ +function createVisionPoints(data) { + data.forEach(config => { + const vp = createVisionPoint(config); + visionPoints.push(vp); + }); +} + +function createVisionPoint(config) { + const group = new THREE.Group(); + group.position.set(config.position.x, config.position.y, config.position.z); + + const color = new THREE.Color(config.color); + + // Floating Crystal + const crystalGeo = new THREE.OctahedronGeometry(0.6, 0); + const crystalMat = new THREE.MeshPhysicalMaterial({ + color: color, + emissive: color, + emissiveIntensity: 1, + roughness: 0, + metalness: 1, + transmission: 0.5, + thickness: 1, + }); + const crystal = new THREE.Mesh(crystalGeo, crystalMat); + crystal.position.y = 2.5; + group.add(crystal); + + // Glow Ring + const ringGeo = new THREE.TorusGeometry(0.8, 0.02, 16, 64); + const ringMat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.5 }); + const ring = new THREE.Mesh(ringGeo, ringMat); + ring.position.y = 2.5; + ring.rotation.x = Math.PI / 2; + group.add(ring); + + // Light + const light = new THREE.PointLight(color, 1, 10); + light.position.set(0, 2.5, 0); + group.add(light); + + scene.add(group); + + return { config, group, crystal, ring, light }; +} + +// ═══ PORTAL SYSTEM ═══ +function createPortals(data) { + data.forEach(config => { + const portal = createPortal(config); + portals.push(portal); + }); +} + +function createPortal(config) { + const group = new THREE.Group(); + group.position.set(config.position.x, config.position.y, config.position.z); + if (config.rotation) { + group.rotation.y = config.rotation.y; + } + + const portalColor = new THREE.Color(config.color); + + // Torus Ring + const torusGeo = new THREE.TorusGeometry(3, 0.15, 16, 64); + const torusMat = new THREE.MeshStandardMaterial({ + color: portalColor, + emissive: portalColor, + emissiveIntensity: 1.5, + roughness: 0.2, + metalness: 0.8, + }); + const ring = new THREE.Mesh(torusGeo, torusMat); + ring.position.y = 3.5; + ring.name = `portal_ring_${config.id}`; + group.add(ring); + + // Swirl Disc + const swirlGeo = new THREE.CircleGeometry(2.8, 64); + const swirlMat = new THREE.ShaderMaterial({ + transparent: true, + side: THREE.DoubleSide, + uniforms: { + uTime: { value: 0 }, + uColor: { value: portalColor }, + }, + 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() { + 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(uColor, vec3(1.0, 1.0, 1.0), swirl * 0.3); + col = mix(col, vec3(1.0, 1.0, 1.0), swirl2 * 0.2); + float alpha = mask * (0.5 + 0.3 * swirl); + gl_FragColor = vec4(col, alpha); + } + `, + }); + const swirl = new THREE.Mesh(swirlGeo, swirlMat); + swirl.position.y = 3.5; + group.add(swirl); + + // Orbital Particles + const pCount = 120; + const pGeo = new THREE.BufferGeometry(); + const pPos = new Float32Array(pCount * 3); + const pSizes = new Float32Array(pCount); + for (let i = 0; i < pCount; i++) { + const angle = Math.random() * Math.PI * 2; + const r = 3.2 + Math.random() * 0.5; + pPos[i * 3] = Math.cos(angle) * r; + pPos[i * 3 + 1] = 3.5 + (Math.random() - 0.5) * 6; + pPos[i * 3 + 2] = (Math.random() - 0.5) * 0.5; + pSizes[i] = 0.05 + Math.random() * 0.1; + } + pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3)); + pGeo.setAttribute('size', new THREE.BufferAttribute(pSizes, 1)); + const pMat = new THREE.PointsMaterial({ + color: portalColor, + size: 0.08, + transparent: true, + opacity: 0.6, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const pSystem = new THREE.Points(pGeo, pMat); + group.add(pSystem); + + // Pulsing Point Light + const light = new THREE.PointLight(portalColor, 2, 15, 1.5); + light.position.set(0, 3.5, 1); + group.add(light); + + // 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 = '#' + portalColor.getHexString(); + lctx.textAlign = 'center'; + lctx.fillText(`◈ ${config.name.toUpperCase()}`, 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.5; + group.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: portalColor, + emissiveIntensity: 0.1, + }); + const pillar = new THREE.Mesh(pillarGeo, pillarMat); + pillar.position.set(side * 3, 3.5, 0); + pillar.castShadow = true; + group.add(pillar); + } + + scene.add(group); + + const portalObj = { + config, + group, + ring, + swirl, + pSystem, + light, + customElements: {} + }; + + // ═══ DISTINCT VISUAL IDENTITIES ═══ + if (config.id === 'archive') { + // Floating Data Cubes + const cubes = []; + for (let i = 0; i < 6; i++) { + const cubeGeo = new THREE.BoxGeometry(0.4, 0.4, 0.4); + const cubeMat = new THREE.MeshStandardMaterial({ + color: portalColor, + emissive: portalColor, + emissiveIntensity: 1.5, + transparent: true, + opacity: 0.8 + }); + const cube = new THREE.Mesh(cubeGeo, cubeMat); + group.add(cube); + cubes.push(cube); + } + portalObj.customElements.cubes = cubes; + } else if (config.id === 'chapel') { + // Glowing Core + Halo + const coreGeo = new THREE.SphereGeometry(1.2, 32, 32); + const coreMat = new THREE.MeshPhysicalMaterial({ + color: 0xffffff, + emissive: portalColor, + emissiveIntensity: 2, + transparent: true, + opacity: 0.4, + transmission: 0.9, + thickness: 2 + }); + const core = new THREE.Mesh(coreGeo, coreMat); + core.position.y = 3.5; + group.add(core); + portalObj.customElements.core = core; + + const haloGeo = new THREE.TorusGeometry(3.5, 0.05, 16, 100); + const haloMat = new THREE.MeshBasicMaterial({ color: portalColor, transparent: true, opacity: 0.3 }); + const halo = new THREE.Mesh(haloGeo, haloMat); + halo.position.y = 3.5; + group.add(halo); + portalObj.customElements.halo = halo; + } else if (config.id === 'courtyard') { + // Double Rotating Rings + const outerRingGeo = new THREE.TorusGeometry(4.2, 0.1, 16, 80); + const outerRingMat = new THREE.MeshStandardMaterial({ + color: portalColor, + emissive: portalColor, + emissiveIntensity: 0.8, + transparent: true, + opacity: 0.5 + }); + const outerRing = new THREE.Mesh(outerRingGeo, outerRingMat); + outerRing.position.y = 3.5; + group.add(outerRing); + portalObj.customElements.outerRing = outerRing; + } else if (config.id === 'gate') { + // Spiky Monoliths + const spikes = []; + for (let i = 0; i < 8; i++) { + const spikeGeo = new THREE.ConeGeometry(0.2, 1.5, 4); + const spikeMat = new THREE.MeshStandardMaterial({ color: 0x111111, emissive: portalColor, emissiveIntensity: 0.5 }); + const spike = new THREE.Mesh(spikeGeo, spikeMat); + const angle = (i / 8) * Math.PI * 2; + spike.position.set(Math.cos(angle) * 3.5, 3.5 + Math.sin(angle) * 3.5, 0); + spike.rotation.z = angle + Math.PI / 2; + group.add(spike); + spikes.push(spike); + } + portalObj.customElements.spikes = spikes; + + // Darker Swirl + swirl.material.uniforms.uColor.value = new THREE.Color(0x220000); + } + + return portalObj; +} + +// ═══ 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() { + 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); + }); + + 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); + } + + 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); + + 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]; + 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); + 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))); + } + 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.toLowerCase() === 'm' && document.activeElement !== document.getElementById('chat-input')) { + openPortalAtlas(); + } + if (e.key === 'Escape') { + document.getElementById('chat-input').blur(); + if (portalOverlayActive) closePortalOverlay(); + if (visionOverlayActive) closeVisionOverlay(); + if (atlasOverlayActive) closePortalAtlas(); + } + if (e.key.toLowerCase() === 'v' && document.activeElement !== document.getElementById('chat-input')) { + cycleNavMode(); + } + if (e.key.toLowerCase() === 'f' && activePortal && !portalOverlayActive) { + activatePortal(activePortal); + } + if (e.key.toLowerCase() === 'e' && activeVisionPoint && !visionOverlayActive) { + activateVisionPoint(activeVisionPoint); + } + }); + document.addEventListener('keyup', (e) => { + keys[e.key.toLowerCase()] = false; + }); + + const canvas = document.getElementById('nexus-canvas'); + canvas.addEventListener('mousedown', (e) => { + if (e.target === canvas) { + mouseDown = true; + orbitState.lastX = e.clientX; + orbitState.lastY = e.clientY; + + // Raycasting for portals + if (!portalOverlayActive) { + const mouse = new THREE.Vector2( + (e.clientX / window.innerWidth) * 2 - 1, + -(e.clientY / window.innerHeight) * 2 + 1 + ); + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(mouse, camera); + const intersects = raycaster.intersectObjects(portals.map(p => p.ring)); + if (intersects.length > 0) { + const clickedRing = intersects[0].object; + const portal = portals.find(p => p.ring === clickedRing); + if (portal) activatePortal(portal); + } + } + } + }); + 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 { + 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)); + } + }); + + 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 }); + + document.getElementById('chat-toggle').addEventListener('click', () => { + chatOpen = !chatOpen; + document.getElementById('chat-panel').classList.toggle('collapsed', !chatOpen); + }); + document.getElementById('chat-send').addEventListener('click', () => sendChatMessage()); + + // Chat quick actions + document.getElementById('chat-quick-actions').addEventListener('click', (e) => { + const btn = e.target.closest('.quick-action-btn'); + if (!btn) return; + + const action = btn.dataset.action; + + switch(action) { + case 'status': + sendChatMessage("Timmy, what is the current system status?"); + break; + case 'agents': + sendChatMessage("Timmy, check on all active agents."); + break; + case 'portals': + openPortalAtlas(); + break; + case 'help': + sendChatMessage("Timmy, I need assistance with Nexus navigation."); + break; + } + }); + + document.getElementById('portal-close-btn').addEventListener('click', closePortalOverlay); + document.getElementById('vision-close-btn').addEventListener('click', closeVisionOverlay); + + document.getElementById('atlas-toggle-btn').addEventListener('click', openPortalAtlas); + document.getElementById('atlas-close-btn').addEventListener('click', closePortalAtlas); +} + +function sendChatMessage(overrideText = null) { + const input = document.getElementById('chat-input'); + const text = overrideText || input.value.trim(); + if (!text) return; + addChatMessage('user', text); + if (!overrideText) input.value = ''; + 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(); +} + +// ═══ HERMES WEBSOCKET ═══ +function connectHermes() { + if (hermesWs) return; + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/world/ws`; + + console.log(`Connecting to Hermes at ${wsUrl}...`); + hermesWs = new WebSocket(wsUrl); + + hermesWs.onopen = () => { + console.log('Hermes connected.'); + wsConnected = true; + addChatMessage('system', 'Hermes link established.'); + updateWsHudStatus(true); + refreshWorkshopPanel(); + }; + + hermesWs.onmessage = (evt) => { + try { + const data = JSON.parse(evt.data); + handleHermesMessage(data); + } catch (e) { + console.error('Failed to parse Hermes message:', e); + } + }; + + hermesWs.onclose = () => { + console.warn('Hermes disconnected. Retrying in 5s...'); + wsConnected = false; + hermesWs = null; + updateWsHudStatus(false); + refreshWorkshopPanel(); + if (wsReconnectTimer) clearTimeout(wsReconnectTimer); + wsReconnectTimer = setTimeout(connectHermes, 5000); + }; + + hermesWs.onerror = (err) => { + console.error('Hermes WS error:', err); + }; +} + +let lastHeartbeatTime = 0; +let heartbeatFrequency = 0.6; // Default 0.6Hz +let heartbeatIntensity = 1.0; +let heartbeatSource = 'local'; + +function handleHermesMessage(data) { + if (data.type === 'chat') { + addChatMessage(data.agent || 'timmy', data.text); + } else if (data.type === 'heartbeat') { + heartbeatFrequency = data.frequency || 0.6; + heartbeatIntensity = data.intensity || 1.0; + heartbeatSource = data.source || 'evonia-layer'; + lastHeartbeatTime = Date.now(); + + // Visual feedback for heartbeat sync + const pulseEl = document.querySelector('.heartbeat-pulse'); + if (pulseEl) { + pulseEl.style.borderColor = '#4af0c0'; + pulseEl.style.boxShadow = '0 0 15px #4af0c0'; + setTimeout(() => { + pulseEl.style.borderColor = ''; + pulseEl.style.boxShadow = ''; + }, 100); + } + } else if (data.type === 'tool_call') { + const content = `Calling ${data.tool}(${JSON.stringify(data.args)})`; + recentToolOutputs.push({ type: 'call', agent: data.agent || 'SYSTEM', content }); + addToolMessage(data.agent || 'SYSTEM', 'call', content); + refreshWorkshopPanel(); + } else if (data.type === 'tool_result') { + const content = `Result: ${JSON.stringify(data.result)}`; + recentToolOutputs.push({ type: 'result', agent: data.agent || 'SYSTEM', content }); + addToolMessage(data.agent || 'SYSTEM', 'result', content); + refreshWorkshopPanel(); + } else if (data.type === 'history') { + const container = document.getElementById('chat-messages'); + container.innerHTML = ''; + data.messages.forEach(msg => { + if (msg.type === 'tool_call') addToolMessage(msg.agent, 'call', msg.content, false); + else if (msg.type === 'tool_result') addToolMessage(msg.agent, 'result', msg.content, false); + else addChatMessage(msg.agent, msg.text, false); + }); + } +} + +function updateWsHudStatus(connected) { + const dot = document.querySelector('.chat-status-dot'); + if (dot) { + dot.style.background = connected ? '#4af0c0' : '#ff4466'; + dot.style.boxShadow = connected ? '0 0 10px #4af0c0' : '0 0 10px #ff4466'; + } +} + +// ═══ SESSION PERSISTENCE ═══ +function saveSession() { + const msgs = Array.from(document.querySelectorAll('.chat-msg')).slice(-60).map(el => ({ + html: el.innerHTML, + className: el.className + })); + localStorage.setItem('nexus_chat_history', JSON.stringify(msgs)); +} + +function loadSession() { + const saved = localStorage.getItem('nexus_chat_history'); + if (saved) { + const msgs = JSON.parse(saved); + const container = document.getElementById('chat-messages'); + container.innerHTML = ''; + msgs.forEach(m => { + const div = document.createElement('div'); + div.className = m.className; + div.innerHTML = m.html; + container.appendChild(div); + }); + container.scrollTop = container.scrollHeight; + } +} + +function addChatMessage(agent, text, shouldSave = true) { + const container = document.getElementById('chat-messages'); + const div = document.createElement('div'); + div.className = `chat-msg chat-msg-${agent}`; + + const prefixes = { + user: '[ALEXANDER]', + timmy: '[TIMMY]', + system: '[NEXUS]', + error: '[ERROR]', + kimi: '[KIMI]', + claude: '[CLAUDE]', + perplexity: '[PERPLEXITY]' + }; + + const prefix = document.createElement('span'); + prefix.className = 'chat-msg-prefix'; + prefix.textContent = `${prefixes[agent] || '[' + agent.toUpperCase() + ']'} `; + + div.appendChild(prefix); + div.appendChild(document.createTextNode(text)); + + container.appendChild(div); + container.scrollTop = container.scrollHeight; + + if (shouldSave) saveSession(); +} + +function addToolMessage(agent, type, content, shouldSave = true) { + const container = document.getElementById('chat-messages'); + const div = document.createElement('div'); + div.className = `chat-msg chat-msg-tool tool-${type}`; + + const prefix = document.createElement('div'); + prefix.className = 'chat-msg-prefix'; + prefix.textContent = `[${agent.toUpperCase()} TOOL ${type.toUpperCase()}]`; + + const pre = document.createElement('pre'); + pre.className = 'tool-content'; + pre.textContent = content; + + div.appendChild(prefix); + div.appendChild(pre); + + container.appendChild(div); + container.scrollTop = container.scrollHeight; + + if (shouldSave) saveSession(); +} + +// ═══ PORTAL INTERACTION ═══ +function checkPortalProximity() { + if (portalOverlayActive) return; + + let closest = null; + let minDist = Infinity; + + portals.forEach(portal => { + const dist = playerPos.distanceTo(portal.group.position); + if (dist < 4.5 && dist < minDist) { + minDist = dist; + closest = portal; + } + }); + + activePortal = closest; + const hint = document.getElementById('portal-hint'); + if (activePortal) { + document.getElementById('portal-hint-name').textContent = activePortal.config.name; + hint.style.display = 'flex'; + } else { + hint.style.display = 'none'; + } +} + +function activatePortal(portal) { + portalOverlayActive = true; + const overlay = document.getElementById('portal-overlay'); + const nameDisplay = document.getElementById('portal-name-display'); + const descDisplay = document.getElementById('portal-desc-display'); + const redirectBox = document.getElementById('portal-redirect-box'); + const errorBox = document.getElementById('portal-error-box'); + const timerDisplay = document.getElementById('portal-timer'); + const statusDot = document.getElementById('portal-status-dot'); + + nameDisplay.textContent = portal.config.name.toUpperCase(); + descDisplay.textContent = portal.config.description; + statusDot.style.background = portal.config.color; + statusDot.style.boxShadow = `0 0 10px ${portal.config.color}`; + + overlay.style.display = 'flex'; + + if (portal.config.destination && portal.config.destination.url) { + redirectBox.style.display = 'block'; + errorBox.style.display = 'none'; + + let count = 5; + timerDisplay.textContent = count; + const interval = setInterval(() => { + count--; + timerDisplay.textContent = count; + if (count <= 0) { + clearInterval(interval); + if (portalOverlayActive) window.location.href = portal.config.destination.url; + } + if (!portalOverlayActive) clearInterval(interval); + }, 1000); + } else { + redirectBox.style.display = 'none'; + errorBox.style.display = 'block'; + } +} + +function closePortalOverlay() { + portalOverlayActive = false; + document.getElementById('portal-overlay').style.display = 'none'; +} + +// ═══ VISION INTERACTION ═══ +function checkVisionProximity() { + if (visionOverlayActive) return; + + let closest = null; + let minDist = Infinity; + + visionPoints.forEach(vp => { + const dist = playerPos.distanceTo(vp.group.position); + if (dist < 3.5 && dist < minDist) { + minDist = dist; + closest = vp; + } + }); + + activeVisionPoint = closest; + const hint = document.getElementById('vision-hint'); + if (activeVisionPoint) { + document.getElementById('vision-hint-title').textContent = activeVisionPoint.config.title; + hint.style.display = 'flex'; + } else { + hint.style.display = 'none'; + } +} + +function activateVisionPoint(vp) { + visionOverlayActive = true; + const overlay = document.getElementById('vision-overlay'); + const titleDisplay = document.getElementById('vision-title-display'); + const contentDisplay = document.getElementById('vision-content-display'); + const statusDot = document.getElementById('vision-status-dot'); + + titleDisplay.textContent = vp.config.title.toUpperCase(); + contentDisplay.textContent = vp.config.content; + statusDot.style.background = vp.config.color; + statusDot.style.boxShadow = `0 0 10px ${vp.config.color}`; + + overlay.style.display = 'flex'; +} + +function closeVisionOverlay() { + visionOverlayActive = false; + document.getElementById('vision-overlay').style.display = 'none'; +} + +// ═══ PORTAL ATLAS ═══ +function openPortalAtlas() { + atlasOverlayActive = true; + document.getElementById('atlas-overlay').style.display = 'flex'; + populateAtlas(); +} + +function closePortalAtlas() { + atlasOverlayActive = false; + document.getElementById('atlas-overlay').style.display = 'none'; +} + +function populateAtlas() { + const grid = document.getElementById('atlas-grid'); + grid.innerHTML = ''; + + let onlineCount = 0; + let standbyCount = 0; + + portals.forEach(portal => { + const config = portal.config; + if (config.status === 'online') onlineCount++; + if (config.status === 'standby') standbyCount++; + + const card = document.createElement('div'); + card.className = 'atlas-card'; + card.style.setProperty('--portal-color', config.color); + + const statusClass = `status-${config.status || 'online'}`; + + card.innerHTML = ` +
Initializing Sovereign Space...
+ +