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 === const NEXUS = { colors: { bg: 0x000008, starCore: 0xffffff, starDim: 0x8899cc, constellationLine: 0x334488, constellationFade: 0x112244, accent: 0x4488ff, } }; // === 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, 0, 5); 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); // === 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, 0, 5); 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); }); // === ANIMATION LOOP === const clock = new THREE.Clock(); /** * Main animation loop — called each frame via requestAnimationFrame. * @returns {void} */ function animate() { 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; if (photoMode) { orbitControls.update(); } 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|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(); });