Files
the-nexus/app.js

4644 lines
149 KiB
JavaScript
Raw Normal View History

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}%`;
};
// Simulate loading a texture for demonstration
const textureLoader = new THREE.TextureLoader(loadingManager);
textureLoader.load('placeholder-texture.jpg', (texture) => {
loadedAssets.set('placeholder-texture', 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);
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`;
for (let i = 0; i < matrixDrops.length; i++) {
const 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 with some randomness
if (y > matrixCanvas.height && Math.random() > 0.975) {
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);
// === 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 24 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 */ }
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) => {
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
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 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 ===
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;
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;
}
return 'Object';
}
function exitZoom() {
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 ===
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);
}
}
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;
}
}
// 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();
}
}
});
// === 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) {
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, 58);
ctx.font = '16px "Courier New", monospace';
ctx.fillStyle = '#8899bb';
ctx.fillText(label.toUpperCase(), 128, 82);
ctx.font = '11px "Courier New", monospace';
ctx.fillStyle = '#445566';
ctx.fillText('SOVEREIGNTY', 128, 104);
return new THREE.CanvasTexture(canvas);
}
const meterSpriteMat = new THREE.SpriteMaterial({
map: buildMeterTexture(sovereigntyScore, sovereigntyLabel),
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();
meterSpriteMat.map = buildMeterTexture(score, label);
meterSpriteMat.needsUpdate = true;
} catch {
// defaults already set above
}
}
loadSovereigntyStatus();
// === RUNE RING ===
// 12 Elder Futhark rune sprites in a slow-orbiting ring around the center platform.
const RUNE_COUNT = 12;
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']; // alternating cyan / magenta
/**
* 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}>}
*/
const runeSprites = [];
for (let i = 0; i < RUNE_COUNT; i++) {
const glyph = ELDER_FUTHARK[i % ELDER_FUTHARK.length];
const color = RUNE_GLOW_COLORS[i % RUNE_GLOW_COLORS.length];
const texture = createRuneTexture(glyph, color);
const runeMat = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0.85,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const sprite = new THREE.Sprite(runeMat);
sprite.scale.set(1.3, 1.3, 1);
const baseAngle = (i / RUNE_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 / RUNE_COUNT) * Math.PI * 2 });
}
// === 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<THREE.Line>} */
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;
// === 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);
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;
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
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;
rune.sprite.material.opacity = 0.65 + Math.sin(elapsed * 1.2 + rune.floatPhase) * 0.2;
}
// Animate holographic Earth — slow axial rotation, gentle float, glow pulse
earthMesh.rotation.y = elapsed * EARTH_ROTATION_SPEED;
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;
}
// 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<OscillatorNode|AudioBufferSourceNode>} */
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', async () => {
try {
const response = await fetch('SOUL.md');
if (!response.ok) throw new Error('Failed to load SOUL.md');
const text = await response.text();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'en-US';
utterance.rate = 0.9;
utterance.pitch = 0.9;
utterance.volume = 1.0;
speechSynthesis.speak(utterance);
} catch (err) {
console.error('Failed to load or play SOUL.md:', err);
alert('Could not load SOUL.md for audio playback.');
}
});
// === 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';
});
}
});
// === 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<typeof setTimeout>|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 portalMat = new THREE.MeshBasicMaterial({
color: new THREE.Color(portal.color).convertSRGBToLinear(),
transparent: true,
opacity: 0.7,
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<Object>} */
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();
// If audio is already running, attach positional hums to the portals now
startPortalHums();
} 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);
});
}
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' },
];
}
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<string[]>}
*/
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 ===
const AGENT_STATUS_STUB = {
agents: [
{ name: 'claude', status: 'working', issue: 'Live agent status board (#199)', prs_today: 3, local: true },
{ name: 'gemini', status: 'idle', issue: null, prs_today: 1, local: false },
{ name: 'kimi', status: 'working', issue: 'Portal system YAML registry (#5)', prs_today: 2, local: false },
{ name: 'groq', status: 'idle', issue: null, prs_today: 0, local: false },
{ name: 'grok', status: 'dead', issue: null, prs_today: 0, local: false },
]
};
const AGENT_STATUS_COLORS = { working: '#00ff88', idle: '#4488ff', dead: '#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, falling back to the stub when the endpoint is unavailable.
* @returns {Promise<typeof AGENT_STATUS_STUB>}
*/
async function fetchAgentStatus() {
try {
const res = await fetch('/api/status.json');
if (!res.ok) throw new Error('status ' + res.status);
return await res.json();
} catch {
return AGENT_STATUS_STUB;
}
}
async function refreshAgentBoard() {
const data = await fetchAgentStatus();
rebuildAgentPanels(data);
}
// Initial render, then poll every 30 s
refreshAgentBoard();
setInterval(refreshAgentBoard, 30000);
// === LORA ADAPTER STATUS PANEL ===
// Holographic panel showing which LoRA fine-tuning adapters are currently active.
// Reads from lora-status.json, falls back to stub data when unavailable.
const LORA_STATUS_STUB = {
adapters: [
{ name: 'timmy-voice-v3', base: 'mistral-7b', active: true, strength: 0.85 },
{ name: 'nexus-style-v2', base: 'llama-3-8b', active: true, strength: 0.70 },
{ name: 'sovereign-tone-v1', base: 'phi-3-mini', active: false, strength: 0.50 },
{ name: 'btc-domain-v1', base: 'mistral-7b', active: true, strength: 0.60 },
],
updated: '',
};
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.
* @param {typeof LORA_STATUS_STUB} 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);
// Active count badge (top-right)
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';
// Separator
ctx.strokeStyle = '#2a1a44';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(14, 46);
ctx.lineTo(W - 14, 46);
ctx.stroke();
// Adapter rows
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;
// Status dot
ctx.beginPath();
ctx.arc(22, rowY + 12, 6, 0, Math.PI * 2);
ctx.fillStyle = col;
ctx.fill();
// Adapter name
ctx.font = 'bold 13px "Courier New", monospace';
ctx.fillStyle = adapter.active ? '#ddeeff' : '#445566';
ctx.fillText(adapter.name, 36, rowY + 16);
// Base model (right-aligned)
ctx.font = '10px "Courier New", monospace';
ctx.fillStyle = '#556688';
ctx.textAlign = 'right';
ctx.fillText(adapter.base, W - 14, rowY + 16);
ctx.textAlign = 'left';
// Strength bar
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;
ctx.font = '9px "Courier New", monospace';
ctx.fillStyle = col;
ctx.textAlign = 'right';
ctx.fillText(`${Math.round(adapter.strength * 100)}%`, W - 14, rowY + 28);
ctx.textAlign = 'left';
}
// Row divider (except after last)
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 {typeof LORA_STATUS_STUB} 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);
}
/**
* Fetches live LoRA adapter status, falling back to stub when unavailable.
*/
async function loadLoRAStatus() {
try {
const res = await fetch('./lora-status.json');
if (!res.ok) throw new Error('not found');
const data = await res.json();
if (!Array.isArray(data.adapters)) throw new Error('invalid');
rebuildLoRAPanel(data);
} catch {
rebuildLoRAPanel(LORA_STATUS_STUB);
}
}
loadLoRAStatus();
// Refresh every 60 s so live updates propagate
setInterval(loadLoRAStatus, 60000);
// === 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}&current=temperature_2m,weather_code,wind_speed_10m&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);
weatherState = { code, temp: cur.temperature_2m, wind: cur.wind_speed_10m, condition, icon };
applyWeatherToScene(weatherState);
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.
// Each zone has a glowing floor ring and a rising particle stream.
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
const 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 };
});
// === 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;
}
}
}
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');
}
lastKnownBlockHeight = height;
blockHeightValue.textContent = height.toLocaleString();
} catch (_) {
// Network unavailable — keep last known value
}
}
fetchBlockHeight();
setInterval(fetchBlockHeight, 60000);