3D flight simulator: 6-DOF physics, three.js r160, browser HUD

- Detailed flight dynamics: aero lift/drag/side-force with smooth stall,
  control-surface moments, stability derivatives, engine spool with prop
  torque and gyro precession, inertial coupling, ISA atmosphere, ground
  effect, 3-point gear handling, landing vs crash classification.
- Pure ESM, zero build step; three.js vendored locally. Deterministic
  simulation; physics core is Node-testable (14 node:test cases).
- Procedural 8 km world with runway, water, sky dome, cloud wrap; DOM
  instrument panel (attitude indicator, tapes, VSI, lamps, stall warning).
- Controls: WASD/arrows, Q/E rudder, Shift/Ctrl throttle, F flaps, G gear,
  B brakes, C camera (chase/cockpit/orbit), R reset, P pause, D debug.
This commit is contained in:
Quintine committed 2026-08-24 17:58:58 +10:00
commit 8c19cdaae6
18 files changed
+4499

No files matched your search

+264
View File
@@ -0,0 +1,264 @@
# Flight Sim — Build Contract v1
A 3D flight simulator with detailed 6-DOF physics, rendered with Three.js.
Zero build step: pure ES modules loaded directly in the browser, all local
files (three.js is vendored at `vendor/three.module.min.js`, r160).
## Ground rules (EVERY agent must obey)
1. **`SPEC.md` is ground truth for all interfaces.** Read it fully before coding.
Read `src/config.js` and `src/world.js` before coding anything that uses them.
2. Pure modules (Node-safe, no DOM, no three.js import): `src/config.js`,
`src/world.js`, `src/physics.js`. These must be importable from Node to run
unit tests. **Never** reference `window`, `document`, or `THREE` there.
3. SI units everywhere internally (m, kg, s, rad, N, Pa). Display conversions
(`kts`, `ft`, `fpm`) live only in instruments.js/main.js.
4. **Deterministic simulation.** No `Math.random()` anywhere in physics. Any
"turbulence" must be a deterministic function of state (e.g. sine of
position/time). Randomness allowed only in cosmetics (cloud placement in
terrain.js is OK because it's static).
5. **File ownership is exclusive.** Touch only the files in your scope. The
integrator may make tiny wiring fixes in any file, and must report them.
6. After writing any `.js`/`.mjs` file, run `node --check <file>` and fix
everything it flags. (The integrator must add a `package.json` with
`"type": "module"` so Node treats `.js` files as ESM for `--check` and tests.)
7. No build tools, no npm installs, no TypeScript, no external CDN imports at
runtime. Three.js comes only from `../vendor/three.module.min.js` (relative
from `src/`).
8. No console.log spam in hot paths (one-shot messages OK but keep minimal).
9. Do not use git. Do not create files outside your scope unless explicitly
allowed.
## Coordinate system (CRITICAL, shared by everything)
- World: right-handed, **+Y up**, **+X east**, **+Z south** (north = −Z, three.js
default). Heading 0° = facing north (−Z); heading increases clockwise viewed
from above (east = 90°).
- Aircraft **body frame**: **+X forward** (nose), **+Y right** (starboard),
**+Z down**. Quaternions are `[w, x, y, z]`.
- Positive conventions (right-hand rule, all consistent):
- `elevator > 0` → nose up (pitch up), `δe` positive = trailing edge down.
- `aileron > 0` → roll right (right wing down, bank right).
- `rudder > 0` → yaw right (nose to starboard).
- Body rates: `p` roll right, `q` pitch up, `r` yaw right (about body +X/+Y/+Z).
- Vertical speed > 0 = climbing.
- The physics `aircraft.quat` orients the vehicle. At spawn (heading north,
wings level) the correct quaternion is `[0.5, 0.5, 0.5, -0.5]` (120° about
(1,1,-1)/√3 — verified against three.js r160; the earlier literal
`[√½,0,√½,0]` rolls the airframe 90° left). Three.js Object3D
`.quaternion`/`.position` can be assigned directly from physics state (same
handedness/axis convention).
## File ownership
| File | Owner |
|---|---|
| `SPEC.md` | core (do not edit) |
| `src/config.js` | core (pre-written, do not edit) |
| `src/world.js` | core (pre-written, do not edit) |
| `src/physics.js`, `tests/physics.test.mjs` | physics agent |
| `src/controls.js` | controls agent |
| `src/aircraft-model.js` | model agent |
| `src/terrain.js` | environment agent |
| `src/instruments.js` | instruments agent |
| `src/audio.js` | audio agent |
| `index.html`, `css/style.css`, `src/main.js`, `package.json`, `README.md` | integrator |
| fixes | QA-1: physics/config/world/tests · QA-2: model/terrain/instruments/audio/main/css/index · gate: any (small only) |
## Shared interfaces
### Inputs object (controls → physics/model/instruments)
```js
{
elevator: number, // -1 (nose down) .. +1 (nose up), smoothed
aileron: number, // -1 (bank left) .. +1 (bank right), smoothed
rudder: number, // -1 (yaw left) .. +1 (yaw right), smoothed
throttle: number, // 0 .. 1, smoothed (slew ~0.4/s)
flaps: number, // 0 | 0.25 | 0.5 | 1
gear: boolean,
brakes: boolean,
}
```
### `src/physics.js` exports
```js
createAircraft() -> aircraft // fresh aircraft at spawn
resetAircraft(aircraft) -> void // back to spawn
step(aircraft, inputs, dt) -> void // dt <= 1/120 (caller guarantees); internally substeps to <= 1/240
getInstrumentData(aircraft) -> object // flat, all fields below
getAeroSummary(aircraft) -> object // { alpha, beta, q, liftN, dragN, sideN, cl, cd, stall, vtas }
```
`getInstrumentData` must return **all** of:
```
airspeedMps, airspeedKts, altM, altFt, vsMps, vsFpm,
headingDeg, pitchDeg, bankDeg, rollRateDps, pitchRateDps, yawRateDps,
alphaDeg, betaDeg, gLoad, stall(bool), engineOn(bool), rpm(0..1),
throttle, flapsPos(0..1), gearDown(bool), brakes(bool), fuelKg,
groundContact, landed, crashed, onGround, rolling, airTimeS
```
Public aircraft fields (readable; do not hand-edit): `pos:[x,y,z]`,
`quat:[w,x,y,z]`, `vel:[x,y,z]` (body), `angVel:[p,q,r]` (body), `throttle`,
`rpm`, `flaps`, `gearDown`, `brakes`, `fuelKg`, `engineOn`, `crashed`,
`landed`, `groundContact`, `groundTime`, `airTime`, `impactSpeed`,
`spawnPos`, `spawnQuat`.
### `src/controls.js` exports
```js
attachInput(canvas, opts?) -> {
getInputs() -> Inputs, // current smoothed inputs (fresh object ok)
reset(), // zero all inputs
dispose(),
onAction(cb) // cb(action) for: 'reset','camera','pause','flaps','gear','brakes','sound','debug'
}
KEY_BINDINGS -> [{key, label}] // for the help panel on the start screen
```
Keymap (make `KEY_BINDINGS` match reality): WASD/arrows elevator+aileron,
Q/E rudder, Shift/Ctrl throttle up/down, F flaps (cycle 0→0.25→0.5→1→0),
G gear toggle, B brakes, C camera, R reset, P pause, D debug overlay,
S or Enter sound toggle. Ignore key repeat for toggle keys (track `pressed`
state). `getInputs()` returns stick values smoothly slewed toward targets
(max slew ~1.6/s for sticks, 0.4/s throttle).
### `src/aircraft-model.js` exports
```js
buildAircraftModel() -> { group, animate(state, inputs, dt), cockpitOffset() -> [x,y,z] }
```
- `group` is a THREE.Group whose **forward is +X** (nose at +X) so physics
quaternion/position assign directly: `group.position.set(...pos);
group.quaternion.set(...quat)`.
- Primitive-built (~1500 tris budget) single-engine prop: fuselage (capsule/
tapered cylinder), main wing (slightly tapered, span from config `b`), tail
plane + fin, prop (3 blades + spinner), wheels+gear struts, flaps/ailerons/
elevator/rudder as **separate child meshes** driven by `animate`.
- `animate(state, inputs, dt)`: elevator surface ∝ inputs.elevator (visual
trailing-edge-down for positive), ailerons differentially, rudder,
flaps rotate down ∝ inputs.flaps, gear retract when !inputs.gear (translate/
rotate into fuselage over ~1s, lerp by dt), prop spins ∝ state.rpm.
- Blend deflections with dt-based lerp for smoothness. Must be cheap per frame.
- `cockpitOffset()` ≈ pilot eye in body frame (e.g. `[0.9, 1.1, 0.15]`), used
by cockpit camera. Provide `getDimensions()` → { length, height } if easy.
### `src/terrain.js` exports
```js
buildEnvironment(scene) -> { update(camPos, dt), groundHeight(x,z), dispose() }
```
- Ground: ~8×8 km plane, 192×192 segments, elevation from `world.getGroundHeight`
(single source of truth!), vertex colors by height: sand (<2m), grass (2–45),
rock (45–120), snow (>120). MeshStandardMaterial vertexColors.
- Runway: from `WORLD.runway` (flattened to y=0 by world.js) — lap it with a
concrete strip + centerline/numbers via CanvasTexture, slightly above (y=+0.02)
to avoid z-fighting.
- Water plane at `WORLD.waterLevel` (semi-transparent, ~0.55 opacity).
- Sky: scene.background gradient (big backside sphere with shader or vertex
colors), sun DirectionalLight + shadows (only where affordable; if shadow map
cost is too high, disable shadows entirely — visual quality first), HemisphereLight.
- Clouds: ~30 billboard sprites (soft radial-gradient canvas) in a 5 km box,
**wrap around camera** each frame (fog + clouds re-centered, cheap).
- Fog: scene.fog ≈ 6000 m. Performance budget: < 150 draw calls.
- `update(camPos, dt)`: recenter clouds; nothing else needed.
### `src/instruments.js` exports
```js
createInstruments(rootEl) -> { update(data), message(text, kind), clearMessage(), debug(lines), reset() }
```
- Pure DOM overlay (no canvas): attitude indicator (artificial horizon that
pitches/rolls via transform), altimeter + vertical speed, airspeed tape,
heading tape, engine rpm bar + throttle, flaps/gear/brakes lamps,
stall warning (flashing, only when `data.stall`), message banner
(`message(text, 'info'|'warn'|'crash')`), debug panel (`debug([line,...])`,
hidden until toggled).
- `update(data)` called every frame; keep writes cheap: transform updates
every frame, text updates only when values change by a threshold.
- All DOM created inside rootEl; no styles inline except dynamic transforms —
classes come from `css/style.css`.
- Pitot/static realism is cosmetic: altimeter in ft, airspeed in kts, VS in fpm.
### `src/audio.js` exports
```js
createAudio() -> { start(), setEngine(rpm01, throttle), setWind(airspeedMps),
setStall(bool), touchdown(), setMuted(bool), dispose() }
```
- WebAudio, no files: engine = sawtooth + sub osc + noise → lowpass keyed to
rpm, gain ∝ throttle; wind = bandpass noise ∝ airspeed; stall = rhythmic
buffet pulse while true; touchdown() = one low thump.
- Must be safe before user gesture (context suspended until `start()` called
from a click handler). `setMuted(true)` → master gain 0.
### physics model spec (implement in physics.js)
Forces in **body frame** (formulas from standard stability-axis theory):
- Airspeed `V = |vel|`; `ρ(h)` from `atmoDensity` in config; `q = 0.5 ρ V²`.
- α = atan2(vel.z, vel.x) (vel.z is body down; positive α = nose-up into the
flow, positive lift — do NOT flip the sign in a +Z-down body frame)
- Lift: `CL = CL0 + CL_alpha·α + CL_flaps(flaps) + ΔCL_groundEffect`
with smooth stall: if |α| > α_crit, blend CL toward flat-plate regime so that
CL peaks near α_crit (CLmax) then decays (use a smooth exponential or
sine-blend; must be continuous+differentiable enough for stable integration).
`L = q·S·CL`, directed **perpendicular to velocity** in the body x-z plane
(apply via rotating the force vector: up = −z_body rotated by α).
- Drag: `CD = CD0 + k·CL² + ΔCD_flaps + ΔCD_gear(false→true) + flatPlate(α)`,
`D = −q·S·CD · (vel/V)`.
- Side force: `CY = CYβ·β + CYδr·δr`, `Y = q·S·CY` along +Y body.
- Moments (radians):
- pitch: `Cm = Cm0 + CmAlpha·α + Cmδe·δe + Cmp·(q·c̄/(2V))`, `M_pitch = q·S·c̄·Cm`
- roll: `Cl = Clβ·β + Clδa·δa + Clp·(p·b/(2V))`, `M_roll = q·S·b·Cl`
- yaw: `Cn = Cnβ·β + Cnδr·δr + Cnδa·δa + Cnr·(r·b/(2V))`, `M_yaw = q·S·b·Cn`
- Engine: spool `rpm → throttle` with `τ=engTau`; thrust `T = Tmax·rpm·(ρ/ρ0)^0.7`
along +X; prop torque `Qz = −propTorque·Tmax·rpm` about +X (unwinds to
left... pick sign that makes torque oppose prop spin; document choice);
gyro moment `M_gyro = −ω × H`, `H = Iprop·ω_prop·[1,0,0]` (ω_prop rad/s from rpm).
- Inertial coupling: `a_ang = invI · (M_total − ω × (I·ω))` with full I matrix
(Ixx ≈ 820, Iyy ≈ 3100, Izz ≈ 3300, Ixz ≈ 60 — use 3×3 symmetric).
- Gravity: transform g·[0,−1,0] into body frame, add to force.
- Integration: semi-implicit Euler, substep dt = 1/240 (internal; caller passes
≤ 1/120). Position integrate with world velocity (rotate body vel by quat).
**Normalize quaternion every substep.** Fuel burn ∝ rpm·throttle·fuelBurn.
- Ground: if `pos.y ≤ getGroundHeight(x,z) + wheelH`:
- compute sink speed & attitude; classify **crash** (gear down, landing
allowed ≤ `maxTouchVz`=3 m/s; gear up ≤ 1.2 m/s; |roll|>75° or inverted →
crash; nose-down pitch < −25° → crash) else **landed/rolling**.
- soft ground constraint: clamp y to ground, kill normal velocity, apply
rolling friction (∝ μ·m·g along velocity), braking multiplies μ ×3,
rudder steers on ground (add yaw moment ∝ rudder·speed), taxi thrust.
- `groundContact=true` while touching; `rolling = groundContact && v_horiz > 0.5`.
- Crash: set `crashed=true`, freeze inputs effect, keep gentle settle for ~2 s
so the scene looks right; `impactSpeed` recorded for the message.
### Required unit tests (`tests/physics.test.mjs`, node:test)
`node --test tests/physics.test.mjs` must pass, deterministic:
1. **Trim/cruise**: at 1000 m, trim elevator per config trim, throttle 0.7,
after 20 s simulated airspeed within ±5% of design cruise
(≈ `sqrt(2·W/(ρ·S·CL_trim))`; compute expected from config, assert 120–180 kts).
2. **Stall**: full up elevator, 30% throttle, from trimmed cruise → α exceeds
α_crit within 25 s and `stall` flag true (lift collapse / sink).
3. **Inverted sign**: at α = −10°, CL is negative (lift points down).
4. **Ground stop**: spawn on runway, wheels down, brakes on, from 30 m/s:
comes to rest in < 400 m and never sinks below ground (y never < ground−0.1).
5. **Heavy impact**: vel.y = −8 m/s at ground with gear up → crashed true.
6. **Quaternion unit**: after 2000 substeps |quat| within 1e-6 of 1.
7. **Lateral symmetry**: roll right for 3 s vs roll left for 3 s → |bank|
within 2% after equal time.
Tests may spawn helpers; use `createAircraft()` then set state directly.
## Integrator checklist (main.js + index.html + css + README)
- Fixed-timestep loop: accumulate real dt (clamp 0.05), run `step` at 1/120
increments, max 8 per frame, then render. Use THREE.Clock.
- Camera modes (C cycles): chase (smooth follow ~[−8, 4, 0] behind nose,
lerp), cockpit (at `cockpitOffset`, slight g-force sway), orbit (rotate
around ship; drag to rotate is a nice-to-have).
- Renderer: antialias, pixelRatio min(devicePixelRatio, 2), shadowMap off
unless cheap; `renderer.setAnimationLoop` not needed — own rAF loop.
- Start overlay (title, controls from `KEY_BINDINGS`, "click to start" →
audio.start(), input focus). P pause (freeze physics, keep HUD), R reset,
D debug panel (rpm/fps/fuel/lift-drag numbers).
- Crash → red message, auto reset after 2.5 s (or R).
- Spawn: `WORLD.spawn`, gear down, flaps 0.25, throttle 0, brakes on,
engine idle on.
- Styling: dark cockpit frame, HUD fonts monospace, glare-free. One CSS file.
- `index.html`: `<canvas id="sim"></canvas>`, `<div id="hud"></div>`,
`<script type="module" src="./src/main.js"></script>`.
MUST load three via `import * as THREE from '../vendor/three.module.min.js'`
— no external network at runtime.
- README.md: what it is, controls table, how to serve (`python3 -m http.server`
or `npx serve`), architecture bullets, physics model summary.