import { initWorld, onWindowResize, disposeWorld } from './world.js'; import { initAgents, updateAgents, getAgentCount, disposeAgents, getAgentStates, applyAgentStates, } from './agents.js'; import { initEffects, updateEffects, disposeEffects } from './effects.js'; import { initUI, updateUI } from './ui.js'; import { initInteraction, updateControls, disposeInteraction } from './interaction.js'; import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; import { initVisitor } from './visitor.js'; import { initActivityFeed } from './activity-feed.js'; let running = false; let canvas = null; /** * Build (or rebuild) the Three.js world. * * @param {boolean} firstInit * true — first page load: also starts UI, WebSocket, and visitor * false — context-restore reinit: skips UI/WS (they survive context loss) * @param {Object.|null} stateSnapshot * Agent state map captured just before teardown; reapplied after initAgents. */ function buildWorld(firstInit, stateSnapshot) { const { scene, camera, renderer } = initWorld(canvas); canvas = renderer.domElement; initEffects(scene); initAgents(scene); if (stateSnapshot) { applyAgentStates(stateSnapshot); } initInteraction(camera, renderer); if (firstInit) { initUI(); initWebSocket(scene); initVisitor(); initActivityFeed(); // Activity feed collapse/expand toggle const $feedPanel = document.getElementById('activity-feed'); const $feedClose = document.getElementById('activity-feed-close'); const $feedToggle = document.getElementById('activity-feed-toggle'); if ($feedClose && $feedPanel) { $feedClose.addEventListener('click', () => $feedPanel.classList.add('collapsed')); } if ($feedToggle && $feedPanel) { $feedToggle.addEventListener('click', () => $feedPanel.classList.remove('collapsed')); } // Dismiss loading screen const loadingScreen = document.getElementById('loading-screen'); if (loadingScreen) loadingScreen.classList.add('hidden'); } // Debounce resize to 1 call per frame const ac = new AbortController(); let resizeFrame = null; window.addEventListener('resize', () => { if (resizeFrame) cancelAnimationFrame(resizeFrame); resizeFrame = requestAnimationFrame(() => onWindowResize(camera, renderer)); }, { signal: ac.signal }); let frameCount = 0; let lastFpsTime = performance.now(); let currentFps = 0; let rafId = null; running = true; function animate() { if (!running) return; rafId = requestAnimationFrame(animate); const now = performance.now(); frameCount++; if (now - lastFpsTime >= 1000) { currentFps = Math.round(frameCount * 1000 / (now - lastFpsTime)); frameCount = 0; lastFpsTime = now; } updateControls(); updateEffects(now); updateAgents(now); updateUI({ fps: currentFps, agentCount: getAgentCount(), jobCount: getJobCount(), connectionState: getConnectionState(), }); renderer.render(scene, camera); } // Pause rendering when tab is backgrounded (saves battery on iPad PWA) document.addEventListener('visibilitychange', () => { if (document.hidden) { if (rafId) { cancelAnimationFrame(rafId); rafId = null; running = false; } } else { if (!running) { running = true; animate(); } } }); animate(); return { scene, renderer, ac }; } function teardown({ scene, renderer, ac }) { running = false; ac.abort(); disposeInteraction(); disposeEffects(); disposeAgents(); disposeWorld(renderer, scene); } function main() { const $overlay = document.getElementById('webgl-recovery-overlay'); let handle = buildWorld(true, null); // WebGL context loss recovery (iPad PWA, GPU driver reset, etc.) canvas.addEventListener('webglcontextlost', event => { event.preventDefault(); running = false; if ($overlay) $overlay.style.display = 'flex'; }); canvas.addEventListener('webglcontextrestored', () => { const snapshot = getAgentStates(); teardown(handle); handle = buildWorld(false, snapshot); if ($overlay) $overlay.style.display = 'none'; }); } main(); // Register service worker only in production builds if (import.meta.env.PROD && 'serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(() => {}); }