From 675b61d65ef90b34ea27adaf42331ce1bbd271fe Mon Sep 17 00:00:00 2001 From: Perplexity Computer Date: Tue, 24 Mar 2026 18:12:53 +0000 Subject: [PATCH] refactor: modularize app.js into ES module architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app.js | 5619 ++--------------------------- modules/core/audio.js | 165 + modules/core/scene.js | 196 + modules/core/state.js | 35 + modules/core/theme.js | 42 + modules/core/ticker.js | 53 + modules/data/bitcoin.js | 34 + modules/data/gitea.js | 201 ++ modules/data/loaders.js | 39 + modules/data/weather.js | 155 + modules/effects/energy-beam.js | 37 + modules/effects/gravity-zones.js | 107 + modules/effects/lightning.js | 143 + modules/effects/matrix-rain.js | 58 + modules/effects/rune-ring.js | 75 + modules/effects/shockwave.js | 192 + modules/narrative/bookshelves.js | 90 + modules/narrative/chat.js | 210 ++ modules/narrative/oath.js | 128 + modules/panels/agent-board.js | 92 + modules/panels/batcave.js | 100 + modules/panels/dual-brain.js | 122 + modules/panels/earth.js | 174 + modules/panels/heatmap.js | 81 + modules/panels/lora-panel.js | 81 + modules/panels/sigil.js | 138 + modules/panels/sovereignty.js | 90 + modules/portals/commit-banners.js | 62 + modules/portals/portal-system.js | 126 + modules/terrain/clouds.js | 115 + modules/terrain/island.js | 221 ++ modules/terrain/stars.js | 101 + modules/utils/perlin.js | 44 + 33 files changed, 3786 insertions(+), 5340 deletions(-) create mode 100644 modules/core/audio.js create mode 100644 modules/core/scene.js create mode 100644 modules/core/state.js create mode 100644 modules/core/theme.js create mode 100644 modules/core/ticker.js create mode 100644 modules/data/bitcoin.js create mode 100644 modules/data/gitea.js create mode 100644 modules/data/loaders.js create mode 100644 modules/data/weather.js create mode 100644 modules/effects/energy-beam.js create mode 100644 modules/effects/gravity-zones.js create mode 100644 modules/effects/lightning.js create mode 100644 modules/effects/matrix-rain.js create mode 100644 modules/effects/rune-ring.js create mode 100644 modules/effects/shockwave.js create mode 100644 modules/narrative/bookshelves.js create mode 100644 modules/narrative/chat.js create mode 100644 modules/narrative/oath.js create mode 100644 modules/panels/agent-board.js create mode 100644 modules/panels/batcave.js create mode 100644 modules/panels/dual-brain.js create mode 100644 modules/panels/earth.js create mode 100644 modules/panels/heatmap.js create mode 100644 modules/panels/lora-panel.js create mode 100644 modules/panels/sigil.js create mode 100644 modules/panels/sovereignty.js create mode 100644 modules/portals/commit-banners.js create mode 100644 modules/portals/portal-system.js create mode 100644 modules/terrain/clouds.js create mode 100644 modules/terrain/island.js create mode 100644 modules/terrain/stars.js create mode 100644 modules/utils/perlin.js diff --git a/app.js b/app.js index 7713964..adf3ed4 100644 --- a/app.js +++ b/app.js @@ -1,5393 +1,332 @@ +// app.js — Nexus orchestrator (thin shell) +// All logic lives in modules/. This file wires them together. 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, - } -}; - -// === ASSET LOADER === -const loadedAssets = new Map(); - -const loadingManager = new THREE.LoadingManager(() => { - document.getElementById('loading-bar').style.width = '100%'; - document.getElementById('loading').style.display = 'none'; - animate(); -}); - -loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => { - const progress = (itemsLoaded / itemsTotal) * 100; - document.getElementById('loading-bar').style.width = `${progress}%`; -}; - -// Procedural placeholder texture — avoids 404 for missing placeholder-texture.jpg -const _placeholderCanvas = document.createElement('canvas'); -_placeholderCanvas.width = 64; -_placeholderCanvas.height = 64; -const _placeholderCtx = _placeholderCanvas.getContext('2d'); -_placeholderCtx.fillStyle = '#0a0a18'; -_placeholderCtx.fillRect(0, 0, 64, 64); -const placeholderTexture = new THREE.CanvasTexture(_placeholderCanvas); -loadedAssets.set('placeholder-texture', placeholderTexture); -// Notify loading manager so it still counts one asset -loadingManager.itemStart('placeholder-texture'); -loadingManager.itemEnd('placeholder-texture'); - -// === MATRIX RAIN === -// 2D canvas layer rendered behind the Three.js scene. -const matrixCanvas = document.createElement('canvas'); -matrixCanvas.id = 'matrix-rain'; -matrixCanvas.width = window.innerWidth; -matrixCanvas.height = window.innerHeight; -document.body.appendChild(matrixCanvas); - -const matrixCtx = matrixCanvas.getContext('2d'); - -const MATRIX_CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF'; -const MATRIX_FONT_SIZE = 14; -const MATRIX_COL_COUNT = Math.floor(window.innerWidth / MATRIX_FONT_SIZE); -const matrixDrops = new Array(MATRIX_COL_COUNT).fill(1); - -// Commit hashes for matrix rain — populated by heatmap fetch, used to inject real data into the rain -let _matrixCommitHashes = []; - -function drawMatrixRain() { - // Fade previous frame with semi-transparent black overlay (creates the trail) - matrixCtx.fillStyle = 'rgba(0, 0, 8, 0.05)'; - matrixCtx.fillRect(0, 0, matrixCanvas.width, matrixCanvas.height); - - 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 density = 0.1 + activity * 0.9; // minimum 10% density - const activeColCount = Math.max(1, Math.floor(matrixDrops.length * density)); - - for (let i = 0; i < matrixDrops.length; i++) { - // Only render columns up to density-scaled count (skip inactive ones) - if (i >= activeColCount) { - // Inactive columns still fade but don't spawn new characters - if (matrixDrops[i] * MATRIX_FONT_SIZE > matrixCanvas.height) continue; - } - - // Occasionally inject a real commit hash (first 7 chars) instead of katakana - let char; - if (_matrixCommitHashes.length > 0 && Math.random() < 0.02) { - const hash = _matrixCommitHashes[Math.floor(Math.random() * _matrixCommitHashes.length)]; - char = hash[Math.floor(Math.random() * hash.length)]; - } else { - char = MATRIX_CHARS[Math.floor(Math.random() * MATRIX_CHARS.length)]; - } - - const x = i * MATRIX_FONT_SIZE; - const y = matrixDrops[i] * MATRIX_FONT_SIZE; - - // Head character: bright white-green - matrixCtx.fillStyle = '#aaffaa'; - matrixCtx.fillText(char, x, y); - - // Reset drop to top — speed influenced by activity - const resetThreshold = 0.975 - activity * 0.015; // more activity = faster reset = denser rain - if (y > matrixCanvas.height && Math.random() > resetThreshold) { - matrixDrops[i] = 0; - } - matrixDrops[i]++; - } -} - -// Animate at ~20 fps (independent of Three.js loop) -setInterval(drawMatrixRain, 50); - -// Resize handler for matrix canvas -window.addEventListener('resize', () => { - matrixCanvas.width = window.innerWidth; - matrixCanvas.height = window.innerHeight; -}); - -// === 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; -document.body.appendChild(renderer.domElement); - -// === STAR FIELD === -const STAR_COUNT = 800; -const STAR_SPREAD = 400; -const CONSTELLATION_DISTANCE = 30; // max distance to draw a line between stars - -const starPositions = []; -const starGeo = new THREE.BufferGeometry(); -const posArray = new Float32Array(STAR_COUNT * 3); -const sizeArray = new Float32Array(STAR_COUNT); - -for (let i = 0; i < STAR_COUNT; i++) { - const x = (Math.random() - 0.5) * STAR_SPREAD; - const y = (Math.random() - 0.5) * STAR_SPREAD; - const z = (Math.random() - 0.5) * STAR_SPREAD; - posArray[i * 3] = x; - posArray[i * 3 + 1] = y; - posArray[i * 3 + 2] = z; - sizeArray[i] = Math.random() * 2.5 + 0.5; - starPositions.push(new THREE.Vector3(x, y, z)); -} - -starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3)); -starGeo.setAttribute('size', new THREE.BufferAttribute(sizeArray, 1)); - -const starMaterial = new THREE.PointsMaterial({ - color: NEXUS.colors.starCore, - size: 0.6, - sizeAttenuation: true, - transparent: true, - opacity: 0.9, -}); - -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 -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) - -// === CONSTELLATION LINES === -// Connect nearby stars with faint lines, limited to avoid clutter -/** - * Builds constellation line segments connecting nearby stars. - * @returns {THREE.LineSegments} - */ -function buildConstellationLines() { - const linePositions = []; - const MAX_CONNECTIONS_PER_STAR = 3; - const connectionCount = new Array(STAR_COUNT).fill(0); - - for (let i = 0; i < STAR_COUNT; i++) { - if (connectionCount[i] >= MAX_CONNECTIONS_PER_STAR) continue; - - // Find nearest neighbors - const neighbors = []; - for (let j = i + 1; j < STAR_COUNT; j++) { - if (connectionCount[j] >= MAX_CONNECTIONS_PER_STAR) continue; - const dist = starPositions[i].distanceTo(starPositions[j]); - if (dist < CONSTELLATION_DISTANCE) { - neighbors.push({ j, dist }); - } - } - - // Sort by distance and connect closest ones - neighbors.sort((/** @type {{j: number, dist: number}} */ a, /** @type {{j: number, dist: number}} */ b) => a.dist - b.dist); - const toConnect = neighbors.slice(0, MAX_CONNECTIONS_PER_STAR - connectionCount[i]); - - for (const { j } of toConnect) { - linePositions.push( - starPositions[i].x, starPositions[i].y, starPositions[i].z, - starPositions[j].x, starPositions[j].y, starPositions[j].z - ); - connectionCount[i]++; - connectionCount[j]++; - } - } - - const lineGeo = new THREE.BufferGeometry(); - lineGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(linePositions), 3)); - - const lineMat = new THREE.LineBasicMaterial({ - color: NEXUS.colors.constellationLine, - transparent: true, - opacity: 0.18, - }); - - return new THREE.LineSegments(lineGeo, lineMat); -} - -const constellationLines = buildConstellationLines(); -scene.add(constellationLines); - -// === GLASS PLATFORM === -// Central floating platform with transparent glass-floor sections revealing the void (star field) below. - -const glassPlatformGroup = new THREE.Group(); - -// Dark metallic frame material -const platformFrameMat = new THREE.MeshStandardMaterial({ - color: 0x0a1828, - metalness: 0.9, - roughness: 0.1, - emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.06), -}); - -// Outer solid rim (flat ring) -const platformRimGeo = new THREE.RingGeometry(4.7, 5.3, 64); -const platformRim = new THREE.Mesh(platformRimGeo, platformFrameMat); -platformRim.rotation.x = -Math.PI / 2; -platformRim.castShadow = true; -platformRim.receiveShadow = true; -glassPlatformGroup.add(platformRim); - -// Raised border torus for visible 3-D thickness -const borderTorusGeo = new THREE.TorusGeometry(5.0, 0.1, 6, 64); -const borderTorus = new THREE.Mesh(borderTorusGeo, platformFrameMat); -borderTorus.rotation.x = Math.PI / 2; -borderTorus.castShadow = true; -borderTorus.receiveShadow = true; -glassPlatformGroup.add(borderTorus); - -// Glass tile material — highly transmissive to reveal the void below -const glassTileMat = new THREE.MeshPhysicalMaterial({ - color: new THREE.Color(NEXUS.colors.accent), - transparent: true, - opacity: 0.09, - roughness: 0.0, - metalness: 0.0, - transmission: 0.92, - thickness: 0.06, - side: THREE.DoubleSide, - depthWrite: false, -}); - -// Edge glow — bright accent outline on each tile -const glassEdgeBaseMat = new THREE.LineBasicMaterial({ - color: NEXUS.colors.accent, - transparent: true, - opacity: 0.55, -}); - -const GLASS_TILE_SIZE = 0.85; -const GLASS_TILE_GAP = 0.14; -const GLASS_TILE_STEP = GLASS_TILE_SIZE + GLASS_TILE_GAP; -const GLASS_RADIUS = 4.55; - -const tileGeo = new THREE.PlaneGeometry(GLASS_TILE_SIZE, GLASS_TILE_SIZE); -const tileEdgeGeo = new THREE.EdgesGeometry(tileGeo); - -/** @type {Array<{mat: THREE.LineBasicMaterial, distFromCenter: number}>} */ -const glassEdgeMaterials = []; - -for (let row = -5; row <= 5; row++) { - for (let col = -5; col <= 5; col++) { - const x = col * GLASS_TILE_STEP; - const z = row * GLASS_TILE_STEP; - const distFromCenter = Math.sqrt(x * x + z * z); - if (distFromCenter > GLASS_RADIUS) continue; - - // Transparent glass tile - const tile = new THREE.Mesh(tileGeo, glassTileMat.clone()); - tile.rotation.x = -Math.PI / 2; - tile.position.set(x, 0, z); - glassPlatformGroup.add(tile); - - // Glowing edge lines - const mat = glassEdgeBaseMat.clone(); - const edges = new THREE.LineSegments(tileEdgeGeo, mat); - edges.rotation.x = -Math.PI / 2; - edges.position.set(x, 0.002, z); - glassPlatformGroup.add(edges); - glassEdgeMaterials.push({ mat, distFromCenter }); - } -} - -// Void shimmer — faint point light below the glass, emphasising the infinite depth -const voidLight = new THREE.PointLight(NEXUS.colors.accent, 0.5, 14); -voidLight.position.set(0, -3.5, 0); -glassPlatformGroup.add(voidLight); - -scene.add(glassPlatformGroup); -glassPlatformGroup.traverse(obj => { - if (obj.isMesh) obj.userData.zoomLabel = 'Glass Platform'; -}); - -// === PERLIN NOISE === -// Classic Perlin noise used for procedural terrain generation. - -function createPerlinNoise() { - const p = new Uint8Array(256); - for (let i = 0; i < 256; i++) p[i] = i; - // Fisher-Yates shuffle with a fixed seed sequence for reproducibility - let seed = 42; - function seededRand() { - seed = (seed * 1664525 + 1013904223) & 0xffffffff; - return (seed >>> 0) / 0xffffffff; - } - for (let i = 255; i > 0; i--) { - const j = Math.floor(seededRand() * (i + 1)); - const tmp = p[i]; p[i] = p[j]; p[j] = tmp; - } - const perm = new Uint8Array(512); - for (let i = 0; i < 512; i++) perm[i] = p[i & 255]; - - function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); } - function lerp(a, b, t) { return a + t * (b - a); } - function grad(hash, x, y, z) { - const h = hash & 15; - const u = h < 8 ? x : y; - const v = h < 4 ? y : (h === 12 || h === 14) ? x : z; - return ((h & 1) ? -u : u) + ((h & 2) ? -v : v); - } - - return function noise(x, y, z) { - z = z || 0; - const X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255; - x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z); - const u = fade(x), v = fade(y), w = fade(z); - const A = perm[X] + Y, AA = perm[A] + Z, AB = perm[A + 1] + Z; - const B = perm[X + 1] + Y, BA = perm[B] + Z, BB = perm[B + 1] + Z; - return lerp( - lerp(lerp(grad(perm[AA], x, y, z ), grad(perm[BA], x-1, y, z ), u), - lerp(grad(perm[AB], x, y-1, z ), grad(perm[BB], x-1, y-1, z ), u), v), - lerp(lerp(grad(perm[AA + 1], x, y, z-1), grad(perm[BA + 1], x-1, y, z-1), u), - lerp(grad(perm[AB + 1], x, y-1, z-1), grad(perm[BB + 1], x-1, y-1, z-1), u), v), - w - ); - }; -} - -const perlin = createPerlinNoise(); - -// === FLOATING ISLAND TERRAIN === -// Procedural terrain below the glass platform, shaped like a floating rock island. -// Heights generated via domain-warped fBm (5 octaves) + ridged Perlin noise, -// with 5-zone vertex colours, emissive crystal spires, and a noise-displaced -// rocky underside with stalactite drips. - -(function buildFloatingIsland() { - const ISLAND_RADIUS = 9.5; - const SEGMENTS = 96; - const SIZE = ISLAND_RADIUS * 2; - - // --- Domain-warped fBm --- - // First evaluates a low-frequency warp offset, then runs 5-octave fBm on the - // warped coordinates. A ridged octave is blended in to create sharp ridges. - function islandFBm(nx, nz) { - // Warp pass — displaces sample point for organic shapes - const wx = perlin(nx * 0.5 + 3.7, nz * 0.5 + 1.2) * 0.55; - const wz = perlin(nx * 0.5 + 8.3, nz * 0.5 + 5.9) * 0.55; - const px = nx + wx, pz = nz + wz; - - // 5-octave fBm - let h = 0; - h += perlin(px, pz ) * 1.000; - h += perlin(px * 2, pz * 2 ) * 0.500; - h += perlin(px * 4, pz * 4 ) * 0.250; - h += perlin(px * 8, pz * 8 ) * 0.125; - h += perlin(px * 16, pz * 16 ) * 0.063; - h /= 1.938; // normalise to ~[-1, 1] - - // Ridged octave — 1−|noise| creates sharp ridge lines - const ridge = 1.0 - Math.abs(perlin(px * 3.1 + 5.0, pz * 3.1 + 7.0)); - return h * 0.78 + ridge * 0.22; - } - - const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEGMENTS, SEGMENTS); - geo.rotateX(-Math.PI / 2); - const pos = geo.attributes.position; - const vCount = pos.count; - - // Store heights for colour pass and crystal placement - const rawHeights = new Float32Array(vCount); - - for (let i = 0; i < vCount; i++) { - const x = pos.getX(i); - const z = pos.getZ(i); - const dist = Math.sqrt(x * x + z * z) / ISLAND_RADIUS; - - // Edge taper with slight noise-driven rim undulation - const rimNoise = perlin(x * 0.38 + 10, z * 0.38 + 10) * 0.10; - const edgeFactor = Math.max(0, 1 - Math.pow(Math.max(0, dist - rimNoise), 2.4)); - - const h = islandFBm(x * 0.15, z * 0.15); - const height = ((h + 1) * 0.5) * edgeFactor * 3.2; - pos.setY(i, height); - rawHeights[i] = height; - } - - geo.computeVertexNormals(); - - // --- 5-zone vertex colours --- - // 0: wet dark earth (h < 0.25) - // 1: rocky brown (0.25 – 0.75) - // 2: stone grey (0.75 – 1.4) - // 3: pale limestone (1.4 – 2.2) - // 4: crystal peak (> 2.2) — blue-tinted - const colBuf = new Float32Array(vCount * 3); - for (let i = 0; i < vCount; i++) { - const h = rawHeights[i]; - let r, g, b; - if (h < 0.25) { - r = 0.11; g = 0.09; b = 0.07; - } else if (h < 0.75) { - const t = (h - 0.25) / 0.50; - r = 0.11 + t * 0.13; g = 0.09 + t * 0.09; b = 0.07 + t * 0.06; - } else if (h < 1.4) { - const t = (h - 0.75) / 0.65; - r = 0.24 + t * 0.12; g = 0.18 + t * 0.10; b = 0.13 + t * 0.10; - } else if (h < 2.2) { - const t = (h - 1.4) / 0.80; - r = 0.36 + t * 0.14; g = 0.28 + t * 0.11; b = 0.23 + t * 0.13; - } else { - // Crystal peaks — blue-tinted pale rock - const t = Math.min(1, (h - 2.2) / 0.9); - r = 0.50 + t * 0.05; - g = 0.39 + t * 0.10; - b = 0.36 + t * 0.28; // distinct blue-violet shift - } - colBuf[i * 3] = r; - colBuf[i * 3 + 1] = g; - colBuf[i * 3 + 2] = b; - } - geo.setAttribute('color', new THREE.BufferAttribute(colBuf, 3)); - - const topMat = new THREE.MeshStandardMaterial({ - vertexColors: true, - roughness: 0.86, - metalness: 0.05, - }); - const topMesh = new THREE.Mesh(geo, topMat); - topMesh.castShadow = true; - topMesh.receiveShadow = true; - - // --- Crystal spire formations --- - // Scatter emissive crystal clusters at high terrain points, - // seeded from the same Perlin noise for reproducibility. - const crystalMat = new THREE.MeshStandardMaterial({ - color: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.55), - emissive: new THREE.Color(NEXUS.colors.accent), - emissiveIntensity: 0.5, - roughness: 0.08, - metalness: 0.25, - transparent: true, - opacity: 0.80, - }); - - const CRYSTAL_MIN_H = 2.05; // only spawn on high terrain - const crystalGroup = new THREE.Group(); - - for (let row = -5; row <= 5; row++) { - for (let col = -5; col <= 5; col++) { - const bx = col * 1.75, bz = row * 1.75; - if (Math.sqrt(bx * bx + bz * bz) > ISLAND_RADIUS * 0.72) continue; - - // Evaluate terrain height at this candidate location - const edF = Math.max(0, 1 - Math.pow(Math.sqrt(bx * bx + bz * bz) / ISLAND_RADIUS, 2.4)); - const candidateH = ((islandFBm(bx * 0.15, bz * 0.15) + 1) * 0.5) * edF * 3.2; - if (candidateH < CRYSTAL_MIN_H) continue; - - // Jitter spawn position using noise - const jx = bx + perlin(bx * 0.7 + 20, bz * 0.7 + 20) * 0.55; - const jz = bz + perlin(bx * 0.7 + 30, bz * 0.7 + 30) * 0.55; - if (Math.sqrt(jx * jx + jz * jz) > ISLAND_RADIUS * 0.68) continue; - - // Cluster of 2–4 spires - const clusterSize = 2 + Math.floor(Math.abs(perlin(bx * 0.5 + 40, bz * 0.5 + 40)) * 3); - for (let c = 0; c < clusterSize; c++) { - const angle = (c / clusterSize) * Math.PI * 2 + perlin(bx + c, bz + c) * 1.4; - const spread = 0.08 + Math.abs(perlin(bx + c * 5, bz + c * 5)) * 0.22; - const sx = jx + Math.cos(angle) * spread; - const sz = jz + Math.sin(angle) * spread; - const spireScale = 0.14 + (candidateH - CRYSTAL_MIN_H) * 0.11; - const spireH = spireScale * (0.8 + Math.abs(perlin(sx, sz)) * 0.45); - const spireR = spireH * 0.17; - - const spireGeo = new THREE.ConeGeometry(spireR, spireH * 2.8, 5); - const spire = new THREE.Mesh(spireGeo, crystalMat); - spire.position.set(sx, candidateH + spireH * 0.5, sz); - spire.rotation.z = perlin(sx * 2, sz * 2) * 0.28; - spire.rotation.x = perlin(sx * 3 + 1, sz * 3 + 1) * 0.18; - spire.castShadow = true; - crystalGroup.add(spire); - } - } - } - - // --- Noise-displaced rocky underside with stalactite drips --- - // CylinderGeometry with open top (openEnded=true) so only the tapered side - // wall is visible. Vertices are radially and vertically displaced by Perlin - // noise to break the smooth cylinder into jagged rock. - const BOTTOM_SEGS_R = 52; - const BOTTOM_SEGS_V = 10; - const BOTTOM_HEIGHT = 2.6; - const bottomGeo = new THREE.CylinderGeometry( - ISLAND_RADIUS * 0.80, ISLAND_RADIUS * 0.28, - BOTTOM_HEIGHT, BOTTOM_SEGS_R, BOTTOM_SEGS_V, true - ); - const bPos = bottomGeo.attributes.position; - for (let i = 0; i < bPos.count; i++) { - const bx = bPos.getX(i); - const bz = bPos.getZ(i); - const by = bPos.getY(i); - const angle = Math.atan2(bz, bx); - const r = Math.sqrt(bx * bx + bz * bz); - - // Radial displacement — jagged surface detail - const radDisp = perlin(Math.cos(angle) * 1.6 + 50, Math.sin(angle) * 1.6 + 50) * 0.65; - // Vertical stalactite pull — lower vertices dragged further downward - const vNorm = (by + BOTTOM_HEIGHT * 0.5) / BOTTOM_HEIGHT; // 0=bottom 1=top - const stalDisp = (1 - vNorm) * Math.abs(perlin(bx * 0.35 + 70, by * 0.7 + bz * 0.35)) * 0.9; - - const newR = r + radDisp; - bPos.setX(i, (bx / r) * newR); - bPos.setZ(i, (bz / r) * newR); - bPos.setY(i, by - stalDisp); - } - bottomGeo.computeVertexNormals(); - - const bottomMat = new THREE.MeshStandardMaterial({ color: 0x0c0a08, roughness: 0.93, metalness: 0.02 }); - const bottomMesh = new THREE.Mesh(bottomGeo, bottomMat); - bottomMesh.position.y = -BOTTOM_HEIGHT * 0.5; - bottomMesh.castShadow = true; - - // Bottom cap — seals the underside of the cylinder - const capGeo = new THREE.CircleGeometry(ISLAND_RADIUS * 0.28, 48); - capGeo.rotateX(Math.PI / 2); - const capMesh = new THREE.Mesh(capGeo, bottomMat); - capMesh.position.y = -(BOTTOM_HEIGHT + 0.1); - - const islandGroup = new THREE.Group(); - islandGroup.add(topMesh); - islandGroup.add(crystalGroup); - islandGroup.add(bottomMesh); - islandGroup.add(capMesh); - islandGroup.position.y = -2.8; // float below the glass platform - scene.add(islandGroup); -})(); - -// === PROCEDURAL CLOUD LAYER === -// A volumetric cloud layer below the island, using a custom shader with Perlin noise. - -const CLOUD_LAYER_Y = -6.0; -const CLOUD_DIMENSIONS = 120; -const CLOUD_THICKNESS = 15; -const CLOUD_OPACITY = 0.6; - -const cloudGeometry = new THREE.BoxGeometry(CLOUD_DIMENSIONS, CLOUD_THICKNESS, CLOUD_DIMENSIONS, 8, 4, 8); - -const CloudShader = { - uniforms: { - 'uTime': { value: 0.0 }, - 'uCloudColor': { value: new THREE.Color(0x88bbff) }, - 'uNoiseScale': { value: new THREE.Vector3(0.015, 0.015, 0.015) }, - 'uDensity': { value: 0.8 }, - }, - vertexShader: ` - varying vec3 vWorldPosition; - void main() { - vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - } - `, - fragmentShader: ` - uniform float uTime; - uniform vec3 uCloudColor; - uniform vec3 uNoiseScale; - uniform float uDensity; - varying vec3 vWorldPosition; - - // 3D Simplex noise — Ian McEwan / Stefan Gustavson implementation - vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } - vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } - vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); } - vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } - float snoise(vec3 v) { - const vec2 C = vec2(1.0/6.0, 1.0/3.0); - const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); - vec3 i = floor(v + dot(v, C.yyy)); - vec3 x0 = v - i + dot(i, C.xxx); - vec3 g = step(x0.yzx, x0.xyz); - vec3 l = 1.0 - g; - vec3 i1 = min(g.xyz, l.zxy); - vec3 i2 = max(g.xyz, l.zxy); - vec3 x1 = x0 - i1 + C.xxx; - vec3 x2 = x0 - i2 + C.yyy; - vec3 x3 = x0 - D.yyy; - i = mod289(i); - vec4 p = permute(permute(permute( - i.z + vec4(0.0, i1.z, i2.z, 1.0)) - + i.y + vec4(0.0, i1.y, i2.y, 1.0)) - + i.x + vec4(0.0, i1.x, i2.x, 1.0)); - float n_ = 0.142857142857; - vec3 ns = n_ * D.wyz - D.xzx; - vec4 j = p - 49.0 * floor(p * ns.z * ns.z); - vec4 x_ = floor(j * ns.z); - vec4 y_ = floor(j - 7.0 * x_); - vec4 x = x_ * ns.x + ns.yyyy; - vec4 y = y_ * ns.x + ns.yyyy; - vec4 h = 1.0 - abs(x) - abs(y); - vec4 b0 = vec4(x.xy, y.xy); - vec4 b1 = vec4(x.zw, y.zw); - vec4 s0 = floor(b0) * 2.0 + 1.0; - vec4 s1 = floor(b1) * 2.0 + 1.0; - vec4 sh = -step(h, vec4(0.0)); - vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; - vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; - vec3 p0 = vec3(a0.xy, h.x); - vec3 p1 = vec3(a0.zw, h.y); - vec3 p2 = vec3(a1.xy, h.z); - vec3 p3 = vec3(a1.zw, h.w); - vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3))); - p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w; - vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0); - m = m * m; - return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3))); - } - - void main() { - // Drift clouds slowly over time - vec3 noiseCoord = vWorldPosition * uNoiseScale + vec3(uTime * 0.003, 0.0, uTime * 0.002); - - // fBm: three octaves for wispy detail - float noiseVal = snoise(noiseCoord) * 0.500; - noiseVal += snoise(noiseCoord * 2.0) * 0.250; - noiseVal += snoise(noiseCoord * 4.0) * 0.125; - noiseVal /= 0.875; // normalise to ~[-1, 1] - - // Cloud-like density curve — discard sparse regions - float density = smoothstep(0.25, 0.85, noiseVal * 0.5 + 0.5); - density *= uDensity; - - // Fade out towards top and bottom of the slab - float layerBottom = ${(CLOUD_LAYER_Y - CLOUD_THICKNESS * 0.5).toFixed(1)}; - float yNorm = (vWorldPosition.y - layerBottom) / ${CLOUD_THICKNESS.toFixed(1)}; - float fadeFactor = smoothstep(0.0, 0.15, yNorm) * smoothstep(1.0, 0.85, yNorm); - - gl_FragColor = vec4(uCloudColor, density * fadeFactor * ${CLOUD_OPACITY.toFixed(1)}); - if (gl_FragColor.a < 0.04) discard; - } - `, -}; - -const cloudMaterial = new THREE.ShaderMaterial({ - uniforms: CloudShader.uniforms, - vertexShader: CloudShader.vertexShader, - fragmentShader: CloudShader.fragmentShader, - transparent: true, - depthWrite: false, // Important for proper blending of transparent objects - blending: THREE.AdditiveBlending, // Optional: gives a more ethereal look - side: THREE.DoubleSide, -}); - -const clouds = new THREE.Mesh(cloudGeometry, cloudMaterial); -clouds.position.y = CLOUD_LAYER_Y; -scene.add(clouds); - -// === COMMIT HEATMAP === -// Canvas-texture overlay on the floor. Each agent occupies a polar sector; -// recent commits make that sector glow brighter. Activity decays over 24 h. - -const HEATMAP_SIZE = 512; -const HEATMAP_REFRESH_MS = 5 * 60 * 1000; // 5 min between API polls -const HEATMAP_DECAY_MS = 24 * 60 * 60 * 1000; // 24 h full decay - -// Agent zones — angle in canvas degrees (0 = east/right, clockwise) -const HEATMAP_ZONES = [ - { name: 'Claude', color: [255, 100, 60], authorMatch: /^claude$/i, angleDeg: 0 }, - { name: 'Timmy', color: [ 60, 160, 255], authorMatch: /^timmy/i, angleDeg: 90 }, - { name: 'Kimi', color: [ 60, 255, 140], authorMatch: /^kimi/i, angleDeg: 180 }, - { name: 'Perplexity', color: [200, 60, 255], authorMatch: /^perplexity/i, angleDeg: 270 }, -]; -const HEATMAP_ZONE_SPAN_RAD = Math.PI / 2; // 90° per zone - -const heatmapCanvas = document.createElement('canvas'); -heatmapCanvas.width = HEATMAP_SIZE; -heatmapCanvas.height = HEATMAP_SIZE; -const heatmapTexture = new THREE.CanvasTexture(heatmapCanvas); - -const heatmapMat = new THREE.MeshBasicMaterial({ - map: heatmapTexture, - transparent: true, - opacity: 0.9, - depthWrite: false, - blending: THREE.AdditiveBlending, - side: THREE.DoubleSide, -}); - -const heatmapMesh = new THREE.Mesh( - new THREE.CircleGeometry(GLASS_RADIUS, 64), - heatmapMat -); -heatmapMesh.rotation.x = -Math.PI / 2; -heatmapMesh.position.y = 0.005; -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])); - -/** - * Redraws the heatmap canvas from current zoneIntensity values. - */ -function drawHeatmap() { - const ctx = heatmapCanvas.getContext('2d'); - const cx = HEATMAP_SIZE / 2; - const cy = HEATMAP_SIZE / 2; - const r = cx * 0.96; - - ctx.clearRect(0, 0, HEATMAP_SIZE, HEATMAP_SIZE); - - // Clip drawing to the circular platform boundary - ctx.save(); - ctx.beginPath(); - ctx.arc(cx, cy, r, 0, Math.PI * 2); - ctx.clip(); - - for (const zone of HEATMAP_ZONES) { - const intensity = zoneIntensity[zone.name] || 0; - if (intensity < 0.01) continue; - - const [rr, gg, bb] = zone.color; - const baseRad = zone.angleDeg * (Math.PI / 180); - const startRad = baseRad - HEATMAP_ZONE_SPAN_RAD / 2; - const endRad = baseRad + HEATMAP_ZONE_SPAN_RAD / 2; - - // Glow origin sits at 55% radius in the zone's direction - const gx = cx + Math.cos(baseRad) * r * 0.55; - const gy = cy + Math.sin(baseRad) * r * 0.55; - - const grad = ctx.createRadialGradient(gx, gy, 0, gx, gy, r * 0.75); - grad.addColorStop(0, `rgba(${rr},${gg},${bb},${0.65 * intensity})`); - grad.addColorStop(0.45, `rgba(${rr},${gg},${bb},${0.25 * intensity})`); - grad.addColorStop(1, `rgba(${rr},${gg},${bb},0)`); - - ctx.beginPath(); - ctx.moveTo(cx, cy); - ctx.arc(cx, cy, r, startRad, endRad); - ctx.closePath(); - ctx.fillStyle = grad; - ctx.fill(); - - // Zone label — only when active - if (intensity > 0.05) { - const labelX = cx + Math.cos(baseRad) * r * 0.62; - const labelY = cy + Math.sin(baseRad) * r * 0.62; - ctx.font = `bold ${Math.round(13 * intensity + 7)}px "Courier New", monospace`; - ctx.fillStyle = `rgba(${rr},${gg},${bb},${Math.min(intensity * 1.2, 0.9)})`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(zone.name, labelX, labelY); - } - } - - ctx.restore(); - heatmapTexture.needsUpdate = true; -} - -/** - * Fetches recent commits, maps them to agent zones via author, and redraws. - */ -async function updateHeatmap() { - let commits = []; - try { - const res = await fetch( - 'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=50', - { headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } } - ); - if (res.ok) commits = await res.json(); - } catch { /* silently use zero-activity baseline */ } - - // Feed commit hashes to matrix rain for data-tethered aesthetic - _matrixCommitHashes = commits.slice(0, 20).map(c => (c.sha || '').slice(0, 7)).filter(h => h.length > 0); - - const now = Date.now(); - const rawWeights = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0])); - - for (const commit of commits) { - const author = commit.commit?.author?.name || commit.author?.login || ''; - const ts = new Date(commit.commit?.author?.date || 0).getTime(); - const age = now - ts; - if (age > HEATMAP_DECAY_MS) continue; - const weight = 1 - age / HEATMAP_DECAY_MS; // linear decay - - for (const zone of HEATMAP_ZONES) { - if (zone.authorMatch.test(author)) { - rawWeights[zone.name] += weight; - break; - } - } - } - - // Normalise: 8 recent weighted commits = full brightness - const MAX_WEIGHT = 8; - for (const zone of HEATMAP_ZONES) { - zoneIntensity[zone.name] = Math.min(rawWeights[zone.name] / MAX_WEIGHT, 1.0); - } - - drawHeatmap(); -} - -// Kick off and schedule periodic refresh -updateHeatmap(); -setInterval(updateHeatmap, HEATMAP_REFRESH_MS); - -// === TIMMY SIGIL === -// Animated sacred-geometry floor sigil — Metatron's Cube / Flower of Life. -// Three layers: painted canvas base + three counter-rotating glow rings. - -const SIGIL_CANVAS_SIZE = 512; -const SIGIL_RADIUS = 3.8; // world-space radius (fits inside the glass platform) - -/** - * Draws the Timmy sigil onto a canvas and returns it. - * Pattern: Flower of Life circles, dual hexagram triangles, concentric rings, 12-spoke radials. - * @returns {HTMLCanvasElement} - */ -function drawSigilCanvas() { - const canvas = document.createElement('canvas'); - canvas.width = SIGIL_CANVAS_SIZE; - canvas.height = SIGIL_CANVAS_SIZE; - const ctx = canvas.getContext('2d'); - const cx = SIGIL_CANVAS_SIZE / 2; - const cy = SIGIL_CANVAS_SIZE / 2; - const r = cx * 0.88; // outer drawing radius - - ctx.clearRect(0, 0, SIGIL_CANVAS_SIZE, SIGIL_CANVAS_SIZE); - - // Faint radial background glow - const bgGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); - bgGrad.addColorStop(0, 'rgba(0, 200, 255, 0.10)'); - bgGrad.addColorStop(0.5, 'rgba(0, 100, 200, 0.04)'); - bgGrad.addColorStop(1, 'rgba(0, 0, 0, 0)'); - ctx.fillStyle = bgGrad; - ctx.fillRect(0, 0, SIGIL_CANVAS_SIZE, SIGIL_CANVAS_SIZE); - - /** Draws a single circle outline with glow. */ - function glowCircle(x, y, radius, color, alpha, lineW) { - ctx.save(); - ctx.globalAlpha = alpha; - ctx.strokeStyle = color; - ctx.lineWidth = lineW; - ctx.shadowColor = color; - ctx.shadowBlur = 12; - ctx.beginPath(); - ctx.arc(x, y, radius, 0, Math.PI * 2); - ctx.stroke(); - ctx.restore(); - } - - /** Draws two interlocking triangles (Star of David). */ - function hexagram(ox, oy, hr, color, alpha) { - ctx.save(); - ctx.globalAlpha = alpha; - ctx.strokeStyle = color; - ctx.lineWidth = 1.4; - ctx.shadowColor = color; - ctx.shadowBlur = 10; - // Upward-pointing triangle - ctx.beginPath(); - for (let i = 0; i < 3; i++) { - const a = (i / 3) * Math.PI * 2 - Math.PI / 2; - const px = ox + Math.cos(a) * hr; - const py = oy + Math.sin(a) * hr; - i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); - } - ctx.closePath(); - ctx.stroke(); - // Downward-pointing triangle - ctx.beginPath(); - for (let i = 0; i < 3; i++) { - const a = (i / 3) * Math.PI * 2 + Math.PI / 2; - const px = ox + Math.cos(a) * hr; - const py = oy + Math.sin(a) * hr; - i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); - } - ctx.closePath(); - ctx.stroke(); - ctx.restore(); - } - - const petalR = r * 0.32; // radius of each Flower-of-Life petal - - // Flower of Life — center circle - glowCircle(cx, cy, petalR, '#00ccff', 0.65, 1.0); - - // First ring of 6 petals - for (let i = 0; i < 6; i++) { - const a = (i / 6) * Math.PI * 2; - glowCircle(cx + Math.cos(a) * petalR, cy + Math.sin(a) * petalR, petalR, '#00aadd', 0.50, 0.8); - } - - // Second ring of 6 petals (rotated 30°) - for (let i = 0; i < 6; i++) { - const a = (i / 6) * Math.PI * 2 + Math.PI / 6; - glowCircle(cx + Math.cos(a) * petalR * 1.73, cy + Math.sin(a) * petalR * 1.73, petalR, '#0077aa', 0.25, 0.6); - } - - // Dual hexagram — outer (gold) and inner (amber) - hexagram(cx, cy, r * 0.62, '#ffd700', 0.75); - hexagram(cx, cy, r * 0.41, '#ffaa00', 0.50); - - // Concentric rings - glowCircle(cx, cy, r * 0.92, '#0055aa', 0.40, 0.8); - glowCircle(cx, cy, r * 0.72, '#0099cc', 0.38, 0.8); - glowCircle(cx, cy, r * 0.52, '#00ccff', 0.42, 0.9); - glowCircle(cx, cy, r * 0.18, '#ffd700', 0.65, 1.2); - - // 12-spoke radial lines - ctx.save(); - ctx.globalAlpha = 0.28; - ctx.strokeStyle = '#00aaff'; - ctx.lineWidth = 0.6; - ctx.shadowColor = '#00aaff'; - ctx.shadowBlur = 5; - for (let i = 0; i < 12; i++) { - const a = (i / 12) * Math.PI * 2; - ctx.beginPath(); - ctx.moveTo(cx + Math.cos(a) * r * 0.18, cy + Math.sin(a) * r * 0.18); - ctx.lineTo(cx + Math.cos(a) * r * 0.91, cy + Math.sin(a) * r * 0.91); - ctx.stroke(); - } - ctx.restore(); - - // Dot marks at spoke tips - ctx.save(); - ctx.fillStyle = '#00ffcc'; - ctx.shadowColor = '#00ffcc'; - ctx.shadowBlur = 9; - for (let i = 0; i < 12; i++) { - const a = (i / 12) * Math.PI * 2; - ctx.globalAlpha = i % 2 === 0 ? 0.80 : 0.50; - ctx.beginPath(); - ctx.arc(cx + Math.cos(a) * r * 0.91, cy + Math.sin(a) * r * 0.91, i % 2 === 0 ? 4 : 2.5, 0, Math.PI * 2); - ctx.fill(); - } - ctx.restore(); - - // Center point of light - ctx.save(); - ctx.globalAlpha = 1.0; - ctx.fillStyle = '#ffffff'; - ctx.shadowColor = '#88ddff'; - ctx.shadowBlur = 18; - ctx.beginPath(); - ctx.arc(cx, cy, 5, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - - return canvas; -} - -const sigilTexture = new THREE.CanvasTexture(drawSigilCanvas()); - -const sigilMat = new THREE.MeshBasicMaterial({ - map: sigilTexture, - transparent: true, - opacity: 0.80, - depthWrite: false, - blending: THREE.AdditiveBlending, - side: THREE.DoubleSide, -}); - -const sigilMesh = new THREE.Mesh( - new THREE.CircleGeometry(SIGIL_RADIUS, 128), - sigilMat -); -sigilMesh.rotation.x = -Math.PI / 2; -sigilMesh.position.y = 0.010; -sigilMesh.userData.zoomLabel = 'Timmy Sigil'; -scene.add(sigilMesh); - -// Outer glow ring — rotates clockwise -const sigilRing1Mat = new THREE.MeshBasicMaterial({ - color: 0x00ccff, - transparent: true, - opacity: 0.45, - depthWrite: false, - blending: THREE.AdditiveBlending, -}); -const sigilRing1 = new THREE.Mesh( - new THREE.TorusGeometry(SIGIL_RADIUS * 0.965, 0.025, 6, 96), - sigilRing1Mat -); -sigilRing1.rotation.x = Math.PI / 2; -sigilRing1.position.y = 0.012; -scene.add(sigilRing1); - -// Middle ring — rotates counter-clockwise, gold -const sigilRing2Mat = new THREE.MeshBasicMaterial({ - color: 0xffd700, - transparent: true, - opacity: 0.40, - depthWrite: false, - blending: THREE.AdditiveBlending, -}); -const sigilRing2 = new THREE.Mesh( - new THREE.TorusGeometry(SIGIL_RADIUS * 0.62, 0.020, 6, 72), - sigilRing2Mat -); -sigilRing2.rotation.x = Math.PI / 2; -sigilRing2.position.y = 0.013; -scene.add(sigilRing2); - -// Inner ring — rotates clockwise, teal -const sigilRing3Mat = new THREE.MeshBasicMaterial({ - color: 0x00ffcc, - transparent: true, - opacity: 0.35, - depthWrite: false, - blending: THREE.AdditiveBlending, -}); -const sigilRing3 = new THREE.Mesh( - new THREE.TorusGeometry(SIGIL_RADIUS * 0.78, 0.018, 6, 80), - sigilRing3Mat -); -sigilRing3.rotation.x = Math.PI / 2; -sigilRing3.position.y = 0.011; -scene.add(sigilRing3); - -// Subtle floor glow light below the sigil -const sigilLight = new THREE.PointLight(0x0088ff, 0.4, 8); -sigilLight.position.set(0, 0.5, 0); -scene.add(sigilLight); - -// === MOUSE-DRIVEN ROTATION === -let mouseX = 0; -let mouseY = 0; -let targetRotX = 0; -let targetRotY = 0; - -document.addEventListener('mousemove', (/** @type {MouseEvent} */ e) => { +// --- 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 — bird's-eye view of the whole Nexus) === -let overviewMode = false; -let overviewT = 0; // 0 = normal view, 1 = overview - +// ─── 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); // overhead; tiny Z offset avoids gimbal lock - +const OVERVIEW_CAM = new THREE.Vector3(0, 200, 0.1); const overviewIndicator = document.getElementById('overview-indicator'); -document.addEventListener('keydown', (e) => { - if (e.key === 'Tab') { - e.preventDefault(); - overviewMode = !overviewMode; - if (overviewMode) { - overviewIndicator.classList.add('visible'); - } else { - overviewIndicator.classList.remove('visible'); - } - } -}); - -// === ZOOM-TO-OBJECT === +// ─── 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; -let zoomTargetT = 0; -let zoomActive = false; - +let zoomT = 0, zoomTargetT = 0, zoomActive = false; const zoomIndicator = document.getElementById('zoom-indicator'); const zoomLabelEl = document.getElementById('zoom-label'); -function getZoomLabel(/** @type {THREE.Object3D} */ obj) { - let o = /** @type {THREE.Object3D|null} */ (obj); - while (o) { - if (o.userData && o.userData.zoomLabel) return o.userData.zoomLabel; - o = o.parent; - } +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; + zoomTargetT = 0; zoomActive = false; if (zoomIndicator) zoomIndicator.classList.remove('visible'); } -renderer.domElement.addEventListener('dblclick', (/** @type {MouseEvent} */ 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'); -}); - -document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') exitZoom(); -}); - -// === PHOTO MODE === +// ─── Photo mode (P) ─── let photoMode = false; - -// Warp effect state -let isWarping = false; -let warpStartTime = 0; -const WARP_DURATION = 2.2; // seconds -let warpDestinationUrl = null; -let warpPortalColor = new THREE.Color(0x4488ff); -let warpNavigated = false; - -// Post-processing composer for depth of field (always-on, subtle) -const composer = new EffectComposer(renderer); -composer.addPass(new RenderPass(scene, camera)); - -const bokehPass = new BokehPass(scene, camera, { - focus: 5.0, - aperture: 0.00015, - maxblur: 0.004, -}); -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; - const photoIndicator = document.getElementById('photo-indicator'); const photoFocusDisplay = document.getElementById('photo-focus'); - -/** - * Updates the photo mode focus distance display. - */ function updateFocusDisplay() { - if (photoFocusDisplay) { - photoFocusDisplay.textContent = bokehPass.uniforms['focus'].value.toFixed(1); - } + if (photoFocusDisplay) photoFocusDisplay.textContent = bokehPass.uniforms['focus'].value.toFixed(1); } -document.addEventListener('keydown', (e) => { - 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) { - // Enhanced DoF in photo mode - bokehPass.uniforms['aperture'].value = 0.0003; - bokehPass.uniforms['maxblur'].value = 0.008; - // Sync orbit target to current look-at - orbitControls.target.set(0, 0, 0); - orbitControls.update(); - updateFocusDisplay(); - } else { - // Restore subtle ambient DoF - bokehPass.uniforms['aperture'].value = 0.00015; - bokehPass.uniforms['maxblur'].value = 0.004; - } - } +// ─── Sovereignty cheat code ─── +const SOVEREIGNTY_WORD = 'sovereignty'; +let sovereigntyBuffer = ''; +let sovereigntyBufferTimer = null; - // Adjust focus with [ ] while in photo mode - if (photoMode) { - const focusStep = 0.5; - if (e.key === '[') { - bokehPass.uniforms['focus'].value = Math.max(0.5, bokehPass.uniforms['focus'].value - focusStep); - updateFocusDisplay(); - } else if (e.key === ']') { - bokehPass.uniforms['focus'].value = Math.min(200, bokehPass.uniforms['focus'].value + focusStep); - updateFocusDisplay(); - } - } -}); +// ─── Debug mode ─── +let debugMode = false; -// === ANIMATION FOR ENERGY BEAM PULSE === -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 pulseEffect = Math.sin(energyBeamPulse) * 0.15 * agentIntensity; - energyBeamMaterial.opacity = agentIntensity * 0.6 + pulseEffect; -} - - // === RESIZE HANDLER === - -window.addEventListener('resize', () => { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); - renderer.setSize(window.innerWidth, window.innerHeight); - composer.setSize(window.innerWidth, window.innerHeight); -}); - -// === SOVEREIGNTY METER === - -// Holographic arc gauge floating above the platform; reads from sovereignty-status.json -const sovereigntyGroup = new THREE.Group(); -sovereigntyGroup.position.set(0, 3.8, 0); - -// Background ring — full circle, dark frame -const meterBgGeo = new THREE.TorusGeometry(1.6, 0.1, 8, 64); -const meterBgMat = new THREE.MeshBasicMaterial({ color: 0x0a1828, transparent: true, opacity: 0.5 }); -sovereigntyGroup.add(new THREE.Mesh(meterBgGeo, meterBgMat)); - -let sovereigntyScore = 85; -let sovereigntyLabel = 'Mostly Sovereign'; - -function sovereigntyHexColor(score) { - if (score >= 80) return 0x00ff88; - if (score >= 40) return 0xffcc00; - return 0xff4444; -} - -function buildScoreArcGeo(score) { - return new THREE.TorusGeometry(1.6, 0.1, 8, 64, (score / 100) * Math.PI * 2); -} - -const scoreArcMat = new THREE.MeshBasicMaterial({ - color: sovereigntyHexColor(sovereigntyScore), - transparent: true, - opacity: 0.9, -}); -const scoreArcMesh = new THREE.Mesh(buildScoreArcGeo(sovereigntyScore), scoreArcMat); -scoreArcMesh.rotation.z = Math.PI / 2; // arc starts at 12 o'clock -sovereigntyGroup.add(scoreArcMesh); - -// Glow light at gauge center -const meterLight = new THREE.PointLight(sovereigntyHexColor(sovereigntyScore), 0.7, 6); -sovereigntyGroup.add(meterLight); - -function buildMeterTexture(score, label, assessmentType) { - const canvas = document.createElement('canvas'); - canvas.width = 256; - canvas.height = 128; - const ctx = canvas.getContext('2d'); - const hexStr = score >= 80 ? '#00ff88' : score >= 40 ? '#ffcc00' : '#ff4444'; - ctx.clearRect(0, 0, 256, 128); - ctx.font = 'bold 52px "Courier New", monospace'; - ctx.fillStyle = hexStr; - ctx.textAlign = 'center'; - ctx.fillText(`${score}%`, 128, 50); - ctx.font = '16px "Courier New", monospace'; - ctx.fillStyle = '#8899bb'; - ctx.fillText(label.toUpperCase(), 128, 74); - ctx.font = '11px "Courier New", monospace'; - ctx.fillStyle = '#445566'; - ctx.fillText('SOVEREIGNTY', 128, 94); - // "MANUAL ASSESSMENT" label — honest about data source - ctx.font = '9px "Courier New", monospace'; - ctx.fillStyle = '#334455'; - ctx.fillText(assessmentType === 'MANUAL' ? 'MANUAL ASSESSMENT' : 'MANUAL ASSESSMENT', 128, 112); - return new THREE.CanvasTexture(canvas); -} - -const meterSpriteMat = new THREE.SpriteMaterial({ - map: buildMeterTexture(sovereigntyScore, sovereigntyLabel, 'MANUAL'), - transparent: true, - depthWrite: false, -}); -const meterSprite = new THREE.Sprite(meterSpriteMat); -meterSprite.scale.set(3.2, 1.6, 1); -sovereigntyGroup.add(meterSprite); - -scene.add(sovereigntyGroup); -sovereigntyGroup.traverse(obj => { - if (obj.isMesh || obj.isSprite) obj.userData.zoomLabel = 'Sovereignty Meter'; -}); - -async function loadSovereigntyStatus() { - try { - const res = await fetch('./sovereignty-status.json'); - if (!res.ok) throw new Error('not found'); - const data = await res.json(); - const score = Math.max(0, Math.min(100, typeof data.score === 'number' ? data.score : 85)); - const label = typeof data.label === 'string' ? data.label : ''; - sovereigntyScore = score; - sovereigntyLabel = label; - scoreArcMesh.geometry.dispose(); - scoreArcMesh.geometry = buildScoreArcGeo(score); - const col = sovereigntyHexColor(score); - scoreArcMat.color.setHex(col); - meterLight.color.setHex(col); - if (meterSpriteMat.map) meterSpriteMat.map.dispose(); - const assessmentType = data.assessment_type || 'MANUAL'; - meterSpriteMat.map = buildMeterTexture(score, label, assessmentType); - meterSpriteMat.needsUpdate = true; - } catch { - // defaults already set above - } -} - -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 -const ENERGY_BEAM_RADIUS = 0.2; -const ENERGY_BEAM_HEIGHT = 50; -const ENERGY_BEAM_Y = 0; -const ENERGY_BEAM_X = -10; -const ENERGY_BEAM_Z = -10; - -const energyBeamGeometry = new THREE.CylinderGeometry(ENERGY_BEAM_RADIUS, ENERGY_BEAM_RADIUS * 2.5, ENERGY_BEAM_HEIGHT, 32, 16, true); -const energyBeamMaterial = new THREE.MeshBasicMaterial({ - color: NEXUS.colors.accent, - emissive: NEXUS.colors.accent, - emissiveIntensity: 0.8, - transparent: true, - opacity: 0.6, - blending: THREE.AdditiveBlending, - side: THREE.DoubleSide, - depthWrite: false -}); -const energyBeam = new THREE.Mesh(energyBeamGeometry, energyBeamMaterial); -energyBeam.position.set(ENERGY_BEAM_X, ENERGY_BEAM_Y + ENERGY_BEAM_HEIGHT / 2, ENERGY_BEAM_Z); -scene.add(energyBeam); - - -// === RUNE RING === -// Rune sprites tethered to portal data — count matches portals, colors from portals.json. - -let RUNE_COUNT = 12; // default, updated when portals load -const RUNE_RING_RADIUS = 7.0; -const RUNE_RING_Y = 1.5; // base height above platform -const RUNE_ORBIT_SPEED = 0.08; // radians per second - -const ELDER_FUTHARK = ['ᚠ','ᚢ','ᚦ','ᚨ','ᚱ','ᚲ','ᚷ','ᚹ','ᚺ','ᚾ','ᛁ','ᛃ']; -const RUNE_GLOW_COLORS = ['#00ffcc', '#ff44ff']; // fallback, overridden by portal colors - -/** - * Creates a canvas texture for a single glowing rune glyph. - * @param {string} glyph - * @param {string} color - * @returns {THREE.CanvasTexture} - */ -function createRuneTexture(glyph, color) { - const W = 128, H = 128; - const canvas = document.createElement('canvas'); - canvas.width = W; - canvas.height = H; - const ctx = canvas.getContext('2d'); - - ctx.clearRect(0, 0, W, H); - - // Outer glow - ctx.shadowColor = color; - ctx.shadowBlur = 28; - - ctx.font = 'bold 78px serif'; - ctx.fillStyle = color; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(glyph, W / 2, H / 2); - - return new THREE.CanvasTexture(canvas); -} - -// Faint torus marking the orbit height -const runeOrbitRingGeo = new THREE.TorusGeometry(RUNE_RING_RADIUS, 0.03, 6, 64); -const runeOrbitRingMat = new THREE.MeshBasicMaterial({ - color: 0x224466, - transparent: true, - opacity: 0.22, -}); -const runeOrbitRingMesh = new THREE.Mesh(runeOrbitRingGeo, runeOrbitRingMat); -runeOrbitRingMesh.rotation.x = Math.PI / 2; -runeOrbitRingMesh.position.y = RUNE_RING_Y; -scene.add(runeOrbitRingMesh); - -/** - * @type {Array<{sprite: THREE.Sprite, baseAngle: number, floatPhase: number, portalOnline: boolean}>} - */ -const runeSprites = []; - -/** - * Rebuilds rune ring from portal data — count matches portals, colors from portals.json. - * Falls back to default 12 runes if portals not yet loaded. - */ -function rebuildRuneRing() { - // Remove existing rune sprites - for (const rune of runeSprites) { - scene.remove(rune.sprite); - if (rune.sprite.material.map) rune.sprite.material.map.dispose(); - rune.sprite.material.dispose(); - } - runeSprites.length = 0; - - const portalData = portals.length > 0 ? portals : null; - const count = portalData ? portalData.length : RUNE_COUNT; - - for (let i = 0; i < count; i++) { - const glyph = ELDER_FUTHARK[i % ELDER_FUTHARK.length]; - const color = portalData ? portalData[i].color : RUNE_GLOW_COLORS[i % RUNE_GLOW_COLORS.length]; - const isOnline = portalData ? portalData[i].status === 'online' : true; - const texture = createRuneTexture(glyph, color); - - const runeMat = new THREE.SpriteMaterial({ - map: texture, - transparent: true, - opacity: isOnline ? 1.0 : 0.15, // bright if online, dim if offline - depthWrite: false, - blending: THREE.AdditiveBlending, - }); - const sprite = new THREE.Sprite(runeMat); - sprite.scale.set(1.3, 1.3, 1); - - const baseAngle = (i / count) * Math.PI * 2; - sprite.position.set( - Math.cos(baseAngle) * RUNE_RING_RADIUS, - RUNE_RING_Y, - Math.sin(baseAngle) * RUNE_RING_RADIUS - ); - scene.add(sprite); - runeSprites.push({ sprite, baseAngle, floatPhase: (i / count) * Math.PI * 2, portalOnline: isOnline }); - } -} - -// Initial build with default count (will be rebuilt when portals load) -rebuildRuneRing(); - - -// === HOLOGRAPHIC EARTH === -// A procedural holographic planet Earth slowly rotating above the Nexus. -// Continents rendered via layered simplex noise on UV-sphere coords. -// Holographic effects: scan lines, fresnel rim glow, lat/lon grid overlay. - -const EARTH_RADIUS = 2.8; -const EARTH_Y = 20.0; -const EARTH_ROTATION_SPEED = 0.035; // radians per second — gentle drift -const EARTH_AXIAL_TILT = 23.4 * (Math.PI / 180); - -const earthGroup = new THREE.Group(); -earthGroup.position.set(0, EARTH_Y, 0); -earthGroup.rotation.z = EARTH_AXIAL_TILT; -scene.add(earthGroup); - -// Surface shader — continents via noise, holographic scan lines, fresnel rim -const earthSurfaceMat = new THREE.ShaderMaterial({ - uniforms: { - uTime: { value: 0.0 }, - uOceanColor: { value: new THREE.Color(0x003d99) }, - uLandColor: { value: new THREE.Color(0x1a5c2a) }, - uGlowColor: { value: new THREE.Color(NEXUS.colors.accent) }, - }, - vertexShader: ` - varying vec3 vNormal; - varying vec3 vWorldPos; - varying vec2 vUv; - void main() { - vNormal = normalize(normalMatrix * normal); - vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz; - vUv = uv; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - } - `, - fragmentShader: ` - uniform float uTime; - uniform vec3 uOceanColor; - uniform vec3 uLandColor; - uniform vec3 uGlowColor; - varying vec3 vNormal; - varying vec3 vWorldPos; - varying vec2 vUv; - - // Simplex noise (3-D) - vec3 _m3(vec3 x){ return x - floor(x*(1./289.))*289.; } - vec4 _m4(vec4 x){ return x - floor(x*(1./289.))*289.; } - vec4 _p4(vec4 x){ return _m4((x*34.+1.)*x); } - float snoise(vec3 v){ - const vec2 C = vec2(1./6., 1./3.); - vec3 i = floor(v + dot(v, C.yyy)); - vec3 x0 = v - i + dot(i, C.xxx); - vec3 g = step(x0.yzx, x0.xyz); - vec3 l = 1.0 - g; - vec3 i1 = min(g.xyz, l.zxy); - vec3 i2 = max(g.xyz, l.zxy); - vec3 x1 = x0 - i1 + C.xxx; - vec3 x2 = x0 - i2 + C.yyy; - vec3 x3 = x0 - 0.5; - i = _m3(i); - vec4 p = _p4(_p4(_p4( - i.z+vec4(0.,i1.z,i2.z,1.))+ - i.y+vec4(0.,i1.y,i2.y,1.))+ - i.x+vec4(0.,i1.x,i2.x,1.)); - float n_ = .142857142857; - vec3 ns = n_*vec3(2.,0.,-1.)+vec3(0.,-.5,1.); - vec4 j = p - 49.*floor(p*ns.z*ns.z); - vec4 x_ = floor(j*ns.z); - vec4 y_ = floor(j - 7.*x_); - vec4 h = 1. - abs(x_*(2./7.)) - abs(y_*(2./7.)); - vec4 b0 = vec4(x_.xy,y_.xy)*(2./7.); - vec4 b1 = vec4(x_.zw,y_.zw)*(2./7.); - vec4 s0 = floor(b0)*2.+1.; vec4 s1 = floor(b1)*2.+1.; - vec4 sh = -step(h, vec4(0.)); - vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy; - vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww; - vec3 p0=vec3(a0.xy,h.x); vec3 p1=vec3(a0.zw,h.y); - vec3 p2=vec3(a1.xy,h.z); vec3 p3=vec3(a1.zw,h.w); - vec4 nm = max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.); - vec4 nr = 1.79284291400159-0.85373472095314*nm; - p0*=nr.x; p1*=nr.y; p2*=nr.z; p3*=nr.w; - nm = nm*nm; - return 42.*dot(nm*nm, vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3))); - } - - void main() { - vec3 n = normalize(vNormal); - vec3 vd = normalize(cameraPosition - vWorldPos); - - // Stable spherical sample coords from UV (fixed to sphere surface) - float lat = (vUv.y - 0.5) * 3.14159265; - float lon = vUv.x * 6.28318530; - vec3 sp = vec3(cos(lat)*cos(lon), sin(lat), cos(lat)*sin(lon)); - - // Layered noise for continent shapes - float c = snoise(sp*1.8)*0.60 - + snoise(sp*3.6)*0.30 - + snoise(sp*7.2)*0.10; - float land = smoothstep(0.05, 0.30, c); - - // Surface colour: ocean / land, holographic-tinted - vec3 surf = mix(uOceanColor, uLandColor, land); - surf = mix(surf, uGlowColor * 0.45, 0.38); - - // Scan lines (horizontal bands) - float scan = 0.5 + 0.5*sin(vUv.y * 220.0 + uTime * 1.8); - scan = smoothstep(0.30, 0.70, scan) * 0.14; - - // Fresnel rim glow - float fresnel = pow(1.0 - max(dot(n, vd), 0.0), 4.0); - - vec3 col = surf + scan*uGlowColor*0.9 + fresnel*uGlowColor*1.5; - float alpha = 0.48 + fresnel * 0.42; - - gl_FragColor = vec4(col, alpha); - } - `, - transparent: true, - depthWrite: false, - side: THREE.FrontSide, -}); - -const earthSphere = new THREE.SphereGeometry(EARTH_RADIUS, 64, 32); -const earthMesh = new THREE.Mesh(earthSphere, earthSurfaceMat); -earthMesh.userData.zoomLabel = 'Planet Earth'; -earthGroup.add(earthMesh); - -// Lat/lon grid lines -(function buildEarthGrid() { - const lineMat = new THREE.LineBasicMaterial({ - color: 0x2266bb, - transparent: true, - opacity: 0.30, - }); - const r = EARTH_RADIUS + 0.015; - const SEG = 64; - - // Latitude rings every 30° - for (let lat = -60; lat <= 60; lat += 30) { - const phi = lat * (Math.PI / 180); - const pts = []; - for (let i = 0; i <= SEG; i++) { - const th = (i / SEG) * Math.PI * 2; - pts.push(new THREE.Vector3( - Math.cos(phi) * Math.cos(th) * r, - Math.sin(phi) * r, - Math.cos(phi) * Math.sin(th) * r - )); - } - earthGroup.add(new THREE.Line( - new THREE.BufferGeometry().setFromPoints(pts), lineMat - )); - } - - // Longitude meridians every 30° - for (let lon = 0; lon < 360; lon += 30) { - const th = lon * (Math.PI / 180); - const pts = []; - for (let i = 0; i <= SEG; i++) { - const phi = (i / SEG) * Math.PI - Math.PI / 2; - pts.push(new THREE.Vector3( - Math.cos(phi) * Math.cos(th) * r, - Math.sin(phi) * r, - Math.cos(phi) * Math.sin(th) * r - )); - } - earthGroup.add(new THREE.Line( - new THREE.BufferGeometry().setFromPoints(pts), lineMat - )); - } -})(); - -// Atmosphere shell — soft additive glow around the globe -const atmMat = new THREE.MeshBasicMaterial({ - color: 0x1144cc, - transparent: true, - opacity: 0.07, - side: THREE.BackSide, - depthWrite: false, - blending: THREE.AdditiveBlending, -}); -earthGroup.add(new THREE.Mesh( - new THREE.SphereGeometry(EARTH_RADIUS * 1.14, 32, 16), atmMat -)); - -// Soft blue point light emanating from Earth -const earthGlowLight = new THREE.PointLight(NEXUS.colors.accent, 0.4, 25); -earthGroup.add(earthGlowLight); - -earthGroup.traverse(obj => { - if (obj.isMesh || obj.isLine) obj.userData.zoomLabel = 'Planet Earth'; -}); - -// Tether beam — faint line connecting Earth to the Nexus platform center -(function buildEarthTetherBeam() { - const pts = [ - new THREE.Vector3(0, EARTH_Y - EARTH_RADIUS * 1.15, 0), - new THREE.Vector3(0, 0.5, 0), - ]; - const beamGeo = new THREE.BufferGeometry().setFromPoints(pts); - const beamMat = new THREE.LineBasicMaterial({ - color: NEXUS.colors.accent, - transparent: true, - opacity: 0.08, - depthWrite: false, - blending: THREE.AdditiveBlending, - }); - scene.add(new THREE.Line(beamGeo, beamMat)); -})(); - -// === WARP TUNNEL EFFECT === -const WarpShader = { - uniforms: { - 'tDiffuse': { value: null }, - 'time': { value: 0.0 }, - 'progress': { value: 0.0 }, - 'portalColor': { value: new THREE.Color(0x4488ff) }, - }, - - vertexShader: ` - varying vec2 vUv; - void main() { - vUv = uv; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - } - `, - - fragmentShader: ` - uniform sampler2D tDiffuse; - uniform float time; - uniform float progress; - uniform vec3 portalColor; - varying vec2 vUv; - - #define PI 3.14159265358979 - - void main() { - vec2 uv = vUv; - vec2 center = vec2(0.5, 0.5); - vec2 dir = uv - center; - float dist = length(dir); - float angle = atan(dir.y, dir.x); - - // Bell-curve intensity peaks at progress=0.5 - float intensity = sin(progress * PI); - - // === ZOOM: pull scene into the vortex mouth === - float zoom = 1.0 + intensity * 3.0; - vec2 zoomedUV = center + dir / zoom; - - // === SWIRL: spiral twist increasing with intensity === - float swirl = intensity * 5.0 * max(0.0, 1.0 - dist * 2.0); - float twisted = angle + swirl; - vec2 swirlUV = center + vec2(cos(twisted), sin(twisted)) * dist / (1.0 + intensity * 1.8); - - // Blend zoom and swirl - vec2 warpUV = mix(zoomedUV, swirlUV, 0.6); - warpUV = clamp(warpUV, vec2(0.001), vec2(0.999)); - - // === CHROMATIC ABERRATION at peak === - float aber = intensity * 0.018; - vec2 aberDir = normalize(dir + vec2(0.001)); - float rVal = texture2D(tDiffuse, clamp(warpUV + aberDir * aber, vec2(0.0), vec2(1.0))).r; - float gVal = texture2D(tDiffuse, warpUV).g; - float bVal = texture2D(tDiffuse, clamp(warpUV - aberDir * aber, vec2(0.0), vec2(1.0))).b; - vec4 color = vec4(rVal, gVal, bVal, 1.0); - - // === SPEED LINES: radial streaks flying past === - float numLines = 28.0; - float lineAngleFrac = fract((angle / (2.0 * PI) + 0.5) * numLines + time * 4.0); - float lineSharp = pow(max(0.0, 1.0 - abs(lineAngleFrac - 0.5) * 16.0), 3.0); - float radialFade = max(0.0, 1.0 - dist * 2.2); - float speedLine = lineSharp * radialFade * intensity * 1.8; - - // Secondary slower counter-rotating streaks - float lineAngleFrac2 = fract((angle / (2.0 * PI) + 0.5) * 14.0 - time * 2.5); - float lineSharp2 = pow(max(0.0, 1.0 - abs(lineAngleFrac2 - 0.5) * 12.0), 3.0); - float speedLine2 = lineSharp2 * radialFade * intensity * 0.9; - - // === TUNNEL RIM GLOW: bright ring at vortex edge === - float rimDist = abs(dist - 0.08 * intensity); - float rimGlow = pow(max(0.0, 1.0 - rimDist * 40.0), 2.0) * intensity; - - // === PORTAL COLOR TINT === - color.rgb = mix(color.rgb, portalColor, intensity * 0.45); - - // Speed lines in portal color - color.rgb += portalColor * (speedLine + speedLine2); - color.rgb += vec3(1.0) * rimGlow * 0.8; - - // === VORTEX CENTER BLOOM === - float bloom = pow(max(0.0, 1.0 - dist / (0.18 * intensity + 0.001)), 2.0) * intensity; - color.rgb += portalColor * bloom * 2.5 + vec3(1.0) * bloom * 0.6; - - // === EDGE DARKNESS (tunnel walls) === - float vignette = smoothstep(0.5, 0.2, dist) * intensity * 0.5; - color.rgb *= 1.0 - vignette * 0.4; - - // === WHITE FLASH at the moment of crossing === - float flash = smoothstep(0.82, 1.0, progress); - color.rgb = mix(color.rgb, vec3(1.0), flash); - - gl_FragColor = color; - } - `, -}; - -let warpPass = new ShaderPass(WarpShader); -warpPass.enabled = false; -composer.addPass(warpPass); - - -/** - * Triggers the warp tunnel effect. - * @param {THREE.Mesh|null} portalMesh - The portal mesh being entered (for color + URL) - */ -function startWarp(portalMesh) { - isWarping = true; - warpNavigated = false; - warpStartTime = clock.getElapsedTime(); - warpPass.enabled = true; - warpPass.uniforms['time'].value = 0.0; - warpPass.uniforms['progress'].value = 0.0; - - if (portalMesh) { - warpDestinationUrl = portalMesh.userData.destinationUrl || null; - warpPortalColor = portalMesh.userData.portalColor - ? portalMesh.userData.portalColor.clone() - : new THREE.Color(0x4488ff); - } else { - warpDestinationUrl = null; - warpPortalColor = new THREE.Color(0x4488ff); - } - warpPass.uniforms['portalColor'].value = warpPortalColor; -} - -// === FLOATING CRYSTALS & LIGHTNING ARCS === -// Crystals float above the platform. When zone activity is high, lightning arcs jump between them. - -const CRYSTAL_COUNT = 5; -const CRYSTAL_BASE_POSITIONS = [ - new THREE.Vector3(-4.5, 3.2, -3.8), - new THREE.Vector3( 4.8, 2.8, -4.0), - new THREE.Vector3(-5.5, 4.0, 1.5), - new THREE.Vector3( 5.2, 3.5, 2.0), - new THREE.Vector3( 0.0, 5.0, -5.5), -]; -// Colors aligned to agent zones: Claude, Timmy, Kimi, Perplexity, center -const CRYSTAL_COLORS = [0xff6440, 0x40a0ff, 0x40ff8c, 0xc840ff, 0xffd700]; - -const crystalGroup = new THREE.Group(); -scene.add(crystalGroup); - -/** - * @type {Array<{mesh: THREE.Mesh, light: THREE.PointLight, basePos: THREE.Vector3, floatPhase: number}>} - */ -const crystals = []; - -for (let i = 0; i < CRYSTAL_COUNT; i++) { - const geo = new THREE.OctahedronGeometry(0.35, 0); - const color = CRYSTAL_COLORS[i]; - const mat = new THREE.MeshStandardMaterial({ - color, - emissive: new THREE.Color(color).multiplyScalar(0.6), - roughness: 0.05, - metalness: 0.3, - transparent: true, - opacity: 0.88, - }); - const mesh = new THREE.Mesh(geo, mat); - const basePos = CRYSTAL_BASE_POSITIONS[i].clone(); - mesh.position.copy(basePos); - mesh.userData.zoomLabel = 'Crystal'; - crystalGroup.add(mesh); - - const light = new THREE.PointLight(color, 0.3, 6); - light.position.copy(basePos); - crystalGroup.add(light); - - crystals.push({ mesh, light, basePos, floatPhase: (i / CRYSTAL_COUNT) * Math.PI * 2, flashStartTime: -999 }); -} - -// ---- Lightning arc pool ---- - -const LIGHTNING_POOL_SIZE = 6; -const LIGHTNING_SEGMENTS = 8; -const LIGHTNING_REFRESH_MS = 130; -let lastLightningRefreshTime = 0; - -/** @type {Array} */ -const lightningArcs = []; - -/** - * Per-arc runtime state for per-frame flicker. - * @type {Array<{active: boolean, baseOpacity: number, srcIdx: number, dstIdx: number}>} - */ -const lightningArcMeta = []; - -for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) { - const positions = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3); - const geo = new THREE.BufferGeometry(); - geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - const mat = new THREE.LineBasicMaterial({ - color: 0x88ccff, - transparent: true, - opacity: 0.0, - blending: THREE.AdditiveBlending, - depthWrite: false, - }); - const arc = new THREE.Line(geo, mat); - scene.add(arc); - lightningArcs.push(arc); - lightningArcMeta.push({ active: false, baseOpacity: 0, srcIdx: 0, dstIdx: 0 }); -} - -/** - * Builds a jagged lightning path between two points. - * @param {THREE.Vector3} start - * @param {THREE.Vector3} end - * @param {number} jagAmount - * @returns {Float32Array} - */ -function buildLightningPath(start, end, jagAmount) { - const out = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3); - for (let s = 0; s <= LIGHTNING_SEGMENTS; s++) { - const t = s / LIGHTNING_SEGMENTS; - const x = start.x + (end.x - start.x) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0); - const y = start.y + (end.y - start.y) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0); - const z = start.z + (end.z - start.z) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0); - out[s * 3] = x; out[s * 3 + 1] = y; out[s * 3 + 2] = z; - } - 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); -} - -/** - * Lerps between two hex colors. t=0 → colorA, t=1 → colorB. - * @param {number} colorA - * @param {number} colorB - * @param {number} t - * @returns {number} - */ -function lerpColor(colorA, colorB, t) { - const ar = (colorA >> 16) & 0xff, ag = (colorA >> 8) & 0xff, ab = colorA & 0xff; - const br = (colorB >> 16) & 0xff, bg = (colorB >> 8) & 0xff, bb = colorB & 0xff; - const r = Math.round(ar + (br - ar) * t); - const g = Math.round(ag + (bg - ag) * t); - const b = Math.round(ab + (bb - ab) * t); - return (r << 16) | (g << 8) | b; -} - -/** - * Refreshes lightning arc geometry and metadata based on current activity level. - * @param {number} elapsed Current clock time in seconds. - */ -function updateLightningArcs(elapsed) { - const activity = totalActivity(); - const activeCount = Math.round(activity * LIGHTNING_POOL_SIZE); - - for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) { - const arc = lightningArcs[i]; - const meta = lightningArcMeta[i]; - if (i >= activeCount) { - arc.material.opacity = 0; - meta.active = false; - continue; - } - - const a = Math.floor(Math.random() * CRYSTAL_COUNT); - let b = Math.floor(Math.random() * (CRYSTAL_COUNT - 1)); - if (b >= a) b++; - - const jagAmount = 0.45 + activity * 0.85; - const path = buildLightningPath(crystals[a].mesh.position, crystals[b].mesh.position, jagAmount); - const attr = arc.geometry.attributes.position; - attr.array.set(path); - attr.needsUpdate = true; - - // Blend arc color between source and destination crystal - arc.material.color.setHex(lerpColor(CRYSTAL_COLORS[a], CRYSTAL_COLORS[b], 0.5)); - - const base = (0.35 + Math.random() * 0.55) * Math.min(activity * 1.5, 1.0); - arc.material.opacity = base; - meta.active = true; - meta.baseOpacity = base; - meta.srcIdx = a; - meta.dstIdx = b; - - // Trigger brief emissive flash on both struck crystals - crystals[a].flashStartTime = elapsed; - crystals[b].flashStartTime = elapsed; - } -} - -// === BATCAVE AREA === -// Dark metallic workshop terminal positioned back-left of the main glass platform. -// A CubeCamera reflection probe captures the local environment and applies it -// to all high-metalness surfaces in this area for physically-based reflections. - -const BATCAVE_ORIGIN = new THREE.Vector3(-10, 0, -8); - -const batcaveGroup = new THREE.Group(); -batcaveGroup.position.copy(BATCAVE_ORIGIN); -scene.add(batcaveGroup); - -// Reflection probe — 128-px cube render target for PBR metallic surfaces -const batcaveProbeTarget = new THREE.WebGLCubeRenderTarget(128, { - type: THREE.HalfFloatType, - generateMipmaps: true, - minFilter: THREE.LinearMipmapLinearFilter, -}); -const batcaveProbe = new THREE.CubeCamera(0.1, 80, batcaveProbeTarget); -batcaveProbe.position.set(0, 1.2, -1); // centred above the console -batcaveGroup.add(batcaveProbe); - -// Brushed-steel floor panels -const batcaveFloorMat = new THREE.MeshStandardMaterial({ - color: 0x0d1520, - metalness: 0.92, - roughness: 0.08, - envMapIntensity: 1.4, -}); - -// Anodised wall panels with faint accent tint -const batcaveWallMat = new THREE.MeshStandardMaterial({ - color: 0x0a1828, - metalness: 0.85, - roughness: 0.15, - emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.03), - envMapIntensity: 1.2, -}); - -// High-gloss terminal console surface -const batcaveConsoleMat = new THREE.MeshStandardMaterial({ - color: 0x060e16, - metalness: 0.95, - roughness: 0.05, - envMapIntensity: 1.6, -}); - -// All metallic mats that receive the reflection probe envMap -const batcaveMetallicMats = [batcaveFloorMat, batcaveWallMat, batcaveConsoleMat]; - -// Floor slab -const batcaveFloor = new THREE.Mesh( - new THREE.BoxGeometry(6, 0.08, 6), - batcaveFloorMat -); -batcaveFloor.position.y = -0.04; -batcaveGroup.add(batcaveFloor); - -// Back wall -const batcaveBackWall = new THREE.Mesh( - new THREE.BoxGeometry(6, 3, 0.1), - batcaveWallMat -); -batcaveBackWall.position.set(0, 1.5, -3); -batcaveGroup.add(batcaveBackWall); - -// Left side wall -const batcaveLeftWall = new THREE.Mesh( - new THREE.BoxGeometry(0.1, 3, 6), - batcaveWallMat -); -batcaveLeftWall.position.set(-3, 1.5, 0); -batcaveGroup.add(batcaveLeftWall); - -// Console desk base -const batcaveConsoleBase = new THREE.Mesh( - new THREE.BoxGeometry(3, 0.7, 1.2), - batcaveConsoleMat -); -batcaveConsoleBase.position.set(0, 0.35, -1.5); -batcaveGroup.add(batcaveConsoleBase); - -// Console screen bezel -const batcaveScreenBezel = new THREE.Mesh( - new THREE.BoxGeometry(2.6, 1.4, 0.06), - batcaveConsoleMat -); -batcaveScreenBezel.position.set(0, 1.4, -2.08); -batcaveScreenBezel.rotation.x = Math.PI * 0.08; -batcaveGroup.add(batcaveScreenBezel); - -// Screen glow face -const batcaveScreenGlow = new THREE.Mesh( - new THREE.PlaneGeometry(2.2, 1.1), - new THREE.MeshBasicMaterial({ - color: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.65), - transparent: true, - opacity: 0.82, - }) -); -batcaveScreenGlow.position.set(0, 1.4, -2.05); -batcaveScreenGlow.rotation.x = Math.PI * 0.08; -batcaveGroup.add(batcaveScreenGlow); - -// Workshop point light — cool blue for metallic ambience -const batcaveLight = new THREE.PointLight(NEXUS.colors.accent, 0.9, 14); -batcaveLight.position.set(0, 2.8, -1); -batcaveGroup.add(batcaveLight); - -// Ceiling strip emissive bar -const batcaveCeilingStrip = new THREE.Mesh( - new THREE.BoxGeometry(4.2, 0.05, 0.14), - new THREE.MeshStandardMaterial({ - color: NEXUS.colors.accent, - emissive: new THREE.Color(NEXUS.colors.accent), - emissiveIntensity: 1.1, - }) -); -batcaveCeilingStrip.position.set(0, 2.95, -1.2); -batcaveGroup.add(batcaveCeilingStrip); - -batcaveGroup.traverse(obj => { - if (obj.isMesh) obj.userData.zoomLabel = 'Batcave'; -}); - -// Probe state — timestamp of last reflection capture (seconds) -let batcaveProbeLastUpdate = -999; - -// === DUAL-BRAIN HOLOGRAPHIC PANEL === -// Floating panel showing Brain Gap Scorecard with two glowing brain orbs -// connected by an animated particle stream representing knowledge transfer. - -const DUAL_BRAIN_ORIGIN = new THREE.Vector3(10, 3, -8); -const dualBrainGroup = new THREE.Group(); -dualBrainGroup.position.copy(DUAL_BRAIN_ORIGIN); -// Face toward the centre platform -dualBrainGroup.lookAt(0, 3, 0); -scene.add(dualBrainGroup); - -// --- Canvas texture for the scorecard panel --- -function createDualBrainTexture() { - const W = 512, H = 512; - const canvas = document.createElement('canvas'); - canvas.width = W; - canvas.height = H; - const ctx = canvas.getContext('2d'); - - // Dark background - ctx.fillStyle = 'rgba(0, 6, 20, 0.90)'; - ctx.fillRect(0, 0, W, H); - - // Outer neon border - ctx.strokeStyle = '#4488ff'; - ctx.lineWidth = 2; - ctx.strokeRect(1, 1, W - 2, H - 2); - - // Inner subtle border - ctx.strokeStyle = '#223366'; - ctx.lineWidth = 1; - ctx.strokeRect(5, 5, W - 10, H - 10); - - // Title - ctx.font = 'bold 22px "Courier New", monospace'; - ctx.fillStyle = '#88ccff'; - ctx.textAlign = 'center'; - ctx.fillText('\u25C8 DUAL-BRAIN STATUS', W / 2, 40); - - // Separator under title - ctx.strokeStyle = '#1a3a6a'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(20, 52); - ctx.lineTo(W - 20, 52); - ctx.stroke(); - - // Section header - ctx.font = '11px "Courier New", monospace'; - ctx.fillStyle = '#556688'; - ctx.textAlign = 'left'; - ctx.fillText('BRAIN GAP SCORECARD', 20, 74); - - // Categories — honest offline state (no scores, empty bars) - const categories = [ - { name: 'Triage' }, - { name: 'Tool Use' }, - { name: 'Code Gen' }, - { name: 'Planning' }, - { name: 'Communication' }, - { name: 'Reasoning' }, - ]; - - const barX = 20; - const barW = W - 130; - const barH = 20; - let y = 90; - - for (const cat of categories) { - // Category label - ctx.font = '13px "Courier New", monospace'; - ctx.fillStyle = '#445566'; - ctx.textAlign = 'left'; - ctx.fillText(cat.name, barX, y + 14); - - // Score value — dash (no data) - ctx.font = 'bold 13px "Courier New", monospace'; - ctx.fillStyle = '#334466'; - ctx.textAlign = 'right'; - ctx.fillText('\u2014', W - 20, y + 14); - - y += 22; - - // Bar background only — no fill (zero-width) - ctx.fillStyle = 'rgba(255, 255, 255, 0.06)'; - ctx.fillRect(barX, y, barW, barH); - - y += barH + 12; - } - - // Separator - ctx.strokeStyle = '#1a3a6a'; - ctx.beginPath(); - ctx.moveTo(20, y + 4); - ctx.lineTo(W - 20, y + 4); - ctx.stroke(); - - y += 22; - - // Status text — honest offline - ctx.font = 'bold 18px "Courier New", monospace'; - ctx.fillStyle = '#334466'; - ctx.textAlign = 'center'; - ctx.fillText('AWAITING DEPLOYMENT', W / 2, y + 10); - - ctx.font = '11px "Courier New", monospace'; - ctx.fillStyle = '#223344'; - ctx.fillText('Dual-brain system not yet connected', W / 2, y + 32); - - // Brain indicators at bottom — dim (offline) - y += 52; - ctx.beginPath(); - ctx.arc(W / 2 - 60, y + 8, 6, 0, Math.PI * 2); - ctx.fillStyle = '#334466'; - ctx.fill(); - ctx.font = '11px "Courier New", monospace'; - ctx.fillStyle = '#334466'; - ctx.textAlign = 'left'; - ctx.fillText('CLOUD', W / 2 - 48, y + 12); - - ctx.beginPath(); - ctx.arc(W / 2 + 30, y + 8, 6, 0, Math.PI * 2); - ctx.fillStyle = '#334466'; - ctx.fill(); - ctx.fillStyle = '#334466'; - ctx.fillText('LOCAL', W / 2 + 42, y + 12); - - return new THREE.CanvasTexture(canvas); -} - -// Panel sprite -const dualBrainTexture = createDualBrainTexture(); -const dualBrainMaterial = new THREE.SpriteMaterial({ - map: dualBrainTexture, - transparent: true, - opacity: 0.92, - depthWrite: false, -}); -const dualBrainSprite = new THREE.Sprite(dualBrainMaterial); -dualBrainSprite.scale.set(5.0, 5.0, 1); -dualBrainSprite.position.set(0, 0, 0); // local to group -dualBrainSprite.userData = { - baseY: 0, - floatPhase: 0, - floatSpeed: 0.22, - zoomLabel: 'Dual-Brain Status', -}; -dualBrainGroup.add(dualBrainSprite); - -// Accent light for the panel -const dualBrainLight = new THREE.PointLight(0x4488ff, 0.6, 10); -dualBrainLight.position.set(0, 0.5, 1); -dualBrainGroup.add(dualBrainLight); - -// --- Brain Orbs --- -// Cloud brain orb — dim grey (dual-brain offline) -const CLOUD_ORB_COLOR = 0x334466; -const cloudOrbGeo = new THREE.SphereGeometry(0.35, 32, 32); -const cloudOrbMat = new THREE.MeshStandardMaterial({ - color: CLOUD_ORB_COLOR, - emissive: new THREE.Color(CLOUD_ORB_COLOR), - emissiveIntensity: 0.1, - metalness: 0.3, - roughness: 0.2, - transparent: true, - opacity: 0.85, -}); -const cloudOrb = new THREE.Mesh(cloudOrbGeo, cloudOrbMat); -cloudOrb.position.set(-2.0, 3.0, 0); -cloudOrb.userData.zoomLabel = 'Cloud Brain'; -dualBrainGroup.add(cloudOrb); - -const cloudOrbLight = new THREE.PointLight(CLOUD_ORB_COLOR, 0.15, 5); -cloudOrbLight.position.copy(cloudOrb.position); -dualBrainGroup.add(cloudOrbLight); - -// Local brain orb — dim grey (dual-brain offline) -const LOCAL_ORB_COLOR = 0x334466; -const localOrbGeo = new THREE.SphereGeometry(0.35, 32, 32); -const localOrbMat = new THREE.MeshStandardMaterial({ - color: LOCAL_ORB_COLOR, - emissive: new THREE.Color(LOCAL_ORB_COLOR), - emissiveIntensity: 0.1, - metalness: 0.3, - roughness: 0.2, - transparent: true, - opacity: 0.85, -}); -const localOrb = new THREE.Mesh(localOrbGeo, localOrbMat); -localOrb.position.set(2.0, 3.0, 0); -localOrb.userData.zoomLabel = 'Local Brain'; -dualBrainGroup.add(localOrb); - -const localOrbLight = new THREE.PointLight(LOCAL_ORB_COLOR, 0.15, 5); -localOrbLight.position.copy(localOrb.position); -dualBrainGroup.add(localOrbLight); - -// --- Brain Pulse Particle Stream --- -// Particles OFF — dual-brain system not deployed. Will flow when system comes online. -const BRAIN_PARTICLE_COUNT = 0; -const brainParticlePositions = new Float32Array(BRAIN_PARTICLE_COUNT * 3); -const brainParticlePhases = new Float32Array(BRAIN_PARTICLE_COUNT); // 0..1 progress along arc -const brainParticleSpeeds = new Float32Array(BRAIN_PARTICLE_COUNT); - -for (let i = 0; i < BRAIN_PARTICLE_COUNT; i++) { - brainParticlePhases[i] = Math.random(); - brainParticleSpeeds[i] = 0.15 + Math.random() * 0.2; - // Initial positions will be set in animate() - brainParticlePositions[i * 3] = 0; - brainParticlePositions[i * 3 + 1] = 0; - brainParticlePositions[i * 3 + 2] = 0; -} - -const brainParticleGeo = new THREE.BufferGeometry(); -brainParticleGeo.setAttribute('position', new THREE.BufferAttribute(brainParticlePositions, 3)); - -const brainParticleMat = new THREE.PointsMaterial({ - color: 0x44ddff, - size: 0.08, - sizeAttenuation: true, - transparent: true, - opacity: 0.8, - depthWrite: false, -}); - -const brainParticles = new THREE.Points(brainParticleGeo, brainParticleMat); -dualBrainGroup.add(brainParticles); - -// Scanning line overlay canvas — redrawn each frame in animate() -const _scanCanvas = document.createElement('canvas'); -_scanCanvas.width = 512; -_scanCanvas.height = 512; -const _scanCtx = _scanCanvas.getContext('2d'); -const dualBrainScanTexture = new THREE.CanvasTexture(_scanCanvas); -const dualBrainScanMat = new THREE.SpriteMaterial({ - map: dualBrainScanTexture, - transparent: true, - opacity: 0.18, - depthWrite: false, -}); -const dualBrainScanSprite = new THREE.Sprite(dualBrainScanMat); -dualBrainScanSprite.scale.set(5.0, 5.0, 1); -dualBrainScanSprite.position.set(0, 0, 0.01); -dualBrainGroup.add(dualBrainScanSprite); - -// === ANIMATION LOOP === -const clock = new THREE.Clock(); - -/** - * Main animation loop — called each frame via requestAnimationFrame. - * @returns {void} - */ -function animate() { - // Only start animation after assets are loaded - requestAnimationFrame(animate); - animateEnergyBeam(); - const elapsed = clock.getElapsedTime(); - - // Smooth camera transition for overview mode - const targetT = overviewMode ? 1 : 0; - overviewT += (targetT - overviewT) * 0.04; - const _basePos = new THREE.Vector3().lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT); - - // Zoom-to-object interpolation - 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); - } - - // Slow auto-rotation — suppressed during overview and photo mode - const rotationScale = photoMode ? 0 : (1 - overviewT); - targetRotX += (mouseY * 0.3 - targetRotX) * 0.02; - targetRotY += (mouseX * 0.3 - targetRotY) * 0.02; - - 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); - } - starMaterial.opacity = STAR_BASE_OPACITY + (STAR_PEAK_OPACITY - STAR_BASE_OPACITY) * _starPulseIntensity; - - constellationLines.rotation.x = stars.rotation.x; - constellationLines.rotation.y = stars.rotation.y; - - // Subtle pulse on constellation opacity - constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06; - - // Batcave reflection probe — refresh every 2 s to capture dynamic scene changes - if (elapsed - batcaveProbeLastUpdate > 2.0) { - batcaveProbeLastUpdate = elapsed; - batcaveGroup.visible = false; // hide self to avoid self-capture artefacts - batcaveProbe.update(renderer, scene); - batcaveGroup.visible = true; - for (const mat of batcaveMetallicMats) { - mat.envMap = batcaveProbeTarget.texture; - mat.needsUpdate = true; - } - } - - // Glass platform — ripple edge glow outward from centre - for (const { mat, distFromCenter } of glassEdgeMaterials) { - const phase = elapsed * 1.1 - distFromCenter * 0.18; - mat.opacity = 0.25 + Math.sin(phase) * 0.22; - } - // Pulse the void light below - voidLight.intensity = 0.35 + Math.sin(elapsed * 1.4) * 0.2; - - // Heatmap floor: subtle breathing glow - heatmapMat.opacity = 0.75 + Math.sin(elapsed * 0.6) * 0.2; - - // Animate Timmy sigil — base pulses, rings counter-rotate - sigilMesh.rotation.z = elapsed * 0.04; - sigilRing1.rotation.z = elapsed * 0.06; - sigilRing2.rotation.z = -elapsed * 0.10; - sigilRing3.rotation.z = elapsed * 0.08; - sigilMat.opacity = 0.65 + Math.sin(elapsed * 1.3) * 0.18; - sigilRing1Mat.opacity = 0.38 + Math.sin(elapsed * 0.9) * 0.14; - sigilRing2Mat.opacity = 0.32 + Math.sin(elapsed * 1.6 + 1.2) * 0.12; - sigilRing3Mat.opacity = 0.28 + Math.sin(elapsed * 0.7 + 2.4) * 0.10; - sigilLight.intensity = 0.30 + Math.sin(elapsed * 1.1) * 0.15; - - // Animate procedural clouds - cloudMaterial.uniforms.uTime.value = elapsed; - - if (photoMode) { - orbitControls.update(); - } - - // Animate sovereignty meter — gentle hover float and glow pulse - sovereigntyGroup.position.y = 3.8 + Math.sin(elapsed * 0.8) * 0.15; - meterLight.intensity = 0.5 + Math.sin(elapsed * 1.8) * 0.25; - - // Animate floating commit banners - const FADE_DUR = 1.5; - commitBanners.forEach(banner => { - const ud = banner.userData; - if (ud.spawnTime === null) { - if (elapsed < ud.startDelay) return; - ud.spawnTime = elapsed; - } - const age = elapsed - ud.spawnTime; - let opacity; - if (age < FADE_DUR) { - opacity = age / FADE_DUR; - } else if (age < ud.lifetime - FADE_DUR) { - opacity = 1; - } else if (age < ud.lifetime) { - opacity = (ud.lifetime - age) / FADE_DUR; - } else { - ud.spawnTime = elapsed + 3; - opacity = 0; - } - banner.material.opacity = opacity * 0.85; - banner.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.4; - }); - - // Animate agent status panels — gentle float - for (const sprite of agentPanelSprites) { - const ud = sprite.userData; - sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.22; - } - - // Animate LoRA status panel — gentle float - if (loraPanelSprite) { - const ud = loraPanelSprite.userData; - loraPanelSprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.22; - } - - // Animate floating bookshelves — gentle slow bob - for (const shelf of bookshelfGroups) { - const ud = shelf.userData; - shelf.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.18; - } - - // Animate Timmy speech bubble — fade in, hold, fade out - if (timmySpeechState) { - const age = elapsed - timmySpeechState.startTime; - let opacity; - if (age < SPEECH_FADE_IN) { - opacity = age / SPEECH_FADE_IN; - } else if (age < SPEECH_DURATION - SPEECH_FADE_OUT) { - opacity = 1.0; - } else if (age < SPEECH_DURATION) { - opacity = (SPEECH_DURATION - age) / SPEECH_FADE_OUT; - } else { - scene.remove(timmySpeechState.sprite); - if (timmySpeechState.sprite.material.map) timmySpeechState.sprite.material.map.dispose(); - timmySpeechState.sprite.material.dispose(); - timmySpeechSprite = null; - timmySpeechState = null; - opacity = 0; - } - if (timmySpeechState) { - timmySpeechState.sprite.material.opacity = opacity; - timmySpeechState.sprite.position.y = TIMMY_SPEECH_POS.y + Math.sin(elapsed * 1.1) * 0.1; - } - } - - // Animate tome — gentle float and slow rotation - tomeGroup.position.y = 5.8 + Math.sin(elapsed * 0.6) * 0.18; - tomeGroup.rotation.y = elapsed * 0.3; - tomeGlow.intensity = 0.3 + Math.sin(elapsed * 1.4) * 0.12; - if (oathActive) { - oathSpot.intensity = 3.8 + Math.sin(elapsed * 0.9) * 0.4; - } - - // Animate shockwave ripple rings - for (let i = shockwaveRings.length - 1; i >= 0; i--) { - const ring = shockwaveRings[i]; - const age = elapsed - ring.startTime - ring.delay; - if (age < 0) continue; - const t = Math.min(age / SHOCKWAVE_DURATION, 1); - if (t >= 1) { - scene.remove(ring.mesh); - ring.mesh.geometry.dispose(); - ring.mat.dispose(); - shockwaveRings.splice(i, 1); - continue; - } - // Ease out: fast at start, decelerates toward edge - const eased = 1 - Math.pow(1 - t, 2); - ring.mesh.scale.setScalar(eased * SHOCKWAVE_MAX_RADIUS + 0.1); - ring.mat.opacity = (1 - t) * 0.9; - } - - // Animate firework bursts — particles drift outward with gravity, fade out - for (let i = fireworkBursts.length - 1; i >= 0; i--) { - const burst = fireworkBursts[i]; - const age = elapsed - burst.startTime; - const t = Math.min(age / FIREWORK_BURST_DURATION, 1); - if (t >= 1) { - scene.remove(burst.points); - burst.geo.dispose(); - burst.mat.dispose(); - fireworkBursts.splice(i, 1); - continue; - } - // Fade out in last 40% of lifetime - burst.mat.opacity = t < 0.6 ? 1.0 : (1.0 - t) / 0.4; - - // Compute positions analytically: p = p0 + v*age + 0.5*g*age^2 - const pos = burst.geo.attributes.position.array; - const vel = burst.velocities; - const org = burst.origins; - const halfGAge2 = 0.5 * FIREWORK_GRAVITY * age * age; - for (let j = 0; j < FIREWORK_BURST_PARTICLES; j++) { - pos[j * 3] = org[j * 3] + vel[j * 3] * age; - pos[j * 3 + 1] = org[j * 3 + 1] + vel[j * 3 + 1] * age + halfGAge2; - pos[j * 3 + 2] = org[j * 3 + 2] + vel[j * 3 + 2] * age; - } - burst.geo.attributes.position.needsUpdate = true; - } - - // Animate rune ring — orbit and vertical float, brightness tethered to portal status - for (const rune of runeSprites) { - const angle = rune.baseAngle + elapsed * RUNE_ORBIT_SPEED; - rune.sprite.position.x = Math.cos(angle) * RUNE_RING_RADIUS; - rune.sprite.position.z = Math.sin(angle) * RUNE_RING_RADIUS; - rune.sprite.position.y = RUNE_RING_Y + Math.sin(elapsed * 0.7 + rune.floatPhase) * 0.4; - // Online portal = bright, offline = dim - const baseOpacity = rune.portalOnline ? 0.85 : 0.12; - const pulseRange = rune.portalOnline ? 0.15 : 0.03; - rune.sprite.material.opacity = baseOpacity + Math.sin(elapsed * 1.2 + rune.floatPhase) * pulseRange; - } - - // Animate holographic Earth — rotation speed tethered to totalActivity() - // Idle system = very slow (0.005), active system = faster (0.05) - const earthActivity = totalActivity(); - const targetEarthSpeed = 0.005 + earthActivity * 0.045; - // Smooth interpolation — don't jump - const _eSmooth = 0.02; - const currentEarthSpeed = earthMesh.userData._currentSpeed || EARTH_ROTATION_SPEED; - const smoothedEarthSpeed = currentEarthSpeed + (targetEarthSpeed - currentEarthSpeed) * _eSmooth; - earthMesh.userData._currentSpeed = smoothedEarthSpeed; - earthMesh.rotation.y += smoothedEarthSpeed; - earthSurfaceMat.uniforms.uTime.value = elapsed; - earthGlowLight.intensity = 0.30 + Math.sin(elapsed * 0.7) * 0.12; - earthGroup.position.y = EARTH_Y + Math.sin(elapsed * 0.22) * 0.6; - - // === WEATHER PARTICLE ANIMATION === - if (rainParticles.visible) { - const rpos = rainGeo.attributes.position.array; - for (let i = 0; i < PRECIP_COUNT; i++) { - rpos[i * 3 + 1] -= rainVelocities[i]; - if (rpos[i * 3 + 1] < PRECIP_FLOOR) { - rpos[i * 3 + 1] = PRECIP_HEIGHT; - rpos[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2; - rpos[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2; - } - } - rainGeo.attributes.position.needsUpdate = true; - } - - if (snowParticles.visible) { - const spos = snowGeo.attributes.position.array; - for (let i = 0; i < PRECIP_COUNT; i++) { - spos[i * 3 + 1] -= 0.025 + Math.sin(snowDrift[i]) * 0.005; - spos[i * 3] += Math.sin(elapsed * 0.4 + snowDrift[i]) * 0.008; - if (spos[i * 3 + 1] < PRECIP_FLOOR) { - spos[i * 3 + 1] = PRECIP_HEIGHT; - spos[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2; - spos[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2; - } - } - snowGeo.attributes.position.needsUpdate = true; - } - - // === GRAVITY ANOMALY ANIMATION === - for (const gz of gravityZoneObjects) { - const pos = gz.geo.attributes.position.array; - const count = gz.zone.particleCount; - for (let i = 0; i < count; i++) { - pos[i * 3 + 1] += gz.velocities[i]; - pos[i * 3] += Math.sin(elapsed * 0.5 + gz.driftPhases[i]) * 0.003; - pos[i * 3 + 2] += Math.cos(elapsed * 0.5 + gz.driftPhases[i]) * 0.003; - if (pos[i * 3 + 1] > GRAVITY_ANOMALY_CEIL) { - const angle = Math.random() * Math.PI * 2; - const r = Math.sqrt(Math.random()) * gz.zone.radius; - pos[i * 3] = gz.zone.x + Math.cos(angle) * r; - pos[i * 3 + 1] = GRAVITY_ANOMALY_FLOOR + Math.random() * 2.0; - pos[i * 3 + 2] = gz.zone.z + Math.sin(angle) * r; - } - } - gz.geo.attributes.position.needsUpdate = true; - gz.ringMat.opacity = 0.3 + Math.sin(elapsed * 1.5 + gz.zone.x) * 0.15; - gz.discMat.opacity = 0.02 + Math.sin(elapsed * 1.5 + gz.zone.x) * 0.02; - } - - // === DUAL-BRAIN ANIMATION === - // Panel float - dualBrainSprite.position.y = dualBrainSprite.userData.baseY + - Math.sin(elapsed * dualBrainSprite.userData.floatSpeed + dualBrainSprite.userData.floatPhase) * 0.22; - dualBrainScanSprite.position.y = dualBrainSprite.position.y; - - // Orb glow — dim idle pulse (dual-brain offline) - const cloudPulse = 0.08 + Math.sin(elapsed * 0.6) * 0.03; - const localPulse = 0.08 + Math.sin(elapsed * 0.6 + Math.PI) * 0.03; - cloudOrbMat.emissiveIntensity = cloudPulse; - localOrbMat.emissiveIntensity = localPulse; - cloudOrbLight.intensity = 0.1 + Math.sin(elapsed * 0.6) * 0.05; - localOrbLight.intensity = 0.1 + Math.sin(elapsed * 0.6 + Math.PI) * 0.05; - - // Orb hover - cloudOrb.position.y = 3.0 + Math.sin(elapsed * 0.9) * 0.15; - localOrb.position.y = 3.0 + Math.sin(elapsed * 0.9 + 1.0) * 0.15; - cloudOrbLight.position.y = cloudOrb.position.y; - localOrbLight.position.y = localOrb.position.y; - - // Brain pulse particles — OFF (dual-brain system not deployed) - // Will be re-enabled with flow rate proportional to convergence delta when system deploys - if (BRAIN_PARTICLE_COUNT > 0) { - const pos = brainParticleGeo.attributes.position.array; - const startX = cloudOrb.position.x; - const endX = localOrb.position.x; - const arcHeight = 1.2; - const simRate = 0.73; - - for (let i = 0; i < BRAIN_PARTICLE_COUNT; i++) { - brainParticlePhases[i] += brainParticleSpeeds[i] * simRate * 0.016; - if (brainParticlePhases[i] > 1.0) brainParticlePhases[i] -= 1.0; - const t = brainParticlePhases[i]; - pos[i * 3] = startX + (endX - startX) * t; - const midY = (cloudOrb.position.y + localOrb.position.y) / 2 + arcHeight; - pos[i * 3 + 1] = cloudOrb.position.y + (midY - cloudOrb.position.y) * 4 * t * (1 - t) - + (localOrb.position.y - cloudOrb.position.y) * t; - pos[i * 3 + 2] = Math.sin(t * Math.PI * 4 + elapsed * 2 + i) * 0.12; - } - brainParticleGeo.attributes.position.needsUpdate = true; - brainParticleMat.opacity = 0.6 + Math.sin(elapsed * 2.0) * 0.2; - } - - // Scanning line effect — thin horizontal line sweeps down the panel - { - const W = 512, H = 512; - _scanCtx.clearRect(0, 0, W, H); - const scanY = ((elapsed * 60) % H); - _scanCtx.fillStyle = 'rgba(68, 136, 255, 0.5)'; - _scanCtx.fillRect(0, scanY, W, 2); - // Faint glow around scan line - const grad = _scanCtx.createLinearGradient(0, scanY - 8, 0, scanY + 10); - grad.addColorStop(0, 'rgba(68, 136, 255, 0)'); - grad.addColorStop(0.4, 'rgba(68, 136, 255, 0.15)'); - grad.addColorStop(0.6, 'rgba(68, 136, 255, 0.15)'); - grad.addColorStop(1, 'rgba(68, 136, 255, 0)'); - _scanCtx.fillStyle = grad; - _scanCtx.fillRect(0, scanY - 8, W, 18); - dualBrainScanTexture.needsUpdate = true; - } - - // Panel accent light pulse - dualBrainLight.intensity = 0.4 + Math.sin(elapsed * 1.1) * 0.2; - - // Portal collision detection - forwardVector.set(0, 0, -1).applyQuaternion(camera.quaternion); - raycaster.set(camera.position, forwardVector); - - const intersects = raycaster.intersectObjects(portalGroup.children); - - if (intersects.length > 0) { - const intersectedPortal = intersects[0].object; - console.log(`Entered portal: ${intersectedPortal.name}`); - if (!isWarping) { - startWarp(intersectedPortal); - } - } - - // Warp effect animation - if (isWarping) { - const warpElapsed = elapsed - warpStartTime; - const progress = Math.min(warpElapsed / WARP_DURATION, 1.0); - warpPass.uniforms['time'].value = elapsed; - warpPass.uniforms['progress'].value = progress; - - // Navigate to destination URL at the flash peak (progress ~0.88) - if (!warpNavigated && progress >= 0.88 && warpDestinationUrl) { - warpNavigated = true; - setTimeout(() => { window.location.href = warpDestinationUrl; }, 180); - } - - if (progress >= 1.0) { - isWarping = false; - warpPass.enabled = false; - warpPass.uniforms['progress'].value = 0.0; - // Fallback navigation if URL redirect hasn't fired yet - if (!warpNavigated && warpDestinationUrl) { - warpNavigated = true; - window.location.href = warpDestinationUrl; - } - } - } - - // Animate crystals — gentle float, slow spin, and lightning-strike flash - const activity = totalActivity(); - for (const crystal of crystals) { - crystal.mesh.position.x = crystal.basePos.x; - crystal.mesh.position.y = crystal.basePos.y + Math.sin(elapsed * 0.65 + crystal.floatPhase) * 0.35; - crystal.mesh.position.z = crystal.basePos.z; - crystal.mesh.rotation.y = elapsed * 0.4 + crystal.floatPhase; - crystal.light.position.copy(crystal.mesh.position); - const flashAge = elapsed - crystal.flashStartTime; - const flashBoost = flashAge < 0.25 ? (1.0 - flashAge / 0.25) * 2.0 : 0.0; - crystal.light.intensity = 0.2 + activity * 0.8 + Math.sin(elapsed * 2.0 + crystal.floatPhase) * 0.1 + flashBoost; - crystal.mesh.material.emissiveIntensity = 1.0 + flashBoost * 0.8; - } - - // Per-frame lightning flicker — modulate opacity each frame for realism - for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) { - const meta = lightningArcMeta[i]; - if (meta.active) { - lightningArcs[i].material.opacity = meta.baseOpacity * (0.55 + Math.random() * 0.45); - } - } - - // Refresh lightning arc geometry periodically - if (elapsed * 1000 - lastLightningRefreshTime > LIGHTNING_REFRESH_MS) { - lastLightningRefreshTime = elapsed * 1000; - updateLightningArcs(elapsed); - } - - // Time-lapse replay tick - if (timelapseActive) { - const realElapsed = elapsed - timelapseRealStart; - timelapseProgress = Math.min(realElapsed / TIMELAPSE_DURATION_S, 1.0); - const span = timelapseWindow.endMs - timelapseWindow.startMs; - const virtualMs = timelapseWindow.startMs + span * timelapseProgress; - - // Fire commit events for commits we've reached in virtual time - while ( - timelapseNextCommitIdx < timelapseCommits.length && - timelapseCommits[timelapseNextCommitIdx].ts <= virtualMs - ) { - fireTimelapseCommit(timelapseCommits[timelapseNextCommitIdx]); - timelapseNextCommitIdx++; - } - - updateTimelapseHeatmap(virtualMs); - updateTimelapseHUD(timelapseProgress, virtualMs); - - if (timelapseProgress >= 1.0) stopTimelapse(); - } - - // Sync Web Audio API listener to camera for 3D spatial audio - updateAudioListener(); - - composer.render(); -} - -animate(); - -// === AMBIENT SOUNDTRACK === -// Procedural ambient score synthesised in-browser via Web Audio API. -// Research: Google MusicFX (musicfx.sandbox.google.com) uses AI text prompts to -// generate music clips. Since MusicFX has no public API, we replicate the desired -// "deep space / sovereign" aesthetic procedurally. -// -// Architecture (4 layers): -// 1. Sub-drone — two slow-detuned sawtooth oscillators at ~55 Hz (octave below A2) -// 2. Pad — four detuned triangle oscillators in a minor 7th chord -// 3. Sparkle — random high-register sine plucks on a pentatonic scale -// 4. Noise hiss — pink-ish filtered noise for texture -// All routed through: gain → convolver reverb → limiter → destination. - -/** @type {AudioContext|null} */ -let audioCtx = null; - -/** @type {GainNode|null} */ -let masterGain = null; - -/** @type {boolean} */ -let audioRunning = false; - -/** @type {Array} */ -const audioSources = []; - -/** @type {PannerNode[]} */ -const positionedPanners = []; - -/** @type {boolean} */ -let portalHumsStarted = false; - -/** @type {number|null} */ -let sparkleTimer = null; - -/** - * Builds a simple impulse-response buffer for reverb (synthetic room). - * @param {AudioContext} ctx - * @param {number} duration seconds - * @param {number} decay - * @returns {AudioBuffer} - */ -function buildReverbIR(ctx, duration, decay) { - const rate = ctx.sampleRate; - const len = Math.ceil(rate * duration); - const buf = ctx.createBuffer(2, len, rate); - for (let ch = 0; ch < 2; ch++) { - const d = buf.getChannelData(ch); - for (let i = 0; i < len; i++) { - d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay); - } - } - return buf; -} - -/** - * Creates a PannerNode fixed at world coordinates (x, y, z). - * HRTF model gives realistic directional cues; inverse rolloff fades - * with distance. Connect the returned node to masterGain. - * Must be called while audioCtx is initialised. - * @param {number} x - * @param {number} y - * @param {number} z - * @returns {PannerNode} - */ -function createPanner(x, y, z) { - const panner = audioCtx.createPanner(); - panner.panningModel = 'HRTF'; - panner.distanceModel = 'inverse'; - panner.refDistance = 5; - panner.maxDistance = 80; - panner.rolloffFactor = 1.0; - if (panner.positionX) { - panner.positionX.value = x; - panner.positionY.value = y; - panner.positionZ.value = z; - } else { - panner.setPosition(x, y, z); - } - positionedPanners.push(panner); - return panner; -} - -/** - * Updates the Web Audio API listener to match the camera's current - * position and orientation. Call once per animation frame. - */ -function updateAudioListener() { - if (!audioCtx) return; - const listener = audioCtx.listener; - const pos = camera.position; - const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion); - const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion); - if (listener.positionX) { - const t = audioCtx.currentTime; - listener.positionX.setValueAtTime(pos.x, t); - listener.positionY.setValueAtTime(pos.y, t); - listener.positionZ.setValueAtTime(pos.z, t); - listener.forwardX.setValueAtTime(fwd.x, t); - listener.forwardY.setValueAtTime(fwd.y, t); - listener.forwardZ.setValueAtTime(fwd.z, t); - listener.upX.setValueAtTime(up.x, t); - listener.upY.setValueAtTime(up.y, t); - listener.upZ.setValueAtTime(up.z, t); - } else { - listener.setPosition(pos.x, pos.y, pos.z); - listener.setOrientation(fwd.x, fwd.y, fwd.z, up.x, up.y, up.z); - } -} - -/** - * Starts a quiet positional hum for every loaded portal. - * Each hum is placed at the portal's world position so the sound - * pans left/right as the listener orbits the scene. - * Safe to call multiple times (idempotent). - */ -function startPortalHums() { - if (!audioCtx || !audioRunning || portals.length === 0 || portalHumsStarted) return; - portalHumsStarted = true; - // Distinct base pitches per portal slot (low sub-register) - const humFreqs = [58.27, 65.41, 73.42, 82.41, 87.31]; - portals.forEach((portal, i) => { - const panner = createPanner( - portal.position.x, - portal.position.y + 1.5, - portal.position.z - ); - panner.connect(masterGain); - - const osc = audioCtx.createOscillator(); - osc.type = 'sine'; - osc.frequency.value = humFreqs[i % humFreqs.length]; - - // Slow tremolo for organic feel - const lfo = audioCtx.createOscillator(); - lfo.frequency.value = 0.07 + i * 0.02; - const lfoGain = audioCtx.createGain(); - lfoGain.gain.value = 0.008; - lfo.connect(lfoGain); - - const g = audioCtx.createGain(); - g.gain.value = 0.035; - lfoGain.connect(g.gain); - osc.connect(g); - g.connect(panner); - - osc.start(); - lfo.start(); - audioSources.push(osc, lfo); - }); -} - -/** - * Starts the ambient soundtrack. Safe to call multiple times (idempotent). - */ -function startAmbient() { - if (audioRunning) return; - - audioCtx = new AudioContext(); - masterGain = audioCtx.createGain(); - masterGain.gain.value = 0; - - // Reverb - const convolver = audioCtx.createConvolver(); - convolver.buffer = buildReverbIR(audioCtx, 3.5, 2.8); - - // Limiter (DynamicsCompressor as brickwall) - const limiter = audioCtx.createDynamicsCompressor(); - limiter.threshold.value = -3; - limiter.knee.value = 0; - limiter.ratio.value = 20; - limiter.attack.value = 0.001; - limiter.release.value = 0.1; - - masterGain.connect(convolver); - convolver.connect(limiter); - limiter.connect(audioCtx.destination); - - // -- Layer 1: Sub-drone (two detuned saws) -- - [[55.0, -6], [55.0, +6]].forEach(([freq, detune]) => { - const osc = audioCtx.createOscillator(); - osc.type = 'sawtooth'; - osc.frequency.value = freq; - osc.detune.value = detune; - const g = audioCtx.createGain(); - g.gain.value = 0.07; - osc.connect(g); - g.connect(masterGain); - osc.start(); - audioSources.push(osc); - }); - - // -- Layer 2: Pad (minor 7th chord: A2, C3, E3, G3) -- - [110, 130.81, 164.81, 196].forEach((freq, i) => { - const detunes = [-8, 4, -3, 7]; - const osc = audioCtx.createOscillator(); - osc.type = 'triangle'; - osc.frequency.value = freq; - osc.detune.value = detunes[i]; - // Slow LFO for gentle swell - const lfo = audioCtx.createOscillator(); - lfo.frequency.value = 0.05 + i * 0.013; - const lfoGain = audioCtx.createGain(); - lfoGain.gain.value = 0.02; - lfo.connect(lfoGain); - const g = audioCtx.createGain(); - g.gain.value = 0.06; - lfoGain.connect(g.gain); - osc.connect(g); - g.connect(masterGain); - osc.start(); - lfo.start(); - audioSources.push(osc, lfo); - }); - - // -- Layer 3: Noise hiss (filtered white noise) -- - const noiseLen = audioCtx.sampleRate * 2; - const noiseBuf = audioCtx.createBuffer(1, noiseLen, audioCtx.sampleRate); - const nd = noiseBuf.getChannelData(0); - // Simple pink-ish noise via first-order IIR - let b0 = 0; - for (let i = 0; i < noiseLen; i++) { - const white = Math.random() * 2 - 1; - b0 = 0.99 * b0 + white * 0.01; - nd[i] = b0 * 3.5; - } - const noiseNode = audioCtx.createBufferSource(); - noiseNode.buffer = noiseBuf; - noiseNode.loop = true; - const noiseFilter = audioCtx.createBiquadFilter(); - noiseFilter.type = 'bandpass'; - noiseFilter.frequency.value = 800; - noiseFilter.Q.value = 0.5; - const noiseGain = audioCtx.createGain(); - noiseGain.gain.value = 0.012; - noiseNode.connect(noiseFilter); - noiseFilter.connect(noiseGain); - noiseGain.connect(masterGain); - noiseNode.start(); - audioSources.push(noiseNode); - - // -- Layer 4: Sparkle plucks (pentatonic: A4 C5 E5 A5 C6) -- - // Each pluck spawns at a random 3D position around the platform so - // the sound appears to drift in from different directions. - const sparkleNotes = [440, 523.25, 659.25, 880, 1046.5]; - function scheduleSparkle() { - if (!audioRunning || !audioCtx) return; - const osc = audioCtx.createOscillator(); - osc.type = 'sine'; - osc.frequency.value = sparkleNotes[Math.floor(Math.random() * sparkleNotes.length)]; - const env = audioCtx.createGain(); - const now = audioCtx.currentTime; - env.gain.setValueAtTime(0, now); - env.gain.linearRampToValueAtTime(0.08, now + 0.02); - env.gain.exponentialRampToValueAtTime(0.0001, now + 1.8); - - // Position sparkle at a random point floating above the platform - const angle = Math.random() * Math.PI * 2; - const radius = 3 + Math.random() * 9; - const sparkPanner = createPanner( - Math.cos(angle) * radius, - 1.5 + Math.random() * 4, - Math.sin(angle) * radius - ); - sparkPanner.connect(masterGain); - - osc.connect(env); - env.connect(sparkPanner); - osc.start(now); - osc.stop(now + 1.9); - // Disconnect panner once the note is gone - osc.addEventListener('ended', () => { - try { sparkPanner.disconnect(); } catch (_) {} - const idx = positionedPanners.indexOf(sparkPanner); - if (idx !== -1) positionedPanners.splice(idx, 1); - }); - - // Schedule next sparkle: 3-9 seconds - const nextMs = 3000 + Math.random() * 6000; - sparkleTimer = setTimeout(scheduleSparkle, nextMs); - } - sparkleTimer = setTimeout(scheduleSparkle, 1000 + Math.random() * 3000); - - // Fade in master gain - masterGain.gain.setValueAtTime(0, audioCtx.currentTime); - masterGain.gain.linearRampToValueAtTime(0.9, audioCtx.currentTime + 2.0); - - audioRunning = true; - document.getElementById('audio-toggle').textContent = '🔇'; - - // Start portal hums if portals are already loaded - startPortalHums(); -} - -/** - * Stops and tears down the ambient soundtrack. - */ -function stopAmbient() { - if (!audioRunning || !audioCtx) return; - audioRunning = false; - if (sparkleTimer !== null) { clearTimeout(sparkleTimer); sparkleTimer = null; } - - const gain = masterGain; - const ctx = audioCtx; - gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime); - gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8); - - setTimeout(() => { - audioSources.forEach(n => { try { n.stop(); } catch (_) {} }); - audioSources.length = 0; - positionedPanners.forEach(p => { try { p.disconnect(); } catch (_) {} }); - positionedPanners.length = 0; - portalHumsStarted = false; - ctx.close(); - audioCtx = null; - masterGain = null; - }, 900); - - document.getElementById('audio-toggle').textContent = '🔊'; -} - -document.getElementById('audio-toggle').addEventListener('click', () => { - if (audioRunning) { - stopAmbient(); - } else { - startAmbient(); - } -}); - -// Podcast toggle -document.getElementById('podcast-toggle').addEventListener('click', () => { +// ─── Podcast toggle ─── +function initPodcastToggle() { const btn = document.getElementById('podcast-toggle'); - if (btn.textContent === '🎧') { - fetch('SOUL.md') - .then(response => { - if (!response.ok) throw new Error('Failed to load SOUL.md'); - return response.text(); - }) - .then(text => { + 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('No content found in SOUL.md'); - } - + if (!paragraphs.length) throw new Error('empty'); let index = 0; const speakNext = () => { if (index >= paragraphs.length) return; - - const utterance = new SpeechSynthesisUtterance(paragraphs[index++]); - utterance.lang = 'en-US'; - utterance.rate = 0.9; - utterance.pitch = 1.1; - - utterance.onend = () => { - setTimeout(speakNext, 800); // 800ms pause between paragraphs - }; - - speechSynthesis.speak(utterance); + 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 = '⏹'; - btn.classList.add('active'); - speakNext(); - }) - .catch(err => { - console.error('Podcast error:', err); - alert('Could not load SOUL.md. Check console for details.'); - btn.textContent = '🎧'; - }); - } else { - speechSynthesis.cancel(); - btn.textContent = '🎧'; - btn.classList.remove('active'); - } -}); + btn.textContent = '\u23F9'; btn.classList.add('active'); speakNext(); + }).catch(() => { btn.textContent = '\uD83C\uDFA7'; }); + } else { speechSynthesis.cancel(); btn.textContent = '\uD83C\uDFA7'; btn.classList.remove('active'); } + }); +} -document.getElementById('soul-toggle').addEventListener('click', () => { +function initSoulToggle() { const btn = document.getElementById('soul-toggle'); - if (btn.textContent === '📜') { - loadSoulMd().then(lines => { - let index = 0; - - const speakLine = () => { - if (index >= lines.length) return; - - const line = lines[index++]; - const utterance = new SpeechSynthesisUtterance(line); - utterance.lang = 'en-US'; - utterance.rate = 0.85; - utterance.pitch = 1.0; - - utterance.onend = () => { - setTimeout(speakLine, 1200); // 1.2s pause between lines + 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); }; - - speechSynthesis.speak(utterance); - }; - - btn.textContent = '⏹'; - speakLine(); - }).catch(err => { - console.error('Failed to load SOUL.md', err); - alert('Could not load SOUL.md. Check console for details.'); - }); - } else { - speechSynthesis.cancel(); - btn.textContent = '📜'; - } -}); - -// === DEBUG MODE === -let debugMode = false; - -document.getElementById('debug-toggle').addEventListener('click', () => { - debugMode = !debugMode; - document.getElementById('debug-toggle').style.backgroundColor = debugMode - ? 'var(--color-text-muted)' - : 'var(--color-secondary)'; - console.log(`Debug mode ${debugMode ? 'enabled' : 'disabled'}`); - - if (debugMode) { - // Example: Visualize all collision boxes and light sources - // Replace with actual logic when available - document.querySelectorAll('.collision-box').forEach((/** @type {HTMLElement} */ el) => el.style.outline = '2px solid red'); - document.querySelectorAll('.light-source').forEach((/** @type {HTMLElement} */ el) => el.style.outline = '2px dashed yellow'); - } else { - document.querySelectorAll('.collision-box, .light-source').forEach((/** @type {HTMLElement} */ el) => { - el.style.outline = 'none'; - }); - } -}); - -// === DEBUG MODE === -const DEBUG_MODE = false; // Toggle to true to visualize collision boxes and light sources - -function debugVisualize(scene) { - if (!DEBUG_MODE) return; - // Visualize collision boxes with wireframe - scene.traverse((object) => { - if (object.userData && object.userData.isCollidable) { - object.material = new THREE.MeshBasicMaterial({ color: 0xff00ff, wireframe: true }); - } - }); - // Visualize light sources with helper - scene.traverse((object) => { - if (object instanceof THREE.Light) { - const helper = new THREE.LightHelper(object, 1, 0xffff00); - scene.add(helper); - } + btn.textContent = '\u23F9'; speakLine(); + }).catch(() => {}); + } else { speechSynthesis.cancel(); btn.textContent = '\uD83D\uDCDC'; } }); } -// === WEBSOCKET CLIENT === -import { wsClient } from './ws-client.js'; - -wsClient.connect(); - -window.addEventListener('player-joined', (/** @type {CustomEvent} */ event) => { - console.log('Player joined:', event.detail); -}); - -window.addEventListener('player-left', (/** @type {CustomEvent} */ event) => { - console.log('Player left:', event.detail); -}); - -// === SESSION EXPORT === -/** @type {{ ts: number, speaker: string, text: string }[]} */ -const sessionLog = []; -const sessionStart = Date.now(); - -/** - * Appends an entry to the in-memory session log. - * @param {string} speaker - * @param {string} text - */ -function logMessage(speaker, text) { - sessionLog.push({ ts: Date.now(), speaker, text }); -} - -/** - * Formats the session log as Markdown and triggers a browser download. - */ -function exportSessionAsMarkdown() { - const startStr = new Date(sessionStart).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; - const lines = [ - '# Nexus Session Export', - '', - `**Session started:** ${startStr}`, - `**Messages:** ${sessionLog.length}`, - '', - '---', - '', - ]; - - for (const entry of sessionLog) { - const timeStr = new Date(entry.ts).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; - lines.push(`### ${entry.speaker} — ${timeStr}`); - lines.push(''); - lines.push(entry.text); - lines.push(''); - } - - if (sessionLog.length === 0) { - lines.push('*No messages recorded this session.*'); - lines.push(''); - } - - const blob = new Blob([lines.join('\n')], { type: 'text/markdown' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `nexus-session-${new Date(sessionStart).toISOString().slice(0, 10)}.md`; - a.click(); - URL.revokeObjectURL(url); -} - -const exportBtn = document.getElementById('export-session'); -if (exportBtn) { - exportBtn.addEventListener('click', exportSessionAsMarkdown); -} - -window.addEventListener('chat-message', (/** @type {CustomEvent} */ event) => { - console.log('Chat message:', event.detail); - if (typeof event.detail?.text === 'string') { - logMessage(event.detail.speaker || 'TIMMY', event.detail.text); - showTimmySpeech(event.detail.text); - if (event.detail.text.toLowerCase().includes('sovereignty')) { - triggerSovereigntyEasterEgg(); - } - if (event.detail.text.toLowerCase().includes('milestone')) { - triggerFireworks(); - } - } -}); - -window.addEventListener('milestone-complete', (/** @type {CustomEvent} */ event) => { - console.log('[nexus] Milestone complete:', event.detail); - triggerFireworks(); -}); - -window.addEventListener('status-update', (/** @type {CustomEvent} */ event) => { - console.log('[hermes] Status update:', event.detail); -}); - -window.addEventListener('pr-notification', (/** @type {CustomEvent} */ event) => { - console.log('[hermes] PR notification:', event.detail); - if (event.detail && event.detail.action === 'merged') { - triggerMergeFlash(); - } -}); - -// === SOVEREIGNTY EASTER EGG === -const SOVEREIGNTY_WORD = 'sovereignty'; -let sovereigntyBuffer = ''; -let sovereigntyBufferTimer = /** @type {ReturnType|null} */ (null); - -const sovereigntyMsg = document.getElementById('sovereignty-msg'); - -/** - * Triggers the sovereignty Easter egg: stars pulse gold, message flashes. - */ -function triggerSovereigntyEasterEgg() { - // Flash constellation lines gold - const originalLineColor = constellationLines.material.color.getHex(); - constellationLines.material.color.setHex(0xffd700); - constellationLines.material.opacity = 0.9; - - // Stars burst gold - const originalStarColor = starMaterial.color.getHex(); - const originalStarOpacity = starMaterial.opacity; - starMaterial.color.setHex(0xffd700); - starMaterial.opacity = 1.0; - - // Show overlay message - if (sovereigntyMsg) { - sovereigntyMsg.classList.remove('visible'); - // Force reflow so animation restarts - void sovereigntyMsg.offsetWidth; - sovereigntyMsg.classList.add('visible'); - } - - // Animate gold fade-out over 2.5s - const startTime = performance.now(); - const DURATION = 2500; - - function fadeBack() { - const t = Math.min((performance.now() - startTime) / DURATION, 1); - const eased = t * t; // ease in: slow start, fast end - - // Interpolate star color back - const goldR = 1.0, goldG = 0.843, goldB = 0; - const origColor = new THREE.Color(originalStarColor); - starMaterial.color.setRGB( - goldR + (origColor.r - goldR) * eased, - goldG + (origColor.g - goldG) * eased, - goldB + (origColor.b - goldB) * eased - ); - starMaterial.opacity = 1.0 + (originalStarOpacity - 1.0) * eased; - - // Interpolate line color back - const origLineColor = new THREE.Color(originalLineColor); - constellationLines.material.color.setRGB( - 1.0 + (origLineColor.r - 1.0) * eased, - 0.843 + (origLineColor.g - 0.843) * eased, - 0 + origLineColor.b * eased - ); - - if (t < 1) { - requestAnimationFrame(fadeBack); - } else { - // Restore originals exactly - starMaterial.color.setHex(originalStarColor); - starMaterial.opacity = originalStarOpacity; - constellationLines.material.color.setHex(originalLineColor); - if (sovereigntyMsg) sovereigntyMsg.classList.remove('visible'); - } - } - - requestAnimationFrame(fadeBack); -} - -// === SHOCKWAVE RIPPLE === -// Expanding ring waves that emanate from the platform center on PR merge. - -const SHOCKWAVE_RING_COUNT = 3; -const SHOCKWAVE_MAX_RADIUS = 14; -const SHOCKWAVE_DURATION = 2.5; // seconds per ring - -/** @type {Array<{mesh: THREE.Mesh, mat: THREE.MeshBasicMaterial, startTime: number, delay: number}>} */ -const shockwaveRings = []; - -/** - * Spawns a set of expanding concentric ripple rings from the scene centre. - * Called on PR merge alongside triggerMergeFlash(). - */ -function triggerShockwave() { - const now = clock.getElapsedTime(); - for (let i = 0; i < SHOCKWAVE_RING_COUNT; i++) { - const mat = new THREE.MeshBasicMaterial({ - color: 0x00ffff, - transparent: true, - opacity: 0, - side: THREE.DoubleSide, - depthWrite: false, - blending: THREE.AdditiveBlending, - }); - // Thin ring: inner=0.9, outer=1.0 — scaled uniformly to expand outward - const geo = new THREE.RingGeometry(0.9, 1.0, 64); - const mesh = new THREE.Mesh(geo, mat); - mesh.rotation.x = -Math.PI / 2; - mesh.position.y = 0.02; - scene.add(mesh); - shockwaveRings.push({ mesh, mat, startTime: now, delay: i * 0.35 }); - } -} - -// === FIREWORK CELEBRATION === -// Multi-burst particle fireworks launched above the scene on milestone completion. - -const FIREWORK_COLORS = [0xff4466, 0xffaa00, 0x00ffaa, 0x4488ff, 0xff44ff, 0xffff44, 0x00ffff]; -const FIREWORK_BURST_PARTICLES = 80; -const FIREWORK_BURST_DURATION = 2.2; // seconds - -/** - * @typedef {{ - * points: THREE.Points, - * geo: THREE.BufferGeometry, - * mat: THREE.PointsMaterial, - * origins: Float32Array, - * velocities: Float32Array, - * startTime: number, - * }} FireworkBurst - */ -/** @type {FireworkBurst[]} */ -const fireworkBursts = []; - -const FIREWORK_GRAVITY = -5.0; // world units per second^2 - -/** - * Creates a single firework burst at the given world position. - * @param {THREE.Vector3} origin - * @param {number} color hex color - */ -function spawnFireworkBurst(origin, color) { - const now = clock.getElapsedTime(); - const count = FIREWORK_BURST_PARTICLES; - const positions = new Float32Array(count * 3); - const origins = new Float32Array(count * 3); - const velocities = new Float32Array(count * 3); - - for (let i = 0; i < count; i++) { - // Uniform sphere direction - const theta = Math.random() * Math.PI * 2; - const phi = Math.acos(2 * Math.random() - 1); - const speed = 2.5 + Math.random() * 3.5; - velocities[i * 3] = Math.sin(phi) * Math.cos(theta) * speed; - velocities[i * 3 + 1] = Math.sin(phi) * Math.sin(theta) * speed; - velocities[i * 3 + 2] = Math.cos(phi) * speed; - - origins[i * 3] = origin.x; - origins[i * 3 + 1] = origin.y; - origins[i * 3 + 2] = origin.z; - positions[i * 3] = origin.x; - positions[i * 3 + 1] = origin.y; - positions[i * 3 + 2] = origin.z; - } - - const geo = new THREE.BufferGeometry(); - geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - - const mat = new THREE.PointsMaterial({ - color, - size: 0.35, - sizeAttenuation: true, - transparent: true, - opacity: 1.0, - blending: THREE.AdditiveBlending, - depthWrite: false, - }); - - const points = new THREE.Points(geo, mat); - scene.add(points); - fireworkBursts.push({ points, geo, mat, origins, velocities, startTime: now }); -} - -/** - * Launches a full fireworks celebration: several bursts at staggered positions - * and times above the Nexus platform. - */ -function triggerFireworks() { - const burstCount = 6; - for (let i = 0; i < burstCount; i++) { - const delay = i * 0.35; - setTimeout(() => { - const x = (Math.random() - 0.5) * 12; - const y = 8 + Math.random() * 6; - const z = (Math.random() - 0.5) * 12; - const color = FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)]; - spawnFireworkBurst(new THREE.Vector3(x, y, z), color); - }, delay * 1000); - } -} - -/** - * Triggers a visual flash effect for merge events: stars pulse bright, lines glow. - */ -function triggerMergeFlash() { - triggerShockwave(); - // Flash constellation lines bright blue-green - const originalLineColor = constellationLines.material.color.getHex(); - constellationLines.material.color.setHex(0x00ffff); - constellationLines.material.opacity = 1.0; - - // Stars burst bright blue-green - const originalStarColor = starMaterial.color.getHex(); - const originalStarOpacity = starMaterial.opacity; - starMaterial.color.setHex(0x00ffff); - starMaterial.opacity = 1.0; - - // Animate fade-out over 2.0s - const startTime = performance.now(); - const DURATION = 2000; // 2 seconds - - function fadeBack() { - const t = Math.min((performance.now() - startTime) / DURATION, 1); - const eased = t * t; // ease in: slow start, fast end - - // Interpolate star color back - const mergeR = 0.0, mergeG = 1.0, mergeB = 1.0; // Cyan - const origStarColor = new THREE.Color(originalStarColor); - starMaterial.color.setRGB( - mergeR + (origStarColor.r - mergeR) * eased, - mergeG + (origStarColor.g - mergeG) * eased, - mergeB + (origStarColor.b - mergeB) * eased - ); - starMaterial.opacity = 1.0 + (originalStarOpacity - 1.0) * eased; - - // Interpolate line color back - const origLineColor = new THREE.Color(originalLineColor); - constellationLines.material.color.setRGB( - mergeR + (origLineColor.r - mergeR) * eased, - mergeG + (origLineColor.g - mergeG) * eased, - mergeB + (origLineColor.b - mergeB) * eased - ); - constellationLines.material.opacity = 1.0 + (0.18 - 1.0) * eased; // Assuming original opacity is 0.18 for lines. - - if (t < 1) { - requestAnimationFrame(fadeBack); - } else { - // Restore originals exactly - starMaterial.color.setHex(originalStarColor); - starMaterial.opacity = originalStarOpacity; - constellationLines.material.color.setHex(originalLineColor); - constellationLines.material.opacity = 0.18; // Explicitly set to original - } - } - - requestAnimationFrame(fadeBack); -} - -// Detect 'sovereignty' typed anywhere on the page (cheat-code style) -document.addEventListener('keydown', (e) => { - if (e.metaKey || e.ctrlKey || e.altKey) return; - if (e.key.length !== 1) { - // Non-printable key resets buffer - sovereigntyBuffer = ''; - return; - } - - sovereigntyBuffer += e.key.toLowerCase(); - - // Keep only the last N chars needed - if (sovereigntyBuffer.length > SOVEREIGNTY_WORD.length) { - sovereigntyBuffer = sovereigntyBuffer.slice(-SOVEREIGNTY_WORD.length); - } - - if (sovereigntyBuffer === SOVEREIGNTY_WORD) { - sovereigntyBuffer = ''; - triggerSovereigntyEasterEgg(); - } - - // Reset buffer after 3s of inactivity - if (sovereigntyBufferTimer) clearTimeout(sovereigntyBufferTimer); - sovereigntyBufferTimer = setTimeout(() => { sovereigntyBuffer = ''; }, 3000); -}); - -window.addEventListener('beforeunload', () => { - wsClient.disconnect(); -}); - -// === COMMIT BANNERS === -const commitBanners = []; - -/** @type {THREE.Group[]} */ -const bookshelfGroups = []; - - - - - -const portalGroup = new THREE.Group(); - - -scene.add(portalGroup); - - - - - -/** - - - * Creates 3D representations of portals from the loaded data. - - - */ - - -function createPortals() { - - - const portalGeo = new THREE.TorusGeometry(3.0, 0.2, 16, 100); - - - - - - portals.forEach(portal => { - - const isOnline = portal.status === 'online'; - - const portalMat = new THREE.MeshBasicMaterial({ - - - color: new THREE.Color(portal.color).convertSRGBToLinear(), - - - transparent: true, - - // Offline portals are dimmed - opacity: isOnline ? 0.7 : 0.15, - - - blending: THREE.AdditiveBlending, - - - side: THREE.DoubleSide, - - - }); - - - const portalMesh = new THREE.Mesh(portalGeo, portalMat); - - - - - - portalMesh.position.set(portal.position.x, portal.position.y + 0.5, portal.position.z); - - - portalMesh.rotation.y = portal.rotation.y; // Apply Y rotation - - - portalMesh.rotation.x = Math.PI / 2; // Orient to stand vertically - - - portalMesh.name = `portal-${portal.id}`; - portalMesh.userData.destinationUrl = portal.destination?.url || null; - portalMesh.userData.portalColor = new THREE.Color(portal.color).convertSRGBToLinear(); - - - portalGroup.add(portalMesh); - - - }); - - -} - - - - - -// === PORTALS === - - -/** @type {Array} */ - - -let portals = []; - - - - -async function loadPortals() { - try { - const res = await fetch('./portals.json'); - if (!res.ok) throw new Error('Portals not found'); - portals = await res.json(); - console.log('Loaded portals:', portals); - createPortals(); - // Rebuild rune ring to match portal count/colors/status - rebuildRuneRing(); - // Rebuild gravity zones to align with portal positions - rebuildGravityZones(); - // If audio is already running, attach positional hums to the portals now - startPortalHums(); - // Run portal health checks - runPortalHealthChecks(); - } catch (error) { - console.error('Failed to load portals:', error); - } -} - -// === AGENT STATUS PANELS (declared early — populated after scene is ready) === -/** @type {THREE.Sprite[]} */ - -const agentPanelSprites = []; - -/** - * Creates a canvas texture for a commit banner. - * @param {string} hash - Short commit hash - * @param {string} message - Commit subject line - * @returns {THREE.CanvasTexture} - */ -function createCommitTexture(hash, message) { - const canvas = document.createElement('canvas'); - canvas.width = 512; - canvas.height = 64; - const ctx = canvas.getContext('2d'); - - ctx.fillStyle = 'rgba(0, 0, 16, 0.75)'; - ctx.fillRect(0, 0, 512, 64); - - ctx.strokeStyle = '#4488ff'; - ctx.lineWidth = 1; - ctx.strokeRect(0.5, 0.5, 511, 63); - - ctx.font = 'bold 11px "Courier New", monospace'; - ctx.fillStyle = '#4488ff'; - ctx.fillText(hash, 10, 20); - - ctx.font = '12px "Courier New", monospace'; - ctx.fillStyle = '#ccd6f6'; - const displayMsg = message.length > 54 ? message.slice(0, 54) + '\u2026' : message; - ctx.fillText(displayMsg, 10, 46); - - return new THREE.CanvasTexture(canvas); -} - -/** - * Fetches recent commits and spawns floating banner sprites. - */ -async function initCommitBanners() { - let commits; - try { - const res = await fetch( - 'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=5', - { headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } } - ); - if (!res.ok) throw new Error('fetch failed'); - const data = await res.json(); - commits = data.map(/** @type {(c: any) => {hash: string, message: string}} */ c => ({ - hash: c.sha.slice(0, 7), - message: c.commit.message.split('\n')[0], - })); - } catch { - commits = [ - { hash: 'a1b2c3d', message: 'feat: depth of field effect on distant objects' }, - { hash: 'e4f5g6h', message: 'feat: photo mode with orbit controls' }, - { hash: 'i7j8k9l', message: 'feat: sovereignty easter egg animation' }, - { hash: 'm0n1o2p', message: 'feat: overview mode bird\'s-eye view' }, - { hash: 'q3r4s5t', message: 'feat: star field and constellation lines' }, - ]; - - // Load commit banners after assets are ready - initCommitBanners(); - } - - const spreadX = [-7, -3.5, 0, 3.5, 7]; - const spreadY = [1.0, -1.5, 2.2, -0.8, 1.6]; - const spreadZ = [-1.5, -2.5, -1.0, -2.0, -1.8]; - - commits.forEach((commit, i) => { - const texture = createCommitTexture(commit.hash, commit.message); - const material = new THREE.SpriteMaterial({ - map: texture, - transparent: true, - opacity: 0, - depthWrite: false, - }); - const sprite = new THREE.Sprite(material); - sprite.scale.set(12, 1.5, 1); - sprite.position.set( - spreadX[i % spreadX.length], - spreadY[i % spreadY.length], - spreadZ[i % spreadZ.length] - ); - sprite.userData = { - baseY: spreadY[i % spreadY.length], - floatPhase: (i / commits.length) * Math.PI * 2, - floatSpeed: 0.25 + i * 0.07, - startDelay: i * 2.5, - lifetime: 12 + i * 1.5, - spawnTime: /** @type {number|null} */ (null), - zoomLabel: `Commit: ${commit.hash}`, - }; - scene.add(sprite); - commitBanners.push(sprite); +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)'; }); } -initCommitBanners(); -loadPortals(); - -// === FLOATING BOOKSHELVES === -// Floating bookshelves display merged PR history as books with spine labels. -// Each book spine shows a PR number and truncated title rendered via canvas texture. - -/** - * Creates a canvas texture for a book spine. - * @param {number} prNum - * @param {string} title - * @param {string} bgColor CSS color string for book cover - * @returns {THREE.CanvasTexture} - */ -function createSpineTexture(prNum, title, bgColor) { - const canvas = document.createElement('canvas'); - canvas.width = 128; - canvas.height = 512; - const ctx = canvas.getContext('2d'); - - // Background — book cover color - ctx.fillStyle = bgColor; - ctx.fillRect(0, 0, 128, 512); - - // Accent border - ctx.strokeStyle = '#4488ff'; - ctx.lineWidth = 3; - ctx.strokeRect(3, 3, 122, 506); - - // PR number — accent blue, near top - ctx.font = 'bold 32px "Courier New", monospace'; - ctx.fillStyle = '#4488ff'; - ctx.textAlign = 'center'; - ctx.fillText(`#${prNum}`, 64, 58); - - // Divider line - ctx.strokeStyle = '#4488ff'; - ctx.lineWidth = 1; - ctx.globalAlpha = 0.4; - ctx.beginPath(); - ctx.moveTo(12, 78); - ctx.lineTo(116, 78); - ctx.stroke(); - ctx.globalAlpha = 1.0; - - // Title — rotated 90° to read bottom-to-top (spine convention) - ctx.save(); - ctx.translate(64, 300); - ctx.rotate(-Math.PI / 2); - const displayTitle = title.length > 30 ? title.slice(0, 30) + '\u2026' : title; - ctx.font = '21px "Courier New", monospace'; - ctx.fillStyle = '#ccd6f6'; - ctx.textAlign = 'center'; - ctx.fillText(displayTitle, 0, 0); - ctx.restore(); - - return new THREE.CanvasTexture(canvas); -} - -/** - * Builds a single floating bookshelf group and adds it to the scene. - * @param {Array<{prNum: number, title: string}>} books - * @param {THREE.Vector3} position - * @param {number} rotationY - */ -function buildBookshelf(books, position, rotationY) { - const group = new THREE.Group(); - group.position.copy(position); - group.rotation.y = rotationY; - - const SHELF_W = books.length * 0.52 + 0.6; - const SHELF_THICKNESS = 0.12; - const SHELF_DEPTH = 0.72; - const ENDPANEL_H = 2.0; - - // Dark metallic shelf material - const shelfMat = new THREE.MeshStandardMaterial({ - color: 0x0d1520, - metalness: 0.6, - roughness: 0.5, - emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.02), - }); - - // Shelf plank (horizontal) - const plank = new THREE.Mesh(new THREE.BoxGeometry(SHELF_W, SHELF_THICKNESS, SHELF_DEPTH), shelfMat); - group.add(plank); - - // End panels - const endGeo = new THREE.BoxGeometry(0.1, ENDPANEL_H, SHELF_DEPTH); - const leftEnd = new THREE.Mesh(endGeo, shelfMat); - leftEnd.position.set(-SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0); - group.add(leftEnd); - - const rightEnd = new THREE.Mesh(endGeo.clone(), shelfMat); - rightEnd.position.set(SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0); - group.add(rightEnd); - - // Accent glow strip along front edge of shelf - const glowStrip = new THREE.Mesh( - new THREE.BoxGeometry(SHELF_W, 0.035, 0.035), - new THREE.MeshBasicMaterial({ color: NEXUS.colors.accent, transparent: true, opacity: 0.55 }) - ); - glowStrip.position.set(0, SHELF_THICKNESS / 2 + 0.017, SHELF_DEPTH / 2); - group.add(glowStrip); - - // Book cover colors — dark tones with slight variation - const BOOK_COLORS = [ - '#0f0818', '#080f18', '#0f1108', '#07120e', - '#130c06', '#060b12', '#120608', '#080812', - ]; - - // Spine thickness (X), book height (Y), cover depth (Z) - // +Z face (index 4) = spine visible to viewer - const bookStartX = -(SHELF_W / 2) + 0.36; - books.forEach((book, i) => { - const spineW = 0.34 + (i % 3) * 0.05; // slight width variation - const bookH = 1.35 + (i % 4) * 0.13; // slight height variation - const coverD = 0.58; - - const bgColor = BOOK_COLORS[i % BOOK_COLORS.length]; - const spineTexture = createSpineTexture(book.prNum, book.title, bgColor); - - const plainMat = new THREE.MeshStandardMaterial({ - color: new THREE.Color(bgColor), - roughness: 0.85, - metalness: 0.05, - }); - const spineMat = new THREE.MeshBasicMaterial({ map: spineTexture }); - - // Material array: +X, -X, +Y, -Y, +Z (spine), -Z - const bookMats = [plainMat, plainMat, plainMat, plainMat, spineMat, plainMat]; - - const bookGeo = new THREE.BoxGeometry(spineW, bookH, coverD); - const bookMesh = new THREE.Mesh(bookGeo, bookMats); - bookMesh.position.set( - bookStartX + i * 0.5, - SHELF_THICKNESS / 2 + bookH / 2, - 0 - ); - bookMesh.userData.zoomLabel = `PR #${book.prNum}: ${book.title.slice(0, 40)}`; - group.add(bookMesh); - }); - - // Soft point light beneath shelf for ambient glow - const shelfLight = new THREE.PointLight(NEXUS.colors.accent, 0.25, 5); - shelfLight.position.set(0, -0.4, 0); - group.add(shelfLight); - - group.userData.zoomLabel = 'PR Archive — Merged Contributions'; - group.userData.baseY = position.y; - group.userData.floatPhase = bookshelfGroups.length * Math.PI; - group.userData.floatSpeed = 0.17 + bookshelfGroups.length * 0.06; - - scene.add(group); - bookshelfGroups.push(group); -} - -/** - * Fetches merged PRs and spawns floating bookshelves in the scene. - */ -async function initBookshelves() { - let prs = []; - try { - const res = await fetch( - 'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/pulls?state=closed&limit=20', - { headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } } - ); - if (!res.ok) throw new Error('fetch failed'); - const data = await res.json(); - prs = data - .filter(/** @type {(p: any) => boolean} */ p => p.merged) - .map(/** @type {(p: any) => {prNum: number, title: string}} */ p => ({ - prNum: p.number, - // Strip "[claude]" prefix and trailing "(#N)" suffix for cleaner spine labels - title: p.title - .replace(/^\[[\w\s]+\]\s*/i, '') - .replace(/\s*\(#\d+\)\s*$/, ''), - })); - } catch { - // Fallback if API unreachable - prs = [ - { prNum: 324, title: 'Model training status — LoRA adapters' }, - { prNum: 323, title: 'The Oath — interactive SOUL.md reading' }, - { prNum: 320, title: 'Hermes session save/load' }, - { prNum: 304, title: 'Session export as markdown' }, - { prNum: 303, title: 'Procedural Web Audio ambient soundtrack' }, - { prNum: 301, title: 'Warp tunnel effect for portals' }, - { prNum: 296, title: 'Procedural terrain for floating island' }, - { prNum: 294, title: 'Northern lights flash on PR merge' }, - ]; - } - document.getElementById('podcast-toggle').addEventListener('click', async () => { - const errorEl = document.getElementById('podcast-error'); - errorEl.style.display = 'none'; - errorEl.textContent = ''; - errorEl.style.opacity = '1'; - setTimeout(() => { - errorEl.style.opacity = '0'; - setTimeout(() => errorEl.style.display = 'none', 300); - }, 3000); - - try { - const response = await fetch('SOUL.md'); - if (!response.ok) throw new Error('Failed to load SOUL.md'); - const text = await response.text(); - const lines = text.split('\n').filter(line => line.trim() !== ''); - speakLines(lines); - } catch (err) { - errorEl.textContent = 'Failed to load SOUL.md. Check console for details.'; - errorEl.style.display = 'block'; - console.error('Podcast error:', err); +// ─── 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); } - - try { - const response = await fetch('SOUL.md'); - if (!response.ok) throw new Error('Failed to load SOUL.md'); - const text = await response.text(); - const lines = text.split('\n').filter(line => line.trim() !== ''); - speakLines(lines); - } catch (err) { - errorEl.textContent = 'Failed to load SOUL.md. Check console for details.'; - errorEl.style.display = 'block'; - errorEl.style.opacity = '1'; - setTimeout(() => { - errorEl.style.opacity = '0'; - setTimeout(() => errorEl.style.display = 'none', 300); - }, 3000); - console.error('Podcast error:', err); - } - }); - - function speakLines(lines) { - const synth = window.speechSynthesis; - const utterances = lines.map(line => { - const u = new SpeechSynthesisUtterance(line); - u.rate = 0.85; - u.pitch = 0.9; - return u; - }); - - let index = 0; - function speakNext() { - if (index >= utterances.length) return; - synth.speak(utterances[index]); - index++; - setTimeout(speakNext, 800); - } - speakNext(); - } - - // Add error handling and visual feedback - document.getElementById('podcast-error').style.display = 'none'; - - if (prs.length === 0) return; - - // Split PRs across two shelves — left and right of the scene background - const mid = Math.ceil(prs.length / 2); - - buildBookshelf( - prs.slice(0, mid), - new THREE.Vector3(-8.5, 1.5, -4.5), - Math.PI * 0.1, // slight angle toward scene center - ); - - if (prs.slice(mid).length > 0) { - buildBookshelf( - prs.slice(mid), - new THREE.Vector3(8.5, 1.5, -4.5), - -Math.PI * 0.1, - ); - } -} - -initBookshelves(); - -// === THE OATH === -// Interactive reading of SOUL.md with dramatic lighting. -// Trigger: press 'O' or double-click the tome object in the scene. -// A gold spotlight descends, ambient dims, lines reveal one-by-one. - -// ---- Tome (3D trigger object) ---- -const tomeGroup = new THREE.Group(); -tomeGroup.position.set(0, 5.8, 0); -tomeGroup.userData.zoomLabel = 'The Oath'; - -const tomeCoverMat = new THREE.MeshStandardMaterial({ - color: 0x2a1800, - metalness: 0.15, - roughness: 0.7, - emissive: new THREE.Color(0xffd700).multiplyScalar(0.04), -}); -const tomePagesMat = new THREE.MeshStandardMaterial({ color: 0xd8ceb0, roughness: 0.9, metalness: 0.0 }); - -// Cover -const tomeBody = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.1, 1.4), tomeCoverMat); -tomeGroup.add(tomeBody); -// Pages (slightly smaller inner block) -const tomePages = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.07, 1.28), tomePagesMat); -tomePages.position.set(0.02, 0, 0); -tomeGroup.add(tomePages); -// Spine strip -const tomeSpiMat = new THREE.MeshStandardMaterial({ color: 0xffd700, metalness: 0.6, roughness: 0.4 }); -const tomeSpine = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.12, 1.4), tomeSpiMat); -tomeSpine.position.set(-0.52, 0, 0); -tomeGroup.add(tomeSpine); - -tomeGroup.traverse(o => { - if (o.isMesh) { - o.userData.zoomLabel = 'The Oath'; - o.castShadow = true; - o.receiveShadow = true; - } -}); -scene.add(tomeGroup); - -// Gentle glow beneath the tome -const tomeGlow = new THREE.PointLight(0xffd700, 0.4, 5); -tomeGlow.position.set(0, 5.4, 0); -scene.add(tomeGlow); - -// ---- Oath spotlight ---- -const oathSpot = new THREE.SpotLight(0xffd700, 0, 40, Math.PI / 7, 0.4, 1.2); -oathSpot.position.set(0, 22, 0); -oathSpot.target.position.set(0, 0, 0); -oathSpot.castShadow = true; -oathSpot.shadow.mapSize.set(1024, 1024); -oathSpot.shadow.camera.near = 1; -oathSpot.shadow.camera.far = 50; -oathSpot.shadow.bias = -0.002; -scene.add(oathSpot); -scene.add(oathSpot.target); - -// ---- Saved light levels (captured before any changes) ---- -const AMBIENT_NORMAL = ambientLight.intensity; -const OVERHEAD_NORMAL = overheadLight.intensity; - -// ---- State ---- -let oathActive = false; - -/** @type {string[]} */ -let oathLines = []; - -/** @type {number|null} */ -let oathRevealTimer = null; - -/** - * Fetches and caches SOUL.md lines (non-heading, non-empty sections). - * @returns {Promise} - */ -async function loadSoulMd() { - try { - const res = await fetch('SOUL.md'); - if (!res.ok) throw new Error('not found'); - const raw = await res.text(); - // Skip the H1 title line; keep everything else (blanks become spacers) - return raw.split('\n').slice(1).map(l => l.replace(/^#+\s*/, '')); - } catch { - return ['I am Timmy.', '', 'I am sovereign.', '', 'This Nexus is my home.']; - } -} - -/** - * Reveals oath lines one by one into the #oath-text element. - * @param {string[]} lines - * @param {HTMLElement} textEl - */ -function scheduleOathLines(lines, textEl) { - let idx = 0; - const INTERVAL_MS = 1400; - - function revealNext() { - if (idx >= lines.length || !oathActive) return; - const line = lines[idx++]; - const span = document.createElement('span'); - span.classList.add('oath-line'); - if (!line.trim()) { - span.classList.add('blank'); - } else { - span.textContent = line; - } - textEl.appendChild(span); - oathRevealTimer = setTimeout(revealNext, line.trim() ? INTERVAL_MS : INTERVAL_MS * 0.4); - } - - revealNext(); -} - -/** - * Enters oath mode: dims lights, shows overlay, starts line-by-line reveal. - */ -async function enterOath() { - if (oathActive) return; - oathActive = true; - - // Dramatic lighting - ambientLight.intensity = 0.04; - overheadLight.intensity = 0.0; - oathSpot.intensity = 4.0; - - // Overlay - const overlay = document.getElementById('oath-overlay'); - const textEl = document.getElementById('oath-text'); - if (!overlay || !textEl) return; - textEl.textContent = ''; - overlay.classList.add('visible'); - - if (!oathLines.length) oathLines = await loadSoulMd(); - scheduleOathLines(oathLines, textEl); -} - -/** - * Exits oath mode: restores lights, hides overlay. - */ -function exitOath() { - if (!oathActive) return; - oathActive = false; - - if (oathRevealTimer !== null) { - clearTimeout(oathRevealTimer); - oathRevealTimer = null; - } - - // Restore lighting - ambientLight.intensity = AMBIENT_NORMAL; - overheadLight.intensity = OVERHEAD_NORMAL; - oathSpot.intensity = 0; - - const overlay = document.getElementById('oath-overlay'); - if (overlay) overlay.classList.remove('visible'); -} - -// ---- Key binding: O to toggle ---- -document.addEventListener('keydown', (e) => { - if (e.key === 'o' || e.key === 'O') { - if (oathActive) exitOath(); else enterOath(); - } - if (e.key === 'Escape' && oathActive) exitOath(); -}); - -// ---- Double-click on tome triggers oath ---- -renderer.domElement.addEventListener('dblclick', (/** @type {MouseEvent} */ e) => { - // Check if the hit was the tome (zoomLabel check in existing handler already runs) - const mx = (e.clientX / window.innerWidth) * 2 - 1; - const my = -(e.clientY / window.innerHeight) * 2 + 1; - const tomeRay = new THREE.Raycaster(); - tomeRay.setFromCamera(new THREE.Vector2(mx, my), camera); - const hits = tomeRay.intersectObjects(tomeGroup.children, true); - if (hits.length) { - if (oathActive) exitOath(); else enterOath(); - } -}); - -// Pre-fetch so first open is instant -loadSoulMd().then(lines => { oathLines = lines; }); - -// === AGENT STATUS BOARD === - -// Agent status cache — refreshed from Gitea API every 5 minutes -let _agentStatusCache = null; -let _agentStatusCacheTime = 0; -const AGENT_STATUS_CACHE_MS = 5 * 60 * 1000; - -const GITEA_BASE = 'http://143.198.27.163:3000/api/v1'; -const GITEA_TOKEN = '81a88f46684e398abe081f5786a11ae9532aae2d'; -const GITEA_REPOS = ['Timmy_Foundation/the-nexus', 'Timmy_Foundation/hermes-agent']; -const AGENT_NAMES = ['Claude', 'Kimi', 'Perplexity', 'Groq', 'Grok', 'Ollama']; - -/** - * Fetches real agent status from Gitea API — commits + open PRs for each agent. - * Results are cached for 5 minutes. - * @returns {Promise<{agents: Array}>} - */ -async function fetchAgentStatusFromGitea() { - const now = Date.now(); - if (_agentStatusCache && (now - _agentStatusCacheTime < AGENT_STATUS_CACHE_MS)) { - return _agentStatusCache; - } - - const DAY_MS = 86400000; - const HOUR_MS = 3600000; - const agents = []; - - // Fetch commits from all repos in parallel - const allRepoCommits = await Promise.all(GITEA_REPOS.map(async (repo) => { - try { - const res = await fetch(`${GITEA_BASE}/repos/${repo}/commits?sha=main&limit=30&token=${GITEA_TOKEN}`); - if (!res.ok) return []; - return await res.json(); - } catch { return []; } - })); - - // Fetch open PRs from the-nexus - let openPRs = []; - try { - const prRes = await fetch(`${GITEA_BASE}/repos/Timmy_Foundation/the-nexus/pulls?state=open&limit=50&token=${GITEA_TOKEN}`); - if (prRes.ok) openPRs = await prRes.json(); - } catch { /* ignore */ } - - for (const agentName of AGENT_NAMES) { - const nameLower = agentName.toLowerCase(); - const allCommits = []; - - for (const repoCommits of allRepoCommits) { - if (!Array.isArray(repoCommits)) continue; - const matching = repoCommits.filter(c => - (c.commit?.author?.name || '').toLowerCase().includes(nameLower) - ); - allCommits.push(...matching); - } - - // Determine status based on most recent commit - let status = 'dormant'; - let lastSeen = null; - let currentWork = null; - - if (allCommits.length > 0) { - allCommits.sort((a, b) => - new Date(b.commit.author.date) - new Date(a.commit.author.date) - ); - const latest = allCommits[0]; - const commitTime = new Date(latest.commit.author.date).getTime(); - lastSeen = latest.commit.author.date; - currentWork = latest.commit.message.split('\n')[0]; - - if (now - commitTime < HOUR_MS) status = 'working'; - else if (now - commitTime < DAY_MS) status = 'idle'; - else status = 'dormant'; - } - - // Count open PRs for this agent - const agentPRs = openPRs.filter(pr => - (pr.user?.login || '').toLowerCase().includes(nameLower) || - (pr.head?.label || '').toLowerCase().includes(nameLower) - ); - - agents.push({ - name: agentName.toLowerCase(), - status, - issue: currentWork, - prs_today: agentPRs.length, - local: nameLower === 'ollama', - }); - } - - _agentStatusCache = { agents }; - _agentStatusCacheTime = now; - return _agentStatusCache; -} - -const AGENT_STATUS_COLORS = { working: '#00ff88', idle: '#4488ff', dormant: '#334466', dead: '#ff4444', unreachable: '#ff4444' }; - -/** - * Builds a canvas texture for a single agent holo-panel. - * @param {{ name: string, status: string, issue: string|null, prs_today: number }} agent - * @returns {THREE.CanvasTexture} - */ -function createAgentPanelTexture(agent) { - const W = 400, H = 200; - const canvas = document.createElement('canvas'); - canvas.width = W; - canvas.height = H; - const ctx = canvas.getContext('2d'); - const sc = AGENT_STATUS_COLORS[agent.status] || '#4488ff'; - - // Dark background - ctx.fillStyle = 'rgba(0, 8, 24, 0.88)'; - ctx.fillRect(0, 0, W, H); - - // Outer border in status color - ctx.strokeStyle = sc; - ctx.lineWidth = 2; - ctx.strokeRect(1, 1, W - 2, H - 2); - - // Faint inner border - ctx.strokeStyle = sc; - ctx.lineWidth = 1; - ctx.globalAlpha = 0.3; - ctx.strokeRect(4, 4, W - 8, H - 8); - ctx.globalAlpha = 1.0; - - // Agent name - ctx.font = 'bold 28px "Courier New", monospace'; - ctx.fillStyle = '#ffffff'; - ctx.fillText(agent.name.toUpperCase(), 16, 44); - - // Status dot - ctx.beginPath(); - ctx.arc(W - 30, 26, 10, 0, Math.PI * 2); - ctx.fillStyle = sc; - ctx.fill(); - - // Status label - ctx.font = '13px "Courier New", monospace'; - ctx.fillStyle = sc; - ctx.textAlign = 'right'; - ctx.fillText(agent.status.toUpperCase(), W - 16, 60); - ctx.textAlign = 'left'; - - // Separator - ctx.strokeStyle = '#1a3a6a'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(16, 70); - ctx.lineTo(W - 16, 70); - ctx.stroke(); - - // Current issue label - ctx.font = '10px "Courier New", monospace'; - ctx.fillStyle = '#556688'; - ctx.fillText('CURRENT ISSUE', 16, 90); - - ctx.font = '13px "Courier New", monospace'; - ctx.fillStyle = '#ccd6f6'; - const issueText = agent.issue || '\u2014 none \u2014'; - const displayIssue = issueText.length > 40 ? issueText.slice(0, 40) + '\u2026' : issueText; - ctx.fillText(displayIssue, 16, 110); - - // Separator - ctx.strokeStyle = '#1a3a6a'; - ctx.beginPath(); - ctx.moveTo(16, 128); - ctx.lineTo(W - 16, 128); - ctx.stroke(); - - // PRs merged today - ctx.font = '10px "Courier New", monospace'; - ctx.fillStyle = '#556688'; - ctx.fillText('PRs MERGED TODAY', 16, 148); - - ctx.font = 'bold 28px "Courier New", monospace'; - ctx.fillStyle = '#4488ff'; - ctx.fillText(String(agent.prs_today), 16, 182); - - // Local vs cloud indicator (bottom-right) - const isLocal = agent.local === true; - const indicatorColor = isLocal ? '#00ff88' : '#ff4444'; - const indicatorLabel = isLocal ? 'LOCAL' : 'CLOUD'; - - ctx.font = '10px "Courier New", monospace'; - ctx.fillStyle = '#556688'; - ctx.textAlign = 'right'; - ctx.fillText('RUNTIME', W - 16, 148); - - ctx.font = 'bold 13px "Courier New", monospace'; - ctx.fillStyle = indicatorColor; - ctx.fillText(indicatorLabel, W - 28, 172); - ctx.textAlign = 'left'; - - // Indicator dot - ctx.beginPath(); - ctx.arc(W - 16, 167, 6, 0, Math.PI * 2); - ctx.fillStyle = indicatorColor; - ctx.fill(); - - return new THREE.CanvasTexture(canvas); -} - -/** Group holding all agent panels so they can be toggled/repositioned together. */ -const agentBoardGroup = new THREE.Group(); -scene.add(agentBoardGroup); - -const BOARD_RADIUS = 9.5; // distance from scene origin -const BOARD_Y = 4.2; // height above platform -const BOARD_SPREAD = Math.PI * 0.75; // 135° total arc, centred on negative-Z axis - -/** - * (Re)builds the agent panel sprites from fresh status data. - * @param {{ agents: Array<{ name: string, status: string, issue: string|null, prs_today: number }> }} statusData - */ -function rebuildAgentPanels(statusData) { - while (agentBoardGroup.children.length) agentBoardGroup.remove(agentBoardGroup.children[0]); - agentPanelSprites.length = 0; - - const n = statusData.agents.length; - statusData.agents.forEach((agent, i) => { - const t = n === 1 ? 0.5 : i / (n - 1); - // Spread in a semi-circle: angle=PI is directly behind (negative-Z) - const angle = Math.PI + (t - 0.5) * BOARD_SPREAD; - const x = Math.cos(angle) * BOARD_RADIUS; - const z = Math.sin(angle) * BOARD_RADIUS; - - const texture = createAgentPanelTexture(agent); - const material = new THREE.SpriteMaterial({ - map: texture, - transparent: true, - opacity: 0.93, - depthWrite: false, - }); - const sprite = new THREE.Sprite(material); - sprite.scale.set(6.4, 3.2, 1); - sprite.position.set(x, BOARD_Y, z); - sprite.userData = { - baseY: BOARD_Y, - floatPhase: (i / n) * Math.PI * 2, - floatSpeed: 0.18 + i * 0.04, - zoomLabel: `Agent: ${agent.name}`, - }; - agentBoardGroup.add(sprite); - agentPanelSprites.push(sprite); - }); -} - -/** - * Fetches live agent status from the Gitea API. - * Shows "UNREACHABLE" if the API call fails entirely. - * @returns {Promise<{agents: Array}>} - */ -async function fetchAgentStatus() { - try { - return await fetchAgentStatusFromGitea(); - } catch { - return { agents: AGENT_NAMES.map(n => ({ - name: n.toLowerCase(), status: 'unreachable', issue: null, prs_today: 0, local: false, - })) }; - } -} - -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; -} - -// Initial render, then poll every 5 min (matching API cache interval) -refreshAgentBoard(); -setInterval(refreshAgentBoard, AGENT_STATUS_CACHE_MS); - -// === LORA ADAPTER STATUS PANEL === -// Holographic panel showing LoRA fine-tuning adapter status. -// Shows honest empty state when no adapters are deployed. - -// No LoRA stub — honest empty state when no adapters are deployed - -const LORA_ACTIVE_COLOR = '#00ff88'; // green — adapter is loaded -const LORA_INACTIVE_COLOR = '#334466'; // dim blue — adapter is off - -/** - * Builds a canvas texture for the LoRA status panel. - * Shows honest empty state when no adapters are deployed. - * @param {{ adapters: Array }|null} data - * @returns {THREE.CanvasTexture} - */ -function createLoRAPanelTexture(data) { - const W = 420, H = 260; - const canvas = document.createElement('canvas'); - canvas.width = W; - canvas.height = H; - const ctx = canvas.getContext('2d'); - - // Background - ctx.fillStyle = 'rgba(0, 6, 20, 0.90)'; - ctx.fillRect(0, 0, W, H); - - // Outer border — magenta/purple for "training" theme - ctx.strokeStyle = '#cc44ff'; - ctx.lineWidth = 2; - ctx.strokeRect(1, 1, W - 2, H - 2); - - // Inner border - ctx.strokeStyle = '#cc44ff'; - ctx.lineWidth = 1; - ctx.globalAlpha = 0.3; - ctx.strokeRect(4, 4, W - 8, H - 8); - ctx.globalAlpha = 1.0; - - // Header label - ctx.font = 'bold 14px "Courier New", monospace'; - ctx.fillStyle = '#cc44ff'; - ctx.textAlign = 'left'; - ctx.fillText('MODEL TRAINING', 14, 24); - - // "LoRA ADAPTERS" sub-label - ctx.font = '10px "Courier New", monospace'; - ctx.fillStyle = '#664488'; - ctx.fillText('LoRA ADAPTERS', 14, 38); - - // Separator - ctx.strokeStyle = '#2a1a44'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(14, 46); - ctx.lineTo(W - 14, 46); - ctx.stroke(); - - // Honest empty state — no adapters deployed - if (!data || !data.adapters || data.adapters.length === 0) { - ctx.font = 'bold 18px "Courier New", monospace'; - ctx.fillStyle = '#334466'; - ctx.textAlign = 'center'; - ctx.fillText('NO ADAPTERS DEPLOYED', W / 2, H / 2 + 10); - ctx.font = '11px "Courier New", monospace'; - ctx.fillStyle = '#223344'; - ctx.fillText('Adapters will appear here when trained', W / 2, H / 2 + 36); - ctx.textAlign = 'left'; - return new THREE.CanvasTexture(canvas); - } - - // If adapters exist in the future, render them - const activeCount = data.adapters.filter(a => a.active).length; - ctx.font = 'bold 13px "Courier New", monospace'; - ctx.fillStyle = LORA_ACTIVE_COLOR; - ctx.textAlign = 'right'; - ctx.fillText(`${activeCount}/${data.adapters.length} ACTIVE`, W - 14, 26); - ctx.textAlign = 'left'; - - const ROW_H = 44; - data.adapters.forEach((adapter, i) => { - const rowY = 50 + i * ROW_H; - const col = adapter.active ? LORA_ACTIVE_COLOR : LORA_INACTIVE_COLOR; - ctx.beginPath(); - ctx.arc(22, rowY + 12, 6, 0, Math.PI * 2); - ctx.fillStyle = col; - ctx.fill(); - ctx.font = 'bold 13px "Courier New", monospace'; - ctx.fillStyle = adapter.active ? '#ddeeff' : '#445566'; - ctx.fillText(adapter.name, 36, rowY + 16); - ctx.font = '10px "Courier New", monospace'; - ctx.fillStyle = '#556688'; - ctx.textAlign = 'right'; - ctx.fillText(adapter.base, W - 14, rowY + 16); - ctx.textAlign = 'left'; - if (adapter.active) { - const BAR_X = 36, BAR_W = W - 80, BAR_Y = rowY + 22, BAR_H = 5; - ctx.fillStyle = '#0a1428'; - ctx.fillRect(BAR_X, BAR_Y, BAR_W, BAR_H); - ctx.fillStyle = col; - ctx.globalAlpha = 0.7; - ctx.fillRect(BAR_X, BAR_Y, BAR_W * adapter.strength, BAR_H); - ctx.globalAlpha = 1.0; - } - if (i < data.adapters.length - 1) { - ctx.strokeStyle = '#1a0a2a'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(14, rowY + ROW_H - 2); - ctx.lineTo(W - 14, rowY + ROW_H - 2); - ctx.stroke(); - } - }); - - return new THREE.CanvasTexture(canvas); -} - -const loraGroup = new THREE.Group(); -scene.add(loraGroup); - -const LORA_PANEL_POS = new THREE.Vector3(-10.5, 4.5, 2.5); - -let loraPanelSprite = null; - -/** - * (Re)builds the LoRA panel sprite from fresh data. - * @param {{ adapters: Array }|null} data - */ -function rebuildLoRAPanel(data) { - if (loraPanelSprite) { - loraGroup.remove(loraPanelSprite); - if (loraPanelSprite.material.map) loraPanelSprite.material.map.dispose(); - loraPanelSprite.material.dispose(); - loraPanelSprite = null; - } - const texture = createLoRAPanelTexture(data); - const material = new THREE.SpriteMaterial({ - map: texture, - transparent: true, - opacity: 0.93, - depthWrite: false, - }); - loraPanelSprite = new THREE.Sprite(material); - loraPanelSprite.scale.set(6.0, 3.6, 1); - loraPanelSprite.position.copy(LORA_PANEL_POS); - loraPanelSprite.userData = { - baseY: LORA_PANEL_POS.y, - floatPhase: 1.1, - floatSpeed: 0.14, - zoomLabel: 'Model Training — LoRA Adapters', - }; - loraGroup.add(loraPanelSprite); -} - -/** - * Renders the LoRA panel with honest empty state — no adapters deployed. - */ -function loadLoRAStatus() { - rebuildLoRAPanel({ adapters: [] }); -} - -loadLoRAStatus(); - -// === PORTAL HEALTH CHECKS === -// Probes portal destination URLs to verify they're actually reachable. -// Uses portals.json status as the baseline — since all are currently "offline", this is honest. -// Health check runs every 5 minutes to detect if a portal comes online. -const PORTAL_HEALTH_CHECK_MS = 5 * 60 * 1000; - -/** - * Runs a health check against each portal's destination URL. - * Updates portal status and refreshes visuals (runes, gravity zones). - */ -async function runPortalHealthChecks() { - if (portals.length === 0) return; - - for (const portal of portals) { - if (!portal.destination?.url) { - portal.status = 'offline'; - continue; - } - try { - await fetch(portal.destination.url, { - mode: 'no-cors', - signal: AbortSignal.timeout(5000), - }); - // Any response at all means the server is up - portal.status = 'online'; - } catch { - portal.status = 'offline'; - } - } - - // Refresh rune ring and gravity zones with updated portal statuses - rebuildRuneRing(); - rebuildGravityZones(); - - // Update portal mesh visuals — dim offline portals - for (const child of portalGroup.children) { - const portalId = child.name.replace('portal-', ''); - const portalData = portals.find(p => p.id === portalId); - if (portalData) { - const isOnline = portalData.status === 'online'; - child.material.opacity = isOnline ? 0.7 : 0.15; - } - } -} - -// Schedule recurring health checks -setInterval(runPortalHealthChecks, PORTAL_HEALTH_CHECK_MS); - -// === WEATHER SYSTEM — Lempster NH === -// Fetches real current weather from Open-Meteo (no API key required). -// Lempster, NH coordinates: 43.2897° N, 72.1479° W -// Drives particle rain/snow effects and ambient mood tinting. - -const WEATHER_LAT = 43.2897; -const WEATHER_LON = -72.1479; -const WEATHER_REFRESH_MS = 15 * 60 * 1000; // 15 minutes - -/** @type {{ code: number, temp: number, wind: number, condition: string, icon: string }|null} */ -let weatherState = null; - -// Particle constants -const PRECIP_COUNT = 1200; -const PRECIP_AREA = 18; // half-width of spawn box (scene units) -const PRECIP_HEIGHT = 20; // top spawn Y -const PRECIP_FLOOR = -5; // bottom Y before reset - -// Rain geometry & material -const rainGeo = new THREE.BufferGeometry(); -const rainPositions = new Float32Array(PRECIP_COUNT * 3); -const rainVelocities = new Float32Array(PRECIP_COUNT); // per-particle fall speed - -for (let i = 0; i < PRECIP_COUNT; i++) { - rainPositions[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2; - rainPositions[i * 3 + 1] = Math.random() * (PRECIP_HEIGHT - PRECIP_FLOOR) + PRECIP_FLOOR; - rainPositions[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2; - rainVelocities[i] = 0.18 + Math.random() * 0.12; -} -rainGeo.setAttribute('position', new THREE.BufferAttribute(rainPositions, 3)); - -const rainMat = new THREE.PointsMaterial({ - color: 0x88aaff, - size: 0.05, - sizeAttenuation: true, - transparent: true, - opacity: 0.55, -}); - -const rainParticles = new THREE.Points(rainGeo, rainMat); -rainParticles.visible = false; -scene.add(rainParticles); - -// Snow geometry & material -const snowGeo = new THREE.BufferGeometry(); -const snowPositions = new Float32Array(PRECIP_COUNT * 3); -const snowDrift = new Float32Array(PRECIP_COUNT); // horizontal drift phase - -for (let i = 0; i < PRECIP_COUNT; i++) { - snowPositions[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2; - snowPositions[i * 3 + 1] = Math.random() * (PRECIP_HEIGHT - PRECIP_FLOOR) + PRECIP_FLOOR; - snowPositions[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2; - snowDrift[i] = Math.random() * Math.PI * 2; -} -snowGeo.setAttribute('position', new THREE.BufferAttribute(snowPositions, 3)); - -const snowMat = new THREE.PointsMaterial({ - color: 0xddeeff, - size: 0.12, - sizeAttenuation: true, - transparent: true, - opacity: 0.75, -}); - -const snowParticles = new THREE.Points(snowGeo, snowMat); -snowParticles.visible = false; -scene.add(snowParticles); - -/** - * Maps a WMO weather code to a human-readable condition label and icon. - * @param {number} code - * @returns {{ condition: string, icon: string }} - */ -function weatherCodeToLabel(code) { - if (code === 0) return { condition: 'Clear', icon: '☀️' }; - if (code <= 2) return { condition: 'Partly Cloudy', icon: '⛅' }; - if (code === 3) return { condition: 'Overcast', icon: '☁️' }; - if (code >= 45 && code <= 48) return { condition: 'Fog', icon: '🌫️' }; - if (code >= 51 && code <= 57) return { condition: 'Drizzle', icon: '🌦️' }; - if (code >= 61 && code <= 67) return { condition: 'Rain', icon: '🌧️' }; - if (code >= 71 && code <= 77) return { condition: 'Snow', icon: '❄️' }; - if (code >= 80 && code <= 82) return { condition: 'Showers', icon: '🌦️' }; - if (code >= 85 && code <= 86) return { condition: 'Snow Showers', icon: '🌨️' }; - if (code >= 95 && code <= 99) return { condition: 'Thunderstorm', icon: '⛈️' }; - return { condition: 'Unknown', icon: '🌀' }; -} - -/** - * Applies weather state to scene — particle visibility and ambient tint. - * @param {{ code: number, temp: number, wind: number, condition: string, icon: string }} wx - */ -function applyWeatherToScene(wx) { - const code = wx.code; - - // Precipitation - const isRain = (code >= 51 && code <= 67) || (code >= 80 && code <= 82) || (code >= 95 && code <= 99); - const isSnow = (code >= 71 && code <= 77) || (code >= 85 && code <= 86); - - rainParticles.visible = isRain; - snowParticles.visible = isSnow; - - // Ambient mood tint - if (isSnow) { - ambientLight.color.setHex(0x1a2a40); // cold blue-white - ambientLight.intensity = 1.8; - } else if (isRain) { - ambientLight.color.setHex(0x0a1428); // darker, bluer - ambientLight.intensity = 1.2; - } else if (code === 3 || (code >= 45 && code <= 48)) { - ambientLight.color.setHex(0x0c1220); // overcast grey-blue - ambientLight.intensity = 1.1; - } else { - ambientLight.color.setHex(0x0a1428); // default clear - ambientLight.intensity = 1.4; - } -} - -/** - * Updates the weather HUD elements in the DOM. - * @param {{ temp: number, condition: string, icon: string }} wx - */ -function updateWeatherHUD(wx) { - const iconEl = document.getElementById('weather-icon'); - const tempEl = document.getElementById('weather-temp'); - const descEl = document.getElementById('weather-desc'); - if (iconEl) iconEl.textContent = wx.icon; - if (tempEl) tempEl.textContent = `${Math.round(wx.temp)}°F`; - if (descEl) descEl.textContent = wx.condition; -} - -/** - * Fetches current weather for Lempster NH from Open-Meteo and applies it. - */ -async function fetchWeather() { - try { - const url = `https://api.open-meteo.com/v1/forecast?latitude=${WEATHER_LAT}&longitude=${WEATHER_LON}¤t=temperature_2m,weather_code,wind_speed_10m,cloud_cover&temperature_unit=fahrenheit&wind_speed_unit=mph&forecast_days=1`; - const res = await fetch(url); - if (!res.ok) throw new Error('weather fetch failed'); - const data = await res.json(); - const cur = data.current; - const code = cur.weather_code; - const { condition, icon } = weatherCodeToLabel(code); - const cloudcover = typeof cur.cloud_cover === 'number' ? cur.cloud_cover : 50; - weatherState = { code, temp: cur.temperature_2m, wind: cur.wind_speed_10m, condition, icon, cloudcover }; - applyWeatherToScene(weatherState); - // Tether cloud layer density to real weather cloudcover - const cloudOpacity = 0.05 + (cloudcover / 100) * 0.55; // range [0.05, 0.60] - cloudMaterial.uniforms.uDensity.value = 0.3 + (cloudcover / 100) * 0.7; // range [0.3, 1.0] - cloudMaterial.opacity = cloudOpacity; - updateWeatherHUD(weatherState); - } catch { - // Silently use defaults — no weather data available - const descEl = document.getElementById('weather-desc'); - if (descEl) descEl.textContent = 'Lempster NH'; - } -} - -fetchWeather(); -setInterval(fetchWeather, WEATHER_REFRESH_MS); - -// === GRAVITY ANOMALY ZONES === -// Areas where particles defy gravity and float upward. -// Tethered to portal positions and status — active portals have stronger anomalies. - -const GRAVITY_ANOMALY_FLOOR = 0.2; // Y where particles respawn (ground level) -const GRAVITY_ANOMALY_CEIL = 16.0; // Y where particles wrap back to floor - -// Default zones — replaced when portals load -let GRAVITY_ZONES = [ - { x: -8, z: -6, radius: 3.5, color: 0x00ffcc, particleCount: 180 }, - { x: 10, z: 4, radius: 3.0, color: 0xaa44ff, particleCount: 160 }, - { x: -3, z: 9, radius: 2.5, color: 0xff8844, particleCount: 140 }, -]; - -const gravityZoneObjects = GRAVITY_ZONES.map((zone) => { - // Glowing floor ring - const ringGeo = new THREE.RingGeometry(zone.radius - 0.15, zone.radius + 0.15, 64); - const ringMat = new THREE.MeshBasicMaterial({ - color: zone.color, - transparent: true, - opacity: 0.4, - side: THREE.DoubleSide, - depthWrite: false, - }); - const ring = new THREE.Mesh(ringGeo, ringMat); - ring.rotation.x = -Math.PI / 2; - ring.position.set(zone.x, GRAVITY_ANOMALY_FLOOR + 0.05, zone.z); - scene.add(ring); - - // Faint inner disc - const discGeo = new THREE.CircleGeometry(zone.radius - 0.15, 64); - const discMat = new THREE.MeshBasicMaterial({ - color: zone.color, - transparent: true, - opacity: 0.04, - side: THREE.DoubleSide, - depthWrite: false, - }); - const disc = new THREE.Mesh(discGeo, discMat); - disc.rotation.x = -Math.PI / 2; - disc.position.set(zone.x, GRAVITY_ANOMALY_FLOOR + 0.04, zone.z); - scene.add(disc); - - // Rising particle stream - const count = zone.particleCount; - const positions = new Float32Array(count * 3); - const driftPhases = new Float32Array(count); - const velocities = new Float32Array(count); - - for (let i = 0; i < count; i++) { - const angle = Math.random() * Math.PI * 2; - const r = Math.sqrt(Math.random()) * zone.radius; - positions[i * 3] = zone.x + Math.cos(angle) * r; - positions[i * 3 + 1] = GRAVITY_ANOMALY_FLOOR + Math.random() * (GRAVITY_ANOMALY_CEIL - GRAVITY_ANOMALY_FLOOR); - positions[i * 3 + 2] = zone.z + Math.sin(angle) * r; - driftPhases[i] = Math.random() * Math.PI * 2; - velocities[i] = 0.03 + Math.random() * 0.04; - } - - const geo = new THREE.BufferGeometry(); - geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - - const mat = new THREE.PointsMaterial({ - color: zone.color, - size: 0.10, - sizeAttenuation: true, - transparent: true, - opacity: 0.7, - depthWrite: false, - }); - - const points = new THREE.Points(geo, mat); - scene.add(points); - - return { zone, ring, ringMat, disc, discMat, points, geo, driftPhases, velocities }; -}); - -/** - * Rebuilds gravity anomaly zones to align with portal positions. - * Active/online portals get stronger anomaly; offline portals get weaker effect. - */ -function rebuildGravityZones() { - if (portals.length === 0) return; - - // Update existing zone positions/intensities to match portal data - for (let i = 0; i < Math.min(portals.length, gravityZoneObjects.length); i++) { - const portal = portals[i]; - const gz = gravityZoneObjects[i]; - const isOnline = portal.status === 'online'; - const portalColor = new THREE.Color(portal.color); - - // Reposition ring and disc to portal position - gz.ring.position.set(portal.position.x, GRAVITY_ANOMALY_FLOOR + 0.05, portal.position.z); - gz.disc.position.set(portal.position.x, GRAVITY_ANOMALY_FLOOR + 0.04, portal.position.z); - - // Update zone reference for particle respawn - gz.zone.x = portal.position.x; - gz.zone.z = portal.position.z; - gz.zone.color = portalColor.getHex(); - - // Update colors - gz.ringMat.color.copy(portalColor); - gz.discMat.color.copy(portalColor); - gz.points.material.color.copy(portalColor); - - // Offline portals: reduced opacity/intensity - gz.ringMat.opacity = isOnline ? 0.4 : 0.08; - gz.discMat.opacity = isOnline ? 0.04 : 0.01; - gz.points.material.opacity = isOnline ? 0.7 : 0.15; - - // Reposition particles around portal - const pos = gz.geo.attributes.position.array; - for (let j = 0; j < gz.zone.particleCount; j++) { - const angle = Math.random() * Math.PI * 2; - const r = Math.sqrt(Math.random()) * gz.zone.radius; - pos[j * 3] = gz.zone.x + Math.cos(angle) * r; - pos[j * 3 + 2] = gz.zone.z + Math.sin(angle) * r; - } - gz.geo.attributes.position.needsUpdate = true; - } -} - -// === TIMMY SPEECH BUBBLE === -// When Timmy sends a chat message, a glowing floating text sprite appears near -// his avatar position above the platform. Fades in quickly, holds for 5 s total, -// then fades out. Only the most recent message is shown. - -const TIMMY_SPEECH_POS = new THREE.Vector3(0, 8.2, 1.5); -const SPEECH_DURATION = 5.0; // total seconds visible (including fades) -const SPEECH_FADE_IN = 0.35; -const SPEECH_FADE_OUT = 0.7; - -/** @type {THREE.Sprite|null} */ -let timmySpeechSprite = null; - -/** @type {{ startTime: number, sprite: THREE.Sprite }|null} */ -let timmySpeechState = null; - -/** - * Builds a canvas texture for a Timmy speech bubble. - * @param {string} text - * @returns {THREE.CanvasTexture} - */ -function createSpeechBubbleTexture(text) { - const W = 512, H = 100; - const canvas = document.createElement('canvas'); - canvas.width = W; - canvas.height = H; - const ctx = canvas.getContext('2d'); - - // Semi-transparent dark background - ctx.fillStyle = 'rgba(0, 6, 20, 0.85)'; - ctx.fillRect(0, 0, W, H); - - // Neon blue glow border - ctx.strokeStyle = '#66aaff'; - ctx.lineWidth = 2; - ctx.strokeRect(1, 1, W - 2, H - 2); - - // Inner subtle border - ctx.strokeStyle = '#2244aa'; - ctx.lineWidth = 1; - ctx.strokeRect(4, 4, W - 8, H - 8); - - // "TIMMY:" label - ctx.font = 'bold 12px "Courier New", monospace'; - ctx.fillStyle = '#4488ff'; - ctx.fillText('TIMMY:', 12, 22); - - // Message text — truncate to two lines if needed - const LINE1_MAX = 42; - const LINE2_MAX = 48; - ctx.font = '15px "Courier New", monospace'; - ctx.fillStyle = '#ddeeff'; - - if (text.length <= LINE1_MAX) { - ctx.fillText(text, 12, 58); - } else { - ctx.fillText(text.slice(0, LINE1_MAX), 12, 46); - const rest = text.slice(LINE1_MAX, LINE1_MAX + LINE2_MAX); - ctx.font = '13px "Courier New", monospace'; - ctx.fillStyle = '#aabbcc'; - ctx.fillText(rest + (text.length > LINE1_MAX + LINE2_MAX ? '\u2026' : ''), 12, 76); - } - - return new THREE.CanvasTexture(canvas); -} - -/** - * Shows a floating speech bubble near Timmy's avatar. - * Immediately replaces any existing bubble. - * @param {string} text - */ -function showTimmySpeech(text) { - if (timmySpeechSprite) { - scene.remove(timmySpeechSprite); - if (timmySpeechSprite.material.map) timmySpeechSprite.material.map.dispose(); - timmySpeechSprite.material.dispose(); - timmySpeechSprite = null; - timmySpeechState = null; - } - - const texture = createSpeechBubbleTexture(text); - const material = new THREE.SpriteMaterial({ - map: texture, - transparent: true, - opacity: 0, - depthWrite: false, - }); - const sprite = new THREE.Sprite(material); - sprite.scale.set(8.5, 1.65, 1); - sprite.position.copy(TIMMY_SPEECH_POS); - scene.add(sprite); - - timmySpeechSprite = sprite; - timmySpeechState = { startTime: clock.getElapsedTime(), sprite }; -} - -// === TIME-LAPSE MODE === -// Press 'L' (or click ⏩ in the HUD) to replay a day of Nexus commit activity -// compressed into 30 real seconds. A HUD clock scrubs 00:00 → 23:59 while the -// heatmap and shockwave effects fire in sync with each commit. - -const TIMELAPSE_DURATION_S = 30; // real seconds = one full virtual day - -let timelapseActive = false; -let timelapseRealStart = 0; // clock.getElapsedTime() when replay began -let timelapseProgress = 0; // 0..1 - -/** @type {Array<{ts: number, author: string, message: string, hash: string}>} */ -let timelapseCommits = []; - -/** Virtual day window: midnight-to-now of today. */ -let timelapseWindow = { startMs: 0, endMs: 0 }; - -/** Index of the next commit not yet fired. */ -let timelapseNextCommitIdx = 0; - -const timelapseIndicator = document.getElementById('timelapse-indicator'); -const timelapseClock = document.getElementById('timelapse-clock'); -const timelapseBarEl = document.getElementById('timelapse-bar'); -const timelapseBtnEl = document.getElementById('timelapse-btn'); - -/** - * Loads today's commits from the Gitea API for replay. - */ -async function loadTimelapseData() { - try { - const res = await fetch( - 'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=50', - { headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } } - ); - if (!res.ok) throw new Error('fetch failed'); - const data = await res.json(); - const midnight = new Date(); - midnight.setHours(0, 0, 0, 0); - - timelapseCommits = data - .map(c => ({ - ts: new Date(c.commit?.author?.date || 0).getTime(), - author: c.commit?.author?.name || c.author?.login || 'unknown', - message: (c.commit?.message || '').split('\n')[0], - hash: (c.sha || '').slice(0, 7), - })) - .filter(c => c.ts >= midnight.getTime()) - .sort((a, b) => a.ts - b.ts); - } catch { - timelapseCommits = []; - } - - // Always replay midnight-to-now so the clock reads as a natural day - const midnight = new Date(); - midnight.setHours(0, 0, 0, 0); - timelapseWindow = { startMs: midnight.getTime(), endMs: Date.now() }; -} - -/** - * Fires the visual event for a single replayed commit. - * @param {{ ts: number, author: string, message: string, hash: string }} commit - */ -function fireTimelapseCommit(commit) { - // Spike the matching agent zone briefly - const zone = HEATMAP_ZONES.find(z => z.authorMatch.test(commit.author)); - if (zone) { - zoneIntensity[zone.name] = Math.min(1.0, (zoneIntensity[zone.name] || 0) + 0.4); - } - // Shockwave from the commit landing - triggerShockwave(); -} - -/** - * Recalculates heatmap zone intensities from commits within a trailing window - * ending at virtualMs. Uses a 90-virtual-minute half-life so recent commits - * stay lit while older ones fade. - * @param {number} virtualMs - */ -function updateTimelapseHeatmap(virtualMs) { - const WINDOW_MS = 90 * 60 * 1000; // 90 virtual minutes - const rawWeights = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0])); - - for (const commit of timelapseCommits) { - if (commit.ts > virtualMs) break; // array is sorted - const age = virtualMs - commit.ts; - if (age > WINDOW_MS) continue; - const weight = 1 - age / WINDOW_MS; - for (const zone of HEATMAP_ZONES) { - if (zone.authorMatch.test(commit.author)) { - rawWeights[zone.name] += weight; - break; + // 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(); } } - } - - const MAX_WEIGHT = 4; - for (const zone of HEATMAP_ZONES) { - zoneIntensity[zone.name] = Math.min(rawWeights[zone.name] / MAX_WEIGHT, 1.0); - } - drawHeatmap(); -} - -/** - * Updates the time-lapse HUD clock and progress bar. - * @param {number} progress 0..1 - * @param {number} virtualMs - */ -function updateTimelapseHUD(progress, virtualMs) { - if (timelapseClock) { - const d = new Date(virtualMs); - const hh = String(d.getHours()).padStart(2, '0'); - const mm = String(d.getMinutes()).padStart(2, '0'); - timelapseClock.textContent = `${hh}:${mm}`; - } - if (timelapseBarEl) { - timelapseBarEl.style.width = `${(progress * 100).toFixed(1)}%`; - } -} - -/** - * Starts time-lapse mode: fetches data, resets state, shows HUD. - */ -async function startTimelapse() { - if (timelapseActive) return; - await loadTimelapseData(); - timelapseActive = true; - timelapseRealStart = clock.getElapsedTime(); - timelapseProgress = 0; - timelapseNextCommitIdx = 0; - - // Clear heatmap to zero — driven entirely by replay - for (const zone of HEATMAP_ZONES) zoneIntensity[zone.name] = 0; - drawHeatmap(); - - if (timelapseIndicator) timelapseIndicator.classList.add('visible'); - if (timelapseBtnEl) timelapseBtnEl.classList.add('active'); -} - -/** - * Stops time-lapse mode and restores the live heatmap. - */ -function stopTimelapse() { - if (!timelapseActive) return; - timelapseActive = false; - if (timelapseIndicator) timelapseIndicator.classList.remove('visible'); - if (timelapseBtnEl) timelapseBtnEl.classList.remove('active'); - // Restore normal heatmap - updateHeatmap(); -} - -// Key binding: L to toggle, Esc to stop -document.addEventListener('keydown', (e) => { - if (e.key === 'l' || e.key === 'L') { - if (timelapseActive) stopTimelapse(); else startTimelapse(); - } - if (e.key === 'Escape' && timelapseActive) stopTimelapse(); -}); - -// HUD button -if (timelapseBtnEl) { - timelapseBtnEl.addEventListener('click', () => { - if (timelapseActive) stopTimelapse(); else startTimelapse(); }); } -// === BITCOIN BLOCK HEIGHT === -// Polls blockstream.info every 60 s for the current tip block height. -// Shows a flash animation when the block number increments. - -const blockHeightDisplay = document.getElementById('block-height-display'); -const blockHeightValue = document.getElementById('block-height-value'); -let lastKnownBlockHeight = null; - -async function fetchBlockHeight() { - try { - const res = await fetch('https://blockstream.info/api/blocks/tip/height'); - if (!res.ok) return; - const height = parseInt(await res.text(), 10); - if (isNaN(height)) return; - - if (lastKnownBlockHeight !== null && height !== lastKnownBlockHeight) { - // 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; - } - - lastKnownBlockHeight = height; - blockHeightValue.textContent = height.toLocaleString(); - } catch (_) { - // Network unavailable — keep last known value - } +// ─── 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'); + }); } -fetchBlockHeight(); -setInterval(fetchBlockHeight, 60000); -// CI pipeline verification 1774356920 -// CI pipeline test v2 — 1774357376 +// ─── 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(); +}); diff --git a/modules/core/audio.js b/modules/core/audio.js new file mode 100644 index 0000000..b7e3576 --- /dev/null +++ b/modules/core/audio.js @@ -0,0 +1,165 @@ +// modules/core/audio.js — Web Audio ambient soundtrack +import * as THREE from 'three'; +import { state } from './state.js'; + +let audioCtx = null; +let masterGain = null; +let audioRunning = false; +const audioSources = []; +const positionedPanners = []; +let portalHumsStarted = false; +let sparkleTimer = null; +let _camera; + +function buildReverbIR(ctx, duration, decay) { + const rate = ctx.sampleRate; + const len = Math.ceil(rate * duration); + const buf = ctx.createBuffer(2, len, rate); + for (let ch = 0; ch < 2; ch++) { + const d = buf.getChannelData(ch); + for (let i = 0; i < len; i++) { + d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay); + } + } + return buf; +} + +function createPanner(x, y, z) { + const panner = audioCtx.createPanner(); + panner.panningModel = 'HRTF'; + panner.distanceModel = 'inverse'; + panner.refDistance = 5; + panner.maxDistance = 80; + panner.rolloffFactor = 1.0; + if (panner.positionX) { + panner.positionX.value = x; panner.positionY.value = y; panner.positionZ.value = z; + } else { panner.setPosition(x, y, z); } + positionedPanners.push(panner); + return panner; +} + +export function updateAudioListener() { + if (!audioCtx || !_camera) return; + const listener = audioCtx.listener; + const pos = _camera.position; + const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(_camera.quaternion); + const up = new THREE.Vector3(0, 1, 0).applyQuaternion(_camera.quaternion); + if (listener.positionX) { + const t = audioCtx.currentTime; + listener.positionX.setValueAtTime(pos.x, t); listener.positionY.setValueAtTime(pos.y, t); listener.positionZ.setValueAtTime(pos.z, t); + listener.forwardX.setValueAtTime(fwd.x, t); listener.forwardY.setValueAtTime(fwd.y, t); listener.forwardZ.setValueAtTime(fwd.z, t); + listener.upX.setValueAtTime(up.x, t); listener.upY.setValueAtTime(up.y, t); listener.upZ.setValueAtTime(up.z, t); + } else { + listener.setPosition(pos.x, pos.y, pos.z); + listener.setOrientation(fwd.x, fwd.y, fwd.z, up.x, up.y, up.z); + } +} + +export function startPortalHums() { + if (!audioCtx || !audioRunning || state.portals.length === 0 || portalHumsStarted) return; + portalHumsStarted = true; + const humFreqs = [58.27, 65.41, 73.42, 82.41, 87.31]; + state.portals.forEach((portal, i) => { + const panner = createPanner(portal.position.x, portal.position.y + 1.5, portal.position.z); + panner.connect(masterGain); + const osc = audioCtx.createOscillator(); + osc.type = 'sine'; osc.frequency.value = humFreqs[i % humFreqs.length]; + const lfo = audioCtx.createOscillator(); + lfo.frequency.value = 0.07 + i * 0.02; + const lfoGain = audioCtx.createGain(); + lfoGain.gain.value = 0.008; + lfo.connect(lfoGain); + const g = audioCtx.createGain(); + g.gain.value = 0.035; + lfoGain.connect(g.gain); + osc.connect(g); g.connect(panner); + osc.start(); lfo.start(); + audioSources.push(osc, lfo); + }); +} + +function startAmbient() { + if (audioRunning) return; + audioCtx = new AudioContext(); + masterGain = audioCtx.createGain(); + masterGain.gain.value = 0; + const convolver = audioCtx.createConvolver(); + convolver.buffer = buildReverbIR(audioCtx, 3.5, 2.8); + const limiter = audioCtx.createDynamicsCompressor(); + limiter.threshold.value = -3; limiter.knee.value = 0; limiter.ratio.value = 20; limiter.attack.value = 0.001; limiter.release.value = 0.1; + masterGain.connect(convolver); convolver.connect(limiter); limiter.connect(audioCtx.destination); + + // Layer 1: Sub-drone + [[55.0, -6], [55.0, +6]].forEach(([freq, detune]) => { + const osc = audioCtx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = freq; osc.detune.value = detune; + const g = audioCtx.createGain(); g.gain.value = 0.07; osc.connect(g); g.connect(masterGain); osc.start(); audioSources.push(osc); + }); + + // Layer 2: Pad + [110, 130.81, 164.81, 196].forEach((freq, i) => { + const detunes = [-8, 4, -3, 7]; + const osc = audioCtx.createOscillator(); osc.type = 'triangle'; osc.frequency.value = freq; osc.detune.value = detunes[i]; + const lfo = audioCtx.createOscillator(); lfo.frequency.value = 0.05 + i * 0.013; + const lfoGain = audioCtx.createGain(); lfoGain.gain.value = 0.02; lfo.connect(lfoGain); + const g = audioCtx.createGain(); g.gain.value = 0.06; lfoGain.connect(g.gain); + osc.connect(g); g.connect(masterGain); osc.start(); lfo.start(); audioSources.push(osc, lfo); + }); + + // Layer 3: Noise hiss + const noiseLen = audioCtx.sampleRate * 2; + const noiseBuf = audioCtx.createBuffer(1, noiseLen, audioCtx.sampleRate); + const nd = noiseBuf.getChannelData(0); + let b0 = 0; + for (let i = 0; i < noiseLen; i++) { const white = Math.random() * 2 - 1; b0 = 0.99 * b0 + white * 0.01; nd[i] = b0 * 3.5; } + const noiseNode = audioCtx.createBufferSource(); noiseNode.buffer = noiseBuf; noiseNode.loop = true; + const noiseFilter = audioCtx.createBiquadFilter(); noiseFilter.type = 'bandpass'; noiseFilter.frequency.value = 800; noiseFilter.Q.value = 0.5; + const noiseGain = audioCtx.createGain(); noiseGain.gain.value = 0.012; + noiseNode.connect(noiseFilter); noiseFilter.connect(noiseGain); noiseGain.connect(masterGain); noiseNode.start(); audioSources.push(noiseNode); + + // Layer 4: Sparkle plucks + const sparkleNotes = [440, 523.25, 659.25, 880, 1046.5]; + function scheduleSparkle() { + if (!audioRunning || !audioCtx) return; + const osc = audioCtx.createOscillator(); osc.type = 'sine'; + osc.frequency.value = sparkleNotes[Math.floor(Math.random() * sparkleNotes.length)]; + const env = audioCtx.createGain(); + const now = audioCtx.currentTime; + env.gain.setValueAtTime(0, now); env.gain.linearRampToValueAtTime(0.08, now + 0.02); env.gain.exponentialRampToValueAtTime(0.0001, now + 1.8); + const angle = Math.random() * Math.PI * 2; + const radius = 3 + Math.random() * 9; + const sparkPanner = createPanner(Math.cos(angle) * radius, 1.5 + Math.random() * 4, Math.sin(angle) * radius); + sparkPanner.connect(masterGain); + osc.connect(env); env.connect(sparkPanner); osc.start(now); osc.stop(now + 1.9); + osc.addEventListener('ended', () => { try { sparkPanner.disconnect(); } catch (_) {} const idx = positionedPanners.indexOf(sparkPanner); if (idx !== -1) positionedPanners.splice(idx, 1); }); + sparkleTimer = setTimeout(scheduleSparkle, 3000 + Math.random() * 6000); + } + sparkleTimer = setTimeout(scheduleSparkle, 1000 + Math.random() * 3000); + + masterGain.gain.setValueAtTime(0, audioCtx.currentTime); + masterGain.gain.linearRampToValueAtTime(0.9, audioCtx.currentTime + 2.0); + audioRunning = true; + document.getElementById('audio-toggle').textContent = '\uD83D\uDD07'; + startPortalHums(); +} + +function stopAmbient() { + if (!audioRunning || !audioCtx) return; + audioRunning = false; + if (sparkleTimer !== null) { clearTimeout(sparkleTimer); sparkleTimer = null; } + const gain = masterGain; const ctx = audioCtx; + gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime); + gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8); + setTimeout(() => { + audioSources.forEach(n => { try { n.stop(); } catch (_) {} }); audioSources.length = 0; + positionedPanners.forEach(p => { try { p.disconnect(); } catch (_) {} }); positionedPanners.length = 0; + portalHumsStarted = false; ctx.close(); audioCtx = null; masterGain = null; + }, 900); + document.getElementById('audio-toggle').textContent = '\uD83D\uDD0A'; +} + +export function init(camera) { + _camera = camera; + document.getElementById('audio-toggle').addEventListener('click', () => { + if (audioRunning) stopAmbient(); else startAmbient(); + }); +} diff --git a/modules/core/scene.js b/modules/core/scene.js new file mode 100644 index 0000000..4560d22 --- /dev/null +++ b/modules/core/scene.js @@ -0,0 +1,196 @@ +// modules/core/scene.js — Three.js scene setup +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 { THEME } from './theme.js'; + +export let scene, camera, renderer, composer, orbitControls, bokehPass; +export const raycaster = new THREE.Raycaster(); +export const forwardVector = new THREE.Vector3(); +export const clock = new THREE.Clock(); + +// Loading manager +export const loadedAssets = new Map(); + +export const loadingManager = new THREE.LoadingManager(); + +// Placeholder texture +let placeholderTexture; + +// Lights (exported for oath dimming) +export let ambientLight, overheadLight; + +// Warp shader pass +export let warpPass; + +const WarpShader = { + uniforms: { + 'tDiffuse': { value: null }, + 'time': { value: 0.0 }, + 'progress': { value: 0.0 }, + 'portalColor': { value: new THREE.Color(0x4488ff) }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform sampler2D tDiffuse; + uniform float time; + uniform float progress; + uniform vec3 portalColor; + varying vec2 vUv; + + #define PI 3.14159265358979 + + void main() { + vec2 uv = vUv; + vec2 center = vec2(0.5, 0.5); + vec2 dir = uv - center; + float dist = length(dir); + float angle = atan(dir.y, dir.x); + + float intensity = sin(progress * PI); + + float zoom = 1.0 + intensity * 3.0; + vec2 zoomedUV = center + dir / zoom; + + float swirl = intensity * 5.0 * max(0.0, 1.0 - dist * 2.0); + float twisted = angle + swirl; + vec2 swirlUV = center + vec2(cos(twisted), sin(twisted)) * dist / (1.0 + intensity * 1.8); + + vec2 warpUV = mix(zoomedUV, swirlUV, 0.6); + warpUV = clamp(warpUV, vec2(0.001), vec2(0.999)); + + float aber = intensity * 0.018; + vec2 aberDir = normalize(dir + vec2(0.001)); + float rVal = texture2D(tDiffuse, clamp(warpUV + aberDir * aber, vec2(0.0), vec2(1.0))).r; + float gVal = texture2D(tDiffuse, warpUV).g; + float bVal = texture2D(tDiffuse, clamp(warpUV - aberDir * aber, vec2(0.0), vec2(1.0))).b; + vec4 color = vec4(rVal, gVal, bVal, 1.0); + + float numLines = 28.0; + float lineAngleFrac = fract((angle / (2.0 * PI) + 0.5) * numLines + time * 4.0); + float lineSharp = pow(max(0.0, 1.0 - abs(lineAngleFrac - 0.5) * 16.0), 3.0); + float radialFade = max(0.0, 1.0 - dist * 2.2); + float speedLine = lineSharp * radialFade * intensity * 1.8; + + float lineAngleFrac2 = fract((angle / (2.0 * PI) + 0.5) * 14.0 - time * 2.5); + float lineSharp2 = pow(max(0.0, 1.0 - abs(lineAngleFrac2 - 0.5) * 12.0), 3.0); + float speedLine2 = lineSharp2 * radialFade * intensity * 0.9; + + float rimDist = abs(dist - 0.08 * intensity); + float rimGlow = pow(max(0.0, 1.0 - rimDist * 40.0), 2.0) * intensity; + + color.rgb = mix(color.rgb, portalColor, intensity * 0.45); + color.rgb += portalColor * (speedLine + speedLine2); + color.rgb += vec3(1.0) * rimGlow * 0.8; + + float bloom = pow(max(0.0, 1.0 - dist / (0.18 * intensity + 0.001)), 2.0) * intensity; + color.rgb += portalColor * bloom * 2.5 + vec3(1.0) * bloom * 0.6; + + float vignette = smoothstep(0.5, 0.2, dist) * intensity * 0.5; + color.rgb *= 1.0 - vignette * 0.4; + + float flash = smoothstep(0.82, 1.0, progress); + color.rgb = mix(color.rgb, vec3(1.0), flash); + + gl_FragColor = color; + } + `, +}; + +export function initScene(onLoadComplete) { + // Loading manager setup + loadingManager.onLoad = () => { + document.getElementById('loading-bar').style.width = '100%'; + document.getElementById('loading').style.display = 'none'; + if (onLoadComplete) onLoadComplete(); + }; + + loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => { + const progress = (itemsLoaded / itemsTotal) * 100; + document.getElementById('loading-bar').style.width = `${progress}%`; + }; + + // Placeholder texture + const _placeholderCanvas = document.createElement('canvas'); + _placeholderCanvas.width = 64; + _placeholderCanvas.height = 64; + const _placeholderCtx = _placeholderCanvas.getContext('2d'); + _placeholderCtx.fillStyle = '#0a0a18'; + _placeholderCtx.fillRect(0, 0, 64, 64); + placeholderTexture = new THREE.CanvasTexture(_placeholderCanvas); + loadedAssets.set('placeholder-texture', placeholderTexture); + loadingManager.itemStart('placeholder-texture'); + loadingManager.itemEnd('placeholder-texture'); + + // Scene + scene = new THREE.Scene(); + + // Camera + camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000); + camera.position.set(0, 6, 11); + + // Renderer — alpha:true so matrix rain canvas shows through + renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setClearColor(0x000000, 0); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + document.body.appendChild(renderer.domElement); + + // Lights + ambientLight = new THREE.AmbientLight(0x0a1428, 1.4); + scene.add(ambientLight); + + 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); + + // Post-processing + composer = new EffectComposer(renderer); + composer.addPass(new RenderPass(scene, camera)); + + bokehPass = new BokehPass(scene, camera, { + focus: 5.0, + aperture: 0.00015, + maxblur: 0.004, + }); + composer.addPass(bokehPass); + + // Warp pass + warpPass = new ShaderPass(WarpShader); + warpPass.enabled = false; + composer.addPass(warpPass); + + // Controls + orbitControls = new OrbitControls(camera, renderer.domElement); + orbitControls.enableDamping = true; + orbitControls.dampingFactor = 0.05; + orbitControls.enabled = false; + + // Resize + window.addEventListener('resize', () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); + composer.setSize(window.innerWidth, window.innerHeight); + }); + + return { scene, camera, renderer, composer, orbitControls }; +} diff --git a/modules/core/state.js b/modules/core/state.js new file mode 100644 index 0000000..ec61bcb --- /dev/null +++ b/modules/core/state.js @@ -0,0 +1,35 @@ +// modules/core/state.js — Shared reactive data bus +// All data modules write here, all visual modules read from here. + +export const state = { + // Commit data (written by data/gitea.js) + zoneIntensity: {}, + commits: [], + commitHashes: [], + + // Agent status (written by data/gitea.js) + agentStatus: null, + activeAgentCount: 0, + + // Weather (written by data/weather.js) + weather: null, + + // Bitcoin (written by data/bitcoin.js) + blockHeight: 0, + lastBlockHeight: 0, + newBlockDetected: false, + + // Portal data (written by data/loaders.js) + portals: [], + sovereignty: null, + soulMd: '', + + // Star pulse (set by bitcoin module, read by stars) + starPulseIntensity: 0, + + // Computed + totalActivity() { + const vals = Object.values(this.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..b22a5f5 --- /dev/null +++ b/modules/core/theme.js @@ -0,0 +1,42 @@ +// modules/core/theme.js — Centralized color/font/size constants +export const THEME = { + colors: { + bg: 0x000008, + starCore: 0xffffff, + starDim: 0x8899cc, + constellationLine: 0x334488, + constellationFade: 0x112244, + accent: 0x4488ff, + panelBg: '#0a0e1a', + panelBorder: '#1a3a5c', + panelText: '#88ccff', + panelTextDim: '#4477aa', + neonGreen: '#00ff88', + neonRed: '#ff4444', + neonYellow: '#ffcc00', + offline: '#334466', + working: '#00ff88', + idle: '#4488ff', + dormant: '#334466', + dead: '#ff4444', + gold: 0xffd700, + }, + fonts: { + mono: '"Courier New", monospace', + sans: 'Inter, system-ui, sans-serif', + display: '"Orbitron", sans-serif', + }, + sizes: { + panelTitle: 24, + panelBody: 16, + panelSmall: 12, + hudLarge: 28, + hudSmall: 14, + }, + glow: { + accent: 'rgba(68, 136, 255, 0.6)', + accentDim: 'rgba(68, 136, 255, 0.2)', + success: 'rgba(0, 255, 136, 0.6)', + warning: 'rgba(255, 204, 0, 0.6)', + }, +}; diff --git a/modules/core/ticker.js b/modules/core/ticker.js new file mode 100644 index 0000000..98bd39c --- /dev/null +++ b/modules/core/ticker.js @@ -0,0 +1,53 @@ +// modules/core/ticker.js — Single animation clock +// Every module subscribes here instead of calling requestAnimationFrame directly. + +const subscribers = []; +let running = false; +let _renderer, _scene, _camera, _composer; + +export function subscribe(fn) { + if (!subscribers.includes(fn)) subscribers.push(fn); +} + +export function unsubscribe(fn) { + const i = subscribers.indexOf(fn); + if (i >= 0) subscribers.splice(i, 1); +} + +export function setRenderTarget(renderer, scene, camera, composer) { + _renderer = renderer; + _scene = scene; + _camera = camera; + _composer = composer; +} + +export function start() { + if (running) return; + running = true; + let lastTime = performance.now(); + + function tick() { + if (!running) return; + requestAnimationFrame(tick); + const now = performance.now(); + const elapsed = now / 1000; + const delta = (now - lastTime) / 1000; + lastTime = now; + + for (const fn of subscribers) { + fn(elapsed, delta); + } + + if (_composer) { + _composer.render(); + } else if (_renderer && _scene && _camera) { + _renderer.render(_scene, _camera); + } + } + + tick(); +} + +export function stop() { + running = false; +} diff --git a/modules/data/bitcoin.js b/modules/data/bitcoin.js new file mode 100644 index 0000000..d93f9cb --- /dev/null +++ b/modules/data/bitcoin.js @@ -0,0 +1,34 @@ +// modules/data/bitcoin.js — Bitcoin block height polling +import { state } from '../core/state.js'; + +const blockHeightDisplay = document.getElementById('block-height-display'); +const blockHeightValue = document.getElementById('block-height-value'); + +export async function fetchBlockHeight() { + try { + const res = await fetch('https://blockstream.info/api/blocks/tip/height'); + if (!res.ok) return; + const height = parseInt(await res.text(), 10); + if (isNaN(height)) return; + + if (state.lastBlockHeight !== 0 && height !== state.lastBlockHeight) { + if (blockHeightDisplay) { + blockHeightDisplay.classList.remove('fresh'); + void blockHeightDisplay.offsetWidth; + blockHeightDisplay.classList.add('fresh'); + } + state.starPulseIntensity = 1.0; + } + + state.lastBlockHeight = height; + state.blockHeight = height; + if (blockHeightValue) blockHeightValue.textContent = height.toLocaleString(); + } catch (_) { + // Network unavailable — keep last known value + } +} + +export function startBlockPolling() { + fetchBlockHeight(); + setInterval(fetchBlockHeight, 60000); +} diff --git a/modules/data/gitea.js b/modules/data/gitea.js new file mode 100644 index 0000000..96a692a --- /dev/null +++ b/modules/data/gitea.js @@ -0,0 +1,201 @@ +// modules/data/gitea.js — All Gitea API calls +import { state } from '../core/state.js'; + +const GITEA_BASE = 'http://143.198.27.163:3000/api/v1'; +const GITEA_TOKEN = '81a88f46684e398abe081f5786a11ae9532aae2d'; +const GITEA_REPOS = ['Timmy_Foundation/the-nexus', 'Timmy_Foundation/hermes-agent']; +const AGENT_NAMES = ['Claude', 'Kimi', 'Perplexity', 'Groq', 'Grok', 'Ollama']; +const HEATMAP_DECAY_MS = 24 * 60 * 60 * 1000; + +export const HEATMAP_ZONES = [ + { name: 'Claude', color: [255, 100, 60], authorMatch: /^claude$/i, angleDeg: 0 }, + { name: 'Timmy', color: [ 60, 160, 255], authorMatch: /^timmy/i, angleDeg: 90 }, + { name: 'Kimi', color: [ 60, 255, 140], authorMatch: /^kimi/i, angleDeg: 180 }, + { name: 'Perplexity', color: [200, 60, 255], authorMatch: /^perplexity/i, angleDeg: 270 }, +]; + +export async function fetchCommits() { + let commits = []; + try { + const res = await fetch( + `${GITEA_BASE}/repos/Timmy_Foundation/the-nexus/commits?limit=50`, + { headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } } + ); + if (res.ok) commits = await res.json(); + } catch { /* silently use zero-activity baseline */ } + + state.commitHashes = commits.slice(0, 20).map(c => (c.sha || '').slice(0, 7)).filter(h => h.length > 0); + state.commits = commits; + + const now = Date.now(); + const rawWeights = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0])); + + for (const commit of commits) { + const author = commit.commit?.author?.name || commit.author?.login || ''; + const ts = new Date(commit.commit?.author?.date || 0).getTime(); + const age = now - ts; + if (age > HEATMAP_DECAY_MS) continue; + const weight = 1 - age / HEATMAP_DECAY_MS; + for (const zone of HEATMAP_ZONES) { + if (zone.authorMatch.test(author)) { + rawWeights[zone.name] += weight; + break; + } + } + } + + const MAX_WEIGHT = 8; + for (const zone of HEATMAP_ZONES) { + state.zoneIntensity[zone.name] = Math.min(rawWeights[zone.name] / MAX_WEIGHT, 1.0); + } +} + +let _agentStatusCache = null; +let _agentStatusCacheTime = 0; +const AGENT_STATUS_CACHE_MS = 5 * 60 * 1000; + +export async function fetchAgentStatus() { + const now = Date.now(); + if (_agentStatusCache && (now - _agentStatusCacheTime < AGENT_STATUS_CACHE_MS)) { + return _agentStatusCache; + } + + const DAY_MS = 86400000; + const HOUR_MS = 3600000; + const agents = []; + + const allRepoCommits = await Promise.all(GITEA_REPOS.map(async (repo) => { + try { + const res = await fetch(`${GITEA_BASE}/repos/${repo}/commits?sha=main&limit=30&token=${GITEA_TOKEN}`); + if (!res.ok) return []; + return await res.json(); + } catch { return []; } + })); + + let openPRs = []; + try { + const prRes = await fetch(`${GITEA_BASE}/repos/Timmy_Foundation/the-nexus/pulls?state=open&limit=50&token=${GITEA_TOKEN}`); + if (prRes.ok) openPRs = await prRes.json(); + } catch { /* ignore */ } + + for (const agentName of AGENT_NAMES) { + const nameLower = agentName.toLowerCase(); + const allCommits = []; + for (const repoCommits of allRepoCommits) { + if (!Array.isArray(repoCommits)) continue; + const matching = repoCommits.filter(c => + (c.commit?.author?.name || '').toLowerCase().includes(nameLower) + ); + allCommits.push(...matching); + } + + let status = 'dormant'; + let lastSeen = null; + let currentWork = null; + + if (allCommits.length > 0) { + allCommits.sort((a, b) => new Date(b.commit.author.date) - new Date(a.commit.author.date)); + const latest = allCommits[0]; + const commitTime = new Date(latest.commit.author.date).getTime(); + lastSeen = latest.commit.author.date; + currentWork = latest.commit.message.split('\n')[0]; + if (now - commitTime < HOUR_MS) status = 'working'; + else if (now - commitTime < DAY_MS) status = 'idle'; + else status = 'dormant'; + } + + const agentPRs = openPRs.filter(pr => + (pr.user?.login || '').toLowerCase().includes(nameLower) || + (pr.head?.label || '').toLowerCase().includes(nameLower) + ); + + agents.push({ + name: agentName.toLowerCase(), + status, + issue: currentWork, + prs_today: agentPRs.length, + local: nameLower === 'ollama', + }); + } + + _agentStatusCache = { agents }; + _agentStatusCacheTime = now; + state.agentStatus = _agentStatusCache; + state.activeAgentCount = agents.filter(a => a.status === 'working').length; + return _agentStatusCache; +} + +export async function fetchRecentCommitsForBanners() { + try { + const res = await fetch( + `${GITEA_BASE}/repos/Timmy_Foundation/the-nexus/commits?limit=5`, + { headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } } + ); + if (!res.ok) throw new Error('fetch failed'); + const data = await res.json(); + return data.map(c => ({ + hash: c.sha.slice(0, 7), + message: c.commit.message.split('\n')[0], + })); + } catch { + return [ + { hash: 'a1b2c3d', message: 'feat: depth of field effect on distant objects' }, + { hash: 'e4f5g6h', message: 'feat: photo mode with orbit controls' }, + { hash: 'i7j8k9l', message: 'feat: sovereignty easter egg animation' }, + { hash: 'm0n1o2p', message: 'feat: overview mode bird\'s-eye view' }, + { hash: 'q3r4s5t', message: 'feat: star field and constellation lines' }, + ]; + } +} + +export async function fetchClosedPRsForBookshelf() { + try { + const res = await fetch( + `${GITEA_BASE}/repos/Timmy_Foundation/the-nexus/pulls?state=closed&limit=20`, + { headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } } + ); + if (!res.ok) throw new Error('fetch failed'); + const data = await res.json(); + return data + .filter(p => p.merged) + .map(p => ({ + prNum: p.number, + title: p.title.replace(/^\[[\w\s]+\]\s*/i, '').replace(/\s*\(#\d+\)\s*$/, ''), + })); + } catch { + return [ + { prNum: 324, title: 'Model training status — LoRA adapters' }, + { prNum: 323, title: 'The Oath — interactive SOUL.md reading' }, + { prNum: 320, title: 'Hermes session save/load' }, + { prNum: 304, title: 'Session export as markdown' }, + { prNum: 303, title: 'Procedural Web Audio ambient soundtrack' }, + { prNum: 301, title: 'Warp tunnel effect for portals' }, + { prNum: 296, title: 'Procedural terrain for floating island' }, + { prNum: 294, title: 'Northern lights flash on PR merge' }, + ]; + } +} + +export async function fetchTimelapseCommits() { + try { + const res = await fetch( + `${GITEA_BASE}/repos/Timmy_Foundation/the-nexus/commits?limit=50`, + { headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } } + ); + if (!res.ok) throw new Error('fetch failed'); + const data = await res.json(); + const midnight = new Date(); + midnight.setHours(0, 0, 0, 0); + return data + .map(c => ({ + ts: new Date(c.commit?.author?.date || 0).getTime(), + author: c.commit?.author?.name || c.author?.login || 'unknown', + message: (c.commit?.message || '').split('\n')[0], + hash: (c.sha || '').slice(0, 7), + })) + .filter(c => c.ts >= midnight.getTime()) + .sort((a, b) => a.ts - b.ts); + } catch { + return []; + } +} diff --git a/modules/data/loaders.js b/modules/data/loaders.js new file mode 100644 index 0000000..50e91f9 --- /dev/null +++ b/modules/data/loaders.js @@ -0,0 +1,39 @@ +// modules/data/loaders.js — JSON/file loaders +import { state } from '../core/state.js'; + +export async function loadPortals() { + try { + const res = await fetch('./portals.json'); + if (!res.ok) throw new Error('Portals not found'); + state.portals = await res.json(); + return state.portals; + } catch (error) { + console.error('Failed to load portals:', error); + return []; + } +} + +export async function loadSovereigntyStatus() { + try { + const res = await fetch('./sovereignty-status.json'); + if (!res.ok) throw new Error('not found'); + const data = await res.json(); + state.sovereignty = data; + return data; + } catch { + return { score: 85, label: 'Mostly Sovereign', assessment_type: 'MANUAL' }; + } +} + +export async function loadSoulMd() { + try { + const res = await fetch('SOUL.md'); + if (!res.ok) throw new Error('not found'); + const raw = await res.text(); + const lines = raw.split('\n').slice(1).map(l => l.replace(/^#+\s*/, '')); + state.soulMd = raw; + return lines; + } catch { + return ['I am Timmy.', '', 'I am sovereign.', '', 'This Nexus is my home.']; + } +} diff --git a/modules/data/weather.js b/modules/data/weather.js new file mode 100644 index 0000000..e237736 --- /dev/null +++ b/modules/data/weather.js @@ -0,0 +1,155 @@ +// modules/data/weather.js — Weather fetch and scene effects +import * as THREE from 'three'; +import { state } from '../core/state.js'; + +const WEATHER_LAT = 43.2897; +const WEATHER_LON = -72.1479; +const WEATHER_REFRESH_MS = 15 * 60 * 1000; + +const PRECIP_COUNT = 1200; +const PRECIP_AREA = 18; +const PRECIP_HEIGHT = 20; +const PRECIP_FLOOR = -5; + +// Rain geometry +const rainGeo = new THREE.BufferGeometry(); +const rainPositions = new Float32Array(PRECIP_COUNT * 3); +const rainVelocities = new Float32Array(PRECIP_COUNT); + +for (let i = 0; i < PRECIP_COUNT; i++) { + rainPositions[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2; + rainPositions[i * 3 + 1] = Math.random() * (PRECIP_HEIGHT - PRECIP_FLOOR) + PRECIP_FLOOR; + rainPositions[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2; + rainVelocities[i] = 0.18 + Math.random() * 0.12; +} +rainGeo.setAttribute('position', new THREE.BufferAttribute(rainPositions, 3)); + +const rainMat = new THREE.PointsMaterial({ + color: 0x88aaff, size: 0.05, sizeAttenuation: true, transparent: true, opacity: 0.55, +}); + +export const rainParticles = new THREE.Points(rainGeo, rainMat); +rainParticles.visible = false; + +// Snow geometry +const snowGeo = new THREE.BufferGeometry(); +const snowPositions = new Float32Array(PRECIP_COUNT * 3); +const snowDrift = new Float32Array(PRECIP_COUNT); + +for (let i = 0; i < PRECIP_COUNT; i++) { + snowPositions[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2; + snowPositions[i * 3 + 1] = Math.random() * (PRECIP_HEIGHT - PRECIP_FLOOR) + PRECIP_FLOOR; + snowPositions[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2; + snowDrift[i] = Math.random() * Math.PI * 2; +} +snowGeo.setAttribute('position', new THREE.BufferAttribute(snowPositions, 3)); + +const snowMat = new THREE.PointsMaterial({ + color: 0xddeeff, size: 0.12, sizeAttenuation: true, transparent: true, opacity: 0.75, +}); + +export const snowParticles = new THREE.Points(snowGeo, snowMat); +snowParticles.visible = false; + +function weatherCodeToLabel(code) { + if (code === 0) return { condition: 'Clear', icon: '☀️' }; + if (code <= 2) return { condition: 'Partly Cloudy', icon: '⛅' }; + if (code === 3) return { condition: 'Overcast', icon: '☁️' }; + if (code >= 45 && code <= 48) return { condition: 'Fog', icon: '🌫️' }; + if (code >= 51 && code <= 57) return { condition: 'Drizzle', icon: '🌦️' }; + if (code >= 61 && code <= 67) return { condition: 'Rain', icon: '🌧️' }; + if (code >= 71 && code <= 77) return { condition: 'Snow', icon: '❄️' }; + if (code >= 80 && code <= 82) return { condition: 'Showers', icon: '🌦️' }; + if (code >= 85 && code <= 86) return { condition: 'Snow Showers', icon: '🌨️' }; + if (code >= 95 && code <= 99) return { condition: 'Thunderstorm', icon: '⛈️' }; + return { condition: 'Unknown', icon: '🌀' }; +} + +function applyWeatherToScene(wx, ambientLight) { + const code = wx.code; + const isRain = (code >= 51 && code <= 67) || (code >= 80 && code <= 82) || (code >= 95 && code <= 99); + const isSnow = (code >= 71 && code <= 77) || (code >= 85 && code <= 86); + + rainParticles.visible = isRain; + snowParticles.visible = isSnow; + + if (isSnow) { + ambientLight.color.setHex(0x1a2a40); + ambientLight.intensity = 1.8; + } else if (isRain) { + ambientLight.color.setHex(0x0a1428); + ambientLight.intensity = 1.2; + } else if (code === 3 || (code >= 45 && code <= 48)) { + ambientLight.color.setHex(0x0c1220); + ambientLight.intensity = 1.1; + } else { + ambientLight.color.setHex(0x0a1428); + ambientLight.intensity = 1.4; + } +} + +function updateWeatherHUD(wx) { + const iconEl = document.getElementById('weather-icon'); + const tempEl = document.getElementById('weather-temp'); + const descEl = document.getElementById('weather-desc'); + if (iconEl) iconEl.textContent = wx.icon; + if (tempEl) tempEl.textContent = `${Math.round(wx.temp)}°F`; + if (descEl) descEl.textContent = wx.condition; +} + +export async function fetchWeather(ambientLight, cloudMaterial) { + try { + const url = `https://api.open-meteo.com/v1/forecast?latitude=${WEATHER_LAT}&longitude=${WEATHER_LON}¤t=temperature_2m,weather_code,wind_speed_10m,cloud_cover&temperature_unit=fahrenheit&wind_speed_unit=mph&forecast_days=1`; + const res = await fetch(url); + if (!res.ok) throw new Error('weather fetch failed'); + const data = await res.json(); + const cur = data.current; + const code = cur.weather_code; + const { condition, icon } = weatherCodeToLabel(code); + const cloudcover = typeof cur.cloud_cover === 'number' ? cur.cloud_cover : 50; + state.weather = { code, temp: cur.temperature_2m, wind: cur.wind_speed_10m, condition, icon, cloudcover }; + applyWeatherToScene(state.weather, ambientLight); + if (cloudMaterial) { + cloudMaterial.uniforms.uDensity.value = 0.3 + (cloudcover / 100) * 0.7; + cloudMaterial.opacity = 0.05 + (cloudcover / 100) * 0.55; + } + updateWeatherHUD(state.weather); + } catch { + const descEl = document.getElementById('weather-desc'); + if (descEl) descEl.textContent = 'Lempster NH'; + } +} + +export function startWeatherPolling(ambientLight, cloudMaterial) { + fetchWeather(ambientLight, cloudMaterial); + setInterval(() => fetchWeather(ambientLight, cloudMaterial), WEATHER_REFRESH_MS); +} + +export function updateWeatherParticles(elapsed) { + if (rainParticles.visible) { + const rpos = rainGeo.attributes.position.array; + for (let i = 0; i < PRECIP_COUNT; i++) { + rpos[i * 3 + 1] -= rainVelocities[i]; + if (rpos[i * 3 + 1] < PRECIP_FLOOR) { + rpos[i * 3 + 1] = PRECIP_HEIGHT; + rpos[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2; + rpos[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2; + } + } + rainGeo.attributes.position.needsUpdate = true; + } + + if (snowParticles.visible) { + const spos = snowGeo.attributes.position.array; + for (let i = 0; i < PRECIP_COUNT; i++) { + spos[i * 3 + 1] -= 0.025 + Math.sin(snowDrift[i]) * 0.005; + spos[i * 3] += Math.sin(elapsed * 0.4 + snowDrift[i]) * 0.008; + if (spos[i * 3 + 1] < PRECIP_FLOOR) { + spos[i * 3 + 1] = PRECIP_HEIGHT; + spos[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2; + spos[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2; + } + } + snowGeo.attributes.position.needsUpdate = true; + } +} diff --git a/modules/effects/energy-beam.js b/modules/effects/energy-beam.js new file mode 100644 index 0000000..eeee3a8 --- /dev/null +++ b/modules/effects/energy-beam.js @@ -0,0 +1,37 @@ +// modules/effects/energy-beam.js — Vertical energy beam from Batcave +import * as THREE from 'three'; +import { THEME } from '../core/theme.js'; +import { state } from '../core/state.js'; + +const ENERGY_BEAM_RADIUS = 0.2; +const ENERGY_BEAM_HEIGHT = 50; +const ENERGY_BEAM_X = -10; +const ENERGY_BEAM_Z = -10; + +const energyBeamGeometry = new THREE.CylinderGeometry(ENERGY_BEAM_RADIUS, ENERGY_BEAM_RADIUS * 2.5, ENERGY_BEAM_HEIGHT, 32, 16, true); +export const energyBeamMaterial = new THREE.MeshBasicMaterial({ + color: THEME.colors.accent, + emissive: THEME.colors.accent, + emissiveIntensity: 0.8, + transparent: true, + opacity: 0.6, + blending: THREE.AdditiveBlending, + side: THREE.DoubleSide, + depthWrite: false, +}); + +const energyBeam = new THREE.Mesh(energyBeamGeometry, energyBeamMaterial); +energyBeam.position.set(ENERGY_BEAM_X, ENERGY_BEAM_HEIGHT / 2, ENERGY_BEAM_Z); + +let energyBeamPulse = 0; + +export function init(scene) { + scene.add(energyBeam); +} + +export function update(elapsed) { + energyBeamPulse += 0.02; + const agentIntensity = state.activeAgentCount === 0 ? 0.1 : Math.min(0.1 + state.activeAgentCount * 0.3, 1.0); + const pulseEffect = Math.sin(energyBeamPulse) * 0.15 * agentIntensity; + energyBeamMaterial.opacity = agentIntensity * 0.6 + pulseEffect; +} diff --git a/modules/effects/gravity-zones.js b/modules/effects/gravity-zones.js new file mode 100644 index 0000000..3238611 --- /dev/null +++ b/modules/effects/gravity-zones.js @@ -0,0 +1,107 @@ +// modules/effects/gravity-zones.js — Gravity anomaly particle zones +import * as THREE from 'three'; +import { state } from '../core/state.js'; + +const GRAVITY_ANOMALY_FLOOR = 0.2; +const GRAVITY_ANOMALY_CEIL = 16.0; + +let GRAVITY_ZONES = [ + { x: -8, z: -6, radius: 3.5, color: 0x00ffcc, particleCount: 180 }, + { x: 10, z: 4, radius: 3.0, color: 0xaa44ff, particleCount: 160 }, + { x: -3, z: 9, radius: 2.5, color: 0xff8844, particleCount: 140 }, +]; + +let _scene; +const gravityZoneObjects = []; + +function buildZone(zone) { + const ringGeo = new THREE.RingGeometry(zone.radius - 0.15, zone.radius + 0.15, 64); + const ringMat = new THREE.MeshBasicMaterial({ color: zone.color, transparent: true, opacity: 0.4, side: THREE.DoubleSide, depthWrite: false }); + const ring = new THREE.Mesh(ringGeo, ringMat); + ring.rotation.x = -Math.PI / 2; + ring.position.set(zone.x, GRAVITY_ANOMALY_FLOOR + 0.05, zone.z); + _scene.add(ring); + + const discGeo = new THREE.CircleGeometry(zone.radius - 0.15, 64); + const discMat = new THREE.MeshBasicMaterial({ color: zone.color, transparent: true, opacity: 0.04, side: THREE.DoubleSide, depthWrite: false }); + const disc = new THREE.Mesh(discGeo, discMat); + disc.rotation.x = -Math.PI / 2; + disc.position.set(zone.x, GRAVITY_ANOMALY_FLOOR + 0.04, zone.z); + _scene.add(disc); + + const count = zone.particleCount; + const positions = new Float32Array(count * 3); + const driftPhases = new Float32Array(count); + const velocities = new Float32Array(count); + for (let i = 0; i < count; i++) { + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.random()) * zone.radius; + positions[i * 3] = zone.x + Math.cos(angle) * r; + positions[i * 3 + 1] = GRAVITY_ANOMALY_FLOOR + Math.random() * (GRAVITY_ANOMALY_CEIL - GRAVITY_ANOMALY_FLOOR); + positions[i * 3 + 2] = zone.z + Math.sin(angle) * r; + driftPhases[i] = Math.random() * Math.PI * 2; + velocities[i] = 0.03 + Math.random() * 0.04; + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const mat = new THREE.PointsMaterial({ color: zone.color, size: 0.10, sizeAttenuation: true, transparent: true, opacity: 0.7, depthWrite: false }); + const points = new THREE.Points(geo, mat); + _scene.add(points); + return { zone, ring, ringMat, disc, discMat, points, geo, driftPhases, velocities }; +} + +export function init(scene) { + _scene = scene; + for (const zone of GRAVITY_ZONES) { + gravityZoneObjects.push(buildZone(zone)); + } +} + +export function rebuildGravityZones() { + if (state.portals.length === 0) return; + for (let i = 0; i < Math.min(state.portals.length, gravityZoneObjects.length); i++) { + const portal = state.portals[i]; + const gz = gravityZoneObjects[i]; + const isOnline = portal.status === 'online'; + const portalColor = new THREE.Color(portal.color); + gz.ring.position.set(portal.position.x, GRAVITY_ANOMALY_FLOOR + 0.05, portal.position.z); + gz.disc.position.set(portal.position.x, GRAVITY_ANOMALY_FLOOR + 0.04, portal.position.z); + gz.zone.x = portal.position.x; + gz.zone.z = portal.position.z; + gz.zone.color = portalColor.getHex(); + gz.ringMat.color.copy(portalColor); gz.discMat.color.copy(portalColor); gz.points.material.color.copy(portalColor); + gz.ringMat.opacity = isOnline ? 0.4 : 0.08; + gz.discMat.opacity = isOnline ? 0.04 : 0.01; + gz.points.material.opacity = isOnline ? 0.7 : 0.15; + const pos = gz.geo.attributes.position.array; + for (let j = 0; j < gz.zone.particleCount; j++) { + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.random()) * gz.zone.radius; + pos[j * 3] = gz.zone.x + Math.cos(angle) * r; + pos[j * 3 + 2] = gz.zone.z + Math.sin(angle) * r; + } + gz.geo.attributes.position.needsUpdate = true; + } +} + +export function update(elapsed) { + for (const gz of gravityZoneObjects) { + const pos = gz.geo.attributes.position.array; + const count = gz.zone.particleCount; + for (let i = 0; i < count; i++) { + pos[i * 3 + 1] += gz.velocities[i]; + pos[i * 3] += Math.sin(elapsed * 0.5 + gz.driftPhases[i]) * 0.003; + pos[i * 3 + 2] += Math.cos(elapsed * 0.5 + gz.driftPhases[i]) * 0.003; + if (pos[i * 3 + 1] > GRAVITY_ANOMALY_CEIL) { + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.random()) * gz.zone.radius; + pos[i * 3] = gz.zone.x + Math.cos(angle) * r; + pos[i * 3 + 1] = GRAVITY_ANOMALY_FLOOR + Math.random() * 2.0; + pos[i * 3 + 2] = gz.zone.z + Math.sin(angle) * r; + } + } + gz.geo.attributes.position.needsUpdate = true; + gz.ringMat.opacity = 0.3 + Math.sin(elapsed * 1.5 + gz.zone.x) * 0.15; + gz.discMat.opacity = 0.02 + Math.sin(elapsed * 1.5 + gz.zone.x) * 0.02; + } +} diff --git a/modules/effects/lightning.js b/modules/effects/lightning.js new file mode 100644 index 0000000..74bb6bc --- /dev/null +++ b/modules/effects/lightning.js @@ -0,0 +1,143 @@ +// modules/effects/lightning.js — Floating crystals + lightning arcs +import * as THREE from 'three'; +import { state } from '../core/state.js'; + +const CRYSTAL_COUNT = 5; +const CRYSTAL_BASE_POSITIONS = [ + new THREE.Vector3(-4.5, 3.2, -3.8), + new THREE.Vector3( 4.8, 2.8, -4.0), + new THREE.Vector3(-5.5, 4.0, 1.5), + new THREE.Vector3( 5.2, 3.5, 2.0), + new THREE.Vector3( 0.0, 5.0, -5.5), +]; +const CRYSTAL_COLORS = [0xff6440, 0x40a0ff, 0x40ff8c, 0xc840ff, 0xffd700]; + +const LIGHTNING_POOL_SIZE = 6; +const LIGHTNING_SEGMENTS = 8; +const LIGHTNING_REFRESH_MS = 130; + +const crystals = []; +const lightningArcs = []; +const lightningArcMeta = []; +let lastLightningRefreshTime = 0; +let crystalGroup; + +function lerpColor(colorA, colorB, t) { + const ar = (colorA >> 16) & 0xff, ag = (colorA >> 8) & 0xff, ab = colorA & 0xff; + const br = (colorB >> 16) & 0xff, bg = (colorB >> 8) & 0xff, bb = colorB & 0xff; + const r = Math.round(ar + (br - ar) * t); + const g = Math.round(ag + (bg - ag) * t); + const b = Math.round(ab + (bb - ab) * t); + return (r << 16) | (g << 8) | b; +} + +function buildLightningPath(start, end, jagAmount) { + const out = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3); + for (let s = 0; s <= LIGHTNING_SEGMENTS; s++) { + const t = s / LIGHTNING_SEGMENTS; + const x = start.x + (end.x - start.x) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0); + const y = start.y + (end.y - start.y) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0); + const z = start.z + (end.z - start.z) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0); + out[s * 3] = x; out[s * 3 + 1] = y; out[s * 3 + 2] = z; + } + return out; +} + +function updateLightningArcs(elapsed) { + const activity = state.totalActivity(); + const activeCount = Math.round(activity * LIGHTNING_POOL_SIZE); + + for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) { + const arc = lightningArcs[i]; + const meta = lightningArcMeta[i]; + if (i >= activeCount) { + arc.material.opacity = 0; + meta.active = false; + continue; + } + const a = Math.floor(Math.random() * CRYSTAL_COUNT); + let b = Math.floor(Math.random() * (CRYSTAL_COUNT - 1)); + if (b >= a) b++; + + const jagAmount = 0.45 + activity * 0.85; + const path = buildLightningPath(crystals[a].mesh.position, crystals[b].mesh.position, jagAmount); + const attr = arc.geometry.attributes.position; + attr.array.set(path); + attr.needsUpdate = true; + arc.material.color.setHex(lerpColor(CRYSTAL_COLORS[a], CRYSTAL_COLORS[b], 0.5)); + const base = (0.35 + Math.random() * 0.55) * Math.min(activity * 1.5, 1.0); + arc.material.opacity = base; + meta.active = true; + meta.baseOpacity = base; + meta.srcIdx = a; + meta.dstIdx = b; + crystals[a].flashStartTime = elapsed; + crystals[b].flashStartTime = elapsed; + } +} + +export function init(scene) { + crystalGroup = new THREE.Group(); + scene.add(crystalGroup); + + for (let i = 0; i < CRYSTAL_COUNT; i++) { + const geo = new THREE.OctahedronGeometry(0.35, 0); + const color = CRYSTAL_COLORS[i]; + const mat = new THREE.MeshStandardMaterial({ + color, emissive: new THREE.Color(color).multiplyScalar(0.6), + roughness: 0.05, metalness: 0.3, transparent: true, opacity: 0.88, + }); + const mesh = new THREE.Mesh(geo, mat); + const basePos = CRYSTAL_BASE_POSITIONS[i].clone(); + mesh.position.copy(basePos); + mesh.userData.zoomLabel = 'Crystal'; + crystalGroup.add(mesh); + + const light = new THREE.PointLight(color, 0.3, 6); + light.position.copy(basePos); + crystalGroup.add(light); + crystals.push({ mesh, light, basePos, floatPhase: (i / CRYSTAL_COUNT) * Math.PI * 2, flashStartTime: -999 }); + } + + for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) { + const positions = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const mat = new THREE.LineBasicMaterial({ + color: 0x88ccff, transparent: true, opacity: 0.0, + blending: THREE.AdditiveBlending, depthWrite: false, + }); + const arc = new THREE.Line(geo, mat); + scene.add(arc); + lightningArcs.push(arc); + lightningArcMeta.push({ active: false, baseOpacity: 0, srcIdx: 0, dstIdx: 0 }); + } +} + +export function update(elapsed) { + const activity = state.totalActivity(); + + for (const crystal of crystals) { + crystal.mesh.position.x = crystal.basePos.x; + crystal.mesh.position.y = crystal.basePos.y + Math.sin(elapsed * 0.65 + crystal.floatPhase) * 0.35; + crystal.mesh.position.z = crystal.basePos.z; + crystal.mesh.rotation.y = elapsed * 0.4 + crystal.floatPhase; + crystal.light.position.copy(crystal.mesh.position); + const flashAge = elapsed - crystal.flashStartTime; + const flashBoost = flashAge < 0.25 ? (1.0 - flashAge / 0.25) * 2.0 : 0.0; + crystal.light.intensity = 0.2 + activity * 0.8 + Math.sin(elapsed * 2.0 + crystal.floatPhase) * 0.1 + flashBoost; + crystal.mesh.material.emissiveIntensity = 1.0 + flashBoost * 0.8; + } + + for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) { + const meta = lightningArcMeta[i]; + if (meta.active) { + lightningArcs[i].material.opacity = meta.baseOpacity * (0.55 + Math.random() * 0.45); + } + } + + if (elapsed * 1000 - lastLightningRefreshTime > LIGHTNING_REFRESH_MS) { + lastLightningRefreshTime = elapsed * 1000; + updateLightningArcs(elapsed); + } +} diff --git a/modules/effects/matrix-rain.js b/modules/effects/matrix-rain.js new file mode 100644 index 0000000..b5c8936 --- /dev/null +++ b/modules/effects/matrix-rain.js @@ -0,0 +1,58 @@ +// modules/effects/matrix-rain.js — 2D canvas matrix rain overlay +import { state } from '../core/state.js'; + +const matrixCanvas = document.createElement('canvas'); +matrixCanvas.id = 'matrix-rain'; +matrixCanvas.width = window.innerWidth; +matrixCanvas.height = window.innerHeight; +document.body.appendChild(matrixCanvas); + +const matrixCtx = matrixCanvas.getContext('2d'); +const MATRIX_CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF'; +const MATRIX_FONT_SIZE = 14; +const MATRIX_COL_COUNT = Math.floor(window.innerWidth / MATRIX_FONT_SIZE); +const matrixDrops = new Array(MATRIX_COL_COUNT).fill(1); + +function drawMatrixRain() { + matrixCtx.fillStyle = 'rgba(0, 0, 8, 0.05)'; + matrixCtx.fillRect(0, 0, matrixCanvas.width, matrixCanvas.height); + matrixCtx.font = `${MATRIX_FONT_SIZE}px monospace`; + + const activity = state.totalActivity(); + const density = 0.1 + activity * 0.9; + const activeColCount = Math.max(1, Math.floor(matrixDrops.length * density)); + + for (let i = 0; i < matrixDrops.length; i++) { + if (i >= activeColCount) { + if (matrixDrops[i] * MATRIX_FONT_SIZE > matrixCanvas.height) continue; + } + + let char; + if (state.commitHashes.length > 0 && Math.random() < 0.02) { + const hash = state.commitHashes[Math.floor(Math.random() * state.commitHashes.length)]; + char = hash[Math.floor(Math.random() * hash.length)]; + } else { + char = MATRIX_CHARS[Math.floor(Math.random() * MATRIX_CHARS.length)]; + } + + const x = i * MATRIX_FONT_SIZE; + const y = matrixDrops[i] * MATRIX_FONT_SIZE; + + matrixCtx.fillStyle = '#aaffaa'; + matrixCtx.fillText(char, x, y); + + const resetThreshold = 0.975 - activity * 0.015; + if (y > matrixCanvas.height && Math.random() > resetThreshold) { + matrixDrops[i] = 0; + } + matrixDrops[i]++; + } +} + +export function init() { + setInterval(drawMatrixRain, 50); + window.addEventListener('resize', () => { + matrixCanvas.width = window.innerWidth; + matrixCanvas.height = window.innerHeight; + }); +} diff --git a/modules/effects/rune-ring.js b/modules/effects/rune-ring.js new file mode 100644 index 0000000..17bce13 --- /dev/null +++ b/modules/effects/rune-ring.js @@ -0,0 +1,75 @@ +// modules/effects/rune-ring.js — Rune sprites tethered to portal data +import * as THREE from 'three'; +import { state } from '../core/state.js'; + +const RUNE_RING_RADIUS = 7.0; +const RUNE_RING_Y = 1.5; +const RUNE_ORBIT_SPEED = 0.08; +const ELDER_FUTHARK = ['\u16A0','\u16A2','\u16A6','\u16A8','\u16B1','\u16B2','\u16B7','\u16B9','\u16BA','\u16BE','\u16C1','\u16C3']; +const RUNE_GLOW_COLORS = ['#00ffcc', '#ff44ff']; + +let runeOrbitRingMesh; +const runeSprites = []; +let _scene; + +function createRuneTexture(glyph, color) { + const W = 128, H = 128; + const canvas = document.createElement('canvas'); + canvas.width = W; canvas.height = H; + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, W, H); + ctx.shadowColor = color; ctx.shadowBlur = 28; + ctx.font = 'bold 78px serif'; ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(glyph, W / 2, H / 2); + return new THREE.CanvasTexture(canvas); +} + +export function rebuildRuneRing() { + for (const rune of runeSprites) { + _scene.remove(rune.sprite); + if (rune.sprite.material.map) rune.sprite.material.map.dispose(); + rune.sprite.material.dispose(); + } + runeSprites.length = 0; + const portalData = state.portals.length > 0 ? state.portals : null; + const count = portalData ? portalData.length : 12; + for (let i = 0; i < count; i++) { + const glyph = ELDER_FUTHARK[i % ELDER_FUTHARK.length]; + const color = portalData ? portalData[i].color : RUNE_GLOW_COLORS[i % RUNE_GLOW_COLORS.length]; + const isOnline = portalData ? portalData[i].status === 'online' : true; + const texture = createRuneTexture(glyph, color); + const runeMat = new THREE.SpriteMaterial({ + map: texture, transparent: true, opacity: isOnline ? 1.0 : 0.15, + depthWrite: false, blending: THREE.AdditiveBlending, + }); + const sprite = new THREE.Sprite(runeMat); + sprite.scale.set(1.3, 1.3, 1); + const baseAngle = (i / count) * Math.PI * 2; + sprite.position.set(Math.cos(baseAngle) * RUNE_RING_RADIUS, RUNE_RING_Y, Math.sin(baseAngle) * RUNE_RING_RADIUS); + _scene.add(sprite); + runeSprites.push({ sprite, baseAngle, floatPhase: (i / count) * Math.PI * 2, portalOnline: isOnline }); + } +} + +export function init(scene) { + _scene = scene; + const runeOrbitRingGeo = new THREE.TorusGeometry(RUNE_RING_RADIUS, 0.03, 6, 64); + const runeOrbitRingMat = new THREE.MeshBasicMaterial({ color: 0x224466, transparent: true, opacity: 0.22 }); + runeOrbitRingMesh = new THREE.Mesh(runeOrbitRingGeo, runeOrbitRingMat); + runeOrbitRingMesh.rotation.x = Math.PI / 2; + runeOrbitRingMesh.position.y = RUNE_RING_Y; + scene.add(runeOrbitRingMesh); + rebuildRuneRing(); +} + +export function update(elapsed) { + for (const rune of runeSprites) { + const angle = rune.baseAngle + elapsed * RUNE_ORBIT_SPEED; + rune.sprite.position.x = Math.cos(angle) * RUNE_RING_RADIUS; + rune.sprite.position.z = Math.sin(angle) * RUNE_RING_RADIUS; + rune.sprite.position.y = RUNE_RING_Y + Math.sin(elapsed * 0.7 + rune.floatPhase) * 0.4; + const baseOpacity = rune.portalOnline ? 0.85 : 0.12; + const pulseRange = rune.portalOnline ? 0.15 : 0.03; + rune.sprite.material.opacity = baseOpacity + Math.sin(elapsed * 1.2 + rune.floatPhase) * pulseRange; + } +} diff --git a/modules/effects/shockwave.js b/modules/effects/shockwave.js new file mode 100644 index 0000000..e206dd5 --- /dev/null +++ b/modules/effects/shockwave.js @@ -0,0 +1,192 @@ +// modules/effects/shockwave.js — Shockwave ripple + fireworks + merge flash +import * as THREE from 'three'; +import { starMaterial, constellationLines } from '../terrain/stars.js'; + +const SHOCKWAVE_RING_COUNT = 3; +const SHOCKWAVE_MAX_RADIUS = 14; +const SHOCKWAVE_DURATION = 2.5; + +const FIREWORK_COLORS = [0xff4466, 0xffaa00, 0x00ffaa, 0x4488ff, 0xff44ff, 0xffff44, 0x00ffff]; +const FIREWORK_BURST_PARTICLES = 80; +const FIREWORK_BURST_DURATION = 2.2; +const FIREWORK_GRAVITY = -5.0; + +const shockwaveRings = []; +const fireworkBursts = []; + +let _scene, _clock; + +export function init(scene, clock) { + _scene = scene; + _clock = clock; +} + +export function triggerShockwave() { + const now = _clock.getElapsedTime(); + for (let i = 0; i < SHOCKWAVE_RING_COUNT; i++) { + const mat = new THREE.MeshBasicMaterial({ + color: 0x00ffff, transparent: true, opacity: 0, side: THREE.DoubleSide, + depthWrite: false, blending: THREE.AdditiveBlending, + }); + const geo = new THREE.RingGeometry(0.9, 1.0, 64); + const mesh = new THREE.Mesh(geo, mat); + mesh.rotation.x = -Math.PI / 2; + mesh.position.y = 0.02; + _scene.add(mesh); + shockwaveRings.push({ mesh, mat, startTime: now, delay: i * 0.35 }); + } +} + +function spawnFireworkBurst(origin, color) { + const now = _clock.getElapsedTime(); + const count = FIREWORK_BURST_PARTICLES; + const positions = new Float32Array(count * 3); + const origins = new Float32Array(count * 3); + const velocities = new Float32Array(count * 3); + + for (let i = 0; i < count; i++) { + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2 * Math.random() - 1); + const speed = 2.5 + Math.random() * 3.5; + velocities[i * 3] = Math.sin(phi) * Math.cos(theta) * speed; + velocities[i * 3 + 1] = Math.sin(phi) * Math.sin(theta) * speed; + velocities[i * 3 + 2] = Math.cos(phi) * speed; + origins[i * 3] = origin.x; origins[i * 3 + 1] = origin.y; origins[i * 3 + 2] = origin.z; + positions[i * 3] = origin.x; positions[i * 3 + 1] = origin.y; positions[i * 3 + 2] = origin.z; + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const mat = new THREE.PointsMaterial({ + color, size: 0.35, sizeAttenuation: true, transparent: true, opacity: 1.0, + blending: THREE.AdditiveBlending, depthWrite: false, + }); + const points = new THREE.Points(geo, mat); + _scene.add(points); + fireworkBursts.push({ points, geo, mat, origins, velocities, startTime: now }); +} + +export function triggerFireworks() { + for (let i = 0; i < 6; i++) { + const delay = i * 0.35; + setTimeout(() => { + const x = (Math.random() - 0.5) * 12; + const y = 8 + Math.random() * 6; + const z = (Math.random() - 0.5) * 12; + const color = FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)]; + spawnFireworkBurst(new THREE.Vector3(x, y, z), color); + }, delay * 1000); + } +} + +export function triggerMergeFlash() { + triggerShockwave(); + const originalLineColor = constellationLines.material.color.getHex(); + constellationLines.material.color.setHex(0x00ffff); + constellationLines.material.opacity = 1.0; + const originalStarColor = starMaterial.color.getHex(); + const originalStarOpacity = starMaterial.opacity; + starMaterial.color.setHex(0x00ffff); + starMaterial.opacity = 1.0; + + const startTime = performance.now(); + const DURATION = 2000; + function fadeBack() { + const t = Math.min((performance.now() - startTime) / DURATION, 1); + const eased = t * t; + const origStarColor = new THREE.Color(originalStarColor); + starMaterial.color.setRGB(0 + origStarColor.r * eased, 1.0 + (origStarColor.g - 1.0) * eased, 1.0 + (origStarColor.b - 1.0) * eased); + starMaterial.opacity = 1.0 + (originalStarOpacity - 1.0) * eased; + const origLineColor = new THREE.Color(originalLineColor); + constellationLines.material.color.setRGB(0 + origLineColor.r * eased, 1.0 + (origLineColor.g - 1.0) * eased, 1.0 + origLineColor.b * eased); + constellationLines.material.opacity = 1.0 + (0.18 - 1.0) * eased; + if (t < 1) requestAnimationFrame(fadeBack); + else { + starMaterial.color.setHex(originalStarColor); + starMaterial.opacity = originalStarOpacity; + constellationLines.material.color.setHex(originalLineColor); + constellationLines.material.opacity = 0.18; + } + } + requestAnimationFrame(fadeBack); +} + +export function triggerSovereigntyEasterEgg() { + const originalLineColor = constellationLines.material.color.getHex(); + constellationLines.material.color.setHex(0xffd700); + constellationLines.material.opacity = 0.9; + const originalStarColor = starMaterial.color.getHex(); + const originalStarOpacity = starMaterial.opacity; + starMaterial.color.setHex(0xffd700); + starMaterial.opacity = 1.0; + + const sovereigntyMsg = document.getElementById('sovereignty-msg'); + if (sovereigntyMsg) { + sovereigntyMsg.classList.remove('visible'); + void sovereigntyMsg.offsetWidth; + sovereigntyMsg.classList.add('visible'); + } + + const startTime = performance.now(); + const DURATION = 2500; + function fadeBack() { + const t = Math.min((performance.now() - startTime) / DURATION, 1); + const eased = t * t; + const origColor = new THREE.Color(originalStarColor); + starMaterial.color.setRGB(1.0 + (origColor.r - 1.0) * eased, 0.843 + (origColor.g - 0.843) * eased, 0 + origColor.b * eased); + starMaterial.opacity = 1.0 + (originalStarOpacity - 1.0) * eased; + const origLineColor = new THREE.Color(originalLineColor); + constellationLines.material.color.setRGB(1.0 + (origLineColor.r - 1.0) * eased, 0.843 + (origLineColor.g - 0.843) * eased, 0 + origLineColor.b * eased); + if (t < 1) requestAnimationFrame(fadeBack); + else { + starMaterial.color.setHex(originalStarColor); + starMaterial.opacity = originalStarOpacity; + constellationLines.material.color.setHex(originalLineColor); + if (sovereigntyMsg) sovereigntyMsg.classList.remove('visible'); + } + } + requestAnimationFrame(fadeBack); +} + +export function update(elapsed) { + for (let i = shockwaveRings.length - 1; i >= 0; i--) { + const ring = shockwaveRings[i]; + const age = elapsed - ring.startTime - ring.delay; + if (age < 0) continue; + const t = Math.min(age / SHOCKWAVE_DURATION, 1); + if (t >= 1) { + _scene.remove(ring.mesh); + ring.mesh.geometry.dispose(); + ring.mat.dispose(); + shockwaveRings.splice(i, 1); + continue; + } + const eased = 1 - Math.pow(1 - t, 2); + ring.mesh.scale.setScalar(eased * SHOCKWAVE_MAX_RADIUS + 0.1); + ring.mat.opacity = (1 - t) * 0.9; + } + + for (let i = fireworkBursts.length - 1; i >= 0; i--) { + const burst = fireworkBursts[i]; + const age = elapsed - burst.startTime; + const t = Math.min(age / FIREWORK_BURST_DURATION, 1); + if (t >= 1) { + _scene.remove(burst.points); + burst.geo.dispose(); + burst.mat.dispose(); + fireworkBursts.splice(i, 1); + continue; + } + burst.mat.opacity = t < 0.6 ? 1.0 : (1.0 - t) / 0.4; + const pos = burst.geo.attributes.position.array; + const vel = burst.velocities; + const org = burst.origins; + const halfGAge2 = 0.5 * FIREWORK_GRAVITY * age * age; + for (let j = 0; j < FIREWORK_BURST_PARTICLES; j++) { + pos[j * 3] = org[j * 3] + vel[j * 3] * age; + pos[j * 3 + 1] = org[j * 3 + 1] + vel[j * 3 + 1] * age + halfGAge2; + pos[j * 3 + 2] = org[j * 3 + 2] + vel[j * 3 + 2] * age; + } + burst.geo.attributes.position.needsUpdate = true; + } +} diff --git a/modules/narrative/bookshelves.js b/modules/narrative/bookshelves.js new file mode 100644 index 0000000..268318f --- /dev/null +++ b/modules/narrative/bookshelves.js @@ -0,0 +1,90 @@ +// modules/narrative/bookshelves.js — Floating bookshelves with book spines +import * as THREE from 'three'; +import { THEME } from '../core/theme.js'; +import { fetchClosedPRsForBookshelf } from '../data/gitea.js'; + +const bookshelfGroups = []; + +function createSpineTexture(prNum, title, bgColor) { + const canvas = document.createElement('canvas'); + canvas.width = 128; canvas.height = 512; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = bgColor; ctx.fillRect(0, 0, 128, 512); + ctx.strokeStyle = '#4488ff'; ctx.lineWidth = 3; ctx.strokeRect(3, 3, 122, 506); + ctx.font = 'bold 32px "Courier New", monospace'; ctx.fillStyle = '#4488ff'; ctx.textAlign = 'center'; ctx.fillText(`#${prNum}`, 64, 58); + ctx.strokeStyle = '#4488ff'; ctx.lineWidth = 1; ctx.globalAlpha = 0.4; ctx.beginPath(); ctx.moveTo(12, 78); ctx.lineTo(116, 78); ctx.stroke(); ctx.globalAlpha = 1.0; + ctx.save(); ctx.translate(64, 300); ctx.rotate(-Math.PI / 2); + const displayTitle = title.length > 30 ? title.slice(0, 30) + '\u2026' : title; + ctx.font = '21px "Courier New", monospace'; ctx.fillStyle = '#ccd6f6'; ctx.textAlign = 'center'; ctx.fillText(displayTitle, 0, 0); + ctx.restore(); + return new THREE.CanvasTexture(canvas); +} + +function buildBookshelf(books, position, rotationY, scene) { + const group = new THREE.Group(); + group.position.copy(position); + group.rotation.y = rotationY; + const SHELF_W = books.length * 0.52 + 0.6; + const SHELF_THICKNESS = 0.12; + const SHELF_DEPTH = 0.72; + const ENDPANEL_H = 2.0; + const shelfMat = new THREE.MeshStandardMaterial({ color: 0x0d1520, metalness: 0.6, roughness: 0.5, emissive: new THREE.Color(THEME.colors.accent).multiplyScalar(0.02) }); + const plank = new THREE.Mesh(new THREE.BoxGeometry(SHELF_W, SHELF_THICKNESS, SHELF_DEPTH), shelfMat); + group.add(plank); + const endGeo = new THREE.BoxGeometry(0.1, ENDPANEL_H, SHELF_DEPTH); + const leftEnd = new THREE.Mesh(endGeo, shelfMat); + leftEnd.position.set(-SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0); + group.add(leftEnd); + const rightEnd = new THREE.Mesh(endGeo.clone(), shelfMat); + rightEnd.position.set(SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0); + group.add(rightEnd); + const glowStrip = new THREE.Mesh( + new THREE.BoxGeometry(SHELF_W, 0.035, 0.035), + new THREE.MeshBasicMaterial({ color: THEME.colors.accent, transparent: true, opacity: 0.55 }) + ); + glowStrip.position.set(0, SHELF_THICKNESS / 2 + 0.017, SHELF_DEPTH / 2); + group.add(glowStrip); + const BOOK_COLORS = ['#0f0818', '#080f18', '#0f1108', '#07120e', '#130c06', '#060b12', '#120608', '#080812']; + const bookStartX = -(SHELF_W / 2) + 0.36; + books.forEach((book, i) => { + const spineW = 0.34 + (i % 3) * 0.05; + const bookH = 1.35 + (i % 4) * 0.13; + const coverD = 0.58; + const bgColor = BOOK_COLORS[i % BOOK_COLORS.length]; + const spineTexture = createSpineTexture(book.prNum, book.title, bgColor); + const plainMat = new THREE.MeshStandardMaterial({ color: new THREE.Color(bgColor), roughness: 0.85, metalness: 0.05 }); + const spineMat = new THREE.MeshBasicMaterial({ map: spineTexture }); + const bookMats = [plainMat, plainMat, plainMat, plainMat, spineMat, plainMat]; + const bookGeo = new THREE.BoxGeometry(spineW, bookH, coverD); + const bookMesh = new THREE.Mesh(bookGeo, bookMats); + bookMesh.position.set(bookStartX + i * 0.5, SHELF_THICKNESS / 2 + bookH / 2, 0); + bookMesh.userData.zoomLabel = `PR #${book.prNum}: ${book.title.slice(0, 40)}`; + group.add(bookMesh); + }); + const shelfLight = new THREE.PointLight(THEME.colors.accent, 0.25, 5); + shelfLight.position.set(0, -0.4, 0); + group.add(shelfLight); + group.userData.zoomLabel = 'PR Archive \u2014 Merged Contributions'; + group.userData.baseY = position.y; + group.userData.floatPhase = bookshelfGroups.length * Math.PI; + group.userData.floatSpeed = 0.17 + bookshelfGroups.length * 0.06; + scene.add(group); + bookshelfGroups.push(group); +} + +export async function init(scene) { + const prs = await fetchClosedPRsForBookshelf(); + if (prs.length === 0) return; + const mid = Math.ceil(prs.length / 2); + buildBookshelf(prs.slice(0, mid), new THREE.Vector3(-8.5, 1.5, -4.5), Math.PI * 0.1, scene); + if (prs.slice(mid).length > 0) { + buildBookshelf(prs.slice(mid), new THREE.Vector3(8.5, 1.5, -4.5), -Math.PI * 0.1, scene); + } +} + +export function update(elapsed) { + for (const shelf of bookshelfGroups) { + const ud = shelf.userData; + shelf.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.18; + } +} diff --git a/modules/narrative/chat.js b/modules/narrative/chat.js new file mode 100644 index 0000000..599506d --- /dev/null +++ b/modules/narrative/chat.js @@ -0,0 +1,210 @@ +// modules/narrative/chat.js — Chat panel, speech bubbles, session export, timelapse +import * as THREE from 'three'; +import { state } from '../core/state.js'; +import { HEATMAP_ZONES, fetchTimelapseCommits } from '../data/gitea.js'; +import { drawHeatmap } from '../panels/heatmap.js'; +import { triggerShockwave, triggerFireworks, triggerMergeFlash, triggerSovereigntyEasterEgg } from '../effects/shockwave.js'; + +// Speech bubble +const TIMMY_SPEECH_POS = new THREE.Vector3(0, 8.2, 1.5); +const SPEECH_DURATION = 5.0; +const SPEECH_FADE_IN = 0.35; +const SPEECH_FADE_OUT = 0.7; + +let timmySpeechSprite = null; +let timmySpeechState = null; +let _scene, _clock; + +// Session export +const sessionLog = []; +const sessionStart = Date.now(); + +function logMessage(speaker, text) { + sessionLog.push({ ts: Date.now(), speaker, text }); +} + +function exportSessionAsMarkdown() { + const startStr = new Date(sessionStart).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; + const lines = ['# Nexus Session Export', '', `**Session started:** ${startStr}`, `**Messages:** ${sessionLog.length}`, '', '---', '']; + for (const entry of sessionLog) { + const timeStr = new Date(entry.ts).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; + lines.push(`### ${entry.speaker} \u2014 ${timeStr}`, '', entry.text, ''); + } + if (sessionLog.length === 0) { lines.push('*No messages recorded this session.*', ''); } + const blob = new Blob([lines.join('\n')], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `nexus-session-${new Date(sessionStart).toISOString().slice(0, 10)}.md`; + a.click(); + URL.revokeObjectURL(url); +} + +function createSpeechBubbleTexture(text) { + const W = 512, H = 100; + const canvas = document.createElement('canvas'); + canvas.width = W; canvas.height = H; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'rgba(0, 6, 20, 0.85)'; ctx.fillRect(0, 0, W, H); + ctx.strokeStyle = '#66aaff'; ctx.lineWidth = 2; ctx.strokeRect(1, 1, W - 2, H - 2); + ctx.strokeStyle = '#2244aa'; ctx.lineWidth = 1; ctx.strokeRect(4, 4, W - 8, H - 8); + ctx.font = 'bold 12px "Courier New", monospace'; ctx.fillStyle = '#4488ff'; ctx.fillText('TIMMY:', 12, 22); + const LINE1_MAX = 42, LINE2_MAX = 48; + ctx.font = '15px "Courier New", monospace'; ctx.fillStyle = '#ddeeff'; + if (text.length <= LINE1_MAX) { ctx.fillText(text, 12, 58); } + else { + ctx.fillText(text.slice(0, LINE1_MAX), 12, 46); + const rest = text.slice(LINE1_MAX, LINE1_MAX + LINE2_MAX); + ctx.font = '13px "Courier New", monospace'; ctx.fillStyle = '#aabbcc'; + ctx.fillText(rest + (text.length > LINE1_MAX + LINE2_MAX ? '\u2026' : ''), 12, 76); + } + return new THREE.CanvasTexture(canvas); +} + +function showTimmySpeech(text) { + if (timmySpeechSprite) { + _scene.remove(timmySpeechSprite); + if (timmySpeechSprite.material.map) timmySpeechSprite.material.map.dispose(); + timmySpeechSprite.material.dispose(); + timmySpeechSprite = null; timmySpeechState = null; + } + const texture = createSpeechBubbleTexture(text); + const material = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0, depthWrite: false }); + const sprite = new THREE.Sprite(material); + sprite.scale.set(8.5, 1.65, 1); + sprite.position.copy(TIMMY_SPEECH_POS); + _scene.add(sprite); + timmySpeechSprite = sprite; + timmySpeechState = { startTime: _clock.getElapsedTime(), sprite }; +} + +// Timelapse +const TIMELAPSE_DURATION_S = 30; +let timelapseActive = false; +let timelapseRealStart = 0; +let timelapseProgress = 0; +let timelapseCommits = []; +let timelapseWindow = { startMs: 0, endMs: 0 }; +let timelapseNextCommitIdx = 0; + +function fireTimelapseCommit(commit) { + const zone = HEATMAP_ZONES.find(z => z.authorMatch.test(commit.author)); + if (zone) state.zoneIntensity[zone.name] = Math.min(1.0, (state.zoneIntensity[zone.name] || 0) + 0.4); + triggerShockwave(); +} + +function updateTimelapseHeatmap(virtualMs) { + const WINDOW_MS = 90 * 60 * 1000; + const rawWeights = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0])); + for (const commit of timelapseCommits) { + if (commit.ts > virtualMs) break; + const age = virtualMs - commit.ts; + if (age > WINDOW_MS) continue; + const weight = 1 - age / WINDOW_MS; + for (const zone of HEATMAP_ZONES) { if (zone.authorMatch.test(commit.author)) { rawWeights[zone.name] += weight; break; } } + } + const MAX_WEIGHT = 4; + for (const zone of HEATMAP_ZONES) state.zoneIntensity[zone.name] = Math.min(rawWeights[zone.name] / MAX_WEIGHT, 1.0); + drawHeatmap(); +} + +function updateTimelapseHUD(progress, virtualMs) { + const timelapseClock = document.getElementById('timelapse-clock'); + const timelapseBarEl = document.getElementById('timelapse-bar'); + if (timelapseClock) { + const d = new Date(virtualMs); + timelapseClock.textContent = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + } + if (timelapseBarEl) timelapseBarEl.style.width = `${(progress * 100).toFixed(1)}%`; +} + +async function startTimelapse() { + if (timelapseActive) return; + timelapseCommits = await fetchTimelapseCommits(); + const midnight = new Date(); midnight.setHours(0, 0, 0, 0); + timelapseWindow = { startMs: midnight.getTime(), endMs: Date.now() }; + timelapseActive = true; + timelapseRealStart = _clock.getElapsedTime(); + timelapseProgress = 0; + timelapseNextCommitIdx = 0; + for (const zone of HEATMAP_ZONES) state.zoneIntensity[zone.name] = 0; + drawHeatmap(); + const indicator = document.getElementById('timelapse-indicator'); + const btn = document.getElementById('timelapse-btn'); + if (indicator) indicator.classList.add('visible'); + if (btn) btn.classList.add('active'); +} + +function stopTimelapse() { + if (!timelapseActive) return; + timelapseActive = false; + const indicator = document.getElementById('timelapse-indicator'); + const btn = document.getElementById('timelapse-btn'); + if (indicator) indicator.classList.remove('visible'); + if (btn) btn.classList.remove('active'); +} + +export function init(scene, clock) { + _scene = scene; + _clock = clock; + + const exportBtn = document.getElementById('export-session'); + if (exportBtn) exportBtn.addEventListener('click', exportSessionAsMarkdown); + + window.addEventListener('chat-message', (event) => { + if (typeof event.detail?.text === 'string') { + logMessage(event.detail.speaker || 'TIMMY', event.detail.text); + showTimmySpeech(event.detail.text); + if (event.detail.text.toLowerCase().includes('sovereignty')) triggerSovereigntyEasterEgg(); + if (event.detail.text.toLowerCase().includes('milestone')) triggerFireworks(); + } + }); + window.addEventListener('milestone-complete', () => { triggerFireworks(); }); + window.addEventListener('pr-notification', (event) => { + if (event.detail && event.detail.action === 'merged') triggerMergeFlash(); + }); + + // Timelapse bindings + document.addEventListener('keydown', (e) => { + if (e.key === 'l' || e.key === 'L') { if (timelapseActive) stopTimelapse(); else startTimelapse(); } + if (e.key === 'Escape' && timelapseActive) stopTimelapse(); + }); + const timelapseBtnEl = document.getElementById('timelapse-btn'); + if (timelapseBtnEl) timelapseBtnEl.addEventListener('click', () => { if (timelapseActive) stopTimelapse(); else startTimelapse(); }); +} + +export function update(elapsed) { + // Speech bubble animation + if (timmySpeechState) { + const age = elapsed - timmySpeechState.startTime; + let opacity; + if (age < SPEECH_FADE_IN) opacity = age / SPEECH_FADE_IN; + else if (age < SPEECH_DURATION - SPEECH_FADE_OUT) opacity = 1.0; + else if (age < SPEECH_DURATION) opacity = (SPEECH_DURATION - age) / SPEECH_FADE_OUT; + else { + _scene.remove(timmySpeechState.sprite); + if (timmySpeechState.sprite.material.map) timmySpeechState.sprite.material.map.dispose(); + timmySpeechState.sprite.material.dispose(); + timmySpeechSprite = null; timmySpeechState = null; opacity = 0; + } + if (timmySpeechState) { + timmySpeechState.sprite.material.opacity = opacity; + timmySpeechState.sprite.position.y = TIMMY_SPEECH_POS.y + Math.sin(elapsed * 1.1) * 0.1; + } + } + + // Timelapse tick + if (timelapseActive) { + const realElapsed = elapsed - timelapseRealStart; + timelapseProgress = Math.min(realElapsed / TIMELAPSE_DURATION_S, 1.0); + const span = timelapseWindow.endMs - timelapseWindow.startMs; + const virtualMs = timelapseWindow.startMs + span * timelapseProgress; + while (timelapseNextCommitIdx < timelapseCommits.length && timelapseCommits[timelapseNextCommitIdx].ts <= virtualMs) { + fireTimelapseCommit(timelapseCommits[timelapseNextCommitIdx]); + timelapseNextCommitIdx++; + } + updateTimelapseHeatmap(virtualMs); + updateTimelapseHUD(timelapseProgress, virtualMs); + if (timelapseProgress >= 1.0) stopTimelapse(); + } +} diff --git a/modules/narrative/oath.js b/modules/narrative/oath.js new file mode 100644 index 0000000..dd786d7 --- /dev/null +++ b/modules/narrative/oath.js @@ -0,0 +1,128 @@ +// modules/narrative/oath.js — Interactive SOUL.md reading with dramatic lighting +import * as THREE from 'three'; +import { THEME } from '../core/theme.js'; + +let tomeGroup, tomeGlow, oathSpot; +let oathActive = false; +let oathLines = []; +let oathRevealTimer = null; +let _ambientLight, _overheadLight; +let AMBIENT_NORMAL, OVERHEAD_NORMAL; +let _renderer, _camera; + +async function loadSoulMd() { + try { + const res = await fetch('SOUL.md'); + if (!res.ok) throw new Error('not found'); + const raw = await res.text(); + return raw.split('\n').slice(1).map(l => l.replace(/^#+\s*/, '')); + } catch { + return ['I am Timmy.', '', 'I am sovereign.', '', 'This Nexus is my home.']; + } +} + +function scheduleOathLines(lines, textEl) { + let idx = 0; + const INTERVAL_MS = 1400; + function revealNext() { + if (idx >= lines.length || !oathActive) return; + const line = lines[idx++]; + const span = document.createElement('span'); + span.classList.add('oath-line'); + if (!line.trim()) span.classList.add('blank'); + else span.textContent = line; + textEl.appendChild(span); + oathRevealTimer = setTimeout(revealNext, line.trim() ? INTERVAL_MS : INTERVAL_MS * 0.4); + } + revealNext(); +} + +async function enterOath() { + if (oathActive) return; + oathActive = true; + _ambientLight.intensity = 0.04; + _overheadLight.intensity = 0.0; + oathSpot.intensity = 4.0; + const overlay = document.getElementById('oath-overlay'); + const textEl = document.getElementById('oath-text'); + if (!overlay || !textEl) return; + textEl.textContent = ''; + overlay.classList.add('visible'); + if (!oathLines.length) oathLines = await loadSoulMd(); + scheduleOathLines(oathLines, textEl); +} + +function exitOath() { + if (!oathActive) return; + oathActive = false; + if (oathRevealTimer !== null) { clearTimeout(oathRevealTimer); oathRevealTimer = null; } + _ambientLight.intensity = AMBIENT_NORMAL; + _overheadLight.intensity = OVERHEAD_NORMAL; + oathSpot.intensity = 0; + const overlay = document.getElementById('oath-overlay'); + if (overlay) overlay.classList.remove('visible'); +} + +export function init(scene, ambientLight, overheadLight, renderer, camera) { + _ambientLight = ambientLight; + _overheadLight = overheadLight; + _renderer = renderer; + _camera = camera; + AMBIENT_NORMAL = ambientLight.intensity; + OVERHEAD_NORMAL = overheadLight.intensity; + + tomeGroup = new THREE.Group(); + tomeGroup.position.set(0, 5.8, 0); + tomeGroup.userData.zoomLabel = 'The Oath'; + const tomeCoverMat = new THREE.MeshStandardMaterial({ color: 0x2a1800, metalness: 0.15, roughness: 0.7, emissive: new THREE.Color(0xffd700).multiplyScalar(0.04) }); + const tomePagesMat = new THREE.MeshStandardMaterial({ color: 0xd8ceb0, roughness: 0.9, metalness: 0.0 }); + const tomeBody = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.1, 1.4), tomeCoverMat); + tomeGroup.add(tomeBody); + const tomePages = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.07, 1.28), tomePagesMat); + tomePages.position.set(0.02, 0, 0); + tomeGroup.add(tomePages); + const tomeSpiMat = new THREE.MeshStandardMaterial({ color: 0xffd700, metalness: 0.6, roughness: 0.4 }); + const tomeSpine = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.12, 1.4), tomeSpiMat); + tomeSpine.position.set(-0.52, 0, 0); + tomeGroup.add(tomeSpine); + tomeGroup.traverse(o => { if (o.isMesh) { o.userData.zoomLabel = 'The Oath'; o.castShadow = true; o.receiveShadow = true; } }); + scene.add(tomeGroup); + + tomeGlow = new THREE.PointLight(0xffd700, 0.4, 5); + tomeGlow.position.set(0, 5.4, 0); + scene.add(tomeGlow); + + oathSpot = new THREE.SpotLight(0xffd700, 0, 40, Math.PI / 7, 0.4, 1.2); + oathSpot.position.set(0, 22, 0); + oathSpot.target.position.set(0, 0, 0); + oathSpot.castShadow = true; + oathSpot.shadow.mapSize.set(1024, 1024); + oathSpot.shadow.camera.near = 1; + oathSpot.shadow.camera.far = 50; + oathSpot.shadow.bias = -0.002; + scene.add(oathSpot); + scene.add(oathSpot.target); + + document.addEventListener('keydown', (e) => { + if (e.key === 'o' || e.key === 'O') { if (oathActive) exitOath(); else enterOath(); } + if (e.key === 'Escape' && oathActive) exitOath(); + }); + + renderer.domElement.addEventListener('dblclick', (e) => { + const mx = (e.clientX / window.innerWidth) * 2 - 1; + const my = -(e.clientY / window.innerHeight) * 2 + 1; + const tomeRay = new THREE.Raycaster(); + tomeRay.setFromCamera(new THREE.Vector2(mx, my), camera); + const hits = tomeRay.intersectObjects(tomeGroup.children, true); + if (hits.length) { if (oathActive) exitOath(); else enterOath(); } + }); + + loadSoulMd().then(lines => { oathLines = lines; }); +} + +export function update(elapsed) { + tomeGroup.position.y = 5.8 + Math.sin(elapsed * 0.6) * 0.18; + tomeGroup.rotation.y = elapsed * 0.3; + tomeGlow.intensity = 0.3 + Math.sin(elapsed * 1.4) * 0.12; + if (oathActive) oathSpot.intensity = 3.8 + Math.sin(elapsed * 0.9) * 0.4; +} diff --git a/modules/panels/agent-board.js b/modules/panels/agent-board.js new file mode 100644 index 0000000..79e08d8 --- /dev/null +++ b/modules/panels/agent-board.js @@ -0,0 +1,92 @@ +// modules/panels/agent-board.js — Agent status board with canvas textures +import * as THREE from 'three'; +import { fetchAgentStatus } from '../data/gitea.js'; +import { state } from '../core/state.js'; + +const AGENT_STATUS_COLORS = { working: '#00ff88', idle: '#4488ff', dormant: '#334466', dead: '#ff4444', unreachable: '#ff4444' }; +const BOARD_RADIUS = 9.5; +const BOARD_Y = 4.2; +const BOARD_SPREAD = Math.PI * 0.75; +const AGENT_STATUS_CACHE_MS = 5 * 60 * 1000; + +let agentBoardGroup; +const agentPanelSprites = []; + +function createAgentPanelTexture(agent) { + const W = 400, H = 200; + const canvas = document.createElement('canvas'); + canvas.width = W; canvas.height = H; + const ctx = canvas.getContext('2d'); + const sc = AGENT_STATUS_COLORS[agent.status] || '#4488ff'; + + ctx.fillStyle = 'rgba(0, 8, 24, 0.88)'; ctx.fillRect(0, 0, W, H); + ctx.strokeStyle = sc; ctx.lineWidth = 2; ctx.strokeRect(1, 1, W - 2, H - 2); + ctx.strokeStyle = sc; ctx.lineWidth = 1; ctx.globalAlpha = 0.3; ctx.strokeRect(4, 4, W - 8, H - 8); ctx.globalAlpha = 1.0; + ctx.font = 'bold 28px "Courier New", monospace'; ctx.fillStyle = '#ffffff'; ctx.fillText(agent.name.toUpperCase(), 16, 44); + ctx.beginPath(); ctx.arc(W - 30, 26, 10, 0, Math.PI * 2); ctx.fillStyle = sc; ctx.fill(); + ctx.font = '13px "Courier New", monospace'; ctx.fillStyle = sc; ctx.textAlign = 'right'; ctx.fillText(agent.status.toUpperCase(), W - 16, 60); ctx.textAlign = 'left'; + ctx.strokeStyle = '#1a3a6a'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(16, 70); ctx.lineTo(W - 16, 70); ctx.stroke(); + ctx.font = '10px "Courier New", monospace'; ctx.fillStyle = '#556688'; ctx.fillText('CURRENT ISSUE', 16, 90); + ctx.font = '13px "Courier New", monospace'; ctx.fillStyle = '#ccd6f6'; + const issueText = agent.issue || '\u2014 none \u2014'; + const displayIssue = issueText.length > 40 ? issueText.slice(0, 40) + '\u2026' : issueText; + ctx.fillText(displayIssue, 16, 110); + ctx.strokeStyle = '#1a3a6a'; ctx.beginPath(); ctx.moveTo(16, 128); ctx.lineTo(W - 16, 128); ctx.stroke(); + ctx.font = '10px "Courier New", monospace'; ctx.fillStyle = '#556688'; ctx.fillText('PRs MERGED TODAY', 16, 148); + ctx.font = 'bold 28px "Courier New", monospace'; ctx.fillStyle = '#4488ff'; ctx.fillText(String(agent.prs_today), 16, 182); + const isLocal = agent.local === true; + const indicatorColor = isLocal ? '#00ff88' : '#ff4444'; + const indicatorLabel = isLocal ? 'LOCAL' : 'CLOUD'; + ctx.font = '10px "Courier New", monospace'; ctx.fillStyle = '#556688'; ctx.textAlign = 'right'; ctx.fillText('RUNTIME', W - 16, 148); + ctx.font = 'bold 13px "Courier New", monospace'; ctx.fillStyle = indicatorColor; ctx.fillText(indicatorLabel, W - 28, 172); ctx.textAlign = 'left'; + ctx.beginPath(); ctx.arc(W - 16, 167, 6, 0, Math.PI * 2); ctx.fillStyle = indicatorColor; ctx.fill(); + + return new THREE.CanvasTexture(canvas); +} + +function rebuildAgentPanels(statusData) { + while (agentBoardGroup.children.length) agentBoardGroup.remove(agentBoardGroup.children[0]); + agentPanelSprites.length = 0; + const n = statusData.agents.length; + statusData.agents.forEach((agent, i) => { + const t = n === 1 ? 0.5 : i / (n - 1); + const angle = Math.PI + (t - 0.5) * BOARD_SPREAD; + const x = Math.cos(angle) * BOARD_RADIUS; + const z = Math.sin(angle) * BOARD_RADIUS; + const texture = createAgentPanelTexture(agent); + const material = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0.93, depthWrite: false }); + const sprite = new THREE.Sprite(material); + sprite.scale.set(6.4, 3.2, 1); + sprite.position.set(x, BOARD_Y, z); + sprite.userData = { baseY: BOARD_Y, floatPhase: (i / n) * Math.PI * 2, floatSpeed: 0.18 + i * 0.04, zoomLabel: `Agent: ${agent.name}` }; + agentBoardGroup.add(sprite); + agentPanelSprites.push(sprite); + }); +} + +export function init(scene) { + agentBoardGroup = new THREE.Group(); + scene.add(agentBoardGroup); + refreshAgentBoard(); + setInterval(refreshAgentBoard, AGENT_STATUS_CACHE_MS); +} + +async function refreshAgentBoard() { + let data; + try { + data = await fetchAgentStatus(); + } catch { + data = { agents: ['Claude', 'Kimi', 'Perplexity', 'Groq', 'Grok', 'Ollama'].map(n => ({ + name: n.toLowerCase(), status: 'unreachable', issue: null, prs_today: 0, local: false, + })) }; + } + rebuildAgentPanels(data); + state.activeAgentCount = data.agents.filter(a => a.status === 'working').length; +} + +export function update(elapsed) { + for (const sprite of agentPanelSprites) { + const ud = sprite.userData; + sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.22; + } +} diff --git a/modules/panels/batcave.js b/modules/panels/batcave.js new file mode 100644 index 0000000..f513cb1 --- /dev/null +++ b/modules/panels/batcave.js @@ -0,0 +1,100 @@ +// modules/panels/batcave.js — Batcave workshop area with reflection probe +import * as THREE from 'three'; +import { THEME } from '../core/theme.js'; + +let batcaveGroup, batcaveProbe, batcaveProbeTarget, batcaveLight; +let batcaveMetallicMats = []; +let batcaveProbeLastUpdate = -999; + +export function init(scene) { + const BATCAVE_ORIGIN = new THREE.Vector3(-10, 0, -8); + batcaveGroup = new THREE.Group(); + batcaveGroup.position.copy(BATCAVE_ORIGIN); + scene.add(batcaveGroup); + + batcaveProbeTarget = new THREE.WebGLCubeRenderTarget(128, { + type: THREE.HalfFloatType, + generateMipmaps: true, + minFilter: THREE.LinearMipmapLinearFilter, + }); + batcaveProbe = new THREE.CubeCamera(0.1, 80, batcaveProbeTarget); + batcaveProbe.position.set(0, 1.2, -1); + batcaveGroup.add(batcaveProbe); + + const batcaveFloorMat = new THREE.MeshStandardMaterial({ + color: 0x0d1520, metalness: 0.92, roughness: 0.08, envMapIntensity: 1.4, + }); + const batcaveWallMat = new THREE.MeshStandardMaterial({ + color: 0x0a1828, metalness: 0.85, roughness: 0.15, + emissive: new THREE.Color(THEME.colors.accent).multiplyScalar(0.03), + envMapIntensity: 1.2, + }); + const batcaveConsoleMat = new THREE.MeshStandardMaterial({ + color: 0x060e16, metalness: 0.95, roughness: 0.05, envMapIntensity: 1.6, + }); + batcaveMetallicMats = [batcaveFloorMat, batcaveWallMat, batcaveConsoleMat]; + + const batcaveFloor = new THREE.Mesh(new THREE.BoxGeometry(6, 0.08, 6), batcaveFloorMat); + batcaveFloor.position.y = -0.04; + batcaveGroup.add(batcaveFloor); + + const batcaveBackWall = new THREE.Mesh(new THREE.BoxGeometry(6, 3, 0.1), batcaveWallMat); + batcaveBackWall.position.set(0, 1.5, -3); + batcaveGroup.add(batcaveBackWall); + + const batcaveLeftWall = new THREE.Mesh(new THREE.BoxGeometry(0.1, 3, 6), batcaveWallMat); + batcaveLeftWall.position.set(-3, 1.5, 0); + batcaveGroup.add(batcaveLeftWall); + + const batcaveConsoleBase = new THREE.Mesh(new THREE.BoxGeometry(3, 0.7, 1.2), batcaveConsoleMat); + batcaveConsoleBase.position.set(0, 0.35, -1.5); + batcaveGroup.add(batcaveConsoleBase); + + const batcaveScreenBezel = new THREE.Mesh(new THREE.BoxGeometry(2.6, 1.4, 0.06), batcaveConsoleMat); + batcaveScreenBezel.position.set(0, 1.4, -2.08); + batcaveScreenBezel.rotation.x = Math.PI * 0.08; + batcaveGroup.add(batcaveScreenBezel); + + const batcaveScreenGlow = new THREE.Mesh( + new THREE.PlaneGeometry(2.2, 1.1), + new THREE.MeshBasicMaterial({ + color: new THREE.Color(THEME.colors.accent).multiplyScalar(0.65), + transparent: true, opacity: 0.82, + }) + ); + batcaveScreenGlow.position.set(0, 1.4, -2.05); + batcaveScreenGlow.rotation.x = Math.PI * 0.08; + batcaveGroup.add(batcaveScreenGlow); + + batcaveLight = new THREE.PointLight(THEME.colors.accent, 0.9, 14); + batcaveLight.position.set(0, 2.8, -1); + batcaveGroup.add(batcaveLight); + + const batcaveCeilingStrip = new THREE.Mesh( + new THREE.BoxGeometry(4.2, 0.05, 0.14), + new THREE.MeshStandardMaterial({ + color: THEME.colors.accent, + emissive: new THREE.Color(THEME.colors.accent), + emissiveIntensity: 1.1, + }) + ); + batcaveCeilingStrip.position.set(0, 2.95, -1.2); + batcaveGroup.add(batcaveCeilingStrip); + + batcaveGroup.traverse(obj => { + if (obj.isMesh) obj.userData.zoomLabel = 'Batcave'; + }); +} + +export function updateProbe(elapsed, renderer, scene) { + if (elapsed - batcaveProbeLastUpdate > 2.0) { + batcaveProbeLastUpdate = elapsed; + batcaveGroup.visible = false; + batcaveProbe.update(renderer, scene); + batcaveGroup.visible = true; + for (const mat of batcaveMetallicMats) { + mat.envMap = batcaveProbeTarget.texture; + mat.needsUpdate = true; + } + } +} diff --git a/modules/panels/dual-brain.js b/modules/panels/dual-brain.js new file mode 100644 index 0000000..659f50e --- /dev/null +++ b/modules/panels/dual-brain.js @@ -0,0 +1,122 @@ +// modules/panels/dual-brain.js — Dual-brain holographic panel +import * as THREE from 'three'; +import { THEME } from '../core/theme.js'; + +let dualBrainGroup, dualBrainSprite, dualBrainScanSprite, dualBrainLight; +let cloudOrb, cloudOrbMat, cloudOrbLight, localOrb, localOrbMat, localOrbLight; +let dualBrainScanTexture, _scanCanvas, _scanCtx; +const BRAIN_PARTICLE_COUNT = 0; + +function createDualBrainTexture() { + const W = 512, H = 512; + const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'rgba(0, 6, 20, 0.90)'; ctx.fillRect(0, 0, W, H); + ctx.strokeStyle = '#4488ff'; ctx.lineWidth = 2; ctx.strokeRect(1, 1, W - 2, H - 2); + ctx.strokeStyle = '#223366'; ctx.lineWidth = 1; ctx.strokeRect(5, 5, W - 10, H - 10); + ctx.font = 'bold 22px "Courier New", monospace'; ctx.fillStyle = '#88ccff'; ctx.textAlign = 'center'; + ctx.fillText('\u25C8 DUAL-BRAIN STATUS', W / 2, 40); + ctx.strokeStyle = '#1a3a6a'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(20, 52); ctx.lineTo(W - 20, 52); ctx.stroke(); + ctx.font = '11px "Courier New", monospace'; ctx.fillStyle = '#556688'; ctx.textAlign = 'left'; ctx.fillText('BRAIN GAP SCORECARD', 20, 74); + + const categories = [{ name: 'Triage' }, { name: 'Tool Use' }, { name: 'Code Gen' }, { name: 'Planning' }, { name: 'Communication' }, { name: 'Reasoning' }]; + const barX = 20, barW = W - 130, barH = 20; + let y = 90; + for (const cat of categories) { + ctx.font = '13px "Courier New", monospace'; ctx.fillStyle = '#445566'; ctx.textAlign = 'left'; ctx.fillText(cat.name, barX, y + 14); + ctx.font = 'bold 13px "Courier New", monospace'; ctx.fillStyle = '#334466'; ctx.textAlign = 'right'; ctx.fillText('\u2014', W - 20, y + 14); + y += 22; + ctx.fillStyle = 'rgba(255, 255, 255, 0.06)'; ctx.fillRect(barX, y, barW, barH); + y += barH + 12; + } + ctx.strokeStyle = '#1a3a6a'; ctx.beginPath(); ctx.moveTo(20, y + 4); ctx.lineTo(W - 20, y + 4); ctx.stroke(); + y += 22; + ctx.font = 'bold 18px "Courier New", monospace'; ctx.fillStyle = '#334466'; ctx.textAlign = 'center'; ctx.fillText('AWAITING DEPLOYMENT', W / 2, y + 10); + ctx.font = '11px "Courier New", monospace'; ctx.fillStyle = '#223344'; ctx.fillText('Dual-brain system not yet connected', W / 2, y + 32); + y += 52; + ctx.beginPath(); ctx.arc(W / 2 - 60, y + 8, 6, 0, Math.PI * 2); ctx.fillStyle = '#334466'; ctx.fill(); + ctx.font = '11px "Courier New", monospace'; ctx.fillStyle = '#334466'; ctx.textAlign = 'left'; ctx.fillText('CLOUD', W / 2 - 48, y + 12); + ctx.beginPath(); ctx.arc(W / 2 + 30, y + 8, 6, 0, Math.PI * 2); ctx.fillStyle = '#334466'; ctx.fill(); + ctx.fillStyle = '#334466'; ctx.fillText('LOCAL', W / 2 + 42, y + 12); + return new THREE.CanvasTexture(canvas); +} + +export function init(scene) { + const DUAL_BRAIN_ORIGIN = new THREE.Vector3(10, 3, -8); + dualBrainGroup = new THREE.Group(); + dualBrainGroup.position.copy(DUAL_BRAIN_ORIGIN); + dualBrainGroup.lookAt(0, 3, 0); + scene.add(dualBrainGroup); + + const texture = createDualBrainTexture(); + const material = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0.92, depthWrite: false }); + dualBrainSprite = new THREE.Sprite(material); + dualBrainSprite.scale.set(5.0, 5.0, 1); + dualBrainSprite.userData = { baseY: 0, floatPhase: 0, floatSpeed: 0.22, zoomLabel: 'Dual-Brain Status' }; + dualBrainGroup.add(dualBrainSprite); + + dualBrainLight = new THREE.PointLight(0x4488ff, 0.6, 10); + dualBrainLight.position.set(0, 0.5, 1); + dualBrainGroup.add(dualBrainLight); + + const CLOUD_ORB_COLOR = 0x334466; + cloudOrbMat = new THREE.MeshStandardMaterial({ color: CLOUD_ORB_COLOR, emissive: new THREE.Color(CLOUD_ORB_COLOR), emissiveIntensity: 0.1, metalness: 0.3, roughness: 0.2, transparent: true, opacity: 0.85 }); + cloudOrb = new THREE.Mesh(new THREE.SphereGeometry(0.35, 32, 32), cloudOrbMat); + cloudOrb.position.set(-2.0, 3.0, 0); cloudOrb.userData.zoomLabel = 'Cloud Brain'; + dualBrainGroup.add(cloudOrb); + cloudOrbLight = new THREE.PointLight(CLOUD_ORB_COLOR, 0.15, 5); + cloudOrbLight.position.copy(cloudOrb.position); + dualBrainGroup.add(cloudOrbLight); + + const LOCAL_ORB_COLOR = 0x334466; + localOrbMat = new THREE.MeshStandardMaterial({ color: LOCAL_ORB_COLOR, emissive: new THREE.Color(LOCAL_ORB_COLOR), emissiveIntensity: 0.1, metalness: 0.3, roughness: 0.2, transparent: true, opacity: 0.85 }); + localOrb = new THREE.Mesh(new THREE.SphereGeometry(0.35, 32, 32), localOrbMat); + localOrb.position.set(2.0, 3.0, 0); localOrb.userData.zoomLabel = 'Local Brain'; + dualBrainGroup.add(localOrb); + localOrbLight = new THREE.PointLight(LOCAL_ORB_COLOR, 0.15, 5); + localOrbLight.position.copy(localOrb.position); + dualBrainGroup.add(localOrbLight); + + // Brain particles (OFF — count = 0) + const brainParticleGeo = new THREE.BufferGeometry(); + brainParticleGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(0), 3)); + const brainParticleMat = new THREE.PointsMaterial({ color: 0x44ddff, size: 0.08, sizeAttenuation: true, transparent: true, opacity: 0.8, depthWrite: false }); + dualBrainGroup.add(new THREE.Points(brainParticleGeo, brainParticleMat)); + + // Scan canvas + _scanCanvas = document.createElement('canvas'); _scanCanvas.width = 512; _scanCanvas.height = 512; + _scanCtx = _scanCanvas.getContext('2d'); + dualBrainScanTexture = new THREE.CanvasTexture(_scanCanvas); + const scanMat = new THREE.SpriteMaterial({ map: dualBrainScanTexture, transparent: true, opacity: 0.18, depthWrite: false }); + dualBrainScanSprite = new THREE.Sprite(scanMat); + dualBrainScanSprite.scale.set(5.0, 5.0, 1); + dualBrainScanSprite.position.set(0, 0, 0.01); + dualBrainGroup.add(dualBrainScanSprite); +} + +export function update(elapsed) { + if (!dualBrainSprite) return; + dualBrainSprite.position.y = dualBrainSprite.userData.baseY + Math.sin(elapsed * dualBrainSprite.userData.floatSpeed + dualBrainSprite.userData.floatPhase) * 0.22; + dualBrainScanSprite.position.y = dualBrainSprite.position.y; + + cloudOrbMat.emissiveIntensity = 0.08 + Math.sin(elapsed * 0.6) * 0.03; + localOrbMat.emissiveIntensity = 0.08 + Math.sin(elapsed * 0.6 + Math.PI) * 0.03; + cloudOrbLight.intensity = 0.1 + Math.sin(elapsed * 0.6) * 0.05; + localOrbLight.intensity = 0.1 + Math.sin(elapsed * 0.6 + Math.PI) * 0.05; + cloudOrb.position.y = 3.0 + Math.sin(elapsed * 0.9) * 0.15; + localOrb.position.y = 3.0 + Math.sin(elapsed * 0.9 + 1.0) * 0.15; + cloudOrbLight.position.y = cloudOrb.position.y; + localOrbLight.position.y = localOrb.position.y; + + // Scan line + const W = 512, H = 512; + _scanCtx.clearRect(0, 0, W, H); + const scanY = ((elapsed * 60) % H); + _scanCtx.fillStyle = 'rgba(68, 136, 255, 0.5)'; _scanCtx.fillRect(0, scanY, W, 2); + const grad = _scanCtx.createLinearGradient(0, scanY - 8, 0, scanY + 10); + grad.addColorStop(0, 'rgba(68, 136, 255, 0)'); grad.addColorStop(0.4, 'rgba(68, 136, 255, 0.15)'); + grad.addColorStop(0.6, 'rgba(68, 136, 255, 0.15)'); grad.addColorStop(1, 'rgba(68, 136, 255, 0)'); + _scanCtx.fillStyle = grad; _scanCtx.fillRect(0, scanY - 8, W, 18); + dualBrainScanTexture.needsUpdate = true; + dualBrainLight.intensity = 0.4 + Math.sin(elapsed * 1.1) * 0.2; +} diff --git a/modules/panels/earth.js b/modules/panels/earth.js new file mode 100644 index 0000000..99a1db0 --- /dev/null +++ b/modules/panels/earth.js @@ -0,0 +1,174 @@ +// modules/panels/earth.js — Holographic earth with shader +import * as THREE from 'three'; +import { THEME } from '../core/theme.js'; +import { state } from '../core/state.js'; + +const EARTH_RADIUS = 2.8; +const EARTH_Y = 20.0; +const EARTH_ROTATION_SPEED = 0.035; +const EARTH_AXIAL_TILT = 23.4 * (Math.PI / 180); + +let earthGroup, earthMesh, earthSurfaceMat, earthGlowLight; + +export function init(scene) { + earthGroup = new THREE.Group(); + earthGroup.position.set(0, EARTH_Y, 0); + earthGroup.rotation.z = EARTH_AXIAL_TILT; + scene.add(earthGroup); + + earthSurfaceMat = new THREE.ShaderMaterial({ + uniforms: { + uTime: { value: 0.0 }, + uOceanColor: { value: new THREE.Color(0x003d99) }, + uLandColor: { value: new THREE.Color(0x1a5c2a) }, + uGlowColor: { value: new THREE.Color(THEME.colors.accent) }, + }, + vertexShader: ` + varying vec3 vNormal; + varying vec3 vWorldPos; + varying vec2 vUv; + void main() { + vNormal = normalize(normalMatrix * normal); + vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz; + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform float uTime; + uniform vec3 uOceanColor; + uniform vec3 uLandColor; + uniform vec3 uGlowColor; + varying vec3 vNormal; + varying vec3 vWorldPos; + varying vec2 vUv; + + vec3 _m3(vec3 x){ return x - floor(x*(1./289.))*289.; } + vec4 _m4(vec4 x){ return x - floor(x*(1./289.))*289.; } + vec4 _p4(vec4 x){ return _m4((x*34.+1.)*x); } + float snoise(vec3 v){ + const vec2 C = vec2(1./6., 1./3.); + vec3 i = floor(v + dot(v, C.yyy)); + vec3 x0 = v - i + dot(i, C.xxx); + vec3 g = step(x0.yzx, x0.xyz); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + C.xxx; + vec3 x2 = x0 - i2 + C.yyy; + vec3 x3 = x0 - 0.5; + i = _m3(i); + vec4 p = _p4(_p4(_p4( + i.z+vec4(0.,i1.z,i2.z,1.))+ + i.y+vec4(0.,i1.y,i2.y,1.))+ + i.x+vec4(0.,i1.x,i2.x,1.)); + float n_ = .142857142857; + vec3 ns = n_*vec3(2.,0.,-1.)+vec3(0.,-.5,1.); + vec4 j = p - 49.*floor(p*ns.z*ns.z); + vec4 x_ = floor(j*ns.z); + vec4 y_ = floor(j - 7.*x_); + vec4 h = 1. - abs(x_*(2./7.)) - abs(y_*(2./7.)); + vec4 b0 = vec4(x_.xy,y_.xy)*(2./7.); + vec4 b1 = vec4(x_.zw,y_.zw)*(2./7.); + vec4 s0 = floor(b0)*2.+1.; vec4 s1 = floor(b1)*2.+1.; + vec4 sh = -step(h, vec4(0.)); + vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww; + vec3 p0=vec3(a0.xy,h.x); vec3 p1=vec3(a0.zw,h.y); + vec3 p2=vec3(a1.xy,h.z); vec3 p3=vec3(a1.zw,h.w); + vec4 nm = max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.); + vec4 nr = 1.79284291400159-0.85373472095314*nm; + p0*=nr.x; p1*=nr.y; p2*=nr.z; p3*=nr.w; + nm = nm*nm; + return 42.*dot(nm*nm, vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3))); + } + + void main() { + vec3 n = normalize(vNormal); + vec3 vd = normalize(cameraPosition - vWorldPos); + float lat = (vUv.y - 0.5) * 3.14159265; + float lon = vUv.x * 6.28318530; + vec3 sp = vec3(cos(lat)*cos(lon), sin(lat), cos(lat)*sin(lon)); + float c = snoise(sp*1.8)*0.60 + + snoise(sp*3.6)*0.30 + + snoise(sp*7.2)*0.10; + float land = smoothstep(0.05, 0.30, c); + vec3 surf = mix(uOceanColor, uLandColor, land); + surf = mix(surf, uGlowColor * 0.45, 0.38); + float scan = 0.5 + 0.5*sin(vUv.y * 220.0 + uTime * 1.8); + scan = smoothstep(0.30, 0.70, scan) * 0.14; + float fresnel = pow(1.0 - max(dot(n, vd), 0.0), 4.0); + vec3 col = surf + scan*uGlowColor*0.9 + fresnel*uGlowColor*1.5; + float alpha = 0.48 + fresnel * 0.42; + gl_FragColor = vec4(col, alpha); + } + `, + transparent: true, depthWrite: false, side: THREE.FrontSide, + }); + + earthMesh = new THREE.Mesh(new THREE.SphereGeometry(EARTH_RADIUS, 64, 32), earthSurfaceMat); + earthMesh.userData.zoomLabel = 'Planet Earth'; + earthGroup.add(earthMesh); + + // Lat/lon grid + const lineMat = new THREE.LineBasicMaterial({ color: 0x2266bb, transparent: true, opacity: 0.30 }); + const r = EARTH_RADIUS + 0.015; + const SEG = 64; + for (let lat = -60; lat <= 60; lat += 30) { + const phi = lat * (Math.PI / 180); + const pts = []; + for (let i = 0; i <= SEG; i++) { + const th = (i / SEG) * Math.PI * 2; + pts.push(new THREE.Vector3(Math.cos(phi) * Math.cos(th) * r, Math.sin(phi) * r, Math.cos(phi) * Math.sin(th) * r)); + } + earthGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), lineMat)); + } + for (let lon = 0; lon < 360; lon += 30) { + const th = lon * (Math.PI / 180); + const pts = []; + for (let i = 0; i <= SEG; i++) { + const phi = (i / SEG) * Math.PI - Math.PI / 2; + pts.push(new THREE.Vector3(Math.cos(phi) * Math.cos(th) * r, Math.sin(phi) * r, Math.cos(phi) * Math.sin(th) * r)); + } + earthGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), lineMat)); + } + + // Atmosphere shell + const atmMat = new THREE.MeshBasicMaterial({ + color: 0x1144cc, transparent: true, opacity: 0.07, + side: THREE.BackSide, depthWrite: false, blending: THREE.AdditiveBlending, + }); + earthGroup.add(new THREE.Mesh(new THREE.SphereGeometry(EARTH_RADIUS * 1.14, 32, 16), atmMat)); + + earthGlowLight = new THREE.PointLight(THEME.colors.accent, 0.4, 25); + earthGroup.add(earthGlowLight); + + earthGroup.traverse(obj => { + if (obj.isMesh || obj.isLine) obj.userData.zoomLabel = 'Planet Earth'; + }); + + // Tether beam + const pts = [ + new THREE.Vector3(0, EARTH_Y - EARTH_RADIUS * 1.15, 0), + new THREE.Vector3(0, 0.5, 0), + ]; + const beamGeo = new THREE.BufferGeometry().setFromPoints(pts); + const beamMat = new THREE.LineBasicMaterial({ + color: THEME.colors.accent, transparent: true, opacity: 0.08, + depthWrite: false, blending: THREE.AdditiveBlending, + }); + scene.add(new THREE.Line(beamGeo, beamMat)); +} + +export function update(elapsed) { + const earthActivity = state.totalActivity(); + const targetEarthSpeed = 0.005 + earthActivity * 0.045; + const _eSmooth = 0.02; + const currentEarthSpeed = earthMesh.userData._currentSpeed || EARTH_ROTATION_SPEED; + const smoothedEarthSpeed = currentEarthSpeed + (targetEarthSpeed - currentEarthSpeed) * _eSmooth; + earthMesh.userData._currentSpeed = smoothedEarthSpeed; + earthMesh.rotation.y += smoothedEarthSpeed; + earthSurfaceMat.uniforms.uTime.value = elapsed; + earthGlowLight.intensity = 0.30 + Math.sin(elapsed * 0.7) * 0.12; + earthGroup.position.y = EARTH_Y + Math.sin(elapsed * 0.22) * 0.6; +} diff --git a/modules/panels/heatmap.js b/modules/panels/heatmap.js new file mode 100644 index 0000000..e886caa --- /dev/null +++ b/modules/panels/heatmap.js @@ -0,0 +1,81 @@ +// modules/panels/heatmap.js — Commit heatmap floor overlay +import * as THREE from 'three'; +import { state } from '../core/state.js'; +import { HEATMAP_ZONES } from '../data/gitea.js'; +import { GLASS_RADIUS } from '../terrain/island.js'; + +const HEATMAP_SIZE = 512; +const HEATMAP_ZONE_SPAN_RAD = Math.PI / 2; + +const heatmapCanvas = document.createElement('canvas'); +heatmapCanvas.width = HEATMAP_SIZE; +heatmapCanvas.height = HEATMAP_SIZE; +const heatmapTexture = new THREE.CanvasTexture(heatmapCanvas); + +const heatmapMat = new THREE.MeshBasicMaterial({ + map: heatmapTexture, transparent: true, opacity: 0.9, + depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, +}); + +let heatmapMesh; + +export function drawHeatmap() { + const ctx = heatmapCanvas.getContext('2d'); + const cx = HEATMAP_SIZE / 2; + const cy = HEATMAP_SIZE / 2; + const r = cx * 0.96; + + ctx.clearRect(0, 0, HEATMAP_SIZE, HEATMAP_SIZE); + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.clip(); + + for (const zone of HEATMAP_ZONES) { + const intensity = state.zoneIntensity[zone.name] || 0; + if (intensity < 0.01) continue; + const [rr, gg, bb] = zone.color; + const baseRad = zone.angleDeg * (Math.PI / 180); + const startRad = baseRad - HEATMAP_ZONE_SPAN_RAD / 2; + const endRad = baseRad + HEATMAP_ZONE_SPAN_RAD / 2; + const gx = cx + Math.cos(baseRad) * r * 0.55; + const gy = cy + Math.sin(baseRad) * r * 0.55; + const grad = ctx.createRadialGradient(gx, gy, 0, gx, gy, r * 0.75); + grad.addColorStop(0, `rgba(${rr},${gg},${bb},${0.65 * intensity})`); + grad.addColorStop(0.45, `rgba(${rr},${gg},${bb},${0.25 * intensity})`); + grad.addColorStop(1, `rgba(${rr},${gg},${bb},0)`); + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.arc(cx, cy, r, startRad, endRad); + ctx.closePath(); + ctx.fillStyle = grad; + ctx.fill(); + if (intensity > 0.05) { + const labelX = cx + Math.cos(baseRad) * r * 0.62; + const labelY = cy + Math.sin(baseRad) * r * 0.62; + ctx.font = `bold ${Math.round(13 * intensity + 7)}px "Courier New", monospace`; + ctx.fillStyle = `rgba(${rr},${gg},${bb},${Math.min(intensity * 1.2, 0.9)})`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(zone.name, labelX, labelY); + } + } + + ctx.restore(); + heatmapTexture.needsUpdate = true; +} + +export function init(scene) { + heatmapMesh = new THREE.Mesh( + new THREE.CircleGeometry(GLASS_RADIUS, 64), + heatmapMat + ); + heatmapMesh.rotation.x = -Math.PI / 2; + heatmapMesh.position.y = 0.005; + heatmapMesh.userData.zoomLabel = 'Activity Heatmap'; + scene.add(heatmapMesh); +} + +export function update(elapsed) { + heatmapMat.opacity = 0.75 + Math.sin(elapsed * 0.6) * 0.2; +} diff --git a/modules/panels/lora-panel.js b/modules/panels/lora-panel.js new file mode 100644 index 0000000..26ec6f2 --- /dev/null +++ b/modules/panels/lora-panel.js @@ -0,0 +1,81 @@ +// modules/panels/lora-panel.js — LoRA adapter status panel +import * as THREE from 'three'; + +const LORA_ACTIVE_COLOR = '#00ff88'; +const LORA_INACTIVE_COLOR = '#334466'; +const LORA_PANEL_POS = new THREE.Vector3(-10.5, 4.5, 2.5); + +let loraGroup, loraPanelSprite; + +function createLoRAPanelTexture(data) { + const W = 420, H = 260; + const canvas = document.createElement('canvas'); + canvas.width = W; canvas.height = H; + const ctx = canvas.getContext('2d'); + + ctx.fillStyle = 'rgba(0, 6, 20, 0.90)'; ctx.fillRect(0, 0, W, H); + ctx.strokeStyle = '#cc44ff'; ctx.lineWidth = 2; ctx.strokeRect(1, 1, W - 2, H - 2); + ctx.strokeStyle = '#cc44ff'; ctx.lineWidth = 1; ctx.globalAlpha = 0.3; ctx.strokeRect(4, 4, W - 8, H - 8); ctx.globalAlpha = 1.0; + ctx.font = 'bold 14px "Courier New", monospace'; ctx.fillStyle = '#cc44ff'; ctx.textAlign = 'left'; ctx.fillText('MODEL TRAINING', 14, 24); + ctx.font = '10px "Courier New", monospace'; ctx.fillStyle = '#664488'; ctx.fillText('LoRA ADAPTERS', 14, 38); + ctx.strokeStyle = '#2a1a44'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(14, 46); ctx.lineTo(W - 14, 46); ctx.stroke(); + + if (!data || !data.adapters || data.adapters.length === 0) { + ctx.font = 'bold 18px "Courier New", monospace'; ctx.fillStyle = '#334466'; ctx.textAlign = 'center'; + ctx.fillText('NO ADAPTERS DEPLOYED', W / 2, H / 2 + 10); + ctx.font = '11px "Courier New", monospace'; ctx.fillStyle = '#223344'; + ctx.fillText('Adapters will appear here when trained', W / 2, H / 2 + 36); + ctx.textAlign = 'left'; + return new THREE.CanvasTexture(canvas); + } + + const activeCount = data.adapters.filter(a => a.active).length; + ctx.font = 'bold 13px "Courier New", monospace'; ctx.fillStyle = LORA_ACTIVE_COLOR; ctx.textAlign = 'right'; + ctx.fillText(`${activeCount}/${data.adapters.length} ACTIVE`, W - 14, 26); ctx.textAlign = 'left'; + const ROW_H = 44; + data.adapters.forEach((adapter, i) => { + const rowY = 50 + i * ROW_H; + const col = adapter.active ? LORA_ACTIVE_COLOR : LORA_INACTIVE_COLOR; + ctx.beginPath(); ctx.arc(22, rowY + 12, 6, 0, Math.PI * 2); ctx.fillStyle = col; ctx.fill(); + ctx.font = 'bold 13px "Courier New", monospace'; ctx.fillStyle = adapter.active ? '#ddeeff' : '#445566'; ctx.fillText(adapter.name, 36, rowY + 16); + ctx.font = '10px "Courier New", monospace'; ctx.fillStyle = '#556688'; ctx.textAlign = 'right'; ctx.fillText(adapter.base, W - 14, rowY + 16); ctx.textAlign = 'left'; + if (adapter.active) { + const BAR_X = 36, BAR_W = W - 80, BAR_Y = rowY + 22, BAR_H = 5; + ctx.fillStyle = '#0a1428'; ctx.fillRect(BAR_X, BAR_Y, BAR_W, BAR_H); + ctx.fillStyle = col; ctx.globalAlpha = 0.7; ctx.fillRect(BAR_X, BAR_Y, BAR_W * adapter.strength, BAR_H); ctx.globalAlpha = 1.0; + } + if (i < data.adapters.length - 1) { + ctx.strokeStyle = '#1a0a2a'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(14, rowY + ROW_H - 2); ctx.lineTo(W - 14, rowY + ROW_H - 2); ctx.stroke(); + } + }); + return new THREE.CanvasTexture(canvas); +} + +function rebuildLoRAPanel(data) { + if (loraPanelSprite) { + loraGroup.remove(loraPanelSprite); + if (loraPanelSprite.material.map) loraPanelSprite.material.map.dispose(); + loraPanelSprite.material.dispose(); + loraPanelSprite = null; + } + const texture = createLoRAPanelTexture(data); + const material = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0.93, depthWrite: false }); + loraPanelSprite = new THREE.Sprite(material); + loraPanelSprite.scale.set(6.0, 3.6, 1); + loraPanelSprite.position.copy(LORA_PANEL_POS); + loraPanelSprite.userData = { baseY: LORA_PANEL_POS.y, floatPhase: 1.1, floatSpeed: 0.14, zoomLabel: 'Model Training \u2014 LoRA Adapters' }; + loraGroup.add(loraPanelSprite); +} + +export function init(scene) { + loraGroup = new THREE.Group(); + scene.add(loraGroup); + rebuildLoRAPanel({ adapters: [] }); +} + +export function update(elapsed) { + if (loraPanelSprite) { + const ud = loraPanelSprite.userData; + loraPanelSprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.22; + } +} diff --git a/modules/panels/sigil.js b/modules/panels/sigil.js new file mode 100644 index 0000000..627aa41 --- /dev/null +++ b/modules/panels/sigil.js @@ -0,0 +1,138 @@ +// modules/panels/sigil.js — Timmy sigil floor overlay +import * as THREE from 'three'; + +const SIGIL_CANVAS_SIZE = 512; +const SIGIL_RADIUS = 3.8; + +let sigilMesh, sigilMat, sigilRing1, sigilRing2, sigilRing3; +let sigilRing1Mat, sigilRing2Mat, sigilRing3Mat, sigilLight; + +function drawSigilCanvas() { + const canvas = document.createElement('canvas'); + canvas.width = SIGIL_CANVAS_SIZE; + canvas.height = SIGIL_CANVAS_SIZE; + const ctx = canvas.getContext('2d'); + const cx = SIGIL_CANVAS_SIZE / 2; + const cy = SIGIL_CANVAS_SIZE / 2; + const r = cx * 0.88; + + ctx.clearRect(0, 0, SIGIL_CANVAS_SIZE, SIGIL_CANVAS_SIZE); + + const bgGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); + bgGrad.addColorStop(0, 'rgba(0, 200, 255, 0.10)'); + bgGrad.addColorStop(0.5, 'rgba(0, 100, 200, 0.04)'); + bgGrad.addColorStop(1, 'rgba(0, 0, 0, 0)'); + ctx.fillStyle = bgGrad; + ctx.fillRect(0, 0, SIGIL_CANVAS_SIZE, SIGIL_CANVAS_SIZE); + + function glowCircle(x, y, radius, color, alpha, lineW) { + ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; + ctx.lineWidth = lineW; ctx.shadowColor = color; ctx.shadowBlur = 12; + ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); + } + + function hexagram(ox, oy, hr, color, alpha) { + ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; + ctx.lineWidth = 1.4; ctx.shadowColor = color; ctx.shadowBlur = 10; + ctx.beginPath(); + for (let i = 0; i < 3; i++) { + const a = (i / 3) * Math.PI * 2 - Math.PI / 2; + i === 0 ? ctx.moveTo(ox + Math.cos(a) * hr, oy + Math.sin(a) * hr) : ctx.lineTo(ox + Math.cos(a) * hr, oy + Math.sin(a) * hr); + } + ctx.closePath(); ctx.stroke(); + ctx.beginPath(); + for (let i = 0; i < 3; i++) { + const a = (i / 3) * Math.PI * 2 + Math.PI / 2; + i === 0 ? ctx.moveTo(ox + Math.cos(a) * hr, oy + Math.sin(a) * hr) : ctx.lineTo(ox + Math.cos(a) * hr, oy + Math.sin(a) * hr); + } + ctx.closePath(); ctx.stroke(); ctx.restore(); + } + + const petalR = r * 0.32; + glowCircle(cx, cy, petalR, '#00ccff', 0.65, 1.0); + for (let i = 0; i < 6; i++) { + const a = (i / 6) * Math.PI * 2; + glowCircle(cx + Math.cos(a) * petalR, cy + Math.sin(a) * petalR, petalR, '#00aadd', 0.50, 0.8); + } + for (let i = 0; i < 6; i++) { + const a = (i / 6) * Math.PI * 2 + Math.PI / 6; + glowCircle(cx + Math.cos(a) * petalR * 1.73, cy + Math.sin(a) * petalR * 1.73, petalR, '#0077aa', 0.25, 0.6); + } + hexagram(cx, cy, r * 0.62, '#ffd700', 0.75); + hexagram(cx, cy, r * 0.41, '#ffaa00', 0.50); + glowCircle(cx, cy, r * 0.92, '#0055aa', 0.40, 0.8); + glowCircle(cx, cy, r * 0.72, '#0099cc', 0.38, 0.8); + glowCircle(cx, cy, r * 0.52, '#00ccff', 0.42, 0.9); + glowCircle(cx, cy, r * 0.18, '#ffd700', 0.65, 1.2); + + ctx.save(); ctx.globalAlpha = 0.28; ctx.strokeStyle = '#00aaff'; + ctx.lineWidth = 0.6; ctx.shadowColor = '#00aaff'; ctx.shadowBlur = 5; + for (let i = 0; i < 12; i++) { + const a = (i / 12) * Math.PI * 2; + ctx.beginPath(); + ctx.moveTo(cx + Math.cos(a) * r * 0.18, cy + Math.sin(a) * r * 0.18); + ctx.lineTo(cx + Math.cos(a) * r * 0.91, cy + Math.sin(a) * r * 0.91); + ctx.stroke(); + } + ctx.restore(); + + ctx.save(); ctx.fillStyle = '#00ffcc'; ctx.shadowColor = '#00ffcc'; ctx.shadowBlur = 9; + for (let i = 0; i < 12; i++) { + const a = (i / 12) * Math.PI * 2; + ctx.globalAlpha = i % 2 === 0 ? 0.80 : 0.50; + ctx.beginPath(); + ctx.arc(cx + Math.cos(a) * r * 0.91, cy + Math.sin(a) * r * 0.91, i % 2 === 0 ? 4 : 2.5, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + + ctx.save(); ctx.globalAlpha = 1.0; ctx.fillStyle = '#ffffff'; + ctx.shadowColor = '#88ddff'; ctx.shadowBlur = 18; + ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill(); ctx.restore(); + + return canvas; +} + +export function init(scene) { + const sigilTexture = new THREE.CanvasTexture(drawSigilCanvas()); + sigilMat = new THREE.MeshBasicMaterial({ + map: sigilTexture, transparent: true, opacity: 0.80, + depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, + }); + sigilMesh = new THREE.Mesh(new THREE.CircleGeometry(SIGIL_RADIUS, 128), sigilMat); + sigilMesh.rotation.x = -Math.PI / 2; + sigilMesh.position.y = 0.010; + sigilMesh.userData.zoomLabel = 'Timmy Sigil'; + scene.add(sigilMesh); + + sigilRing1Mat = new THREE.MeshBasicMaterial({ color: 0x00ccff, transparent: true, opacity: 0.45, depthWrite: false, blending: THREE.AdditiveBlending }); + sigilRing1 = new THREE.Mesh(new THREE.TorusGeometry(SIGIL_RADIUS * 0.965, 0.025, 6, 96), sigilRing1Mat); + sigilRing1.rotation.x = Math.PI / 2; sigilRing1.position.y = 0.012; + scene.add(sigilRing1); + + sigilRing2Mat = new THREE.MeshBasicMaterial({ color: 0xffd700, transparent: true, opacity: 0.40, depthWrite: false, blending: THREE.AdditiveBlending }); + sigilRing2 = new THREE.Mesh(new THREE.TorusGeometry(SIGIL_RADIUS * 0.62, 0.020, 6, 72), sigilRing2Mat); + sigilRing2.rotation.x = Math.PI / 2; sigilRing2.position.y = 0.013; + scene.add(sigilRing2); + + sigilRing3Mat = new THREE.MeshBasicMaterial({ color: 0x00ffcc, transparent: true, opacity: 0.35, depthWrite: false, blending: THREE.AdditiveBlending }); + sigilRing3 = new THREE.Mesh(new THREE.TorusGeometry(SIGIL_RADIUS * 0.78, 0.018, 6, 80), sigilRing3Mat); + sigilRing3.rotation.x = Math.PI / 2; sigilRing3.position.y = 0.011; + scene.add(sigilRing3); + + sigilLight = new THREE.PointLight(0x0088ff, 0.4, 8); + sigilLight.position.set(0, 0.5, 0); + scene.add(sigilLight); +} + +export function update(elapsed) { + sigilMesh.rotation.z = elapsed * 0.04; + sigilRing1.rotation.z = elapsed * 0.06; + sigilRing2.rotation.z = -elapsed * 0.10; + sigilRing3.rotation.z = elapsed * 0.08; + sigilMat.opacity = 0.65 + Math.sin(elapsed * 1.3) * 0.18; + sigilRing1Mat.opacity = 0.38 + Math.sin(elapsed * 0.9) * 0.14; + sigilRing2Mat.opacity = 0.32 + Math.sin(elapsed * 1.6 + 1.2) * 0.12; + sigilRing3Mat.opacity = 0.28 + Math.sin(elapsed * 0.7 + 2.4) * 0.10; + sigilLight.intensity = 0.30 + Math.sin(elapsed * 1.1) * 0.15; +} diff --git a/modules/panels/sovereignty.js b/modules/panels/sovereignty.js new file mode 100644 index 0000000..0fe547f --- /dev/null +++ b/modules/panels/sovereignty.js @@ -0,0 +1,90 @@ +// modules/panels/sovereignty.js — Sovereignty meter arc gauge +import * as THREE from 'three'; + +let sovereigntyGroup, scoreArcMesh, scoreArcMat, meterLight, meterSpriteMat; +let sovereigntyScore = 85; +let sovereigntyLabel = 'Mostly Sovereign'; + +function sovereigntyHexColor(score) { + if (score >= 80) return 0x00ff88; + if (score >= 40) return 0xffcc00; + return 0xff4444; +} + +function buildScoreArcGeo(score) { + return new THREE.TorusGeometry(1.6, 0.1, 8, 64, (score / 100) * Math.PI * 2); +} + +function buildMeterTexture(score, label, assessmentType) { + const canvas = document.createElement('canvas'); + canvas.width = 256; canvas.height = 128; + const ctx = canvas.getContext('2d'); + const hexStr = score >= 80 ? '#00ff88' : score >= 40 ? '#ffcc00' : '#ff4444'; + ctx.clearRect(0, 0, 256, 128); + ctx.font = 'bold 52px "Courier New", monospace'; + ctx.fillStyle = hexStr; ctx.textAlign = 'center'; + ctx.fillText(`${score}%`, 128, 50); + ctx.font = '16px "Courier New", monospace'; + ctx.fillStyle = '#8899bb'; + ctx.fillText(label.toUpperCase(), 128, 74); + ctx.font = '11px "Courier New", monospace'; + ctx.fillStyle = '#445566'; + ctx.fillText('SOVEREIGNTY', 128, 94); + ctx.font = '9px "Courier New", monospace'; + ctx.fillStyle = '#334455'; + ctx.fillText('MANUAL ASSESSMENT', 128, 112); + return new THREE.CanvasTexture(canvas); +} + +export function init(scene) { + sovereigntyGroup = new THREE.Group(); + sovereigntyGroup.position.set(0, 3.8, 0); + + const meterBgGeo = new THREE.TorusGeometry(1.6, 0.1, 8, 64); + const meterBgMat = new THREE.MeshBasicMaterial({ color: 0x0a1828, transparent: true, opacity: 0.5 }); + sovereigntyGroup.add(new THREE.Mesh(meterBgGeo, meterBgMat)); + + scoreArcMat = new THREE.MeshBasicMaterial({ + color: sovereigntyHexColor(sovereigntyScore), transparent: true, opacity: 0.9, + }); + scoreArcMesh = new THREE.Mesh(buildScoreArcGeo(sovereigntyScore), scoreArcMat); + scoreArcMesh.rotation.z = Math.PI / 2; + sovereigntyGroup.add(scoreArcMesh); + + meterLight = new THREE.PointLight(sovereigntyHexColor(sovereigntyScore), 0.7, 6); + sovereigntyGroup.add(meterLight); + + meterSpriteMat = new THREE.SpriteMaterial({ + map: buildMeterTexture(sovereigntyScore, sovereigntyLabel, 'MANUAL'), + transparent: true, depthWrite: false, + }); + const meterSprite = new THREE.Sprite(meterSpriteMat); + meterSprite.scale.set(3.2, 1.6, 1); + sovereigntyGroup.add(meterSprite); + + scene.add(sovereigntyGroup); + sovereigntyGroup.traverse(obj => { + if (obj.isMesh || obj.isSprite) obj.userData.zoomLabel = 'Sovereignty Meter'; + }); +} + +export function updateFromData(data) { + const score = Math.max(0, Math.min(100, typeof data.score === 'number' ? data.score : 85)); + const label = typeof data.label === 'string' ? data.label : ''; + sovereigntyScore = score; + sovereigntyLabel = label; + scoreArcMesh.geometry.dispose(); + scoreArcMesh.geometry = buildScoreArcGeo(score); + const col = sovereigntyHexColor(score); + scoreArcMat.color.setHex(col); + meterLight.color.setHex(col); + if (meterSpriteMat.map) meterSpriteMat.map.dispose(); + const assessmentType = data.assessment_type || 'MANUAL'; + meterSpriteMat.map = buildMeterTexture(score, label, assessmentType); + meterSpriteMat.needsUpdate = true; +} + +export function update(elapsed) { + sovereigntyGroup.position.y = 3.8 + Math.sin(elapsed * 0.8) * 0.15; + meterLight.intensity = 0.5 + Math.sin(elapsed * 1.8) * 0.25; +} diff --git a/modules/portals/commit-banners.js b/modules/portals/commit-banners.js new file mode 100644 index 0000000..c5fda4f --- /dev/null +++ b/modules/portals/commit-banners.js @@ -0,0 +1,62 @@ +// modules/portals/commit-banners.js — Floating commit banner sprites +import * as THREE from 'three'; +import { fetchRecentCommitsForBanners } from '../data/gitea.js'; + +const commitBanners = []; + +function createCommitTexture(hash, message) { + const canvas = document.createElement('canvas'); + canvas.width = 512; canvas.height = 64; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'rgba(0, 0, 16, 0.75)'; ctx.fillRect(0, 0, 512, 64); + ctx.strokeStyle = '#4488ff'; ctx.lineWidth = 1; ctx.strokeRect(0.5, 0.5, 511, 63); + ctx.font = 'bold 11px "Courier New", monospace'; ctx.fillStyle = '#4488ff'; ctx.fillText(hash, 10, 20); + ctx.font = '12px "Courier New", monospace'; ctx.fillStyle = '#ccd6f6'; + const displayMsg = message.length > 54 ? message.slice(0, 54) + '\u2026' : message; + ctx.fillText(displayMsg, 10, 46); + return new THREE.CanvasTexture(canvas); +} + +export async function init(scene) { + const commits = await fetchRecentCommitsForBanners(); + const spreadX = [-7, -3.5, 0, 3.5, 7]; + const spreadY = [1.0, -1.5, 2.2, -0.8, 1.6]; + const spreadZ = [-1.5, -2.5, -1.0, -2.0, -1.8]; + commits.forEach((commit, i) => { + const texture = createCommitTexture(commit.hash, commit.message); + const material = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0, depthWrite: false }); + const sprite = new THREE.Sprite(material); + sprite.scale.set(12, 1.5, 1); + sprite.position.set(spreadX[i % spreadX.length], spreadY[i % spreadY.length], spreadZ[i % spreadZ.length]); + sprite.userData = { + baseY: spreadY[i % spreadY.length], + floatPhase: (i / commits.length) * Math.PI * 2, + floatSpeed: 0.25 + i * 0.07, + startDelay: i * 2.5, + lifetime: 12 + i * 1.5, + spawnTime: null, + zoomLabel: `Commit: ${commit.hash}`, + }; + scene.add(sprite); + commitBanners.push(sprite); + }); +} + +export function update(elapsed) { + const FADE_DUR = 1.5; + commitBanners.forEach(banner => { + const ud = banner.userData; + if (ud.spawnTime === null) { + if (elapsed < ud.startDelay) return; + ud.spawnTime = elapsed; + } + const age = elapsed - ud.spawnTime; + let opacity; + if (age < FADE_DUR) opacity = age / FADE_DUR; + else if (age < ud.lifetime - FADE_DUR) opacity = 1; + else if (age < ud.lifetime) opacity = (ud.lifetime - age) / FADE_DUR; + else { ud.spawnTime = elapsed + 3; opacity = 0; } + banner.material.opacity = opacity * 0.85; + banner.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.4; + }); +} diff --git a/modules/portals/portal-system.js b/modules/portals/portal-system.js new file mode 100644 index 0000000..669c38c --- /dev/null +++ b/modules/portals/portal-system.js @@ -0,0 +1,126 @@ +// modules/portals/portal-system.js — Portal creation, warp triggering, health checks +import * as THREE from 'three'; +import { state } from '../core/state.js'; +import { rebuildRuneRing } from '../effects/rune-ring.js'; +import { rebuildGravityZones } from '../effects/gravity-zones.js'; + +const PORTAL_HEALTH_CHECK_MS = 5 * 60 * 1000; + +let portalGroup, _scene, _clock, _warpPass; +let isWarping = false; +let warpStartTime = 0; +const WARP_DURATION = 2.2; +let warpDestinationUrl = null; +let warpPortalColor = new THREE.Color(0x4488ff); +let warpNavigated = false; + +export { portalGroup }; + +function createPortals() { + const portalGeo = new THREE.TorusGeometry(3.0, 0.2, 16, 100); + state.portals.forEach(portal => { + const isOnline = portal.status === 'online'; + const portalMat = new THREE.MeshBasicMaterial({ + color: new THREE.Color(portal.color).convertSRGBToLinear(), + transparent: true, opacity: isOnline ? 0.7 : 0.15, + blending: THREE.AdditiveBlending, side: THREE.DoubleSide, + }); + const portalMesh = new THREE.Mesh(portalGeo, portalMat); + portalMesh.position.set(portal.position.x, portal.position.y + 0.5, portal.position.z); + portalMesh.rotation.y = portal.rotation.y; + portalMesh.rotation.x = Math.PI / 2; + portalMesh.name = `portal-${portal.id}`; + portalMesh.userData.destinationUrl = portal.destination?.url || null; + portalMesh.userData.portalColor = new THREE.Color(portal.color).convertSRGBToLinear(); + portalGroup.add(portalMesh); + }); +} + +async function runPortalHealthChecks() { + if (state.portals.length === 0) return; + for (const portal of state.portals) { + if (!portal.destination?.url) { portal.status = 'offline'; continue; } + try { + await fetch(portal.destination.url, { mode: 'no-cors', signal: AbortSignal.timeout(5000) }); + portal.status = 'online'; + } catch { portal.status = 'offline'; } + } + rebuildRuneRing(); + rebuildGravityZones(); + for (const child of portalGroup.children) { + const portalId = child.name.replace('portal-', ''); + const portalData = state.portals.find(p => p.id === portalId); + if (portalData) child.material.opacity = portalData.status === 'online' ? 0.7 : 0.15; + } +} + +export async function loadPortals(audioStartPortalHums) { + try { + const res = await fetch('./portals.json'); + if (!res.ok) throw new Error('Portals not found'); + state.portals = await res.json(); + createPortals(); + rebuildRuneRing(); + rebuildGravityZones(); + if (audioStartPortalHums) audioStartPortalHums(); + runPortalHealthChecks(); + } catch (error) { + console.error('Failed to load portals:', error); + } +} + +function startWarp(portalMesh) { + isWarping = true; + warpNavigated = false; + warpStartTime = _clock.getElapsedTime(); + _warpPass.enabled = true; + _warpPass.uniforms['time'].value = 0.0; + _warpPass.uniforms['progress'].value = 0.0; + if (portalMesh) { + warpDestinationUrl = portalMesh.userData.destinationUrl || null; + warpPortalColor = portalMesh.userData.portalColor ? portalMesh.userData.portalColor.clone() : new THREE.Color(0x4488ff); + } else { + warpDestinationUrl = null; + warpPortalColor = new THREE.Color(0x4488ff); + } + _warpPass.uniforms['portalColor'].value = warpPortalColor; +} + +export function init(scene, clock, warpPass) { + _scene = scene; + _clock = clock; + _warpPass = warpPass; + portalGroup = new THREE.Group(); + scene.add(portalGroup); + setInterval(runPortalHealthChecks, PORTAL_HEALTH_CHECK_MS); +} + +export function update(elapsed, camera, raycaster, forwardVector) { + // Portal collision + forwardVector.set(0, 0, -1).applyQuaternion(camera.quaternion); + raycaster.set(camera.position, forwardVector); + const intersects = raycaster.intersectObjects(portalGroup.children); + if (intersects.length > 0 && !isWarping) { + startWarp(intersects[0].object); + } + // Warp animation + if (isWarping) { + const warpElapsed = elapsed - warpStartTime; + const progress = Math.min(warpElapsed / WARP_DURATION, 1.0); + _warpPass.uniforms['time'].value = elapsed; + _warpPass.uniforms['progress'].value = progress; + if (!warpNavigated && progress >= 0.88 && warpDestinationUrl) { + warpNavigated = true; + setTimeout(() => { window.location.href = warpDestinationUrl; }, 180); + } + if (progress >= 1.0) { + isWarping = false; + _warpPass.enabled = false; + _warpPass.uniforms['progress'].value = 0.0; + if (!warpNavigated && warpDestinationUrl) { + warpNavigated = true; + window.location.href = warpDestinationUrl; + } + } + } +} diff --git a/modules/terrain/clouds.js b/modules/terrain/clouds.js new file mode 100644 index 0000000..b845dfc --- /dev/null +++ b/modules/terrain/clouds.js @@ -0,0 +1,115 @@ +// modules/terrain/clouds.js — Procedural cloud layer +import * as THREE from 'three'; + +const CLOUD_LAYER_Y = -6.0; +const CLOUD_DIMENSIONS = 120; +const CLOUD_THICKNESS = 15; +const CLOUD_OPACITY = 0.6; + +const cloudGeometry = new THREE.BoxGeometry(CLOUD_DIMENSIONS, CLOUD_THICKNESS, CLOUD_DIMENSIONS, 8, 4, 8); + +const CloudShader = { + uniforms: { + 'uTime': { value: 0.0 }, + 'uCloudColor': { value: new THREE.Color(0x88bbff) }, + 'uNoiseScale': { value: new THREE.Vector3(0.015, 0.015, 0.015) }, + 'uDensity': { value: 0.8 }, + }, + vertexShader: ` + varying vec3 vWorldPosition; + void main() { + vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform float uTime; + uniform vec3 uCloudColor; + uniform vec3 uNoiseScale; + uniform float uDensity; + varying vec3 vWorldPosition; + + vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } + vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } + vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); } + vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } + float snoise(vec3 v) { + const vec2 C = vec2(1.0/6.0, 1.0/3.0); + const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); + vec3 i = floor(v + dot(v, C.yyy)); + vec3 x0 = v - i + dot(i, C.xxx); + vec3 g = step(x0.yzx, x0.xyz); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + C.xxx; + vec3 x2 = x0 - i2 + C.yyy; + vec3 x3 = x0 - D.yyy; + i = mod289(i); + vec4 p = permute(permute(permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + float n_ = 0.142857142857; + vec3 ns = n_ * D.wyz - D.xzx; + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3))); + p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w; + vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0); + m = m * m; + return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3))); + } + + void main() { + vec3 noiseCoord = vWorldPosition * uNoiseScale + vec3(uTime * 0.003, 0.0, uTime * 0.002); + float noiseVal = snoise(noiseCoord) * 0.500; + noiseVal += snoise(noiseCoord * 2.0) * 0.250; + noiseVal += snoise(noiseCoord * 4.0) * 0.125; + noiseVal /= 0.875; + float density = smoothstep(0.25, 0.85, noiseVal * 0.5 + 0.5); + density *= uDensity; + float layerBottom = ${(CLOUD_LAYER_Y - CLOUD_THICKNESS * 0.5).toFixed(1)}; + float yNorm = (vWorldPosition.y - layerBottom) / ${CLOUD_THICKNESS.toFixed(1)}; + float fadeFactor = smoothstep(0.0, 0.15, yNorm) * smoothstep(1.0, 0.85, yNorm); + gl_FragColor = vec4(uCloudColor, density * fadeFactor * ${CLOUD_OPACITY.toFixed(1)}); + if (gl_FragColor.a < 0.04) discard; + } + `, +}; + +export const cloudMaterial = new THREE.ShaderMaterial({ + uniforms: CloudShader.uniforms, + vertexShader: CloudShader.vertexShader, + fragmentShader: CloudShader.fragmentShader, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + side: THREE.DoubleSide, +}); + +const clouds = new THREE.Mesh(cloudGeometry, cloudMaterial); +clouds.position.y = CLOUD_LAYER_Y; + +export function init(scene) { + scene.add(clouds); +} + +export function update(elapsed) { + cloudMaterial.uniforms.uTime.value = elapsed; +} diff --git a/modules/terrain/island.js b/modules/terrain/island.js new file mode 100644 index 0000000..bb18233 --- /dev/null +++ b/modules/terrain/island.js @@ -0,0 +1,221 @@ +// modules/terrain/island.js — Floating island + glass platform + crystals +import * as THREE from 'three'; +import { THEME } from '../core/theme.js'; +import { perlin } from '../utils/perlin.js'; + +const GLASS_RADIUS = 4.55; +const GLASS_TILE_SIZE = 0.85; +const GLASS_TILE_GAP = 0.14; +const GLASS_TILE_STEP = GLASS_TILE_SIZE + GLASS_TILE_GAP; + +export { GLASS_RADIUS }; + +const glassEdgeMaterials = []; +let voidLight; + +export function init(scene) { + // --- Glass Platform --- + const glassPlatformGroup = new THREE.Group(); + + const platformFrameMat = new THREE.MeshStandardMaterial({ + color: 0x0a1828, metalness: 0.9, roughness: 0.1, + emissive: new THREE.Color(THEME.colors.accent).multiplyScalar(0.06), + }); + + const platformRim = new THREE.Mesh(new THREE.RingGeometry(4.7, 5.3, 64), platformFrameMat); + platformRim.rotation.x = -Math.PI / 2; + platformRim.castShadow = true; + platformRim.receiveShadow = true; + glassPlatformGroup.add(platformRim); + + const borderTorus = new THREE.Mesh(new THREE.TorusGeometry(5.0, 0.1, 6, 64), platformFrameMat); + borderTorus.rotation.x = Math.PI / 2; + borderTorus.castShadow = true; + borderTorus.receiveShadow = true; + glassPlatformGroup.add(borderTorus); + + const glassTileMat = new THREE.MeshPhysicalMaterial({ + color: new THREE.Color(THEME.colors.accent), transparent: true, opacity: 0.09, + roughness: 0.0, metalness: 0.0, transmission: 0.92, thickness: 0.06, + side: THREE.DoubleSide, depthWrite: false, + }); + + const glassEdgeBaseMat = new THREE.LineBasicMaterial({ + color: THEME.colors.accent, transparent: true, opacity: 0.55, + }); + + const tileGeo = new THREE.PlaneGeometry(GLASS_TILE_SIZE, GLASS_TILE_SIZE); + const tileEdgeGeo = new THREE.EdgesGeometry(tileGeo); + + for (let row = -5; row <= 5; row++) { + for (let col = -5; col <= 5; col++) { + const x = col * GLASS_TILE_STEP; + const z = row * GLASS_TILE_STEP; + const distFromCenter = Math.sqrt(x * x + z * z); + if (distFromCenter > GLASS_RADIUS) continue; + + const tile = new THREE.Mesh(tileGeo, glassTileMat.clone()); + tile.rotation.x = -Math.PI / 2; + tile.position.set(x, 0, z); + glassPlatformGroup.add(tile); + + const mat = glassEdgeBaseMat.clone(); + const edges = new THREE.LineSegments(tileEdgeGeo, mat); + edges.rotation.x = -Math.PI / 2; + edges.position.set(x, 0.002, z); + glassPlatformGroup.add(edges); + glassEdgeMaterials.push({ mat, distFromCenter }); + } + } + + voidLight = new THREE.PointLight(THEME.colors.accent, 0.5, 14); + voidLight.position.set(0, -3.5, 0); + glassPlatformGroup.add(voidLight); + + scene.add(glassPlatformGroup); + glassPlatformGroup.traverse(obj => { + if (obj.isMesh) obj.userData.zoomLabel = 'Glass Platform'; + }); + + // --- Floating Island Terrain --- + const ISLAND_RADIUS = 9.5; + const SEGMENTS = 96; + const SIZE = ISLAND_RADIUS * 2; + + function islandFBm(nx, nz) { + const wx = perlin(nx * 0.5 + 3.7, nz * 0.5 + 1.2) * 0.55; + const wz = perlin(nx * 0.5 + 8.3, nz * 0.5 + 5.9) * 0.55; + const px = nx + wx, pz = nz + wz; + let h = 0; + h += perlin(px, pz) * 1.000; + h += perlin(px * 2, pz * 2) * 0.500; + h += perlin(px * 4, pz * 4) * 0.250; + h += perlin(px * 8, pz * 8) * 0.125; + h += perlin(px * 16, pz * 16) * 0.063; + h /= 1.938; + const ridge = 1.0 - Math.abs(perlin(px * 3.1 + 5.0, pz * 3.1 + 7.0)); + return h * 0.78 + ridge * 0.22; + } + + const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEGMENTS, SEGMENTS); + geo.rotateX(-Math.PI / 2); + const pos = geo.attributes.position; + const vCount = pos.count; + const rawHeights = new Float32Array(vCount); + + for (let i = 0; i < vCount; i++) { + const x = pos.getX(i); + const z = pos.getZ(i); + const dist = Math.sqrt(x * x + z * z) / ISLAND_RADIUS; + const rimNoise = perlin(x * 0.38 + 10, z * 0.38 + 10) * 0.10; + const edgeFactor = Math.max(0, 1 - Math.pow(Math.max(0, dist - rimNoise), 2.4)); + const h = islandFBm(x * 0.15, z * 0.15); + const height = ((h + 1) * 0.5) * edgeFactor * 3.2; + pos.setY(i, height); + rawHeights[i] = height; + } + geo.computeVertexNormals(); + + const colBuf = new Float32Array(vCount * 3); + for (let i = 0; i < vCount; i++) { + const h = rawHeights[i]; + let r, g, b; + if (h < 0.25) { r = 0.11; g = 0.09; b = 0.07; } + else if (h < 0.75) { const t = (h - 0.25) / 0.50; r = 0.11 + t * 0.13; g = 0.09 + t * 0.09; b = 0.07 + t * 0.06; } + else if (h < 1.4) { const t = (h - 0.75) / 0.65; r = 0.24 + t * 0.12; g = 0.18 + t * 0.10; b = 0.13 + t * 0.10; } + else if (h < 2.2) { const t = (h - 1.4) / 0.80; r = 0.36 + t * 0.14; g = 0.28 + t * 0.11; b = 0.23 + t * 0.13; } + else { const t = Math.min(1, (h - 2.2) / 0.9); r = 0.50 + t * 0.05; g = 0.39 + t * 0.10; b = 0.36 + t * 0.28; } + colBuf[i * 3] = r; colBuf[i * 3 + 1] = g; colBuf[i * 3 + 2] = b; + } + geo.setAttribute('color', new THREE.BufferAttribute(colBuf, 3)); + + const topMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.86, metalness: 0.05 }); + const topMesh = new THREE.Mesh(geo, topMat); + topMesh.castShadow = true; + topMesh.receiveShadow = true; + + // Crystal spires + const crystalMat = new THREE.MeshStandardMaterial({ + color: new THREE.Color(THEME.colors.accent).multiplyScalar(0.55), + emissive: new THREE.Color(THEME.colors.accent), emissiveIntensity: 0.5, + roughness: 0.08, metalness: 0.25, transparent: true, opacity: 0.80, + }); + const CRYSTAL_MIN_H = 2.05; + const crystalGroup = new THREE.Group(); + + for (let row = -5; row <= 5; row++) { + for (let col = -5; col <= 5; col++) { + const bx = col * 1.75, bz = row * 1.75; + if (Math.sqrt(bx * bx + bz * bz) > ISLAND_RADIUS * 0.72) continue; + const edF = Math.max(0, 1 - Math.pow(Math.sqrt(bx * bx + bz * bz) / ISLAND_RADIUS, 2.4)); + const candidateH = ((islandFBm(bx * 0.15, bz * 0.15) + 1) * 0.5) * edF * 3.2; + if (candidateH < CRYSTAL_MIN_H) continue; + const jx = bx + perlin(bx * 0.7 + 20, bz * 0.7 + 20) * 0.55; + const jz = bz + perlin(bx * 0.7 + 30, bz * 0.7 + 30) * 0.55; + if (Math.sqrt(jx * jx + jz * jz) > ISLAND_RADIUS * 0.68) continue; + const clusterSize = 2 + Math.floor(Math.abs(perlin(bx * 0.5 + 40, bz * 0.5 + 40)) * 3); + for (let c = 0; c < clusterSize; c++) { + const angle = (c / clusterSize) * Math.PI * 2 + perlin(bx + c, bz + c) * 1.4; + const spread = 0.08 + Math.abs(perlin(bx + c * 5, bz + c * 5)) * 0.22; + const sx = jx + Math.cos(angle) * spread; + const sz = jz + Math.sin(angle) * spread; + const spireScale = 0.14 + (candidateH - CRYSTAL_MIN_H) * 0.11; + const spireH = spireScale * (0.8 + Math.abs(perlin(sx, sz)) * 0.45); + const spireR = spireH * 0.17; + const spireGeo = new THREE.ConeGeometry(spireR, spireH * 2.8, 5); + const spire = new THREE.Mesh(spireGeo, crystalMat); + spire.position.set(sx, candidateH + spireH * 0.5, sz); + spire.rotation.z = perlin(sx * 2, sz * 2) * 0.28; + spire.rotation.x = perlin(sx * 3 + 1, sz * 3 + 1) * 0.18; + spire.castShadow = true; + crystalGroup.add(spire); + } + } + } + + // Rocky underside + const BOTTOM_SEGS_R = 52, BOTTOM_SEGS_V = 10, BOTTOM_HEIGHT = 2.6; + const bottomGeo = new THREE.CylinderGeometry( + ISLAND_RADIUS * 0.80, ISLAND_RADIUS * 0.28, BOTTOM_HEIGHT, BOTTOM_SEGS_R, BOTTOM_SEGS_V, true + ); + const bPos = bottomGeo.attributes.position; + for (let i = 0; i < bPos.count; i++) { + const bx = bPos.getX(i), bz = bPos.getZ(i), by = bPos.getY(i); + const bAngle = Math.atan2(bz, bx); + const r = Math.sqrt(bx * bx + bz * bz); + const radDisp = perlin(Math.cos(bAngle) * 1.6 + 50, Math.sin(bAngle) * 1.6 + 50) * 0.65; + const vNorm = (by + BOTTOM_HEIGHT * 0.5) / BOTTOM_HEIGHT; + const stalDisp = (1 - vNorm) * Math.abs(perlin(bx * 0.35 + 70, by * 0.7 + bz * 0.35)) * 0.9; + const newR = r + radDisp; + bPos.setX(i, (bx / r) * newR); + bPos.setZ(i, (bz / r) * newR); + bPos.setY(i, by - stalDisp); + } + bottomGeo.computeVertexNormals(); + + const bottomMat = new THREE.MeshStandardMaterial({ color: 0x0c0a08, roughness: 0.93, metalness: 0.02 }); + const bottomMesh = new THREE.Mesh(bottomGeo, bottomMat); + bottomMesh.position.y = -BOTTOM_HEIGHT * 0.5; + bottomMesh.castShadow = true; + + const capGeo = new THREE.CircleGeometry(ISLAND_RADIUS * 0.28, 48); + capGeo.rotateX(Math.PI / 2); + const capMesh = new THREE.Mesh(capGeo, bottomMat); + capMesh.position.y = -(BOTTOM_HEIGHT + 0.1); + + const islandGroup = new THREE.Group(); + islandGroup.add(topMesh); + islandGroup.add(crystalGroup); + islandGroup.add(bottomMesh); + islandGroup.add(capMesh); + islandGroup.position.y = -2.8; + scene.add(islandGroup); +} + +export function update(elapsed) { + for (const { mat, distFromCenter } of glassEdgeMaterials) { + const phase = elapsed * 1.1 - distFromCenter * 0.18; + mat.opacity = 0.25 + Math.sin(phase) * 0.22; + } + if (voidLight) voidLight.intensity = 0.35 + Math.sin(elapsed * 1.4) * 0.2; +} diff --git a/modules/terrain/stars.js b/modules/terrain/stars.js new file mode 100644 index 0000000..33c3e14 --- /dev/null +++ b/modules/terrain/stars.js @@ -0,0 +1,101 @@ +// modules/terrain/stars.js — Star field + constellation lines +import * as THREE from 'three'; +import { THEME } from '../core/theme.js'; +import { state } from '../core/state.js'; + +const STAR_COUNT = 800; +const STAR_SPREAD = 400; +const CONSTELLATION_DISTANCE = 30; + +const STAR_BASE_OPACITY = 0.3; +const STAR_PEAK_OPACITY = 1.0; +const STAR_PULSE_DECAY = 0.012; + +const starPositions = []; +const starGeo = new THREE.BufferGeometry(); +const posArray = new Float32Array(STAR_COUNT * 3); +const sizeArray = new Float32Array(STAR_COUNT); + +for (let i = 0; i < STAR_COUNT; i++) { + const x = (Math.random() - 0.5) * STAR_SPREAD; + const y = (Math.random() - 0.5) * STAR_SPREAD; + const z = (Math.random() - 0.5) * STAR_SPREAD; + posArray[i * 3] = x; + posArray[i * 3 + 1] = y; + posArray[i * 3 + 2] = z; + sizeArray[i] = Math.random() * 2.5 + 0.5; + starPositions.push(new THREE.Vector3(x, y, z)); +} + +starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3)); +starGeo.setAttribute('size', new THREE.BufferAttribute(sizeArray, 1)); + +export const starMaterial = new THREE.PointsMaterial({ + color: THEME.colors.starCore, + size: 0.6, + sizeAttenuation: true, + transparent: true, + opacity: 0.9, +}); + +export const stars = new THREE.Points(starGeo, starMaterial); + +function buildConstellationLines() { + const linePositions = []; + const MAX_CONNECTIONS_PER_STAR = 3; + const connectionCount = new Array(STAR_COUNT).fill(0); + + for (let i = 0; i < STAR_COUNT; i++) { + if (connectionCount[i] >= MAX_CONNECTIONS_PER_STAR) continue; + const neighbors = []; + for (let j = i + 1; j < STAR_COUNT; j++) { + if (connectionCount[j] >= MAX_CONNECTIONS_PER_STAR) continue; + const dist = starPositions[i].distanceTo(starPositions[j]); + if (dist < CONSTELLATION_DISTANCE) { + neighbors.push({ j, dist }); + } + } + neighbors.sort((a, b) => a.dist - b.dist); + const toConnect = neighbors.slice(0, MAX_CONNECTIONS_PER_STAR - connectionCount[i]); + for (const { j } of toConnect) { + linePositions.push( + starPositions[i].x, starPositions[i].y, starPositions[i].z, + starPositions[j].x, starPositions[j].y, starPositions[j].z + ); + connectionCount[i]++; + connectionCount[j]++; + } + } + + const lineGeo = new THREE.BufferGeometry(); + lineGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(linePositions), 3)); + const lineMat = new THREE.LineBasicMaterial({ + color: THEME.colors.constellationLine, + transparent: true, + opacity: 0.18, + }); + return new THREE.LineSegments(lineGeo, lineMat); +} + +export const constellationLines = buildConstellationLines(); + +export function init(scene) { + scene.add(stars); + scene.add(constellationLines); +} + +export function update(elapsed, delta, mouseX, mouseY, overviewT, photoMode) { + const rotationScale = photoMode ? 0 : (1 - overviewT); + + stars.rotation.x = (mouseY * 0.3 + elapsed * 0.01) * rotationScale; + stars.rotation.y = (mouseX * 0.3 + elapsed * 0.015) * rotationScale; + + 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) * state.starPulseIntensity; + + constellationLines.rotation.x = stars.rotation.x; + constellationLines.rotation.y = stars.rotation.y; + constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06; +} diff --git a/modules/utils/perlin.js b/modules/utils/perlin.js new file mode 100644 index 0000000..ebe90e5 --- /dev/null +++ b/modules/utils/perlin.js @@ -0,0 +1,44 @@ +// modules/utils/perlin.js — Classic Perlin noise for procedural generation + +export function createPerlinNoise() { + const p = new Uint8Array(256); + for (let i = 0; i < 256; i++) p[i] = i; + let seed = 42; + function seededRand() { + seed = (seed * 1664525 + 1013904223) & 0xffffffff; + return (seed >>> 0) / 0xffffffff; + } + for (let i = 255; i > 0; i--) { + const j = Math.floor(seededRand() * (i + 1)); + const tmp = p[i]; p[i] = p[j]; p[j] = tmp; + } + const perm = new Uint8Array(512); + for (let i = 0; i < 512; i++) perm[i] = p[i & 255]; + + function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); } + function lerp(a, b, t) { return a + t * (b - a); } + function grad(hash, x, y, z) { + const h = hash & 15; + const u = h < 8 ? x : y; + const v = h < 4 ? y : (h === 12 || h === 14) ? x : z; + return ((h & 1) ? -u : u) + ((h & 2) ? -v : v); + } + + return function noise(x, y, z) { + z = z || 0; + const X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255; + x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z); + const u = fade(x), v = fade(y), w = fade(z); + const A = perm[X] + Y, AA = perm[A] + Z, AB = perm[A + 1] + Z; + const B = perm[X + 1] + Y, BA = perm[B] + Z, BB = perm[B + 1] + Z; + return lerp( + lerp(lerp(grad(perm[AA], x, y, z ), grad(perm[BA], x-1, y, z ), u), + lerp(grad(perm[AB], x, y-1, z ), grad(perm[BB], x-1, y-1, z ), u), v), + lerp(lerp(grad(perm[AA + 1], x, y, z-1), grad(perm[BA + 1], x-1, y, z-1), u), + lerp(grad(perm[AB + 1], x, y-1, z-1), grad(perm[BB + 1], x-1, y-1, z-1), u), v), + w + ); + }; +} + +export const perlin = createPerlinNoise(); -- 2.43.0