Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Co-authored-by: Perplexity Computer <perplexity@tower.local> Co-committed-by: Perplexity Computer <perplexity@tower.local>
5394 lines
175 KiB
JavaScript
5394 lines
175 KiB
JavaScript
import * as THREE from 'three';
|
||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
||
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
||
import { BokehPass } from 'three/addons/postprocessing/BokehPass.js';
|
||
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
|
||
import { LoadingManager } from 'three';
|
||
|
||
// === COLOR PALETTE ===
|
||
const NEXUS = {
|
||
colors: {
|
||
bg: 0x000008,
|
||
starCore: 0xffffff,
|
||
starDim: 0x8899cc,
|
||
constellationLine: 0x334488,
|
||
constellationFade: 0x112244,
|
||
accent: 0x4488ff,
|
||
}
|
||
};
|
||
|
||
// === ASSET LOADER ===
|
||
const loadedAssets = new Map();
|
||
|
||
const loadingManager = new THREE.LoadingManager(() => {
|
||
document.getElementById('loading-bar').style.width = '100%';
|
||
document.getElementById('loading').style.display = 'none';
|
||
animate();
|
||
});
|
||
|
||
loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => {
|
||
const progress = (itemsLoaded / itemsTotal) * 100;
|
||
document.getElementById('loading-bar').style.width = `${progress}%`;
|
||
};
|
||
|
||
// Procedural placeholder texture — avoids 404 for missing placeholder-texture.jpg
|
||
const _placeholderCanvas = document.createElement('canvas');
|
||
_placeholderCanvas.width = 64;
|
||
_placeholderCanvas.height = 64;
|
||
const _placeholderCtx = _placeholderCanvas.getContext('2d');
|
||
_placeholderCtx.fillStyle = '#0a0a18';
|
||
_placeholderCtx.fillRect(0, 0, 64, 64);
|
||
const placeholderTexture = new THREE.CanvasTexture(_placeholderCanvas);
|
||
loadedAssets.set('placeholder-texture', placeholderTexture);
|
||
// Notify loading manager so it still counts one asset
|
||
loadingManager.itemStart('placeholder-texture');
|
||
loadingManager.itemEnd('placeholder-texture');
|
||
|
||
// === MATRIX RAIN ===
|
||
// 2D canvas layer rendered behind the Three.js scene.
|
||
const matrixCanvas = document.createElement('canvas');
|
||
matrixCanvas.id = 'matrix-rain';
|
||
matrixCanvas.width = window.innerWidth;
|
||
matrixCanvas.height = window.innerHeight;
|
||
document.body.appendChild(matrixCanvas);
|
||
|
||
const matrixCtx = matrixCanvas.getContext('2d');
|
||
|
||
const MATRIX_CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF';
|
||
const MATRIX_FONT_SIZE = 14;
|
||
const MATRIX_COL_COUNT = Math.floor(window.innerWidth / MATRIX_FONT_SIZE);
|
||
const matrixDrops = new Array(MATRIX_COL_COUNT).fill(1);
|
||
|
||
// Commit hashes for matrix rain — populated by heatmap fetch, used to inject real data into the rain
|
||
let _matrixCommitHashes = [];
|
||
|
||
function drawMatrixRain() {
|
||
// Fade previous frame with semi-transparent black overlay (creates the trail)
|
||
matrixCtx.fillStyle = 'rgba(0, 0, 8, 0.05)';
|
||
matrixCtx.fillRect(0, 0, matrixCanvas.width, matrixCanvas.height);
|
||
|
||
matrixCtx.font = `${MATRIX_FONT_SIZE}px monospace`;
|
||
|
||
// Tether rain density to commit activity — density range [0.1, 1.0]
|
||
const activity = typeof totalActivity === 'function' ? totalActivity() : 0;
|
||
const density = 0.1 + activity * 0.9; // minimum 10% density
|
||
const activeColCount = Math.max(1, Math.floor(matrixDrops.length * density));
|
||
|
||
for (let i = 0; i < matrixDrops.length; i++) {
|
||
// Only render columns up to density-scaled count (skip inactive ones)
|
||
if (i >= activeColCount) {
|
||
// Inactive columns still fade but don't spawn new characters
|
||
if (matrixDrops[i] * MATRIX_FONT_SIZE > matrixCanvas.height) continue;
|
||
}
|
||
|
||
// Occasionally inject a real commit hash (first 7 chars) instead of katakana
|
||
let char;
|
||
if (_matrixCommitHashes.length > 0 && Math.random() < 0.02) {
|
||
const hash = _matrixCommitHashes[Math.floor(Math.random() * _matrixCommitHashes.length)];
|
||
char = hash[Math.floor(Math.random() * hash.length)];
|
||
} else {
|
||
char = MATRIX_CHARS[Math.floor(Math.random() * MATRIX_CHARS.length)];
|
||
}
|
||
|
||
const x = i * MATRIX_FONT_SIZE;
|
||
const y = matrixDrops[i] * MATRIX_FONT_SIZE;
|
||
|
||
// Head character: bright white-green
|
||
matrixCtx.fillStyle = '#aaffaa';
|
||
matrixCtx.fillText(char, x, y);
|
||
|
||
// Reset drop to top — speed influenced by activity
|
||
const resetThreshold = 0.975 - activity * 0.015; // more activity = faster reset = denser rain
|
||
if (y > matrixCanvas.height && Math.random() > resetThreshold) {
|
||
matrixDrops[i] = 0;
|
||
}
|
||
matrixDrops[i]++;
|
||
}
|
||
}
|
||
|
||
// Animate at ~20 fps (independent of Three.js loop)
|
||
setInterval(drawMatrixRain, 50);
|
||
|
||
// Resize handler for matrix canvas
|
||
window.addEventListener('resize', () => {
|
||
matrixCanvas.width = window.innerWidth;
|
||
matrixCanvas.height = window.innerHeight;
|
||
});
|
||
|
||
// === SCENE SETUP ===
|
||
const scene = new THREE.Scene();
|
||
// Background is null — the matrix rain canvas shows through the transparent renderer
|
||
|
||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000);
|
||
camera.position.set(0, 6, 11);
|
||
|
||
const raycaster = new THREE.Raycaster();
|
||
const forwardVector = new THREE.Vector3();
|
||
|
||
// === LIGHTING ===
|
||
// Required for MeshStandardMaterial / MeshPhysicalMaterial used on the platform.
|
||
const ambientLight = new THREE.AmbientLight(0x0a1428, 1.4);
|
||
scene.add(ambientLight);
|
||
|
||
// SpotLight replaces PointLight so shadows can be cast with a single depth map
|
||
// (PointLights require 6 cube-face renders; SpotLights need only 1)
|
||
const overheadLight = new THREE.SpotLight(0x8899bb, 0.6, 80, Math.PI / 3.5, 0.5, 1.0);
|
||
overheadLight.position.set(0, 25, 0);
|
||
overheadLight.target.position.set(0, 0, 0);
|
||
overheadLight.castShadow = true;
|
||
overheadLight.shadow.mapSize.set(2048, 2048);
|
||
overheadLight.shadow.camera.near = 5;
|
||
overheadLight.shadow.camera.far = 60;
|
||
overheadLight.shadow.bias = -0.001;
|
||
scene.add(overheadLight);
|
||
scene.add(overheadLight.target);
|
||
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||
renderer.setClearColor(0x000000, 0);
|
||
renderer.setPixelRatio(window.devicePixelRatio);
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
// === SHADOW SYSTEM ===
|
||
// PCFSoftShadowMap provides smooth penumbra edges matching the holographic aesthetic.
|
||
renderer.shadowMap.enabled = true;
|
||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||
document.body.appendChild(renderer.domElement);
|
||
|
||
// === STAR FIELD ===
|
||
const STAR_COUNT = 800;
|
||
const STAR_SPREAD = 400;
|
||
const CONSTELLATION_DISTANCE = 30; // max distance to draw a line between stars
|
||
|
||
const starPositions = [];
|
||
const starGeo = new THREE.BufferGeometry();
|
||
const posArray = new Float32Array(STAR_COUNT * 3);
|
||
const sizeArray = new Float32Array(STAR_COUNT);
|
||
|
||
for (let i = 0; i < STAR_COUNT; i++) {
|
||
const x = (Math.random() - 0.5) * STAR_SPREAD;
|
||
const y = (Math.random() - 0.5) * STAR_SPREAD;
|
||
const z = (Math.random() - 0.5) * STAR_SPREAD;
|
||
posArray[i * 3] = x;
|
||
posArray[i * 3 + 1] = y;
|
||
posArray[i * 3 + 2] = z;
|
||
sizeArray[i] = Math.random() * 2.5 + 0.5;
|
||
starPositions.push(new THREE.Vector3(x, y, z));
|
||
}
|
||
|
||
starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
|
||
starGeo.setAttribute('size', new THREE.BufferAttribute(sizeArray, 1));
|
||
|
||
const starMaterial = new THREE.PointsMaterial({
|
||
color: NEXUS.colors.starCore,
|
||
size: 0.6,
|
||
sizeAttenuation: true,
|
||
transparent: true,
|
||
opacity: 0.9,
|
||
});
|
||
|
||
const stars = new THREE.Points(starGeo, starMaterial);
|
||
scene.add(stars);
|
||
|
||
// Star pulse state — tethered to Bitcoin block events
|
||
let _starPulseIntensity = 0; // 0 = normal, 1 = peak brightness
|
||
const STAR_BASE_OPACITY = 0.3;
|
||
const STAR_PEAK_OPACITY = 1.0;
|
||
const STAR_PULSE_DECAY = 0.012; // decay per frame (~3 seconds to fade)
|
||
|
||
// === CONSTELLATION LINES ===
|
||
// Connect nearby stars with faint lines, limited to avoid clutter
|
||
/**
|
||
* Builds constellation line segments connecting nearby stars.
|
||
* @returns {THREE.LineSegments}
|
||
*/
|
||
function buildConstellationLines() {
|
||
const linePositions = [];
|
||
const MAX_CONNECTIONS_PER_STAR = 3;
|
||
const connectionCount = new Array(STAR_COUNT).fill(0);
|
||
|
||
for (let i = 0; i < STAR_COUNT; i++) {
|
||
if (connectionCount[i] >= MAX_CONNECTIONS_PER_STAR) continue;
|
||
|
||
// Find nearest neighbors
|
||
const neighbors = [];
|
||
for (let j = i + 1; j < STAR_COUNT; j++) {
|
||
if (connectionCount[j] >= MAX_CONNECTIONS_PER_STAR) continue;
|
||
const dist = starPositions[i].distanceTo(starPositions[j]);
|
||
if (dist < CONSTELLATION_DISTANCE) {
|
||
neighbors.push({ j, dist });
|
||
}
|
||
}
|
||
|
||
// Sort by distance and connect closest ones
|
||
neighbors.sort((/** @type {{j: number, dist: number}} */ a, /** @type {{j: number, dist: number}} */ b) => a.dist - b.dist);
|
||
const toConnect = neighbors.slice(0, MAX_CONNECTIONS_PER_STAR - connectionCount[i]);
|
||
|
||
for (const { j } of toConnect) {
|
||
linePositions.push(
|
||
starPositions[i].x, starPositions[i].y, starPositions[i].z,
|
||
starPositions[j].x, starPositions[j].y, starPositions[j].z
|
||
);
|
||
connectionCount[i]++;
|
||
connectionCount[j]++;
|
||
}
|
||
}
|
||
|
||
const lineGeo = new THREE.BufferGeometry();
|
||
lineGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(linePositions), 3));
|
||
|
||
const lineMat = new THREE.LineBasicMaterial({
|
||
color: NEXUS.colors.constellationLine,
|
||
transparent: true,
|
||
opacity: 0.18,
|
||
});
|
||
|
||
return new THREE.LineSegments(lineGeo, lineMat);
|
||
}
|
||
|
||
const constellationLines = buildConstellationLines();
|
||
scene.add(constellationLines);
|
||
|
||
// === GLASS PLATFORM ===
|
||
// Central floating platform with transparent glass-floor sections revealing the void (star field) below.
|
||
|
||
const glassPlatformGroup = new THREE.Group();
|
||
|
||
// Dark metallic frame material
|
||
const platformFrameMat = new THREE.MeshStandardMaterial({
|
||
color: 0x0a1828,
|
||
metalness: 0.9,
|
||
roughness: 0.1,
|
||
emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.06),
|
||
});
|
||
|
||
// Outer solid rim (flat ring)
|
||
const platformRimGeo = new THREE.RingGeometry(4.7, 5.3, 64);
|
||
const platformRim = new THREE.Mesh(platformRimGeo, platformFrameMat);
|
||
platformRim.rotation.x = -Math.PI / 2;
|
||
platformRim.castShadow = true;
|
||
platformRim.receiveShadow = true;
|
||
glassPlatformGroup.add(platformRim);
|
||
|
||
// Raised border torus for visible 3-D thickness
|
||
const borderTorusGeo = new THREE.TorusGeometry(5.0, 0.1, 6, 64);
|
||
const borderTorus = new THREE.Mesh(borderTorusGeo, platformFrameMat);
|
||
borderTorus.rotation.x = Math.PI / 2;
|
||
borderTorus.castShadow = true;
|
||
borderTorus.receiveShadow = true;
|
||
glassPlatformGroup.add(borderTorus);
|
||
|
||
// Glass tile material — highly transmissive to reveal the void below
|
||
const glassTileMat = new THREE.MeshPhysicalMaterial({
|
||
color: new THREE.Color(NEXUS.colors.accent),
|
||
transparent: true,
|
||
opacity: 0.09,
|
||
roughness: 0.0,
|
||
metalness: 0.0,
|
||
transmission: 0.92,
|
||
thickness: 0.06,
|
||
side: THREE.DoubleSide,
|
||
depthWrite: false,
|
||
});
|
||
|
||
// Edge glow — bright accent outline on each tile
|
||
const glassEdgeBaseMat = new THREE.LineBasicMaterial({
|
||
color: NEXUS.colors.accent,
|
||
transparent: true,
|
||
opacity: 0.55,
|
||
});
|
||
|
||
const GLASS_TILE_SIZE = 0.85;
|
||
const GLASS_TILE_GAP = 0.14;
|
||
const GLASS_TILE_STEP = GLASS_TILE_SIZE + GLASS_TILE_GAP;
|
||
const GLASS_RADIUS = 4.55;
|
||
|
||
const tileGeo = new THREE.PlaneGeometry(GLASS_TILE_SIZE, GLASS_TILE_SIZE);
|
||
const tileEdgeGeo = new THREE.EdgesGeometry(tileGeo);
|
||
|
||
/** @type {Array<{mat: THREE.LineBasicMaterial, distFromCenter: number}>} */
|
||
const glassEdgeMaterials = [];
|
||
|
||
for (let row = -5; row <= 5; row++) {
|
||
for (let col = -5; col <= 5; col++) {
|
||
const x = col * GLASS_TILE_STEP;
|
||
const z = row * GLASS_TILE_STEP;
|
||
const distFromCenter = Math.sqrt(x * x + z * z);
|
||
if (distFromCenter > GLASS_RADIUS) continue;
|
||
|
||
// Transparent glass tile
|
||
const tile = new THREE.Mesh(tileGeo, glassTileMat.clone());
|
||
tile.rotation.x = -Math.PI / 2;
|
||
tile.position.set(x, 0, z);
|
||
glassPlatformGroup.add(tile);
|
||
|
||
// Glowing edge lines
|
||
const mat = glassEdgeBaseMat.clone();
|
||
const edges = new THREE.LineSegments(tileEdgeGeo, mat);
|
||
edges.rotation.x = -Math.PI / 2;
|
||
edges.position.set(x, 0.002, z);
|
||
glassPlatformGroup.add(edges);
|
||
glassEdgeMaterials.push({ mat, distFromCenter });
|
||
}
|
||
}
|
||
|
||
// Void shimmer — faint point light below the glass, emphasising the infinite depth
|
||
const voidLight = new THREE.PointLight(NEXUS.colors.accent, 0.5, 14);
|
||
voidLight.position.set(0, -3.5, 0);
|
||
glassPlatformGroup.add(voidLight);
|
||
|
||
scene.add(glassPlatformGroup);
|
||
glassPlatformGroup.traverse(obj => {
|
||
if (obj.isMesh) obj.userData.zoomLabel = 'Glass Platform';
|
||
});
|
||
|
||
// === PERLIN NOISE ===
|
||
// Classic Perlin noise used for procedural terrain generation.
|
||
|
||
function createPerlinNoise() {
|
||
const p = new Uint8Array(256);
|
||
for (let i = 0; i < 256; i++) p[i] = i;
|
||
// Fisher-Yates shuffle with a fixed seed sequence for reproducibility
|
||
let seed = 42;
|
||
function seededRand() {
|
||
seed = (seed * 1664525 + 1013904223) & 0xffffffff;
|
||
return (seed >>> 0) / 0xffffffff;
|
||
}
|
||
for (let i = 255; i > 0; i--) {
|
||
const j = Math.floor(seededRand() * (i + 1));
|
||
const tmp = p[i]; p[i] = p[j]; p[j] = tmp;
|
||
}
|
||
const perm = new Uint8Array(512);
|
||
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
|
||
|
||
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
|
||
function lerp(a, b, t) { return a + t * (b - a); }
|
||
function grad(hash, x, y, z) {
|
||
const h = hash & 15;
|
||
const u = h < 8 ? x : y;
|
||
const v = h < 4 ? y : (h === 12 || h === 14) ? x : z;
|
||
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
|
||
}
|
||
|
||
return function noise(x, y, z) {
|
||
z = z || 0;
|
||
const X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255;
|
||
x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
|
||
const u = fade(x), v = fade(y), w = fade(z);
|
||
const A = perm[X] + Y, AA = perm[A] + Z, AB = perm[A + 1] + Z;
|
||
const B = perm[X + 1] + Y, BA = perm[B] + Z, BB = perm[B + 1] + Z;
|
||
return lerp(
|
||
lerp(lerp(grad(perm[AA], x, y, z ), grad(perm[BA], x-1, y, z ), u),
|
||
lerp(grad(perm[AB], x, y-1, z ), grad(perm[BB], x-1, y-1, z ), u), v),
|
||
lerp(lerp(grad(perm[AA + 1], x, y, z-1), grad(perm[BA + 1], x-1, y, z-1), u),
|
||
lerp(grad(perm[AB + 1], x, y-1, z-1), grad(perm[BB + 1], x-1, y-1, z-1), u), v),
|
||
w
|
||
);
|
||
};
|
||
}
|
||
|
||
const perlin = createPerlinNoise();
|
||
|
||
// === FLOATING ISLAND TERRAIN ===
|
||
// Procedural terrain below the glass platform, shaped like a floating rock island.
|
||
// Heights generated via domain-warped fBm (5 octaves) + ridged Perlin noise,
|
||
// with 5-zone vertex colours, emissive crystal spires, and a noise-displaced
|
||
// rocky underside with stalactite drips.
|
||
|
||
(function buildFloatingIsland() {
|
||
const ISLAND_RADIUS = 9.5;
|
||
const SEGMENTS = 96;
|
||
const SIZE = ISLAND_RADIUS * 2;
|
||
|
||
// --- Domain-warped fBm ---
|
||
// First evaluates a low-frequency warp offset, then runs 5-octave fBm on the
|
||
// warped coordinates. A ridged octave is blended in to create sharp ridges.
|
||
function islandFBm(nx, nz) {
|
||
// Warp pass — displaces sample point for organic shapes
|
||
const wx = perlin(nx * 0.5 + 3.7, nz * 0.5 + 1.2) * 0.55;
|
||
const wz = perlin(nx * 0.5 + 8.3, nz * 0.5 + 5.9) * 0.55;
|
||
const px = nx + wx, pz = nz + wz;
|
||
|
||
// 5-octave fBm
|
||
let h = 0;
|
||
h += perlin(px, pz ) * 1.000;
|
||
h += perlin(px * 2, pz * 2 ) * 0.500;
|
||
h += perlin(px * 4, pz * 4 ) * 0.250;
|
||
h += perlin(px * 8, pz * 8 ) * 0.125;
|
||
h += perlin(px * 16, pz * 16 ) * 0.063;
|
||
h /= 1.938; // normalise to ~[-1, 1]
|
||
|
||
// Ridged octave — 1−|noise| creates sharp ridge lines
|
||
const ridge = 1.0 - Math.abs(perlin(px * 3.1 + 5.0, pz * 3.1 + 7.0));
|
||
return h * 0.78 + ridge * 0.22;
|
||
}
|
||
|
||
const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEGMENTS, SEGMENTS);
|
||
geo.rotateX(-Math.PI / 2);
|
||
const pos = geo.attributes.position;
|
||
const vCount = pos.count;
|
||
|
||
// Store heights for colour pass and crystal placement
|
||
const rawHeights = new Float32Array(vCount);
|
||
|
||
for (let i = 0; i < vCount; i++) {
|
||
const x = pos.getX(i);
|
||
const z = pos.getZ(i);
|
||
const dist = Math.sqrt(x * x + z * z) / ISLAND_RADIUS;
|
||
|
||
// Edge taper with slight noise-driven rim undulation
|
||
const rimNoise = perlin(x * 0.38 + 10, z * 0.38 + 10) * 0.10;
|
||
const edgeFactor = Math.max(0, 1 - Math.pow(Math.max(0, dist - rimNoise), 2.4));
|
||
|
||
const h = islandFBm(x * 0.15, z * 0.15);
|
||
const height = ((h + 1) * 0.5) * edgeFactor * 3.2;
|
||
pos.setY(i, height);
|
||
rawHeights[i] = height;
|
||
}
|
||
|
||
geo.computeVertexNormals();
|
||
|
||
// --- 5-zone vertex colours ---
|
||
// 0: wet dark earth (h < 0.25)
|
||
// 1: rocky brown (0.25 – 0.75)
|
||
// 2: stone grey (0.75 – 1.4)
|
||
// 3: pale limestone (1.4 – 2.2)
|
||
// 4: crystal peak (> 2.2) — blue-tinted
|
||
const colBuf = new Float32Array(vCount * 3);
|
||
for (let i = 0; i < vCount; i++) {
|
||
const h = rawHeights[i];
|
||
let r, g, b;
|
||
if (h < 0.25) {
|
||
r = 0.11; g = 0.09; b = 0.07;
|
||
} else if (h < 0.75) {
|
||
const t = (h - 0.25) / 0.50;
|
||
r = 0.11 + t * 0.13; g = 0.09 + t * 0.09; b = 0.07 + t * 0.06;
|
||
} else if (h < 1.4) {
|
||
const t = (h - 0.75) / 0.65;
|
||
r = 0.24 + t * 0.12; g = 0.18 + t * 0.10; b = 0.13 + t * 0.10;
|
||
} else if (h < 2.2) {
|
||
const t = (h - 1.4) / 0.80;
|
||
r = 0.36 + t * 0.14; g = 0.28 + t * 0.11; b = 0.23 + t * 0.13;
|
||
} else {
|
||
// Crystal peaks — blue-tinted pale rock
|
||
const t = Math.min(1, (h - 2.2) / 0.9);
|
||
r = 0.50 + t * 0.05;
|
||
g = 0.39 + t * 0.10;
|
||
b = 0.36 + t * 0.28; // distinct blue-violet shift
|
||
}
|
||
colBuf[i * 3] = r;
|
||
colBuf[i * 3 + 1] = g;
|
||
colBuf[i * 3 + 2] = b;
|
||
}
|
||
geo.setAttribute('color', new THREE.BufferAttribute(colBuf, 3));
|
||
|
||
const topMat = new THREE.MeshStandardMaterial({
|
||
vertexColors: true,
|
||
roughness: 0.86,
|
||
metalness: 0.05,
|
||
});
|
||
const topMesh = new THREE.Mesh(geo, topMat);
|
||
topMesh.castShadow = true;
|
||
topMesh.receiveShadow = true;
|
||
|
||
// --- Crystal spire formations ---
|
||
// Scatter emissive crystal clusters at high terrain points,
|
||
// seeded from the same Perlin noise for reproducibility.
|
||
const crystalMat = new THREE.MeshStandardMaterial({
|
||
color: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.55),
|
||
emissive: new THREE.Color(NEXUS.colors.accent),
|
||
emissiveIntensity: 0.5,
|
||
roughness: 0.08,
|
||
metalness: 0.25,
|
||
transparent: true,
|
||
opacity: 0.80,
|
||
});
|
||
|
||
const CRYSTAL_MIN_H = 2.05; // only spawn on high terrain
|
||
const crystalGroup = new THREE.Group();
|
||
|
||
for (let row = -5; row <= 5; row++) {
|
||
for (let col = -5; col <= 5; col++) {
|
||
const bx = col * 1.75, bz = row * 1.75;
|
||
if (Math.sqrt(bx * bx + bz * bz) > ISLAND_RADIUS * 0.72) continue;
|
||
|
||
// Evaluate terrain height at this candidate location
|
||
const edF = Math.max(0, 1 - Math.pow(Math.sqrt(bx * bx + bz * bz) / ISLAND_RADIUS, 2.4));
|
||
const candidateH = ((islandFBm(bx * 0.15, bz * 0.15) + 1) * 0.5) * edF * 3.2;
|
||
if (candidateH < CRYSTAL_MIN_H) continue;
|
||
|
||
// Jitter spawn position using noise
|
||
const jx = bx + perlin(bx * 0.7 + 20, bz * 0.7 + 20) * 0.55;
|
||
const jz = bz + perlin(bx * 0.7 + 30, bz * 0.7 + 30) * 0.55;
|
||
if (Math.sqrt(jx * jx + jz * jz) > ISLAND_RADIUS * 0.68) continue;
|
||
|
||
// Cluster of 2–4 spires
|
||
const clusterSize = 2 + Math.floor(Math.abs(perlin(bx * 0.5 + 40, bz * 0.5 + 40)) * 3);
|
||
for (let c = 0; c < clusterSize; c++) {
|
||
const angle = (c / clusterSize) * Math.PI * 2 + perlin(bx + c, bz + c) * 1.4;
|
||
const spread = 0.08 + Math.abs(perlin(bx + c * 5, bz + c * 5)) * 0.22;
|
||
const sx = jx + Math.cos(angle) * spread;
|
||
const sz = jz + Math.sin(angle) * spread;
|
||
const spireScale = 0.14 + (candidateH - CRYSTAL_MIN_H) * 0.11;
|
||
const spireH = spireScale * (0.8 + Math.abs(perlin(sx, sz)) * 0.45);
|
||
const spireR = spireH * 0.17;
|
||
|
||
const spireGeo = new THREE.ConeGeometry(spireR, spireH * 2.8, 5);
|
||
const spire = new THREE.Mesh(spireGeo, crystalMat);
|
||
spire.position.set(sx, candidateH + spireH * 0.5, sz);
|
||
spire.rotation.z = perlin(sx * 2, sz * 2) * 0.28;
|
||
spire.rotation.x = perlin(sx * 3 + 1, sz * 3 + 1) * 0.18;
|
||
spire.castShadow = true;
|
||
crystalGroup.add(spire);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Noise-displaced rocky underside with stalactite drips ---
|
||
// CylinderGeometry with open top (openEnded=true) so only the tapered side
|
||
// wall is visible. Vertices are radially and vertically displaced by Perlin
|
||
// noise to break the smooth cylinder into jagged rock.
|
||
const BOTTOM_SEGS_R = 52;
|
||
const BOTTOM_SEGS_V = 10;
|
||
const BOTTOM_HEIGHT = 2.6;
|
||
const bottomGeo = new THREE.CylinderGeometry(
|
||
ISLAND_RADIUS * 0.80, ISLAND_RADIUS * 0.28,
|
||
BOTTOM_HEIGHT, BOTTOM_SEGS_R, BOTTOM_SEGS_V, true
|
||
);
|
||
const bPos = bottomGeo.attributes.position;
|
||
for (let i = 0; i < bPos.count; i++) {
|
||
const bx = bPos.getX(i);
|
||
const bz = bPos.getZ(i);
|
||
const by = bPos.getY(i);
|
||
const angle = Math.atan2(bz, bx);
|
||
const r = Math.sqrt(bx * bx + bz * bz);
|
||
|
||
// Radial displacement — jagged surface detail
|
||
const radDisp = perlin(Math.cos(angle) * 1.6 + 50, Math.sin(angle) * 1.6 + 50) * 0.65;
|
||
// Vertical stalactite pull — lower vertices dragged further downward
|
||
const vNorm = (by + BOTTOM_HEIGHT * 0.5) / BOTTOM_HEIGHT; // 0=bottom 1=top
|
||
const stalDisp = (1 - vNorm) * Math.abs(perlin(bx * 0.35 + 70, by * 0.7 + bz * 0.35)) * 0.9;
|
||
|
||
const newR = r + radDisp;
|
||
bPos.setX(i, (bx / r) * newR);
|
||
bPos.setZ(i, (bz / r) * newR);
|
||
bPos.setY(i, by - stalDisp);
|
||
}
|
||
bottomGeo.computeVertexNormals();
|
||
|
||
const bottomMat = new THREE.MeshStandardMaterial({ color: 0x0c0a08, roughness: 0.93, metalness: 0.02 });
|
||
const bottomMesh = new THREE.Mesh(bottomGeo, bottomMat);
|
||
bottomMesh.position.y = -BOTTOM_HEIGHT * 0.5;
|
||
bottomMesh.castShadow = true;
|
||
|
||
// Bottom cap — seals the underside of the cylinder
|
||
const capGeo = new THREE.CircleGeometry(ISLAND_RADIUS * 0.28, 48);
|
||
capGeo.rotateX(Math.PI / 2);
|
||
const capMesh = new THREE.Mesh(capGeo, bottomMat);
|
||
capMesh.position.y = -(BOTTOM_HEIGHT + 0.1);
|
||
|
||
const islandGroup = new THREE.Group();
|
||
islandGroup.add(topMesh);
|
||
islandGroup.add(crystalGroup);
|
||
islandGroup.add(bottomMesh);
|
||
islandGroup.add(capMesh);
|
||
islandGroup.position.y = -2.8; // float below the glass platform
|
||
scene.add(islandGroup);
|
||
})();
|
||
|
||
// === PROCEDURAL CLOUD LAYER ===
|
||
// A volumetric cloud layer below the island, using a custom shader with Perlin noise.
|
||
|
||
const CLOUD_LAYER_Y = -6.0;
|
||
const CLOUD_DIMENSIONS = 120;
|
||
const CLOUD_THICKNESS = 15;
|
||
const CLOUD_OPACITY = 0.6;
|
||
|
||
const cloudGeometry = new THREE.BoxGeometry(CLOUD_DIMENSIONS, CLOUD_THICKNESS, CLOUD_DIMENSIONS, 8, 4, 8);
|
||
|
||
const CloudShader = {
|
||
uniforms: {
|
||
'uTime': { value: 0.0 },
|
||
'uCloudColor': { value: new THREE.Color(0x88bbff) },
|
||
'uNoiseScale': { value: new THREE.Vector3(0.015, 0.015, 0.015) },
|
||
'uDensity': { value: 0.8 },
|
||
},
|
||
vertexShader: `
|
||
varying vec3 vWorldPosition;
|
||
void main() {
|
||
vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
|
||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||
}
|
||
`,
|
||
fragmentShader: `
|
||
uniform float uTime;
|
||
uniform vec3 uCloudColor;
|
||
uniform vec3 uNoiseScale;
|
||
uniform float uDensity;
|
||
varying vec3 vWorldPosition;
|
||
|
||
// 3D Simplex noise — Ian McEwan / Stefan Gustavson implementation
|
||
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
|
||
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
|
||
float snoise(vec3 v) {
|
||
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
|
||
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
|
||
vec3 i = floor(v + dot(v, C.yyy));
|
||
vec3 x0 = v - i + dot(i, C.xxx);
|
||
vec3 g = step(x0.yzx, x0.xyz);
|
||
vec3 l = 1.0 - g;
|
||
vec3 i1 = min(g.xyz, l.zxy);
|
||
vec3 i2 = max(g.xyz, l.zxy);
|
||
vec3 x1 = x0 - i1 + C.xxx;
|
||
vec3 x2 = x0 - i2 + C.yyy;
|
||
vec3 x3 = x0 - D.yyy;
|
||
i = mod289(i);
|
||
vec4 p = permute(permute(permute(
|
||
i.z + vec4(0.0, i1.z, i2.z, 1.0))
|
||
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
|
||
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
|
||
float n_ = 0.142857142857;
|
||
vec3 ns = n_ * D.wyz - D.xzx;
|
||
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
|
||
vec4 x_ = floor(j * ns.z);
|
||
vec4 y_ = floor(j - 7.0 * x_);
|
||
vec4 x = x_ * ns.x + ns.yyyy;
|
||
vec4 y = y_ * ns.x + ns.yyyy;
|
||
vec4 h = 1.0 - abs(x) - abs(y);
|
||
vec4 b0 = vec4(x.xy, y.xy);
|
||
vec4 b1 = vec4(x.zw, y.zw);
|
||
vec4 s0 = floor(b0) * 2.0 + 1.0;
|
||
vec4 s1 = floor(b1) * 2.0 + 1.0;
|
||
vec4 sh = -step(h, vec4(0.0));
|
||
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||
vec3 p0 = vec3(a0.xy, h.x);
|
||
vec3 p1 = vec3(a0.zw, h.y);
|
||
vec3 p2 = vec3(a1.xy, h.z);
|
||
vec3 p3 = vec3(a1.zw, h.w);
|
||
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
|
||
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
|
||
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
|
||
m = m * m;
|
||
return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
|
||
}
|
||
|
||
void main() {
|
||
// Drift clouds slowly over time
|
||
vec3 noiseCoord = vWorldPosition * uNoiseScale + vec3(uTime * 0.003, 0.0, uTime * 0.002);
|
||
|
||
// fBm: three octaves for wispy detail
|
||
float noiseVal = snoise(noiseCoord) * 0.500;
|
||
noiseVal += snoise(noiseCoord * 2.0) * 0.250;
|
||
noiseVal += snoise(noiseCoord * 4.0) * 0.125;
|
||
noiseVal /= 0.875; // normalise to ~[-1, 1]
|
||
|
||
// Cloud-like density curve — discard sparse regions
|
||
float density = smoothstep(0.25, 0.85, noiseVal * 0.5 + 0.5);
|
||
density *= uDensity;
|
||
|
||
// Fade out towards top and bottom of the slab
|
||
float layerBottom = ${(CLOUD_LAYER_Y - CLOUD_THICKNESS * 0.5).toFixed(1)};
|
||
float yNorm = (vWorldPosition.y - layerBottom) / ${CLOUD_THICKNESS.toFixed(1)};
|
||
float fadeFactor = smoothstep(0.0, 0.15, yNorm) * smoothstep(1.0, 0.85, yNorm);
|
||
|
||
gl_FragColor = vec4(uCloudColor, density * fadeFactor * ${CLOUD_OPACITY.toFixed(1)});
|
||
if (gl_FragColor.a < 0.04) discard;
|
||
}
|
||
`,
|
||
};
|
||
|
||
const cloudMaterial = new THREE.ShaderMaterial({
|
||
uniforms: CloudShader.uniforms,
|
||
vertexShader: CloudShader.vertexShader,
|
||
fragmentShader: CloudShader.fragmentShader,
|
||
transparent: true,
|
||
depthWrite: false, // Important for proper blending of transparent objects
|
||
blending: THREE.AdditiveBlending, // Optional: gives a more ethereal look
|
||
side: THREE.DoubleSide,
|
||
});
|
||
|
||
const clouds = new THREE.Mesh(cloudGeometry, cloudMaterial);
|
||
clouds.position.y = CLOUD_LAYER_Y;
|
||
scene.add(clouds);
|
||
|
||
// === COMMIT HEATMAP ===
|
||
// Canvas-texture overlay on the floor. Each agent occupies a polar sector;
|
||
// recent commits make that sector glow brighter. Activity decays over 24 h.
|
||
|
||
const HEATMAP_SIZE = 512;
|
||
const HEATMAP_REFRESH_MS = 5 * 60 * 1000; // 5 min between API polls
|
||
const HEATMAP_DECAY_MS = 24 * 60 * 60 * 1000; // 24 h full decay
|
||
|
||
// Agent zones — angle in canvas degrees (0 = east/right, clockwise)
|
||
const HEATMAP_ZONES = [
|
||
{ name: 'Claude', color: [255, 100, 60], authorMatch: /^claude$/i, angleDeg: 0 },
|
||
{ name: 'Timmy', color: [ 60, 160, 255], authorMatch: /^timmy/i, angleDeg: 90 },
|
||
{ name: 'Kimi', color: [ 60, 255, 140], authorMatch: /^kimi/i, angleDeg: 180 },
|
||
{ name: 'Perplexity', color: [200, 60, 255], authorMatch: /^perplexity/i, angleDeg: 270 },
|
||
];
|
||
const HEATMAP_ZONE_SPAN_RAD = Math.PI / 2; // 90° per zone
|
||
|
||
const heatmapCanvas = document.createElement('canvas');
|
||
heatmapCanvas.width = HEATMAP_SIZE;
|
||
heatmapCanvas.height = HEATMAP_SIZE;
|
||
const heatmapTexture = new THREE.CanvasTexture(heatmapCanvas);
|
||
|
||
const heatmapMat = new THREE.MeshBasicMaterial({
|
||
map: heatmapTexture,
|
||
transparent: true,
|
||
opacity: 0.9,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
side: THREE.DoubleSide,
|
||
});
|
||
|
||
const heatmapMesh = new THREE.Mesh(
|
||
new THREE.CircleGeometry(GLASS_RADIUS, 64),
|
||
heatmapMat
|
||
);
|
||
heatmapMesh.rotation.x = -Math.PI / 2;
|
||
heatmapMesh.position.y = 0.005;
|
||
heatmapMesh.userData.zoomLabel = 'Activity Heatmap';
|
||
scene.add(heatmapMesh);
|
||
|
||
// Per-zone intensity [0..1], updated by updateHeatmap()
|
||
const zoneIntensity = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0]));
|
||
|
||
/**
|
||
* Redraws the heatmap canvas from current zoneIntensity values.
|
||
*/
|
||
function drawHeatmap() {
|
||
const ctx = heatmapCanvas.getContext('2d');
|
||
const cx = HEATMAP_SIZE / 2;
|
||
const cy = HEATMAP_SIZE / 2;
|
||
const r = cx * 0.96;
|
||
|
||
ctx.clearRect(0, 0, HEATMAP_SIZE, HEATMAP_SIZE);
|
||
|
||
// Clip drawing to the circular platform boundary
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||
ctx.clip();
|
||
|
||
for (const zone of HEATMAP_ZONES) {
|
||
const intensity = zoneIntensity[zone.name] || 0;
|
||
if (intensity < 0.01) continue;
|
||
|
||
const [rr, gg, bb] = zone.color;
|
||
const baseRad = zone.angleDeg * (Math.PI / 180);
|
||
const startRad = baseRad - HEATMAP_ZONE_SPAN_RAD / 2;
|
||
const endRad = baseRad + HEATMAP_ZONE_SPAN_RAD / 2;
|
||
|
||
// Glow origin sits at 55% radius in the zone's direction
|
||
const gx = cx + Math.cos(baseRad) * r * 0.55;
|
||
const gy = cy + Math.sin(baseRad) * r * 0.55;
|
||
|
||
const grad = ctx.createRadialGradient(gx, gy, 0, gx, gy, r * 0.75);
|
||
grad.addColorStop(0, `rgba(${rr},${gg},${bb},${0.65 * intensity})`);
|
||
grad.addColorStop(0.45, `rgba(${rr},${gg},${bb},${0.25 * intensity})`);
|
||
grad.addColorStop(1, `rgba(${rr},${gg},${bb},0)`);
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy);
|
||
ctx.arc(cx, cy, r, startRad, endRad);
|
||
ctx.closePath();
|
||
ctx.fillStyle = grad;
|
||
ctx.fill();
|
||
|
||
// Zone label — only when active
|
||
if (intensity > 0.05) {
|
||
const labelX = cx + Math.cos(baseRad) * r * 0.62;
|
||
const labelY = cy + Math.sin(baseRad) * r * 0.62;
|
||
ctx.font = `bold ${Math.round(13 * intensity + 7)}px "Courier New", monospace`;
|
||
ctx.fillStyle = `rgba(${rr},${gg},${bb},${Math.min(intensity * 1.2, 0.9)})`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(zone.name, labelX, labelY);
|
||
}
|
||
}
|
||
|
||
ctx.restore();
|
||
heatmapTexture.needsUpdate = true;
|
||
}
|
||
|
||
/**
|
||
* Fetches recent commits, maps them to agent zones via author, and redraws.
|
||
*/
|
||
async function updateHeatmap() {
|
||
let commits = [];
|
||
try {
|
||
const res = await fetch(
|
||
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=50',
|
||
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
|
||
);
|
||
if (res.ok) commits = await res.json();
|
||
} catch { /* silently use zero-activity baseline */ }
|
||
|
||
// Feed commit hashes to matrix rain for data-tethered aesthetic
|
||
_matrixCommitHashes = commits.slice(0, 20).map(c => (c.sha || '').slice(0, 7)).filter(h => h.length > 0);
|
||
|
||
const now = Date.now();
|
||
const rawWeights = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0]));
|
||
|
||
for (const commit of commits) {
|
||
const author = commit.commit?.author?.name || commit.author?.login || '';
|
||
const ts = new Date(commit.commit?.author?.date || 0).getTime();
|
||
const age = now - ts;
|
||
if (age > HEATMAP_DECAY_MS) continue;
|
||
const weight = 1 - age / HEATMAP_DECAY_MS; // linear decay
|
||
|
||
for (const zone of HEATMAP_ZONES) {
|
||
if (zone.authorMatch.test(author)) {
|
||
rawWeights[zone.name] += weight;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Normalise: 8 recent weighted commits = full brightness
|
||
const MAX_WEIGHT = 8;
|
||
for (const zone of HEATMAP_ZONES) {
|
||
zoneIntensity[zone.name] = Math.min(rawWeights[zone.name] / MAX_WEIGHT, 1.0);
|
||
}
|
||
|
||
drawHeatmap();
|
||
}
|
||
|
||
// Kick off and schedule periodic refresh
|
||
updateHeatmap();
|
||
setInterval(updateHeatmap, HEATMAP_REFRESH_MS);
|
||
|
||
// === TIMMY SIGIL ===
|
||
// Animated sacred-geometry floor sigil — Metatron's Cube / Flower of Life.
|
||
// Three layers: painted canvas base + three counter-rotating glow rings.
|
||
|
||
const SIGIL_CANVAS_SIZE = 512;
|
||
const SIGIL_RADIUS = 3.8; // world-space radius (fits inside the glass platform)
|
||
|
||
/**
|
||
* Draws the Timmy sigil onto a canvas and returns it.
|
||
* Pattern: Flower of Life circles, dual hexagram triangles, concentric rings, 12-spoke radials.
|
||
* @returns {HTMLCanvasElement}
|
||
*/
|
||
function drawSigilCanvas() {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = SIGIL_CANVAS_SIZE;
|
||
canvas.height = SIGIL_CANVAS_SIZE;
|
||
const ctx = canvas.getContext('2d');
|
||
const cx = SIGIL_CANVAS_SIZE / 2;
|
||
const cy = SIGIL_CANVAS_SIZE / 2;
|
||
const r = cx * 0.88; // outer drawing radius
|
||
|
||
ctx.clearRect(0, 0, SIGIL_CANVAS_SIZE, SIGIL_CANVAS_SIZE);
|
||
|
||
// Faint radial background glow
|
||
const bgGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||
bgGrad.addColorStop(0, 'rgba(0, 200, 255, 0.10)');
|
||
bgGrad.addColorStop(0.5, 'rgba(0, 100, 200, 0.04)');
|
||
bgGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||
ctx.fillStyle = bgGrad;
|
||
ctx.fillRect(0, 0, SIGIL_CANVAS_SIZE, SIGIL_CANVAS_SIZE);
|
||
|
||
/** Draws a single circle outline with glow. */
|
||
function glowCircle(x, y, radius, color, alpha, lineW) {
|
||
ctx.save();
|
||
ctx.globalAlpha = alpha;
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = lineW;
|
||
ctx.shadowColor = color;
|
||
ctx.shadowBlur = 12;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
/** Draws two interlocking triangles (Star of David). */
|
||
function hexagram(ox, oy, hr, color, alpha) {
|
||
ctx.save();
|
||
ctx.globalAlpha = alpha;
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 1.4;
|
||
ctx.shadowColor = color;
|
||
ctx.shadowBlur = 10;
|
||
// Upward-pointing triangle
|
||
ctx.beginPath();
|
||
for (let i = 0; i < 3; i++) {
|
||
const a = (i / 3) * Math.PI * 2 - Math.PI / 2;
|
||
const px = ox + Math.cos(a) * hr;
|
||
const py = oy + Math.sin(a) * hr;
|
||
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||
}
|
||
ctx.closePath();
|
||
ctx.stroke();
|
||
// Downward-pointing triangle
|
||
ctx.beginPath();
|
||
for (let i = 0; i < 3; i++) {
|
||
const a = (i / 3) * Math.PI * 2 + Math.PI / 2;
|
||
const px = ox + Math.cos(a) * hr;
|
||
const py = oy + Math.sin(a) * hr;
|
||
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||
}
|
||
ctx.closePath();
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
const petalR = r * 0.32; // radius of each Flower-of-Life petal
|
||
|
||
// Flower of Life — center circle
|
||
glowCircle(cx, cy, petalR, '#00ccff', 0.65, 1.0);
|
||
|
||
// First ring of 6 petals
|
||
for (let i = 0; i < 6; i++) {
|
||
const a = (i / 6) * Math.PI * 2;
|
||
glowCircle(cx + Math.cos(a) * petalR, cy + Math.sin(a) * petalR, petalR, '#00aadd', 0.50, 0.8);
|
||
}
|
||
|
||
// Second ring of 6 petals (rotated 30°)
|
||
for (let i = 0; i < 6; i++) {
|
||
const a = (i / 6) * Math.PI * 2 + Math.PI / 6;
|
||
glowCircle(cx + Math.cos(a) * petalR * 1.73, cy + Math.sin(a) * petalR * 1.73, petalR, '#0077aa', 0.25, 0.6);
|
||
}
|
||
|
||
// Dual hexagram — outer (gold) and inner (amber)
|
||
hexagram(cx, cy, r * 0.62, '#ffd700', 0.75);
|
||
hexagram(cx, cy, r * 0.41, '#ffaa00', 0.50);
|
||
|
||
// Concentric rings
|
||
glowCircle(cx, cy, r * 0.92, '#0055aa', 0.40, 0.8);
|
||
glowCircle(cx, cy, r * 0.72, '#0099cc', 0.38, 0.8);
|
||
glowCircle(cx, cy, r * 0.52, '#00ccff', 0.42, 0.9);
|
||
glowCircle(cx, cy, r * 0.18, '#ffd700', 0.65, 1.2);
|
||
|
||
// 12-spoke radial lines
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.28;
|
||
ctx.strokeStyle = '#00aaff';
|
||
ctx.lineWidth = 0.6;
|
||
ctx.shadowColor = '#00aaff';
|
||
ctx.shadowBlur = 5;
|
||
for (let i = 0; i < 12; i++) {
|
||
const a = (i / 12) * Math.PI * 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx + Math.cos(a) * r * 0.18, cy + Math.sin(a) * r * 0.18);
|
||
ctx.lineTo(cx + Math.cos(a) * r * 0.91, cy + Math.sin(a) * r * 0.91);
|
||
ctx.stroke();
|
||
}
|
||
ctx.restore();
|
||
|
||
// Dot marks at spoke tips
|
||
ctx.save();
|
||
ctx.fillStyle = '#00ffcc';
|
||
ctx.shadowColor = '#00ffcc';
|
||
ctx.shadowBlur = 9;
|
||
for (let i = 0; i < 12; i++) {
|
||
const a = (i / 12) * Math.PI * 2;
|
||
ctx.globalAlpha = i % 2 === 0 ? 0.80 : 0.50;
|
||
ctx.beginPath();
|
||
ctx.arc(cx + Math.cos(a) * r * 0.91, cy + Math.sin(a) * r * 0.91, i % 2 === 0 ? 4 : 2.5, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
ctx.restore();
|
||
|
||
// Center point of light
|
||
ctx.save();
|
||
ctx.globalAlpha = 1.0;
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.shadowColor = '#88ddff';
|
||
ctx.shadowBlur = 18;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
|
||
return canvas;
|
||
}
|
||
|
||
const sigilTexture = new THREE.CanvasTexture(drawSigilCanvas());
|
||
|
||
const sigilMat = new THREE.MeshBasicMaterial({
|
||
map: sigilTexture,
|
||
transparent: true,
|
||
opacity: 0.80,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
side: THREE.DoubleSide,
|
||
});
|
||
|
||
const sigilMesh = new THREE.Mesh(
|
||
new THREE.CircleGeometry(SIGIL_RADIUS, 128),
|
||
sigilMat
|
||
);
|
||
sigilMesh.rotation.x = -Math.PI / 2;
|
||
sigilMesh.position.y = 0.010;
|
||
sigilMesh.userData.zoomLabel = 'Timmy Sigil';
|
||
scene.add(sigilMesh);
|
||
|
||
// Outer glow ring — rotates clockwise
|
||
const sigilRing1Mat = new THREE.MeshBasicMaterial({
|
||
color: 0x00ccff,
|
||
transparent: true,
|
||
opacity: 0.45,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
});
|
||
const sigilRing1 = new THREE.Mesh(
|
||
new THREE.TorusGeometry(SIGIL_RADIUS * 0.965, 0.025, 6, 96),
|
||
sigilRing1Mat
|
||
);
|
||
sigilRing1.rotation.x = Math.PI / 2;
|
||
sigilRing1.position.y = 0.012;
|
||
scene.add(sigilRing1);
|
||
|
||
// Middle ring — rotates counter-clockwise, gold
|
||
const sigilRing2Mat = new THREE.MeshBasicMaterial({
|
||
color: 0xffd700,
|
||
transparent: true,
|
||
opacity: 0.40,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
});
|
||
const sigilRing2 = new THREE.Mesh(
|
||
new THREE.TorusGeometry(SIGIL_RADIUS * 0.62, 0.020, 6, 72),
|
||
sigilRing2Mat
|
||
);
|
||
sigilRing2.rotation.x = Math.PI / 2;
|
||
sigilRing2.position.y = 0.013;
|
||
scene.add(sigilRing2);
|
||
|
||
// Inner ring — rotates clockwise, teal
|
||
const sigilRing3Mat = new THREE.MeshBasicMaterial({
|
||
color: 0x00ffcc,
|
||
transparent: true,
|
||
opacity: 0.35,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
});
|
||
const sigilRing3 = new THREE.Mesh(
|
||
new THREE.TorusGeometry(SIGIL_RADIUS * 0.78, 0.018, 6, 80),
|
||
sigilRing3Mat
|
||
);
|
||
sigilRing3.rotation.x = Math.PI / 2;
|
||
sigilRing3.position.y = 0.011;
|
||
scene.add(sigilRing3);
|
||
|
||
// Subtle floor glow light below the sigil
|
||
const sigilLight = new THREE.PointLight(0x0088ff, 0.4, 8);
|
||
sigilLight.position.set(0, 0.5, 0);
|
||
scene.add(sigilLight);
|
||
|
||
// === MOUSE-DRIVEN ROTATION ===
|
||
let mouseX = 0;
|
||
let mouseY = 0;
|
||
let targetRotX = 0;
|
||
let targetRotY = 0;
|
||
|
||
document.addEventListener('mousemove', (/** @type {MouseEvent} */ e) => {
|
||
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();
|
||
}
|
||
}
|
||
});
|
||
|
||
// === ANIMATION FOR ENERGY BEAM PULSE ===
|
||
let energyBeamPulse = 0;
|
||
|
||
function animateEnergyBeam() {
|
||
energyBeamPulse += 0.02;
|
||
// Tether beam intensity to active agent count: 0=faint, 1=0.4, 2=0.7, 3+=1.0
|
||
const agentIntensity = _activeAgentCount === 0 ? 0.1 : Math.min(0.1 + _activeAgentCount * 0.3, 1.0);
|
||
const pulseEffect = Math.sin(energyBeamPulse) * 0.15 * agentIntensity;
|
||
energyBeamMaterial.opacity = agentIntensity * 0.6 + pulseEffect;
|
||
}
|
||
|
||
// === RESIZE HANDLER ===
|
||
|
||
window.addEventListener('resize', () => {
|
||
camera.aspect = window.innerWidth / window.innerHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
composer.setSize(window.innerWidth, window.innerHeight);
|
||
});
|
||
|
||
// === SOVEREIGNTY METER ===
|
||
|
||
// Holographic arc gauge floating above the platform; reads from sovereignty-status.json
|
||
const sovereigntyGroup = new THREE.Group();
|
||
sovereigntyGroup.position.set(0, 3.8, 0);
|
||
|
||
// Background ring — full circle, dark frame
|
||
const meterBgGeo = new THREE.TorusGeometry(1.6, 0.1, 8, 64);
|
||
const meterBgMat = new THREE.MeshBasicMaterial({ color: 0x0a1828, transparent: true, opacity: 0.5 });
|
||
sovereigntyGroup.add(new THREE.Mesh(meterBgGeo, meterBgMat));
|
||
|
||
let sovereigntyScore = 85;
|
||
let sovereigntyLabel = 'Mostly Sovereign';
|
||
|
||
function sovereigntyHexColor(score) {
|
||
if (score >= 80) return 0x00ff88;
|
||
if (score >= 40) return 0xffcc00;
|
||
return 0xff4444;
|
||
}
|
||
|
||
function buildScoreArcGeo(score) {
|
||
return new THREE.TorusGeometry(1.6, 0.1, 8, 64, (score / 100) * Math.PI * 2);
|
||
}
|
||
|
||
const scoreArcMat = new THREE.MeshBasicMaterial({
|
||
color: sovereigntyHexColor(sovereigntyScore),
|
||
transparent: true,
|
||
opacity: 0.9,
|
||
});
|
||
const scoreArcMesh = new THREE.Mesh(buildScoreArcGeo(sovereigntyScore), scoreArcMat);
|
||
scoreArcMesh.rotation.z = Math.PI / 2; // arc starts at 12 o'clock
|
||
sovereigntyGroup.add(scoreArcMesh);
|
||
|
||
// Glow light at gauge center
|
||
const meterLight = new THREE.PointLight(sovereigntyHexColor(sovereigntyScore), 0.7, 6);
|
||
sovereigntyGroup.add(meterLight);
|
||
|
||
function buildMeterTexture(score, label, assessmentType) {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 256;
|
||
canvas.height = 128;
|
||
const ctx = canvas.getContext('2d');
|
||
const hexStr = score >= 80 ? '#00ff88' : score >= 40 ? '#ffcc00' : '#ff4444';
|
||
ctx.clearRect(0, 0, 256, 128);
|
||
ctx.font = 'bold 52px "Courier New", monospace';
|
||
ctx.fillStyle = hexStr;
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(`${score}%`, 128, 50);
|
||
ctx.font = '16px "Courier New", monospace';
|
||
ctx.fillStyle = '#8899bb';
|
||
ctx.fillText(label.toUpperCase(), 128, 74);
|
||
ctx.font = '11px "Courier New", monospace';
|
||
ctx.fillStyle = '#445566';
|
||
ctx.fillText('SOVEREIGNTY', 128, 94);
|
||
// "MANUAL ASSESSMENT" label — honest about data source
|
||
ctx.font = '9px "Courier New", monospace';
|
||
ctx.fillStyle = '#334455';
|
||
ctx.fillText(assessmentType === 'MANUAL' ? 'MANUAL ASSESSMENT' : 'MANUAL ASSESSMENT', 128, 112);
|
||
return new THREE.CanvasTexture(canvas);
|
||
}
|
||
|
||
const meterSpriteMat = new THREE.SpriteMaterial({
|
||
map: buildMeterTexture(sovereigntyScore, sovereigntyLabel, 'MANUAL'),
|
||
transparent: true,
|
||
depthWrite: false,
|
||
});
|
||
const meterSprite = new THREE.Sprite(meterSpriteMat);
|
||
meterSprite.scale.set(3.2, 1.6, 1);
|
||
sovereigntyGroup.add(meterSprite);
|
||
|
||
scene.add(sovereigntyGroup);
|
||
sovereigntyGroup.traverse(obj => {
|
||
if (obj.isMesh || obj.isSprite) obj.userData.zoomLabel = 'Sovereignty Meter';
|
||
});
|
||
|
||
async function loadSovereigntyStatus() {
|
||
try {
|
||
const res = await fetch('./sovereignty-status.json');
|
||
if (!res.ok) throw new Error('not found');
|
||
const data = await res.json();
|
||
const score = Math.max(0, Math.min(100, typeof data.score === 'number' ? data.score : 85));
|
||
const label = typeof data.label === 'string' ? data.label : '';
|
||
sovereigntyScore = score;
|
||
sovereigntyLabel = label;
|
||
scoreArcMesh.geometry.dispose();
|
||
scoreArcMesh.geometry = buildScoreArcGeo(score);
|
||
const col = sovereigntyHexColor(score);
|
||
scoreArcMat.color.setHex(col);
|
||
meterLight.color.setHex(col);
|
||
if (meterSpriteMat.map) meterSpriteMat.map.dispose();
|
||
const assessmentType = data.assessment_type || 'MANUAL';
|
||
meterSpriteMat.map = buildMeterTexture(score, label, assessmentType);
|
||
meterSpriteMat.needsUpdate = true;
|
||
} catch {
|
||
// defaults already set above
|
||
}
|
||
}
|
||
|
||
loadSovereigntyStatus();
|
||
|
||
// === ENERGY BEAM FOR BATCAVE TERMINAL ===
|
||
// Vertical energy beam from Batcave terminal area — intensity tethered to active agent count.
|
||
let _activeAgentCount = 0; // updated by agent status fetch
|
||
const ENERGY_BEAM_RADIUS = 0.2;
|
||
const ENERGY_BEAM_HEIGHT = 50;
|
||
const ENERGY_BEAM_Y = 0;
|
||
const ENERGY_BEAM_X = -10;
|
||
const ENERGY_BEAM_Z = -10;
|
||
|
||
const energyBeamGeometry = new THREE.CylinderGeometry(ENERGY_BEAM_RADIUS, ENERGY_BEAM_RADIUS * 2.5, ENERGY_BEAM_HEIGHT, 32, 16, true);
|
||
const energyBeamMaterial = new THREE.MeshBasicMaterial({
|
||
color: NEXUS.colors.accent,
|
||
emissive: NEXUS.colors.accent,
|
||
emissiveIntensity: 0.8,
|
||
transparent: true,
|
||
opacity: 0.6,
|
||
blending: THREE.AdditiveBlending,
|
||
side: THREE.DoubleSide,
|
||
depthWrite: false
|
||
});
|
||
const energyBeam = new THREE.Mesh(energyBeamGeometry, energyBeamMaterial);
|
||
energyBeam.position.set(ENERGY_BEAM_X, ENERGY_BEAM_Y + ENERGY_BEAM_HEIGHT / 2, ENERGY_BEAM_Z);
|
||
scene.add(energyBeam);
|
||
|
||
|
||
// === RUNE RING ===
|
||
// Rune sprites tethered to portal data — count matches portals, colors from portals.json.
|
||
|
||
let RUNE_COUNT = 12; // default, updated when portals load
|
||
const RUNE_RING_RADIUS = 7.0;
|
||
const RUNE_RING_Y = 1.5; // base height above platform
|
||
const RUNE_ORBIT_SPEED = 0.08; // radians per second
|
||
|
||
const ELDER_FUTHARK = ['ᚠ','ᚢ','ᚦ','ᚨ','ᚱ','ᚲ','ᚷ','ᚹ','ᚺ','ᚾ','ᛁ','ᛃ'];
|
||
const RUNE_GLOW_COLORS = ['#00ffcc', '#ff44ff']; // fallback, overridden by portal colors
|
||
|
||
/**
|
||
* Creates a canvas texture for a single glowing rune glyph.
|
||
* @param {string} glyph
|
||
* @param {string} color
|
||
* @returns {THREE.CanvasTexture}
|
||
*/
|
||
function createRuneTexture(glyph, color) {
|
||
const W = 128, H = 128;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = W;
|
||
canvas.height = H;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
// Outer glow
|
||
ctx.shadowColor = color;
|
||
ctx.shadowBlur = 28;
|
||
|
||
ctx.font = 'bold 78px serif';
|
||
ctx.fillStyle = color;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(glyph, W / 2, H / 2);
|
||
|
||
return new THREE.CanvasTexture(canvas);
|
||
}
|
||
|
||
// Faint torus marking the orbit height
|
||
const runeOrbitRingGeo = new THREE.TorusGeometry(RUNE_RING_RADIUS, 0.03, 6, 64);
|
||
const runeOrbitRingMat = new THREE.MeshBasicMaterial({
|
||
color: 0x224466,
|
||
transparent: true,
|
||
opacity: 0.22,
|
||
});
|
||
const runeOrbitRingMesh = new THREE.Mesh(runeOrbitRingGeo, runeOrbitRingMat);
|
||
runeOrbitRingMesh.rotation.x = Math.PI / 2;
|
||
runeOrbitRingMesh.position.y = RUNE_RING_Y;
|
||
scene.add(runeOrbitRingMesh);
|
||
|
||
/**
|
||
* @type {Array<{sprite: THREE.Sprite, baseAngle: number, floatPhase: number, portalOnline: boolean}>}
|
||
*/
|
||
const runeSprites = [];
|
||
|
||
/**
|
||
* Rebuilds rune ring from portal data — count matches portals, colors from portals.json.
|
||
* Falls back to default 12 runes if portals not yet loaded.
|
||
*/
|
||
function rebuildRuneRing() {
|
||
// Remove existing rune sprites
|
||
for (const rune of runeSprites) {
|
||
scene.remove(rune.sprite);
|
||
if (rune.sprite.material.map) rune.sprite.material.map.dispose();
|
||
rune.sprite.material.dispose();
|
||
}
|
||
runeSprites.length = 0;
|
||
|
||
const portalData = portals.length > 0 ? portals : null;
|
||
const count = portalData ? portalData.length : RUNE_COUNT;
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const glyph = ELDER_FUTHARK[i % ELDER_FUTHARK.length];
|
||
const color = portalData ? portalData[i].color : RUNE_GLOW_COLORS[i % RUNE_GLOW_COLORS.length];
|
||
const isOnline = portalData ? portalData[i].status === 'online' : true;
|
||
const texture = createRuneTexture(glyph, color);
|
||
|
||
const runeMat = new THREE.SpriteMaterial({
|
||
map: texture,
|
||
transparent: true,
|
||
opacity: isOnline ? 1.0 : 0.15, // bright if online, dim if offline
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
});
|
||
const sprite = new THREE.Sprite(runeMat);
|
||
sprite.scale.set(1.3, 1.3, 1);
|
||
|
||
const baseAngle = (i / count) * Math.PI * 2;
|
||
sprite.position.set(
|
||
Math.cos(baseAngle) * RUNE_RING_RADIUS,
|
||
RUNE_RING_Y,
|
||
Math.sin(baseAngle) * RUNE_RING_RADIUS
|
||
);
|
||
scene.add(sprite);
|
||
runeSprites.push({ sprite, baseAngle, floatPhase: (i / count) * Math.PI * 2, portalOnline: isOnline });
|
||
}
|
||
}
|
||
|
||
// Initial build with default count (will be rebuilt when portals load)
|
||
rebuildRuneRing();
|
||
|
||
|
||
// === HOLOGRAPHIC EARTH ===
|
||
// A procedural holographic planet Earth slowly rotating above the Nexus.
|
||
// Continents rendered via layered simplex noise on UV-sphere coords.
|
||
// Holographic effects: scan lines, fresnel rim glow, lat/lon grid overlay.
|
||
|
||
const EARTH_RADIUS = 2.8;
|
||
const EARTH_Y = 20.0;
|
||
const EARTH_ROTATION_SPEED = 0.035; // radians per second — gentle drift
|
||
const EARTH_AXIAL_TILT = 23.4 * (Math.PI / 180);
|
||
|
||
const earthGroup = new THREE.Group();
|
||
earthGroup.position.set(0, EARTH_Y, 0);
|
||
earthGroup.rotation.z = EARTH_AXIAL_TILT;
|
||
scene.add(earthGroup);
|
||
|
||
// Surface shader — continents via noise, holographic scan lines, fresnel rim
|
||
const earthSurfaceMat = new THREE.ShaderMaterial({
|
||
uniforms: {
|
||
uTime: { value: 0.0 },
|
||
uOceanColor: { value: new THREE.Color(0x003d99) },
|
||
uLandColor: { value: new THREE.Color(0x1a5c2a) },
|
||
uGlowColor: { value: new THREE.Color(NEXUS.colors.accent) },
|
||
},
|
||
vertexShader: `
|
||
varying vec3 vNormal;
|
||
varying vec3 vWorldPos;
|
||
varying vec2 vUv;
|
||
void main() {
|
||
vNormal = normalize(normalMatrix * normal);
|
||
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
|
||
vUv = uv;
|
||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||
}
|
||
`,
|
||
fragmentShader: `
|
||
uniform float uTime;
|
||
uniform vec3 uOceanColor;
|
||
uniform vec3 uLandColor;
|
||
uniform vec3 uGlowColor;
|
||
varying vec3 vNormal;
|
||
varying vec3 vWorldPos;
|
||
varying vec2 vUv;
|
||
|
||
// Simplex noise (3-D)
|
||
vec3 _m3(vec3 x){ return x - floor(x*(1./289.))*289.; }
|
||
vec4 _m4(vec4 x){ return x - floor(x*(1./289.))*289.; }
|
||
vec4 _p4(vec4 x){ return _m4((x*34.+1.)*x); }
|
||
float snoise(vec3 v){
|
||
const vec2 C = vec2(1./6., 1./3.);
|
||
vec3 i = floor(v + dot(v, C.yyy));
|
||
vec3 x0 = v - i + dot(i, C.xxx);
|
||
vec3 g = step(x0.yzx, x0.xyz);
|
||
vec3 l = 1.0 - g;
|
||
vec3 i1 = min(g.xyz, l.zxy);
|
||
vec3 i2 = max(g.xyz, l.zxy);
|
||
vec3 x1 = x0 - i1 + C.xxx;
|
||
vec3 x2 = x0 - i2 + C.yyy;
|
||
vec3 x3 = x0 - 0.5;
|
||
i = _m3(i);
|
||
vec4 p = _p4(_p4(_p4(
|
||
i.z+vec4(0.,i1.z,i2.z,1.))+
|
||
i.y+vec4(0.,i1.y,i2.y,1.))+
|
||
i.x+vec4(0.,i1.x,i2.x,1.));
|
||
float n_ = .142857142857;
|
||
vec3 ns = n_*vec3(2.,0.,-1.)+vec3(0.,-.5,1.);
|
||
vec4 j = p - 49.*floor(p*ns.z*ns.z);
|
||
vec4 x_ = floor(j*ns.z);
|
||
vec4 y_ = floor(j - 7.*x_);
|
||
vec4 h = 1. - abs(x_*(2./7.)) - abs(y_*(2./7.));
|
||
vec4 b0 = vec4(x_.xy,y_.xy)*(2./7.);
|
||
vec4 b1 = vec4(x_.zw,y_.zw)*(2./7.);
|
||
vec4 s0 = floor(b0)*2.+1.; vec4 s1 = floor(b1)*2.+1.;
|
||
vec4 sh = -step(h, vec4(0.));
|
||
vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy;
|
||
vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww;
|
||
vec3 p0=vec3(a0.xy,h.x); vec3 p1=vec3(a0.zw,h.y);
|
||
vec3 p2=vec3(a1.xy,h.z); vec3 p3=vec3(a1.zw,h.w);
|
||
vec4 nm = max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.);
|
||
vec4 nr = 1.79284291400159-0.85373472095314*nm;
|
||
p0*=nr.x; p1*=nr.y; p2*=nr.z; p3*=nr.w;
|
||
nm = nm*nm;
|
||
return 42.*dot(nm*nm, vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
|
||
}
|
||
|
||
void main() {
|
||
vec3 n = normalize(vNormal);
|
||
vec3 vd = normalize(cameraPosition - vWorldPos);
|
||
|
||
// Stable spherical sample coords from UV (fixed to sphere surface)
|
||
float lat = (vUv.y - 0.5) * 3.14159265;
|
||
float lon = vUv.x * 6.28318530;
|
||
vec3 sp = vec3(cos(lat)*cos(lon), sin(lat), cos(lat)*sin(lon));
|
||
|
||
// Layered noise for continent shapes
|
||
float c = snoise(sp*1.8)*0.60
|
||
+ snoise(sp*3.6)*0.30
|
||
+ snoise(sp*7.2)*0.10;
|
||
float land = smoothstep(0.05, 0.30, c);
|
||
|
||
// Surface colour: ocean / land, holographic-tinted
|
||
vec3 surf = mix(uOceanColor, uLandColor, land);
|
||
surf = mix(surf, uGlowColor * 0.45, 0.38);
|
||
|
||
// Scan lines (horizontal bands)
|
||
float scan = 0.5 + 0.5*sin(vUv.y * 220.0 + uTime * 1.8);
|
||
scan = smoothstep(0.30, 0.70, scan) * 0.14;
|
||
|
||
// Fresnel rim glow
|
||
float fresnel = pow(1.0 - max(dot(n, vd), 0.0), 4.0);
|
||
|
||
vec3 col = surf + scan*uGlowColor*0.9 + fresnel*uGlowColor*1.5;
|
||
float alpha = 0.48 + fresnel * 0.42;
|
||
|
||
gl_FragColor = vec4(col, alpha);
|
||
}
|
||
`,
|
||
transparent: true,
|
||
depthWrite: false,
|
||
side: THREE.FrontSide,
|
||
});
|
||
|
||
const earthSphere = new THREE.SphereGeometry(EARTH_RADIUS, 64, 32);
|
||
const earthMesh = new THREE.Mesh(earthSphere, earthSurfaceMat);
|
||
earthMesh.userData.zoomLabel = 'Planet Earth';
|
||
earthGroup.add(earthMesh);
|
||
|
||
// Lat/lon grid lines
|
||
(function buildEarthGrid() {
|
||
const lineMat = new THREE.LineBasicMaterial({
|
||
color: 0x2266bb,
|
||
transparent: true,
|
||
opacity: 0.30,
|
||
});
|
||
const r = EARTH_RADIUS + 0.015;
|
||
const SEG = 64;
|
||
|
||
// Latitude rings every 30°
|
||
for (let lat = -60; lat <= 60; lat += 30) {
|
||
const phi = lat * (Math.PI / 180);
|
||
const pts = [];
|
||
for (let i = 0; i <= SEG; i++) {
|
||
const th = (i / SEG) * Math.PI * 2;
|
||
pts.push(new THREE.Vector3(
|
||
Math.cos(phi) * Math.cos(th) * r,
|
||
Math.sin(phi) * r,
|
||
Math.cos(phi) * Math.sin(th) * r
|
||
));
|
||
}
|
||
earthGroup.add(new THREE.Line(
|
||
new THREE.BufferGeometry().setFromPoints(pts), lineMat
|
||
));
|
||
}
|
||
|
||
// Longitude meridians every 30°
|
||
for (let lon = 0; lon < 360; lon += 30) {
|
||
const th = lon * (Math.PI / 180);
|
||
const pts = [];
|
||
for (let i = 0; i <= SEG; i++) {
|
||
const phi = (i / SEG) * Math.PI - Math.PI / 2;
|
||
pts.push(new THREE.Vector3(
|
||
Math.cos(phi) * Math.cos(th) * r,
|
||
Math.sin(phi) * r,
|
||
Math.cos(phi) * Math.sin(th) * r
|
||
));
|
||
}
|
||
earthGroup.add(new THREE.Line(
|
||
new THREE.BufferGeometry().setFromPoints(pts), lineMat
|
||
));
|
||
}
|
||
})();
|
||
|
||
// Atmosphere shell — soft additive glow around the globe
|
||
const atmMat = new THREE.MeshBasicMaterial({
|
||
color: 0x1144cc,
|
||
transparent: true,
|
||
opacity: 0.07,
|
||
side: THREE.BackSide,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
});
|
||
earthGroup.add(new THREE.Mesh(
|
||
new THREE.SphereGeometry(EARTH_RADIUS * 1.14, 32, 16), atmMat
|
||
));
|
||
|
||
// Soft blue point light emanating from Earth
|
||
const earthGlowLight = new THREE.PointLight(NEXUS.colors.accent, 0.4, 25);
|
||
earthGroup.add(earthGlowLight);
|
||
|
||
earthGroup.traverse(obj => {
|
||
if (obj.isMesh || obj.isLine) obj.userData.zoomLabel = 'Planet Earth';
|
||
});
|
||
|
||
// Tether beam — faint line connecting Earth to the Nexus platform center
|
||
(function buildEarthTetherBeam() {
|
||
const pts = [
|
||
new THREE.Vector3(0, EARTH_Y - EARTH_RADIUS * 1.15, 0),
|
||
new THREE.Vector3(0, 0.5, 0),
|
||
];
|
||
const beamGeo = new THREE.BufferGeometry().setFromPoints(pts);
|
||
const beamMat = new THREE.LineBasicMaterial({
|
||
color: NEXUS.colors.accent,
|
||
transparent: true,
|
||
opacity: 0.08,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
});
|
||
scene.add(new THREE.Line(beamGeo, beamMat));
|
||
})();
|
||
|
||
// === WARP TUNNEL EFFECT ===
|
||
const WarpShader = {
|
||
uniforms: {
|
||
'tDiffuse': { value: null },
|
||
'time': { value: 0.0 },
|
||
'progress': { value: 0.0 },
|
||
'portalColor': { value: new THREE.Color(0x4488ff) },
|
||
},
|
||
|
||
vertexShader: `
|
||
varying vec2 vUv;
|
||
void main() {
|
||
vUv = uv;
|
||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||
}
|
||
`,
|
||
|
||
fragmentShader: `
|
||
uniform sampler2D tDiffuse;
|
||
uniform float time;
|
||
uniform float progress;
|
||
uniform vec3 portalColor;
|
||
varying vec2 vUv;
|
||
|
||
#define PI 3.14159265358979
|
||
|
||
void main() {
|
||
vec2 uv = vUv;
|
||
vec2 center = vec2(0.5, 0.5);
|
||
vec2 dir = uv - center;
|
||
float dist = length(dir);
|
||
float angle = atan(dir.y, dir.x);
|
||
|
||
// Bell-curve intensity peaks at progress=0.5
|
||
float intensity = sin(progress * PI);
|
||
|
||
// === ZOOM: pull scene into the vortex mouth ===
|
||
float zoom = 1.0 + intensity * 3.0;
|
||
vec2 zoomedUV = center + dir / zoom;
|
||
|
||
// === SWIRL: spiral twist increasing with intensity ===
|
||
float swirl = intensity * 5.0 * max(0.0, 1.0 - dist * 2.0);
|
||
float twisted = angle + swirl;
|
||
vec2 swirlUV = center + vec2(cos(twisted), sin(twisted)) * dist / (1.0 + intensity * 1.8);
|
||
|
||
// Blend zoom and swirl
|
||
vec2 warpUV = mix(zoomedUV, swirlUV, 0.6);
|
||
warpUV = clamp(warpUV, vec2(0.001), vec2(0.999));
|
||
|
||
// === CHROMATIC ABERRATION at peak ===
|
||
float aber = intensity * 0.018;
|
||
vec2 aberDir = normalize(dir + vec2(0.001));
|
||
float rVal = texture2D(tDiffuse, clamp(warpUV + aberDir * aber, vec2(0.0), vec2(1.0))).r;
|
||
float gVal = texture2D(tDiffuse, warpUV).g;
|
||
float bVal = texture2D(tDiffuse, clamp(warpUV - aberDir * aber, vec2(0.0), vec2(1.0))).b;
|
||
vec4 color = vec4(rVal, gVal, bVal, 1.0);
|
||
|
||
// === SPEED LINES: radial streaks flying past ===
|
||
float numLines = 28.0;
|
||
float lineAngleFrac = fract((angle / (2.0 * PI) + 0.5) * numLines + time * 4.0);
|
||
float lineSharp = pow(max(0.0, 1.0 - abs(lineAngleFrac - 0.5) * 16.0), 3.0);
|
||
float radialFade = max(0.0, 1.0 - dist * 2.2);
|
||
float speedLine = lineSharp * radialFade * intensity * 1.8;
|
||
|
||
// Secondary slower counter-rotating streaks
|
||
float lineAngleFrac2 = fract((angle / (2.0 * PI) + 0.5) * 14.0 - time * 2.5);
|
||
float lineSharp2 = pow(max(0.0, 1.0 - abs(lineAngleFrac2 - 0.5) * 12.0), 3.0);
|
||
float speedLine2 = lineSharp2 * radialFade * intensity * 0.9;
|
||
|
||
// === TUNNEL RIM GLOW: bright ring at vortex edge ===
|
||
float rimDist = abs(dist - 0.08 * intensity);
|
||
float rimGlow = pow(max(0.0, 1.0 - rimDist * 40.0), 2.0) * intensity;
|
||
|
||
// === PORTAL COLOR TINT ===
|
||
color.rgb = mix(color.rgb, portalColor, intensity * 0.45);
|
||
|
||
// Speed lines in portal color
|
||
color.rgb += portalColor * (speedLine + speedLine2);
|
||
color.rgb += vec3(1.0) * rimGlow * 0.8;
|
||
|
||
// === VORTEX CENTER BLOOM ===
|
||
float bloom = pow(max(0.0, 1.0 - dist / (0.18 * intensity + 0.001)), 2.0) * intensity;
|
||
color.rgb += portalColor * bloom * 2.5 + vec3(1.0) * bloom * 0.6;
|
||
|
||
// === EDGE DARKNESS (tunnel walls) ===
|
||
float vignette = smoothstep(0.5, 0.2, dist) * intensity * 0.5;
|
||
color.rgb *= 1.0 - vignette * 0.4;
|
||
|
||
// === WHITE FLASH at the moment of crossing ===
|
||
float flash = smoothstep(0.82, 1.0, progress);
|
||
color.rgb = mix(color.rgb, vec3(1.0), flash);
|
||
|
||
gl_FragColor = color;
|
||
}
|
||
`,
|
||
};
|
||
|
||
let warpPass = new ShaderPass(WarpShader);
|
||
warpPass.enabled = false;
|
||
composer.addPass(warpPass);
|
||
|
||
|
||
/**
|
||
* Triggers the warp tunnel effect.
|
||
* @param {THREE.Mesh|null} portalMesh - The portal mesh being entered (for color + URL)
|
||
*/
|
||
function startWarp(portalMesh) {
|
||
isWarping = true;
|
||
warpNavigated = false;
|
||
warpStartTime = clock.getElapsedTime();
|
||
warpPass.enabled = true;
|
||
warpPass.uniforms['time'].value = 0.0;
|
||
warpPass.uniforms['progress'].value = 0.0;
|
||
|
||
if (portalMesh) {
|
||
warpDestinationUrl = portalMesh.userData.destinationUrl || null;
|
||
warpPortalColor = portalMesh.userData.portalColor
|
||
? portalMesh.userData.portalColor.clone()
|
||
: new THREE.Color(0x4488ff);
|
||
} else {
|
||
warpDestinationUrl = null;
|
||
warpPortalColor = new THREE.Color(0x4488ff);
|
||
}
|
||
warpPass.uniforms['portalColor'].value = warpPortalColor;
|
||
}
|
||
|
||
// === FLOATING CRYSTALS & LIGHTNING ARCS ===
|
||
// Crystals float above the platform. When zone activity is high, lightning arcs jump between them.
|
||
|
||
const CRYSTAL_COUNT = 5;
|
||
const CRYSTAL_BASE_POSITIONS = [
|
||
new THREE.Vector3(-4.5, 3.2, -3.8),
|
||
new THREE.Vector3( 4.8, 2.8, -4.0),
|
||
new THREE.Vector3(-5.5, 4.0, 1.5),
|
||
new THREE.Vector3( 5.2, 3.5, 2.0),
|
||
new THREE.Vector3( 0.0, 5.0, -5.5),
|
||
];
|
||
// Colors aligned to agent zones: Claude, Timmy, Kimi, Perplexity, center
|
||
const CRYSTAL_COLORS = [0xff6440, 0x40a0ff, 0x40ff8c, 0xc840ff, 0xffd700];
|
||
|
||
const crystalGroup = new THREE.Group();
|
||
scene.add(crystalGroup);
|
||
|
||
/**
|
||
* @type {Array<{mesh: THREE.Mesh, light: THREE.PointLight, basePos: THREE.Vector3, floatPhase: number}>}
|
||
*/
|
||
const crystals = [];
|
||
|
||
for (let i = 0; i < CRYSTAL_COUNT; i++) {
|
||
const geo = new THREE.OctahedronGeometry(0.35, 0);
|
||
const color = CRYSTAL_COLORS[i];
|
||
const mat = new THREE.MeshStandardMaterial({
|
||
color,
|
||
emissive: new THREE.Color(color).multiplyScalar(0.6),
|
||
roughness: 0.05,
|
||
metalness: 0.3,
|
||
transparent: true,
|
||
opacity: 0.88,
|
||
});
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
const basePos = CRYSTAL_BASE_POSITIONS[i].clone();
|
||
mesh.position.copy(basePos);
|
||
mesh.userData.zoomLabel = 'Crystal';
|
||
crystalGroup.add(mesh);
|
||
|
||
const light = new THREE.PointLight(color, 0.3, 6);
|
||
light.position.copy(basePos);
|
||
crystalGroup.add(light);
|
||
|
||
crystals.push({ mesh, light, basePos, floatPhase: (i / CRYSTAL_COUNT) * Math.PI * 2, flashStartTime: -999 });
|
||
}
|
||
|
||
// ---- Lightning arc pool ----
|
||
|
||
const LIGHTNING_POOL_SIZE = 6;
|
||
const LIGHTNING_SEGMENTS = 8;
|
||
const LIGHTNING_REFRESH_MS = 130;
|
||
let lastLightningRefreshTime = 0;
|
||
|
||
/** @type {Array<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;
|
||
|
||
// === DUAL-BRAIN HOLOGRAPHIC PANEL ===
|
||
// Floating panel showing Brain Gap Scorecard with two glowing brain orbs
|
||
// connected by an animated particle stream representing knowledge transfer.
|
||
|
||
const DUAL_BRAIN_ORIGIN = new THREE.Vector3(10, 3, -8);
|
||
const dualBrainGroup = new THREE.Group();
|
||
dualBrainGroup.position.copy(DUAL_BRAIN_ORIGIN);
|
||
// Face toward the centre platform
|
||
dualBrainGroup.lookAt(0, 3, 0);
|
||
scene.add(dualBrainGroup);
|
||
|
||
// --- Canvas texture for the scorecard panel ---
|
||
function createDualBrainTexture() {
|
||
const W = 512, H = 512;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = W;
|
||
canvas.height = H;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// Dark background
|
||
ctx.fillStyle = 'rgba(0, 6, 20, 0.90)';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Outer neon border
|
||
ctx.strokeStyle = '#4488ff';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(1, 1, W - 2, H - 2);
|
||
|
||
// Inner subtle border
|
||
ctx.strokeStyle = '#223366';
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(5, 5, W - 10, H - 10);
|
||
|
||
// Title
|
||
ctx.font = 'bold 22px "Courier New", monospace';
|
||
ctx.fillStyle = '#88ccff';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('\u25C8 DUAL-BRAIN STATUS', W / 2, 40);
|
||
|
||
// Separator under title
|
||
ctx.strokeStyle = '#1a3a6a';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(20, 52);
|
||
ctx.lineTo(W - 20, 52);
|
||
ctx.stroke();
|
||
|
||
// Section header
|
||
ctx.font = '11px "Courier New", monospace';
|
||
ctx.fillStyle = '#556688';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('BRAIN GAP SCORECARD', 20, 74);
|
||
|
||
// Categories — honest offline state (no scores, empty bars)
|
||
const categories = [
|
||
{ name: 'Triage' },
|
||
{ name: 'Tool Use' },
|
||
{ name: 'Code Gen' },
|
||
{ name: 'Planning' },
|
||
{ name: 'Communication' },
|
||
{ name: 'Reasoning' },
|
||
];
|
||
|
||
const barX = 20;
|
||
const barW = W - 130;
|
||
const barH = 20;
|
||
let y = 90;
|
||
|
||
for (const cat of categories) {
|
||
// Category label
|
||
ctx.font = '13px "Courier New", monospace';
|
||
ctx.fillStyle = '#445566';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(cat.name, barX, y + 14);
|
||
|
||
// Score value — dash (no data)
|
||
ctx.font = 'bold 13px "Courier New", monospace';
|
||
ctx.fillStyle = '#334466';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText('\u2014', W - 20, y + 14);
|
||
|
||
y += 22;
|
||
|
||
// Bar background only — no fill (zero-width)
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
|
||
ctx.fillRect(barX, y, barW, barH);
|
||
|
||
y += barH + 12;
|
||
}
|
||
|
||
// Separator
|
||
ctx.strokeStyle = '#1a3a6a';
|
||
ctx.beginPath();
|
||
ctx.moveTo(20, y + 4);
|
||
ctx.lineTo(W - 20, y + 4);
|
||
ctx.stroke();
|
||
|
||
y += 22;
|
||
|
||
// Status text — honest offline
|
||
ctx.font = 'bold 18px "Courier New", monospace';
|
||
ctx.fillStyle = '#334466';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('AWAITING DEPLOYMENT', W / 2, y + 10);
|
||
|
||
ctx.font = '11px "Courier New", monospace';
|
||
ctx.fillStyle = '#223344';
|
||
ctx.fillText('Dual-brain system not yet connected', W / 2, y + 32);
|
||
|
||
// Brain indicators at bottom — dim (offline)
|
||
y += 52;
|
||
ctx.beginPath();
|
||
ctx.arc(W / 2 - 60, y + 8, 6, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#334466';
|
||
ctx.fill();
|
||
ctx.font = '11px "Courier New", monospace';
|
||
ctx.fillStyle = '#334466';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('CLOUD', W / 2 - 48, y + 12);
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(W / 2 + 30, y + 8, 6, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#334466';
|
||
ctx.fill();
|
||
ctx.fillStyle = '#334466';
|
||
ctx.fillText('LOCAL', W / 2 + 42, y + 12);
|
||
|
||
return new THREE.CanvasTexture(canvas);
|
||
}
|
||
|
||
// Panel sprite
|
||
const dualBrainTexture = createDualBrainTexture();
|
||
const dualBrainMaterial = new THREE.SpriteMaterial({
|
||
map: dualBrainTexture,
|
||
transparent: true,
|
||
opacity: 0.92,
|
||
depthWrite: false,
|
||
});
|
||
const dualBrainSprite = new THREE.Sprite(dualBrainMaterial);
|
||
dualBrainSprite.scale.set(5.0, 5.0, 1);
|
||
dualBrainSprite.position.set(0, 0, 0); // local to group
|
||
dualBrainSprite.userData = {
|
||
baseY: 0,
|
||
floatPhase: 0,
|
||
floatSpeed: 0.22,
|
||
zoomLabel: 'Dual-Brain Status',
|
||
};
|
||
dualBrainGroup.add(dualBrainSprite);
|
||
|
||
// Accent light for the panel
|
||
const dualBrainLight = new THREE.PointLight(0x4488ff, 0.6, 10);
|
||
dualBrainLight.position.set(0, 0.5, 1);
|
||
dualBrainGroup.add(dualBrainLight);
|
||
|
||
// --- Brain Orbs ---
|
||
// Cloud brain orb — dim grey (dual-brain offline)
|
||
const CLOUD_ORB_COLOR = 0x334466;
|
||
const cloudOrbGeo = new THREE.SphereGeometry(0.35, 32, 32);
|
||
const cloudOrbMat = new THREE.MeshStandardMaterial({
|
||
color: CLOUD_ORB_COLOR,
|
||
emissive: new THREE.Color(CLOUD_ORB_COLOR),
|
||
emissiveIntensity: 0.1,
|
||
metalness: 0.3,
|
||
roughness: 0.2,
|
||
transparent: true,
|
||
opacity: 0.85,
|
||
});
|
||
const cloudOrb = new THREE.Mesh(cloudOrbGeo, cloudOrbMat);
|
||
cloudOrb.position.set(-2.0, 3.0, 0);
|
||
cloudOrb.userData.zoomLabel = 'Cloud Brain';
|
||
dualBrainGroup.add(cloudOrb);
|
||
|
||
const cloudOrbLight = new THREE.PointLight(CLOUD_ORB_COLOR, 0.15, 5);
|
||
cloudOrbLight.position.copy(cloudOrb.position);
|
||
dualBrainGroup.add(cloudOrbLight);
|
||
|
||
// Local brain orb — dim grey (dual-brain offline)
|
||
const LOCAL_ORB_COLOR = 0x334466;
|
||
const localOrbGeo = new THREE.SphereGeometry(0.35, 32, 32);
|
||
const localOrbMat = new THREE.MeshStandardMaterial({
|
||
color: LOCAL_ORB_COLOR,
|
||
emissive: new THREE.Color(LOCAL_ORB_COLOR),
|
||
emissiveIntensity: 0.1,
|
||
metalness: 0.3,
|
||
roughness: 0.2,
|
||
transparent: true,
|
||
opacity: 0.85,
|
||
});
|
||
const localOrb = new THREE.Mesh(localOrbGeo, localOrbMat);
|
||
localOrb.position.set(2.0, 3.0, 0);
|
||
localOrb.userData.zoomLabel = 'Local Brain';
|
||
dualBrainGroup.add(localOrb);
|
||
|
||
const localOrbLight = new THREE.PointLight(LOCAL_ORB_COLOR, 0.15, 5);
|
||
localOrbLight.position.copy(localOrb.position);
|
||
dualBrainGroup.add(localOrbLight);
|
||
|
||
// --- Brain Pulse Particle Stream ---
|
||
// Particles OFF — dual-brain system not deployed. Will flow when system comes online.
|
||
const BRAIN_PARTICLE_COUNT = 0;
|
||
const brainParticlePositions = new Float32Array(BRAIN_PARTICLE_COUNT * 3);
|
||
const brainParticlePhases = new Float32Array(BRAIN_PARTICLE_COUNT); // 0..1 progress along arc
|
||
const brainParticleSpeeds = new Float32Array(BRAIN_PARTICLE_COUNT);
|
||
|
||
for (let i = 0; i < BRAIN_PARTICLE_COUNT; i++) {
|
||
brainParticlePhases[i] = Math.random();
|
||
brainParticleSpeeds[i] = 0.15 + Math.random() * 0.2;
|
||
// Initial positions will be set in animate()
|
||
brainParticlePositions[i * 3] = 0;
|
||
brainParticlePositions[i * 3 + 1] = 0;
|
||
brainParticlePositions[i * 3 + 2] = 0;
|
||
}
|
||
|
||
const brainParticleGeo = new THREE.BufferGeometry();
|
||
brainParticleGeo.setAttribute('position', new THREE.BufferAttribute(brainParticlePositions, 3));
|
||
|
||
const brainParticleMat = new THREE.PointsMaterial({
|
||
color: 0x44ddff,
|
||
size: 0.08,
|
||
sizeAttenuation: true,
|
||
transparent: true,
|
||
opacity: 0.8,
|
||
depthWrite: false,
|
||
});
|
||
|
||
const brainParticles = new THREE.Points(brainParticleGeo, brainParticleMat);
|
||
dualBrainGroup.add(brainParticles);
|
||
|
||
// Scanning line overlay canvas — redrawn each frame in animate()
|
||
const _scanCanvas = document.createElement('canvas');
|
||
_scanCanvas.width = 512;
|
||
_scanCanvas.height = 512;
|
||
const _scanCtx = _scanCanvas.getContext('2d');
|
||
const dualBrainScanTexture = new THREE.CanvasTexture(_scanCanvas);
|
||
const dualBrainScanMat = new THREE.SpriteMaterial({
|
||
map: dualBrainScanTexture,
|
||
transparent: true,
|
||
opacity: 0.18,
|
||
depthWrite: false,
|
||
});
|
||
const dualBrainScanSprite = new THREE.Sprite(dualBrainScanMat);
|
||
dualBrainScanSprite.scale.set(5.0, 5.0, 1);
|
||
dualBrainScanSprite.position.set(0, 0, 0.01);
|
||
dualBrainGroup.add(dualBrainScanSprite);
|
||
|
||
// === ANIMATION LOOP ===
|
||
const clock = new THREE.Clock();
|
||
|
||
/**
|
||
* Main animation loop — called each frame via requestAnimationFrame.
|
||
* @returns {void}
|
||
*/
|
||
function animate() {
|
||
// Only start animation after assets are loaded
|
||
requestAnimationFrame(animate);
|
||
animateEnergyBeam();
|
||
const elapsed = clock.getElapsedTime();
|
||
|
||
// Smooth camera transition for overview mode
|
||
const targetT = overviewMode ? 1 : 0;
|
||
overviewT += (targetT - overviewT) * 0.04;
|
||
const _basePos = new THREE.Vector3().lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT);
|
||
|
||
// Zoom-to-object interpolation
|
||
if (!photoMode) {
|
||
zoomT += (zoomTargetT - zoomT) * 0.07;
|
||
}
|
||
if (zoomT > 0.001 && !photoMode && !overviewMode) {
|
||
camera.position.lerpVectors(_basePos, _zoomCamTarget, zoomT);
|
||
camera.lookAt(new THREE.Vector3(0, 0, 0).lerp(_zoomLookTarget, zoomT));
|
||
} else {
|
||
camera.position.copy(_basePos);
|
||
camera.lookAt(0, 0, 0);
|
||
}
|
||
|
||
// Slow auto-rotation — suppressed during overview and photo mode
|
||
const rotationScale = photoMode ? 0 : (1 - overviewT);
|
||
targetRotX += (mouseY * 0.3 - targetRotX) * 0.02;
|
||
targetRotY += (mouseX * 0.3 - targetRotY) * 0.02;
|
||
|
||
stars.rotation.x = (targetRotX + elapsed * 0.01) * rotationScale;
|
||
stars.rotation.y = (targetRotY + elapsed * 0.015) * rotationScale;
|
||
|
||
// Star brightness pulse — tethered to Bitcoin block events
|
||
if (_starPulseIntensity > 0) {
|
||
_starPulseIntensity = Math.max(0, _starPulseIntensity - STAR_PULSE_DECAY);
|
||
}
|
||
starMaterial.opacity = STAR_BASE_OPACITY + (STAR_PEAK_OPACITY - STAR_BASE_OPACITY) * _starPulseIntensity;
|
||
|
||
constellationLines.rotation.x = stars.rotation.x;
|
||
constellationLines.rotation.y = stars.rotation.y;
|
||
|
||
// Subtle pulse on constellation opacity
|
||
constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06;
|
||
|
||
// Batcave reflection probe — refresh every 2 s to capture dynamic scene changes
|
||
if (elapsed - batcaveProbeLastUpdate > 2.0) {
|
||
batcaveProbeLastUpdate = elapsed;
|
||
batcaveGroup.visible = false; // hide self to avoid self-capture artefacts
|
||
batcaveProbe.update(renderer, scene);
|
||
batcaveGroup.visible = true;
|
||
for (const mat of batcaveMetallicMats) {
|
||
mat.envMap = batcaveProbeTarget.texture;
|
||
mat.needsUpdate = true;
|
||
}
|
||
}
|
||
|
||
// Glass platform — ripple edge glow outward from centre
|
||
for (const { mat, distFromCenter } of glassEdgeMaterials) {
|
||
const phase = elapsed * 1.1 - distFromCenter * 0.18;
|
||
mat.opacity = 0.25 + Math.sin(phase) * 0.22;
|
||
}
|
||
// Pulse the void light below
|
||
voidLight.intensity = 0.35 + Math.sin(elapsed * 1.4) * 0.2;
|
||
|
||
// Heatmap floor: subtle breathing glow
|
||
heatmapMat.opacity = 0.75 + Math.sin(elapsed * 0.6) * 0.2;
|
||
|
||
// Animate Timmy sigil — base pulses, rings counter-rotate
|
||
sigilMesh.rotation.z = elapsed * 0.04;
|
||
sigilRing1.rotation.z = elapsed * 0.06;
|
||
sigilRing2.rotation.z = -elapsed * 0.10;
|
||
sigilRing3.rotation.z = elapsed * 0.08;
|
||
sigilMat.opacity = 0.65 + Math.sin(elapsed * 1.3) * 0.18;
|
||
sigilRing1Mat.opacity = 0.38 + Math.sin(elapsed * 0.9) * 0.14;
|
||
sigilRing2Mat.opacity = 0.32 + Math.sin(elapsed * 1.6 + 1.2) * 0.12;
|
||
sigilRing3Mat.opacity = 0.28 + Math.sin(elapsed * 0.7 + 2.4) * 0.10;
|
||
sigilLight.intensity = 0.30 + Math.sin(elapsed * 1.1) * 0.15;
|
||
|
||
// Animate procedural clouds
|
||
cloudMaterial.uniforms.uTime.value = elapsed;
|
||
|
||
if (photoMode) {
|
||
orbitControls.update();
|
||
}
|
||
|
||
// Animate sovereignty meter — gentle hover float and glow pulse
|
||
sovereigntyGroup.position.y = 3.8 + Math.sin(elapsed * 0.8) * 0.15;
|
||
meterLight.intensity = 0.5 + Math.sin(elapsed * 1.8) * 0.25;
|
||
|
||
// Animate floating commit banners
|
||
const FADE_DUR = 1.5;
|
||
commitBanners.forEach(banner => {
|
||
const ud = banner.userData;
|
||
if (ud.spawnTime === null) {
|
||
if (elapsed < ud.startDelay) return;
|
||
ud.spawnTime = elapsed;
|
||
}
|
||
const age = elapsed - ud.spawnTime;
|
||
let opacity;
|
||
if (age < FADE_DUR) {
|
||
opacity = age / FADE_DUR;
|
||
} else if (age < ud.lifetime - FADE_DUR) {
|
||
opacity = 1;
|
||
} else if (age < ud.lifetime) {
|
||
opacity = (ud.lifetime - age) / FADE_DUR;
|
||
} else {
|
||
ud.spawnTime = elapsed + 3;
|
||
opacity = 0;
|
||
}
|
||
banner.material.opacity = opacity * 0.85;
|
||
banner.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.4;
|
||
});
|
||
|
||
// Animate agent status panels — gentle float
|
||
for (const sprite of agentPanelSprites) {
|
||
const ud = sprite.userData;
|
||
sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.22;
|
||
}
|
||
|
||
// Animate LoRA status panel — gentle float
|
||
if (loraPanelSprite) {
|
||
const ud = loraPanelSprite.userData;
|
||
loraPanelSprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.22;
|
||
}
|
||
|
||
// Animate floating bookshelves — gentle slow bob
|
||
for (const shelf of bookshelfGroups) {
|
||
const ud = shelf.userData;
|
||
shelf.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.18;
|
||
}
|
||
|
||
// Animate Timmy speech bubble — fade in, hold, fade out
|
||
if (timmySpeechState) {
|
||
const age = elapsed - timmySpeechState.startTime;
|
||
let opacity;
|
||
if (age < SPEECH_FADE_IN) {
|
||
opacity = age / SPEECH_FADE_IN;
|
||
} else if (age < SPEECH_DURATION - SPEECH_FADE_OUT) {
|
||
opacity = 1.0;
|
||
} else if (age < SPEECH_DURATION) {
|
||
opacity = (SPEECH_DURATION - age) / SPEECH_FADE_OUT;
|
||
} else {
|
||
scene.remove(timmySpeechState.sprite);
|
||
if (timmySpeechState.sprite.material.map) timmySpeechState.sprite.material.map.dispose();
|
||
timmySpeechState.sprite.material.dispose();
|
||
timmySpeechSprite = null;
|
||
timmySpeechState = null;
|
||
opacity = 0;
|
||
}
|
||
if (timmySpeechState) {
|
||
timmySpeechState.sprite.material.opacity = opacity;
|
||
timmySpeechState.sprite.position.y = TIMMY_SPEECH_POS.y + Math.sin(elapsed * 1.1) * 0.1;
|
||
}
|
||
}
|
||
|
||
// Animate tome — gentle float and slow rotation
|
||
tomeGroup.position.y = 5.8 + Math.sin(elapsed * 0.6) * 0.18;
|
||
tomeGroup.rotation.y = elapsed * 0.3;
|
||
tomeGlow.intensity = 0.3 + Math.sin(elapsed * 1.4) * 0.12;
|
||
if (oathActive) {
|
||
oathSpot.intensity = 3.8 + Math.sin(elapsed * 0.9) * 0.4;
|
||
}
|
||
|
||
// Animate shockwave ripple rings
|
||
for (let i = shockwaveRings.length - 1; i >= 0; i--) {
|
||
const ring = shockwaveRings[i];
|
||
const age = elapsed - ring.startTime - ring.delay;
|
||
if (age < 0) continue;
|
||
const t = Math.min(age / SHOCKWAVE_DURATION, 1);
|
||
if (t >= 1) {
|
||
scene.remove(ring.mesh);
|
||
ring.mesh.geometry.dispose();
|
||
ring.mat.dispose();
|
||
shockwaveRings.splice(i, 1);
|
||
continue;
|
||
}
|
||
// Ease out: fast at start, decelerates toward edge
|
||
const eased = 1 - Math.pow(1 - t, 2);
|
||
ring.mesh.scale.setScalar(eased * SHOCKWAVE_MAX_RADIUS + 0.1);
|
||
ring.mat.opacity = (1 - t) * 0.9;
|
||
}
|
||
|
||
// Animate firework bursts — particles drift outward with gravity, fade out
|
||
for (let i = fireworkBursts.length - 1; i >= 0; i--) {
|
||
const burst = fireworkBursts[i];
|
||
const age = elapsed - burst.startTime;
|
||
const t = Math.min(age / FIREWORK_BURST_DURATION, 1);
|
||
if (t >= 1) {
|
||
scene.remove(burst.points);
|
||
burst.geo.dispose();
|
||
burst.mat.dispose();
|
||
fireworkBursts.splice(i, 1);
|
||
continue;
|
||
}
|
||
// Fade out in last 40% of lifetime
|
||
burst.mat.opacity = t < 0.6 ? 1.0 : (1.0 - t) / 0.4;
|
||
|
||
// Compute positions analytically: p = p0 + v*age + 0.5*g*age^2
|
||
const pos = burst.geo.attributes.position.array;
|
||
const vel = burst.velocities;
|
||
const org = burst.origins;
|
||
const halfGAge2 = 0.5 * FIREWORK_GRAVITY * age * age;
|
||
for (let j = 0; j < FIREWORK_BURST_PARTICLES; j++) {
|
||
pos[j * 3] = org[j * 3] + vel[j * 3] * age;
|
||
pos[j * 3 + 1] = org[j * 3 + 1] + vel[j * 3 + 1] * age + halfGAge2;
|
||
pos[j * 3 + 2] = org[j * 3 + 2] + vel[j * 3 + 2] * age;
|
||
}
|
||
burst.geo.attributes.position.needsUpdate = true;
|
||
}
|
||
|
||
// Animate rune ring — orbit and vertical float, brightness tethered to portal status
|
||
for (const rune of runeSprites) {
|
||
const angle = rune.baseAngle + elapsed * RUNE_ORBIT_SPEED;
|
||
rune.sprite.position.x = Math.cos(angle) * RUNE_RING_RADIUS;
|
||
rune.sprite.position.z = Math.sin(angle) * RUNE_RING_RADIUS;
|
||
rune.sprite.position.y = RUNE_RING_Y + Math.sin(elapsed * 0.7 + rune.floatPhase) * 0.4;
|
||
// Online portal = bright, offline = dim
|
||
const baseOpacity = rune.portalOnline ? 0.85 : 0.12;
|
||
const pulseRange = rune.portalOnline ? 0.15 : 0.03;
|
||
rune.sprite.material.opacity = baseOpacity + Math.sin(elapsed * 1.2 + rune.floatPhase) * pulseRange;
|
||
}
|
||
|
||
// Animate holographic Earth — rotation speed tethered to totalActivity()
|
||
// Idle system = very slow (0.005), active system = faster (0.05)
|
||
const earthActivity = totalActivity();
|
||
const targetEarthSpeed = 0.005 + earthActivity * 0.045;
|
||
// Smooth interpolation — don't jump
|
||
const _eSmooth = 0.02;
|
||
const currentEarthSpeed = earthMesh.userData._currentSpeed || EARTH_ROTATION_SPEED;
|
||
const smoothedEarthSpeed = currentEarthSpeed + (targetEarthSpeed - currentEarthSpeed) * _eSmooth;
|
||
earthMesh.userData._currentSpeed = smoothedEarthSpeed;
|
||
earthMesh.rotation.y += smoothedEarthSpeed;
|
||
earthSurfaceMat.uniforms.uTime.value = elapsed;
|
||
earthGlowLight.intensity = 0.30 + Math.sin(elapsed * 0.7) * 0.12;
|
||
earthGroup.position.y = EARTH_Y + Math.sin(elapsed * 0.22) * 0.6;
|
||
|
||
// === WEATHER PARTICLE ANIMATION ===
|
||
if (rainParticles.visible) {
|
||
const rpos = rainGeo.attributes.position.array;
|
||
for (let i = 0; i < PRECIP_COUNT; i++) {
|
||
rpos[i * 3 + 1] -= rainVelocities[i];
|
||
if (rpos[i * 3 + 1] < PRECIP_FLOOR) {
|
||
rpos[i * 3 + 1] = PRECIP_HEIGHT;
|
||
rpos[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2;
|
||
rpos[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2;
|
||
}
|
||
}
|
||
rainGeo.attributes.position.needsUpdate = true;
|
||
}
|
||
|
||
if (snowParticles.visible) {
|
||
const spos = snowGeo.attributes.position.array;
|
||
for (let i = 0; i < PRECIP_COUNT; i++) {
|
||
spos[i * 3 + 1] -= 0.025 + Math.sin(snowDrift[i]) * 0.005;
|
||
spos[i * 3] += Math.sin(elapsed * 0.4 + snowDrift[i]) * 0.008;
|
||
if (spos[i * 3 + 1] < PRECIP_FLOOR) {
|
||
spos[i * 3 + 1] = PRECIP_HEIGHT;
|
||
spos[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2;
|
||
spos[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2;
|
||
}
|
||
}
|
||
snowGeo.attributes.position.needsUpdate = true;
|
||
}
|
||
|
||
// === GRAVITY ANOMALY ANIMATION ===
|
||
for (const gz of gravityZoneObjects) {
|
||
const pos = gz.geo.attributes.position.array;
|
||
const count = gz.zone.particleCount;
|
||
for (let i = 0; i < count; i++) {
|
||
pos[i * 3 + 1] += gz.velocities[i];
|
||
pos[i * 3] += Math.sin(elapsed * 0.5 + gz.driftPhases[i]) * 0.003;
|
||
pos[i * 3 + 2] += Math.cos(elapsed * 0.5 + gz.driftPhases[i]) * 0.003;
|
||
if (pos[i * 3 + 1] > GRAVITY_ANOMALY_CEIL) {
|
||
const angle = Math.random() * Math.PI * 2;
|
||
const r = Math.sqrt(Math.random()) * gz.zone.radius;
|
||
pos[i * 3] = gz.zone.x + Math.cos(angle) * r;
|
||
pos[i * 3 + 1] = GRAVITY_ANOMALY_FLOOR + Math.random() * 2.0;
|
||
pos[i * 3 + 2] = gz.zone.z + Math.sin(angle) * r;
|
||
}
|
||
}
|
||
gz.geo.attributes.position.needsUpdate = true;
|
||
gz.ringMat.opacity = 0.3 + Math.sin(elapsed * 1.5 + gz.zone.x) * 0.15;
|
||
gz.discMat.opacity = 0.02 + Math.sin(elapsed * 1.5 + gz.zone.x) * 0.02;
|
||
}
|
||
|
||
// === DUAL-BRAIN ANIMATION ===
|
||
// Panel float
|
||
dualBrainSprite.position.y = dualBrainSprite.userData.baseY +
|
||
Math.sin(elapsed * dualBrainSprite.userData.floatSpeed + dualBrainSprite.userData.floatPhase) * 0.22;
|
||
dualBrainScanSprite.position.y = dualBrainSprite.position.y;
|
||
|
||
// Orb glow — dim idle pulse (dual-brain offline)
|
||
const cloudPulse = 0.08 + Math.sin(elapsed * 0.6) * 0.03;
|
||
const localPulse = 0.08 + Math.sin(elapsed * 0.6 + Math.PI) * 0.03;
|
||
cloudOrbMat.emissiveIntensity = cloudPulse;
|
||
localOrbMat.emissiveIntensity = localPulse;
|
||
cloudOrbLight.intensity = 0.1 + Math.sin(elapsed * 0.6) * 0.05;
|
||
localOrbLight.intensity = 0.1 + Math.sin(elapsed * 0.6 + Math.PI) * 0.05;
|
||
|
||
// Orb hover
|
||
cloudOrb.position.y = 3.0 + Math.sin(elapsed * 0.9) * 0.15;
|
||
localOrb.position.y = 3.0 + Math.sin(elapsed * 0.9 + 1.0) * 0.15;
|
||
cloudOrbLight.position.y = cloudOrb.position.y;
|
||
localOrbLight.position.y = localOrb.position.y;
|
||
|
||
// Brain pulse particles — OFF (dual-brain system not deployed)
|
||
// Will be re-enabled with flow rate proportional to convergence delta when system deploys
|
||
if (BRAIN_PARTICLE_COUNT > 0) {
|
||
const pos = brainParticleGeo.attributes.position.array;
|
||
const startX = cloudOrb.position.x;
|
||
const endX = localOrb.position.x;
|
||
const arcHeight = 1.2;
|
||
const simRate = 0.73;
|
||
|
||
for (let i = 0; i < BRAIN_PARTICLE_COUNT; i++) {
|
||
brainParticlePhases[i] += brainParticleSpeeds[i] * simRate * 0.016;
|
||
if (brainParticlePhases[i] > 1.0) brainParticlePhases[i] -= 1.0;
|
||
const t = brainParticlePhases[i];
|
||
pos[i * 3] = startX + (endX - startX) * t;
|
||
const midY = (cloudOrb.position.y + localOrb.position.y) / 2 + arcHeight;
|
||
pos[i * 3 + 1] = cloudOrb.position.y + (midY - cloudOrb.position.y) * 4 * t * (1 - t)
|
||
+ (localOrb.position.y - cloudOrb.position.y) * t;
|
||
pos[i * 3 + 2] = Math.sin(t * Math.PI * 4 + elapsed * 2 + i) * 0.12;
|
||
}
|
||
brainParticleGeo.attributes.position.needsUpdate = true;
|
||
brainParticleMat.opacity = 0.6 + Math.sin(elapsed * 2.0) * 0.2;
|
||
}
|
||
|
||
// Scanning line effect — thin horizontal line sweeps down the panel
|
||
{
|
||
const W = 512, H = 512;
|
||
_scanCtx.clearRect(0, 0, W, H);
|
||
const scanY = ((elapsed * 60) % H);
|
||
_scanCtx.fillStyle = 'rgba(68, 136, 255, 0.5)';
|
||
_scanCtx.fillRect(0, scanY, W, 2);
|
||
// Faint glow around scan line
|
||
const grad = _scanCtx.createLinearGradient(0, scanY - 8, 0, scanY + 10);
|
||
grad.addColorStop(0, 'rgba(68, 136, 255, 0)');
|
||
grad.addColorStop(0.4, 'rgba(68, 136, 255, 0.15)');
|
||
grad.addColorStop(0.6, 'rgba(68, 136, 255, 0.15)');
|
||
grad.addColorStop(1, 'rgba(68, 136, 255, 0)');
|
||
_scanCtx.fillStyle = grad;
|
||
_scanCtx.fillRect(0, scanY - 8, W, 18);
|
||
dualBrainScanTexture.needsUpdate = true;
|
||
}
|
||
|
||
// Panel accent light pulse
|
||
dualBrainLight.intensity = 0.4 + Math.sin(elapsed * 1.1) * 0.2;
|
||
|
||
// Portal collision detection
|
||
forwardVector.set(0, 0, -1).applyQuaternion(camera.quaternion);
|
||
raycaster.set(camera.position, forwardVector);
|
||
|
||
const intersects = raycaster.intersectObjects(portalGroup.children);
|
||
|
||
if (intersects.length > 0) {
|
||
const intersectedPortal = intersects[0].object;
|
||
console.log(`Entered portal: ${intersectedPortal.name}`);
|
||
if (!isWarping) {
|
||
startWarp(intersectedPortal);
|
||
}
|
||
}
|
||
|
||
// Warp effect animation
|
||
if (isWarping) {
|
||
const warpElapsed = elapsed - warpStartTime;
|
||
const progress = Math.min(warpElapsed / WARP_DURATION, 1.0);
|
||
warpPass.uniforms['time'].value = elapsed;
|
||
warpPass.uniforms['progress'].value = progress;
|
||
|
||
// Navigate to destination URL at the flash peak (progress ~0.88)
|
||
if (!warpNavigated && progress >= 0.88 && warpDestinationUrl) {
|
||
warpNavigated = true;
|
||
setTimeout(() => { window.location.href = warpDestinationUrl; }, 180);
|
||
}
|
||
|
||
if (progress >= 1.0) {
|
||
isWarping = false;
|
||
warpPass.enabled = false;
|
||
warpPass.uniforms['progress'].value = 0.0;
|
||
// Fallback navigation if URL redirect hasn't fired yet
|
||
if (!warpNavigated && warpDestinationUrl) {
|
||
warpNavigated = true;
|
||
window.location.href = warpDestinationUrl;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Animate crystals — gentle float, slow spin, and lightning-strike flash
|
||
const activity = totalActivity();
|
||
for (const crystal of crystals) {
|
||
crystal.mesh.position.x = crystal.basePos.x;
|
||
crystal.mesh.position.y = crystal.basePos.y + Math.sin(elapsed * 0.65 + crystal.floatPhase) * 0.35;
|
||
crystal.mesh.position.z = crystal.basePos.z;
|
||
crystal.mesh.rotation.y = elapsed * 0.4 + crystal.floatPhase;
|
||
crystal.light.position.copy(crystal.mesh.position);
|
||
const flashAge = elapsed - crystal.flashStartTime;
|
||
const flashBoost = flashAge < 0.25 ? (1.0 - flashAge / 0.25) * 2.0 : 0.0;
|
||
crystal.light.intensity = 0.2 + activity * 0.8 + Math.sin(elapsed * 2.0 + crystal.floatPhase) * 0.1 + flashBoost;
|
||
crystal.mesh.material.emissiveIntensity = 1.0 + flashBoost * 0.8;
|
||
}
|
||
|
||
// Per-frame lightning flicker — modulate opacity each frame for realism
|
||
for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) {
|
||
const meta = lightningArcMeta[i];
|
||
if (meta.active) {
|
||
lightningArcs[i].material.opacity = meta.baseOpacity * (0.55 + Math.random() * 0.45);
|
||
}
|
||
}
|
||
|
||
// Refresh lightning arc geometry periodically
|
||
if (elapsed * 1000 - lastLightningRefreshTime > LIGHTNING_REFRESH_MS) {
|
||
lastLightningRefreshTime = elapsed * 1000;
|
||
updateLightningArcs(elapsed);
|
||
}
|
||
|
||
// Time-lapse replay tick
|
||
if (timelapseActive) {
|
||
const realElapsed = elapsed - timelapseRealStart;
|
||
timelapseProgress = Math.min(realElapsed / TIMELAPSE_DURATION_S, 1.0);
|
||
const span = timelapseWindow.endMs - timelapseWindow.startMs;
|
||
const virtualMs = timelapseWindow.startMs + span * timelapseProgress;
|
||
|
||
// Fire commit events for commits we've reached in virtual time
|
||
while (
|
||
timelapseNextCommitIdx < timelapseCommits.length &&
|
||
timelapseCommits[timelapseNextCommitIdx].ts <= virtualMs
|
||
) {
|
||
fireTimelapseCommit(timelapseCommits[timelapseNextCommitIdx]);
|
||
timelapseNextCommitIdx++;
|
||
}
|
||
|
||
updateTimelapseHeatmap(virtualMs);
|
||
updateTimelapseHUD(timelapseProgress, virtualMs);
|
||
|
||
if (timelapseProgress >= 1.0) stopTimelapse();
|
||
}
|
||
|
||
// Sync Web Audio API listener to camera for 3D spatial audio
|
||
updateAudioListener();
|
||
|
||
composer.render();
|
||
}
|
||
|
||
animate();
|
||
|
||
// === AMBIENT SOUNDTRACK ===
|
||
// Procedural ambient score synthesised in-browser via Web Audio API.
|
||
// Research: Google MusicFX (musicfx.sandbox.google.com) uses AI text prompts to
|
||
// generate music clips. Since MusicFX has no public API, we replicate the desired
|
||
// "deep space / sovereign" aesthetic procedurally.
|
||
//
|
||
// Architecture (4 layers):
|
||
// 1. Sub-drone — two slow-detuned sawtooth oscillators at ~55 Hz (octave below A2)
|
||
// 2. Pad — four detuned triangle oscillators in a minor 7th chord
|
||
// 3. Sparkle — random high-register sine plucks on a pentatonic scale
|
||
// 4. Noise hiss — pink-ish filtered noise for texture
|
||
// All routed through: gain → convolver reverb → limiter → destination.
|
||
|
||
/** @type {AudioContext|null} */
|
||
let audioCtx = null;
|
||
|
||
/** @type {GainNode|null} */
|
||
let masterGain = null;
|
||
|
||
/** @type {boolean} */
|
||
let audioRunning = false;
|
||
|
||
/** @type {Array<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', () => {
|
||
const btn = document.getElementById('podcast-toggle');
|
||
if (btn.textContent === '🎧') {
|
||
fetch('SOUL.md')
|
||
.then(response => {
|
||
if (!response.ok) throw new Error('Failed to load SOUL.md');
|
||
return response.text();
|
||
})
|
||
.then(text => {
|
||
const paragraphs = text.split('\n\n').filter(p => p.trim());
|
||
|
||
if (!paragraphs.length) {
|
||
throw new Error('No content found in SOUL.md');
|
||
}
|
||
|
||
let index = 0;
|
||
const speakNext = () => {
|
||
if (index >= paragraphs.length) return;
|
||
|
||
const utterance = new SpeechSynthesisUtterance(paragraphs[index++]);
|
||
utterance.lang = 'en-US';
|
||
utterance.rate = 0.9;
|
||
utterance.pitch = 1.1;
|
||
|
||
utterance.onend = () => {
|
||
setTimeout(speakNext, 800); // 800ms pause between paragraphs
|
||
};
|
||
|
||
speechSynthesis.speak(utterance);
|
||
};
|
||
|
||
btn.textContent = '⏹';
|
||
btn.classList.add('active');
|
||
speakNext();
|
||
})
|
||
.catch(err => {
|
||
console.error('Podcast error:', err);
|
||
alert('Could not load SOUL.md. Check console for details.');
|
||
btn.textContent = '🎧';
|
||
});
|
||
} else {
|
||
speechSynthesis.cancel();
|
||
btn.textContent = '🎧';
|
||
btn.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
document.getElementById('soul-toggle').addEventListener('click', () => {
|
||
const btn = document.getElementById('soul-toggle');
|
||
if (btn.textContent === '📜') {
|
||
loadSoulMd().then(lines => {
|
||
let index = 0;
|
||
|
||
const speakLine = () => {
|
||
if (index >= lines.length) return;
|
||
|
||
const line = lines[index++];
|
||
const utterance = new SpeechSynthesisUtterance(line);
|
||
utterance.lang = 'en-US';
|
||
utterance.rate = 0.85;
|
||
utterance.pitch = 1.0;
|
||
|
||
utterance.onend = () => {
|
||
setTimeout(speakLine, 1200); // 1.2s pause between lines
|
||
};
|
||
|
||
speechSynthesis.speak(utterance);
|
||
};
|
||
|
||
btn.textContent = '⏹';
|
||
speakLine();
|
||
}).catch(err => {
|
||
console.error('Failed to load SOUL.md', err);
|
||
alert('Could not load SOUL.md. Check console for details.');
|
||
});
|
||
} else {
|
||
speechSynthesis.cancel();
|
||
btn.textContent = '📜';
|
||
}
|
||
});
|
||
|
||
// === DEBUG MODE ===
|
||
let debugMode = false;
|
||
|
||
document.getElementById('debug-toggle').addEventListener('click', () => {
|
||
debugMode = !debugMode;
|
||
document.getElementById('debug-toggle').style.backgroundColor = debugMode
|
||
? 'var(--color-text-muted)'
|
||
: 'var(--color-secondary)';
|
||
console.log(`Debug mode ${debugMode ? 'enabled' : 'disabled'}`);
|
||
|
||
if (debugMode) {
|
||
// Example: Visualize all collision boxes and light sources
|
||
// Replace with actual logic when available
|
||
document.querySelectorAll('.collision-box').forEach((/** @type {HTMLElement} */ el) => el.style.outline = '2px solid red');
|
||
document.querySelectorAll('.light-source').forEach((/** @type {HTMLElement} */ el) => el.style.outline = '2px dashed yellow');
|
||
} else {
|
||
document.querySelectorAll('.collision-box, .light-source').forEach((/** @type {HTMLElement} */ el) => {
|
||
el.style.outline = 'none';
|
||
});
|
||
}
|
||
});
|
||
|
||
// === DEBUG MODE ===
|
||
const DEBUG_MODE = false; // Toggle to true to visualize collision boxes and light sources
|
||
|
||
function debugVisualize(scene) {
|
||
if (!DEBUG_MODE) return;
|
||
// Visualize collision boxes with wireframe
|
||
scene.traverse((object) => {
|
||
if (object.userData && object.userData.isCollidable) {
|
||
object.material = new THREE.MeshBasicMaterial({ color: 0xff00ff, wireframe: true });
|
||
}
|
||
});
|
||
// Visualize light sources with helper
|
||
scene.traverse((object) => {
|
||
if (object instanceof THREE.Light) {
|
||
const helper = new THREE.LightHelper(object, 1, 0xffff00);
|
||
scene.add(helper);
|
||
}
|
||
});
|
||
}
|
||
|
||
// === 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 isOnline = portal.status === 'online';
|
||
|
||
const portalMat = new THREE.MeshBasicMaterial({
|
||
|
||
|
||
color: new THREE.Color(portal.color).convertSRGBToLinear(),
|
||
|
||
|
||
transparent: true,
|
||
|
||
// Offline portals are dimmed
|
||
opacity: isOnline ? 0.7 : 0.15,
|
||
|
||
|
||
blending: THREE.AdditiveBlending,
|
||
|
||
|
||
side: THREE.DoubleSide,
|
||
|
||
|
||
});
|
||
|
||
|
||
const portalMesh = new THREE.Mesh(portalGeo, portalMat);
|
||
|
||
|
||
|
||
|
||
|
||
portalMesh.position.set(portal.position.x, portal.position.y + 0.5, portal.position.z);
|
||
|
||
|
||
portalMesh.rotation.y = portal.rotation.y; // Apply Y rotation
|
||
|
||
|
||
portalMesh.rotation.x = Math.PI / 2; // Orient to stand vertically
|
||
|
||
|
||
portalMesh.name = `portal-${portal.id}`;
|
||
portalMesh.userData.destinationUrl = portal.destination?.url || null;
|
||
portalMesh.userData.portalColor = new THREE.Color(portal.color).convertSRGBToLinear();
|
||
|
||
|
||
portalGroup.add(portalMesh);
|
||
|
||
|
||
});
|
||
|
||
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
// === PORTALS ===
|
||
|
||
|
||
/** @type {Array<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();
|
||
// Rebuild rune ring to match portal count/colors/status
|
||
rebuildRuneRing();
|
||
// Rebuild gravity zones to align with portal positions
|
||
rebuildGravityZones();
|
||
// If audio is already running, attach positional hums to the portals now
|
||
startPortalHums();
|
||
// Run portal health checks
|
||
runPortalHealthChecks();
|
||
} catch (error) {
|
||
console.error('Failed to load portals:', error);
|
||
}
|
||
}
|
||
|
||
// === AGENT STATUS PANELS (declared early — populated after scene is ready) ===
|
||
/** @type {THREE.Sprite[]} */
|
||
|
||
const agentPanelSprites = [];
|
||
|
||
/**
|
||
* Creates a canvas texture for a commit banner.
|
||
* @param {string} hash - Short commit hash
|
||
* @param {string} message - Commit subject line
|
||
* @returns {THREE.CanvasTexture}
|
||
*/
|
||
function createCommitTexture(hash, message) {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 512;
|
||
canvas.height = 64;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
ctx.fillStyle = 'rgba(0, 0, 16, 0.75)';
|
||
ctx.fillRect(0, 0, 512, 64);
|
||
|
||
ctx.strokeStyle = '#4488ff';
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(0.5, 0.5, 511, 63);
|
||
|
||
ctx.font = 'bold 11px "Courier New", monospace';
|
||
ctx.fillStyle = '#4488ff';
|
||
ctx.fillText(hash, 10, 20);
|
||
|
||
ctx.font = '12px "Courier New", monospace';
|
||
ctx.fillStyle = '#ccd6f6';
|
||
const displayMsg = message.length > 54 ? message.slice(0, 54) + '\u2026' : message;
|
||
ctx.fillText(displayMsg, 10, 46);
|
||
|
||
return new THREE.CanvasTexture(canvas);
|
||
}
|
||
|
||
/**
|
||
* Fetches recent commits and spawns floating banner sprites.
|
||
*/
|
||
async function initCommitBanners() {
|
||
let commits;
|
||
try {
|
||
const res = await fetch(
|
||
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=5',
|
||
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
|
||
);
|
||
if (!res.ok) throw new Error('fetch failed');
|
||
const data = await res.json();
|
||
commits = data.map(/** @type {(c: any) => {hash: string, message: string}} */ c => ({
|
||
hash: c.sha.slice(0, 7),
|
||
message: c.commit.message.split('\n')[0],
|
||
}));
|
||
} catch {
|
||
commits = [
|
||
{ hash: 'a1b2c3d', message: 'feat: depth of field effect on distant objects' },
|
||
{ hash: 'e4f5g6h', message: 'feat: photo mode with orbit controls' },
|
||
{ hash: 'i7j8k9l', message: 'feat: sovereignty easter egg animation' },
|
||
{ hash: 'm0n1o2p', message: 'feat: overview mode bird\'s-eye view' },
|
||
{ hash: 'q3r4s5t', message: 'feat: star field and constellation lines' },
|
||
];
|
||
|
||
// Load commit banners after assets are ready
|
||
initCommitBanners();
|
||
}
|
||
|
||
const spreadX = [-7, -3.5, 0, 3.5, 7];
|
||
const spreadY = [1.0, -1.5, 2.2, -0.8, 1.6];
|
||
const spreadZ = [-1.5, -2.5, -1.0, -2.0, -1.8];
|
||
|
||
commits.forEach((commit, i) => {
|
||
const texture = createCommitTexture(commit.hash, commit.message);
|
||
const material = new THREE.SpriteMaterial({
|
||
map: texture,
|
||
transparent: true,
|
||
opacity: 0,
|
||
depthWrite: false,
|
||
});
|
||
const sprite = new THREE.Sprite(material);
|
||
sprite.scale.set(12, 1.5, 1);
|
||
sprite.position.set(
|
||
spreadX[i % spreadX.length],
|
||
spreadY[i % spreadY.length],
|
||
spreadZ[i % spreadZ.length]
|
||
);
|
||
sprite.userData = {
|
||
baseY: spreadY[i % spreadY.length],
|
||
floatPhase: (i / commits.length) * Math.PI * 2,
|
||
floatSpeed: 0.25 + i * 0.07,
|
||
startDelay: i * 2.5,
|
||
lifetime: 12 + i * 1.5,
|
||
spawnTime: /** @type {number|null} */ (null),
|
||
zoomLabel: `Commit: ${commit.hash}`,
|
||
};
|
||
scene.add(sprite);
|
||
commitBanners.push(sprite);
|
||
});
|
||
}
|
||
|
||
initCommitBanners();
|
||
loadPortals();
|
||
|
||
// === FLOATING BOOKSHELVES ===
|
||
// Floating bookshelves display merged PR history as books with spine labels.
|
||
// Each book spine shows a PR number and truncated title rendered via canvas texture.
|
||
|
||
/**
|
||
* Creates a canvas texture for a book spine.
|
||
* @param {number} prNum
|
||
* @param {string} title
|
||
* @param {string} bgColor CSS color string for book cover
|
||
* @returns {THREE.CanvasTexture}
|
||
*/
|
||
function createSpineTexture(prNum, title, bgColor) {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 128;
|
||
canvas.height = 512;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// Background — book cover color
|
||
ctx.fillStyle = bgColor;
|
||
ctx.fillRect(0, 0, 128, 512);
|
||
|
||
// Accent border
|
||
ctx.strokeStyle = '#4488ff';
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeRect(3, 3, 122, 506);
|
||
|
||
// PR number — accent blue, near top
|
||
ctx.font = 'bold 32px "Courier New", monospace';
|
||
ctx.fillStyle = '#4488ff';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(`#${prNum}`, 64, 58);
|
||
|
||
// Divider line
|
||
ctx.strokeStyle = '#4488ff';
|
||
ctx.lineWidth = 1;
|
||
ctx.globalAlpha = 0.4;
|
||
ctx.beginPath();
|
||
ctx.moveTo(12, 78);
|
||
ctx.lineTo(116, 78);
|
||
ctx.stroke();
|
||
ctx.globalAlpha = 1.0;
|
||
|
||
// Title — rotated 90° to read bottom-to-top (spine convention)
|
||
ctx.save();
|
||
ctx.translate(64, 300);
|
||
ctx.rotate(-Math.PI / 2);
|
||
const displayTitle = title.length > 30 ? title.slice(0, 30) + '\u2026' : title;
|
||
ctx.font = '21px "Courier New", monospace';
|
||
ctx.fillStyle = '#ccd6f6';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(displayTitle, 0, 0);
|
||
ctx.restore();
|
||
|
||
return new THREE.CanvasTexture(canvas);
|
||
}
|
||
|
||
/**
|
||
* Builds a single floating bookshelf group and adds it to the scene.
|
||
* @param {Array<{prNum: number, title: string}>} books
|
||
* @param {THREE.Vector3} position
|
||
* @param {number} rotationY
|
||
*/
|
||
function buildBookshelf(books, position, rotationY) {
|
||
const group = new THREE.Group();
|
||
group.position.copy(position);
|
||
group.rotation.y = rotationY;
|
||
|
||
const SHELF_W = books.length * 0.52 + 0.6;
|
||
const SHELF_THICKNESS = 0.12;
|
||
const SHELF_DEPTH = 0.72;
|
||
const ENDPANEL_H = 2.0;
|
||
|
||
// Dark metallic shelf material
|
||
const shelfMat = new THREE.MeshStandardMaterial({
|
||
color: 0x0d1520,
|
||
metalness: 0.6,
|
||
roughness: 0.5,
|
||
emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.02),
|
||
});
|
||
|
||
// Shelf plank (horizontal)
|
||
const plank = new THREE.Mesh(new THREE.BoxGeometry(SHELF_W, SHELF_THICKNESS, SHELF_DEPTH), shelfMat);
|
||
group.add(plank);
|
||
|
||
// End panels
|
||
const endGeo = new THREE.BoxGeometry(0.1, ENDPANEL_H, SHELF_DEPTH);
|
||
const leftEnd = new THREE.Mesh(endGeo, shelfMat);
|
||
leftEnd.position.set(-SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0);
|
||
group.add(leftEnd);
|
||
|
||
const rightEnd = new THREE.Mesh(endGeo.clone(), shelfMat);
|
||
rightEnd.position.set(SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0);
|
||
group.add(rightEnd);
|
||
|
||
// Accent glow strip along front edge of shelf
|
||
const glowStrip = new THREE.Mesh(
|
||
new THREE.BoxGeometry(SHELF_W, 0.035, 0.035),
|
||
new THREE.MeshBasicMaterial({ color: NEXUS.colors.accent, transparent: true, opacity: 0.55 })
|
||
);
|
||
glowStrip.position.set(0, SHELF_THICKNESS / 2 + 0.017, SHELF_DEPTH / 2);
|
||
group.add(glowStrip);
|
||
|
||
// Book cover colors — dark tones with slight variation
|
||
const BOOK_COLORS = [
|
||
'#0f0818', '#080f18', '#0f1108', '#07120e',
|
||
'#130c06', '#060b12', '#120608', '#080812',
|
||
];
|
||
|
||
// Spine thickness (X), book height (Y), cover depth (Z)
|
||
// +Z face (index 4) = spine visible to viewer
|
||
const bookStartX = -(SHELF_W / 2) + 0.36;
|
||
books.forEach((book, i) => {
|
||
const spineW = 0.34 + (i % 3) * 0.05; // slight width variation
|
||
const bookH = 1.35 + (i % 4) * 0.13; // slight height variation
|
||
const coverD = 0.58;
|
||
|
||
const bgColor = BOOK_COLORS[i % BOOK_COLORS.length];
|
||
const spineTexture = createSpineTexture(book.prNum, book.title, bgColor);
|
||
|
||
const plainMat = new THREE.MeshStandardMaterial({
|
||
color: new THREE.Color(bgColor),
|
||
roughness: 0.85,
|
||
metalness: 0.05,
|
||
});
|
||
const spineMat = new THREE.MeshBasicMaterial({ map: spineTexture });
|
||
|
||
// Material array: +X, -X, +Y, -Y, +Z (spine), -Z
|
||
const bookMats = [plainMat, plainMat, plainMat, plainMat, spineMat, plainMat];
|
||
|
||
const bookGeo = new THREE.BoxGeometry(spineW, bookH, coverD);
|
||
const bookMesh = new THREE.Mesh(bookGeo, bookMats);
|
||
bookMesh.position.set(
|
||
bookStartX + i * 0.5,
|
||
SHELF_THICKNESS / 2 + bookH / 2,
|
||
0
|
||
);
|
||
bookMesh.userData.zoomLabel = `PR #${book.prNum}: ${book.title.slice(0, 40)}`;
|
||
group.add(bookMesh);
|
||
});
|
||
|
||
// Soft point light beneath shelf for ambient glow
|
||
const shelfLight = new THREE.PointLight(NEXUS.colors.accent, 0.25, 5);
|
||
shelfLight.position.set(0, -0.4, 0);
|
||
group.add(shelfLight);
|
||
|
||
group.userData.zoomLabel = 'PR Archive — Merged Contributions';
|
||
group.userData.baseY = position.y;
|
||
group.userData.floatPhase = bookshelfGroups.length * Math.PI;
|
||
group.userData.floatSpeed = 0.17 + bookshelfGroups.length * 0.06;
|
||
|
||
scene.add(group);
|
||
bookshelfGroups.push(group);
|
||
}
|
||
|
||
/**
|
||
* Fetches merged PRs and spawns floating bookshelves in the scene.
|
||
*/
|
||
async function initBookshelves() {
|
||
let prs = [];
|
||
try {
|
||
const res = await fetch(
|
||
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/pulls?state=closed&limit=20',
|
||
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
|
||
);
|
||
if (!res.ok) throw new Error('fetch failed');
|
||
const data = await res.json();
|
||
prs = data
|
||
.filter(/** @type {(p: any) => boolean} */ p => p.merged)
|
||
.map(/** @type {(p: any) => {prNum: number, title: string}} */ p => ({
|
||
prNum: p.number,
|
||
// Strip "[claude]" prefix and trailing "(#N)" suffix for cleaner spine labels
|
||
title: p.title
|
||
.replace(/^\[[\w\s]+\]\s*/i, '')
|
||
.replace(/\s*\(#\d+\)\s*$/, ''),
|
||
}));
|
||
} catch {
|
||
// Fallback if API unreachable
|
||
prs = [
|
||
{ prNum: 324, title: 'Model training status — LoRA adapters' },
|
||
{ prNum: 323, title: 'The Oath — interactive SOUL.md reading' },
|
||
{ prNum: 320, title: 'Hermes session save/load' },
|
||
{ prNum: 304, title: 'Session export as markdown' },
|
||
{ prNum: 303, title: 'Procedural Web Audio ambient soundtrack' },
|
||
{ prNum: 301, title: 'Warp tunnel effect for portals' },
|
||
{ prNum: 296, title: 'Procedural terrain for floating island' },
|
||
{ prNum: 294, title: 'Northern lights flash on PR merge' },
|
||
];
|
||
}
|
||
document.getElementById('podcast-toggle').addEventListener('click', async () => {
|
||
const errorEl = document.getElementById('podcast-error');
|
||
errorEl.style.display = 'none';
|
||
errorEl.textContent = '';
|
||
errorEl.style.opacity = '1';
|
||
setTimeout(() => {
|
||
errorEl.style.opacity = '0';
|
||
setTimeout(() => errorEl.style.display = 'none', 300);
|
||
}, 3000);
|
||
|
||
try {
|
||
const response = await fetch('SOUL.md');
|
||
if (!response.ok) throw new Error('Failed to load SOUL.md');
|
||
const text = await response.text();
|
||
const lines = text.split('\n').filter(line => line.trim() !== '');
|
||
speakLines(lines);
|
||
} catch (err) {
|
||
errorEl.textContent = 'Failed to load SOUL.md. Check console for details.';
|
||
errorEl.style.display = 'block';
|
||
console.error('Podcast error:', err);
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('SOUL.md');
|
||
if (!response.ok) throw new Error('Failed to load SOUL.md');
|
||
const text = await response.text();
|
||
const lines = text.split('\n').filter(line => line.trim() !== '');
|
||
speakLines(lines);
|
||
} catch (err) {
|
||
errorEl.textContent = 'Failed to load SOUL.md. Check console for details.';
|
||
errorEl.style.display = 'block';
|
||
errorEl.style.opacity = '1';
|
||
setTimeout(() => {
|
||
errorEl.style.opacity = '0';
|
||
setTimeout(() => errorEl.style.display = 'none', 300);
|
||
}, 3000);
|
||
console.error('Podcast error:', err);
|
||
}
|
||
});
|
||
|
||
function speakLines(lines) {
|
||
const synth = window.speechSynthesis;
|
||
const utterances = lines.map(line => {
|
||
const u = new SpeechSynthesisUtterance(line);
|
||
u.rate = 0.85;
|
||
u.pitch = 0.9;
|
||
return u;
|
||
});
|
||
|
||
let index = 0;
|
||
function speakNext() {
|
||
if (index >= utterances.length) return;
|
||
synth.speak(utterances[index]);
|
||
index++;
|
||
setTimeout(speakNext, 800);
|
||
}
|
||
speakNext();
|
||
}
|
||
|
||
// Add error handling and visual feedback
|
||
document.getElementById('podcast-error').style.display = 'none';
|
||
|
||
if (prs.length === 0) return;
|
||
|
||
// Split PRs across two shelves — left and right of the scene background
|
||
const mid = Math.ceil(prs.length / 2);
|
||
|
||
buildBookshelf(
|
||
prs.slice(0, mid),
|
||
new THREE.Vector3(-8.5, 1.5, -4.5),
|
||
Math.PI * 0.1, // slight angle toward scene center
|
||
);
|
||
|
||
if (prs.slice(mid).length > 0) {
|
||
buildBookshelf(
|
||
prs.slice(mid),
|
||
new THREE.Vector3(8.5, 1.5, -4.5),
|
||
-Math.PI * 0.1,
|
||
);
|
||
}
|
||
}
|
||
|
||
initBookshelves();
|
||
|
||
// === THE OATH ===
|
||
// Interactive reading of SOUL.md with dramatic lighting.
|
||
// Trigger: press 'O' or double-click the tome object in the scene.
|
||
// A gold spotlight descends, ambient dims, lines reveal one-by-one.
|
||
|
||
// ---- Tome (3D trigger object) ----
|
||
const tomeGroup = new THREE.Group();
|
||
tomeGroup.position.set(0, 5.8, 0);
|
||
tomeGroup.userData.zoomLabel = 'The Oath';
|
||
|
||
const tomeCoverMat = new THREE.MeshStandardMaterial({
|
||
color: 0x2a1800,
|
||
metalness: 0.15,
|
||
roughness: 0.7,
|
||
emissive: new THREE.Color(0xffd700).multiplyScalar(0.04),
|
||
});
|
||
const tomePagesMat = new THREE.MeshStandardMaterial({ color: 0xd8ceb0, roughness: 0.9, metalness: 0.0 });
|
||
|
||
// Cover
|
||
const tomeBody = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.1, 1.4), tomeCoverMat);
|
||
tomeGroup.add(tomeBody);
|
||
// Pages (slightly smaller inner block)
|
||
const tomePages = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.07, 1.28), tomePagesMat);
|
||
tomePages.position.set(0.02, 0, 0);
|
||
tomeGroup.add(tomePages);
|
||
// Spine strip
|
||
const tomeSpiMat = new THREE.MeshStandardMaterial({ color: 0xffd700, metalness: 0.6, roughness: 0.4 });
|
||
const tomeSpine = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.12, 1.4), tomeSpiMat);
|
||
tomeSpine.position.set(-0.52, 0, 0);
|
||
tomeGroup.add(tomeSpine);
|
||
|
||
tomeGroup.traverse(o => {
|
||
if (o.isMesh) {
|
||
o.userData.zoomLabel = 'The Oath';
|
||
o.castShadow = true;
|
||
o.receiveShadow = true;
|
||
}
|
||
});
|
||
scene.add(tomeGroup);
|
||
|
||
// Gentle glow beneath the tome
|
||
const tomeGlow = new THREE.PointLight(0xffd700, 0.4, 5);
|
||
tomeGlow.position.set(0, 5.4, 0);
|
||
scene.add(tomeGlow);
|
||
|
||
// ---- Oath spotlight ----
|
||
const oathSpot = new THREE.SpotLight(0xffd700, 0, 40, Math.PI / 7, 0.4, 1.2);
|
||
oathSpot.position.set(0, 22, 0);
|
||
oathSpot.target.position.set(0, 0, 0);
|
||
oathSpot.castShadow = true;
|
||
oathSpot.shadow.mapSize.set(1024, 1024);
|
||
oathSpot.shadow.camera.near = 1;
|
||
oathSpot.shadow.camera.far = 50;
|
||
oathSpot.shadow.bias = -0.002;
|
||
scene.add(oathSpot);
|
||
scene.add(oathSpot.target);
|
||
|
||
// ---- Saved light levels (captured before any changes) ----
|
||
const AMBIENT_NORMAL = ambientLight.intensity;
|
||
const OVERHEAD_NORMAL = overheadLight.intensity;
|
||
|
||
// ---- State ----
|
||
let oathActive = false;
|
||
|
||
/** @type {string[]} */
|
||
let oathLines = [];
|
||
|
||
/** @type {number|null} */
|
||
let oathRevealTimer = null;
|
||
|
||
/**
|
||
* Fetches and caches SOUL.md lines (non-heading, non-empty sections).
|
||
* @returns {Promise<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 ===
|
||
|
||
// Agent status cache — refreshed from Gitea API every 5 minutes
|
||
let _agentStatusCache = null;
|
||
let _agentStatusCacheTime = 0;
|
||
const AGENT_STATUS_CACHE_MS = 5 * 60 * 1000;
|
||
|
||
const GITEA_BASE = 'http://143.198.27.163:3000/api/v1';
|
||
const GITEA_TOKEN = '81a88f46684e398abe081f5786a11ae9532aae2d';
|
||
const GITEA_REPOS = ['Timmy_Foundation/the-nexus', 'Timmy_Foundation/hermes-agent'];
|
||
const AGENT_NAMES = ['Claude', 'Kimi', 'Perplexity', 'Groq', 'Grok', 'Ollama'];
|
||
|
||
/**
|
||
* Fetches real agent status from Gitea API — commits + open PRs for each agent.
|
||
* Results are cached for 5 minutes.
|
||
* @returns {Promise<{agents: Array}>}
|
||
*/
|
||
async function fetchAgentStatusFromGitea() {
|
||
const now = Date.now();
|
||
if (_agentStatusCache && (now - _agentStatusCacheTime < AGENT_STATUS_CACHE_MS)) {
|
||
return _agentStatusCache;
|
||
}
|
||
|
||
const DAY_MS = 86400000;
|
||
const HOUR_MS = 3600000;
|
||
const agents = [];
|
||
|
||
// Fetch commits from all repos in parallel
|
||
const allRepoCommits = await Promise.all(GITEA_REPOS.map(async (repo) => {
|
||
try {
|
||
const res = await fetch(`${GITEA_BASE}/repos/${repo}/commits?sha=main&limit=30&token=${GITEA_TOKEN}`);
|
||
if (!res.ok) return [];
|
||
return await res.json();
|
||
} catch { return []; }
|
||
}));
|
||
|
||
// Fetch open PRs from the-nexus
|
||
let openPRs = [];
|
||
try {
|
||
const prRes = await fetch(`${GITEA_BASE}/repos/Timmy_Foundation/the-nexus/pulls?state=open&limit=50&token=${GITEA_TOKEN}`);
|
||
if (prRes.ok) openPRs = await prRes.json();
|
||
} catch { /* ignore */ }
|
||
|
||
for (const agentName of AGENT_NAMES) {
|
||
const nameLower = agentName.toLowerCase();
|
||
const allCommits = [];
|
||
|
||
for (const repoCommits of allRepoCommits) {
|
||
if (!Array.isArray(repoCommits)) continue;
|
||
const matching = repoCommits.filter(c =>
|
||
(c.commit?.author?.name || '').toLowerCase().includes(nameLower)
|
||
);
|
||
allCommits.push(...matching);
|
||
}
|
||
|
||
// Determine status based on most recent commit
|
||
let status = 'dormant';
|
||
let lastSeen = null;
|
||
let currentWork = null;
|
||
|
||
if (allCommits.length > 0) {
|
||
allCommits.sort((a, b) =>
|
||
new Date(b.commit.author.date) - new Date(a.commit.author.date)
|
||
);
|
||
const latest = allCommits[0];
|
||
const commitTime = new Date(latest.commit.author.date).getTime();
|
||
lastSeen = latest.commit.author.date;
|
||
currentWork = latest.commit.message.split('\n')[0];
|
||
|
||
if (now - commitTime < HOUR_MS) status = 'working';
|
||
else if (now - commitTime < DAY_MS) status = 'idle';
|
||
else status = 'dormant';
|
||
}
|
||
|
||
// Count open PRs for this agent
|
||
const agentPRs = openPRs.filter(pr =>
|
||
(pr.user?.login || '').toLowerCase().includes(nameLower) ||
|
||
(pr.head?.label || '').toLowerCase().includes(nameLower)
|
||
);
|
||
|
||
agents.push({
|
||
name: agentName.toLowerCase(),
|
||
status,
|
||
issue: currentWork,
|
||
prs_today: agentPRs.length,
|
||
local: nameLower === 'ollama',
|
||
});
|
||
}
|
||
|
||
_agentStatusCache = { agents };
|
||
_agentStatusCacheTime = now;
|
||
return _agentStatusCache;
|
||
}
|
||
|
||
const AGENT_STATUS_COLORS = { working: '#00ff88', idle: '#4488ff', dormant: '#334466', dead: '#ff4444', unreachable: '#ff4444' };
|
||
|
||
/**
|
||
* Builds a canvas texture for a single agent holo-panel.
|
||
* @param {{ name: string, status: string, issue: string|null, prs_today: number }} agent
|
||
* @returns {THREE.CanvasTexture}
|
||
*/
|
||
function createAgentPanelTexture(agent) {
|
||
const W = 400, H = 200;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = W;
|
||
canvas.height = H;
|
||
const ctx = canvas.getContext('2d');
|
||
const sc = AGENT_STATUS_COLORS[agent.status] || '#4488ff';
|
||
|
||
// Dark background
|
||
ctx.fillStyle = 'rgba(0, 8, 24, 0.88)';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Outer border in status color
|
||
ctx.strokeStyle = sc;
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(1, 1, W - 2, H - 2);
|
||
|
||
// Faint inner border
|
||
ctx.strokeStyle = sc;
|
||
ctx.lineWidth = 1;
|
||
ctx.globalAlpha = 0.3;
|
||
ctx.strokeRect(4, 4, W - 8, H - 8);
|
||
ctx.globalAlpha = 1.0;
|
||
|
||
// Agent name
|
||
ctx.font = 'bold 28px "Courier New", monospace';
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillText(agent.name.toUpperCase(), 16, 44);
|
||
|
||
// Status dot
|
||
ctx.beginPath();
|
||
ctx.arc(W - 30, 26, 10, 0, Math.PI * 2);
|
||
ctx.fillStyle = sc;
|
||
ctx.fill();
|
||
|
||
// Status label
|
||
ctx.font = '13px "Courier New", monospace';
|
||
ctx.fillStyle = sc;
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(agent.status.toUpperCase(), W - 16, 60);
|
||
ctx.textAlign = 'left';
|
||
|
||
// Separator
|
||
ctx.strokeStyle = '#1a3a6a';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(16, 70);
|
||
ctx.lineTo(W - 16, 70);
|
||
ctx.stroke();
|
||
|
||
// Current issue label
|
||
ctx.font = '10px "Courier New", monospace';
|
||
ctx.fillStyle = '#556688';
|
||
ctx.fillText('CURRENT ISSUE', 16, 90);
|
||
|
||
ctx.font = '13px "Courier New", monospace';
|
||
ctx.fillStyle = '#ccd6f6';
|
||
const issueText = agent.issue || '\u2014 none \u2014';
|
||
const displayIssue = issueText.length > 40 ? issueText.slice(0, 40) + '\u2026' : issueText;
|
||
ctx.fillText(displayIssue, 16, 110);
|
||
|
||
// Separator
|
||
ctx.strokeStyle = '#1a3a6a';
|
||
ctx.beginPath();
|
||
ctx.moveTo(16, 128);
|
||
ctx.lineTo(W - 16, 128);
|
||
ctx.stroke();
|
||
|
||
// PRs merged today
|
||
ctx.font = '10px "Courier New", monospace';
|
||
ctx.fillStyle = '#556688';
|
||
ctx.fillText('PRs MERGED TODAY', 16, 148);
|
||
|
||
ctx.font = 'bold 28px "Courier New", monospace';
|
||
ctx.fillStyle = '#4488ff';
|
||
ctx.fillText(String(agent.prs_today), 16, 182);
|
||
|
||
// Local vs cloud indicator (bottom-right)
|
||
const isLocal = agent.local === true;
|
||
const indicatorColor = isLocal ? '#00ff88' : '#ff4444';
|
||
const indicatorLabel = isLocal ? 'LOCAL' : 'CLOUD';
|
||
|
||
ctx.font = '10px "Courier New", monospace';
|
||
ctx.fillStyle = '#556688';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText('RUNTIME', W - 16, 148);
|
||
|
||
ctx.font = 'bold 13px "Courier New", monospace';
|
||
ctx.fillStyle = indicatorColor;
|
||
ctx.fillText(indicatorLabel, W - 28, 172);
|
||
ctx.textAlign = 'left';
|
||
|
||
// Indicator dot
|
||
ctx.beginPath();
|
||
ctx.arc(W - 16, 167, 6, 0, Math.PI * 2);
|
||
ctx.fillStyle = indicatorColor;
|
||
ctx.fill();
|
||
|
||
return new THREE.CanvasTexture(canvas);
|
||
}
|
||
|
||
/** Group holding all agent panels so they can be toggled/repositioned together. */
|
||
const agentBoardGroup = new THREE.Group();
|
||
scene.add(agentBoardGroup);
|
||
|
||
const BOARD_RADIUS = 9.5; // distance from scene origin
|
||
const BOARD_Y = 4.2; // height above platform
|
||
const BOARD_SPREAD = Math.PI * 0.75; // 135° total arc, centred on negative-Z axis
|
||
|
||
/**
|
||
* (Re)builds the agent panel sprites from fresh status data.
|
||
* @param {{ agents: Array<{ name: string, status: string, issue: string|null, prs_today: number }> }} statusData
|
||
*/
|
||
function rebuildAgentPanels(statusData) {
|
||
while (agentBoardGroup.children.length) agentBoardGroup.remove(agentBoardGroup.children[0]);
|
||
agentPanelSprites.length = 0;
|
||
|
||
const n = statusData.agents.length;
|
||
statusData.agents.forEach((agent, i) => {
|
||
const t = n === 1 ? 0.5 : i / (n - 1);
|
||
// Spread in a semi-circle: angle=PI is directly behind (negative-Z)
|
||
const angle = Math.PI + (t - 0.5) * BOARD_SPREAD;
|
||
const x = Math.cos(angle) * BOARD_RADIUS;
|
||
const z = Math.sin(angle) * BOARD_RADIUS;
|
||
|
||
const texture = createAgentPanelTexture(agent);
|
||
const material = new THREE.SpriteMaterial({
|
||
map: texture,
|
||
transparent: true,
|
||
opacity: 0.93,
|
||
depthWrite: false,
|
||
});
|
||
const sprite = new THREE.Sprite(material);
|
||
sprite.scale.set(6.4, 3.2, 1);
|
||
sprite.position.set(x, BOARD_Y, z);
|
||
sprite.userData = {
|
||
baseY: BOARD_Y,
|
||
floatPhase: (i / n) * Math.PI * 2,
|
||
floatSpeed: 0.18 + i * 0.04,
|
||
zoomLabel: `Agent: ${agent.name}`,
|
||
};
|
||
agentBoardGroup.add(sprite);
|
||
agentPanelSprites.push(sprite);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Fetches live agent status from the Gitea API.
|
||
* Shows "UNREACHABLE" if the API call fails entirely.
|
||
* @returns {Promise<{agents: Array}>}
|
||
*/
|
||
async function fetchAgentStatus() {
|
||
try {
|
||
return await fetchAgentStatusFromGitea();
|
||
} catch {
|
||
return { agents: AGENT_NAMES.map(n => ({
|
||
name: n.toLowerCase(), status: 'unreachable', issue: null, prs_today: 0, local: false,
|
||
})) };
|
||
}
|
||
}
|
||
|
||
async function refreshAgentBoard() {
|
||
const data = await fetchAgentStatus();
|
||
rebuildAgentPanels(data);
|
||
// Update active agent count for energy beam tethering
|
||
_activeAgentCount = data.agents.filter(a => a.status === 'working').length;
|
||
}
|
||
|
||
// Initial render, then poll every 5 min (matching API cache interval)
|
||
refreshAgentBoard();
|
||
setInterval(refreshAgentBoard, AGENT_STATUS_CACHE_MS);
|
||
|
||
// === LORA ADAPTER STATUS PANEL ===
|
||
// Holographic panel showing LoRA fine-tuning adapter status.
|
||
// Shows honest empty state when no adapters are deployed.
|
||
|
||
// No LoRA stub — honest empty state when no adapters are deployed
|
||
|
||
const LORA_ACTIVE_COLOR = '#00ff88'; // green — adapter is loaded
|
||
const LORA_INACTIVE_COLOR = '#334466'; // dim blue — adapter is off
|
||
|
||
/**
|
||
* Builds a canvas texture for the LoRA status panel.
|
||
* Shows honest empty state when no adapters are deployed.
|
||
* @param {{ adapters: Array }|null} data
|
||
* @returns {THREE.CanvasTexture}
|
||
*/
|
||
function createLoRAPanelTexture(data) {
|
||
const W = 420, H = 260;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = W;
|
||
canvas.height = H;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// Background
|
||
ctx.fillStyle = 'rgba(0, 6, 20, 0.90)';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Outer border — magenta/purple for "training" theme
|
||
ctx.strokeStyle = '#cc44ff';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(1, 1, W - 2, H - 2);
|
||
|
||
// Inner border
|
||
ctx.strokeStyle = '#cc44ff';
|
||
ctx.lineWidth = 1;
|
||
ctx.globalAlpha = 0.3;
|
||
ctx.strokeRect(4, 4, W - 8, H - 8);
|
||
ctx.globalAlpha = 1.0;
|
||
|
||
// Header label
|
||
ctx.font = 'bold 14px "Courier New", monospace';
|
||
ctx.fillStyle = '#cc44ff';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('MODEL TRAINING', 14, 24);
|
||
|
||
// "LoRA ADAPTERS" sub-label
|
||
ctx.font = '10px "Courier New", monospace';
|
||
ctx.fillStyle = '#664488';
|
||
ctx.fillText('LoRA ADAPTERS', 14, 38);
|
||
|
||
// Separator
|
||
ctx.strokeStyle = '#2a1a44';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(14, 46);
|
||
ctx.lineTo(W - 14, 46);
|
||
ctx.stroke();
|
||
|
||
// Honest empty state — no adapters deployed
|
||
if (!data || !data.adapters || data.adapters.length === 0) {
|
||
ctx.font = 'bold 18px "Courier New", monospace';
|
||
ctx.fillStyle = '#334466';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('NO ADAPTERS DEPLOYED', W / 2, H / 2 + 10);
|
||
ctx.font = '11px "Courier New", monospace';
|
||
ctx.fillStyle = '#223344';
|
||
ctx.fillText('Adapters will appear here when trained', W / 2, H / 2 + 36);
|
||
ctx.textAlign = 'left';
|
||
return new THREE.CanvasTexture(canvas);
|
||
}
|
||
|
||
// If adapters exist in the future, render them
|
||
const activeCount = data.adapters.filter(a => a.active).length;
|
||
ctx.font = 'bold 13px "Courier New", monospace';
|
||
ctx.fillStyle = LORA_ACTIVE_COLOR;
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(`${activeCount}/${data.adapters.length} ACTIVE`, W - 14, 26);
|
||
ctx.textAlign = 'left';
|
||
|
||
const ROW_H = 44;
|
||
data.adapters.forEach((adapter, i) => {
|
||
const rowY = 50 + i * ROW_H;
|
||
const col = adapter.active ? LORA_ACTIVE_COLOR : LORA_INACTIVE_COLOR;
|
||
ctx.beginPath();
|
||
ctx.arc(22, rowY + 12, 6, 0, Math.PI * 2);
|
||
ctx.fillStyle = col;
|
||
ctx.fill();
|
||
ctx.font = 'bold 13px "Courier New", monospace';
|
||
ctx.fillStyle = adapter.active ? '#ddeeff' : '#445566';
|
||
ctx.fillText(adapter.name, 36, rowY + 16);
|
||
ctx.font = '10px "Courier New", monospace';
|
||
ctx.fillStyle = '#556688';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(adapter.base, W - 14, rowY + 16);
|
||
ctx.textAlign = 'left';
|
||
if (adapter.active) {
|
||
const BAR_X = 36, BAR_W = W - 80, BAR_Y = rowY + 22, BAR_H = 5;
|
||
ctx.fillStyle = '#0a1428';
|
||
ctx.fillRect(BAR_X, BAR_Y, BAR_W, BAR_H);
|
||
ctx.fillStyle = col;
|
||
ctx.globalAlpha = 0.7;
|
||
ctx.fillRect(BAR_X, BAR_Y, BAR_W * adapter.strength, BAR_H);
|
||
ctx.globalAlpha = 1.0;
|
||
}
|
||
if (i < data.adapters.length - 1) {
|
||
ctx.strokeStyle = '#1a0a2a';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(14, rowY + ROW_H - 2);
|
||
ctx.lineTo(W - 14, rowY + ROW_H - 2);
|
||
ctx.stroke();
|
||
}
|
||
});
|
||
|
||
return new THREE.CanvasTexture(canvas);
|
||
}
|
||
|
||
const loraGroup = new THREE.Group();
|
||
scene.add(loraGroup);
|
||
|
||
const LORA_PANEL_POS = new THREE.Vector3(-10.5, 4.5, 2.5);
|
||
|
||
let loraPanelSprite = null;
|
||
|
||
/**
|
||
* (Re)builds the LoRA panel sprite from fresh data.
|
||
* @param {{ adapters: Array }|null} data
|
||
*/
|
||
function rebuildLoRAPanel(data) {
|
||
if (loraPanelSprite) {
|
||
loraGroup.remove(loraPanelSprite);
|
||
if (loraPanelSprite.material.map) loraPanelSprite.material.map.dispose();
|
||
loraPanelSprite.material.dispose();
|
||
loraPanelSprite = null;
|
||
}
|
||
const texture = createLoRAPanelTexture(data);
|
||
const material = new THREE.SpriteMaterial({
|
||
map: texture,
|
||
transparent: true,
|
||
opacity: 0.93,
|
||
depthWrite: false,
|
||
});
|
||
loraPanelSprite = new THREE.Sprite(material);
|
||
loraPanelSprite.scale.set(6.0, 3.6, 1);
|
||
loraPanelSprite.position.copy(LORA_PANEL_POS);
|
||
loraPanelSprite.userData = {
|
||
baseY: LORA_PANEL_POS.y,
|
||
floatPhase: 1.1,
|
||
floatSpeed: 0.14,
|
||
zoomLabel: 'Model Training — LoRA Adapters',
|
||
};
|
||
loraGroup.add(loraPanelSprite);
|
||
}
|
||
|
||
/**
|
||
* Renders the LoRA panel with honest empty state — no adapters deployed.
|
||
*/
|
||
function loadLoRAStatus() {
|
||
rebuildLoRAPanel({ adapters: [] });
|
||
}
|
||
|
||
loadLoRAStatus();
|
||
|
||
// === PORTAL HEALTH CHECKS ===
|
||
// Probes portal destination URLs to verify they're actually reachable.
|
||
// Uses portals.json status as the baseline — since all are currently "offline", this is honest.
|
||
// Health check runs every 5 minutes to detect if a portal comes online.
|
||
const PORTAL_HEALTH_CHECK_MS = 5 * 60 * 1000;
|
||
|
||
/**
|
||
* Runs a health check against each portal's destination URL.
|
||
* Updates portal status and refreshes visuals (runes, gravity zones).
|
||
*/
|
||
async function runPortalHealthChecks() {
|
||
if (portals.length === 0) return;
|
||
|
||
for (const portal of portals) {
|
||
if (!portal.destination?.url) {
|
||
portal.status = 'offline';
|
||
continue;
|
||
}
|
||
try {
|
||
await fetch(portal.destination.url, {
|
||
mode: 'no-cors',
|
||
signal: AbortSignal.timeout(5000),
|
||
});
|
||
// Any response at all means the server is up
|
||
portal.status = 'online';
|
||
} catch {
|
||
portal.status = 'offline';
|
||
}
|
||
}
|
||
|
||
// Refresh rune ring and gravity zones with updated portal statuses
|
||
rebuildRuneRing();
|
||
rebuildGravityZones();
|
||
|
||
// Update portal mesh visuals — dim offline portals
|
||
for (const child of portalGroup.children) {
|
||
const portalId = child.name.replace('portal-', '');
|
||
const portalData = portals.find(p => p.id === portalId);
|
||
if (portalData) {
|
||
const isOnline = portalData.status === 'online';
|
||
child.material.opacity = isOnline ? 0.7 : 0.15;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Schedule recurring health checks
|
||
setInterval(runPortalHealthChecks, PORTAL_HEALTH_CHECK_MS);
|
||
|
||
// === WEATHER SYSTEM — Lempster NH ===
|
||
// Fetches real current weather from Open-Meteo (no API key required).
|
||
// Lempster, NH coordinates: 43.2897° N, 72.1479° W
|
||
// Drives particle rain/snow effects and ambient mood tinting.
|
||
|
||
const WEATHER_LAT = 43.2897;
|
||
const WEATHER_LON = -72.1479;
|
||
const WEATHER_REFRESH_MS = 15 * 60 * 1000; // 15 minutes
|
||
|
||
/** @type {{ code: number, temp: number, wind: number, condition: string, icon: string }|null} */
|
||
let weatherState = null;
|
||
|
||
// Particle constants
|
||
const PRECIP_COUNT = 1200;
|
||
const PRECIP_AREA = 18; // half-width of spawn box (scene units)
|
||
const PRECIP_HEIGHT = 20; // top spawn Y
|
||
const PRECIP_FLOOR = -5; // bottom Y before reset
|
||
|
||
// Rain geometry & material
|
||
const rainGeo = new THREE.BufferGeometry();
|
||
const rainPositions = new Float32Array(PRECIP_COUNT * 3);
|
||
const rainVelocities = new Float32Array(PRECIP_COUNT); // per-particle fall speed
|
||
|
||
for (let i = 0; i < PRECIP_COUNT; i++) {
|
||
rainPositions[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2;
|
||
rainPositions[i * 3 + 1] = Math.random() * (PRECIP_HEIGHT - PRECIP_FLOOR) + PRECIP_FLOOR;
|
||
rainPositions[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2;
|
||
rainVelocities[i] = 0.18 + Math.random() * 0.12;
|
||
}
|
||
rainGeo.setAttribute('position', new THREE.BufferAttribute(rainPositions, 3));
|
||
|
||
const rainMat = new THREE.PointsMaterial({
|
||
color: 0x88aaff,
|
||
size: 0.05,
|
||
sizeAttenuation: true,
|
||
transparent: true,
|
||
opacity: 0.55,
|
||
});
|
||
|
||
const rainParticles = new THREE.Points(rainGeo, rainMat);
|
||
rainParticles.visible = false;
|
||
scene.add(rainParticles);
|
||
|
||
// Snow geometry & material
|
||
const snowGeo = new THREE.BufferGeometry();
|
||
const snowPositions = new Float32Array(PRECIP_COUNT * 3);
|
||
const snowDrift = new Float32Array(PRECIP_COUNT); // horizontal drift phase
|
||
|
||
for (let i = 0; i < PRECIP_COUNT; i++) {
|
||
snowPositions[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2;
|
||
snowPositions[i * 3 + 1] = Math.random() * (PRECIP_HEIGHT - PRECIP_FLOOR) + PRECIP_FLOOR;
|
||
snowPositions[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2;
|
||
snowDrift[i] = Math.random() * Math.PI * 2;
|
||
}
|
||
snowGeo.setAttribute('position', new THREE.BufferAttribute(snowPositions, 3));
|
||
|
||
const snowMat = new THREE.PointsMaterial({
|
||
color: 0xddeeff,
|
||
size: 0.12,
|
||
sizeAttenuation: true,
|
||
transparent: true,
|
||
opacity: 0.75,
|
||
});
|
||
|
||
const snowParticles = new THREE.Points(snowGeo, snowMat);
|
||
snowParticles.visible = false;
|
||
scene.add(snowParticles);
|
||
|
||
/**
|
||
* Maps a WMO weather code to a human-readable condition label and icon.
|
||
* @param {number} code
|
||
* @returns {{ condition: string, icon: string }}
|
||
*/
|
||
function weatherCodeToLabel(code) {
|
||
if (code === 0) return { condition: 'Clear', icon: '☀️' };
|
||
if (code <= 2) return { condition: 'Partly Cloudy', icon: '⛅' };
|
||
if (code === 3) return { condition: 'Overcast', icon: '☁️' };
|
||
if (code >= 45 && code <= 48) return { condition: 'Fog', icon: '🌫️' };
|
||
if (code >= 51 && code <= 57) return { condition: 'Drizzle', icon: '🌦️' };
|
||
if (code >= 61 && code <= 67) return { condition: 'Rain', icon: '🌧️' };
|
||
if (code >= 71 && code <= 77) return { condition: 'Snow', icon: '❄️' };
|
||
if (code >= 80 && code <= 82) return { condition: 'Showers', icon: '🌦️' };
|
||
if (code >= 85 && code <= 86) return { condition: 'Snow Showers', icon: '🌨️' };
|
||
if (code >= 95 && code <= 99) return { condition: 'Thunderstorm', icon: '⛈️' };
|
||
return { condition: 'Unknown', icon: '🌀' };
|
||
}
|
||
|
||
/**
|
||
* Applies weather state to scene — particle visibility and ambient tint.
|
||
* @param {{ code: number, temp: number, wind: number, condition: string, icon: string }} wx
|
||
*/
|
||
function applyWeatherToScene(wx) {
|
||
const code = wx.code;
|
||
|
||
// Precipitation
|
||
const isRain = (code >= 51 && code <= 67) || (code >= 80 && code <= 82) || (code >= 95 && code <= 99);
|
||
const isSnow = (code >= 71 && code <= 77) || (code >= 85 && code <= 86);
|
||
|
||
rainParticles.visible = isRain;
|
||
snowParticles.visible = isSnow;
|
||
|
||
// Ambient mood tint
|
||
if (isSnow) {
|
||
ambientLight.color.setHex(0x1a2a40); // cold blue-white
|
||
ambientLight.intensity = 1.8;
|
||
} else if (isRain) {
|
||
ambientLight.color.setHex(0x0a1428); // darker, bluer
|
||
ambientLight.intensity = 1.2;
|
||
} else if (code === 3 || (code >= 45 && code <= 48)) {
|
||
ambientLight.color.setHex(0x0c1220); // overcast grey-blue
|
||
ambientLight.intensity = 1.1;
|
||
} else {
|
||
ambientLight.color.setHex(0x0a1428); // default clear
|
||
ambientLight.intensity = 1.4;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Updates the weather HUD elements in the DOM.
|
||
* @param {{ temp: number, condition: string, icon: string }} wx
|
||
*/
|
||
function updateWeatherHUD(wx) {
|
||
const iconEl = document.getElementById('weather-icon');
|
||
const tempEl = document.getElementById('weather-temp');
|
||
const descEl = document.getElementById('weather-desc');
|
||
if (iconEl) iconEl.textContent = wx.icon;
|
||
if (tempEl) tempEl.textContent = `${Math.round(wx.temp)}°F`;
|
||
if (descEl) descEl.textContent = wx.condition;
|
||
}
|
||
|
||
/**
|
||
* Fetches current weather for Lempster NH from Open-Meteo and applies it.
|
||
*/
|
||
async function fetchWeather() {
|
||
try {
|
||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${WEATHER_LAT}&longitude=${WEATHER_LON}¤t=temperature_2m,weather_code,wind_speed_10m,cloud_cover&temperature_unit=fahrenheit&wind_speed_unit=mph&forecast_days=1`;
|
||
const res = await fetch(url);
|
||
if (!res.ok) throw new Error('weather fetch failed');
|
||
const data = await res.json();
|
||
const cur = data.current;
|
||
const code = cur.weather_code;
|
||
const { condition, icon } = weatherCodeToLabel(code);
|
||
const cloudcover = typeof cur.cloud_cover === 'number' ? cur.cloud_cover : 50;
|
||
weatherState = { code, temp: cur.temperature_2m, wind: cur.wind_speed_10m, condition, icon, cloudcover };
|
||
applyWeatherToScene(weatherState);
|
||
// Tether cloud layer density to real weather cloudcover
|
||
const cloudOpacity = 0.05 + (cloudcover / 100) * 0.55; // range [0.05, 0.60]
|
||
cloudMaterial.uniforms.uDensity.value = 0.3 + (cloudcover / 100) * 0.7; // range [0.3, 1.0]
|
||
cloudMaterial.opacity = cloudOpacity;
|
||
updateWeatherHUD(weatherState);
|
||
} catch {
|
||
// Silently use defaults — no weather data available
|
||
const descEl = document.getElementById('weather-desc');
|
||
if (descEl) descEl.textContent = 'Lempster NH';
|
||
}
|
||
}
|
||
|
||
fetchWeather();
|
||
setInterval(fetchWeather, WEATHER_REFRESH_MS);
|
||
|
||
// === GRAVITY ANOMALY ZONES ===
|
||
// Areas where particles defy gravity and float upward.
|
||
// Tethered to portal positions and status — active portals have stronger anomalies.
|
||
|
||
const GRAVITY_ANOMALY_FLOOR = 0.2; // Y where particles respawn (ground level)
|
||
const GRAVITY_ANOMALY_CEIL = 16.0; // Y where particles wrap back to floor
|
||
|
||
// Default zones — replaced when portals load
|
||
let GRAVITY_ZONES = [
|
||
{ x: -8, z: -6, radius: 3.5, color: 0x00ffcc, particleCount: 180 },
|
||
{ x: 10, z: 4, radius: 3.0, color: 0xaa44ff, particleCount: 160 },
|
||
{ x: -3, z: 9, radius: 2.5, color: 0xff8844, particleCount: 140 },
|
||
];
|
||
|
||
const gravityZoneObjects = GRAVITY_ZONES.map((zone) => {
|
||
// Glowing floor ring
|
||
const ringGeo = new THREE.RingGeometry(zone.radius - 0.15, zone.radius + 0.15, 64);
|
||
const ringMat = new THREE.MeshBasicMaterial({
|
||
color: zone.color,
|
||
transparent: true,
|
||
opacity: 0.4,
|
||
side: THREE.DoubleSide,
|
||
depthWrite: false,
|
||
});
|
||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||
ring.rotation.x = -Math.PI / 2;
|
||
ring.position.set(zone.x, GRAVITY_ANOMALY_FLOOR + 0.05, zone.z);
|
||
scene.add(ring);
|
||
|
||
// Faint inner disc
|
||
const discGeo = new THREE.CircleGeometry(zone.radius - 0.15, 64);
|
||
const discMat = new THREE.MeshBasicMaterial({
|
||
color: zone.color,
|
||
transparent: true,
|
||
opacity: 0.04,
|
||
side: THREE.DoubleSide,
|
||
depthWrite: false,
|
||
});
|
||
const disc = new THREE.Mesh(discGeo, discMat);
|
||
disc.rotation.x = -Math.PI / 2;
|
||
disc.position.set(zone.x, GRAVITY_ANOMALY_FLOOR + 0.04, zone.z);
|
||
scene.add(disc);
|
||
|
||
// Rising particle stream
|
||
const count = zone.particleCount;
|
||
const positions = new Float32Array(count * 3);
|
||
const driftPhases = new Float32Array(count);
|
||
const velocities = new Float32Array(count);
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const angle = Math.random() * Math.PI * 2;
|
||
const r = Math.sqrt(Math.random()) * zone.radius;
|
||
positions[i * 3] = zone.x + Math.cos(angle) * r;
|
||
positions[i * 3 + 1] = GRAVITY_ANOMALY_FLOOR + Math.random() * (GRAVITY_ANOMALY_CEIL - GRAVITY_ANOMALY_FLOOR);
|
||
positions[i * 3 + 2] = zone.z + Math.sin(angle) * r;
|
||
driftPhases[i] = Math.random() * Math.PI * 2;
|
||
velocities[i] = 0.03 + Math.random() * 0.04;
|
||
}
|
||
|
||
const geo = new THREE.BufferGeometry();
|
||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||
|
||
const mat = new THREE.PointsMaterial({
|
||
color: zone.color,
|
||
size: 0.10,
|
||
sizeAttenuation: true,
|
||
transparent: true,
|
||
opacity: 0.7,
|
||
depthWrite: false,
|
||
});
|
||
|
||
const points = new THREE.Points(geo, mat);
|
||
scene.add(points);
|
||
|
||
return { zone, ring, ringMat, disc, discMat, points, geo, driftPhases, velocities };
|
||
});
|
||
|
||
/**
|
||
* Rebuilds gravity anomaly zones to align with portal positions.
|
||
* Active/online portals get stronger anomaly; offline portals get weaker effect.
|
||
*/
|
||
function rebuildGravityZones() {
|
||
if (portals.length === 0) return;
|
||
|
||
// Update existing zone positions/intensities to match portal data
|
||
for (let i = 0; i < Math.min(portals.length, gravityZoneObjects.length); i++) {
|
||
const portal = portals[i];
|
||
const gz = gravityZoneObjects[i];
|
||
const isOnline = portal.status === 'online';
|
||
const portalColor = new THREE.Color(portal.color);
|
||
|
||
// Reposition ring and disc to portal position
|
||
gz.ring.position.set(portal.position.x, GRAVITY_ANOMALY_FLOOR + 0.05, portal.position.z);
|
||
gz.disc.position.set(portal.position.x, GRAVITY_ANOMALY_FLOOR + 0.04, portal.position.z);
|
||
|
||
// Update zone reference for particle respawn
|
||
gz.zone.x = portal.position.x;
|
||
gz.zone.z = portal.position.z;
|
||
gz.zone.color = portalColor.getHex();
|
||
|
||
// Update colors
|
||
gz.ringMat.color.copy(portalColor);
|
||
gz.discMat.color.copy(portalColor);
|
||
gz.points.material.color.copy(portalColor);
|
||
|
||
// Offline portals: reduced opacity/intensity
|
||
gz.ringMat.opacity = isOnline ? 0.4 : 0.08;
|
||
gz.discMat.opacity = isOnline ? 0.04 : 0.01;
|
||
gz.points.material.opacity = isOnline ? 0.7 : 0.15;
|
||
|
||
// Reposition particles around portal
|
||
const pos = gz.geo.attributes.position.array;
|
||
for (let j = 0; j < gz.zone.particleCount; j++) {
|
||
const angle = Math.random() * Math.PI * 2;
|
||
const r = Math.sqrt(Math.random()) * gz.zone.radius;
|
||
pos[j * 3] = gz.zone.x + Math.cos(angle) * r;
|
||
pos[j * 3 + 2] = gz.zone.z + Math.sin(angle) * r;
|
||
}
|
||
gz.geo.attributes.position.needsUpdate = true;
|
||
}
|
||
}
|
||
|
||
// === TIMMY SPEECH BUBBLE ===
|
||
// When Timmy sends a chat message, a glowing floating text sprite appears near
|
||
// his avatar position above the platform. Fades in quickly, holds for 5 s total,
|
||
// then fades out. Only the most recent message is shown.
|
||
|
||
const TIMMY_SPEECH_POS = new THREE.Vector3(0, 8.2, 1.5);
|
||
const SPEECH_DURATION = 5.0; // total seconds visible (including fades)
|
||
const SPEECH_FADE_IN = 0.35;
|
||
const SPEECH_FADE_OUT = 0.7;
|
||
|
||
/** @type {THREE.Sprite|null} */
|
||
let timmySpeechSprite = null;
|
||
|
||
/** @type {{ startTime: number, sprite: THREE.Sprite }|null} */
|
||
let timmySpeechState = null;
|
||
|
||
/**
|
||
* Builds a canvas texture for a Timmy speech bubble.
|
||
* @param {string} text
|
||
* @returns {THREE.CanvasTexture}
|
||
*/
|
||
function createSpeechBubbleTexture(text) {
|
||
const W = 512, H = 100;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = W;
|
||
canvas.height = H;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// Semi-transparent dark background
|
||
ctx.fillStyle = 'rgba(0, 6, 20, 0.85)';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Neon blue glow border
|
||
ctx.strokeStyle = '#66aaff';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(1, 1, W - 2, H - 2);
|
||
|
||
// Inner subtle border
|
||
ctx.strokeStyle = '#2244aa';
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(4, 4, W - 8, H - 8);
|
||
|
||
// "TIMMY:" label
|
||
ctx.font = 'bold 12px "Courier New", monospace';
|
||
ctx.fillStyle = '#4488ff';
|
||
ctx.fillText('TIMMY:', 12, 22);
|
||
|
||
// Message text — truncate to two lines if needed
|
||
const LINE1_MAX = 42;
|
||
const LINE2_MAX = 48;
|
||
ctx.font = '15px "Courier New", monospace';
|
||
ctx.fillStyle = '#ddeeff';
|
||
|
||
if (text.length <= LINE1_MAX) {
|
||
ctx.fillText(text, 12, 58);
|
||
} else {
|
||
ctx.fillText(text.slice(0, LINE1_MAX), 12, 46);
|
||
const rest = text.slice(LINE1_MAX, LINE1_MAX + LINE2_MAX);
|
||
ctx.font = '13px "Courier New", monospace';
|
||
ctx.fillStyle = '#aabbcc';
|
||
ctx.fillText(rest + (text.length > LINE1_MAX + LINE2_MAX ? '\u2026' : ''), 12, 76);
|
||
}
|
||
|
||
return new THREE.CanvasTexture(canvas);
|
||
}
|
||
|
||
/**
|
||
* Shows a floating speech bubble near Timmy's avatar.
|
||
* Immediately replaces any existing bubble.
|
||
* @param {string} text
|
||
*/
|
||
function showTimmySpeech(text) {
|
||
if (timmySpeechSprite) {
|
||
scene.remove(timmySpeechSprite);
|
||
if (timmySpeechSprite.material.map) timmySpeechSprite.material.map.dispose();
|
||
timmySpeechSprite.material.dispose();
|
||
timmySpeechSprite = null;
|
||
timmySpeechState = null;
|
||
}
|
||
|
||
const texture = createSpeechBubbleTexture(text);
|
||
const material = new THREE.SpriteMaterial({
|
||
map: texture,
|
||
transparent: true,
|
||
opacity: 0,
|
||
depthWrite: false,
|
||
});
|
||
const sprite = new THREE.Sprite(material);
|
||
sprite.scale.set(8.5, 1.65, 1);
|
||
sprite.position.copy(TIMMY_SPEECH_POS);
|
||
scene.add(sprite);
|
||
|
||
timmySpeechSprite = sprite;
|
||
timmySpeechState = { startTime: clock.getElapsedTime(), sprite };
|
||
}
|
||
|
||
// === TIME-LAPSE MODE ===
|
||
// Press 'L' (or click ⏩ in the HUD) to replay a day of Nexus commit activity
|
||
// compressed into 30 real seconds. A HUD clock scrubs 00:00 → 23:59 while the
|
||
// heatmap and shockwave effects fire in sync with each commit.
|
||
|
||
const TIMELAPSE_DURATION_S = 30; // real seconds = one full virtual day
|
||
|
||
let timelapseActive = false;
|
||
let timelapseRealStart = 0; // clock.getElapsedTime() when replay began
|
||
let timelapseProgress = 0; // 0..1
|
||
|
||
/** @type {Array<{ts: number, author: string, message: string, hash: string}>} */
|
||
let timelapseCommits = [];
|
||
|
||
/** Virtual day window: midnight-to-now of today. */
|
||
let timelapseWindow = { startMs: 0, endMs: 0 };
|
||
|
||
/** Index of the next commit not yet fired. */
|
||
let timelapseNextCommitIdx = 0;
|
||
|
||
const timelapseIndicator = document.getElementById('timelapse-indicator');
|
||
const timelapseClock = document.getElementById('timelapse-clock');
|
||
const timelapseBarEl = document.getElementById('timelapse-bar');
|
||
const timelapseBtnEl = document.getElementById('timelapse-btn');
|
||
|
||
/**
|
||
* Loads today's commits from the Gitea API for replay.
|
||
*/
|
||
async function loadTimelapseData() {
|
||
try {
|
||
const res = await fetch(
|
||
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=50',
|
||
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
|
||
);
|
||
if (!res.ok) throw new Error('fetch failed');
|
||
const data = await res.json();
|
||
const midnight = new Date();
|
||
midnight.setHours(0, 0, 0, 0);
|
||
|
||
timelapseCommits = data
|
||
.map(c => ({
|
||
ts: new Date(c.commit?.author?.date || 0).getTime(),
|
||
author: c.commit?.author?.name || c.author?.login || 'unknown',
|
||
message: (c.commit?.message || '').split('\n')[0],
|
||
hash: (c.sha || '').slice(0, 7),
|
||
}))
|
||
.filter(c => c.ts >= midnight.getTime())
|
||
.sort((a, b) => a.ts - b.ts);
|
||
} catch {
|
||
timelapseCommits = [];
|
||
}
|
||
|
||
// Always replay midnight-to-now so the clock reads as a natural day
|
||
const midnight = new Date();
|
||
midnight.setHours(0, 0, 0, 0);
|
||
timelapseWindow = { startMs: midnight.getTime(), endMs: Date.now() };
|
||
}
|
||
|
||
/**
|
||
* Fires the visual event for a single replayed commit.
|
||
* @param {{ ts: number, author: string, message: string, hash: string }} commit
|
||
*/
|
||
function fireTimelapseCommit(commit) {
|
||
// Spike the matching agent zone briefly
|
||
const zone = HEATMAP_ZONES.find(z => z.authorMatch.test(commit.author));
|
||
if (zone) {
|
||
zoneIntensity[zone.name] = Math.min(1.0, (zoneIntensity[zone.name] || 0) + 0.4);
|
||
}
|
||
// Shockwave from the commit landing
|
||
triggerShockwave();
|
||
}
|
||
|
||
/**
|
||
* Recalculates heatmap zone intensities from commits within a trailing window
|
||
* ending at virtualMs. Uses a 90-virtual-minute half-life so recent commits
|
||
* stay lit while older ones fade.
|
||
* @param {number} virtualMs
|
||
*/
|
||
function updateTimelapseHeatmap(virtualMs) {
|
||
const WINDOW_MS = 90 * 60 * 1000; // 90 virtual minutes
|
||
const rawWeights = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0]));
|
||
|
||
for (const commit of timelapseCommits) {
|
||
if (commit.ts > virtualMs) break; // array is sorted
|
||
const age = virtualMs - commit.ts;
|
||
if (age > WINDOW_MS) continue;
|
||
const weight = 1 - age / WINDOW_MS;
|
||
for (const zone of HEATMAP_ZONES) {
|
||
if (zone.authorMatch.test(commit.author)) {
|
||
rawWeights[zone.name] += weight;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
const MAX_WEIGHT = 4;
|
||
for (const zone of HEATMAP_ZONES) {
|
||
zoneIntensity[zone.name] = Math.min(rawWeights[zone.name] / MAX_WEIGHT, 1.0);
|
||
}
|
||
drawHeatmap();
|
||
}
|
||
|
||
/**
|
||
* Updates the time-lapse HUD clock and progress bar.
|
||
* @param {number} progress 0..1
|
||
* @param {number} virtualMs
|
||
*/
|
||
function updateTimelapseHUD(progress, virtualMs) {
|
||
if (timelapseClock) {
|
||
const d = new Date(virtualMs);
|
||
const hh = String(d.getHours()).padStart(2, '0');
|
||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||
timelapseClock.textContent = `${hh}:${mm}`;
|
||
}
|
||
if (timelapseBarEl) {
|
||
timelapseBarEl.style.width = `${(progress * 100).toFixed(1)}%`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Starts time-lapse mode: fetches data, resets state, shows HUD.
|
||
*/
|
||
async function startTimelapse() {
|
||
if (timelapseActive) return;
|
||
await loadTimelapseData();
|
||
timelapseActive = true;
|
||
timelapseRealStart = clock.getElapsedTime();
|
||
timelapseProgress = 0;
|
||
timelapseNextCommitIdx = 0;
|
||
|
||
// Clear heatmap to zero — driven entirely by replay
|
||
for (const zone of HEATMAP_ZONES) zoneIntensity[zone.name] = 0;
|
||
drawHeatmap();
|
||
|
||
if (timelapseIndicator) timelapseIndicator.classList.add('visible');
|
||
if (timelapseBtnEl) timelapseBtnEl.classList.add('active');
|
||
}
|
||
|
||
/**
|
||
* Stops time-lapse mode and restores the live heatmap.
|
||
*/
|
||
function stopTimelapse() {
|
||
if (!timelapseActive) return;
|
||
timelapseActive = false;
|
||
if (timelapseIndicator) timelapseIndicator.classList.remove('visible');
|
||
if (timelapseBtnEl) timelapseBtnEl.classList.remove('active');
|
||
// Restore normal heatmap
|
||
updateHeatmap();
|
||
}
|
||
|
||
// Key binding: L to toggle, Esc to stop
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'l' || e.key === 'L') {
|
||
if (timelapseActive) stopTimelapse(); else startTimelapse();
|
||
}
|
||
if (e.key === 'Escape' && timelapseActive) stopTimelapse();
|
||
});
|
||
|
||
// HUD button
|
||
if (timelapseBtnEl) {
|
||
timelapseBtnEl.addEventListener('click', () => {
|
||
if (timelapseActive) stopTimelapse(); else startTimelapse();
|
||
});
|
||
}
|
||
|
||
// === BITCOIN BLOCK HEIGHT ===
|
||
// Polls blockstream.info every 60 s for the current tip block height.
|
||
// Shows a flash animation when the block number increments.
|
||
|
||
const blockHeightDisplay = document.getElementById('block-height-display');
|
||
const blockHeightValue = document.getElementById('block-height-value');
|
||
let lastKnownBlockHeight = null;
|
||
|
||
async function fetchBlockHeight() {
|
||
try {
|
||
const res = await fetch('https://blockstream.info/api/blocks/tip/height');
|
||
if (!res.ok) return;
|
||
const height = parseInt(await res.text(), 10);
|
||
if (isNaN(height)) return;
|
||
|
||
if (lastKnownBlockHeight !== null && height !== lastKnownBlockHeight) {
|
||
// New block — trigger flash
|
||
blockHeightDisplay.classList.remove('fresh');
|
||
// Force reflow so animation restarts
|
||
void blockHeightDisplay.offsetWidth;
|
||
blockHeightDisplay.classList.add('fresh');
|
||
// Pulse stars — chain heartbeat
|
||
_starPulseIntensity = 1.0;
|
||
}
|
||
|
||
lastKnownBlockHeight = height;
|
||
blockHeightValue.textContent = height.toLocaleString();
|
||
} catch (_) {
|
||
// Network unavailable — keep last known value
|
||
}
|
||
}
|
||
|
||
fetchBlockHeight();
|
||
setInterval(fetchBlockHeight, 60000);
|
||
// CI pipeline verification 1774356920
|
||
// CI pipeline test v2 — 1774357376
|