Split the monolithic 5393-line app.js into 32 focused ES modules under modules/ with a thin ~330-line orchestrator. No bundler required — runs in-browser via import maps. Module structure: core/ — scene, ticker, state, theme, audio data/ — gitea, weather, bitcoin, loaders terrain/ — stars, clouds, island effects/ — matrix-rain, energy-beam, lightning, shockwave, rune-ring, gravity-zones panels/ — heatmap, sigil, sovereignty, dual-brain, batcave, earth, agent-board, lora-panel portals/ — portal-system, commit-banners narrative/ — bookshelves, oath, chat utils/ — perlin All files pass node --check. No new dependencies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
333 lines
12 KiB
JavaScript
333 lines
12 KiB
JavaScript
// app.js — Nexus orchestrator (thin shell)
|
|
// All logic lives in modules/. This file wires them together.
|
|
import * as THREE from 'three';
|
|
|
|
// --- Core ---
|
|
import { initScene, scene, camera, renderer, composer, orbitControls, bokehPass,
|
|
raycaster, forwardVector, clock, ambientLight, overheadLight, warpPass } from './modules/core/scene.js';
|
|
import { subscribe, setRenderTarget, start as startTicker } from './modules/core/ticker.js';
|
|
import { state } from './modules/core/state.js';
|
|
import * as audio from './modules/core/audio.js';
|
|
|
|
// --- Terrain ---
|
|
import * as stars from './modules/terrain/stars.js';
|
|
import * as clouds from './modules/terrain/clouds.js';
|
|
import * as island from './modules/terrain/island.js';
|
|
|
|
// --- Effects ---
|
|
import * as matrixRain from './modules/effects/matrix-rain.js';
|
|
import * as energyBeam from './modules/effects/energy-beam.js';
|
|
import * as lightning from './modules/effects/lightning.js';
|
|
import * as shockwave from './modules/effects/shockwave.js';
|
|
import * as runeRing from './modules/effects/rune-ring.js';
|
|
import * as gravityZones from './modules/effects/gravity-zones.js';
|
|
|
|
// --- Panels ---
|
|
import * as heatmap from './modules/panels/heatmap.js';
|
|
import * as sigil from './modules/panels/sigil.js';
|
|
import * as sovereignty from './modules/panels/sovereignty.js';
|
|
import * as dualBrain from './modules/panels/dual-brain.js';
|
|
import * as batcave from './modules/panels/batcave.js';
|
|
import * as earth from './modules/panels/earth.js';
|
|
import * as agentBoard from './modules/panels/agent-board.js';
|
|
import * as loraPanel from './modules/panels/lora-panel.js';
|
|
|
|
// --- Portals ---
|
|
import * as portalSystem from './modules/portals/portal-system.js';
|
|
import * as commitBanners from './modules/portals/commit-banners.js';
|
|
|
|
// --- Narrative ---
|
|
import * as bookshelves from './modules/narrative/bookshelves.js';
|
|
import * as oath from './modules/narrative/oath.js';
|
|
import * as chat from './modules/narrative/chat.js';
|
|
|
|
// --- Data ---
|
|
import { fetchCommits } from './modules/data/gitea.js';
|
|
import { startWeatherPolling, updateWeatherParticles } from './modules/data/weather.js';
|
|
import { startBlockPolling } from './modules/data/bitcoin.js';
|
|
import { loadSovereigntyStatus } from './modules/data/loaders.js';
|
|
import { cloudMaterial } from './modules/terrain/clouds.js';
|
|
import { startPortalHums } from './modules/core/audio.js';
|
|
|
|
// --- WebSocket ---
|
|
import { wsClient } from './ws-client.js';
|
|
|
|
// ─── Mouse-driven rotation ───
|
|
let mouseX = 0, mouseY = 0, targetRotX = 0, targetRotY = 0;
|
|
document.addEventListener('mousemove', (e) => {
|
|
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
|
|
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
|
|
});
|
|
|
|
// ─── Overview mode (Tab) ───
|
|
let overviewMode = false, overviewT = 0;
|
|
const NORMAL_CAM = new THREE.Vector3(0, 6, 11);
|
|
const OVERVIEW_CAM = new THREE.Vector3(0, 200, 0.1);
|
|
const overviewIndicator = document.getElementById('overview-indicator');
|
|
|
|
// ─── Zoom-to-object (dblclick) ───
|
|
const _zoomRaycaster = new THREE.Raycaster();
|
|
const _zoomMouse = new THREE.Vector2();
|
|
const _zoomCamTarget = new THREE.Vector3();
|
|
const _zoomLookTarget = new THREE.Vector3();
|
|
let zoomT = 0, zoomTargetT = 0, zoomActive = false;
|
|
const zoomIndicator = document.getElementById('zoom-indicator');
|
|
const zoomLabelEl = document.getElementById('zoom-label');
|
|
|
|
function getZoomLabel(obj) {
|
|
let o = obj;
|
|
while (o) { if (o.userData && o.userData.zoomLabel) return o.userData.zoomLabel; o = o.parent; }
|
|
return 'Object';
|
|
}
|
|
function exitZoom() {
|
|
zoomTargetT = 0; zoomActive = false;
|
|
if (zoomIndicator) zoomIndicator.classList.remove('visible');
|
|
}
|
|
|
|
// ─── Photo mode (P) ───
|
|
let photoMode = false;
|
|
const photoIndicator = document.getElementById('photo-indicator');
|
|
const photoFocusDisplay = document.getElementById('photo-focus');
|
|
function updateFocusDisplay() {
|
|
if (photoFocusDisplay) photoFocusDisplay.textContent = bokehPass.uniforms['focus'].value.toFixed(1);
|
|
}
|
|
|
|
// ─── Sovereignty cheat code ───
|
|
const SOVEREIGNTY_WORD = 'sovereignty';
|
|
let sovereigntyBuffer = '';
|
|
let sovereigntyBufferTimer = null;
|
|
|
|
// ─── Debug mode ───
|
|
let debugMode = false;
|
|
|
|
// ─── Podcast toggle ───
|
|
function initPodcastToggle() {
|
|
const btn = document.getElementById('podcast-toggle');
|
|
if (!btn) return;
|
|
btn.addEventListener('click', () => {
|
|
if (btn.textContent === '\uD83C\uDFA7') {
|
|
fetch('SOUL.md').then(r => { if (!r.ok) throw new Error('fail'); return r.text(); }).then(text => {
|
|
const paragraphs = text.split('\n\n').filter(p => p.trim());
|
|
if (!paragraphs.length) throw new Error('empty');
|
|
let index = 0;
|
|
const speakNext = () => {
|
|
if (index >= paragraphs.length) return;
|
|
const u = new SpeechSynthesisUtterance(paragraphs[index++]);
|
|
u.lang = 'en-US'; u.rate = 0.9; u.pitch = 1.1;
|
|
u.onend = () => setTimeout(speakNext, 800);
|
|
speechSynthesis.speak(u);
|
|
};
|
|
btn.textContent = '\u23F9'; btn.classList.add('active'); speakNext();
|
|
}).catch(() => { btn.textContent = '\uD83C\uDFA7'; });
|
|
} else { speechSynthesis.cancel(); btn.textContent = '\uD83C\uDFA7'; btn.classList.remove('active'); }
|
|
});
|
|
}
|
|
|
|
function initSoulToggle() {
|
|
const btn = document.getElementById('soul-toggle');
|
|
if (!btn) return;
|
|
btn.addEventListener('click', () => {
|
|
if (btn.textContent === '\uD83D\uDCDC') {
|
|
fetch('SOUL.md').then(r => { if (!r.ok) throw new Error('fail'); return r.text(); }).then(text => {
|
|
const lines = text.split('\n').slice(1).map(l => l.replace(/^#+\s*/, ''));
|
|
let index = 0;
|
|
const speakLine = () => {
|
|
if (index >= lines.length) return;
|
|
const line = lines[index++];
|
|
if (!line.trim()) { setTimeout(speakLine, 400); return; }
|
|
const u = new SpeechSynthesisUtterance(line);
|
|
u.lang = 'en-US'; u.rate = 0.85; u.pitch = 1.0;
|
|
u.onend = () => setTimeout(speakLine, 600);
|
|
speechSynthesis.speak(u);
|
|
};
|
|
btn.textContent = '\u23F9'; speakLine();
|
|
}).catch(() => {});
|
|
} else { speechSynthesis.cancel(); btn.textContent = '\uD83D\uDCDC'; }
|
|
});
|
|
}
|
|
|
|
function initDebugToggle() {
|
|
const btn = document.getElementById('debug-toggle');
|
|
if (!btn) return;
|
|
btn.addEventListener('click', () => {
|
|
debugMode = !debugMode;
|
|
btn.style.backgroundColor = debugMode ? 'var(--color-text-muted)' : 'var(--color-secondary)';
|
|
});
|
|
}
|
|
|
|
// ─── Keyboard bindings ───
|
|
function initKeyboardBindings() {
|
|
document.addEventListener('keydown', (e) => {
|
|
// Tab — overview toggle
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
overviewMode = !overviewMode;
|
|
if (overviewIndicator) overviewIndicator.classList.toggle('visible', overviewMode);
|
|
}
|
|
// Escape — exit zoom
|
|
if (e.key === 'Escape') exitZoom();
|
|
// P — photo mode
|
|
if (e.key === 'p' || e.key === 'P') {
|
|
photoMode = !photoMode;
|
|
document.body.classList.toggle('photo-mode', photoMode);
|
|
orbitControls.enabled = photoMode;
|
|
if (photoIndicator) photoIndicator.classList.toggle('visible', photoMode);
|
|
if (photoMode) {
|
|
bokehPass.uniforms['aperture'].value = 0.0003;
|
|
bokehPass.uniforms['maxblur'].value = 0.008;
|
|
orbitControls.target.set(0, 0, 0); orbitControls.update();
|
|
updateFocusDisplay();
|
|
} else {
|
|
bokehPass.uniforms['aperture'].value = 0.00015;
|
|
bokehPass.uniforms['maxblur'].value = 0.004;
|
|
}
|
|
}
|
|
// [ ] — adjust focus in photo mode
|
|
if (photoMode) {
|
|
if (e.key === '[') { bokehPass.uniforms['focus'].value = Math.max(0.5, bokehPass.uniforms['focus'].value - 0.5); updateFocusDisplay(); }
|
|
if (e.key === ']') { bokehPass.uniforms['focus'].value = Math.min(200, bokehPass.uniforms['focus'].value + 0.5); updateFocusDisplay(); }
|
|
}
|
|
// Sovereignty cheat code
|
|
if (e.key.length === 1) {
|
|
sovereigntyBuffer += e.key.toLowerCase();
|
|
if (sovereigntyBufferTimer) clearTimeout(sovereigntyBufferTimer);
|
|
sovereigntyBufferTimer = setTimeout(() => { sovereigntyBuffer = ''; }, 2000);
|
|
if (sovereigntyBuffer.includes(SOVEREIGNTY_WORD)) {
|
|
sovereigntyBuffer = '';
|
|
shockwave.triggerSovereigntyEasterEgg();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ─── Zoom-to-object (dblclick on renderer) ───
|
|
function initZoomInteraction() {
|
|
renderer.domElement.addEventListener('dblclick', (e) => {
|
|
if (overviewMode || photoMode) return;
|
|
_zoomMouse.x = (e.clientX / window.innerWidth) * 2 - 1;
|
|
_zoomMouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
|
_zoomRaycaster.setFromCamera(_zoomMouse, camera);
|
|
const hits = _zoomRaycaster.intersectObjects(scene.children, true)
|
|
.filter(h => !(h.object instanceof THREE.Points) && !(h.object instanceof THREE.Line));
|
|
if (!hits.length) { exitZoom(); return; }
|
|
const hit = hits[0];
|
|
const label = getZoomLabel(hit.object);
|
|
const dir = new THREE.Vector3().subVectors(camera.position, hit.point).normalize();
|
|
const flyDist = Math.max(1.5, Math.min(5, hit.distance * 0.45));
|
|
_zoomCamTarget.copy(hit.point).addScaledVector(dir, flyDist);
|
|
_zoomLookTarget.copy(hit.point);
|
|
zoomT = 0; zoomTargetT = 1; zoomActive = true;
|
|
if (zoomLabelEl) zoomLabelEl.textContent = label;
|
|
if (zoomIndicator) zoomIndicator.classList.add('visible');
|
|
});
|
|
}
|
|
|
|
// ─── Main per-frame update (subscribed to ticker) ───
|
|
function onFrame(elapsed, delta) {
|
|
// Camera — overview blend
|
|
const targetT = overviewMode ? 1 : 0;
|
|
overviewT += (targetT - overviewT) * 0.04;
|
|
const basePos = new THREE.Vector3().lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT);
|
|
|
|
// Camera — zoom blend
|
|
if (!photoMode) zoomT += (zoomTargetT - zoomT) * 0.07;
|
|
if (zoomT > 0.001 && !photoMode && !overviewMode) {
|
|
camera.position.lerpVectors(basePos, _zoomCamTarget, zoomT);
|
|
camera.lookAt(new THREE.Vector3(0, 0, 0).lerp(_zoomLookTarget, zoomT));
|
|
} else {
|
|
camera.position.copy(basePos);
|
|
camera.lookAt(0, 0, 0);
|
|
}
|
|
|
|
// Mouse-driven rotation
|
|
const rotScale = photoMode ? 0 : (1 - overviewT);
|
|
targetRotX += (mouseY * 0.3 - targetRotX) * 0.02;
|
|
targetRotY += (mouseX * 0.3 - targetRotY) * 0.02;
|
|
|
|
if (photoMode) orbitControls.update();
|
|
|
|
// Module updates
|
|
stars.update(elapsed, delta, mouseX, mouseY, overviewT, photoMode);
|
|
clouds.update(elapsed);
|
|
island.update(elapsed);
|
|
energyBeam.update(elapsed);
|
|
lightning.update(elapsed);
|
|
shockwave.update(elapsed);
|
|
runeRing.update(elapsed);
|
|
gravityZones.update(elapsed);
|
|
heatmap.update(elapsed);
|
|
sigil.update(elapsed);
|
|
sovereignty.update(elapsed);
|
|
dualBrain.update(elapsed);
|
|
batcave.updateProbe(elapsed, renderer, scene);
|
|
earth.update(elapsed);
|
|
agentBoard.update(elapsed);
|
|
loraPanel.update(elapsed);
|
|
portalSystem.update(elapsed, camera, raycaster, forwardVector);
|
|
commitBanners.update(elapsed);
|
|
bookshelves.update(elapsed);
|
|
oath.update(elapsed);
|
|
chat.update(elapsed);
|
|
updateWeatherParticles(elapsed);
|
|
audio.updateAudioListener();
|
|
}
|
|
|
|
// ─── Data loading after scene is ready ───
|
|
async function loadData() {
|
|
fetchCommits();
|
|
startWeatherPolling(ambientLight, cloudMaterial);
|
|
startBlockPolling();
|
|
const sovData = await loadSovereigntyStatus();
|
|
if (sovData) sovereignty.updateFromData(sovData);
|
|
portalSystem.loadPortals(startPortalHums);
|
|
}
|
|
|
|
// ─── Boot ───
|
|
initScene(() => {
|
|
// Init all modules
|
|
matrixRain.init();
|
|
stars.init(scene);
|
|
clouds.init(scene);
|
|
island.init(scene);
|
|
energyBeam.init(scene);
|
|
lightning.init(scene);
|
|
shockwave.init(scene, clock);
|
|
runeRing.init(scene);
|
|
gravityZones.init(scene);
|
|
heatmap.init(scene);
|
|
heatmap.drawHeatmap();
|
|
sigil.init(scene);
|
|
sovereignty.init(scene);
|
|
dualBrain.init(scene);
|
|
batcave.init(scene);
|
|
earth.init(scene);
|
|
agentBoard.init(scene);
|
|
loraPanel.init(scene);
|
|
portalSystem.init(scene, clock, warpPass);
|
|
commitBanners.init(scene);
|
|
bookshelves.init(scene);
|
|
oath.init(scene, ambientLight, overheadLight, renderer, camera);
|
|
chat.init(scene, clock);
|
|
audio.init(camera);
|
|
|
|
// Interactions & bindings
|
|
initKeyboardBindings();
|
|
initZoomInteraction();
|
|
initPodcastToggle();
|
|
initSoulToggle();
|
|
initDebugToggle();
|
|
|
|
// WebSocket
|
|
wsClient.connect();
|
|
window.addEventListener('player-joined', (e) => console.log('Player joined:', e.detail));
|
|
window.addEventListener('player-left', (e) => console.log('Player left:', e.detail));
|
|
|
|
// Wire up ticker and start
|
|
subscribe(onFrame);
|
|
setRenderTarget(renderer, scene, camera, composer);
|
|
startTicker();
|
|
|
|
// Kick off async data loading
|
|
loadData();
|
|
});
|