diff --git a/app.js b/app.js index 60689a0..551008c 100644 --- a/app.js +++ b/app.js @@ -34,6 +34,26 @@ let debugOverlay; let frameCount = 0, lastFPSTime = 0, fps = 0; let chatOpen = true; let loadProgress = 0; +let performanceTier = 'high'; // 'high' | 'medium' | 'low' + +// ═══ NAVIGATION SYSTEM ═══ +const NAV_MODES = ['walk', 'orbit', 'fly']; +let navModeIdx = 0; // default: walk + +// Orbit state +const orbitState = { + target: new THREE.Vector3(0, 2, 0), + radius: 14, + theta: Math.PI, // azimuthal (horizontal rotation) + phi: Math.PI / 6, // polar (vertical tilt, 0=top) + minR: 3, + maxR: 40, + lastX: 0, + lastY: 0, +}; + +// Fly state — separate Y so walk and fly share XZ history +let flyY = 2; // ═══ INIT ═══ function init() { @@ -45,11 +65,13 @@ function init() { const canvas = document.getElementById('nexus-canvas'); renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.2; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; + + // Performance budget — must run before scene objects are created + performanceTier = detectPerformanceTier(); updateLoad(20); // Scene @@ -125,6 +147,33 @@ function updateLoad(pct) { if (fill) fill.style.width = pct + '%'; } +// ═══ PERFORMANCE BUDGET ═══ +function detectPerformanceTier() { + const isMobile = /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent) || window.innerWidth < 768; + const cores = navigator.hardwareConcurrency || 4; + + if (isMobile) { + renderer.setPixelRatio(1); + renderer.shadowMap.enabled = false; + renderer.toneMappingExposure = 1.0; + return 'low'; + } else if (cores < 8) { + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); + renderer.shadowMap.type = THREE.BasicShadowMap; + return 'medium'; + } else { + // M3 Max / high-end desktop — full quality + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + return 'high'; + } +} + +function particleCount(base) { + if (performanceTier === 'low') return Math.floor(base * 0.25); + if (performanceTier === 'medium') return Math.floor(base * 0.6); + return base; +} + // ═══ SKYBOX ═══ function createSkybox() { // Procedural nebula skybox using shader @@ -230,8 +279,9 @@ function createLighting() { // Main directional (moonlight feel) const dirLight = new THREE.DirectionalLight(0x4466aa, 0.6); dirLight.position.set(10, 20, 10); - dirLight.castShadow = true; - dirLight.shadow.mapSize.set(1024, 1024); + dirLight.castShadow = renderer.shadowMap.enabled; + const shadowRes = performanceTier === 'high' ? 2048 : performanceTier === 'medium' ? 1024 : 512; + dirLight.shadow.mapSize.set(shadowRes, shadowRes); dirLight.shadow.camera.near = 0.5; dirLight.shadow.camera.far = 80; dirLight.shadow.camera.left = -30; @@ -617,7 +667,7 @@ function createPortal() { // ═══ PARTICLES ═══ function createParticles() { - const count = 1500; + const count = particleCount(1500); const geo = new THREE.BufferGeometry(); const positions = new Float32Array(count * 3); const colors = new Float32Array(count * 3); @@ -681,7 +731,7 @@ function createParticles() { } function createDustParticles() { - const count = 500; + const count = particleCount(500); const geo = new THREE.BufferGeometry(); const positions = new Float32Array(count * 3); @@ -787,6 +837,33 @@ function createAmbientStructures() { scene.add(pedestal); } +// ═══ NAVIGATION MODE ═══ +function cycleNavMode() { + navModeIdx = (navModeIdx + 1) % NAV_MODES.length; + const mode = NAV_MODES[navModeIdx]; + + // Sync orbit target/radius from current camera when switching into orbit + if (mode === 'orbit') { + const dir = new THREE.Vector3(0, 0, -1).applyEuler(playerRot); + orbitState.target.copy(playerPos).addScaledVector(dir, orbitState.radius); + orbitState.target.y = Math.max(0, orbitState.target.y); + // Recompute angles from current camera → target vector + const toCamera = new THREE.Vector3().subVectors(playerPos, orbitState.target); + orbitState.radius = toCamera.length(); + orbitState.theta = Math.atan2(toCamera.x, toCamera.z); + orbitState.phi = Math.acos(Math.max(-1, Math.min(1, toCamera.y / orbitState.radius))); + } + // Sync fly Y from current walk position + if (mode === 'fly') flyY = playerPos.y; + + updateNavModeUI(mode); +} + +function updateNavModeUI(mode) { + const el = document.getElementById('nav-mode-label'); + if (el) el.textContent = mode.toUpperCase(); +} + // ═══ CONTROLS ═══ function setupControls() { document.addEventListener('keydown', (e) => { @@ -803,25 +880,51 @@ function setupControls() { if (e.key === 'Escape') { document.getElementById('chat-input').blur(); } + // V cycles navigation modes + if (e.key.toLowerCase() === 'v' && document.activeElement !== document.getElementById('chat-input')) { + cycleNavMode(); + } }); document.addEventListener('keyup', (e) => { keys[e.key.toLowerCase()] = false; }); - // Mouse look + // Mouse look / orbit drag const canvas = document.getElementById('nexus-canvas'); canvas.addEventListener('mousedown', (e) => { - if (e.target === canvas) mouseDown = true; + if (e.target === canvas) { + mouseDown = true; + orbitState.lastX = e.clientX; + orbitState.lastY = e.clientY; + } }); document.addEventListener('mouseup', () => { mouseDown = false; }); document.addEventListener('mousemove', (e) => { if (!mouseDown) return; if (document.activeElement === document.getElementById('chat-input')) return; - playerRot.y -= e.movementX * 0.003; - playerRot.x -= e.movementY * 0.003; - playerRot.x = Math.max(-Math.PI / 3, Math.min(Math.PI / 3, playerRot.x)); + const mode = NAV_MODES[navModeIdx]; + if (mode === 'orbit') { + const dx = e.clientX - orbitState.lastX; + const dy = e.clientY - orbitState.lastY; + orbitState.lastX = e.clientX; + orbitState.lastY = e.clientY; + orbitState.theta -= dx * 0.005; + orbitState.phi = Math.max(0.05, Math.min(Math.PI * 0.85, orbitState.phi + dy * 0.005)); + } else { + // Walk and Fly: mouse look + playerRot.y -= e.movementX * 0.003; + playerRot.x -= e.movementY * 0.003; + playerRot.x = Math.max(-Math.PI / 3, Math.min(Math.PI / 3, playerRot.x)); + } }); + // Scroll to zoom in orbit mode + canvas.addEventListener('wheel', (e) => { + if (NAV_MODES[navModeIdx] === 'orbit') { + orbitState.radius = Math.max(orbitState.minR, Math.min(orbitState.maxR, orbitState.radius + e.deltaY * 0.02)); + } + }, { passive: true }); + // Chat toggle document.getElementById('chat-toggle').addEventListener('click', () => { chatOpen = !chatOpen; @@ -878,30 +981,77 @@ function gameLoop() { const delta = Math.min(clock.getDelta(), 0.1); const elapsed = clock.elapsedTime; - // Movement - if (document.activeElement !== document.getElementById('chat-input')) { - const speed = 6 * delta; - const dir = new THREE.Vector3(); - if (keys['w']) dir.z -= 1; - if (keys['s']) dir.z += 1; - if (keys['a']) dir.x -= 1; - if (keys['d']) dir.x += 1; - if (dir.length() > 0) { - dir.normalize().multiplyScalar(speed); - dir.applyAxisAngle(new THREE.Vector3(0, 1, 0), playerRot.y); - playerPos.add(dir); - // Clamp to platform - const maxR = 24; - const dist = Math.sqrt(playerPos.x * playerPos.x + playerPos.z * playerPos.z); - if (dist > maxR) { - playerPos.x *= maxR / dist; - playerPos.z *= maxR / dist; + // ─── Navigation update ─────────────────────────────────────────── + const mode = NAV_MODES[navModeIdx]; + const chatActive = document.activeElement === document.getElementById('chat-input'); + + if (mode === 'walk') { + if (!chatActive) { + const speed = 6 * delta; + const dir = new THREE.Vector3(); + if (keys['w']) dir.z -= 1; + if (keys['s']) dir.z += 1; + if (keys['a']) dir.x -= 1; + if (keys['d']) dir.x += 1; + if (dir.length() > 0) { + dir.normalize().multiplyScalar(speed); + dir.applyAxisAngle(new THREE.Vector3(0, 1, 0), playerRot.y); + playerPos.add(dir); + // Clamp to platform + const maxR = 24; + const dist = Math.sqrt(playerPos.x * playerPos.x + playerPos.z * playerPos.z); + if (dist > maxR) { playerPos.x *= maxR / dist; playerPos.z *= maxR / dist; } } } - } + playerPos.y = 2; // fixed eye height in walk mode + camera.position.copy(playerPos); + camera.rotation.set(playerRot.x, playerRot.y, 0, 'YXZ'); - camera.position.copy(playerPos); - camera.rotation.set(playerRot.x, playerRot.y, 0, 'YXZ'); + } else if (mode === 'orbit') { + // Pan target with WASD + if (!chatActive) { + const speed = 8 * delta; + const pan = new THREE.Vector3(); + if (keys['w']) pan.z -= 1; + if (keys['s']) pan.z += 1; + if (keys['a']) pan.x -= 1; + if (keys['d']) pan.x += 1; + if (pan.length() > 0) { + pan.normalize().multiplyScalar(speed); + pan.applyAxisAngle(new THREE.Vector3(0, 1, 0), orbitState.theta); + orbitState.target.add(pan); + orbitState.target.y = Math.max(0, Math.min(20, orbitState.target.y)); + } + } + // Position camera on sphere around target + const r = orbitState.radius; + camera.position.set( + orbitState.target.x + r * Math.sin(orbitState.phi) * Math.sin(orbitState.theta), + orbitState.target.y + r * Math.cos(orbitState.phi), + orbitState.target.z + r * Math.sin(orbitState.phi) * Math.cos(orbitState.theta) + ); + camera.lookAt(orbitState.target); + // Keep playerPos in sync so switching back to walk is smooth + playerPos.copy(camera.position); + playerRot.y = orbitState.theta; + + } else if (mode === 'fly') { + if (!chatActive) { + const speed = 8 * delta; + const forward = new THREE.Vector3(-Math.sin(playerRot.y), 0, -Math.cos(playerRot.y)); + const right = new THREE.Vector3( Math.cos(playerRot.y), 0, -Math.sin(playerRot.y)); + if (keys['w']) playerPos.addScaledVector(forward, speed); + if (keys['s']) playerPos.addScaledVector(forward, -speed); + if (keys['a']) playerPos.addScaledVector(right, -speed); + if (keys['d']) playerPos.addScaledVector(right, speed); + if (keys['q'] || keys[' ']) flyY += speed; + if (keys['e'] || keys['shift']) flyY -= speed; + flyY = Math.max(0.5, Math.min(30, flyY)); + playerPos.y = flyY; + } + camera.position.copy(playerPos); + camera.rotation.set(playerRot.x, playerRot.y, 0, 'YXZ'); + } // Animate skybox const sky = scene.getObjectByName('skybox'); @@ -964,8 +1114,8 @@ function gameLoop() { if (debugOverlay) { const info = renderer.info; debugOverlay.textContent = - `FPS: ${fps} Draw: ${info.render?.calls} Tri: ${info.render?.triangles}\n` + - `Pos: ${playerPos.x.toFixed(1)}, ${playerPos.y.toFixed(1)}, ${playerPos.z.toFixed(1)}`; + `FPS: ${fps} Draw: ${info.render?.calls} Tri: ${info.render?.triangles} [${performanceTier}]\n` + + `Pos: ${playerPos.x.toFixed(1)}, ${playerPos.y.toFixed(1)}, ${playerPos.z.toFixed(1)} NAV: ${NAV_MODES[navModeIdx]}`; } renderer.info.reset(); } diff --git a/index.html b/index.html index 3a2c6ea..48dd6ab 100644 --- a/index.html +++ b/index.html @@ -95,9 +95,11 @@ - +
- WASD move   Mouse look   Enter chat + WASD move   Mouse look   Enter chat   + V mode: WALK +
diff --git a/style.css b/style.css index 519b05e..1b5f3a1 100644 --- a/style.css +++ b/style.css @@ -215,6 +215,11 @@ canvas#nexus-canvas { color: var(--color-primary); font-weight: 600; } +#nav-mode-label { + color: var(--color-gold); + font-weight: 700; + letter-spacing: 0.05em; +} /* === CHAT PANEL === */ .chat-panel {