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:
commit
8c19cdaae6
18 files changed
+4499
No files matched your search
@@ -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.
|
||||
Reference in new issue
Block a user