- Adds earthGroup positioned at y=20 with Earth's 23.4° axial tilt - Custom GLSL shader: layered simplex noise for continent shapes, ocean/land coloring with holographic cyan tint, animated scan lines, fresnel rim glow - Lat/lon grid wireframe (every 30°) for classic holographic globe look - Additive atmosphere shell for soft outer glow - PointLight pulses gently in sync with Earth's glow - Slow axial rotation (0.035 rad/s) in animate() loop - zoomLabel 'Planet Earth' for double-click zoom support Fixes #253 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2989 lines
94 KiB
JavaScript
2989 lines
94 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}%`;
|
||
};
|
||
|
||
// Simulate loading a texture for demonstration
|
||
const textureLoader = new THREE.TextureLoader(loadingManager);
|
||
textureLoader.load('placeholder-texture.jpg', (texture) => {
|
||
loadedAssets.set('placeholder-texture', texture);
|
||
});
|
||
|
||
// === SCENE SETUP ===
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(NEXUS.colors.bg);
|
||
|
||
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);
|
||
|
||
const overheadLight = new THREE.PointLight(0x8899bb, 0.6, 60);
|
||
overheadLight.position.set(0, 25, 0);
|
||
scene.add(overheadLight);
|
||
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
renderer.setPixelRatio(window.devicePixelRatio);
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
document.body.appendChild(renderer.domElement);
|
||
|
||
// === STAR FIELD ===
|
||
const STAR_COUNT = 800;
|
||
const STAR_SPREAD = 400;
|
||
const CONSTELLATION_DISTANCE = 30; // max distance to draw a line between stars
|
||
|
||
const starPositions = [];
|
||
const starGeo = new THREE.BufferGeometry();
|
||
const posArray = new Float32Array(STAR_COUNT * 3);
|
||
const sizeArray = new Float32Array(STAR_COUNT);
|
||
|
||
for (let i = 0; i < STAR_COUNT; i++) {
|
||
const x = (Math.random() - 0.5) * STAR_SPREAD;
|
||
const y = (Math.random() - 0.5) * STAR_SPREAD;
|
||
const z = (Math.random() - 0.5) * STAR_SPREAD;
|
||
posArray[i * 3] = x;
|
||
posArray[i * 3 + 1] = y;
|
||
posArray[i * 3 + 2] = z;
|
||
sizeArray[i] = Math.random() * 2.5 + 0.5;
|
||
starPositions.push(new THREE.Vector3(x, y, z));
|
||
}
|
||
|
||
starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
|
||
starGeo.setAttribute('size', new THREE.BufferAttribute(sizeArray, 1));
|
||
|
||
const starMaterial = new THREE.PointsMaterial({
|
||
color: NEXUS.colors.starCore,
|
||
size: 0.6,
|
||
sizeAttenuation: true,
|
||
transparent: true,
|
||
opacity: 0.9,
|
||
});
|
||
|
||
const stars = new THREE.Points(starGeo, starMaterial);
|
||
scene.add(stars);
|
||
|
||
// === CONSTELLATION LINES ===
|
||
// Connect nearby stars with faint lines, limited to avoid clutter
|
||
/**
|
||
* Builds constellation line segments connecting nearby stars.
|
||
* @returns {THREE.LineSegments}
|
||
*/
|
||
function buildConstellationLines() {
|
||
const linePositions = [];
|
||
const MAX_CONNECTIONS_PER_STAR = 3;
|
||
const connectionCount = new Array(STAR_COUNT).fill(0);
|
||
|
||
for (let i = 0; i < STAR_COUNT; i++) {
|
||
if (connectionCount[i] >= MAX_CONNECTIONS_PER_STAR) continue;
|
||
|
||
// Find nearest neighbors
|
||
const neighbors = [];
|
||
for (let j = i + 1; j < STAR_COUNT; j++) {
|
||
if (connectionCount[j] >= MAX_CONNECTIONS_PER_STAR) continue;
|
||
const dist = starPositions[i].distanceTo(starPositions[j]);
|
||
if (dist < CONSTELLATION_DISTANCE) {
|
||
neighbors.push({ j, dist });
|
||
}
|
||
}
|
||
|
||
// Sort by distance and connect closest ones
|
||
neighbors.sort((/** @type {{j: number, dist: number}} */ a, /** @type {{j: number, dist: number}} */ b) => a.dist - b.dist);
|
||
const toConnect = neighbors.slice(0, MAX_CONNECTIONS_PER_STAR - connectionCount[i]);
|
||
|
||
for (const { j } of toConnect) {
|
||
linePositions.push(
|
||
starPositions[i].x, starPositions[i].y, starPositions[i].z,
|
||
starPositions[j].x, starPositions[j].y, starPositions[j].z
|
||
);
|
||
connectionCount[i]++;
|
||
connectionCount[j]++;
|
||
}
|
||
}
|
||
|
||
const lineGeo = new THREE.BufferGeometry();
|
||
lineGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(linePositions), 3));
|
||
|
||
const lineMat = new THREE.LineBasicMaterial({
|
||
color: NEXUS.colors.constellationLine,
|
||
transparent: true,
|
||
opacity: 0.18,
|
||
});
|
||
|
||
return new THREE.LineSegments(lineGeo, lineMat);
|
||
}
|
||
|
||
const constellationLines = buildConstellationLines();
|
||
scene.add(constellationLines);
|
||
|
||
// === GLASS PLATFORM ===
|
||
// Central floating platform with transparent glass-floor sections revealing the void (star field) below.
|
||
|
||
const glassPlatformGroup = new THREE.Group();
|
||
|
||
// Dark metallic frame material
|
||
const platformFrameMat = new THREE.MeshStandardMaterial({
|
||
color: 0x0a1828,
|
||
metalness: 0.9,
|
||
roughness: 0.1,
|
||
emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.06),
|
||
});
|
||
|
||
// Outer solid rim (flat ring)
|
||
const platformRimGeo = new THREE.RingGeometry(4.7, 5.3, 64);
|
||
const platformRim = new THREE.Mesh(platformRimGeo, platformFrameMat);
|
||
platformRim.rotation.x = -Math.PI / 2;
|
||
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;
|
||
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 fBm (fractional Brownian motion) layered Perlin noise.
|
||
|
||
(function buildFloatingIsland() {
|
||
const ISLAND_RADIUS = 9.5;
|
||
const SEGMENTS = 90;
|
||
const SIZE = ISLAND_RADIUS * 2;
|
||
|
||
const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEGMENTS, SEGMENTS);
|
||
geo.rotateX(-Math.PI / 2);
|
||
const pos = geo.attributes.position;
|
||
const count = pos.count;
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const x = pos.getX(i);
|
||
const z = pos.getZ(i);
|
||
const dist = Math.sqrt(x * x + z * z) / ISLAND_RADIUS;
|
||
|
||
// Island edge taper — smooth falloff toward rim
|
||
const edgeFactor = Math.max(0, 1 - Math.pow(dist, 2.2));
|
||
|
||
// fBm: four octaves of Perlin noise
|
||
const nx = x * 0.17, nz = z * 0.17;
|
||
let h = 0;
|
||
h += perlin(nx, nz ) * 1.000;
|
||
h += perlin(nx * 2, nz * 2 ) * 0.500;
|
||
h += perlin(nx * 4, nz * 4 ) * 0.250;
|
||
h += perlin(nx * 8, nz * 8 ) * 0.125;
|
||
h /= 1.875; // normalise to ~[-1, 1]
|
||
|
||
const height = ((h + 1) * 0.5) * edgeFactor * 2.6;
|
||
pos.setY(i, height);
|
||
}
|
||
|
||
geo.computeVertexNormals();
|
||
|
||
// Vertex colours: low=dark earth, mid=dusty stone, high=pale rock
|
||
const colors = new Float32Array(count * 3);
|
||
for (let i = 0; i < count; i++) {
|
||
const t = Math.min(1, pos.getY(i) / 2.0);
|
||
colors[i * 3] = 0.18 + t * 0.22; // R
|
||
colors[i * 3 + 1] = 0.14 + t * 0.16; // G
|
||
colors[i * 3 + 2] = 0.10 + t * 0.15; // B
|
||
}
|
||
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
|
||
const topMat = new THREE.MeshStandardMaterial({
|
||
vertexColors: true,
|
||
roughness: 0.88,
|
||
metalness: 0.04,
|
||
});
|
||
const topMesh = new THREE.Mesh(geo, topMat);
|
||
|
||
// Underside — tapered cylinder giving the island its rocky underbelly
|
||
const bottomGeo = new THREE.CylinderGeometry(ISLAND_RADIUS * 0.82, ISLAND_RADIUS * 0.35, 2.0, 64, 1);
|
||
const bottomMat = new THREE.MeshStandardMaterial({ color: 0x0c0a08, roughness: 0.92, metalness: 0.03 });
|
||
const bottomMesh = new THREE.Mesh(bottomGeo, bottomMat);
|
||
bottomMesh.position.y = -1.0;
|
||
|
||
const islandGroup = new THREE.Group();
|
||
islandGroup.add(topMesh);
|
||
islandGroup.add(bottomMesh);
|
||
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;
|
||
|
||
// Perlin noise function (simplified, 3D)
|
||
// Source: https://thebookofshaders.com/11/
|
||
vec3 mod289(vec3 x) { return x - floor(x / 289.0) * 289.0; }
|
||
vec4 mod289(vec4 x) { return x - floor(x / 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 h = 1.0 - abs(x_ - ns.x) - abs(y_ - ns.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 - floor(b0);
|
||
vec4 a1 = b1 - floor(b1);
|
||
vec3 p0 = vec3(a0.xy, h.x);
|
||
vec3 p1 = vec3(a1.xy, h.y);
|
||
vec3 p2 = vec3(a0.zw, 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() {
|
||
// Offset by time for animation
|
||
vec3 noiseCoord = vWorldPosition * uNoiseScale + vec3(0.0, uTime * 0.005, uTime * 0.003);
|
||
|
||
// Combine multiple octaves of noise for a more detailed cloud
|
||
float noiseVal = snoise(noiseCoord * 1.0) * 0.5;
|
||
noiseVal += snoise(noiseCoord * 2.0) * 0.25;
|
||
noiseVal += snoise(noiseCoord * 4.0) * 0.125;
|
||
noiseVal = noiseVal / (1.0 + 0.5 + 0.25 + 0.125); // Normalize
|
||
|
||
// Remap noise to a more cloud-like density curve
|
||
float density = smoothstep(0.3, 1.0, noiseVal * 0.5 + 0.5); // 0.3-1.0 to give more wispy edges
|
||
density *= uDensity;
|
||
|
||
// Make clouds fade out towards the top and bottom of the layer
|
||
float yPosNormalized = (vWorldPosition.y - (CLOUD_LAYER_Y - CLOUD_THICKNESS / 2.0)) / CLOUD_THICKNESS;
|
||
float fadeFactor = 1.0 - smoothstep(0.0, 0.2, yPosNormalized) * smoothstep(1.0, 0.8, yPosNormalized);
|
||
|
||
gl_FragColor = vec4(uCloudColor, density * fadeFactor * CLOUD_OPACITY);
|
||
if (gl_FragColor.a < 0.05) discard; // Don't render very transparent pixels
|
||
}
|
||
`,
|
||
};
|
||
|
||
const cloudMaterial = new THREE.ShaderMaterial({
|
||
uniforms: CloudShader.uniforms,
|
||
vertexShader: CloudShader.vertexShader,
|
||
fragmentShader: CloudShader.fragmentShader,
|
||
transparent: true,
|
||
depthWrite: false, // Important for proper blending of transparent objects
|
||
blending: THREE.AdditiveBlending, // Optional: gives a more ethereal look
|
||
side: THREE.DoubleSide,
|
||
});
|
||
|
||
const clouds = new THREE.Mesh(cloudGeometry, cloudMaterial);
|
||
clouds.position.y = CLOUD_LAYER_Y;
|
||
scene.add(clouds);
|
||
|
||
// === COMMIT HEATMAP ===
|
||
// Canvas-texture overlay on the floor. Each agent occupies a polar sector;
|
||
// recent commits make that sector glow brighter. Activity decays over 24 h.
|
||
|
||
const HEATMAP_SIZE = 512;
|
||
const HEATMAP_REFRESH_MS = 5 * 60 * 1000; // 5 min between API polls
|
||
const HEATMAP_DECAY_MS = 24 * 60 * 60 * 1000; // 24 h full decay
|
||
|
||
// Agent zones — angle in canvas degrees (0 = east/right, clockwise)
|
||
const HEATMAP_ZONES = [
|
||
{ name: 'Claude', color: [255, 100, 60], authorMatch: /^claude$/i, angleDeg: 0 },
|
||
{ name: 'Timmy', color: [ 60, 160, 255], authorMatch: /^timmy/i, angleDeg: 90 },
|
||
{ name: 'Kimi', color: [ 60, 255, 140], authorMatch: /^kimi/i, angleDeg: 180 },
|
||
{ name: 'Perplexity', color: [200, 60, 255], authorMatch: /^perplexity/i, angleDeg: 270 },
|
||
];
|
||
const HEATMAP_ZONE_SPAN_RAD = Math.PI / 2; // 90° per zone
|
||
|
||
const heatmapCanvas = document.createElement('canvas');
|
||
heatmapCanvas.width = HEATMAP_SIZE;
|
||
heatmapCanvas.height = HEATMAP_SIZE;
|
||
const heatmapTexture = new THREE.CanvasTexture(heatmapCanvas);
|
||
|
||
const heatmapMat = new THREE.MeshBasicMaterial({
|
||
map: heatmapTexture,
|
||
transparent: true,
|
||
opacity: 0.9,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
side: THREE.DoubleSide,
|
||
});
|
||
|
||
const heatmapMesh = new THREE.Mesh(
|
||
new THREE.CircleGeometry(GLASS_RADIUS, 64),
|
||
heatmapMat
|
||
);
|
||
heatmapMesh.rotation.x = -Math.PI / 2;
|
||
heatmapMesh.position.y = 0.005;
|
||
heatmapMesh.userData.zoomLabel = 'Activity Heatmap';
|
||
scene.add(heatmapMesh);
|
||
|
||
// Per-zone intensity [0..1], updated by updateHeatmap()
|
||
const zoneIntensity = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0]));
|
||
|
||
/**
|
||
* Redraws the heatmap canvas from current zoneIntensity values.
|
||
*/
|
||
function drawHeatmap() {
|
||
const ctx = heatmapCanvas.getContext('2d');
|
||
const cx = HEATMAP_SIZE / 2;
|
||
const cy = HEATMAP_SIZE / 2;
|
||
const r = cx * 0.96;
|
||
|
||
ctx.clearRect(0, 0, HEATMAP_SIZE, HEATMAP_SIZE);
|
||
|
||
// Clip drawing to the circular platform boundary
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||
ctx.clip();
|
||
|
||
for (const zone of HEATMAP_ZONES) {
|
||
const intensity = zoneIntensity[zone.name] || 0;
|
||
if (intensity < 0.01) continue;
|
||
|
||
const [rr, gg, bb] = zone.color;
|
||
const baseRad = zone.angleDeg * (Math.PI / 180);
|
||
const startRad = baseRad - HEATMAP_ZONE_SPAN_RAD / 2;
|
||
const endRad = baseRad + HEATMAP_ZONE_SPAN_RAD / 2;
|
||
|
||
// Glow origin sits at 55% radius in the zone's direction
|
||
const gx = cx + Math.cos(baseRad) * r * 0.55;
|
||
const gy = cy + Math.sin(baseRad) * r * 0.55;
|
||
|
||
const grad = ctx.createRadialGradient(gx, gy, 0, gx, gy, r * 0.75);
|
||
grad.addColorStop(0, `rgba(${rr},${gg},${bb},${0.65 * intensity})`);
|
||
grad.addColorStop(0.45, `rgba(${rr},${gg},${bb},${0.25 * intensity})`);
|
||
grad.addColorStop(1, `rgba(${rr},${gg},${bb},0)`);
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy);
|
||
ctx.arc(cx, cy, r, startRad, endRad);
|
||
ctx.closePath();
|
||
ctx.fillStyle = grad;
|
||
ctx.fill();
|
||
|
||
// Zone label — only when active
|
||
if (intensity > 0.05) {
|
||
const labelX = cx + Math.cos(baseRad) * r * 0.62;
|
||
const labelY = cy + Math.sin(baseRad) * r * 0.62;
|
||
ctx.font = `bold ${Math.round(13 * intensity + 7)}px "Courier New", monospace`;
|
||
ctx.fillStyle = `rgba(${rr},${gg},${bb},${Math.min(intensity * 1.2, 0.9)})`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(zone.name, labelX, labelY);
|
||
}
|
||
}
|
||
|
||
ctx.restore();
|
||
heatmapTexture.needsUpdate = true;
|
||
}
|
||
|
||
/**
|
||
* Fetches recent commits, maps them to agent zones via author, and redraws.
|
||
*/
|
||
async function updateHeatmap() {
|
||
let commits = [];
|
||
try {
|
||
const res = await fetch(
|
||
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=50',
|
||
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
|
||
);
|
||
if (res.ok) commits = await res.json();
|
||
} catch { /* silently use zero-activity baseline */ }
|
||
|
||
const now = Date.now();
|
||
const rawWeights = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0]));
|
||
|
||
for (const commit of commits) {
|
||
const author = commit.commit?.author?.name || commit.author?.login || '';
|
||
const ts = new Date(commit.commit?.author?.date || 0).getTime();
|
||
const age = now - ts;
|
||
if (age > HEATMAP_DECAY_MS) continue;
|
||
const weight = 1 - age / HEATMAP_DECAY_MS; // linear decay
|
||
|
||
for (const zone of HEATMAP_ZONES) {
|
||
if (zone.authorMatch.test(author)) {
|
||
rawWeights[zone.name] += weight;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Normalise: 8 recent weighted commits = full brightness
|
||
const MAX_WEIGHT = 8;
|
||
for (const zone of HEATMAP_ZONES) {
|
||
zoneIntensity[zone.name] = Math.min(rawWeights[zone.name] / MAX_WEIGHT, 1.0);
|
||
}
|
||
|
||
drawHeatmap();
|
||
}
|
||
|
||
// Kick off and schedule periodic refresh
|
||
updateHeatmap();
|
||
setInterval(updateHeatmap, HEATMAP_REFRESH_MS);
|
||
|
||
// === 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 = 1.5; // seconds
|
||
|
||
// Post-processing composer for depth of field (always-on, subtle)
|
||
const composer = new EffectComposer(renderer);
|
||
composer.addPass(new RenderPass(scene, camera));
|
||
|
||
const bokehPass = new BokehPass(scene, camera, {
|
||
focus: 5.0,
|
||
aperture: 0.00015,
|
||
maxblur: 0.004,
|
||
});
|
||
composer.addPass(bokehPass);
|
||
|
||
// Orbit controls for free camera movement in photo mode
|
||
const orbitControls = new OrbitControls(camera, renderer.domElement);
|
||
orbitControls.enableDamping = true;
|
||
orbitControls.dampingFactor = 0.05;
|
||
orbitControls.enabled = false;
|
||
|
||
const photoIndicator = document.getElementById('photo-indicator');
|
||
const photoFocusDisplay = document.getElementById('photo-focus');
|
||
|
||
/**
|
||
* Updates the photo mode focus distance display.
|
||
*/
|
||
function updateFocusDisplay() {
|
||
if (photoFocusDisplay) {
|
||
photoFocusDisplay.textContent = bokehPass.uniforms['focus'].value.toFixed(1);
|
||
}
|
||
}
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'p' || e.key === 'P') {
|
||
photoMode = !photoMode;
|
||
document.body.classList.toggle('photo-mode', photoMode);
|
||
orbitControls.enabled = photoMode;
|
||
if (photoIndicator) {
|
||
photoIndicator.classList.toggle('visible', photoMode);
|
||
}
|
||
if (photoMode) {
|
||
// Enhanced DoF in photo mode
|
||
bokehPass.uniforms['aperture'].value = 0.0003;
|
||
bokehPass.uniforms['maxblur'].value = 0.008;
|
||
// Sync orbit target to current look-at
|
||
orbitControls.target.set(0, 0, 0);
|
||
orbitControls.update();
|
||
updateFocusDisplay();
|
||
} else {
|
||
// Restore subtle ambient DoF
|
||
bokehPass.uniforms['aperture'].value = 0.00015;
|
||
bokehPass.uniforms['maxblur'].value = 0.004;
|
||
}
|
||
}
|
||
|
||
// Adjust focus with [ ] while in photo mode
|
||
if (photoMode) {
|
||
const focusStep = 0.5;
|
||
if (e.key === '[') {
|
||
bokehPass.uniforms['focus'].value = Math.max(0.5, bokehPass.uniforms['focus'].value - focusStep);
|
||
updateFocusDisplay();
|
||
} else if (e.key === ']') {
|
||
bokehPass.uniforms['focus'].value = Math.min(200, bokehPass.uniforms['focus'].value + focusStep);
|
||
updateFocusDisplay();
|
||
}
|
||
}
|
||
});
|
||
|
||
// === RESIZE HANDLER ===
|
||
window.addEventListener('resize', () => {
|
||
camera.aspect = window.innerWidth / window.innerHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
composer.setSize(window.innerWidth, window.innerHeight);
|
||
});
|
||
|
||
// === SOVEREIGNTY METER ===
|
||
// Holographic arc gauge floating above the platform; reads from sovereignty-status.json
|
||
const sovereigntyGroup = new THREE.Group();
|
||
sovereigntyGroup.position.set(0, 3.8, 0);
|
||
|
||
// Background ring — full circle, dark frame
|
||
const meterBgGeo = new THREE.TorusGeometry(1.6, 0.1, 8, 64);
|
||
const meterBgMat = new THREE.MeshBasicMaterial({ color: 0x0a1828, transparent: true, opacity: 0.5 });
|
||
sovereigntyGroup.add(new THREE.Mesh(meterBgGeo, meterBgMat));
|
||
|
||
let sovereigntyScore = 85;
|
||
let sovereigntyLabel = 'Mostly Sovereign';
|
||
|
||
function sovereigntyHexColor(score) {
|
||
if (score >= 80) return 0x00ff88;
|
||
if (score >= 40) return 0xffcc00;
|
||
return 0xff4444;
|
||
}
|
||
|
||
function buildScoreArcGeo(score) {
|
||
return new THREE.TorusGeometry(1.6, 0.1, 8, 64, (score / 100) * Math.PI * 2);
|
||
}
|
||
|
||
const scoreArcMat = new THREE.MeshBasicMaterial({
|
||
color: sovereigntyHexColor(sovereigntyScore),
|
||
transparent: true,
|
||
opacity: 0.9,
|
||
});
|
||
const scoreArcMesh = new THREE.Mesh(buildScoreArcGeo(sovereigntyScore), scoreArcMat);
|
||
scoreArcMesh.rotation.z = Math.PI / 2; // arc starts at 12 o'clock
|
||
sovereigntyGroup.add(scoreArcMesh);
|
||
|
||
// Glow light at gauge center
|
||
const meterLight = new THREE.PointLight(sovereigntyHexColor(sovereigntyScore), 0.7, 6);
|
||
sovereigntyGroup.add(meterLight);
|
||
|
||
function buildMeterTexture(score, label) {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 256;
|
||
canvas.height = 128;
|
||
const ctx = canvas.getContext('2d');
|
||
const hexStr = score >= 80 ? '#00ff88' : score >= 40 ? '#ffcc00' : '#ff4444';
|
||
ctx.clearRect(0, 0, 256, 128);
|
||
ctx.font = 'bold 52px "Courier New", monospace';
|
||
ctx.fillStyle = hexStr;
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(`${score}%`, 128, 58);
|
||
ctx.font = '16px "Courier New", monospace';
|
||
ctx.fillStyle = '#8899bb';
|
||
ctx.fillText(label.toUpperCase(), 128, 82);
|
||
ctx.font = '11px "Courier New", monospace';
|
||
ctx.fillStyle = '#445566';
|
||
ctx.fillText('SOVEREIGNTY', 128, 104);
|
||
return new THREE.CanvasTexture(canvas);
|
||
}
|
||
|
||
const meterSpriteMat = new THREE.SpriteMaterial({
|
||
map: buildMeterTexture(sovereigntyScore, sovereigntyLabel),
|
||
transparent: true,
|
||
depthWrite: false,
|
||
});
|
||
const meterSprite = new THREE.Sprite(meterSpriteMat);
|
||
meterSprite.scale.set(3.2, 1.6, 1);
|
||
sovereigntyGroup.add(meterSprite);
|
||
|
||
scene.add(sovereigntyGroup);
|
||
sovereigntyGroup.traverse(obj => {
|
||
if (obj.isMesh || obj.isSprite) obj.userData.zoomLabel = 'Sovereignty Meter';
|
||
});
|
||
|
||
async function loadSovereigntyStatus() {
|
||
try {
|
||
const res = await fetch('./sovereignty-status.json');
|
||
if (!res.ok) throw new Error('not found');
|
||
const data = await res.json();
|
||
const score = Math.max(0, Math.min(100, typeof data.score === 'number' ? data.score : 85));
|
||
const label = typeof data.label === 'string' ? data.label : '';
|
||
sovereigntyScore = score;
|
||
sovereigntyLabel = label;
|
||
scoreArcMesh.geometry.dispose();
|
||
scoreArcMesh.geometry = buildScoreArcGeo(score);
|
||
const col = sovereigntyHexColor(score);
|
||
scoreArcMat.color.setHex(col);
|
||
meterLight.color.setHex(col);
|
||
if (meterSpriteMat.map) meterSpriteMat.map.dispose();
|
||
meterSpriteMat.map = buildMeterTexture(score, label);
|
||
meterSpriteMat.needsUpdate = true;
|
||
} catch {
|
||
// defaults already set above
|
||
}
|
||
}
|
||
|
||
loadSovereigntyStatus();
|
||
|
||
// === RUNE RING ===
|
||
// 12 Elder Futhark rune sprites in a slow-orbiting ring around the center platform.
|
||
|
||
const RUNE_COUNT = 12;
|
||
const RUNE_RING_RADIUS = 7.0;
|
||
const RUNE_RING_Y = 1.5; // base height above platform
|
||
const RUNE_ORBIT_SPEED = 0.08; // radians per second
|
||
|
||
const ELDER_FUTHARK = ['ᚠ','ᚢ','ᚦ','ᚨ','ᚱ','ᚲ','ᚷ','ᚹ','ᚺ','ᚾ','ᛁ','ᛃ'];
|
||
const RUNE_GLOW_COLORS = ['#00ffcc', '#ff44ff']; // alternating cyan / magenta
|
||
|
||
/**
|
||
* Creates a canvas texture for a single glowing rune glyph.
|
||
* @param {string} glyph
|
||
* @param {string} color
|
||
* @returns {THREE.CanvasTexture}
|
||
*/
|
||
function createRuneTexture(glyph, color) {
|
||
const W = 128, H = 128;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = W;
|
||
canvas.height = H;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
// Outer glow
|
||
ctx.shadowColor = color;
|
||
ctx.shadowBlur = 28;
|
||
|
||
ctx.font = 'bold 78px serif';
|
||
ctx.fillStyle = color;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(glyph, W / 2, H / 2);
|
||
|
||
return new THREE.CanvasTexture(canvas);
|
||
}
|
||
|
||
// Faint torus marking the orbit height
|
||
const runeOrbitRingGeo = new THREE.TorusGeometry(RUNE_RING_RADIUS, 0.03, 6, 64);
|
||
const runeOrbitRingMat = new THREE.MeshBasicMaterial({
|
||
color: 0x224466,
|
||
transparent: true,
|
||
opacity: 0.22,
|
||
});
|
||
const runeOrbitRingMesh = new THREE.Mesh(runeOrbitRingGeo, runeOrbitRingMat);
|
||
runeOrbitRingMesh.rotation.x = Math.PI / 2;
|
||
runeOrbitRingMesh.position.y = RUNE_RING_Y;
|
||
scene.add(runeOrbitRingMesh);
|
||
|
||
/**
|
||
* @type {Array<{sprite: THREE.Sprite, baseAngle: number, floatPhase: number}>}
|
||
*/
|
||
const runeSprites = [];
|
||
|
||
for (let i = 0; i < RUNE_COUNT; i++) {
|
||
const glyph = ELDER_FUTHARK[i % ELDER_FUTHARK.length];
|
||
const color = RUNE_GLOW_COLORS[i % RUNE_GLOW_COLORS.length];
|
||
const texture = createRuneTexture(glyph, color);
|
||
|
||
const runeMat = new THREE.SpriteMaterial({
|
||
map: texture,
|
||
transparent: true,
|
||
opacity: 0.85,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
});
|
||
const sprite = new THREE.Sprite(runeMat);
|
||
sprite.scale.set(1.3, 1.3, 1);
|
||
|
||
const baseAngle = (i / RUNE_COUNT) * Math.PI * 2;
|
||
sprite.position.set(
|
||
Math.cos(baseAngle) * RUNE_RING_RADIUS,
|
||
RUNE_RING_Y,
|
||
Math.sin(baseAngle) * RUNE_RING_RADIUS
|
||
);
|
||
scene.add(sprite);
|
||
runeSprites.push({ sprite, baseAngle, floatPhase: (i / RUNE_COUNT) * Math.PI * 2 });
|
||
}
|
||
|
||
|
||
// === HOLOGRAPHIC EARTH ===
|
||
// A procedural holographic planet Earth slowly rotating above the Nexus.
|
||
// Continents rendered via layered simplex noise on UV-sphere coords.
|
||
// Holographic effects: scan lines, fresnel rim glow, lat/lon grid overlay.
|
||
|
||
const EARTH_RADIUS = 2.8;
|
||
const EARTH_Y = 20.0;
|
||
const EARTH_ROTATION_SPEED = 0.035; // radians per second — gentle drift
|
||
const EARTH_AXIAL_TILT = 23.4 * (Math.PI / 180);
|
||
|
||
const earthGroup = new THREE.Group();
|
||
earthGroup.position.set(0, EARTH_Y, 0);
|
||
earthGroup.rotation.z = EARTH_AXIAL_TILT;
|
||
scene.add(earthGroup);
|
||
|
||
// Surface shader — continents via noise, holographic scan lines, fresnel rim
|
||
const earthSurfaceMat = new THREE.ShaderMaterial({
|
||
uniforms: {
|
||
uTime: { value: 0.0 },
|
||
uOceanColor: { value: new THREE.Color(0x003d99) },
|
||
uLandColor: { value: new THREE.Color(0x1a5c2a) },
|
||
uGlowColor: { value: new THREE.Color(NEXUS.colors.accent) },
|
||
},
|
||
vertexShader: `
|
||
varying vec3 vNormal;
|
||
varying vec3 vWorldPos;
|
||
varying vec2 vUv;
|
||
void main() {
|
||
vNormal = normalize(normalMatrix * normal);
|
||
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
|
||
vUv = uv;
|
||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||
}
|
||
`,
|
||
fragmentShader: `
|
||
uniform float uTime;
|
||
uniform vec3 uOceanColor;
|
||
uniform vec3 uLandColor;
|
||
uniform vec3 uGlowColor;
|
||
varying vec3 vNormal;
|
||
varying vec3 vWorldPos;
|
||
varying vec2 vUv;
|
||
|
||
// Simplex noise (3-D)
|
||
vec3 _m3(vec3 x){ return x - floor(x*(1./289.))*289.; }
|
||
vec4 _m4(vec4 x){ return x - floor(x*(1./289.))*289.; }
|
||
vec4 _p4(vec4 x){ return _m4((x*34.+1.)*x); }
|
||
float snoise(vec3 v){
|
||
const vec2 C = vec2(1./6., 1./3.);
|
||
vec3 i = floor(v + dot(v, C.yyy));
|
||
vec3 x0 = v - i + dot(i, C.xxx);
|
||
vec3 g = step(x0.yzx, x0.xyz);
|
||
vec3 l = 1.0 - g;
|
||
vec3 i1 = min(g.xyz, l.zxy);
|
||
vec3 i2 = max(g.xyz, l.zxy);
|
||
vec3 x1 = x0 - i1 + C.xxx;
|
||
vec3 x2 = x0 - i2 + C.yyy;
|
||
vec3 x3 = x0 - 0.5;
|
||
i = _m3(i);
|
||
vec4 p = _p4(_p4(_p4(
|
||
i.z+vec4(0.,i1.z,i2.z,1.))+
|
||
i.y+vec4(0.,i1.y,i2.y,1.))+
|
||
i.x+vec4(0.,i1.x,i2.x,1.));
|
||
float n_ = .142857142857;
|
||
vec3 ns = n_*vec3(2.,0.,-1.)+vec3(0.,-.5,1.);
|
||
vec4 j = p - 49.*floor(p*ns.z*ns.z);
|
||
vec4 x_ = floor(j*ns.z);
|
||
vec4 y_ = floor(j - 7.*x_);
|
||
vec4 h = 1. - abs(x_*(2./7.)) - abs(y_*(2./7.));
|
||
vec4 b0 = vec4(x_.xy,y_.xy)*(2./7.);
|
||
vec4 b1 = vec4(x_.zw,y_.zw)*(2./7.);
|
||
vec4 s0 = floor(b0)*2.+1.; vec4 s1 = floor(b1)*2.+1.;
|
||
vec4 sh = -step(h, vec4(0.));
|
||
vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy;
|
||
vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww;
|
||
vec3 p0=vec3(a0.xy,h.x); vec3 p1=vec3(a0.zw,h.y);
|
||
vec3 p2=vec3(a1.xy,h.z); vec3 p3=vec3(a1.zw,h.w);
|
||
vec4 nm = max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.);
|
||
vec4 nr = 1.79284291400159-0.85373472095314*nm;
|
||
p0*=nr.x; p1*=nr.y; p2*=nr.z; p3*=nr.w;
|
||
nm = nm*nm;
|
||
return 42.*dot(nm*nm, vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
|
||
}
|
||
|
||
void main() {
|
||
vec3 n = normalize(vNormal);
|
||
vec3 vd = normalize(cameraPosition - vWorldPos);
|
||
|
||
// Stable spherical sample coords from UV (fixed to sphere surface)
|
||
float lat = (vUv.y - 0.5) * 3.14159265;
|
||
float lon = vUv.x * 6.28318530;
|
||
vec3 sp = vec3(cos(lat)*cos(lon), sin(lat), cos(lat)*sin(lon));
|
||
|
||
// Layered noise for continent shapes
|
||
float c = snoise(sp*1.8)*0.60
|
||
+ snoise(sp*3.6)*0.30
|
||
+ snoise(sp*7.2)*0.10;
|
||
float land = smoothstep(0.05, 0.30, c);
|
||
|
||
// Surface colour: ocean / land, holographic-tinted
|
||
vec3 surf = mix(uOceanColor, uLandColor, land);
|
||
surf = mix(surf, uGlowColor * 0.45, 0.38);
|
||
|
||
// Scan lines (horizontal bands)
|
||
float scan = 0.5 + 0.5*sin(vUv.y * 220.0 + uTime * 1.8);
|
||
scan = smoothstep(0.30, 0.70, scan) * 0.14;
|
||
|
||
// Fresnel rim glow
|
||
float fresnel = pow(1.0 - max(dot(n, vd), 0.0), 4.0);
|
||
|
||
vec3 col = surf + scan*uGlowColor*0.9 + fresnel*uGlowColor*1.5;
|
||
float alpha = 0.48 + fresnel * 0.42;
|
||
|
||
gl_FragColor = vec4(col, alpha);
|
||
}
|
||
`,
|
||
transparent: true,
|
||
depthWrite: false,
|
||
side: THREE.FrontSide,
|
||
});
|
||
|
||
const earthSphere = new THREE.SphereGeometry(EARTH_RADIUS, 64, 32);
|
||
const earthMesh = new THREE.Mesh(earthSphere, earthSurfaceMat);
|
||
earthMesh.userData.zoomLabel = 'Planet Earth';
|
||
earthGroup.add(earthMesh);
|
||
|
||
// Lat/lon grid lines
|
||
(function buildEarthGrid() {
|
||
const lineMat = new THREE.LineBasicMaterial({
|
||
color: 0x2266bb,
|
||
transparent: true,
|
||
opacity: 0.30,
|
||
});
|
||
const r = EARTH_RADIUS + 0.015;
|
||
const SEG = 64;
|
||
|
||
// Latitude rings every 30°
|
||
for (let lat = -60; lat <= 60; lat += 30) {
|
||
const phi = lat * (Math.PI / 180);
|
||
const pts = [];
|
||
for (let i = 0; i <= SEG; i++) {
|
||
const th = (i / SEG) * Math.PI * 2;
|
||
pts.push(new THREE.Vector3(
|
||
Math.cos(phi) * Math.cos(th) * r,
|
||
Math.sin(phi) * r,
|
||
Math.cos(phi) * Math.sin(th) * r
|
||
));
|
||
}
|
||
earthGroup.add(new THREE.Line(
|
||
new THREE.BufferGeometry().setFromPoints(pts), lineMat
|
||
));
|
||
}
|
||
|
||
// Longitude meridians every 30°
|
||
for (let lon = 0; lon < 360; lon += 30) {
|
||
const th = lon * (Math.PI / 180);
|
||
const pts = [];
|
||
for (let i = 0; i <= SEG; i++) {
|
||
const phi = (i / SEG) * Math.PI - Math.PI / 2;
|
||
pts.push(new THREE.Vector3(
|
||
Math.cos(phi) * Math.cos(th) * r,
|
||
Math.sin(phi) * r,
|
||
Math.cos(phi) * Math.sin(th) * r
|
||
));
|
||
}
|
||
earthGroup.add(new THREE.Line(
|
||
new THREE.BufferGeometry().setFromPoints(pts), lineMat
|
||
));
|
||
}
|
||
})();
|
||
|
||
// Atmosphere shell — soft additive glow around the globe
|
||
const atmMat = new THREE.MeshBasicMaterial({
|
||
color: 0x1144cc,
|
||
transparent: true,
|
||
opacity: 0.07,
|
||
side: THREE.BackSide,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
});
|
||
earthGroup.add(new THREE.Mesh(
|
||
new THREE.SphereGeometry(EARTH_RADIUS * 1.14, 32, 16), atmMat
|
||
));
|
||
|
||
// Soft blue point light emanating from Earth
|
||
const earthGlowLight = new THREE.PointLight(NEXUS.colors.accent, 0.4, 25);
|
||
earthGroup.add(earthGlowLight);
|
||
|
||
earthGroup.traverse(obj => {
|
||
if (obj.isMesh || obj.isLine) obj.userData.zoomLabel = 'Planet Earth';
|
||
});
|
||
|
||
// === WARP TUNNEL EFFECT ===
|
||
const WarpShader = {
|
||
uniforms: {
|
||
'tDiffuse': { value: null },
|
||
'time': { value: 0.0 },
|
||
'distortionStrength': { value: 0.0 },
|
||
},
|
||
|
||
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 distortionStrength;
|
||
varying vec2 vUv;
|
||
|
||
void main() {
|
||
vec2 uv = vUv;
|
||
vec2 center = vec2(0.5, 0.5);
|
||
|
||
// Simple swirling distortion
|
||
vec2 dir = uv - center;
|
||
float angle = atan(dir.y, dir.x);
|
||
float radius = length(dir);
|
||
|
||
angle += radius * distortionStrength * sin(time * 5.0 + radius * 10.0);
|
||
radius *= 1.0 - distortionStrength * 0.1 * sin(time * 3.0 + radius * 5.0);
|
||
|
||
uv = center + vec2(cos(angle), sin(angle)) * radius;
|
||
|
||
gl_FragColor = texture2D(tDiffuse, uv);
|
||
}
|
||
`,
|
||
};
|
||
|
||
let warpPass = new ShaderPass(WarpShader);
|
||
warpPass.enabled = false;
|
||
composer.addPass(warpPass);
|
||
|
||
|
||
/**
|
||
* Triggers the warp tunnel effect.
|
||
*/
|
||
function startWarp() {
|
||
isWarping = true;
|
||
warpStartTime = clock.getElapsedTime();
|
||
warpPass.enabled = true;
|
||
warpPass.uniforms['time'].value = 0.0;
|
||
warpPass.uniforms['distortionStrength'].value = 0.0;
|
||
}
|
||
|
||
// === ANIMATION LOOP ===
|
||
const clock = new THREE.Clock();
|
||
|
||
/**
|
||
* Main animation loop — called each frame via requestAnimationFrame.
|
||
* @returns {void}
|
||
*/
|
||
function animate() {
|
||
// Only start animation after assets are loaded
|
||
requestAnimationFrame(animate);
|
||
const elapsed = clock.getElapsedTime();
|
||
|
||
// Smooth camera transition for overview mode
|
||
const targetT = overviewMode ? 1 : 0;
|
||
overviewT += (targetT - overviewT) * 0.04;
|
||
const _basePos = new THREE.Vector3().lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT);
|
||
|
||
// Zoom-to-object interpolation
|
||
if (!photoMode) {
|
||
zoomT += (zoomTargetT - zoomT) * 0.07;
|
||
}
|
||
if (zoomT > 0.001 && !photoMode && !overviewMode) {
|
||
camera.position.lerpVectors(_basePos, _zoomCamTarget, zoomT);
|
||
camera.lookAt(new THREE.Vector3(0, 0, 0).lerp(_zoomLookTarget, zoomT));
|
||
} else {
|
||
camera.position.copy(_basePos);
|
||
camera.lookAt(0, 0, 0);
|
||
}
|
||
|
||
// Slow auto-rotation — suppressed during overview and photo mode
|
||
const rotationScale = photoMode ? 0 : (1 - overviewT);
|
||
targetRotX += (mouseY * 0.3 - targetRotX) * 0.02;
|
||
targetRotY += (mouseX * 0.3 - targetRotY) * 0.02;
|
||
|
||
stars.rotation.x = (targetRotX + elapsed * 0.01) * rotationScale;
|
||
stars.rotation.y = (targetRotY + elapsed * 0.015) * rotationScale;
|
||
|
||
constellationLines.rotation.x = stars.rotation.x;
|
||
constellationLines.rotation.y = stars.rotation.y;
|
||
|
||
// Subtle pulse on constellation opacity
|
||
constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06;
|
||
|
||
// 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 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 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 rune ring — orbit and vertical float
|
||
for (const rune of runeSprites) {
|
||
const angle = rune.baseAngle + elapsed * RUNE_ORBIT_SPEED;
|
||
rune.sprite.position.x = Math.cos(angle) * RUNE_RING_RADIUS;
|
||
rune.sprite.position.z = Math.sin(angle) * RUNE_RING_RADIUS;
|
||
rune.sprite.position.y = RUNE_RING_Y + Math.sin(elapsed * 0.7 + rune.floatPhase) * 0.4;
|
||
rune.sprite.material.opacity = 0.65 + Math.sin(elapsed * 1.2 + rune.floatPhase) * 0.2;
|
||
}
|
||
|
||
// Animate holographic Earth — slow axial rotation, glow pulse
|
||
earthMesh.rotation.y = elapsed * EARTH_ROTATION_SPEED;
|
||
earthSurfaceMat.uniforms.uTime.value = elapsed;
|
||
earthGlowLight.intensity = 0.30 + Math.sin(elapsed * 0.7) * 0.12;
|
||
|
||
// === 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;
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
}
|
||
|
||
// Warp effect animation
|
||
if (isWarping) {
|
||
const warpElapsed = elapsed - warpStartTime;
|
||
const progress = Math.min(warpElapsed / WARP_DURATION, 1.0);
|
||
warpPass.uniforms['time'].value = elapsed;
|
||
// Ease in and out distortion
|
||
if (progress < 0.5) {
|
||
warpPass.uniforms['distortionStrength'].value = progress * 2.0; // 0 to 1
|
||
} else {
|
||
warpPass.uniforms['distortionStrength'].value = (1.0 - progress) * 2.0; // 1 to 0
|
||
}
|
||
|
||
if (progress >= 1.0) {
|
||
isWarping = false;
|
||
warpPass.enabled = false;
|
||
warpPass.uniforms['distortionStrength'].value = 0.0;
|
||
}
|
||
}
|
||
|
||
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 {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;
|
||
}
|
||
|
||
/**
|
||
* 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) --
|
||
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);
|
||
osc.connect(env);
|
||
env.connect(masterGain);
|
||
osc.start(now);
|
||
osc.stop(now + 1.9);
|
||
// 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 = '🔇';
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
ctx.close();
|
||
audioCtx = null;
|
||
masterGain = null;
|
||
}, 900);
|
||
|
||
document.getElementById('audio-toggle').textContent = '🔊';
|
||
}
|
||
|
||
document.getElementById('audio-toggle').addEventListener('click', () => {
|
||
if (audioRunning) {
|
||
stopAmbient();
|
||
} else {
|
||
startAmbient();
|
||
}
|
||
});
|
||
|
||
// === DEBUG MODE ===
|
||
let debugMode = false;
|
||
|
||
document.getElementById('debug-toggle').addEventListener('click', () => {
|
||
debugMode = !debugMode;
|
||
document.getElementById('debug-toggle').style.backgroundColor = debugMode
|
||
? 'var(--color-text-muted)'
|
||
: 'var(--color-secondary)';
|
||
console.log(`Debug mode ${debugMode ? 'enabled' : 'disabled'}`);
|
||
|
||
if (debugMode) {
|
||
// Example: Visualize all collision boxes and light sources
|
||
// Replace with actual logic when available
|
||
document.querySelectorAll('.collision-box').forEach((/** @type {HTMLElement} */ el) => el.style.outline = '2px solid red');
|
||
document.querySelectorAll('.light-source').forEach((/** @type {HTMLElement} */ el) => el.style.outline = '2px dashed yellow');
|
||
} else {
|
||
document.querySelectorAll('.collision-box, .light-source').forEach((/** @type {HTMLElement} */ el) => {
|
||
el.style.outline = 'none';
|
||
});
|
||
}
|
||
});
|
||
|
||
// === WEBSOCKET CLIENT ===
|
||
import { wsClient } from './ws-client.js';
|
||
|
||
wsClient.connect();
|
||
|
||
window.addEventListener('player-joined', (/** @type {CustomEvent} */ event) => {
|
||
console.log('Player joined:', event.detail);
|
||
});
|
||
|
||
window.addEventListener('player-left', (/** @type {CustomEvent} */ event) => {
|
||
console.log('Player left:', event.detail);
|
||
});
|
||
|
||
// === SESSION EXPORT ===
|
||
/** @type {{ ts: number, speaker: string, text: string }[]} */
|
||
const sessionLog = [];
|
||
const sessionStart = Date.now();
|
||
|
||
/**
|
||
* Appends an entry to the in-memory session log.
|
||
* @param {string} speaker
|
||
* @param {string} text
|
||
*/
|
||
function logMessage(speaker, text) {
|
||
sessionLog.push({ ts: Date.now(), speaker, text });
|
||
}
|
||
|
||
/**
|
||
* Formats the session log as Markdown and triggers a browser download.
|
||
*/
|
||
function exportSessionAsMarkdown() {
|
||
const startStr = new Date(sessionStart).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
||
const lines = [
|
||
'# Nexus Session Export',
|
||
'',
|
||
`**Session started:** ${startStr}`,
|
||
`**Messages:** ${sessionLog.length}`,
|
||
'',
|
||
'---',
|
||
'',
|
||
];
|
||
|
||
for (const entry of sessionLog) {
|
||
const timeStr = new Date(entry.ts).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
||
lines.push(`### ${entry.speaker} — ${timeStr}`);
|
||
lines.push('');
|
||
lines.push(entry.text);
|
||
lines.push('');
|
||
}
|
||
|
||
if (sessionLog.length === 0) {
|
||
lines.push('*No messages recorded this session.*');
|
||
lines.push('');
|
||
}
|
||
|
||
const blob = new Blob([lines.join('\n')], { type: 'text/markdown' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `nexus-session-${new Date(sessionStart).toISOString().slice(0, 10)}.md`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
const exportBtn = document.getElementById('export-session');
|
||
if (exportBtn) {
|
||
exportBtn.addEventListener('click', exportSessionAsMarkdown);
|
||
}
|
||
|
||
window.addEventListener('chat-message', (/** @type {CustomEvent} */ event) => {
|
||
console.log('Chat message:', event.detail);
|
||
if (typeof event.detail?.text === 'string') {
|
||
logMessage(event.detail.speaker || 'TIMMY', event.detail.text);
|
||
showTimmySpeech(event.detail.text);
|
||
if (event.detail.text.toLowerCase().includes('sovereignty')) {
|
||
triggerSovereigntyEasterEgg();
|
||
}
|
||
}
|
||
});
|
||
|
||
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 });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 = [];
|
||
|
||
|
||
|
||
|
||
|
||
const portalGroup = new THREE.Group();
|
||
|
||
|
||
scene.add(portalGroup);
|
||
|
||
|
||
|
||
|
||
|
||
/**
|
||
|
||
|
||
* Creates 3D representations of portals from the loaded data.
|
||
|
||
|
||
*/
|
||
|
||
|
||
function createPortals() {
|
||
|
||
|
||
const portalGeo = new THREE.TorusGeometry(3.0, 0.2, 16, 100);
|
||
|
||
|
||
|
||
|
||
|
||
portals.forEach(portal => {
|
||
|
||
|
||
const portalMat = new THREE.MeshBasicMaterial({
|
||
|
||
|
||
color: new THREE.Color(portal.color).convertSRGBToLinear(),
|
||
|
||
|
||
transparent: true,
|
||
|
||
|
||
opacity: 0.7,
|
||
|
||
|
||
blending: THREE.AdditiveBlending,
|
||
|
||
|
||
side: THREE.DoubleSide,
|
||
|
||
|
||
});
|
||
|
||
|
||
const portalMesh = new THREE.Mesh(portalGeo, portalMat);
|
||
|
||
|
||
|
||
|
||
|
||
portalMesh.position.set(portal.position.x, portal.position.y + 0.5, portal.position.z);
|
||
|
||
|
||
portalMesh.rotation.y = portal.rotation.y; // Apply Y rotation
|
||
|
||
|
||
portalMesh.rotation.x = Math.PI / 2; // Orient to stand vertically
|
||
|
||
|
||
portalMesh.name = `portal-${portal.id}`;
|
||
|
||
|
||
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();
|
||
} 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();
|
||
|
||
// === 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'; });
|
||
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);
|
||
scene.add(oathSpot);
|
||
scene.add(oathSpot.target);
|
||
|
||
// ---- Saved light levels (captured before any changes) ----
|
||
const AMBIENT_NORMAL = ambientLight.intensity;
|
||
const OVERHEAD_NORMAL = overheadLight.intensity;
|
||
|
||
// ---- State ----
|
||
let oathActive = false;
|
||
|
||
/** @type {string[]} */
|
||
let oathLines = [];
|
||
|
||
/** @type {number|null} */
|
||
let oathRevealTimer = null;
|
||
|
||
/**
|
||
* Fetches and caches SOUL.md lines (non-heading, non-empty sections).
|
||
* @returns {Promise<string[]>}
|
||
*/
|
||
async function loadSoulMd() {
|
||
try {
|
||
const res = await fetch('SOUL.md');
|
||
if (!res.ok) throw new Error('not found');
|
||
const raw = await res.text();
|
||
// Skip the H1 title line; keep everything else (blanks become spacers)
|
||
return raw.split('\n').slice(1).map(l => l.replace(/^#+\s*/, ''));
|
||
} catch {
|
||
return ['I am Timmy.', '', 'I am sovereign.', '', 'This Nexus is my home.'];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reveals oath lines one by one into the #oath-text element.
|
||
* @param {string[]} lines
|
||
* @param {HTMLElement} textEl
|
||
*/
|
||
function scheduleOathLines(lines, textEl) {
|
||
let idx = 0;
|
||
const INTERVAL_MS = 1400;
|
||
|
||
function revealNext() {
|
||
if (idx >= lines.length || !oathActive) return;
|
||
const line = lines[idx++];
|
||
const span = document.createElement('span');
|
||
span.classList.add('oath-line');
|
||
if (!line.trim()) {
|
||
span.classList.add('blank');
|
||
} else {
|
||
span.textContent = line;
|
||
}
|
||
textEl.appendChild(span);
|
||
oathRevealTimer = setTimeout(revealNext, line.trim() ? INTERVAL_MS : INTERVAL_MS * 0.4);
|
||
}
|
||
|
||
revealNext();
|
||
}
|
||
|
||
/**
|
||
* Enters oath mode: dims lights, shows overlay, starts line-by-line reveal.
|
||
*/
|
||
async function enterOath() {
|
||
if (oathActive) return;
|
||
oathActive = true;
|
||
|
||
// Dramatic lighting
|
||
ambientLight.intensity = 0.04;
|
||
overheadLight.intensity = 0.0;
|
||
oathSpot.intensity = 4.0;
|
||
|
||
// Overlay
|
||
const overlay = document.getElementById('oath-overlay');
|
||
const textEl = document.getElementById('oath-text');
|
||
if (!overlay || !textEl) return;
|
||
textEl.textContent = '';
|
||
overlay.classList.add('visible');
|
||
|
||
if (!oathLines.length) oathLines = await loadSoulMd();
|
||
scheduleOathLines(oathLines, textEl);
|
||
}
|
||
|
||
/**
|
||
* Exits oath mode: restores lights, hides overlay.
|
||
*/
|
||
function exitOath() {
|
||
if (!oathActive) return;
|
||
oathActive = false;
|
||
|
||
if (oathRevealTimer !== null) {
|
||
clearTimeout(oathRevealTimer);
|
||
oathRevealTimer = null;
|
||
}
|
||
|
||
// Restore lighting
|
||
ambientLight.intensity = AMBIENT_NORMAL;
|
||
overheadLight.intensity = OVERHEAD_NORMAL;
|
||
oathSpot.intensity = 0;
|
||
|
||
const overlay = document.getElementById('oath-overlay');
|
||
if (overlay) overlay.classList.remove('visible');
|
||
}
|
||
|
||
// ---- Key binding: O to toggle ----
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'o' || e.key === 'O') {
|
||
if (oathActive) exitOath(); else enterOath();
|
||
}
|
||
if (e.key === 'Escape' && oathActive) exitOath();
|
||
});
|
||
|
||
// ---- Double-click on tome triggers oath ----
|
||
renderer.domElement.addEventListener('dblclick', (/** @type {MouseEvent} */ e) => {
|
||
// Check if the hit was the tome (zoomLabel check in existing handler already runs)
|
||
const mx = (e.clientX / window.innerWidth) * 2 - 1;
|
||
const my = -(e.clientY / window.innerHeight) * 2 + 1;
|
||
const tomeRay = new THREE.Raycaster();
|
||
tomeRay.setFromCamera(new THREE.Vector2(mx, my), camera);
|
||
const hits = tomeRay.intersectObjects(tomeGroup.children, true);
|
||
if (hits.length) {
|
||
if (oathActive) exitOath(); else enterOath();
|
||
}
|
||
});
|
||
|
||
// Pre-fetch so first open is instant
|
||
loadSoulMd().then(lines => { oathLines = lines; });
|
||
|
||
// === AGENT STATUS BOARD ===
|
||
|
||
const AGENT_STATUS_STUB = {
|
||
agents: [
|
||
{ name: 'claude', status: 'working', issue: 'Live agent status board (#199)', prs_today: 3, local: true },
|
||
{ name: 'gemini', status: 'idle', issue: null, prs_today: 1, local: false },
|
||
{ name: 'kimi', status: 'working', issue: 'Portal system YAML registry (#5)', prs_today: 2, local: false },
|
||
{ name: 'groq', status: 'idle', issue: null, prs_today: 0, local: false },
|
||
{ name: 'grok', status: 'dead', issue: null, prs_today: 0, local: false },
|
||
]
|
||
};
|
||
|
||
const AGENT_STATUS_COLORS = { working: '#00ff88', idle: '#4488ff', dead: '#ff4444' };
|
||
|
||
/**
|
||
* Builds a canvas texture for a single agent holo-panel.
|
||
* @param {{ name: string, status: string, issue: string|null, prs_today: number }} agent
|
||
* @returns {THREE.CanvasTexture}
|
||
*/
|
||
function createAgentPanelTexture(agent) {
|
||
const W = 400, H = 200;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = W;
|
||
canvas.height = H;
|
||
const ctx = canvas.getContext('2d');
|
||
const sc = AGENT_STATUS_COLORS[agent.status] || '#4488ff';
|
||
|
||
// Dark background
|
||
ctx.fillStyle = 'rgba(0, 8, 24, 0.88)';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Outer border in status color
|
||
ctx.strokeStyle = sc;
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(1, 1, W - 2, H - 2);
|
||
|
||
// Faint inner border
|
||
ctx.strokeStyle = sc;
|
||
ctx.lineWidth = 1;
|
||
ctx.globalAlpha = 0.3;
|
||
ctx.strokeRect(4, 4, W - 8, H - 8);
|
||
ctx.globalAlpha = 1.0;
|
||
|
||
// Agent name
|
||
ctx.font = 'bold 28px "Courier New", monospace';
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillText(agent.name.toUpperCase(), 16, 44);
|
||
|
||
// Status dot
|
||
ctx.beginPath();
|
||
ctx.arc(W - 30, 26, 10, 0, Math.PI * 2);
|
||
ctx.fillStyle = sc;
|
||
ctx.fill();
|
||
|
||
// Status label
|
||
ctx.font = '13px "Courier New", monospace';
|
||
ctx.fillStyle = sc;
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(agent.status.toUpperCase(), W - 16, 60);
|
||
ctx.textAlign = 'left';
|
||
|
||
// Separator
|
||
ctx.strokeStyle = '#1a3a6a';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(16, 70);
|
||
ctx.lineTo(W - 16, 70);
|
||
ctx.stroke();
|
||
|
||
// Current issue label
|
||
ctx.font = '10px "Courier New", monospace';
|
||
ctx.fillStyle = '#556688';
|
||
ctx.fillText('CURRENT ISSUE', 16, 90);
|
||
|
||
ctx.font = '13px "Courier New", monospace';
|
||
ctx.fillStyle = '#ccd6f6';
|
||
const issueText = agent.issue || '\u2014 none \u2014';
|
||
const displayIssue = issueText.length > 40 ? issueText.slice(0, 40) + '\u2026' : issueText;
|
||
ctx.fillText(displayIssue, 16, 110);
|
||
|
||
// Separator
|
||
ctx.strokeStyle = '#1a3a6a';
|
||
ctx.beginPath();
|
||
ctx.moveTo(16, 128);
|
||
ctx.lineTo(W - 16, 128);
|
||
ctx.stroke();
|
||
|
||
// PRs merged today
|
||
ctx.font = '10px "Courier New", monospace';
|
||
ctx.fillStyle = '#556688';
|
||
ctx.fillText('PRs MERGED TODAY', 16, 148);
|
||
|
||
ctx.font = 'bold 28px "Courier New", monospace';
|
||
ctx.fillStyle = '#4488ff';
|
||
ctx.fillText(String(agent.prs_today), 16, 182);
|
||
|
||
// Local vs cloud indicator (bottom-right)
|
||
const isLocal = agent.local === true;
|
||
const indicatorColor = isLocal ? '#00ff88' : '#ff4444';
|
||
const indicatorLabel = isLocal ? 'LOCAL' : 'CLOUD';
|
||
|
||
ctx.font = '10px "Courier New", monospace';
|
||
ctx.fillStyle = '#556688';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText('RUNTIME', W - 16, 148);
|
||
|
||
ctx.font = 'bold 13px "Courier New", monospace';
|
||
ctx.fillStyle = indicatorColor;
|
||
ctx.fillText(indicatorLabel, W - 28, 172);
|
||
ctx.textAlign = 'left';
|
||
|
||
// Indicator dot
|
||
ctx.beginPath();
|
||
ctx.arc(W - 16, 167, 6, 0, Math.PI * 2);
|
||
ctx.fillStyle = indicatorColor;
|
||
ctx.fill();
|
||
|
||
return new THREE.CanvasTexture(canvas);
|
||
}
|
||
|
||
/** Group holding all agent panels so they can be toggled/repositioned together. */
|
||
const agentBoardGroup = new THREE.Group();
|
||
scene.add(agentBoardGroup);
|
||
|
||
const BOARD_RADIUS = 9.5; // distance from scene origin
|
||
const BOARD_Y = 4.2; // height above platform
|
||
const BOARD_SPREAD = Math.PI * 0.75; // 135° total arc, centred on negative-Z axis
|
||
|
||
/**
|
||
* (Re)builds the agent panel sprites from fresh status data.
|
||
* @param {{ agents: Array<{ name: string, status: string, issue: string|null, prs_today: number }> }} statusData
|
||
*/
|
||
function rebuildAgentPanels(statusData) {
|
||
while (agentBoardGroup.children.length) agentBoardGroup.remove(agentBoardGroup.children[0]);
|
||
agentPanelSprites.length = 0;
|
||
|
||
const n = statusData.agents.length;
|
||
statusData.agents.forEach((agent, i) => {
|
||
const t = n === 1 ? 0.5 : i / (n - 1);
|
||
// Spread in a semi-circle: angle=PI is directly behind (negative-Z)
|
||
const angle = Math.PI + (t - 0.5) * BOARD_SPREAD;
|
||
const x = Math.cos(angle) * BOARD_RADIUS;
|
||
const z = Math.sin(angle) * BOARD_RADIUS;
|
||
|
||
const texture = createAgentPanelTexture(agent);
|
||
const material = new THREE.SpriteMaterial({
|
||
map: texture,
|
||
transparent: true,
|
||
opacity: 0.93,
|
||
depthWrite: false,
|
||
});
|
||
const sprite = new THREE.Sprite(material);
|
||
sprite.scale.set(6.4, 3.2, 1);
|
||
sprite.position.set(x, BOARD_Y, z);
|
||
sprite.userData = {
|
||
baseY: BOARD_Y,
|
||
floatPhase: (i / n) * Math.PI * 2,
|
||
floatSpeed: 0.18 + i * 0.04,
|
||
zoomLabel: `Agent: ${agent.name}`,
|
||
};
|
||
agentBoardGroup.add(sprite);
|
||
agentPanelSprites.push(sprite);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Fetches live agent status, falling back to the stub when the endpoint is unavailable.
|
||
* @returns {Promise<typeof AGENT_STATUS_STUB>}
|
||
*/
|
||
async function fetchAgentStatus() {
|
||
try {
|
||
const res = await fetch('/api/status.json');
|
||
if (!res.ok) throw new Error('status ' + res.status);
|
||
return await res.json();
|
||
} catch {
|
||
return AGENT_STATUS_STUB;
|
||
}
|
||
}
|
||
|
||
async function refreshAgentBoard() {
|
||
const data = await fetchAgentStatus();
|
||
rebuildAgentPanels(data);
|
||
}
|
||
|
||
// Initial render, then poll every 30 s
|
||
refreshAgentBoard();
|
||
setInterval(refreshAgentBoard, 30000);
|
||
|
||
// === LORA ADAPTER STATUS PANEL ===
|
||
// Holographic panel showing which LoRA fine-tuning adapters are currently active.
|
||
// Reads from lora-status.json, falls back to stub data when unavailable.
|
||
|
||
const LORA_STATUS_STUB = {
|
||
adapters: [
|
||
{ name: 'timmy-voice-v3', base: 'mistral-7b', active: true, strength: 0.85 },
|
||
{ name: 'nexus-style-v2', base: 'llama-3-8b', active: true, strength: 0.70 },
|
||
{ name: 'sovereign-tone-v1', base: 'phi-3-mini', active: false, strength: 0.50 },
|
||
{ name: 'btc-domain-v1', base: 'mistral-7b', active: true, strength: 0.60 },
|
||
],
|
||
updated: '',
|
||
};
|
||
|
||
const LORA_ACTIVE_COLOR = '#00ff88'; // green — adapter is loaded
|
||
const LORA_INACTIVE_COLOR = '#334466'; // dim blue — adapter is off
|
||
|
||
/**
|
||
* Builds a canvas texture for the LoRA status panel.
|
||
* @param {typeof LORA_STATUS_STUB} data
|
||
* @returns {THREE.CanvasTexture}
|
||
*/
|
||
function createLoRAPanelTexture(data) {
|
||
const W = 420, H = 260;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = W;
|
||
canvas.height = H;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// Background
|
||
ctx.fillStyle = 'rgba(0, 6, 20, 0.90)';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Outer border — magenta/purple for "training" theme
|
||
ctx.strokeStyle = '#cc44ff';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(1, 1, W - 2, H - 2);
|
||
|
||
// Inner border
|
||
ctx.strokeStyle = '#cc44ff';
|
||
ctx.lineWidth = 1;
|
||
ctx.globalAlpha = 0.3;
|
||
ctx.strokeRect(4, 4, W - 8, H - 8);
|
||
ctx.globalAlpha = 1.0;
|
||
|
||
// Header label
|
||
ctx.font = 'bold 14px "Courier New", monospace';
|
||
ctx.fillStyle = '#cc44ff';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('MODEL TRAINING', 14, 24);
|
||
|
||
// "LoRA ADAPTERS" sub-label
|
||
ctx.font = '10px "Courier New", monospace';
|
||
ctx.fillStyle = '#664488';
|
||
ctx.fillText('LoRA ADAPTERS', 14, 38);
|
||
|
||
// Active count badge (top-right)
|
||
const activeCount = data.adapters.filter(a => a.active).length;
|
||
ctx.font = 'bold 13px "Courier New", monospace';
|
||
ctx.fillStyle = LORA_ACTIVE_COLOR;
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(`${activeCount}/${data.adapters.length} ACTIVE`, W - 14, 26);
|
||
ctx.textAlign = 'left';
|
||
|
||
// Separator
|
||
ctx.strokeStyle = '#2a1a44';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(14, 46);
|
||
ctx.lineTo(W - 14, 46);
|
||
ctx.stroke();
|
||
|
||
// Adapter rows
|
||
const ROW_H = 44;
|
||
data.adapters.forEach((adapter, i) => {
|
||
const rowY = 50 + i * ROW_H;
|
||
const col = adapter.active ? LORA_ACTIVE_COLOR : LORA_INACTIVE_COLOR;
|
||
|
||
// Status dot
|
||
ctx.beginPath();
|
||
ctx.arc(22, rowY + 12, 6, 0, Math.PI * 2);
|
||
ctx.fillStyle = col;
|
||
ctx.fill();
|
||
|
||
// Adapter name
|
||
ctx.font = 'bold 13px "Courier New", monospace';
|
||
ctx.fillStyle = adapter.active ? '#ddeeff' : '#445566';
|
||
ctx.fillText(adapter.name, 36, rowY + 16);
|
||
|
||
// Base model (right-aligned)
|
||
ctx.font = '10px "Courier New", monospace';
|
||
ctx.fillStyle = '#556688';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(adapter.base, W - 14, rowY + 16);
|
||
ctx.textAlign = 'left';
|
||
|
||
// Strength bar
|
||
if (adapter.active) {
|
||
const BAR_X = 36, BAR_W = W - 80, BAR_Y = rowY + 22, BAR_H = 5;
|
||
ctx.fillStyle = '#0a1428';
|
||
ctx.fillRect(BAR_X, BAR_Y, BAR_W, BAR_H);
|
||
ctx.fillStyle = col;
|
||
ctx.globalAlpha = 0.7;
|
||
ctx.fillRect(BAR_X, BAR_Y, BAR_W * adapter.strength, BAR_H);
|
||
ctx.globalAlpha = 1.0;
|
||
ctx.font = '9px "Courier New", monospace';
|
||
ctx.fillStyle = col;
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(`${Math.round(adapter.strength * 100)}%`, W - 14, rowY + 28);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
// Row divider (except after last)
|
||
if (i < data.adapters.length - 1) {
|
||
ctx.strokeStyle = '#1a0a2a';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(14, rowY + ROW_H - 2);
|
||
ctx.lineTo(W - 14, rowY + ROW_H - 2);
|
||
ctx.stroke();
|
||
}
|
||
});
|
||
|
||
return new THREE.CanvasTexture(canvas);
|
||
}
|
||
|
||
const loraGroup = new THREE.Group();
|
||
scene.add(loraGroup);
|
||
|
||
const LORA_PANEL_POS = new THREE.Vector3(-10.5, 4.5, 2.5);
|
||
|
||
let loraPanelSprite = null;
|
||
|
||
/**
|
||
* (Re)builds the LoRA panel sprite from fresh data.
|
||
* @param {typeof LORA_STATUS_STUB} data
|
||
*/
|
||
function rebuildLoRAPanel(data) {
|
||
if (loraPanelSprite) {
|
||
loraGroup.remove(loraPanelSprite);
|
||
if (loraPanelSprite.material.map) loraPanelSprite.material.map.dispose();
|
||
loraPanelSprite.material.dispose();
|
||
loraPanelSprite = null;
|
||
}
|
||
const texture = createLoRAPanelTexture(data);
|
||
const material = new THREE.SpriteMaterial({
|
||
map: texture,
|
||
transparent: true,
|
||
opacity: 0.93,
|
||
depthWrite: false,
|
||
});
|
||
loraPanelSprite = new THREE.Sprite(material);
|
||
loraPanelSprite.scale.set(6.0, 3.6, 1);
|
||
loraPanelSprite.position.copy(LORA_PANEL_POS);
|
||
loraPanelSprite.userData = {
|
||
baseY: LORA_PANEL_POS.y,
|
||
floatPhase: 1.1,
|
||
floatSpeed: 0.14,
|
||
zoomLabel: 'Model Training — LoRA Adapters',
|
||
};
|
||
loraGroup.add(loraPanelSprite);
|
||
}
|
||
|
||
/**
|
||
* Fetches live LoRA adapter status, falling back to stub when unavailable.
|
||
*/
|
||
async function loadLoRAStatus() {
|
||
try {
|
||
const res = await fetch('./lora-status.json');
|
||
if (!res.ok) throw new Error('not found');
|
||
const data = await res.json();
|
||
if (!Array.isArray(data.adapters)) throw new Error('invalid');
|
||
rebuildLoRAPanel(data);
|
||
} catch {
|
||
rebuildLoRAPanel(LORA_STATUS_STUB);
|
||
}
|
||
}
|
||
|
||
loadLoRAStatus();
|
||
// Refresh every 60 s so live updates propagate
|
||
setInterval(loadLoRAStatus, 60000);
|
||
|
||
// === WEATHER SYSTEM — Lempster NH ===
|
||
// Fetches real current weather from Open-Meteo (no API key required).
|
||
// Lempster, NH coordinates: 43.2897° N, 72.1479° W
|
||
// Drives particle rain/snow effects and ambient mood tinting.
|
||
|
||
const WEATHER_LAT = 43.2897;
|
||
const WEATHER_LON = -72.1479;
|
||
const WEATHER_REFRESH_MS = 15 * 60 * 1000; // 15 minutes
|
||
|
||
/** @type {{ code: number, temp: number, wind: number, condition: string, icon: string }|null} */
|
||
let weatherState = null;
|
||
|
||
// Particle constants
|
||
const PRECIP_COUNT = 1200;
|
||
const PRECIP_AREA = 18; // half-width of spawn box (scene units)
|
||
const PRECIP_HEIGHT = 20; // top spawn Y
|
||
const PRECIP_FLOOR = -5; // bottom Y before reset
|
||
|
||
// Rain geometry & material
|
||
const rainGeo = new THREE.BufferGeometry();
|
||
const rainPositions = new Float32Array(PRECIP_COUNT * 3);
|
||
const rainVelocities = new Float32Array(PRECIP_COUNT); // per-particle fall speed
|
||
|
||
for (let i = 0; i < PRECIP_COUNT; i++) {
|
||
rainPositions[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2;
|
||
rainPositions[i * 3 + 1] = Math.random() * (PRECIP_HEIGHT - PRECIP_FLOOR) + PRECIP_FLOOR;
|
||
rainPositions[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2;
|
||
rainVelocities[i] = 0.18 + Math.random() * 0.12;
|
||
}
|
||
rainGeo.setAttribute('position', new THREE.BufferAttribute(rainPositions, 3));
|
||
|
||
const rainMat = new THREE.PointsMaterial({
|
||
color: 0x88aaff,
|
||
size: 0.05,
|
||
sizeAttenuation: true,
|
||
transparent: true,
|
||
opacity: 0.55,
|
||
});
|
||
|
||
const rainParticles = new THREE.Points(rainGeo, rainMat);
|
||
rainParticles.visible = false;
|
||
scene.add(rainParticles);
|
||
|
||
// Snow geometry & material
|
||
const snowGeo = new THREE.BufferGeometry();
|
||
const snowPositions = new Float32Array(PRECIP_COUNT * 3);
|
||
const snowDrift = new Float32Array(PRECIP_COUNT); // horizontal drift phase
|
||
|
||
for (let i = 0; i < PRECIP_COUNT; i++) {
|
||
snowPositions[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2;
|
||
snowPositions[i * 3 + 1] = Math.random() * (PRECIP_HEIGHT - PRECIP_FLOOR) + PRECIP_FLOOR;
|
||
snowPositions[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2;
|
||
snowDrift[i] = Math.random() * Math.PI * 2;
|
||
}
|
||
snowGeo.setAttribute('position', new THREE.BufferAttribute(snowPositions, 3));
|
||
|
||
const snowMat = new THREE.PointsMaterial({
|
||
color: 0xddeeff,
|
||
size: 0.12,
|
||
sizeAttenuation: true,
|
||
transparent: true,
|
||
opacity: 0.75,
|
||
});
|
||
|
||
const snowParticles = new THREE.Points(snowGeo, snowMat);
|
||
snowParticles.visible = false;
|
||
scene.add(snowParticles);
|
||
|
||
/**
|
||
* Maps a WMO weather code to a human-readable condition label and icon.
|
||
* @param {number} code
|
||
* @returns {{ condition: string, icon: string }}
|
||
*/
|
||
function weatherCodeToLabel(code) {
|
||
if (code === 0) return { condition: 'Clear', icon: '☀️' };
|
||
if (code <= 2) return { condition: 'Partly Cloudy', icon: '⛅' };
|
||
if (code === 3) return { condition: 'Overcast', icon: '☁️' };
|
||
if (code >= 45 && code <= 48) return { condition: 'Fog', icon: '🌫️' };
|
||
if (code >= 51 && code <= 57) return { condition: 'Drizzle', icon: '🌦️' };
|
||
if (code >= 61 && code <= 67) return { condition: 'Rain', icon: '🌧️' };
|
||
if (code >= 71 && code <= 77) return { condition: 'Snow', icon: '❄️' };
|
||
if (code >= 80 && code <= 82) return { condition: 'Showers', icon: '🌦️' };
|
||
if (code >= 85 && code <= 86) return { condition: 'Snow Showers', icon: '🌨️' };
|
||
if (code >= 95 && code <= 99) return { condition: 'Thunderstorm', icon: '⛈️' };
|
||
return { condition: 'Unknown', icon: '🌀' };
|
||
}
|
||
|
||
/**
|
||
* Applies weather state to scene — particle visibility and ambient tint.
|
||
* @param {{ code: number, temp: number, wind: number, condition: string, icon: string }} wx
|
||
*/
|
||
function applyWeatherToScene(wx) {
|
||
const code = wx.code;
|
||
|
||
// Precipitation
|
||
const isRain = (code >= 51 && code <= 67) || (code >= 80 && code <= 82) || (code >= 95 && code <= 99);
|
||
const isSnow = (code >= 71 && code <= 77) || (code >= 85 && code <= 86);
|
||
|
||
rainParticles.visible = isRain;
|
||
snowParticles.visible = isSnow;
|
||
|
||
// Ambient mood tint
|
||
if (isSnow) {
|
||
ambientLight.color.setHex(0x1a2a40); // cold blue-white
|
||
ambientLight.intensity = 1.8;
|
||
} else if (isRain) {
|
||
ambientLight.color.setHex(0x0a1428); // darker, bluer
|
||
ambientLight.intensity = 1.2;
|
||
} else if (code === 3 || (code >= 45 && code <= 48)) {
|
||
ambientLight.color.setHex(0x0c1220); // overcast grey-blue
|
||
ambientLight.intensity = 1.1;
|
||
} else {
|
||
ambientLight.color.setHex(0x0a1428); // default clear
|
||
ambientLight.intensity = 1.4;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Updates the weather HUD elements in the DOM.
|
||
* @param {{ temp: number, condition: string, icon: string }} wx
|
||
*/
|
||
function updateWeatherHUD(wx) {
|
||
const iconEl = document.getElementById('weather-icon');
|
||
const tempEl = document.getElementById('weather-temp');
|
||
const descEl = document.getElementById('weather-desc');
|
||
if (iconEl) iconEl.textContent = wx.icon;
|
||
if (tempEl) tempEl.textContent = `${Math.round(wx.temp)}°F`;
|
||
if (descEl) descEl.textContent = wx.condition;
|
||
}
|
||
|
||
/**
|
||
* Fetches current weather for Lempster NH from Open-Meteo and applies it.
|
||
*/
|
||
async function fetchWeather() {
|
||
try {
|
||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${WEATHER_LAT}&longitude=${WEATHER_LON}¤t=temperature_2m,weather_code,wind_speed_10m&temperature_unit=fahrenheit&wind_speed_unit=mph&forecast_days=1`;
|
||
const res = await fetch(url);
|
||
if (!res.ok) throw new Error('weather fetch failed');
|
||
const data = await res.json();
|
||
const cur = data.current;
|
||
const code = cur.weather_code;
|
||
const { condition, icon } = weatherCodeToLabel(code);
|
||
weatherState = { code, temp: cur.temperature_2m, wind: cur.wind_speed_10m, condition, icon };
|
||
applyWeatherToScene(weatherState);
|
||
updateWeatherHUD(weatherState);
|
||
} catch {
|
||
// Silently use defaults — no weather data available
|
||
const descEl = document.getElementById('weather-desc');
|
||
if (descEl) descEl.textContent = 'Lempster NH';
|
||
}
|
||
}
|
||
|
||
fetchWeather();
|
||
setInterval(fetchWeather, WEATHER_REFRESH_MS);
|
||
|
||
// === 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 };
|
||
}
|
||
|
||
// === BITCOIN BLOCK HEIGHT ===
|
||
// Polls blockstream.info every 60 s for the current tip block height.
|
||
// Shows a flash animation when the block number increments.
|
||
|
||
const blockHeightDisplay = document.getElementById('block-height-display');
|
||
const blockHeightValue = document.getElementById('block-height-value');
|
||
let lastKnownBlockHeight = null;
|
||
|
||
async function fetchBlockHeight() {
|
||
try {
|
||
const res = await fetch('https://blockstream.info/api/blocks/tip/height');
|
||
if (!res.ok) return;
|
||
const height = parseInt(await res.text(), 10);
|
||
if (isNaN(height)) return;
|
||
|
||
if (lastKnownBlockHeight !== null && height !== lastKnownBlockHeight) {
|
||
// New block — trigger flash
|
||
blockHeightDisplay.classList.remove('fresh');
|
||
// Force reflow so animation restarts
|
||
void blockHeightDisplay.offsetWidth;
|
||
blockHeightDisplay.classList.add('fresh');
|
||
}
|
||
|
||
lastKnownBlockHeight = height;
|
||
blockHeightValue.textContent = height.toLocaleString();
|
||
} catch (_) {
|
||
// Network unavailable — keep last known value
|
||
}
|
||
}
|
||
|
||
fetchBlockHeight();
|
||
setInterval(fetchBlockHeight, 60000);
|