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:
182
app.js
182
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;
|
||||
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,8 +981,12 @@ function gameLoop() {
|
||||
const delta = Math.min(clock.getDelta(), 0.1);
|
||||
const elapsed = clock.elapsedTime;
|
||||
|
||||
// Movement
|
||||
if (document.activeElement !== document.getElementById('chat-input')) {
|
||||
// ─── 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;
|
||||
@@ -893,16 +1000,59 @@ function gameLoop() {
|
||||
// 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;
|
||||
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');
|
||||
|
||||
} 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');
|
||||
if (sky) sky.material.uniforms.uTime.value = elapsed;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -95,9 +95,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Minimap / Controls hint -->
|
||||
<!-- Controls hint + nav mode -->
|
||||
<div class="hud-controls">
|
||||
<span>WASD</span> move <span>Mouse</span> look <span>Enter</span> chat
|
||||
<span>WASD</span> move <span>Mouse</span> look <span>Enter</span> chat
|
||||
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
|
||||
<span id="nav-mode-hint" class="nav-mode-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user