// src/main.js — integrator module: game loop, cameras, audio/HUD wiring. // // Owns: renderer/scene/camera bootstrap, fixed-timestep physics driver // (1/120 s, real-dt clamped to 0.05, max 8 steps/frame), three camera modes // (chase / cockpit / orbit), crash→auto-reset, landed message + thump, // pause/reset/debug keys, audio parameter feed, HUD update, resize handling. // // Pure browser entry point. Depends on the module contracts in SPEC.md: // physics.js : createAircraft, resetAircraft, step, getInstrumentData, // getAeroSummary // controls.js : attachInput(canvas, opts) -> { getInputs, reset, dispose, // onAction } // aircraft-model : buildAircraftModel() -> { group, animate, cockpitOffset } // terrain.js : buildEnvironment(scene) -> { update, groundHeight, dispose } // instruments.js : createInstruments(rootEl) -> { update, message, // clearMessage, debug, reset } (DOM class contract in style.css) // audio.js : createAudio() -> { start, setEngine, setWind, setStall, // touchdown, setMuted, dispose } import * as THREE from '../vendor/three.module.min.js'; import { WORLD } from './world.js'; import { createAircraft, resetAircraft, step, getInstrumentData, getAeroSummary, } from './physics.js'; import { attachInput } from './controls.js'; import { buildAircraftModel } from './aircraft-model.js'; import { buildEnvironment } from './terrain.js'; import { createInstruments } from './instruments.js'; import { createAudio } from './audio.js'; // --------------------------------------------------------------------------- // Tunables // --------------------------------------------------------------------------- const FIXED_DT = 1 / 120; // physics substep the caller guarantees const MAX_STEPS_PER_FRAME = 8; const MAX_FRAME_DT = 0.05; // clamp real dt (seconds) per frame const CRASH_RESET_DELAY = 2.5; // s, auto reset after a crash const LANDED_MSG_DURATION = 3.5; // s, LANDED banner lifetime const CAMERA_MODES = ['CHASE', 'COCKPIT', 'ORBIT']; // Chase offset in BODY frame: 8 m aft of the c.g., 2.2 m above centreline. // (SPEC suggests ~[-8, 4, 0]; pure side view hides the nose-line, so we stay // on the centreline and look slightly ahead of the nose instead.) const CHASE_OFFSET = new THREE.Vector3(-8, 0, -2.2); const CHASE_SMOOTH = 6.0; // 1/s lerp rate for chase position const ORBIT_RADIUS = 55; // m const ORBIT_HEIGHT = 18; // m above the c.g. const ORBIT_SPEED = 0.12; // rad/s auto spin const ORBIT_SMOOTH = 4.0; // 1/s lerp rate for orbit position // Cockpit view fix: a three.js camera looks down its LOCAL −Z, while the // aircraft body +X is forward. Copying the physics quaternion alone would aim // the view along body −Z (aircraft up), i.e. straight out of the canopy. // Compose with a −90° rotation about the body Y axis so the view axis −Z // lands on body +X (forward): q_cam = q_plane ∘ R_y(−π/2). // (x, y, z, w) — same handedness convention as Object3D.quaternion. const COCKPIT_Q = new THREE.Quaternion(0, -Math.SQRT1_2, 0, Math.SQRT1_2); // --------------------------------------------------------------------------- // Bootstrap // --------------------------------------------------------------------------- const canvas = document.getElementById('sim'); const hudRoot = document.getElementById('hud'); const overlay = document.getElementById('start-overlay'); const renderer = new THREE.WebGLRenderer({ antialias: true, canvas }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setSize(window.innerWidth, window.innerHeight); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.5, 30000 ); const env = buildEnvironment(scene); // shadows default off (cheap path) const model = buildAircraftModel(); scene.add(model.group); const instruments = createInstruments(hudRoot); const audio = createAudio(); const controls = attachInput(canvas, {}); // --------------------------------------------------------------------------- // Simulation state // --------------------------------------------------------------------------- const aircraft = createAircraft(); let paused = false; let debugOn = false; let cameraMode = 0; // 0 chase, 1 cockpit, 2 orbit let camSnap = true; // snap camera on first frame / mode switch let crashTimer = 0; let landedMsgTimer = 0; let wasLanded = false; let muted = false; let started = false; let fps = 60; let orbitAngle = -Math.PI / 2; // start looking at the ship from its starboard let orbitHeight = ORBIT_HEIGHT; // Per-frame scratch (no allocations in the loop). const planePos = new THREE.Vector3(); const planeQuat = new THREE.Quaternion(); // physics [w,x,y,z] -> (x,y,z,w) const tmpV = new THREE.Vector3(); const tmpV2 = new THREE.Vector3(); const eyeTarget = new THREE.Vector3(); const offVec = new THREE.Vector3(); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function syncModel() { const p = aircraft.pos, q = aircraft.quat; planePos.set(p[0], p[1], p[2]); planeQuat.set(q[1], q[2], q[3], q[0]); // [w,x,y,z] -> (x,y,z,w) model.group.position.copy(planePos); model.group.quaternion.copy(planeQuat); } function resetFlight() { resetAircraft(aircraft); controls.reset(); instruments.reset(); audio.setStall(false); crashTimer = 0; landedMsgTimer = 0; wasLanded = false; } function toggleSound() { muted = !muted; audio.setMuted(muted); } function showDebugLines(data) { if (!debugOn) { instruments.debug(null); return; } const s = getAeroSummary(aircraft); instruments.debug([ `RPM ${(aircraft.rpm * 100).toFixed(0)}% FPS ${fps.toFixed(0)}`, `FUEL ${aircraft.fuelKg.toFixed(1)} kg`, `ALPHA ${data.alphaDeg.toFixed(1)}° BETA ${data.betaDeg.toFixed(1)}°`, `LIFT ${s.liftN.toFixed(0)} N DRAG ${s.dragN.toFixed(0)} N`, `CL ${s.cl.toFixed(3)} CD ${s.cd.toFixed(3)} Q ${s.q.toFixed(1)} Pa`, ]); } function updateCamera(dt, data) { // Chase / orbit: compute the desired eye position, then (unless snapping) // lerp toward it; cockpit: hard-attach to the aircraft attitude. if (cameraMode === 1) { // --- cockpit --- const off = model.cockpitOffset(); // [x, y, z] body frame offVec.set(off[0], off[1], off[2]); // g-load sway: positive g pushes the eye down (+Z body), negative g up. const g = data.gLoad; offVec.z += Math.max(-0.25, Math.min(0.25, (1 - g) * 0.10)); eyeTarget.copy(offVec).applyQuaternion(planeQuat).add(planePos); camera.position.copy(eyeTarget); camera.quaternion.copy(planeQuat); camera.quaternion.multiply(COCKPIT_Q); // aim view −Z along body +X (nose) // cockpits need no snap logic — direct attach is already exact camSnap = false; return; } if (cameraMode === 2) { // --- orbit --- orbitAngle += ORBIT_SPEED * dt; const ca = Math.cos(orbitAngle), sa = Math.sin(orbitAngle); eyeTarget.set( planePos.x + ca * ORBIT_RADIUS, planePos.y + orbitHeight, planePos.z + sa * ORBIT_RADIUS ); const k = camSnap ? 1 : 1 - Math.exp(-ORBIT_SMOOTH * dt); camera.position.lerp(eyeTarget, k); tmpV.copy(planePos); // look at the ship camera.lookAt(tmpV); camSnap = false; return; } // --- chase --- tmpV.copy(CHASE_OFFSET).applyQuaternion(planeQuat).add(planePos); const k = camSnap ? 1 : 1 - Math.exp(-CHASE_SMOOTH * dt); camera.position.lerp(tmpV, k); // look slightly ahead of the nose so the horizon leads the frame tmpV2.set(1, 0, 0).applyQuaternion(planeQuat).multiplyScalar(6).add(planePos); camera.lookAt(tmpV2); camSnap = false; } // Drag-to-rotate for the orbit camera (canvas pointer events; HUD is // pointer-events:none and the overlay is removed after start). let orbitDragging = false; let dragX = 0; let dragY = 0; canvas.addEventListener('pointerdown', (e) => { if (cameraMode !== 2) return; orbitDragging = true; dragX = e.clientX; dragY = e.clientY; try { canvas.setPointerCapture(e.pointerId); } catch (err) { /* no-op */ } }); canvas.addEventListener('pointermove', (e) => { if (!orbitDragging) return; orbitAngle += (e.clientX - dragX) * 0.006; orbitHeight = Math.max(2, Math.min(60, orbitHeight + (e.clientY - dragY) * 0.03)); dragX = e.clientX; dragY = e.clientY; }); function endDrag() { orbitDragging = false; } canvas.addEventListener('pointerup', endDrag); canvas.addEventListener('pointercancel', endDrag); // --------------------------------------------------------------------------- // Actions from controls.js // --------------------------------------------------------------------------- controls.onAction((action) => { switch (action) { case 'reset': resetFlight(); break; case 'pause': paused = !paused; break; case 'camera': cameraMode = (cameraMode + 1) % CAMERA_MODES.length; camSnap = true; break; case 'debug': debugOn = !debugOn; break; case 'sound': toggleSound(); break; // 'flaps' / 'gear' / 'brakes' need no side effect here: physics reads // the toggles straight from the inputs object each step. default: break; } }); // --------------------------------------------------------------------------- // Start overlay // --------------------------------------------------------------------------- function start() { if (started) return; started = true; // A pre-start S/Enter keypress can toggle the mute flag before the graph // exists (Enter on the focused START button fires both the sound action and // this click). Always begin the flight audible — the first deliberate // S/Enter press after start is then the user's real choice. muted = false; audio.setMuted(false); audio.start(); // user gesture — must be inside the click overlay.classList.add('hidden'); try { canvas.focus(); } catch (err) { /* no-op */ } } overlay.addEventListener('click', start); // --------------------------------------------------------------------------- // Resize // --------------------------------------------------------------------------- function onResize() { const w = window.innerWidth; const h = window.innerHeight; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); } window.addEventListener('resize', onResize); // --------------------------------------------------------------------------- // Fixed-timestep game loop // --------------------------------------------------------------------------- let acc = 0; let lastT = performance.now(); function frame(now) { requestAnimationFrame(frame); const realDt = Math.min(MAX_FRAME_DT, Math.max(0.0001, (now - lastT) / 1000)); lastT = now; fps = fps * 0.95 + (1 / realDt) * 0.05; const inputs = controls.getInputs(); if (!paused) { acc += realDt; let n = 0; while (acc >= FIXED_DT && n < MAX_STEPS_PER_FRAME) { step(aircraft, inputs, FIXED_DT); acc -= FIXED_DT; n++; } if (n === MAX_STEPS_PER_FRAME) acc = 0; // drop backlog (dt clamp makes rare) syncModel(); model.animate(aircraft, inputs, realDt); } else { syncModel(); // keep nav lights/parts synced } const data = getInstrumentData(aircraft); // -- crash → red banner + auto reset -------------------------------------- if (data.crashed && crashTimer <= 0) { instruments.message('CRASH', 'crash'); crashTimer = CRASH_RESET_DELAY; } if (crashTimer > 0) { crashTimer -= realDt; if (crashTimer <= 0) resetFlight(); } // -- landed → banner + touchdown thump (one-shot edge) --------------------- if (data.landed && !wasLanded) { instruments.message('LANDED', 'info'); audio.touchdown(); landedMsgTimer = LANDED_MSG_DURATION; } wasLanded = data.landed; if (landedMsgTimer > 0) { landedMsgTimer -= realDt; if (landedMsgTimer <= 0) instruments.clearMessage(); } // -- cameras --------------------------------------------------------------- updateCamera(realDt, data); env.update(camera.position, realDt); // -- audio feed (safe no-ops before audio.start()) ------------------------- // After a crash the engine is dead: silence it instead of looping the // pre-crash rpm drone (physics freezes the engine params when crashed). const engineLive = !aircraft.crashed; audio.setEngine(engineLive ? aircraft.rpm : 0, engineLive ? aircraft.throttle : 0); audio.setWind(data.airspeedMps); audio.setStall(data.stall); // -- HUD -------------------------------------------------------------------- instruments.update(data); showDebugLines(data); renderer.render(scene, camera); } requestAnimationFrame(frame);