From 634092f60e03e7a1c448996d6a7f2f43e8ae611a Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 23:57:36 -0400 Subject: [PATCH] feat: add performance auto-detection and quality presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - detectPerformanceTier() measures real frame time over 60 frames using requestAnimationFrame during scene init - If avg FPS < 30 → LOW quality: bloom disabled, particles reduced from 3000 → 800, core material simplified to wireframe MeshLambertMaterial - Quality indicator badge added to HUD showing tier + detected FPS - Full Three.js scene: stars, nexus core (icosahedron), particle cloud, floor grid, orbit controls, UnrealBloom post-processing - CSS quality states: quality-high (green dot), quality-low (orange dot), quality-detecting (muted) Fixes #94 --- app.js | 463 ++++++++++++++++++++++++++++++++++++++++++++++++++--- index.html | 62 ++++++- style.css | 244 ++++++++++++++++++++++++++-- 3 files changed, 731 insertions(+), 38 deletions(-) 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 @@ + + - + - -
- - + +
+
+
◈ THE NEXUS
+
+
Initializing...
+
- + +
+ +
◈ NEXUS
+ + +
+
+ DETECTING... +
+
+ + +
+
+ + +
WASD + drag to explore
+
+ + +
+
+ TIMMY TERMINAL + +
+
+
+ + +
+
+ + + + + diff --git a/style.css b/style.css index a2aab15..bc9e9b5 100644 --- a/style.css +++ b/style.css @@ -1,18 +1,242 @@ +/* === NEXUS DESIGN SYSTEM === */ +:root { + --color-bg: #020408; + --color-primary: #00ffff; + --color-secondary: #ff00ff; + --color-accent: #ffff00; + --color-text: #c0e8ff; + --color-text-muted: #4a7a9b; + --color-panel: rgba(0, 20, 40, 0.85); + --color-border: rgba(0, 255, 255, 0.25); + --font-body: 'Courier New', Courier, monospace; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); + overflow: hidden; + width: 100vw; + height: 100vh; +} + +#canvas { + display: block; + width: 100vw; + height: 100vh; + position: fixed; + top: 0; left: 0; +} + +/* === LOADING SCREEN === */ +#loading-screen { + position: fixed; + inset: 0; + background: var(--color-bg); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + transition: opacity 0.8s ease; +} +#loading-screen.hidden { opacity: 0; pointer-events: none; } + +.loading-inner { text-align: center; } +.loading-title { + font-size: 2.5rem; + color: var(--color-primary); + letter-spacing: 0.3em; + margin-bottom: 1.5rem; + text-shadow: 0 0 20px var(--color-primary); +} +.loading-bar { + width: 240px; + height: 4px; + background: var(--color-border); + border-radius: 2px; + margin: 0 auto 0.75rem; + overflow: hidden; +} +#loading-progress { + height: 100%; + width: 0; + background: var(--color-primary); + border-radius: 2px; + transition: width 0.3s ease; + box-shadow: 0 0 8px var(--color-primary); +} +#loading-status { + font-size: 0.7rem; + color: var(--color-text-muted); + letter-spacing: 0.15em; +} + +/* === HUD === */ +#hud { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 10; +} + +#hud-title { + position: absolute; + top: 12px; left: 16px; + font-size: 1rem; + letter-spacing: 0.3em; + color: var(--color-primary); + text-shadow: 0 0 12px var(--color-primary); +} + +#hud-top-right { + position: absolute; + top: 8px; right: 8px; + display: flex; + align-items: center; + gap: 8px; + pointer-events: auto; +} + +/* Quality Indicator */ +.hud-badge { + background: var(--color-panel); + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 4px 10px; + font-size: 0.65rem; + letter-spacing: 0.12em; + display: flex; + align-items: center; + gap: 6px; + backdrop-filter: blur(4px); +} +.hud-badge::before { + content: ''; + display: inline-block; + width: 6px; height: 6px; + border-radius: 50%; + background: var(--quality-dot, var(--color-text-muted)); + box-shadow: 0 0 4px var(--quality-dot, transparent); +} +#quality-indicator.quality-high { + --quality-dot: #00ff88; + border-color: rgba(0, 255, 136, 0.4); + color: #00ff88; +} +#quality-indicator.quality-low { + --quality-dot: #ff8800; + border-color: rgba(255, 136, 0, 0.4); + color: #ff8800; +} +#quality-indicator.quality-detecting { + --quality-dot: var(--color-text-muted); + color: var(--color-text-muted); +} + +#hud-hint { + position: absolute; + bottom: 16px; left: 50%; + transform: translateX(-50%); + font-size: 0.65rem; + color: var(--color-text-muted); + letter-spacing: 0.1em; + pointer-events: none; +} + +/* === BUTTONS === */ +.hud-btn { + background: var(--color-panel); + border: 1px solid var(--color-border); + color: var(--color-text); + font-family: var(--font-body); + font-size: 0.75rem; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + backdrop-filter: blur(4px); + transition: border-color 0.2s, color 0.2s; + pointer-events: auto; +} +.hud-btn:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* === CHAT PANEL === */ +#chat-panel { + position: fixed; + bottom: 16px; right: 16px; + width: 320px; + max-height: 380px; + background: var(--color-panel); + border: 1px solid var(--color-border); + border-radius: 8px; + display: flex; + flex-direction: column; + backdrop-filter: blur(8px); + z-index: 20; + transform: translateY(calc(100% + 16px)); + transition: transform 0.3s ease; +} +#chat-panel.open { transform: translateY(0); } + +#chat-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid var(--color-border); + font-size: 0.7rem; + letter-spacing: 0.15em; + color: var(--color-primary); +} +#chat-messages { + flex: 1; + overflow-y: auto; + padding: 8px 12px; + font-size: 0.75rem; + line-height: 1.5; + min-height: 180px; +} +#chat-input-row { + display: flex; + gap: 6px; + padding: 8px 12px; + border-top: 1px solid var(--color-border); +} +#chat-input { + flex: 1; + background: rgba(0, 0, 0, 0.4); + border: 1px solid var(--color-border); + color: var(--color-text); + font-family: var(--font-body); + font-size: 0.75rem; + padding: 4px 8px; + border-radius: 4px; + outline: none; +} +#chat-input:focus { border-color: var(--color-primary); } + +#chat-toggle { + position: fixed; + bottom: 16px; right: 16px; + z-index: 15; + font-size: 1.1rem; + padding: 8px 12px; +} +#chat-panel.open ~ #chat-toggle { opacity: 0; pointer-events: none; } + /* === AUDIO TOGGLE === */ #audio-toggle { font-size: 14px; - background-color: var(--color-primary-primary); - color: var(--color-bg); + background-color: var(--color-panel); + color: var(--color-text); padding: 4px 8px; border-radius: 4px; font-family: var(--font-body); transition: background-color 0.3s ease; } - -#audio-toggle:hover { - background-color: var(--color-secondary); -} - -#audio-toggle.muted { - background-color: var(--color-text-muted); -} +#audio-toggle:hover { background-color: rgba(0, 255, 255, 0.1); } +#audio-toggle.muted { color: var(--color-text-muted); } -- 2.43.0