[claude] Three.js scene foundation — navigation modes + performance budget (#4) (#28)

Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
This commit was merged in pull request #28.
This commit is contained in:
2026-03-24 01:30:25 +00:00
committed by Timmy Time
parent c3ce95b9e7
commit 2ea5ad3780
3 changed files with 192 additions and 35 deletions

216
app.js
View File

@@ -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();
}

View File

@@ -95,9 +95,11 @@
</div>
</div>
<!-- Minimap / Controls hint -->
<!-- Controls hint + nav mode -->
<div class="hud-controls">
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat &nbsp;
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
<span id="nav-mode-hint" class="nav-mode-hint"></span>
</div>
</div>

View File

@@ -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 {