diff --git a/app.js b/app.js index 3f9b2cd..48c2599 100644 --- a/app.js +++ b/app.js @@ -1,21 +1,13 @@ 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, - } -}; +import { scene, camera, composer, orbitControls } from './modules/scene.js'; +import { + stars, constellationLines, glassEdgeMaterials, voidLight, + sovereigntyGroup, meterLight, + commitBanners, agentPanelSprites, + loadSovereigntyStatus, initCommitBanners, refreshAgentBoard, +} from './modules/effects.js'; +import { state as controls, NORMAL_CAM, OVERVIEW_CAM } from './modules/controls.js'; +import './modules/ui.js'; // === ASSET LOADER === const loadedAssets = new Map(); @@ -31,405 +23,16 @@ loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => { 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); - -// === 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 -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 - } -} - +// === BOOTSTRAP === loadSovereigntyStatus(); +initCommitBanners(); +refreshAgentBoard(); +setInterval(refreshAgentBoard, 30000); // === ANIMATION LOOP === const clock = new THREE.Clock(); @@ -439,23 +42,22 @@ const clock = new THREE.Clock(); * @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); + const targetT = controls.overviewMode ? 1 : 0; + controls.overviewT += (targetT - controls.overviewT) * 0.04; + camera.position.lerpVectors(NORMAL_CAM, OVERVIEW_CAM, controls.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; + const rotationScale = controls.photoMode ? 0 : (1 - controls.overviewT); + controls.targetRotX += (controls.mouseY * 0.3 - controls.targetRotX) * 0.02; + controls.targetRotY += (controls.mouseX * 0.3 - controls.targetRotY) * 0.02; - stars.rotation.x = (targetRotX + elapsed * 0.01) * rotationScale; - stars.rotation.y = (targetRotY + elapsed * 0.015) * rotationScale; + stars.rotation.x = (controls.targetRotX + elapsed * 0.01) * rotationScale; + stars.rotation.y = (controls.targetRotY + elapsed * 0.015) * rotationScale; constellationLines.rotation.x = stars.rotation.x; constellationLines.rotation.y = stars.rotation.y; @@ -471,7 +73,7 @@ function animate() { // Pulse the void light below voidLight.intensity = 0.35 + Math.sin(elapsed * 1.4) * 0.2; - if (photoMode) { + if (controls.photoMode) { orbitControls.update(); } @@ -513,412 +115,3 @@ function animate() { } 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|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} - */ -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); diff --git a/modules/controls.js b/modules/controls.js new file mode 100644 index 0000000..8b3ac5d --- /dev/null +++ b/modules/controls.js @@ -0,0 +1,87 @@ +import * as THREE from 'three'; +import { camera, bokehPass, orbitControls, composer, renderer } from './scene.js'; + +// === MOUSE-DRIVEN ROTATION STATE === +export const state = { + mouseX: 0, + mouseY: 0, + targetRotX: 0, + targetRotY: 0, + overviewMode: false, + overviewT: 0, + photoMode: false, +}; + +export const NORMAL_CAM = new THREE.Vector3(0, 6, 11); +export const OVERVIEW_CAM = new THREE.Vector3(0, 200, 0.1); + +document.addEventListener('mousemove', (/** @type {MouseEvent} */ e) => { + state.mouseX = (e.clientX / window.innerWidth - 0.5) * 2; + state.mouseY = (e.clientY / window.innerHeight - 0.5) * 2; +}); + +// === OVERVIEW MODE (Tab — bird's-eye view) === +const overviewIndicator = document.getElementById('overview-indicator'); + +document.addEventListener('keydown', (e) => { + if (e.key === 'Tab') { + e.preventDefault(); + state.overviewMode = !state.overviewMode; + if (overviewIndicator) { + overviewIndicator.classList.toggle('visible', state.overviewMode); + } + } +}); + +// === PHOTO MODE === +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') { + state.photoMode = !state.photoMode; + document.body.classList.toggle('photo-mode', state.photoMode); + orbitControls.enabled = state.photoMode; + if (photoIndicator) { + photoIndicator.classList.toggle('visible', state.photoMode); + } + if (state.photoMode) { + bokehPass.uniforms['aperture'].value = 0.0003; + bokehPass.uniforms['maxblur'].value = 0.008; + orbitControls.target.set(0, 0, 0); + orbitControls.update(); + updateFocusDisplay(); + } else { + bokehPass.uniforms['aperture'].value = 0.00015; + bokehPass.uniforms['maxblur'].value = 0.004; + } + } + + if (state.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); +}); diff --git a/modules/effects.js b/modules/effects.js new file mode 100644 index 0000000..6ae600e --- /dev/null +++ b/modules/effects.js @@ -0,0 +1,494 @@ +import * as THREE from 'three'; +import { NEXUS, scene } from './scene.js'; + +// === STAR FIELD === +export const STAR_COUNT = 800; +const STAR_SPREAD = 400; +const CONSTELLATION_DISTANCE = 30; + +export 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)); + +export const starMaterial = new THREE.PointsMaterial({ + color: NEXUS.colors.starCore, + size: 0.6, + sizeAttenuation: true, + transparent: true, + opacity: 0.9, +}); + +export const stars = new THREE.Points(starGeo, starMaterial); +scene.add(stars); + +// === CONSTELLATION LINES === +/** + * 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; + + 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 }); + } + } + + 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); +} + +export const constellationLines = buildConstellationLines(); +scene.add(constellationLines); + +// === GLASS PLATFORM === +const glassPlatformGroup = new THREE.Group(); + +const platformFrameMat = new THREE.MeshStandardMaterial({ + color: 0x0a1828, + metalness: 0.9, + roughness: 0.1, + emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.06), +}); + +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); + +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); + +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, +}); + +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}>} */ +export 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; + + const tile = new THREE.Mesh(tileGeo, glassTileMat.clone()); + tile.rotation.x = -Math.PI / 2; + tile.position.set(x, 0, z); + glassPlatformGroup.add(tile); + + 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 }); + } +} + +export const voidLight = new THREE.PointLight(NEXUS.colors.accent, 0.5, 14); +voidLight.position.set(0, -3.5, 0); +glassPlatformGroup.add(voidLight); + +scene.add(glassPlatformGroup); + +// === SOVEREIGNTY METER === +export const sovereigntyGroup = new THREE.Group(); +sovereigntyGroup.position.set(0, 3.8, 0); + +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); +} + +export const scoreArcMat = new THREE.MeshBasicMaterial({ + color: sovereigntyHexColor(sovereigntyScore), + transparent: true, + opacity: 0.9, +}); +export const scoreArcMesh = new THREE.Mesh(buildScoreArcGeo(sovereigntyScore), scoreArcMat); +scoreArcMesh.rotation.z = Math.PI / 2; +sovereigntyGroup.add(scoreArcMesh); + +export 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); +} + +export 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); + +export 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 + } +} + +// === COMMIT BANNERS === +export const commitBanners = []; + +/** + * 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. + */ +export 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' }, + ]; + } + + 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); + }); +} + +// === 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'; + + ctx.fillStyle = 'rgba(0, 8, 24, 0.88)'; + ctx.fillRect(0, 0, W, H); + + ctx.strokeStyle = sc; + ctx.lineWidth = 2; + ctx.strokeRect(1, 1, W - 2, H - 2); + + ctx.strokeStyle = sc; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.3; + ctx.strokeRect(4, 4, W - 8, H - 8); + ctx.globalAlpha = 1.0; + + ctx.font = 'bold 28px "Courier New", monospace'; + ctx.fillStyle = '#ffffff'; + ctx.fillText(agent.name.toUpperCase(), 16, 44); + + ctx.beginPath(); + ctx.arc(W - 30, 26, 10, 0, Math.PI * 2); + ctx.fillStyle = sc; + ctx.fill(); + + ctx.font = '13px "Courier New", monospace'; + ctx.fillStyle = sc; + ctx.textAlign = 'right'; + ctx.fillText(agent.status.toUpperCase(), W - 16, 60); + ctx.textAlign = 'left'; + + ctx.strokeStyle = '#1a3a6a'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(16, 70); + ctx.lineTo(W - 16, 70); + ctx.stroke(); + + 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); + + ctx.strokeStyle = '#1a3a6a'; + ctx.beginPath(); + ctx.moveTo(16, 128); + ctx.lineTo(W - 16, 128); + ctx.stroke(); + + 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. */ +export const agentBoardGroup = new THREE.Group(); +scene.add(agentBoardGroup); + +const BOARD_RADIUS = 9.5; +const BOARD_Y = 4.2; +const BOARD_SPREAD = Math.PI * 0.75; + +/** @type {THREE.Sprite[]} */ +export const agentPanelSprites = []; + +/** + * (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); + 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} + */ +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; + } +} + +export async function refreshAgentBoard() { + const data = await fetchAgentStatus(); + rebuildAgentPanels(data); +} diff --git a/modules/scene.js b/modules/scene.js new file mode 100644 index 0000000..fd78fda --- /dev/null +++ b/modules/scene.js @@ -0,0 +1,54 @@ +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'; + +// === COLOR PALETTE === +export const NEXUS = { + colors: { + bg: 0x000008, + starCore: 0xffffff, + starDim: 0x8899cc, + constellationLine: 0x334488, + constellationFade: 0x112244, + accent: 0x4488ff, + } +}; + +// === SCENE SETUP === +export const scene = new THREE.Scene(); +scene.background = new THREE.Color(NEXUS.colors.bg); + +export const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000); +camera.position.set(0, 6, 11); + +// === LIGHTING === +export const ambientLight = new THREE.AmbientLight(0x0a1428, 1.4); +scene.add(ambientLight); + +export const overheadLight = new THREE.PointLight(0x8899bb, 0.6, 60); +overheadLight.position.set(0, 25, 0); +scene.add(overheadLight); + +export const renderer = new THREE.WebGLRenderer({ antialias: true }); +renderer.setPixelRatio(window.devicePixelRatio); +renderer.setSize(window.innerWidth, window.innerHeight); +document.body.appendChild(renderer.domElement); + +// === POST-PROCESSING COMPOSER === +export const composer = new EffectComposer(renderer); +composer.addPass(new RenderPass(scene, camera)); + +export const bokehPass = new BokehPass(scene, camera, { + focus: 5.0, + aperture: 0.00015, + maxblur: 0.004, +}); +composer.addPass(bokehPass); + +// === ORBIT CONTROLS (photo mode) === +export const orbitControls = new OrbitControls(camera, renderer.domElement); +orbitControls.enableDamping = true; +orbitControls.dampingFactor = 0.05; +orbitControls.enabled = false; diff --git a/modules/ui.js b/modules/ui.js new file mode 100644 index 0000000..8261081 --- /dev/null +++ b/modules/ui.js @@ -0,0 +1,129 @@ +import * as THREE from 'three'; +import { constellationLines, starMaterial } from './effects.js'; +import { wsClient } from '../ws-client.js'; + +// === WEBSOCKET CLIENT === +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(); + } +}); + +window.addEventListener('beforeunload', () => { + wsClient.disconnect(); +}); + +// === SOVEREIGNTY EASTER EGG === +const SOVEREIGNTY_WORD = 'sovereignty'; +let sovereigntyBuffer = ''; +let sovereigntyBufferTimer = /** @type {ReturnType|null} */ (null); + +const sovereigntyMsg = document.getElementById('sovereignty-msg'); + +/** + * Triggers the sovereignty Easter egg: stars pulse gold, message flashes. + */ +export function triggerSovereigntyEasterEgg() { + const originalLineColor = constellationLines.material.color.getHex(); + constellationLines.material.color.setHex(0xffd700); + constellationLines.material.opacity = 0.9; + + const originalStarColor = starMaterial.color.getHex(); + const originalStarOpacity = starMaterial.opacity; + starMaterial.color.setHex(0xffd700); + starMaterial.opacity = 1.0; + + if (sovereigntyMsg) { + sovereigntyMsg.classList.remove('visible'); + void sovereigntyMsg.offsetWidth; + sovereigntyMsg.classList.add('visible'); + } + + const startTime = performance.now(); + const DURATION = 2500; + + function fadeBack() { + const t = Math.min((performance.now() - startTime) / DURATION, 1); + const eased = t * t; + + 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; + + 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 { + starMaterial.color.setHex(originalStarColor); + starMaterial.opacity = originalStarOpacity; + constellationLines.material.color.setHex(originalLineColor); + if (sovereigntyMsg) sovereigntyMsg.classList.remove('visible'); + } + } + + requestAnimationFrame(fadeBack); +} + +document.addEventListener('keydown', (e) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (e.key.length !== 1) { + sovereigntyBuffer = ''; + return; + } + + sovereigntyBuffer += e.key.toLowerCase(); + + if (sovereigntyBuffer.length > SOVEREIGNTY_WORD.length) { + sovereigntyBuffer = sovereigntyBuffer.slice(-SOVEREIGNTY_WORD.length); + } + + if (sovereigntyBuffer === SOVEREIGNTY_WORD) { + sovereigntyBuffer = ''; + triggerSovereigntyEasterEgg(); + } + + if (sovereigntyBufferTimer) clearTimeout(sovereigntyBufferTimer); + sovereigntyBufferTimer = setTimeout(() => { sovereigntyBuffer = ''; }, 3000); +}); + +// === 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) { + 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'; + }); + } +});