diff --git a/app.js b/app.js index 7713964..7454b6d 100644 --- a/app.js +++ b/app.js @@ -1,22 +1,15 @@ import * as THREE from 'three'; -import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; import { BokehPass } from 'three/addons/postprocessing/BokehPass.js'; import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js'; import { LoadingManager } from 'three'; -// === COLOR PALETTE === -const NEXUS = { - colors: { - bg: 0x000008, - starCore: 0xffffff, - starDim: 0x8899cc, - constellationLine: 0x334488, - constellationFade: 0x112244, - accent: 0x4488ff, - } -}; +// === CORE MODULES === +import { NEXUS } from './modules/core/theme.js'; +import { zoneIntensity, state, totalActivity } from './modules/core/state.js'; +import { scene, camera, renderer, orbitControls, raycaster } from './modules/core/scene.js'; +import { subscribe as tickerSubscribe, start as tickerStart } from './modules/core/ticker.js'; // === ASSET LOADER === const loadedAssets = new Map(); @@ -24,7 +17,7 @@ const loadedAssets = new Map(); const loadingManager = new THREE.LoadingManager(() => { document.getElementById('loading-bar').style.width = '100%'; document.getElementById('loading').style.display = 'none'; - animate(); + tickerStart(); }); loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => { @@ -71,7 +64,7 @@ function drawMatrixRain() { matrixCtx.font = `${MATRIX_FONT_SIZE}px monospace`; // Tether rain density to commit activity — density range [0.1, 1.0] - const activity = typeof totalActivity === 'function' ? totalActivity() : 0; + const activity = totalActivity(); const density = 0.1 + activity * 0.9; // minimum 10% density const activeColCount = Math.max(1, Math.floor(matrixDrops.length * density)); @@ -117,43 +110,12 @@ window.addEventListener('resize', () => { }); // === SCENE SETUP === -const scene = new THREE.Scene(); -// Background is null — the matrix rain canvas shows through the transparent renderer - -const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000); -camera.position.set(0, 6, 11); - -const raycaster = new THREE.Raycaster(); -const forwardVector = new THREE.Vector3(); - -// === LIGHTING === -// Required for MeshStandardMaterial / MeshPhysicalMaterial used on the platform. -const ambientLight = new THREE.AmbientLight(0x0a1428, 1.4); -scene.add(ambientLight); - -// SpotLight replaces PointLight so shadows can be cast with a single depth map -// (PointLights require 6 cube-face renders; SpotLights need only 1) -const overheadLight = new THREE.SpotLight(0x8899bb, 0.6, 80, Math.PI / 3.5, 0.5, 1.0); -overheadLight.position.set(0, 25, 0); -overheadLight.target.position.set(0, 0, 0); -overheadLight.castShadow = true; -overheadLight.shadow.mapSize.set(2048, 2048); -overheadLight.shadow.camera.near = 5; -overheadLight.shadow.camera.far = 60; -overheadLight.shadow.bias = -0.001; -scene.add(overheadLight); -scene.add(overheadLight.target); - -const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); -renderer.setClearColor(0x000000, 0); -renderer.setPixelRatio(window.devicePixelRatio); -renderer.setSize(window.innerWidth, window.innerHeight); -// === SHADOW SYSTEM === -// PCFSoftShadowMap provides smooth penumbra edges matching the holographic aesthetic. -renderer.shadowMap.enabled = true; -renderer.shadowMap.type = THREE.PCFSoftShadowMap; +// scene, camera, renderer, orbitControls, raycaster are imported from modules/core/scene.js. +// Append renderer after matrix canvas so the Z-order is correct (matrix behind Three.js). document.body.appendChild(renderer.domElement); +const forwardVector = new THREE.Vector3(); + // === STAR FIELD === const STAR_COUNT = 800; const STAR_SPREAD = 400; @@ -189,8 +151,7 @@ const starMaterial = new THREE.PointsMaterial({ const stars = new THREE.Points(starGeo, starMaterial); scene.add(stars); -// Star pulse state — tethered to Bitcoin block events -let _starPulseIntensity = 0; // 0 = normal, 1 = peak brightness +// Star pulse state — tethered to Bitcoin block events (via state.starPulseIntensity) const STAR_BASE_OPACITY = 0.3; const STAR_PEAK_OPACITY = 1.0; const STAR_PULSE_DECAY = 0.012; // decay per frame (~3 seconds to fade) @@ -754,7 +715,8 @@ heatmapMesh.userData.zoomLabel = 'Activity Heatmap'; scene.add(heatmapMesh); // Per-zone intensity [0..1], updated by updateHeatmap() -const zoneIntensity = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0])); +// zoneIntensity is imported from modules/core/state.js — initialize zone keys here. +for (const zone of HEATMAP_ZONES) zoneIntensity[zone.name] = 0; /** * Redraws the heatmap canvas from current zoneIntensity values. @@ -1195,11 +1157,7 @@ const bokehPass = new BokehPass(scene, camera, { }); composer.addPass(bokehPass); -// Orbit controls for free camera movement in photo mode -const orbitControls = new OrbitControls(camera, renderer.domElement); -orbitControls.enableDamping = true; -orbitControls.dampingFactor = 0.05; -orbitControls.enabled = false; +// orbitControls is imported from modules/core/scene.js const photoIndicator = document.getElementById('photo-indicator'); const photoFocusDisplay = document.getElementById('photo-focus'); @@ -1255,17 +1213,16 @@ let energyBeamPulse = 0; function animateEnergyBeam() { energyBeamPulse += 0.02; // Tether beam intensity to active agent count: 0=faint, 1=0.4, 2=0.7, 3+=1.0 - const agentIntensity = _activeAgentCount === 0 ? 0.1 : Math.min(0.1 + _activeAgentCount * 0.3, 1.0); + const agentIntensity = state.agentCount === 0 ? 0.1 : Math.min(0.1 + state.agentCount * 0.3, 1.0); const pulseEffect = Math.sin(energyBeamPulse) * 0.15 * agentIntensity; energyBeamMaterial.opacity = agentIntensity * 0.6 + pulseEffect; } // === RESIZE HANDLER === +// camera + renderer resize handled by modules/core/scene.js. +// Only the EffectComposer (owned by app.js) needs an additional listener. window.addEventListener('resize', () => { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); - renderer.setSize(window.innerWidth, window.innerHeight); composer.setSize(window.innerWidth, window.innerHeight); }); @@ -1371,7 +1328,7 @@ loadSovereigntyStatus(); // === ENERGY BEAM FOR BATCAVE TERMINAL === // Vertical energy beam from Batcave terminal area — intensity tethered to active agent count. -let _activeAgentCount = 0; // updated by agent status fetch +// state.agentCount replaces _activeAgentCount — kept in state.js const ENERGY_BEAM_RADIUS = 0.2; const ENERGY_BEAM_HEIGHT = 50; const ENERGY_BEAM_Y = 0; @@ -1930,13 +1887,7 @@ function buildLightningPath(start, end, jagAmount) { return out; } -/** - * Returns mean activity [0..1] across all agent zones. - */ -function totalActivity() { - const vals = Object.values(zoneIntensity); - return vals.reduce((s, v) => s + v, 0) / Math.max(vals.length, 1); -} +// totalActivity() is imported from modules/core/state.js /** * Lerps between two hex colors. t=0 → colorA, t=1 → colorB. @@ -2370,17 +2321,16 @@ dualBrainScanSprite.position.set(0, 0, 0.01); dualBrainGroup.add(dualBrainScanSprite); // === ANIMATION LOOP === -const clock = new THREE.Clock(); +// clock is replaced by the ticker — elapsed is passed as a parameter each frame. /** - * Main animation loop — called each frame via requestAnimationFrame. - * @returns {void} + * Per-frame update function registered with modules/core/ticker.js. + * Receives elapsed (seconds since start) and delta (seconds since last frame). + * @param {number} elapsed + * @param {number} _delta */ -function animate() { - // Only start animation after assets are loaded - requestAnimationFrame(animate); +function animate(elapsed, _delta) { animateEnergyBeam(); - const elapsed = clock.getElapsedTime(); // Smooth camera transition for overview mode const targetT = overviewMode ? 1 : 0; @@ -2407,11 +2357,11 @@ function animate() { stars.rotation.x = (targetRotX + elapsed * 0.01) * rotationScale; stars.rotation.y = (targetRotY + elapsed * 0.015) * rotationScale; - // Star brightness pulse — tethered to Bitcoin block events - if (_starPulseIntensity > 0) { - _starPulseIntensity = Math.max(0, _starPulseIntensity - STAR_PULSE_DECAY); + // Star brightness pulse — tethered to Bitcoin block events (state.starPulseIntensity) + if (state.starPulseIntensity > 0) { + state.starPulseIntensity = Math.max(0, state.starPulseIntensity - STAR_PULSE_DECAY); } - starMaterial.opacity = STAR_BASE_OPACITY + (STAR_PEAK_OPACITY - STAR_BASE_OPACITY) * _starPulseIntensity; + starMaterial.opacity = STAR_BASE_OPACITY + (STAR_PEAK_OPACITY - STAR_BASE_OPACITY) * state.starPulseIntensity; constellationLines.rotation.x = stars.rotation.x; constellationLines.rotation.y = stars.rotation.y; @@ -2819,7 +2769,9 @@ function animate() { composer.render(); } -animate(); +// Register animate with the ticker and start the loop. +tickerSubscribe(animate); +tickerStart(); // === AMBIENT SOUNDTRACK === // Procedural ambient score synthesised in-browser via Web Audio API. @@ -4595,7 +4547,7 @@ async function refreshAgentBoard() { const data = await fetchAgentStatus(); rebuildAgentPanels(data); // Update active agent count for energy beam tethering - _activeAgentCount = data.agents.filter(a => a.status === 'working').length; + state.agentCount = data.agents.filter(a => a.status === 'working').length; } // Initial render, then poll every 5 min (matching API cache interval) @@ -5361,7 +5313,7 @@ if (timelapseBtnEl) { const blockHeightDisplay = document.getElementById('block-height-display'); const blockHeightValue = document.getElementById('block-height-value'); -let lastKnownBlockHeight = null; +// state.blockHeight replaces lastKnownBlockHeight — kept in state.js async function fetchBlockHeight() { try { @@ -5370,17 +5322,17 @@ async function fetchBlockHeight() { const height = parseInt(await res.text(), 10); if (isNaN(height)) return; - if (lastKnownBlockHeight !== null && height !== lastKnownBlockHeight) { + if (state.blockHeight !== null && height !== state.blockHeight) { // New block — trigger flash blockHeightDisplay.classList.remove('fresh'); // Force reflow so animation restarts void blockHeightDisplay.offsetWidth; blockHeightDisplay.classList.add('fresh'); // Pulse stars — chain heartbeat - _starPulseIntensity = 1.0; + state.starPulseIntensity = 1.0; } - lastKnownBlockHeight = height; + state.blockHeight = height; blockHeightValue.textContent = height.toLocaleString(); } catch (_) { // Network unavailable — keep last known value diff --git a/modules/core/scene.js b/modules/core/scene.js new file mode 100644 index 0000000..fbba82c --- /dev/null +++ b/modules/core/scene.js @@ -0,0 +1,72 @@ +/** + * modules/core/scene.js — Core 3D scene infrastructure. + * + * Creates and exports: THREE.Scene, camera, renderer, lighting, OrbitControls, raycaster. + * Registers the window resize handler for camera + renderer. + * + * IMPORTANT: renderer.domElement is NOT appended to document.body here. + * app.js controls DOM insertion order — the 2D matrix canvas must be inserted + * before the Three.js canvas so the Z-order (matrix behind Three.js) is correct. + */ + +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +// === SCENE === +export const scene = new THREE.Scene(); +// Background is null — the matrix rain canvas shows through the transparent renderer. + +// === CAMERA === +export const camera = new THREE.PerspectiveCamera( + 75, + window.innerWidth / window.innerHeight, + 0.1, + 2000 +); +camera.position.set(0, 6, 11); + +// === RAYCASTER === +export const raycaster = new THREE.Raycaster(); + +// === LIGHTING === +// AmbientLight provides a dark-blue fill so unlit geometry stays visible. +const ambientLight = new THREE.AmbientLight(0x0a1428, 1.4); +scene.add(ambientLight); + +// SpotLight replaces PointLight: shadows need only one depth map instead of six. +const overheadLight = new THREE.SpotLight(0x8899bb, 0.6, 80, Math.PI / 3.5, 0.5, 1.0); +overheadLight.position.set(0, 25, 0); +overheadLight.target.position.set(0, 0, 0); +overheadLight.castShadow = true; +overheadLight.shadow.mapSize.set(2048, 2048); +overheadLight.shadow.camera.near = 5; +overheadLight.shadow.camera.far = 60; +overheadLight.shadow.bias = -0.001; +scene.add(overheadLight); +scene.add(overheadLight.target); + +// === RENDERER === +// NOTE: renderer.domElement is intentionally NOT appended here — see module docblock. +export const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); +renderer.setClearColor(0x000000, 0); +renderer.setPixelRatio(window.devicePixelRatio); +renderer.setSize(window.innerWidth, window.innerHeight); +// PCFSoftShadowMap gives smooth penumbra edges matching the holographic aesthetic. +renderer.shadowMap.enabled = true; +renderer.shadowMap.type = THREE.PCFSoftShadowMap; + +// === ORBIT CONTROLS === +// Free-camera controls used in photo mode only (enabled = false by default). +export const orbitControls = new OrbitControls(camera, renderer.domElement); +orbitControls.enableDamping = true; +orbitControls.dampingFactor = 0.05; +orbitControls.enabled = false; + +// === RESIZE HANDLER === +// Keeps camera aspect ratio and renderer size in sync with the browser window. +// The EffectComposer (owned by app.js) must register its own resize listener. +window.addEventListener('resize', () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +}); diff --git a/modules/core/state.js b/modules/core/state.js new file mode 100644 index 0000000..8420e52 --- /dev/null +++ b/modules/core/state.js @@ -0,0 +1,49 @@ +/** + * modules/core/state.js — Shared reactive data bus. + * + * Data modules write here; visual modules read from here. + * No fetch() calls live in this file — state is populated by data/ modules. + * + * Design: + * - zoneIntensity is exported directly so callers can mutate it by reference. + * - state holds scalar/object fields; mutate properties directly (state.agentCount = N). + * - totalActivity() is a derived getter over zoneIntensity. + */ + +/** + * Per-zone commit activity intensity values (0..1). + * Keys are zone names matching HEATMAP_ZONES entries in app.js. + * Populated at init time once HEATMAP_ZONES is available. + * @type {Record} + */ +export const zoneIntensity = {}; + +/** + * Global shared state — all fields are mutable by their respective data sources. + */ +export const state = { + /** Current Bitcoin block height. null until first fetch. @type {number|null} */ + blockHeight: null, + + /** Weather payload from Open-Meteo. null until first fetch. @type {object|null} */ + weather: null, + + /** Number of agents currently in 'working' status. Updated by agent status fetch. */ + agentCount: 0, + + /** + * Star pulse intensity driven by new Bitcoin block events. + * 0 = base brightness, 1 = peak. Decays each frame. + */ + starPulseIntensity: 0, +}; + +/** + * Returns mean activity level [0..1] across all agent zones. + * Used by matrix rain, energy beam, holographic earth, etc. + * @returns {number} + */ +export function totalActivity() { + const vals = Object.values(zoneIntensity); + return vals.reduce((s, v) => s + v, 0) / Math.max(vals.length, 1); +} diff --git a/modules/core/theme.js b/modules/core/theme.js new file mode 100644 index 0000000..2663574 --- /dev/null +++ b/modules/core/theme.js @@ -0,0 +1,62 @@ +/** + * modules/core/theme.js — NEXUS.theme: authoritative source for all visual constants. + * + * NEXUS.colors is preserved as a backwards-compatible alias so existing code + * that references NEXUS.colors.xxx continues to work unchanged. + * + * Category: STRUCTURAL (no data source required — purely declarative constants) + */ + +const _colors = { + bg: 0x000008, + starCore: 0xffffff, + starDim: 0x8899cc, + constellationLine: 0x334488, + constellationFade: 0x112244, + accent: 0x4488ff, +}; + +export const NEXUS = { + /** Backwards-compat alias — identical to theme.colors. */ + colors: _colors, + + theme: { + /** Colour palette used across all visual elements. */ + colors: _colors, + + /** Typography definitions for canvas and HUD text. */ + fonts: { + mono: 'monospace', + matrixSize: 14, // px — matrix rain character size + hud: '12px monospace', + panel: '11px monospace', + panelLabel: 'bold 11px monospace', + }, + + /** Line/stroke weights for geometry outlines and connections. */ + lineWeights: { + constellation: 0.18, + portalRing: 0.6, + glassEdge: 0.55, + runeRing: 0.7, + }, + + /** Post-processing and emissive glow parameters. */ + glowParams: { + bloomStrength: 0.35, + bloomRadius: 0.4, + bloomThreshold: 0.1, + emissiveScale: 0.06, // multiplier for emissive colour on platform frame + }, + + /** Canonical opacity values — keeps visual language consistent. */ + opacity: { + glassTile: 0.09, + starBase: 0.3, + starPeak: 1.0, + heatmapBase: 0.75, + constellationBase: 0.12, + constellationPulse: 0.06, + }, + }, +}; diff --git a/modules/core/ticker.js b/modules/core/ticker.js new file mode 100644 index 0000000..8d6be4f --- /dev/null +++ b/modules/core/ticker.js @@ -0,0 +1,78 @@ +/** + * modules/core/ticker.js — Global Animation Clock. + * + * The single requestAnimationFrame loop for the entire Nexus. + * No module may call requestAnimationFrame directly — all subscribe here instead. + * + * Usage: + * import { subscribe, unsubscribe, start } from './modules/core/ticker.js'; + * subscribe((elapsed, delta) => { ... }); + * start(); // call once after the scene is ready + * + * Callback signature: fn(elapsed: number, delta: number) + * elapsed — seconds since start() was first called + * delta — seconds since the previous frame (0 on the very first frame) + */ + +/** @type {Set} */ +const _subscribers = new Set(); + +/** @type {number|null} */ +let _rafId = null; + +/** @type {number|null} */ +let _startTime = null; + +/** @type {number|null} */ +let _prevTime = null; + +function _tick() { + _rafId = requestAnimationFrame(_tick); + + const now = performance.now() / 1000; // convert ms → seconds + if (_startTime === null) _startTime = now; + + const elapsed = now - _startTime; + const delta = _prevTime === null ? 0 : now - _prevTime; + _prevTime = now; + + for (const fn of _subscribers) { + fn(elapsed, delta); + } +} + +/** + * Subscribe a function to receive (elapsed, delta) each animation frame. + * Safe to call before or after start(). + * @param {function(number, number): void} fn + */ +export function subscribe(fn) { + _subscribers.add(fn); +} + +/** + * Unsubscribe a previously registered callback. + * @param {function(number, number): void} fn + */ +export function unsubscribe(fn) { + _subscribers.delete(fn); +} + +/** + * Start the animation loop. Idempotent — safe to call multiple times. + */ +export function start() { + if (_rafId === null) { + _tick(); + } +} + +/** + * Stop the animation loop. Subscribers are preserved and will resume if start() is called again. + */ +export function stop() { + if (_rafId !== null) { + cancelAnimationFrame(_rafId); + _rafId = null; + } +}