Right-clicking any inspectable 3D mesh or sprite now shows a holographic tooltip with the object's name and description. Tagged objects: glass platform, platform rim, sovereignty meter, sovereignty score sprite, agent panels, and commit banners. The contextmenu handler uses THREE.Raycaster against all scene children and surfaces the first hit with userData.inspectName. Tooltip dismisses on click or Escape. Fixes #141
983 lines
32 KiB
JavaScript
983 lines
32 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;
|
|
platformRim.userData.inspectName = 'Glass Platform';
|
|
platformRim.userData.inspectDesc = 'Central floating platform — the sovereign foundation of the Nexus.';
|
|
glassPlatformGroup.add(platformRim);
|
|
|
|
// Raised border torus for visible 3-D thickness
|
|
const borderTorusGeo = new THREE.TorusGeometry(5.0, 0.1, 6, 64);
|
|
const borderTorus = new THREE.Mesh(borderTorusGeo, platformFrameMat);
|
|
borderTorus.rotation.x = Math.PI / 2;
|
|
borderTorus.userData.inspectName = 'Platform Rim';
|
|
borderTorus.userData.inspectDesc = 'Raised metallic torus encircling the Nexus platform.';
|
|
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);
|
|
|
|
// === 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);
|
|
}
|
|
}
|
|
|
|
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
|
|
scoreArcMesh.userData.inspectName = 'Sovereignty Meter';
|
|
scoreArcMesh.userData.inspectDesc = 'Holographic arc gauge tracking Timmy\'s sovereignty score across all systems.';
|
|
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);
|
|
meterSprite.userData.inspectName = 'Sovereignty Score';
|
|
meterSprite.userData.inspectDesc = 'Live sovereignty score display. Reads from sovereignty-status.json.';
|
|
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();
|
|
|
|
// === 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;
|
|
|
|
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;
|
|
}
|
|
|
|
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';
|
|
});
|
|
}
|
|
});
|
|
|
|
// === 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' && event.detail.text.toLowerCase().includes('sovereignty')) {
|
|
triggerSovereigntyEasterEgg();
|
|
}
|
|
});
|
|
|
|
// === 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),
|
|
inspectName: `Commit ${commit.hash}`,
|
|
inspectDesc: commit.message,
|
|
};
|
|
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,
|
|
inspectName: `Agent: ${agent.name.toUpperCase()}`,
|
|
inspectDesc: `Status: ${agent.status} · PRs today: ${agent.prs_today}${agent.issue ? '\n' + agent.issue : ''}`,
|
|
};
|
|
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);
|
|
|
|
// === OBJECT INSPECTION (right-click) ===
|
|
const inspectRaycaster = new THREE.Raycaster();
|
|
const inspectTooltip = document.getElementById('inspect-tooltip');
|
|
const inspectNameEl = document.getElementById('inspect-name');
|
|
const inspectDescEl = document.getElementById('inspect-desc');
|
|
|
|
/**
|
|
* Shows the inspection tooltip near the cursor.
|
|
* @param {number} x - Client X position
|
|
* @param {number} y - Client Y position
|
|
* @param {string} name - Object name
|
|
* @param {string} desc - Object description
|
|
*/
|
|
function showInspectTooltip(x, y, name, desc) {
|
|
inspectNameEl.textContent = name;
|
|
inspectDescEl.textContent = desc;
|
|
// Offset from cursor; keep within viewport
|
|
const tx = Math.min(x + 14, window.innerWidth - 300);
|
|
const ty = Math.min(y + 14, window.innerHeight - 80);
|
|
inspectTooltip.style.left = tx + 'px';
|
|
inspectTooltip.style.top = ty + 'px';
|
|
inspectTooltip.classList.add('visible');
|
|
}
|
|
|
|
renderer.domElement.addEventListener('contextmenu', (/** @type {MouseEvent} */ e) => {
|
|
e.preventDefault();
|
|
const mouse = new THREE.Vector2(
|
|
(e.clientX / window.innerWidth) * 2 - 1,
|
|
-(e.clientY / window.innerHeight) * 2 + 1
|
|
);
|
|
inspectRaycaster.setFromCamera(mouse, camera);
|
|
const hits = inspectRaycaster.intersectObjects(scene.children, true);
|
|
const hit = hits.find(h => h.object.userData.inspectName);
|
|
if (hit) {
|
|
const ud = hit.object.userData;
|
|
showInspectTooltip(e.clientX, e.clientY, ud.inspectName, ud.inspectDesc || '');
|
|
} else {
|
|
inspectTooltip.classList.remove('visible');
|
|
}
|
|
});
|
|
|
|
document.addEventListener('click', () => inspectTooltip.classList.remove('visible'));
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') inspectTooltip.classList.remove('visible');
|
|
});
|