Files
Quintine 8c19cdaae6 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.
2026-08-24 17:58:58 +10:00

264 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.