diff --git a/app.js b/app.js index 051fe82..6d4fcc3 100644 --- a/app.js +++ b/app.js @@ -1,27 +1,450 @@ -// ... existing code ... - -// === WEBSOCKET CLIENT === +// === THE NEXUS — app.js === +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 { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; import { wsClient } from './ws-client.js'; -// Initialize WebSocket client -wsClient.connect(); +// === COLOR PALETTE === +const NEXUS = { + colors: { + primary: 0x00ffff, + secondary: 0xff00ff, + accent: 0xffff00, + bg: 0x020408, + starWhite: 0xc0e8ff, + coreGlow: 0x00ffff, + nebula1: 0x1a0040, + nebula2: 0x001a40, + particle: 0x88ccff, + } +}; -// Handle WebSocket events -window.addEventListener('player-joined', (event) => { - console.log('Player joined:', event.detail); -}); +// === QUALITY PRESETS === +const QUALITY_HIGH = 'high'; +const QUALITY_LOW = 'low'; +let currentQuality = null; -window.addEventListener('player-left', (event) => { - console.log('Player left:', event.detail); -}); +// Scene globals +let scene, camera, renderer, controls; +let composer, bloomPass; +let nexusCore, particles, particlePositions; +const PARTICLE_COUNT_HIGH = 3000; +const PARTICLE_COUNT_LOW = 800; -window.addEventListener('chat-message', (event) => { - console.log('Chat message:', event.detail); -}); +// === PERFORMANCE AUTO-DETECTION === +// Measures actual frame time over N frames, returns average FPS. +function measureFPS(frameCount) { + return new Promise((resolve) => { + const times = []; + let prev = performance.now(); -// Clean up on page unload -window.addEventListener('beforeunload', () => { - wsClient.disconnect(); -}); + function tick() { + const now = performance.now(); + times.push(now - prev); + prev = now; -// ... existing code ... + if (times.length < frameCount) { + requestAnimationFrame(tick); + } else { + const avgMs = times.reduce((a, b) => a + b, 0) / times.length; + resolve(1000 / avgMs); + } + } + + requestAnimationFrame(tick); + }); +} + +async function detectPerformanceTier() { + setLoadingStatus('Measuring performance…'); + setLoadingProgress(50); + + const fps = await measureFPS(60); + console.log(`[Nexus] Performance: ${fps.toFixed(1)} FPS avg over 60 frames`); + + const tier = fps >= 30 ? QUALITY_HIGH : QUALITY_LOW; + applyQualityPreset(tier, fps); + return tier; +} + +// === QUALITY APPLICATION === +function applyQualityPreset(tier, fps) { + currentQuality = tier; + + if (tier === QUALITY_LOW) { + // Disable bloom + if (composer && bloomPass) { + bloomPass.enabled = false; + } + + // Reduce particle count + if (particles) { + rebuildParticles(PARTICLE_COUNT_LOW); + } + + // Simplify core material + if (nexusCore) { + nexusCore.material.dispose(); + nexusCore.material = new THREE.MeshLambertMaterial({ + color: NEXUS.colors.primary, + wireframe: true, + }); + } + + console.log('[Nexus] Quality: LOW — bloom off, particles reduced, materials simplified'); + } else { + // Ensure bloom is enabled (default) + if (composer && bloomPass) { + bloomPass.enabled = true; + } + console.log('[Nexus] Quality: HIGH — full rendering enabled'); + } + + updateQualityIndicator(tier, fps); +} + +function updateQualityIndicator(tier, fps) { + const el = document.getElementById('quality-indicator'); + const label = document.getElementById('quality-label'); + if (!el || !label) return; + + el.classList.remove('quality-high', 'quality-low', 'quality-detecting'); + el.classList.add(`quality-${tier}`); + label.textContent = `${tier.toUpperCase()} · ${Math.round(fps)} FPS`; +} + +// === SCENE SETUP === +function initScene() { + scene = new THREE.Scene(); + scene.fog = new THREE.FogExp2(NEXUS.colors.bg, 0.018); + scene.background = new THREE.Color(NEXUS.colors.bg); + + camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 600); + camera.position.set(0, 2, 12); + + renderer = new THREE.WebGLRenderer({ + canvas: document.getElementById('canvas'), + antialias: true, + powerPreference: 'high-performance', + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.0; + + controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.06; + controls.minDistance = 3; + controls.maxDistance = 80; + controls.target.set(0, 0, 0); + + // Post-processing + composer = new EffectComposer(renderer); + composer.addPass(new RenderPass(scene, camera)); + bloomPass = new UnrealBloomPass( + new THREE.Vector2(window.innerWidth, window.innerHeight), + 1.2, // strength + 0.5, // radius + 0.75 // threshold + ); + composer.addPass(bloomPass); + + window.addEventListener('resize', onResize); +} + +function onResize() { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); + composer.setSize(window.innerWidth, window.innerHeight); +} + +// === LIGHTING === +function buildLighting() { + scene.add(new THREE.AmbientLight(0x112233, 0.8)); + + const sun = new THREE.DirectionalLight(0xffffff, 1.2); + sun.position.set(10, 20, 10); + scene.add(sun); + + const rimLight = new THREE.PointLight(NEXUS.colors.primary, 2, 30); + rimLight.position.set(-6, 4, -6); + scene.add(rimLight); + + const fill = new THREE.PointLight(NEXUS.colors.secondary, 1, 20); + fill.position.set(6, -2, 6); + scene.add(fill); +} + +// === STAR FIELD === +function buildStars() { + const count = 4000; + const geo = new THREE.BufferGeometry(); + const pos = new Float32Array(count * 3); + + for (let i = 0; i < count * 3; i++) { + pos[i] = (Math.random() - 0.5) * 500; + } + geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + + const mat = new THREE.PointsMaterial({ + color: NEXUS.colors.starWhite, + size: 0.35, + sizeAttenuation: true, + transparent: true, + opacity: 0.85, + }); + + scene.add(new THREE.Points(geo, mat)); +} + +// === NEXUS CORE === +function buildNexusCore() { + const geo = new THREE.IcosahedronGeometry(1.2, 1); + const mat = new THREE.MeshStandardMaterial({ + color: NEXUS.colors.primary, + emissive: NEXUS.colors.primary, + emissiveIntensity: 0.4, + metalness: 0.8, + roughness: 0.15, + }); + nexusCore = new THREE.Mesh(geo, mat); + nexusCore.position.set(0, 0.5, 0); + scene.add(nexusCore); + + // Pedestal + const pedGeo = new THREE.CylinderGeometry(0.3, 0.5, 0.5, 8); + const pedMat = new THREE.MeshStandardMaterial({ color: 0x224466, metalness: 0.6, roughness: 0.4 }); + const pedestal = new THREE.Mesh(pedGeo, pedMat); + pedestal.position.set(0, -0.85, 0); + scene.add(pedestal); + + // Glow ring + const ringGeo = new THREE.TorusGeometry(1.8, 0.04, 8, 64); + const ringMat = new THREE.MeshBasicMaterial({ color: NEXUS.colors.primary }); + const ring = new THREE.Mesh(ringGeo, ringMat); + ring.rotation.x = Math.PI / 2; + ring.position.y = 0.5; + scene.add(ring); +} + +// === PARTICLE SYSTEM === +function buildParticles(count) { + const geo = new THREE.BufferGeometry(); + particlePositions = new Float32Array(count * 3); + + for (let i = 0; i < count; i++) { + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2 * Math.random() - 1); + const r = 3 + Math.random() * 14; + particlePositions[i * 3] = r * Math.sin(phi) * Math.cos(theta); + particlePositions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta); + particlePositions[i * 3 + 2] = r * Math.cos(phi); + } + + geo.setAttribute('position', new THREE.BufferAttribute(particlePositions, 3)); + + const mat = new THREE.PointsMaterial({ + color: NEXUS.colors.particle, + size: 0.12, + sizeAttenuation: true, + transparent: true, + opacity: 0.7, + }); + + if (particles) { + scene.remove(particles); + particles.geometry.dispose(); + particles.material.dispose(); + } + + particles = new THREE.Points(geo, mat); + scene.add(particles); +} + +function rebuildParticles(count) { + buildParticles(count); +} + +// === FLOOR GRID === +function buildFloor() { + const helper = new THREE.GridHelper(40, 20, 0x0a2030, 0x071520); + helper.position.y = -1.2; + scene.add(helper); + + const floorGeo = new THREE.PlaneGeometry(40, 40); + const floorMat = new THREE.MeshStandardMaterial({ + color: 0x030d18, + roughness: 0.9, + metalness: 0.1, + transparent: true, + opacity: 0.85, + }); + const floor = new THREE.Mesh(floorGeo, floorMat); + floor.rotation.x = -Math.PI / 2; + floor.position.y = -1.2; + scene.add(floor); +} + +// === ANIMATION LOOP === +let clock; + +function startLoop() { + clock = new THREE.Clock(); + + function animate() { + requestAnimationFrame(animate); + const t = clock.getElapsedTime(); + + // Rotate core + if (nexusCore) { + nexusCore.rotation.y = t * 0.4; + nexusCore.rotation.x = t * 0.15; + } + + // Drift particles + if (particles) { + particles.rotation.y = t * 0.02; + particles.rotation.x = t * 0.005; + } + + controls.update(); + composer.render(); + } + + animate(); +} + +// === CHAT === +function initChat() { + const toggle = document.getElementById('chat-toggle'); + const panel = document.getElementById('chat-panel'); + const close = document.getElementById('chat-close'); + const input = document.getElementById('chat-input'); + const send = document.getElementById('chat-send'); + const msgs = document.getElementById('chat-messages'); + + function openPanel() { panel.classList.add('open'); toggle.style.display = 'none'; } + function closePanel() { panel.classList.remove('open'); toggle.style.display = ''; } + + toggle.addEventListener('click', openPanel); + close.addEventListener('click', closePanel); + + function sendMsg() { + const text = input.value.trim(); + if (!text) return; + appendMsg('You', text, '#aaddff'); + wsClient.send({ type: 'chat-message', text }); + input.value = ''; + } + + send.addEventListener('click', sendMsg); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendMsg(); }); + + function appendMsg(from, text, color) { + const p = document.createElement('p'); + p.innerHTML = `[${from}] ${text}`; + msgs.appendChild(p); + msgs.scrollTop = msgs.scrollHeight; + } + + window.addEventListener('chat-message', (e) => { + appendMsg('Timmy', e.detail.text || e.detail.message || '…', '#00ffcc'); + }); +} + +// === AUDIO === +function initAudio() { + const btn = document.getElementById('audio-toggle'); + const audio = document.getElementById('ambient-sound'); + let muted = true; + + btn.addEventListener('click', () => { + muted = !muted; + if (muted) { + audio.pause(); + btn.textContent = '🔇'; + btn.classList.add('muted'); + } else { + audio.play().catch(() => {}); + btn.textContent = '🔊'; + btn.classList.remove('muted'); + } + }); +} + +// === WEBSOCKET === +function initWebSocket() { + wsClient.connect(); + + window.addEventListener('player-joined', (e) => { + console.log('[Nexus] Player joined:', e.detail); + }); + window.addEventListener('player-left', (e) => { + console.log('[Nexus] Player left:', e.detail); + }); + + window.addEventListener('beforeunload', () => wsClient.disconnect()); +} + +// === LOADING HELPERS === +function setLoadingProgress(pct) { + const bar = document.getElementById('loading-progress'); + if (bar) bar.style.width = `${pct}%`; +} + +function setLoadingStatus(msg) { + const el = document.getElementById('loading-status'); + if (el) el.textContent = msg; +} + +function hideLoading() { + const el = document.getElementById('loading-screen'); + if (el) { + el.classList.add('hidden'); + setTimeout(() => el.remove(), 900); + } +} + +// === BOOT === +async function boot() { + // Set quality indicator to detecting state + const qEl = document.getElementById('quality-indicator'); + if (qEl) qEl.classList.add('quality-detecting'); + + setLoadingProgress(10); + setLoadingStatus('Building scene…'); + + initScene(); + buildLighting(); + buildStars(); + buildNexusCore(); + buildParticles(PARTICLE_COUNT_HIGH); + buildFloor(); + + setLoadingProgress(30); + setLoadingStatus('Starting render loop…'); + + // Start rendering so the GPU warms up before measurement + startLoop(); + + setLoadingProgress(40); + + // Measure real FPS over 60 frames (blocks ~1 second at 60 FPS) + await detectPerformanceTier(); + + setLoadingProgress(90); + setLoadingStatus('Ready.'); + + initChat(); + initAudio(); + initWebSocket(); + + setLoadingProgress(100); + setTimeout(hideLoading, 300); +} + +boot(); diff --git a/index.html b/index.html index 34af931..944fb79 100644 --- a/index.html +++ b/index.html @@ -14,18 +14,64 @@ + +
- + - -