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:
216
app.js
216
app.js
@@ -34,6 +34,26 @@ let debugOverlay;
|
|||||||
let frameCount = 0, lastFPSTime = 0, fps = 0;
|
let frameCount = 0, lastFPSTime = 0, fps = 0;
|
||||||
let chatOpen = true;
|
let chatOpen = true;
|
||||||
let loadProgress = 0;
|
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 ═══
|
// ═══ INIT ═══
|
||||||
function init() {
|
function init() {
|
||||||
@@ -45,11 +65,13 @@ function init() {
|
|||||||
const canvas = document.getElementById('nexus-canvas');
|
const canvas = document.getElementById('nexus-canvas');
|
||||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
renderer.toneMappingExposure = 1.2;
|
renderer.toneMappingExposure = 1.2;
|
||||||
renderer.shadowMap.enabled = true;
|
renderer.shadowMap.enabled = true;
|
||||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
|
||||||
|
// Performance budget — must run before scene objects are created
|
||||||
|
performanceTier = detectPerformanceTier();
|
||||||
updateLoad(20);
|
updateLoad(20);
|
||||||
|
|
||||||
// Scene
|
// Scene
|
||||||
@@ -125,6 +147,33 @@ function updateLoad(pct) {
|
|||||||
if (fill) fill.style.width = 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 ═══
|
// ═══ SKYBOX ═══
|
||||||
function createSkybox() {
|
function createSkybox() {
|
||||||
// Procedural nebula skybox using shader
|
// Procedural nebula skybox using shader
|
||||||
@@ -230,8 +279,9 @@ function createLighting() {
|
|||||||
// Main directional (moonlight feel)
|
// Main directional (moonlight feel)
|
||||||
const dirLight = new THREE.DirectionalLight(0x4466aa, 0.6);
|
const dirLight = new THREE.DirectionalLight(0x4466aa, 0.6);
|
||||||
dirLight.position.set(10, 20, 10);
|
dirLight.position.set(10, 20, 10);
|
||||||
dirLight.castShadow = true;
|
dirLight.castShadow = renderer.shadowMap.enabled;
|
||||||
dirLight.shadow.mapSize.set(1024, 1024);
|
const shadowRes = performanceTier === 'high' ? 2048 : performanceTier === 'medium' ? 1024 : 512;
|
||||||
|
dirLight.shadow.mapSize.set(shadowRes, shadowRes);
|
||||||
dirLight.shadow.camera.near = 0.5;
|
dirLight.shadow.camera.near = 0.5;
|
||||||
dirLight.shadow.camera.far = 80;
|
dirLight.shadow.camera.far = 80;
|
||||||
dirLight.shadow.camera.left = -30;
|
dirLight.shadow.camera.left = -30;
|
||||||
@@ -617,7 +667,7 @@ function createPortal() {
|
|||||||
|
|
||||||
// ═══ PARTICLES ═══
|
// ═══ PARTICLES ═══
|
||||||
function createParticles() {
|
function createParticles() {
|
||||||
const count = 1500;
|
const count = particleCount(1500);
|
||||||
const geo = new THREE.BufferGeometry();
|
const geo = new THREE.BufferGeometry();
|
||||||
const positions = new Float32Array(count * 3);
|
const positions = new Float32Array(count * 3);
|
||||||
const colors = new Float32Array(count * 3);
|
const colors = new Float32Array(count * 3);
|
||||||
@@ -681,7 +731,7 @@ function createParticles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDustParticles() {
|
function createDustParticles() {
|
||||||
const count = 500;
|
const count = particleCount(500);
|
||||||
const geo = new THREE.BufferGeometry();
|
const geo = new THREE.BufferGeometry();
|
||||||
const positions = new Float32Array(count * 3);
|
const positions = new Float32Array(count * 3);
|
||||||
|
|
||||||
@@ -787,6 +837,33 @@ function createAmbientStructures() {
|
|||||||
scene.add(pedestal);
|
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 ═══
|
// ═══ CONTROLS ═══
|
||||||
function setupControls() {
|
function setupControls() {
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
@@ -803,25 +880,51 @@ function setupControls() {
|
|||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
document.getElementById('chat-input').blur();
|
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) => {
|
document.addEventListener('keyup', (e) => {
|
||||||
keys[e.key.toLowerCase()] = false;
|
keys[e.key.toLowerCase()] = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mouse look
|
// Mouse look / orbit drag
|
||||||
const canvas = document.getElementById('nexus-canvas');
|
const canvas = document.getElementById('nexus-canvas');
|
||||||
canvas.addEventListener('mousedown', (e) => {
|
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('mouseup', () => { mouseDown = false; });
|
||||||
document.addEventListener('mousemove', (e) => {
|
document.addEventListener('mousemove', (e) => {
|
||||||
if (!mouseDown) return;
|
if (!mouseDown) return;
|
||||||
if (document.activeElement === document.getElementById('chat-input')) return;
|
if (document.activeElement === document.getElementById('chat-input')) return;
|
||||||
playerRot.y -= e.movementX * 0.003;
|
const mode = NAV_MODES[navModeIdx];
|
||||||
playerRot.x -= e.movementY * 0.003;
|
if (mode === 'orbit') {
|
||||||
playerRot.x = Math.max(-Math.PI / 3, Math.min(Math.PI / 3, playerRot.x));
|
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
|
// Chat toggle
|
||||||
document.getElementById('chat-toggle').addEventListener('click', () => {
|
document.getElementById('chat-toggle').addEventListener('click', () => {
|
||||||
chatOpen = !chatOpen;
|
chatOpen = !chatOpen;
|
||||||
@@ -878,30 +981,77 @@ function gameLoop() {
|
|||||||
const delta = Math.min(clock.getDelta(), 0.1);
|
const delta = Math.min(clock.getDelta(), 0.1);
|
||||||
const elapsed = clock.elapsedTime;
|
const elapsed = clock.elapsedTime;
|
||||||
|
|
||||||
// Movement
|
// ─── Navigation update ───────────────────────────────────────────
|
||||||
if (document.activeElement !== document.getElementById('chat-input')) {
|
const mode = NAV_MODES[navModeIdx];
|
||||||
const speed = 6 * delta;
|
const chatActive = document.activeElement === document.getElementById('chat-input');
|
||||||
const dir = new THREE.Vector3();
|
|
||||||
if (keys['w']) dir.z -= 1;
|
if (mode === 'walk') {
|
||||||
if (keys['s']) dir.z += 1;
|
if (!chatActive) {
|
||||||
if (keys['a']) dir.x -= 1;
|
const speed = 6 * delta;
|
||||||
if (keys['d']) dir.x += 1;
|
const dir = new THREE.Vector3();
|
||||||
if (dir.length() > 0) {
|
if (keys['w']) dir.z -= 1;
|
||||||
dir.normalize().multiplyScalar(speed);
|
if (keys['s']) dir.z += 1;
|
||||||
dir.applyAxisAngle(new THREE.Vector3(0, 1, 0), playerRot.y);
|
if (keys['a']) dir.x -= 1;
|
||||||
playerPos.add(dir);
|
if (keys['d']) dir.x += 1;
|
||||||
// Clamp to platform
|
if (dir.length() > 0) {
|
||||||
const maxR = 24;
|
dir.normalize().multiplyScalar(speed);
|
||||||
const dist = Math.sqrt(playerPos.x * playerPos.x + playerPos.z * playerPos.z);
|
dir.applyAxisAngle(new THREE.Vector3(0, 1, 0), playerRot.y);
|
||||||
if (dist > maxR) {
|
playerPos.add(dir);
|
||||||
playerPos.x *= maxR / dist;
|
// Clamp to platform
|
||||||
playerPos.z *= maxR / dist;
|
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);
|
} else if (mode === 'orbit') {
|
||||||
camera.rotation.set(playerRot.x, playerRot.y, 0, 'YXZ');
|
// 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
|
// Animate skybox
|
||||||
const sky = scene.getObjectByName('skybox');
|
const sky = scene.getObjectByName('skybox');
|
||||||
@@ -964,8 +1114,8 @@ function gameLoop() {
|
|||||||
if (debugOverlay) {
|
if (debugOverlay) {
|
||||||
const info = renderer.info;
|
const info = renderer.info;
|
||||||
debugOverlay.textContent =
|
debugOverlay.textContent =
|
||||||
`FPS: ${fps} Draw: ${info.render?.calls} Tri: ${info.render?.triangles}\n` +
|
`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)}`;
|
`Pos: ${playerPos.x.toFixed(1)}, ${playerPos.y.toFixed(1)}, ${playerPos.z.toFixed(1)} NAV: ${NAV_MODES[navModeIdx]}`;
|
||||||
}
|
}
|
||||||
renderer.info.reset();
|
renderer.info.reset();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,9 +95,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Minimap / Controls hint -->
|
<!-- Controls hint + nav mode -->
|
||||||
<div class="hud-controls">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,11 @@ canvas#nexus-canvas {
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
#nav-mode-label {
|
||||||
|
color: var(--color-gold);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
/* === CHAT PANEL === */
|
/* === CHAT PANEL === */
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
|
|||||||
Reference in New Issue
Block a user