Adds two floating bookshelves to the Nexus 3D scene, each holding books whose spines display merged PR numbers and titles. Books are built from canvas textures via the Gitea API, with fallback data if unreachable. Both shelves gently bob in the scene. Refs #264 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2618 lines
80 KiB
JavaScript
2618 lines
80 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);
|
||
})();
|
||
|
||
// === 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 });
|
||
}
|
||
|
||
|
||
// === 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;
|
||
|
||
if (photoMode) {
|
||
orbitControls.update();
|
||
}
|
||
|
||
// Animate sovereignty meter — gentle hover float and glow pulse
|
||
sovereigntyGroup.position.y = 3.8 + Math.sin(elapsed * 0.8) * 0.15;
|
||
meterLight.intensity = 0.5 + Math.sin(elapsed * 1.8) * 0.25;
|
||
|
||
// Animate floating commit banners
|
||
const FADE_DUR = 1.5;
|
||
commitBanners.forEach(banner => {
|
||
const ud = banner.userData;
|
||
if (ud.spawnTime === null) {
|
||
if (elapsed < ud.startDelay) return;
|
||
ud.spawnTime = elapsed;
|
||
}
|
||
const age = elapsed - ud.spawnTime;
|
||
let opacity;
|
||
if (age < FADE_DUR) {
|
||
opacity = age / FADE_DUR;
|
||
} else if (age < ud.lifetime - FADE_DUR) {
|
||
opacity = 1;
|
||
} else if (age < ud.lifetime) {
|
||
opacity = (ud.lifetime - age) / FADE_DUR;
|
||
} else {
|
||
ud.spawnTime = elapsed + 3;
|
||
opacity = 0;
|
||
}
|
||
banner.material.opacity = opacity * 0.85;
|
||
banner.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.4;
|
||
});
|
||
|
||
// Animate agent status panels — gentle float
|
||
for (const sprite of agentPanelSprites) {
|
||
const ud = sprite.userData;
|
||
sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.22;
|
||
}
|
||
|
||
// Animate LoRA status panel — gentle float
|
||
if (loraPanelSprite) {
|
||
const ud = loraPanelSprite.userData;
|
||
loraPanelSprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.22;
|
||
}
|
||
|
||
// Animate floating bookshelves — gentle slow bob
|
||
for (const shelf of bookshelfGroups) {
|
||
const ud = shelf.userData;
|
||
shelf.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.18;
|
||
}
|
||
|
||
// Animate Timmy speech bubble — fade in, hold, fade out
|
||
if (timmySpeechState) {
|
||
const age = elapsed - timmySpeechState.startTime;
|
||
let opacity;
|
||
if (age < SPEECH_FADE_IN) {
|
||
opacity = age / SPEECH_FADE_IN;
|
||
} else if (age < SPEECH_DURATION - SPEECH_FADE_OUT) {
|
||
opacity = 1.0;
|
||
} else if (age < SPEECH_DURATION) {
|
||
opacity = (SPEECH_DURATION - age) / SPEECH_FADE_OUT;
|
||
} else {
|
||
scene.remove(timmySpeechState.sprite);
|
||
if (timmySpeechState.sprite.material.map) timmySpeechState.sprite.material.map.dispose();
|
||
timmySpeechState.sprite.material.dispose();
|
||
timmySpeechSprite = null;
|
||
timmySpeechState = null;
|
||
opacity = 0;
|
||
}
|
||
if (timmySpeechState) {
|
||
timmySpeechState.sprite.material.opacity = opacity;
|
||
timmySpeechState.sprite.position.y = TIMMY_SPEECH_POS.y + Math.sin(elapsed * 1.1) * 0.1;
|
||
}
|
||
}
|
||
|
||
// Animate tome — gentle float and slow rotation
|
||
tomeGroup.position.y = 5.8 + Math.sin(elapsed * 0.6) * 0.18;
|
||
tomeGroup.rotation.y = elapsed * 0.3;
|
||
tomeGlow.intensity = 0.3 + Math.sin(elapsed * 1.4) * 0.12;
|
||
if (oathActive) {
|
||
oathSpot.intensity = 3.8 + Math.sin(elapsed * 0.9) * 0.4;
|
||
}
|
||
|
||
// Animate 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;
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
/**
|
||
* Triggers a visual flash effect for merge events: stars pulse bright, lines glow.
|
||
*/
|
||
function triggerMergeFlash() {
|
||
// Flash constellation lines bright blue-green
|
||
const originalLineColor = constellationLines.material.color.getHex();
|
||
constellationLines.material.color.setHex(0x00ffff);
|
||
constellationLines.material.opacity = 1.0;
|
||
|
||
// Stars burst bright blue-green
|
||
const originalStarColor = starMaterial.color.getHex();
|
||
const originalStarOpacity = starMaterial.opacity;
|
||
starMaterial.color.setHex(0x00ffff);
|
||
starMaterial.opacity = 1.0;
|
||
|
||
// Animate fade-out over 2.0s
|
||
const startTime = performance.now();
|
||
const DURATION = 2000; // 2 seconds
|
||
|
||
function fadeBack() {
|
||
const t = Math.min((performance.now() - startTime) / DURATION, 1);
|
||
const eased = t * t; // ease in: slow start, fast end
|
||
|
||
// Interpolate star color back
|
||
const mergeR = 0.0, mergeG = 1.0, mergeB = 1.0; // Cyan
|
||
const origStarColor = new THREE.Color(originalStarColor);
|
||
starMaterial.color.setRGB(
|
||
mergeR + (origStarColor.r - mergeR) * eased,
|
||
mergeG + (origStarColor.g - mergeG) * eased,
|
||
mergeB + (origStarColor.b - mergeB) * eased
|
||
);
|
||
starMaterial.opacity = 1.0 + (originalStarOpacity - 1.0) * eased;
|
||
|
||
// Interpolate line color back
|
||
const origLineColor = new THREE.Color(originalLineColor);
|
||
constellationLines.material.color.setRGB(
|
||
mergeR + (origLineColor.r - mergeR) * eased,
|
||
mergeG + (origLineColor.g - mergeG) * eased,
|
||
mergeB + (origLineColor.b - mergeB) * eased
|
||
);
|
||
constellationLines.material.opacity = 1.0 + (0.18 - 1.0) * eased; // Assuming original opacity is 0.18 for lines.
|
||
|
||
if (t < 1) {
|
||
requestAnimationFrame(fadeBack);
|
||
} else {
|
||
// Restore originals exactly
|
||
starMaterial.color.setHex(originalStarColor);
|
||
starMaterial.opacity = originalStarOpacity;
|
||
constellationLines.material.color.setHex(originalLineColor);
|
||
constellationLines.material.opacity = 0.18; // Explicitly set to original
|
||
}
|
||
}
|
||
|
||
requestAnimationFrame(fadeBack);
|
||
}
|
||
|
||
// Detect 'sovereignty' typed anywhere on the page (cheat-code style)
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
if (e.key.length !== 1) {
|
||
// Non-printable key resets buffer
|
||
sovereigntyBuffer = '';
|
||
return;
|
||
}
|
||
|
||
sovereigntyBuffer += e.key.toLowerCase();
|
||
|
||
// Keep only the last N chars needed
|
||
if (sovereigntyBuffer.length > SOVEREIGNTY_WORD.length) {
|
||
sovereigntyBuffer = sovereigntyBuffer.slice(-SOVEREIGNTY_WORD.length);
|
||
}
|
||
|
||
if (sovereigntyBuffer === SOVEREIGNTY_WORD) {
|
||
sovereigntyBuffer = '';
|
||
triggerSovereigntyEasterEgg();
|
||
}
|
||
|
||
// Reset buffer after 3s of inactivity
|
||
if (sovereigntyBufferTimer) clearTimeout(sovereigntyBufferTimer);
|
||
sovereigntyBufferTimer = setTimeout(() => { sovereigntyBuffer = ''; }, 3000);
|
||
});
|
||
|
||
window.addEventListener('beforeunload', () => {
|
||
wsClient.disconnect();
|
||
});
|
||
|
||
// === COMMIT BANNERS ===
|
||
const commitBanners = [];
|
||
|
||
/** @type {THREE.Group[]} */
|
||
const bookshelfGroups = [];
|
||
|
||
|
||
|
||
|
||
|
||
const portalGroup = new THREE.Group();
|
||
|
||
|
||
scene.add(portalGroup);
|
||
|
||
|
||
|
||
|
||
|
||
/**
|
||
|
||
|
||
* Creates 3D representations of portals from the loaded data.
|
||
|
||
|
||
*/
|
||
|
||
|
||
function createPortals() {
|
||
|
||
|
||
const portalGeo = new THREE.TorusGeometry(3.0, 0.2, 16, 100);
|
||
|
||
|
||
|
||
|
||
|
||
portals.forEach(portal => {
|
||
|
||
|
||
const portalMat = new THREE.MeshBasicMaterial({
|
||
|
||
|
||
color: new THREE.Color(portal.color).convertSRGBToLinear(),
|
||
|
||
|
||
transparent: true,
|
||
|
||
|
||
opacity: 0.7,
|
||
|
||
|
||
blending: THREE.AdditiveBlending,
|
||
|
||
|
||
side: THREE.DoubleSide,
|
||
|
||
|
||
});
|
||
|
||
|
||
const portalMesh = new THREE.Mesh(portalGeo, portalMat);
|
||
|
||
|
||
|
||
|
||
|
||
portalMesh.position.set(portal.position.x, portal.position.y + 0.5, portal.position.z);
|
||
|
||
|
||
portalMesh.rotation.y = portal.rotation.y; // Apply Y rotation
|
||
|
||
|
||
portalMesh.rotation.x = Math.PI / 2; // Orient to stand vertically
|
||
|
||
|
||
portalMesh.name = `portal-${portal.id}`;
|
||
|
||
|
||
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();
|
||
|
||
// === FLOATING BOOKSHELVES ===
|
||
// Floating bookshelves display merged PR history as books with spine labels.
|
||
// Each book spine shows a PR number and truncated title rendered via canvas texture.
|
||
|
||
/**
|
||
* Creates a canvas texture for a book spine.
|
||
* @param {number} prNum
|
||
* @param {string} title
|
||
* @param {string} bgColor CSS color string for book cover
|
||
* @returns {THREE.CanvasTexture}
|
||
*/
|
||
function createSpineTexture(prNum, title, bgColor) {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 128;
|
||
canvas.height = 512;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// Background — book cover color
|
||
ctx.fillStyle = bgColor;
|
||
ctx.fillRect(0, 0, 128, 512);
|
||
|
||
// Accent border
|
||
ctx.strokeStyle = '#4488ff';
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeRect(3, 3, 122, 506);
|
||
|
||
// PR number — accent blue, near top
|
||
ctx.font = 'bold 32px "Courier New", monospace';
|
||
ctx.fillStyle = '#4488ff';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(`#${prNum}`, 64, 58);
|
||
|
||
// Divider line
|
||
ctx.strokeStyle = '#4488ff';
|
||
ctx.lineWidth = 1;
|
||
ctx.globalAlpha = 0.4;
|
||
ctx.beginPath();
|
||
ctx.moveTo(12, 78);
|
||
ctx.lineTo(116, 78);
|
||
ctx.stroke();
|
||
ctx.globalAlpha = 1.0;
|
||
|
||
// Title — rotated 90° to read bottom-to-top (spine convention)
|
||
ctx.save();
|
||
ctx.translate(64, 300);
|
||
ctx.rotate(-Math.PI / 2);
|
||
const displayTitle = title.length > 30 ? title.slice(0, 30) + '\u2026' : title;
|
||
ctx.font = '21px "Courier New", monospace';
|
||
ctx.fillStyle = '#ccd6f6';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(displayTitle, 0, 0);
|
||
ctx.restore();
|
||
|
||
return new THREE.CanvasTexture(canvas);
|
||
}
|
||
|
||
/**
|
||
* Builds a single floating bookshelf group and adds it to the scene.
|
||
* @param {Array<{prNum: number, title: string}>} books
|
||
* @param {THREE.Vector3} position
|
||
* @param {number} rotationY
|
||
*/
|
||
function buildBookshelf(books, position, rotationY) {
|
||
const group = new THREE.Group();
|
||
group.position.copy(position);
|
||
group.rotation.y = rotationY;
|
||
|
||
const SHELF_W = books.length * 0.52 + 0.6;
|
||
const SHELF_THICKNESS = 0.12;
|
||
const SHELF_DEPTH = 0.72;
|
||
const ENDPANEL_H = 2.0;
|
||
|
||
// Dark metallic shelf material
|
||
const shelfMat = new THREE.MeshStandardMaterial({
|
||
color: 0x0d1520,
|
||
metalness: 0.6,
|
||
roughness: 0.5,
|
||
emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.02),
|
||
});
|
||
|
||
// Shelf plank (horizontal)
|
||
const plank = new THREE.Mesh(new THREE.BoxGeometry(SHELF_W, SHELF_THICKNESS, SHELF_DEPTH), shelfMat);
|
||
group.add(plank);
|
||
|
||
// End panels
|
||
const endGeo = new THREE.BoxGeometry(0.1, ENDPANEL_H, SHELF_DEPTH);
|
||
const leftEnd = new THREE.Mesh(endGeo, shelfMat);
|
||
leftEnd.position.set(-SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0);
|
||
group.add(leftEnd);
|
||
|
||
const rightEnd = new THREE.Mesh(endGeo.clone(), shelfMat);
|
||
rightEnd.position.set(SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0);
|
||
group.add(rightEnd);
|
||
|
||
// Accent glow strip along front edge of shelf
|
||
const glowStrip = new THREE.Mesh(
|
||
new THREE.BoxGeometry(SHELF_W, 0.035, 0.035),
|
||
new THREE.MeshBasicMaterial({ color: NEXUS.colors.accent, transparent: true, opacity: 0.55 })
|
||
);
|
||
glowStrip.position.set(0, SHELF_THICKNESS / 2 + 0.017, SHELF_DEPTH / 2);
|
||
group.add(glowStrip);
|
||
|
||
// Book cover colors — dark tones with slight variation
|
||
const BOOK_COLORS = [
|
||
'#0f0818', '#080f18', '#0f1108', '#07120e',
|
||
'#130c06', '#060b12', '#120608', '#080812',
|
||
];
|
||
|
||
// Spine thickness (X), book height (Y), cover depth (Z)
|
||
// +Z face (index 4) = spine visible to viewer
|
||
const bookStartX = -(SHELF_W / 2) + 0.36;
|
||
books.forEach((book, i) => {
|
||
const spineW = 0.34 + (i % 3) * 0.05; // slight width variation
|
||
const bookH = 1.35 + (i % 4) * 0.13; // slight height variation
|
||
const coverD = 0.58;
|
||
|
||
const bgColor = BOOK_COLORS[i % BOOK_COLORS.length];
|
||
const spineTexture = createSpineTexture(book.prNum, book.title, bgColor);
|
||
|
||
const plainMat = new THREE.MeshStandardMaterial({
|
||
color: new THREE.Color(bgColor),
|
||
roughness: 0.85,
|
||
metalness: 0.05,
|
||
});
|
||
const spineMat = new THREE.MeshBasicMaterial({ map: spineTexture });
|
||
|
||
// Material array: +X, -X, +Y, -Y, +Z (spine), -Z
|
||
const bookMats = [plainMat, plainMat, plainMat, plainMat, spineMat, plainMat];
|
||
|
||
const bookGeo = new THREE.BoxGeometry(spineW, bookH, coverD);
|
||
const bookMesh = new THREE.Mesh(bookGeo, bookMats);
|
||
bookMesh.position.set(
|
||
bookStartX + i * 0.5,
|
||
SHELF_THICKNESS / 2 + bookH / 2,
|
||
0
|
||
);
|
||
bookMesh.userData.zoomLabel = `PR #${book.prNum}: ${book.title.slice(0, 40)}`;
|
||
group.add(bookMesh);
|
||
});
|
||
|
||
// Soft point light beneath shelf for ambient glow
|
||
const shelfLight = new THREE.PointLight(NEXUS.colors.accent, 0.25, 5);
|
||
shelfLight.position.set(0, -0.4, 0);
|
||
group.add(shelfLight);
|
||
|
||
group.userData.zoomLabel = 'PR Archive — Merged Contributions';
|
||
group.userData.baseY = position.y;
|
||
group.userData.floatPhase = bookshelfGroups.length * Math.PI;
|
||
group.userData.floatSpeed = 0.17 + bookshelfGroups.length * 0.06;
|
||
|
||
scene.add(group);
|
||
bookshelfGroups.push(group);
|
||
}
|
||
|
||
/**
|
||
* Fetches merged PRs and spawns floating bookshelves in the scene.
|
||
*/
|
||
async function initBookshelves() {
|
||
let prs = [];
|
||
try {
|
||
const res = await fetch(
|
||
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/pulls?state=closed&limit=20',
|
||
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
|
||
);
|
||
if (!res.ok) throw new Error('fetch failed');
|
||
const data = await res.json();
|
||
prs = data
|
||
.filter(/** @type {(p: any) => boolean} */ p => p.merged)
|
||
.map(/** @type {(p: any) => {prNum: number, title: string}} */ p => ({
|
||
prNum: p.number,
|
||
// Strip "[claude]" prefix and trailing "(#N)" suffix for cleaner spine labels
|
||
title: p.title
|
||
.replace(/^\[[\w\s]+\]\s*/i, '')
|
||
.replace(/\s*\(#\d+\)\s*$/, ''),
|
||
}));
|
||
} catch {
|
||
// Fallback if API unreachable
|
||
prs = [
|
||
{ prNum: 324, title: 'Model training status — LoRA adapters' },
|
||
{ prNum: 323, title: 'The Oath — interactive SOUL.md reading' },
|
||
{ prNum: 320, title: 'Hermes session save/load' },
|
||
{ prNum: 304, title: 'Session export as markdown' },
|
||
{ prNum: 303, title: 'Procedural Web Audio ambient soundtrack' },
|
||
{ prNum: 301, title: 'Warp tunnel effect for portals' },
|
||
{ prNum: 296, title: 'Procedural terrain for floating island' },
|
||
{ prNum: 294, title: 'Northern lights flash on PR merge' },
|
||
];
|
||
}
|
||
|
||
if (prs.length === 0) return;
|
||
|
||
// Split PRs across two shelves — left and right of the scene background
|
||
const mid = Math.ceil(prs.length / 2);
|
||
|
||
buildBookshelf(
|
||
prs.slice(0, mid),
|
||
new THREE.Vector3(-8.5, 1.5, -4.5),
|
||
Math.PI * 0.1, // slight angle toward scene center
|
||
);
|
||
|
||
if (prs.slice(mid).length > 0) {
|
||
buildBookshelf(
|
||
prs.slice(mid),
|
||
new THREE.Vector3(8.5, 1.5, -4.5),
|
||
-Math.PI * 0.1,
|
||
);
|
||
}
|
||
}
|
||
|
||
initBookshelves();
|
||
|
||
// === THE OATH ===
|
||
// Interactive reading of SOUL.md with dramatic lighting.
|
||
// Trigger: press 'O' or double-click the tome object in the scene.
|
||
// A gold spotlight descends, ambient dims, lines reveal one-by-one.
|
||
|
||
// ---- Tome (3D trigger object) ----
|
||
const tomeGroup = new THREE.Group();
|
||
tomeGroup.position.set(0, 5.8, 0);
|
||
tomeGroup.userData.zoomLabel = 'The Oath';
|
||
|
||
const tomeCoverMat = new THREE.MeshStandardMaterial({
|
||
color: 0x2a1800,
|
||
metalness: 0.15,
|
||
roughness: 0.7,
|
||
emissive: new THREE.Color(0xffd700).multiplyScalar(0.04),
|
||
});
|
||
const tomePagesMat = new THREE.MeshStandardMaterial({ color: 0xd8ceb0, roughness: 0.9, metalness: 0.0 });
|
||
|
||
// Cover
|
||
const tomeBody = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.1, 1.4), tomeCoverMat);
|
||
tomeGroup.add(tomeBody);
|
||
// Pages (slightly smaller inner block)
|
||
const tomePages = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.07, 1.28), tomePagesMat);
|
||
tomePages.position.set(0.02, 0, 0);
|
||
tomeGroup.add(tomePages);
|
||
// Spine strip
|
||
const tomeSpiMat = new THREE.MeshStandardMaterial({ color: 0xffd700, metalness: 0.6, roughness: 0.4 });
|
||
const tomeSpine = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.12, 1.4), tomeSpiMat);
|
||
tomeSpine.position.set(-0.52, 0, 0);
|
||
tomeGroup.add(tomeSpine);
|
||
|
||
tomeGroup.traverse(o => { if (o.isMesh) o.userData.zoomLabel = 'The Oath'; });
|
||
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);
|
||
|
||
// === 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 };
|
||
}
|