1329 lines
42 KiB
JavaScript
1329 lines
42 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 { 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);
|
||
|
||
// === 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);
|
||
|
||
// === 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;
|
||
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');
|
||
}
|
||
}
|
||
});
|
||
|
||
// === PHOTO MODE ===
|
||
let photoMode = false;
|
||
|
||
// Post-processing composer for depth of field (always-on, subtle)
|
||
const composer = new EffectComposer(renderer);
|
||
composer.addPass(new RenderPass(scene, camera));
|
||
|
||
const bokehPass = new BokehPass(scene, camera, {
|
||
focus: 5.0,
|
||
aperture: 0.00015,
|
||
maxblur: 0.004,
|
||
});
|
||
composer.addPass(bokehPass);
|
||
|
||
// Orbit controls for free camera movement in photo mode
|
||
const orbitControls = new OrbitControls(camera, renderer.domElement);
|
||
orbitControls.enableDamping = true;
|
||
orbitControls.dampingFactor = 0.05;
|
||
orbitControls.enabled = false;
|
||
|
||
const photoIndicator = document.getElementById('photo-indicator');
|
||
const photoFocusDisplay = document.getElementById('photo-focus');
|
||
|
||
/**
|
||
* Updates the photo mode focus distance display.
|
||
*/
|
||
function updateFocusDisplay() {
|
||
if (photoFocusDisplay) {
|
||
photoFocusDisplay.textContent = bokehPass.uniforms['focus'].value.toFixed(1);
|
||
}
|
||
|
||
// 3D Audio: Update panner position to match camera
|
||
if (audioPanner) {
|
||
audioPanner.setPosition(camera.position.x, camera.position.y, camera.position.z);
|
||
}
|
||
}
|
||
|
||
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);
|
||
|
||
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 });
|
||
}
|
||
|
||
// === 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;
|
||
camera.position.lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT);
|
||
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 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 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;
|
||
}
|
||
|
||
composer.render();
|
||
}
|
||
|
||
animate();
|
||
|
||
// === 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';
|
||
});
|
||
}
|
||
});
|
||
|
||
// === 3D AUDIO SETUP ===
|
||
let audioContext;
|
||
let audioSource;
|
||
let audioPanner;
|
||
let is3DAudioEnabled = true;
|
||
|
||
// Replace the <audio> element with Web Audio API
|
||
const ambientSound = document.getElementById('ambient-sound');
|
||
ambientSound.remove();
|
||
|
||
const initAudio = async () => {
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
const response = await fetch('ambient.mp3');
|
||
const arrayBuffer = await response.arrayBuffer();
|
||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||
|
||
audioSource = audioContext.createBufferSource();
|
||
audioSource.buffer = audioBuffer;
|
||
|
||
audioPanner = audioContext.createPanner();
|
||
audioPanner.panningModel = 'HRTF';
|
||
audioPanner.distanceModel = 'inverse';
|
||
audioSource.connect(audioPanner);
|
||
audioPanner.connect(audioContext.destination);
|
||
|
||
audioSource.loop = true;
|
||
audioSource.start();
|
||
};
|
||
|
||
initAudio();
|
||
|
||
// === 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);
|
||
});
|
||
|
||
window.addEventListener('chat-message', (/** @type {CustomEvent} */ event) => {
|
||
console.log('Chat message:', event.detail);
|
||
if (typeof event.detail?.text === 'string') {
|
||
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);
|
||
});
|
||
|
||
// === 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);
|
||
}
|
||
|
||
// 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 = [];
|
||
|
||
// === 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),
|
||
};
|
||
scene.add(sprite);
|
||
commitBanners.push(sprite);
|
||
});
|
||
}
|
||
|
||
initCommitBanners();
|
||
|
||
// === AGENT STATUS BOARD ===
|
||
|
||
const AGENT_STATUS_STUB = {
|
||
agents: [
|
||
{ name: 'claude', status: 'working', issue: 'Live agent status board (#199)', prs_today: 3 },
|
||
{ name: 'gemini', status: 'idle', issue: null, prs_today: 1 },
|
||
{ name: 'kimi', status: 'working', issue: 'Portal system YAML registry (#5)', prs_today: 2 },
|
||
{ name: 'groq', status: 'idle', issue: null, prs_today: 0 },
|
||
{ name: 'grok', status: 'dead', issue: null, prs_today: 0 },
|
||
]
|
||
};
|
||
|
||
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);
|
||
|
||
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,
|
||
};
|
||
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);
|
||
|
||
// === 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 };
|
||
}
|