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

+4
View File
@@ -0,0 +1,4 @@
node_modules/
.smoke*/
*.log
.DS_Store
+108
View File
@@ -0,0 +1,108 @@
# Flight Sim
A browser 3D flight simulator: a detailed 6-DOF propeller aircraft flown over a
procedural 8 km world, with an instrument HUD, synthesized engine/wind audio and
three camera views (chase / cockpit / orbit). Pure ES modules, zero build step,
no assets, no runtime network — Three.js r160 is vendored locally.
Spawn: on the south end of the runway, engine idling, flaps 25 %, gear down,
brakes on. Throttle up with **Shift**, release brakes with **B**, roll forward
with the stick, and rotate at ~120–140 kts.
## Run it
ES modules must be served over HTTP (opening `index.html` directly on `file://`
is blocked by the browser). From the project root:
```bash
python3 -m http.server 8080
# or
npx serve .
```
Then open <http://localhost:8080>. Fullscreen canvas; **CLICK TO START**
enables audio (the browser requires a user gesture for WebAudio).
## Controls
> Table below mirrors `KEY_BINDINGS` from `src/controls.js` (single source).
| Key | Command |
|----------------|--------------------------------|
| `W` / `S` | Elevator — pitch up / down |
| `A` / `D` | Aileron — bank left / right |
| `↑` / `↓` | Elevator (arrow keys) |
| `←` / `→` | Aileron (arrow keys) |
| `Q` / `E` | Rudder — yaw left / right |
| `Shift`/`Ctrl` | Throttle up / down |
| `F` | Flaps 0 → 25 % → 50 % → 100 % |
| `G` | Gear up / down |
| `B` | Brakes on / off |
| `C` | Camera view (chase/cockpit/orbit) |
| `R` | Reset flight |
| `P` | Pause |
| `D` | Debug overlay |
| `S` / `Enter` | Sound on / off |
Sticks slew smoothly (~1.6 full-range/s), throttle slews slowly (~0.4/s) so
engine response feels realistic; toggle keys ignore auto-repeat.
## Architecture
```
index.html static shell: canvas, #hud, start overlay
css/style.css HUD/instrument glass styles + start overlay (integrator)
src/main.js game loop, fixed-timestep physics driver, cameras,
crash/reset/pause/debug, audio + HUD feed (integrator)
src/config.js SI constants — single source of truth (airframe, atmo)
src/world.js deterministic height field + runway (single ground source)
src/physics.js 6-DOF rigid-body dynamics, ground model, crash logic
src/controls.js keyboard → smoothed inputs + action events
src/aircraft-model.js Three.js visual model (surfaces/prop/gear animate)
src/terrain.js sky, sun, elevation-mapped ground, runway, water, clouds
src/instruments.js DOM instrument overlay (attitude, tapes, lamps, stall)
src/audio.js synthesized engine/wind/stall/touchdown (WebAudio)
vendor/three.module.min.js Three.js r160 (vendored)
```
### Loop
`main.js` runs a fixed-timestep sim: real frame dt is clamped to 0.05 s,
accumulated, and consumed in 1/120 s physics steps (max 8 per frame), so
simulation behaviour is frame-rate independent and deterministic. No
`Math.random()` anywhere in physics; cosmetic randomness is seeded.
### Physics summary
- Body frame **+X forward, +Y right, +Z down**; quaternions `[w,x,y,z]`;
heading 0° = north, +Y world up.
- Forces: stability-axis lift fitted with a smooth stall blend toward the
flat-plate regime, drag `CD0 + k·CL² + flaps/gear + flat-plate`, side force
from β and rudder; moments from static stability, control effectiveness and
rate damping (`Cmq`, `Clp`, `Cnr`).
- Engine: first-order spool to throttle, thrust scaled by `(ρ/ρ0)^0.7`,
prop torque and gyroscopic moment about the thrust axis.
- Inertial coupling with the full symmetric inertia tensor; semi-implicit
Euler integration at 1/240 s substeps, quaternion renormalised per substep.
- Ground: wheel-height contact, crash vs. landing classification by sink rate,
gear state and attitude; rolling friction, ×3 braking, ground steering,
taxi thrust. Fuel burn ∝ rpm·throttle.
- Atmosphere: ISA density lapse with altitude.
### Camera modes (`C`)
1. **Chase** — smooth follow 8 m aft / 2.2 m above the c.g., looking just
ahead of the nose.
2. **Cockpit** — pilot eye at `aircraft-model.cockpitOffset()`, hard-locked to
the aircraft attitude with a subtle g-load sway.
3. **Orbit** — slow auto-spin around the ship; drag with the mouse to steer
the view angle and height.
### Notes
- Shadows are disabled by default (cheap path; flip
`buildEnvironment(scene, { shadows: true })` and enable
`renderer.shadowMap` in `main.js` to test the 4096 px variant).
- Altimeter/airspeed/VS are pitot-static cosmetics: ft, kts, fpm.
- One CSS file owns all HUD look; `instruments.js` only sets per-frame
transforms and class toggles.
+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.
+600
View File
@@ -0,0 +1,600 @@
/* ============================================================================
flight-sim — HUD + UI stylesheet (integrator-owned).
----------------------------------------------------------------------------
CONTRACT for src/instruments.js (see README / INTEGRATOR_NOTES):
createInstruments(rootEl) creates ALL overlay DOM inside #hud using exactly
the class names below. No inline styles except dynamic transforms set per
frame (pitch/roll of the horizon, tape translations, bar widths, lamp glow).
Conventions:
.hud-debug — hidden by default (display:none); add .visible to show it.
instruments.debug(lines|null): null hides, array shows.
.hud-message — hidden by default; message(text, kind) sets text + kind
class (.info | .warn | .crash) + visible; clearMessage()
removes visible. autoHideMessageMs is applied by main.js.
.lamp — off-state lamp; add .on to light it.
.hud-stall — warning card; add .on (flashes) while data.stall is true.
========================================================================== */
:root {
color-scheme: dark;
--hud-bg: rgba(8, 14, 22, 0.55);
--hud-edge: rgba(120, 190, 255, 0.18);
--hud-text: #cfe6ff;
--hud-dim: #7fa6cf;
--hud-amber: #ffb347;
--hud-green: #7dff9e;
--hud-red: #ff5a5a;
--hud-cyan: #86f4ff;
--font-mono: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Courier New', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #05070c;
}
body {
font-family: var(--font-mono);
color: var(--hud-text);
-webkit-user-select: none;
user-select: none;
}
/* ---- 3D canvas ----------------------------------------------------------- */
#sim {
position: fixed;
inset: 0;
display: block;
width: 100vw;
height: 100vh;
cursor: crosshair;
outline: none;
touch-action: none;
}
/* ---- HUD root -------------------------------------------------------------
instruments.js mounts every panel into #hud. pointer-events:none so clicks
reach the canvas (orbit-camera drag). */
#hud {
position: fixed;
inset: 0;
z-index: 10;
pointer-events: none;
font-family: var(--font-mono);
font-size: 13px;
color: var(--hud-text);
letter-spacing: 0.5px;
}
/* Generic glassy panel look used by every instrument block. */
.hud-attitude, .hud-airspeed, .hud-altitude, .hud-heading, .hud-engine,
.hud-lamps, .hud-debug, .hud-message, .hud-stall {
position: absolute;
background: var(--hud-bg);
border: 1px solid var(--hud-edge);
border-radius: 8px;
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
text-shadow: 0 0 6px rgba(90, 160, 255, 0.45);
}
/* Big numeric readouts (airspeed / altitude current value). */
.hud-num {
font-size: 26px;
font-weight: 700;
color: #fff;
text-align: center;
line-height: 1.05;
}
.hud-num small {
display: block;
font-size: 10px;
font-weight: 400;
color: var(--hud-dim);
letter-spacing: 1px;
}
/* Generic tape (moving scale strip). The module translates it per frame. */
.tape {
position: relative;
overflow: hidden;
background: rgba(6, 10, 16, 0.55);
border: 1px solid rgba(120, 190, 255, 0.14);
border-radius: 4px;
}
.tape .tick {
position: absolute;
color: var(--hud-dim);
font-size: 10px;
white-space: nowrap;
}
/* ---- attitude indicator (left column, above the airspeed tape) ---------- */
/* The AI and airspeed panel share one flex column, vertically centered on
the left edge, so the middle of the screen stays clear for flying. */
.hud-left-col {
position: absolute;
left: 18px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.hud-attitude {
position: relative;
width: 210px;
height: 210px;
border-radius: 50%;
overflow: hidden;
background: rgba(6, 10, 16, 0.45);
}
.ai-horizon {
position: absolute;
left: 50%;
top: 50%;
width: 340px;
height: 340px;
margin: -170px 0 0 -170px;
/* instrument sets: translateY(pitchPx) rotate(-bankDeg) */
will-change: transform;
}
.ai-sky {
position: absolute;
inset: 0 0 50% 0;
background: linear-gradient(180deg, #2b6fb3, #7fb2d8);
}
.ai-ground {
position: absolute;
inset: 50% 0 0 0;
background: linear-gradient(180deg, #8a6a3c, #b08a52);
}
.ai-line {
position: absolute;
left: 8px;
right: 8px;
height: 2px;
background: rgba(255, 255, 255, 0.75);
}
.ai-fixed {
position: absolute;
left: 50%;
top: 50%;
width: 120px;
height: 90px;
transform: translate(-50%, -50%);
pointer-events: none;
}
.ai-symbol {
position: absolute;
left: 50%;
top: 50%;
width: 86px;
height: 14px;
transform: translate(-50%, -50%);
border: 2px solid var(--hud-amber);
border-radius: 3px;
}
.ai-symbol::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 10px;
height: 10px;
transform: translate(-50%, -50%);
background: var(--hud-amber);
border-radius: 50%;
}
/* ---- airspeed tape (left) ------------------------------------------------ */
.hud-airspeed {
position: relative;
width: 92px;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.hud-airspeed .tape {
width: 64px;
height: 220px;
}
.hud-airspeed .tick {
left: 0;
right: 0;
text-align: center;
}
/* ---- altitude + vertical speed (right) ----------------------------------- */
.hud-altitude {
right: 18px;
top: 50%;
transform: translateY(-50%);
width: 116px;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.hud-altitude .tape {
width: 84px;
height: 220px;
}
.hud-altitude .tick {
left: 0;
right: 0;
text-align: center;
}
.hud-vsi {
position: absolute;
right: -34px;
top: 8px;
bottom: 8px;
width: 26px;
border: 1px solid rgba(120, 190, 255, 0.25);
border-radius: 4px;
background: rgba(6, 10, 16, 0.5);
overflow: hidden;
}
.hud-vsi-fill {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--hud-cyan);
/* module sets top/bottom of a zero-bar span; simplest: a centered bar
that the module scales: transform: translateY via per-frame inline */
box-shadow: 0 0 8px var(--hud-cyan);
}
/* ---- heading tape (bottom center) ---------------------------------------- */
.hud-heading {
bottom: 64px;
left: 50%;
transform: translateX(-50%);
width: 330px;
padding: 8px 10px;
}
.hud-heading .tape {
width: 100%;
height: 34px;
}
.hud-heading .tick {
bottom: 2px;
text-align: center;
}
.hud-heading .tick.major {
color: #fff;
font-weight: 700;
}
.hud-heading .bug {
position: absolute;
left: 50%;
top: -4px;
width: 0;
height: 0;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 10px solid var(--hud-amber);
transform: translateX(-50%);
}
/* ---- engine panel (bottom left) ------------------------------------------ */
.hud-engine {
left: 18px;
bottom: 18px;
width: 190px;
padding: 10px 12px;
}
.hud-engine .bar-row {
display: flex;
align-items: center;
gap: 8px;
margin: 5px 0;
font-size: 11px;
color: var(--hud-dim);
}
.hud-engine .bar {
position: relative;
flex: 1;
height: 10px;
background: rgba(6, 10, 16, 0.7);
border: 1px solid rgba(120, 190, 255, 0.18);
border-radius: 3px;
overflow: hidden;
}
.hud-engine .bar-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0%;
background: linear-gradient(90deg, var(--hud-green), var(--hud-amber));
}
.hud-engine .bar-fill.throttle {
background: linear-gradient(90deg, var(--hud-cyan), #4aa8ff);
}
/* ---- lamps (bottom right) ------------------------------------------------- */
.hud-lamps {
right: 18px;
bottom: 18px;
display: flex;
gap: 8px;
padding: 8px 10px;
}
.lamp {
padding: 4px 9px;
border-radius: 4px;
border: 1px solid rgba(120, 190, 255, 0.22);
background: rgba(6, 10, 16, 0.6);
color: var(--hud-dim);
font-size: 11px;
letter-spacing: 1px;
text-transform: uppercase;
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.lamp.on {
color: #06121c;
background: var(--hud-green);
box-shadow: 0 0 10px rgba(125, 255, 158, 0.8);
}
.lamp--gear.on {
background: var(--hud-amber);
color: #241500;
box-shadow: 0 0 10px rgba(255, 179, 71, 0.8);
}
.lamp--brakes.on {
background: var(--hud-red);
color: #2a0606;
box-shadow: 0 0 10px rgba(255, 90, 90, 0.8);
}
.lamp--stall.on {
background: var(--hud-red);
color: #2a0606;
animation: stall-flash 0.45s steps(2, jump-none) infinite;
}
/* ---- stall warning (center, flashing) ------------------------------------- */
.hud-stall {
display: none;
left: 50%;
top: 26%;
transform: translateX(-50%);
padding: 10px 26px;
font-size: 22px;
font-weight: 700;
letter-spacing: 4px;
color: var(--hud-red);
border-color: var(--hud-red);
text-shadow: 0 0 12px rgba(255, 90, 90, 0.9);
}
.hud-stall.on {
display: block;
animation: stall-flash 0.45s steps(2, jump-none) infinite;
}
/* ---- message banner (top center) ----------------------------------------- */
.hud-message {
display: none;
top: 26px;
left: 50%;
transform: translateX(-50%);
padding: 12px 30px;
font-size: 24px;
font-weight: 700;
letter-spacing: 3px;
border-width: 2px;
background: rgba(8, 14, 22, 0.72);
animation: msg-in 0.25s ease-out;
}
.hud-message.visible { display: block; }
.hud-message.info {
color: var(--hud-green);
border-color: rgba(125, 255, 158, 0.6);
text-shadow: 0 0 14px rgba(125, 255, 158, 0.7);
}
.hud-message.warn {
color: var(--hud-amber);
border-color: rgba(255, 179, 71, 0.6);
text-shadow: 0 0 14px rgba(255, 179, 71, 0.7);
}
.hud-message.crash {
color: #fff;
border-color: var(--hud-red);
background: rgba(120, 12, 12, 0.62);
text-shadow: 0 0 16px rgba(255, 90, 90, 0.95);
animation: msg-in 0.25s ease-out, crash-pulse 0.6s ease-in-out 2;
}
/* ---- debug panel (top left) ---------------------------------------------- */
.hud-debug {
display: none;
top: 18px;
left: 18px;
min-width: 210px;
padding: 10px 12px;
font-size: 11px;
line-height: 1.7;
color: var(--hud-cyan);
}
.hud-debug.visible { display: block; }
.hud-debug .line b {
color: #fff;
font-weight: 700;
}
.hud-debug .line.pair b::after {
content: ' ';
}
/* ---- start overlay --------------------------------------------------------- */
#start-overlay {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background:
radial-gradient(1200px 700px at 50% 30%, rgba(30, 60, 110, 0.35), transparent 70%),
rgba(3, 6, 12, 0.66);
}
#start-overlay.hidden { display: none; }
.start-card {
width: min(620px, 92vw);
max-height: 92vh;
overflow-y: auto;
padding: 34px 42px 30px;
text-align: center;
background: rgba(9, 15, 24, 0.85);
border: 1px solid rgba(140, 200, 255, 0.25);
border-radius: 14px;
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.start-title {
font-size: 34px;
font-weight: 800;
letter-spacing: 12px;
color: #eaf4ff;
text-shadow: 0 0 24px rgba(90, 170, 255, 0.6);
margin-bottom: 8px;
}
.start-sub {
font-size: 12px;
color: var(--hud-dim);
line-height: 1.6;
margin-bottom: 18px;
}
.start-controls {
width: 100%;
border-collapse: collapse;
margin: 0 auto 22px;
font-size: 12px;
}
.start-controls th {
color: var(--hud-cyan);
font-weight: 700;
text-align: left;
padding: 5px 12px;
border-bottom: 1px solid rgba(140, 200, 255, 0.3);
letter-spacing: 2px;
}
.start-controls td {
padding: 4px 12px;
text-align: left;
border-bottom: 1px solid rgba(140, 200, 255, 0.09);
}
.start-controls td:first-child {
color: #fff;
font-weight: 700;
white-space: nowrap;
width: 34%;
}
#start-btn {
display: inline-block;
padding: 14px 46px;
font-family: var(--font-mono);
font-size: 17px;
font-weight: 800;
letter-spacing: 4px;
color: #04101c;
background: linear-gradient(180deg, #9fe0ff, #4aa8ff);
border: none;
border-radius: 8px;
cursor: pointer;
box-shadow: 0 0 24px rgba(74, 168, 255, 0.55);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
#start-btn:hover {
transform: translateY(-2px);
box-shadow: 0 0 34px rgba(74, 168, 255, 0.85);
}
#start-btn:active {
transform: translateY(0);
}
/* ---- animations ------------------------------------------------------------ */
@keyframes stall-flash {
0%, 100% { opacity: 1; }
50% { opacity: 0.12; }
}
@keyframes msg-in {
from { transform: translate(-50%, -14px); opacity: 0; }
to { transform: translate(-50%, 0); opacity: 1; }
}
@keyframes crash-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 90, 90, 0); }
50% { box-shadow: 0 0 26px 4px rgba(255, 90, 90, 0.55); }
}
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flight Sim</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="./css/style.css">
</head>
<body>
<canvas id="sim" tabindex="0"></canvas>
<div id="hud"></div>
<div id="start-overlay" role="button" aria-label="Start flight simulator">
<div class="start-card">
<h1 class="start-title">FLIGHT&#8202;SIM</h1>
<p class="start-sub">A deterministic 6-DOF prop aircraft over a procedural world. No assets, no network — raw Three.js.</p>
<table class="start-controls">
<thead>
<tr><th>Key</th><th>Command</th></tr>
</thead>
<tbody>
<tr><td>W / S</td><td>Elevator — pitch up / down</td></tr>
<tr><td>A / D</td><td>Aileron — bank left / right</td></tr>
<tr><td>↑ / ↓</td><td>Elevator (arrow keys)</td></tr>
<tr><td>← / →</td><td>Aileron (arrow keys)</td></tr>
<tr><td>Q / E</td><td>Rudder — yaw left / right</td></tr>
<tr><td>Shift / Ctrl</td><td>Throttle up / down</td></tr>
<tr><td>F</td><td>Flaps 0 → 25% → 50% → 100%</td></tr>
<tr><td>G</td><td>Gear up / down</td></tr>
<tr><td>B</td><td>Brakes on / off</td></tr>
<tr><td>C</td><td>Camera view</td></tr>
<tr><td>R</td><td>Reset flight</td></tr>
<tr><td>P</td><td>Pause</td></tr>
<tr><td>D</td><td>Debug overlay</td></tr>
<tr><td>S / Enter</td><td>Sound on / off</td></tr>
</tbody>
</table>
<button type="button" id="start-btn">CLICK TO START</button>
</div>
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
{
"name": "flight-sim",
"version": "1.0.0",
"type": "module",
"private": true
}
+383
View File
@@ -0,0 +1,383 @@
// aircraft-model.js — Visual 3D model of the flight-sim aircraft.
// Built in the SPEC body frame: +X forward (nose), +Y right (starboard),
// +Z down. Primitive-built (~900 tris), deterministic, pure ESM.
//
// Exports: buildAircraftModel() -> {
// group, // THREE.Group, nose along +X
// animate(state, inputs, dt), // drive control surfaces/prop/gear
// cockpitOffset(), // [x, y, z] pilot eye in body frame
// getDimensions(), // { length, height, width } (m)
// }
import * as THREE from '../vendor/three.module.min.js';
import { cfg } from './config.js';
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
// ---------------------------------------------------------------------------
// Small geometry builders
// ---------------------------------------------------------------------------
// Full-span wing/stabilizer planform (in body X–Y plane, span along +Y),
// extruded ±thick/2 along Z. Taper starts at kinkFrac·span. The leading edge
// sits at x = 0 and the CHORD RUNS AFT (toward −X, the tail): trailing edge at
// −cRoot (root) / −cTip (tip). NOTE: body +X is the NOSE, so the chord must
// extend toward −X — a +X chord renders the wing backwards, parking the
// flaps/ailerons on the leading edge (the bug this file once shipped).
function planformGeometry(halfSpan, cRoot, cTip, kinkFrac, thick) {
const s = halfSpan;
const ky = kinkFrac * s;
const shp = new THREE.Shape();
shp.moveTo(0, -s);
shp.lineTo(0, s);
shp.lineTo(-cTip, s);
shp.lineTo(-cRoot, ky);
shp.lineTo(-cRoot, -ky);
shp.lineTo(-cTip, -s);
shp.closePath();
const geo = new THREE.ExtrudeGeometry(shp, { depth: thick, bevelEnabled: false });
geo.translate(0, 0, -thick / 2);
return geo;
}
// Lift both wingtips by `lift` m (dihedral). `lift` negative => tips up (−Z).
function liftTips(geo, halfSpan, lift) {
const p = geo.attributes.position;
const s = halfSpan;
for (let i = 0; i < p.count; i++) {
p.setZ(i, p.getZ(i) - lift * Math.abs(p.getY(i)) / s);
}
geo.computeVertexNormals();
return geo;
}
// Vertical fin/rudder: profile in the X–Z plane (sx = aft distance, sy =
// height, root at sy=0 going UP to negative sy), extruded ±thick/2 along +Y.
function verticalPrism(profile, thick) {
const shp = new THREE.Shape();
shp.moveTo(profile[0][0], profile[0][1]);
for (let i = 1; i < profile.length; i++) shp.lineTo(profile[i][0], profile[i][1]);
shp.closePath();
const geo = new THREE.ExtrudeGeometry(shp, { depth: thick, bevelEnabled: false });
geo.rotateX(Math.PI / 2); // shape y (height) -> world Z; extrusion -> world Y
geo.translate(0, thick / 2, 0);
geo.computeVertexNormals();
return geo;
}
// Thin hinged surface (box) with its hinged edge at local x = 0 and its free
// end extending FORWARD (+X) — the original convention (superseded by the
// AFT variant below for wing/stab control surfaces).
function hingedSurface(cord, span, thick) {
const geo = new THREE.BoxGeometry(cord, span, thick);
geo.translate(cord / 2, 0, 0);
return geo;
}
// Thin hinged surface whose free (trailing) end extends AFT (−X): hinge at
// the wing/stab trailing edge, panel sweeping back toward the tail — like a
// real flap or elevator. A positive rotation.y then deflects the free end
// DOWN (+Z) — see animate().
function hingedSurfaceAft(cord, span, thick) {
const geo = new THREE.BoxGeometry(cord, span, thick);
geo.translate(-cord / 2, 0, 0);
return geo;
}
// ---------------------------------------------------------------------------
// Model assembly
// ---------------------------------------------------------------------------
export function buildAircraftModel() {
const group = new THREE.Group();
// animated-state holder + part refs (filled as parts are built below)
const model = {
elevator: null, rudder: null,
flapS: null, flapP: null, ailS: null, ailP: null,
propGroup: null, gears: [],
s: { elev: 0, ail: 0, rud: 0, flap: 0, gearT: 0, propA: 0 },
};
// --- shared materials ---------------------------------------------------
const paint = new THREE.MeshStandardMaterial({ color: 0xe8edf2, roughness: 0.72, metalness: 0.05 });
const accent = new THREE.MeshStandardMaterial({ color: 0xb5302a, roughness: 0.68, metalness: 0.05 });
const cowl = new THREE.MeshStandardMaterial({ color: 0x39424b, roughness: 0.55, metalness: 0.35 });
const metal = new THREE.MeshStandardMaterial({ color: 0xb9c1c8, roughness: 0.30, metalness: 0.92 });
const bladeMat = new THREE.MeshStandardMaterial({ color: 0x1c2024, roughness: 0.35, metalness: 0.85 });
const rubber = new THREE.MeshStandardMaterial({ color: 0x14161a, roughness: 0.95, metalness: 0.0 });
const glass = new THREE.MeshStandardMaterial({
color: 0x0e1a26, roughness: 0.10, metalness: 0.9,
transparent: true, opacity: 0.5,
});
const lensMat = new THREE.MeshStandardMaterial({
color: 0x111111, emissive: 0xfff6d8, emissiveIntensity: 6, roughness: 0.4,
});
const add = (geo, mat, x, y, z, shadow) => {
const m = new THREE.Mesh(geo, mat);
m.position.set(x, y, z);
if (shadow) { m.castShadow = true; m.receiveShadow = true; }
group.add(m);
return m;
};
// --- fuselage: lathe profile (radius, axial), nose at +Y then → +X ------
{
const pts = [
new THREE.Vector2(0.16, -3.50),
new THREE.Vector2(0.30, -3.00),
new THREE.Vector2(0.46, -2.20),
new THREE.Vector2(0.56, -1.20),
new THREE.Vector2(0.60, 0.00),
new THREE.Vector2(0.62, 1.60),
new THREE.Vector2(0.56, 2.80),
new THREE.Vector2(0.44, 3.45),
new THREE.Vector2(0.18, 3.68),
];
const geo = new THREE.LatheGeometry(pts, 22);
geo.rotateZ(-Math.PI / 2); // lathe +Y -> +X (nose forward)
geo.computeVertexNormals();
add(geo, paint, 0, 0, 0, true);
// tail cone (apex aft, radius matches fuselage tail base)
const tc = new THREE.ConeGeometry(0.17, 0.55, 16);
tc.rotateZ(Math.PI / 2);
tc.computeVertexNormals();
add(tc, paint, -3.775, 0, 0, true);
// engine cowl band (slightly proud of fuselage — no z-fight)
const cowlGeo = new THREE.CylinderGeometry(0.615, 0.615, 0.55, 22);
cowlGeo.rotateZ(-Math.PI / 2);
cowlGeo.computeVertexNormals();
add(cowlGeo, cowl, 3.18, 0, 0);
}
// --- canopy (glass dome on top, z = -Z is up) ----------------------------
{
const geo = new THREE.SphereGeometry(0.42, 16, 8);
geo.scale(1.15, 0.62, 0.78);
geo.computeVertexNormals();
add(geo, glass, 0.86, 0, -0.30);
}
// --- main wing (span = cfg.b from config, low-wing at belly level) -------
// Leading edge at x = 0.65; chord runs AFT, so the root trailing edge lands
// at x = 0.65 − 1.95 = −1.30 (behind the CG, as on a real aircraft).
{
const s = cfg.b / 2;
const geo = planformGeometry(s, 1.95, 0.95, 0.35, 0.16);
liftTips(geo, s, 0.10);
add(geo, paint, 0.65, 0, 0.62, true);
// red wingtip caps (mid-chord at each wingtip)
const tip = new THREE.BoxGeometry(0.38, 0.055, 0.19);
add(tip, accent, 0.25, s - 0.03, 0.52);
add(tip, accent, 0.25, -(s - 0.03), 0.52);
}
// --- flaps (hinged at the wing TRAILING edge, sweeping AFT; both deflect
// down together). z = 0.74 hangs just below the wing's lower face (~0.70)
// so they read as attached to the TE, not embedded in the slab. ----------
const flapGeo = hingedSurfaceAft(0.45, 1.15, 0.045);
model.flapS = add(flapGeo, paint, -1.30, 0.575, 0.74, true);
model.flapP = add(flapGeo.clone(), paint, -1.30, -0.575, 0.74, true);
// --- ailerons (hinged at wing TE, sweeping AFT; starboard TE-up for
// positive input → roll right) --------------------------------------------
const ailGeo = hingedSurfaceAft(0.42, 2.0, 0.045);
model.ailS = add(ailGeo, paint, -1.24, 2.05, 0.74, true);
model.ailP = add(ailGeo.clone(), paint, -1.24, -2.05, 0.74, true);
// --- tail: horizontal stabilizer + elevator ------------------------------
// Stab leading edge at x = −2.20, chord AFT → trailing edge at −3.08.
let stabTE;
{
const geo = planformGeometry(1.9, 0.88, 0.55, 0.5, 0.07);
liftTips(geo, 1.9, 0.02);
add(geo, paint, -2.20, 0, -0.10, true);
stabTE = -2.20 - 0.88; // trailing edge x of the stabilizer (−3.08)
const elevGeo = hingedSurfaceAft(0.32, 4.0, 0.06);
model.elevator = add(elevGeo, paint, stabTE, 0, -0.10, true);
// --- vertical fin + rudder ---------------------------------------------
const finGeo = verticalPrism([[0, 0], [-0.12, -1.0], [0.43, -1.0], [0.90, 0]], 0.08);
add(finGeo, paint, -2.35, 0, -0.30, true);
const rudGeo = verticalPrism([[0, 0], [0, -0.95], [0.26, -0.95], [0.26, 0]], 0.05);
model.rudder = add(rudGeo, accent, -1.45, 0, -0.30);
}
// --- propeller (blades + hub + spinner, whole assembly spins) ------------
const propGroup = new THREE.Group();
propGroup.position.set(3.66, 0, 0);
{
const hub = new THREE.CylinderGeometry(0.075, 0.075, 0.16, 10);
hub.rotateZ(-Math.PI / 2);
hub.computeVertexNormals();
const hubMesh = new THREE.Mesh(hub, bladeMat);
propGroup.add(hubMesh);
const bladeGeo = new THREE.BoxGeometry(0.10, 0.86, 0.18);
for (let k = 0; k < 3; k++) {
const b = new THREE.Mesh(bladeGeo, bladeMat);
b.position.y = 0.50;
b.rotation.x = (k * 2 * Math.PI) / 3;
b.castShadow = true;
propGroup.add(b);
}
const spin = new THREE.ConeGeometry(0.19, 0.30, 18);
spin.rotateZ(-Math.PI / 2);
spin.computeVertexNormals();
const spinner = new THREE.Mesh(spin, metal);
propGroup.add(spinner);
}
group.add(propGroup);
model.propGroup = propGroup;
// --- landing gear (retractable) ------------------------------------------
const gearDefs = makeGear(group);
// data-driven refs for animate
model.gears = gearDefs.map((d) => ({
group: d.group,
qDown: new THREE.Quaternion(),
qUp: d.qUp,
downPos: new THREE.Vector3(d.down[0], d.down[1], d.down[2]),
upPos: new THREE.Vector3(d.up[0], d.up[1], d.up[2]),
qTmp: new THREE.Quaternion(),
}));
// --- landing light (subtle) ----------------------------------------------
{
const lens = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.045, 0.06), lensMat);
lens.position.set(1.02, -2.0, 0.50);
group.add(lens);
const light = new THREE.PointLight(0xfff1cf, 25, 45, 1.7);
light.position.set(1.0, -2.1, 0.42);
group.add(light);
}
// -------------------------------------------------------------------------
// Frame driver
// -------------------------------------------------------------------------
function animate(state, inputs, dt) {
const d = Number.isFinite(dt) && dt > 0 ? Math.min(dt, 0.1) : 0;
const kS = d ? 1 - Math.exp(-d * 12.0) : 1; // surfaces: fast smoothing
const kG = d ? 1 - Math.exp(-d * 3.2) : 1; // gear: ~1 s full travel
const st = state || {};
const m = model.s;
if (inputs) {
const i = inputs;
const v = (x, lo, hi) => clamp(typeof x === 'number' && Number.isFinite(x) ? x : 0, lo, hi);
m.elev = m.elev + (v(i.elevator, -1, 1) * 0.42 - m.elev) * kS;
m.ail = m.ail + (v(i.aileron, -1, 1) * 0.35 - m.ail) * kS;
m.rud = m.rud + (v(i.rudder, -1, 1) * 0.44 - m.rud) * kS;
m.flap = m.flap + (v(i.flaps, 0, 1) * 0.55 - m.flap) * kS;
const gearTarget = i.gear === true ? 0 : 1;
m.gearT = m.gearT + (gearTarget - m.gearT) * kG;
} else {
// inputs undefined: leave surfaces where they are (freeze), prop only
}
// Control surfaces — every free ("trailing") end now swings AFT (local
// −X). A positive rotation.y deflects the aft end DOWN (+Z): R_y(θ) maps
// the point (−1,0,0) to z' = +sinθ.
// Elevator: nose-up command (positive elevator) needs TE UP (−Z) → −θ.
if (model.elevator) model.elevator.rotation.y = -m.elev;
// Rudder (vertical hinge, unchanged geometry): positive rudder (yaw
// right) → trailing edge to starboard (+Y).
if (model.rudder) model.rudder.rotation.z = m.rud;
// Ailerons: positive input → starboard TE up / port TE down → roll right.
model.ailS.rotation.y = -m.ail;
model.ailP.rotation.y = m.ail;
// Flaps: both trailing edges down.
model.flapS.rotation.y = m.flap;
model.flapP.rotation.y = m.flap;
// landing gear: slerp + position lerp into the fuselage
for (const g of model.gears) {
g.qTmp.slerpQuaternions(g.qDown, g.qUp, m.gearT);
g.group.quaternion.copy(g.qTmp);
g.group.position.set(
g.downPos.x + (g.upPos.x - g.downPos.x) * m.gearT,
g.downPos.y + (g.upPos.y - g.downPos.y) * m.gearT,
g.downPos.z + (g.upPos.z - g.downPos.z) * m.gearT
);
}
// propeller: rad/s = rpm01 · (2600/60) · 2π (SPEC formula)
const rpm = clamp(typeof st.rpm === 'number' && Number.isFinite(st.rpm) ? st.rpm : 0, 0, 1);
if (d) m.propA += d * rpm * (cfg.propRpmMax / 60) * 2 * Math.PI;
model.propGroup.rotation.x = m.propA;
}
// helper to build the three gear assemblies + their retract data
function makeGear(parent) {
const defs = [];
const buildStrut = (rTop, rBot, len, y, z, extraMat) => {
const sg = new THREE.CylinderGeometry(rTop, rBot, len, 10);
sg.rotateX(Math.PI / 2); // cylinder +Y axis -> +Z (down)
sg.computeVertexNormals();
const strut = new THREE.Mesh(sg, extraMat || paint);
strut.position.set(0, y, z);
strut.castShadow = true;
return strut;
};
const buildWheel = (r, wd, z) => {
const wg = new THREE.CylinderGeometry(r, r, wd, 14);
const wheel = new THREE.Mesh(wg, rubber);
wheel.position.set(0, 0, z);
wheel.castShadow = true;
return wheel;
};
// main gear: right (+Y) and left (−Y), fold inward into the belly.
// Wheel bottom lands at 0.01 + 0.98 + 0.16 = 1.15 m ≈ cfg.wheelH (1.15).
for (const side of [1, -1]) {
const g = new THREE.Group();
g.position.set(0.55, 0.85 * side, 0.01);
g.add(buildStrut(0.035, 0.045, 0.85, 0, 0.5));
g.add(buildWheel(0.16, 0.11, 0.98)); // wheel bottom z = 1.15 ≈ cfg.wheelH
parent.add(g);
defs.push({
group: g,
down: [0.55, 0.85 * side, 0.01],
up: [0.42, 0.28 * side, -0.15],
qUp: new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(1, 0, 0), 1.50 * side),
});
}
// tailwheel: folds aft into the tailcone
{
const g = new THREE.Group();
g.position.set(-3.05, 0, 0.15);
g.add(buildStrut(0.022, 0.026, 0.5, 0, 0.3));
g.add(buildWheel(0.09, 0.08, 0.55));
parent.add(g);
defs.push({
group: g,
down: [-3.05, 0, 0.15],
up: [-3.28, 0, 0.08],
qUp: new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0), -1.50),
});
}
return defs;
}
function cockpitOffset() {
// pilot eye, body frame (x fwd, y starboard, z down)
return [0.9, 0.0, -0.5];
}
function getDimensions() {
return { length: 7.9, height: 2.5, width: cfg.b };
}
return { group, animate, cockpitOffset, getDimensions };
}
+296
View File
@@ -0,0 +1,296 @@
// src/audio.js — Procedural WebAudio sound for the flight sim.
//
// No audio files: everything is synthesized from oscillators + filtered
// noise. The node graph is created ONCE inside start() (which must be called
// from a user gesture, e.g. the "click to start" handler) and then kept
// static — afterwards only AudioParams (frequencies / gains) are modulated
// with setTargetAtTime / exponential ramps, so there are no clicks and no
// per-frame node churn.
//
// Every public method is a safe no-op before start() (and never throws).
//
// engine : sawtooth (600 + 2600·rpm) Hz + sine sub at 0.5×
// + white noise → lowpass (400 + 2500·rpm) Hz,
// all through one gain ∝ throttle with an idle floor.
// wind : white noise → bandpass (300 + 1800·min(1, V/80)) Hz,
// gain = min(1, V/70).
// stall : 7 Hz LFO amplitude-modulating a lowpassed-noise gain while
// setStall(true) → rhythmic buffet.
// touchdown : 90 → 40 Hz sine sweep + lowpassed noise burst, decaying
// over ~0.4 s.
//
// Noise buffers come from a seeded PRNG (no Math.random anywhere), which is
// a deliberate bonus: this module is cosmetic audio, but staying random-free
// keeps the whole codebase deterministic and QA-friendly.
// Tiny deterministic PRNG (mulberry32) for the noise buffers.
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6D2B79F5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Mono white-noise buffer of `seconds` from a fixed seed.
function makeNoiseBuffer(ctx, seconds, seed) {
const len = Math.max(1, Math.floor(ctx.sampleRate * seconds));
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const data = buf.getChannelData(0);
const rnd = mulberry32(seed);
for (let i = 0; i < len; i++) data[i] = rnd() * 2 - 1;
return buf;
}
// A looping white-noise source node (each branch gets its own source so the
// graph stays static; they share the same underlying buffer).
function makeLoopNode(ctx, buffer) {
const src = ctx.createBufferSource();
src.buffer = buffer;
src.loop = true;
src.start(0);
return src;
}
export function createAudio() {
let ctx = null;
let master = null;
let noiseBuf = null;
// Engine branch
let engOsc = null; // sawtooth fundamental
let engSub = null; // sine sub-oscillator at 0.5×
let engNoise = null; // noise → lowpass
let engLP = null;
let engGain = null;
// Wind branch
let windSrc = null;
let windBP = null;
let windGain = null;
// Stall buffet branch
let stallSrc = null;
let stallLP = null;
let stallGain = null;
let lfoOsc = null;
let lfoGain = null;
// Touchdown thump branch (permanent graph, gain ~0 until triggered)
let thumpOsc = null;
let thumpOscGain = null;
let thumpSrc = null;
let thumpLP = null;
let thumpGain = null;
let muted = false;
// True while a live context exists. A context the browser closed (state
// 'closed') is NOT usable: AudioParams throw InvalidStateError, and the
// setters below run every frame from main.js — an unguarded throw there
// would kill the whole animation loop, so every public method no-ops on it.
function live() {
return ctx !== null && ctx.state !== 'closed';
}
function buildGraph() {
const now = ctx.currentTime;
master = ctx.createGain();
master.gain.setValueAtTime(muted ? 0 : 1, now);
master.connect(ctx.destination);
noiseBuf = makeNoiseBuffer(ctx, 2.0, 0x5EED);
// ---- Engine: saw + sub + filtered noise → engGain → master ----
engOsc = ctx.createOscillator();
engOsc.type = 'sawtooth';
engOsc.frequency.setValueAtTime(600, now);
engSub = ctx.createOscillator();
engSub.type = 'sine';
engSub.frequency.setValueAtTime(300, now);
engNoise = makeLoopNode(ctx, noiseBuf);
engLP = ctx.createBiquadFilter();
engLP.type = 'lowpass';
engLP.frequency.setValueAtTime(400, now);
engLP.Q.setValueAtTime(0.7, now);
engGain = ctx.createGain();
engGain.gain.setValueAtTime(0, now);
const engOscG = ctx.createGain();
engOscG.gain.value = 0.20;
const engSubG = ctx.createGain();
engSubG.gain.value = 0.45;
const engNoiseG = ctx.createGain();
engNoiseG.gain.value = 0.14;
engOsc.connect(engOscG).connect(engGain);
engSub.connect(engSubG).connect(engGain);
engNoise.connect(engLP).connect(engNoiseG).connect(engGain);
engGain.connect(master);
engOsc.start(now);
engSub.start(now);
// ---- Wind: noise → bandpass → windGain → master ----
windSrc = makeLoopNode(ctx, noiseBuf);
windBP = ctx.createBiquadFilter();
windBP.type = 'bandpass';
windBP.frequency.setValueAtTime(300, now);
windBP.Q.setValueAtTime(0.6, now);
windGain = ctx.createGain();
windGain.gain.setValueAtTime(0, now);
windSrc.connect(windBP).connect(windGain).connect(master);
// ---- Stall buffet: noise → lowpass → stallGain, AM by 7 Hz LFO ----
stallSrc = makeLoopNode(ctx, noiseBuf);
stallLP = ctx.createBiquadFilter();
stallLP.type = 'lowpass';
stallLP.frequency.setValueAtTime(95, now);
stallLP.Q.setValueAtTime(1.4, now);
stallGain = ctx.createGain();
stallGain.gain.setValueAtTime(0, now);
stallSrc.connect(stallLP).connect(stallGain).connect(master);
lfoOsc = ctx.createOscillator();
lfoOsc.type = 'sine';
lfoOsc.frequency.setValueAtTime(7.0, now);
lfoGain = ctx.createGain();
lfoGain.gain.setValueAtTime(0, now);
lfoOsc.connect(lfoGain);
lfoGain.connect(stallGain.gain); // ±depth modulation of the buffet gain
lfoOsc.start(now);
// ---- Touchdown thump: sine sweep + noise burst, silent until played ----
thumpOsc = ctx.createOscillator();
thumpOsc.type = 'sine';
thumpOsc.frequency.setValueAtTime(40, now);
thumpOscGain = ctx.createGain();
thumpOscGain.gain.setValueAtTime(0, now);
thumpSrc = makeLoopNode(ctx, noiseBuf);
thumpLP = ctx.createBiquadFilter();
thumpLP.type = 'lowpass';
thumpLP.frequency.setValueAtTime(260, now);
thumpGain = ctx.createGain();
thumpGain.gain.setValueAtTime(0, now);
thumpOsc.connect(thumpOscGain).connect(master);
thumpSrc.connect(thumpLP).connect(thumpGain).connect(master);
thumpOsc.start(now);
}
/**
* Create/resume the AudioContext and build the (one-time) node graph.
* Safe to call repeatedly: first call builds, later calls just resume a
* suspended context (e.g. after the browser suspends an inaudible tab).
* Must first be called from a user gesture. Never throws.
*/
function start() {
if (!ctx) {
try {
const AC = typeof window !== 'undefined'
? (window.AudioContext || window.webkitAudioContext)
: null;
if (!AC) return;
ctx = new AC();
buildGraph();
} catch (err) {
ctx = null;
return;
}
}
if (ctx.state === 'suspended') {
const p = ctx.resume();
if (p && typeof p.catch === 'function') p.catch(() => {});
}
}
/**
* rpm01 / throttle in 0..1 (clamped). Saw + sub + noise all keyed to rpm;
* their shared gain is proportional to throttle with an idle floor so the
* engine never goes completely silent at zero throttle (engine idles on).
*/
function setEngine(rpm01, throttle) {
if (!live()) return;
const r = Math.min(1, Math.max(0, rpm01));
const t = Math.min(1, Math.max(0, throttle));
const now = ctx.currentTime;
const sawF = 600 + 2600 * r;
engOsc.frequency.setTargetAtTime(sawF, now, 0.09);
engSub.frequency.setTargetAtTime(sawF * 0.5, now, 0.09);
engLP.frequency.setTargetAtTime(400 + 2500 * r, now, 0.09);
const g = 0.15 + 0.85 * t; // idle floor at zero throttle
engGain.gain.setTargetAtTime(g, now, 0.08);
}
/** airspeedMps in m/s; bandpass frequency and gain both follow airspeed. */
function setWind(airspeedMps) {
if (!live()) return;
const v = Math.max(0, airspeedMps);
const now = ctx.currentTime;
windBP.frequency.setTargetAtTime(
300 + 1800 * Math.min(1, v / 80), now, 0.12);
windGain.gain.setTargetAtTime(Math.min(1, v / 70), now, 0.12);
}
/** on=true: rhythmic buffet pulses; on=false: fade the buffet out. */
function setStall(on) {
if (!live()) return;
const now = ctx.currentTime;
stallGain.gain.setTargetAtTime(on ? 0.4 : 0, now, 0.05);
lfoGain.gain.setTargetAtTime(on ? 0.32 : 0, now, 0.05);
}
/** One low thump: 90 → 40 Hz sine sweep + noise burst, ~0.4 s decay. */
function touchdown() {
if (!live()) return;
const now = ctx.currentTime;
thumpOsc.frequency.cancelScheduledValues(now);
thumpOsc.frequency.setValueAtTime(90, now);
thumpOsc.frequency.exponentialRampToValueAtTime(40, now + 0.18);
thumpOscGain.gain.cancelScheduledValues(now);
thumpOscGain.gain.setValueAtTime(0.0001, now);
thumpOscGain.gain.exponentialRampToValueAtTime(0.9, now + 0.015);
thumpOscGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.42);
thumpGain.gain.cancelScheduledValues(now);
thumpGain.gain.setValueAtTime(0.0001, now);
thumpGain.gain.exponentialRampToValueAtTime(0.5, now + 0.01);
thumpGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.40);
}
/** Ramp master gain to 0 (mute) or back to 1 — no click, state remembered. */
function setMuted(m) {
muted = !!m;
if (!live()) return;
master.gain.setTargetAtTime(muted ? 0 : 1, ctx.currentTime, 0.03);
}
/** Close the context and drop every node reference (start() rebuilds). */
function dispose() {
if (ctx) {
try {
const p = ctx.close();
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch (err) { /* never throw */ }
}
ctx = null;
master = engOsc = engSub = engNoise = engLP = engGain = null;
windSrc = windBP = windGain = null;
stallSrc = stallLP = stallGain = lfoOsc = lfoGain = null;
thumpOsc = thumpOscGain = thumpSrc = thumpLP = thumpGain = null;
noiseBuf = null;
}
return { start, setEngine, setWind, setStall, touchdown, setMuted, dispose };
}
+107
View File
@@ -0,0 +1,107 @@
// Aircraft + atmosphere constants (SI). Pure module — Node-safe, no three.js,
// no DOM. Single source of truth for physics parameters. Do not edit.
export const cfg = {
// Mass & geometry
mass: 1200, // kg
S: 16.4, // wing area m^2
b: 10.2, // wingspan m
cBar: 1.62, // mean aerodynamic chord m
AR: (10.2 * 10.2) / 16.4, // aspect ratio ≈ 6.34
e: 0.8, // Oswald span efficiency
kInd: 1 / (Math.PI * ((10.2 * 10.2) / 16.4) * 0.8), // induced drag factor
g: 9.81,
// Inertia (kg·m²), symmetric tensor [Ixx, Iyy, Izz, Ixz]
Ixx: 820,
Iyy: 3100,
Izz: 3300,
Ixz: 60,
// Lift
CL0: 0.15, // zero-angle lift (wing incidence)
CLalpha: 5.3, // /rad lift slope
alphaCrit: 0.30, // rad ≈ 17.2° stall angle
CLmax: 1.55,
CLflat: 1.05, // post-stall flat plate CL regime
flapEff: 0.55, // ΔCL when flaps fully deployed
groundEff: 0.35, // ΔCL fraction near ground (h < 1.5·b)
// Drag
CD0: 0.026, // parasite drag
dCD_flapsMid: 0.018,
dCD_flapsFull: 0.045,
dCD_gear: 0.022,
// Pitching stability/control
Cm0: 0.02,
CmAlpha: -0.85, // static pitch stability (/rad, negative)
Cmδe: -0.95, // elevator effectiveness (/rad)
Cmq: -13.0, // pitch damping
// Rolling stability/control
Clβ: -0.09, // dihedral effect (/rad)
Clδa: 0.32, // aileron effectiveness (/rad)
Clp: -0.50, // roll damping
Clδr: 0.02, // roll due to rudder (cross)
// Yaw stability/control
Cnβ: 0.11, // directional stability (/rad, positive)
Cnδr: -0.11, // rudder effectiveness (/rad)
Cnδa: -0.04, // adverse yaw from ailerons
Cnr: -0.28, // yaw damping
// Side force
CYβ: -0.85,
CYδr: 0.20,
// Control surface limits (rad)
dE_max: 0.42, // ≈ 24°
dA_max: 0.35, // ≈ 20°
dR_max: 0.44, // ≈ 25°
// Engine / propeller
Tmax: 2600, // N max thrust at sea level
engTau: 1.6, // rpm spool time constant (s)
rpmIdle: 0.24,
rpmMax: 1.0,
propD: 1.9, // m
propTorque: 0.055, // torque factor × Tmax·propD (opposes prop spin)
Iprop: 0.9, // kg·m²
propRpmMax: 2600, // rpm at rpm=1
// Fuel
fuelKg: 180,
fuelBurn: 0.055, // kg/s at full throttle & rpm
// Ground / landing
wheelH: 1.15, // m, ground contact height of main gear
rollFriction: 0.045, // rolling resistance coefficient (off brakes)
brakeFriction: 0.16, // ×g decel cap when braking
maxTouchVz: 3.0, // m/s safe sink rate, gear down
maxTouchVzGearUp: 1.2,
crashPitchDeg: -25, // nose-down beyond this at impact → crash
crashRollDeg: 75,
steerGain: 0.6, // ground steering moment factor
};
export const atmo = {
rho0: 1.225, // kg/m³ sea level
T0: 288.15, // K
lapse: 0.0065, // K/m
R: 287.05, // J/(kg·K)
};
// ISA density as a function of altitude (m). Troposphere + lower stratosphere.
export function atmoDensity(h) {
const hClamp = Math.max(0, h);
if (hClamp < 11000) {
return 1.225 * Math.pow(1 - 2.25577e-5 * hClamp, 4.25588);
}
return 0.3639 * Math.exp((11000 - hClamp) / 6341.6);
}
export const UNITS = {
ktsPerMps: 1.94384,
ftPerM: 3.28084,
fpmPerMps: 196.8504,
};
+405
View File
@@ -0,0 +1,405 @@
// src/controls.js — keyboard flight controls.
//
// Turns keyboard presses into smoothed control inputs for physics, plus
// one-shot action events for the integrator (reset / camera / pause / flaps /
// gear / brakes / sound / debug).
//
// Behavior contract (see SPEC.md):
// - Sticks: W/S + ArrowUp/ArrowDown = elevator, A/D + ArrowLeft/ArrowRight =
// aileron, Q/E = rudder. Digital targets are slewed toward the actual
// values at ~1.6 full-range per second (frame-rate independent — uses
// wall-clock time inside getInputs()).
// - Throttle: Shift = up, Ctrl = down. Digital trim stepping: every keydown
// INCLUDING auto-repeat adds one step (auto-repeat is deliberately allowed),
// then the value slews at ~0.4 full-range per second. Releasing the key
// leaves throttle where it was stepped to.
// - Toggles: F cycles flaps 0 → 0.25 → 0.5 → 1 → 0, G gear, B brakes,
// C camera, R reset, P pause, D debug, S/Enter sound. Toggle/action keys
// ignore key auto-repeat via a pressed-state set and fire onAction() exactly
// once per physical press. D and S double as aileron-right / elevator-down
// hold axes (see the axis branch below).
// - Conventions: elevator > 0 nose up, aileron > 0 bank right, rudder > 0
// yaw right (matches SPEC body frame).
// - Robustness: window blur / hidden tab releases every held key; arrows and
// space are preventDefault'ed so the page cannot scroll; Ctrl/Meta+handled
// keys are preventDefault'ed so browser accelerators (Ctrl+R reload, Ctrl+P
// print, Ctrl+S save) do not fire while flying; nothing in this module
// throws.
//
// Browser-side module by design (DOM/events are its job). Importing it in Node
// is side-effect free (all window/document access happens inside attachInput),
// which keeps `node --check` and import tests happy.
// ---- static tables ---------------------------------------------------------
const FLAP_POSITIONS = Object.freeze([0, 0.25, 0.5, 1]);
const DEFAULT_OPTS = Object.freeze({
stickSlew: 1.6, // full-range per second (elevator / aileron / rudder)
throttleSlew: 0.4, // full-range per second (throttle)
throttleStep: 0.05, // throttle change per Shift/Ctrl keydown (repeat counts)
// Spawn checklist from SPEC: gear down, flaps 25%, throttle 0, brakes on.
initial: Object.freeze({ throttle: 0, flaps: 0.25, gear: true, brakes: true }),
});
// e.key (lowercased) -> action.
// NOTE: 's' is deliberately mapped to the elevator axis (WASD = elevator per
// SPEC) AND doubles as the sound toggle — the sound half is handled inside the
// axis branch so the key both holds the elevator down and toggles sound once
// per press. Enter is the pure sound toggle.
const KEY_ACTION = {
w: 'elevUp', s: 'elevDown',
a: 'ailLeft', d: 'ailRight',
q: 'rudLeft', e: 'rudRight',
arrowup: 'elevUp', arrowdown: 'elevDown',
arrowleft: 'ailLeft', arrowright: 'ailRight',
shift: 'thrUp', control: 'thrDown',
f: 'flaps', g: 'gear', b: 'brakes',
c: 'camera', r: 'reset', p: 'pause',
enter: 'sound',
' ': 'space',
};
const AXIS_ACTIONS = new Set([
'elevUp', 'elevDown', 'ailLeft', 'ailRight', 'rudLeft', 'rudRight',
]);
/** For the help panel on the start screen. Must match KEY_ACTION exactly. */
export const KEY_BINDINGS = [
{ key: 'W / S', label: 'Elevator — pitch up / down' },
{ key: 'A / D', label: 'Aileron — bank left / right' },
{ key: '↑ / ↓', label: 'Elevator (arrow keys)' },
{ key: '← / →', label: 'Aileron (arrow keys)' },
{ key: 'Q / E', label: 'Rudder — yaw left / right' },
{ key: 'Shift / Ctrl', label: 'Throttle up / down' },
{ key: 'F', label: 'Flaps 0 → 25% → 50% → 100%' },
{ key: 'G', label: 'Gear up / down' },
{ key: 'B', label: 'Brakes on / off' },
{ key: 'C', label: 'Camera view' },
{ key: 'R', label: 'Reset flight' },
{ key: 'P', label: 'Pause' },
{ key: 'D', label: 'Debug overlay' },
{ key: 'S / Enter', label: 'Sound on / off' },
];
// ---- small helpers ---------------------------------------------------------
const EPS = 1e-9; // snap sub-epsilon float noise at the clamping limits
function clamp01(v) {
return v <= 0 + EPS ? 0 : v >= 1 - EPS ? 1 : v;
}
function clampAxis(v) {
return v <= -1 + EPS ? -1 : v >= 1 - EPS ? 1 : v;
}
function fin(v, fallback) {
return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
}
function limit(v, fallback, min, max) {
const f = fin(v, fallback);
return f < min ? min : f > max ? max : f;
}
function snapFlaps(v) {
let best = FLAP_POSITIONS[0];
let bestD = Infinity;
for (const p of FLAP_POSITIONS) {
const d = Math.abs(p - v);
if (d < bestD) { bestD = d; best = p; }
}
return best;
}
function freshHeld() {
return {
elevUp: false, elevDown: false,
ailLeft: false, ailRight: false,
rudLeft: false, rudRight: false,
};
}
function nowMs() {
return (typeof performance !== 'undefined' && typeof performance.now === 'function')
? performance.now()
: Date.now();
}
// ---- attachInput -----------------------------------------------------------
/**
* Attach keyboard controls. `canvas` is accepted for API symmetry (all
* listeners are window/document scoped, so the canvas needs no focus); an
* extra canvas blur listener is attached anyway for robustness.
*
* opts (all optional):
* stickSlew number sticks full-range slew rate /s (default 1.6)
* throttleSlew number throttle full-range slew rate /s (default 0.4)
* throttleStep number throttle step per keydown (default 0.05)
* initial { throttle, flaps, gear, brakes } spawn/toggle configuration
* (default: throttle 0, flaps 0.25, gear true, brakes true —
* matches the SPEC spawn checklist)
*/
export function attachInput(canvas, opts) {
const src = (opts && typeof opts === 'object') ? opts : {};
const init = (src.initial && typeof src.initial === 'object') ? src.initial : {};
const state = {
opts: {
stickSlew: limit(src.stickSlew, DEFAULT_OPTS.stickSlew, 0.05, 10),
throttleSlew: limit(src.throttleSlew, DEFAULT_OPTS.throttleSlew, 0.01, 5),
throttleStep: limit(src.throttleStep, DEFAULT_OPTS.throttleStep, 0.001, 0.5),
initial: {
throttle: clamp01(fin(init.throttle, DEFAULT_OPTS.initial.throttle)),
flaps: snapFlaps(fin(init.flaps, DEFAULT_OPTS.initial.flaps)),
gear: init.gear === undefined ? DEFAULT_OPTS.initial.gear : !!init.gear,
brakes: init.brakes === undefined ? DEFAULT_OPTS.initial.brakes : !!init.brakes,
},
},
held: freshHeld(),
targets: { elevator: 0, aileron: 0, rudder: 0 },
cur: { elevator: 0, aileron: 0, rudder: 0, throttle: 0 },
throttleTarget: 0,
flaps: DEFAULT_OPTS.initial.flaps,
gear: DEFAULT_OPTS.initial.gear,
brakes: DEFAULT_OPTS.initial.brakes,
pressed: new Set(),
lastT: nowMs(), // slew clock epoch; handled new in getInputs
actionCb: null,
disposed: false,
};
// Apply the initial configuration after pumping out targets/state.
state.throttleTarget = state.opts.initial.throttle;
state.cur.throttle = state.opts.initial.throttle;
state.flaps = state.opts.initial.flaps;
state.gear = state.opts.initial.gear;
state.brakes = state.opts.initial.brakes;
function fireAction(action) {
try {
if (state.actionCb) state.actionCb(action);
} catch (err) {
// Callback errors must never break input handling.
}
}
function recomputeTargets() {
const h = state.held;
state.targets.elevator = (h.elevUp ? 1 : 0) + (h.elevDown ? -1 : 0);
state.targets.aileron = (h.ailLeft ? -1 : 0) + (h.ailRight ? 1 : 0);
state.targets.rudder = (h.rudLeft ? -1 : 0) + (h.rudRight ? 1 : 0);
}
function applyToggle(hand) {
switch (hand) {
case 'flaps': {
const i = FLAP_POSITIONS.indexOf(state.flaps);
state.flaps = FLAP_POSITIONS[(i + 1) % FLAP_POSITIONS.length];
fireAction('flaps');
break;
}
case 'gear':
state.gear = !state.gear;
fireAction('gear');
break;
case 'brakes':
state.brakes = !state.brakes;
fireAction('brakes');
break;
case 'camera': fireAction('camera'); break;
case 'reset': fireAction('reset'); break;
case 'pause': fireAction('pause'); break;
case 'debug': fireAction('debug'); break;
case 'sound': fireAction('sound'); break;
default: break;
}
}
function onKeyDown(e) {
try {
if (state.disposed) return;
const k = (e.key || '').toLowerCase();
const hand = KEY_ACTION[k];
if (hand === 'space') {
if (e.preventDefault) e.preventDefault(); // stop page scroll
return;
}
if (!hand) return;
// Block browser accelerators (Ctrl+R reload, Ctrl+P print, Ctrl+S save)
// and page scrolling (letters trigger quick-find in some browsers).
if ((e.ctrlKey || e.metaKey) || k.length === 1 || k.indexOf('arrow') === 0) {
if (e.preventDefault) e.preventDefault();
}
if (hand === 'thrUp' || hand === 'thrDown') {
// Digital trim: EVERY keydown counts, auto-repeat intentionally allowed.
const dir = hand === 'thrUp' ? 1 : -1;
state.throttleTarget = clamp01(state.throttleTarget + dir * state.opts.throttleStep);
return;
}
if (AXIS_ACTIONS.has(hand)) {
state.held[hand] = true;
recomputeTargets();
// 's' is both elevator-down AND the sound toggle (SPEC keymap);
// 'd' is both aileron-right AND the debug toggle — the toggle half
// fires once per physical press, the axis stays held.
if (k === 's' && !state.pressed.has('s')) {
state.pressed.add('s');
fireAction('sound');
}
if (k === 'd' && !state.pressed.has('d')) {
state.pressed.add('d');
fireAction('debug');
}
return;
}
// Momentary toggle/action keys: ignore auto-repeat via the pressed set.
if (state.pressed.has(k)) return;
state.pressed.add(k);
applyToggle(hand);
} catch (err) {
// never throw
}
}
function onKeyUp(e) {
try {
if (state.disposed) return;
const k = (e.key || '').toLowerCase();
const hand = KEY_ACTION[k];
state.pressed.delete(k);
if (hand && AXIS_ACTIONS.has(hand)) {
state.held[hand] = false;
recomputeTargets();
}
// Throttle keeps its stepped value on key release (not a hold key).
} catch (err) {
// never throw
}
}
function releaseAll() {
state.pressed.clear();
state.held = freshHeld();
recomputeTargets();
}
function onBlur() {
try {
releaseAll();
} catch (err) {
// never throw
}
}
function onVisibilityChange() {
try {
if (document.hidden) releaseAll();
} catch (err) {
// never throw
}
}
const listeners = [];
function add(target, type, fn) {
try {
if (target && typeof target.addEventListener === 'function') {
target.addEventListener(type, fn);
listeners.push([target, type, fn]);
}
} catch (err) {
// never throw
}
}
add(window, 'keydown', onKeyDown);
add(window, 'keyup', onKeyUp);
add(window, 'blur', onBlur);
add(document, 'visibilitychange', onVisibilityChange);
if (canvas) add(canvas, 'blur', onBlur);
function slewTo(cur, tgt, maxDelta) {
const delta = tgt - cur;
if (Math.abs(delta) <= maxDelta) return tgt;
return cur + (delta > 0 ? maxDelta : -maxDelta);
}
function getInputs() {
try {
const now = nowMs();
// Belt-and-braces: if the clock epoch was never set (should not happen
// because attachInput seeds it), seed it now.
if (state.lastT < 0) state.lastT = now;
// Clamp dt so a long pause (hidden tab, breakpoint, clock reset) cannot
// teleport the smoothed values; 0.1 s is plenty for a smooth catch-up.
const dt = Math.min(0.1, Math.max(0, (now - state.lastT) / 1000));
state.lastT = now;
const o = state.opts;
state.cur.elevator = slewTo(state.cur.elevator, state.targets.elevator, o.stickSlew * dt);
state.cur.aileron = slewTo(state.cur.aileron, state.targets.aileron, o.stickSlew * dt);
state.cur.rudder = slewTo(state.cur.rudder, state.targets.rudder, o.stickSlew * dt);
state.cur.throttle = slewTo(state.cur.throttle, state.throttleTarget, o.throttleSlew * dt);
return {
elevator: clampAxis(state.cur.elevator),
aileron: clampAxis(state.cur.aileron),
rudder: clampAxis(state.cur.rudder),
throttle: clamp01(state.cur.throttle),
flaps: state.flaps,
gear: state.gear,
brakes: state.brakes,
};
} catch (err) {
// never throw — return the last known values if anything goes wrong
return {
elevator: clampAxis(state.cur.elevator),
aileron: clampAxis(state.cur.aileron),
rudder: clampAxis(state.cur.rudder),
throttle: clamp01(state.cur.throttle),
flaps: state.flaps,
gear: state.gear,
brakes: state.brakes,
};
}
}
function reset() {
// Zero every continuous input...
state.held = freshHeld();
state.targets = { elevator: 0, aileron: 0, rudder: 0 };
state.cur = { elevator: 0, aileron: 0, rudder: 0, throttle: 0 };
state.throttleTarget = 0;
state.pressed.clear();
// The slew clock is deliberately NOT reset: the next getInputs() diff will
// be tiny (and the 0.1 s dt clamp covers any pause), so nothing jumps.
// ...and restore toggles to the configured initial state (default = SPEC
// spawn: flaps 25%, gear down, brakes on). Pass opts.initial to change.
state.flaps = state.opts.initial.flaps;
state.gear = state.opts.initial.gear;
state.brakes = state.opts.initial.brakes;
}
function dispose() {
if (state.disposed) return;
state.disposed = true;
releaseAll();
for (let i = 0; i < listeners.length; i++) {
const [target, type, fn] = listeners[i];
try {
if (target && typeof target.removeEventListener === 'function') {
target.removeEventListener(type, fn);
}
} catch (err) {
// never throw
}
}
listeners.length = 0;
state.actionCb = null;
}
function onAction(cb) {
state.actionCb = typeof cb === 'function' ? cb : null;
}
return { getInputs, reset, dispose, onAction };
}
+311
View File
@@ -0,0 +1,311 @@
// src/instruments.js — DOM instrument overlay (HUD).
//
// Browser module (DOM is its job). Implements the SPEC contract:
// createInstruments(rootEl) -> { update(data), message(text, kind),
// clearMessage(), debug(lines), reset() }
//
// All overlay DOM is created inside #hud using ONLY the classes declared in
// css/style.css (that file's header documents the exact contract). No inline
// styles except: static per-tick layout offsets set once at build time, and
// per-frame dynamic transforms (horizon pitch/roll, tape scrolls, VSI bar).
//
// Per-frame cost discipline (SPEC: "keep writes cheap"):
// * every frame : horizon transform, tape transforms (cached to 0.5 px),
// VSI bar translate
// * on threshold : numeric readouts (kts ≥ 1, ft ≥ 1, fpm ≥ 25), bar widths
// (≥ 1%), lamp/stall/message class toggles (state-change only)
//
// Instrument ranges:
// airspeed tape 0–260 kts, ticks/10, labels/20, 2.0 px per kt
// altimeter tape 0–10000 ft, ticks/50, labels/500, 2.0 px per ft
// heading tape 0–359°, ticks/5, major labels/15 (3 periods for wrap)
// VSI ±2000 fpm full scale
// horizon pitch scale 2.5 px/°, bank rotate(−bankDeg) per CSS hint;
// note the sign: a 90° pitch-up should fully hide the ground
// half, so pitch displacement is clamped to ±80 px (340 px
// horizon in a 210 px mask).
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
// ---- scale/layout constants -------------------------------------------------
const HZ_PX_PER_DEG = 2.5;
const HZ_MAX_PX = 80;
const AS_MIN_KTS = 0, AS_MAX_KTS = 260, AS_PX_PER_KT = 2.0;
const AL_MIN_FT = 0, AL_MAX_FT = 10000, AL_PX_PER_FT = 2.0;
const HDG_STEP = 5, HDG_MAJOR = 15, HDG_PERIODS = 3;
const VSI_RANGE_FPM = 2000;
// Thresholds for text/bar updates (avoid DOM churn every frame).
const TH_KTS = 1, TH_FT = 1, TH_FPM = 25, TH_PCT = 1, TH_KG = 1;
function el(tag, cls, parent) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (parent) parent.appendChild(e);
return e;
}
export function createInstruments(rootEl) {
if (!rootEl) rootEl = document.body;
const root = rootEl;
// ---- panels --------------------------------------------------------------
// Attitude indicator + airspeed share a left-hand column (above/ below),
// keeping the screen centre clear. See .hud-left-col in style.css.
const leftCol = el('div', 'hud-left-col', root);
const attitude = el('div', 'hud-attitude', leftCol);
const horizon = el('div', 'ai-horizon', attitude);
el('div', 'ai-sky', horizon);
el('div', 'ai-ground', horizon);
// pitch ladder: static lines 20° apart; they translate/rotate with the ball
for (let m = -20; m <= 20; m += 20) {
const line = el('div', 'ai-line', horizon);
const frac = 0.5 + m * (HZ_PX_PER_DEG / 340); // 340px horizon height
line.style.top = (frac * 100) + '%';
}
const fixedSym = el('div', 'ai-fixed', attitude);
el('div', 'ai-symbol', fixedSym);
// Airspeed (left column, under the attitude indicator) ----------------------
const asPanel = el('div', 'hud-airspeed', leftCol);
const asNum = el('div', 'hud-num', asPanel);
el('small', null, asNum).textContent = 'KTS';
const asVal = el('span', null, asNum);
const asTape = el('div', 'tape', asPanel);
const asTicks = [];
for (let v = AS_MIN_KTS; v <= AS_MAX_KTS; v += 10) {
const t = el('div', 'tick', asTape);
t.textContent = v % 20 === 0 ? String(v) : '·';
// static anchor: value v sits at tapeH/2 + v·pxPer from the tape top
t.style.top = (110 + v * AS_PX_PER_KT) + 'px';
asTicks.push(t);
}
// Altimeter + VSI (right) ---------------------------------------------------
const alPanel = el('div', 'hud-altitude', root);
const alNum = el('div', 'hud-num', alPanel);
el('small', null, alNum).textContent = 'ALT FT';
const alVal = el('span', null, alNum);
const alTape = el('div', 'tape', alPanel);
const alTicks = [];
for (let v = AL_MIN_FT; v <= AL_MAX_FT; v += 50) {
const t = el('div', 'tick', alTape);
t.textContent = v % 500 === 0 ? String(v) : '·';
t.style.top = (110 + v * AL_PX_PER_FT) + 'px';
alTicks.push(t);
}
const vsi = el('div', 'hud-vsi', alPanel);
const vsiFill = el('div', 'hud-vsi-fill', vsi);
// Heading tape (bottom centre) ----------------------------------------------
const hdgPanel = el('div', 'hud-heading', root);
const hdgNum = el('div', 'hud-num', hdgPanel);
el('small', null, hdgNum).textContent = 'HDG';
const hdgVal = el('span', null, hdgNum);
const hdgTape = el('div', 'tape', hdgPanel);
const bug = el('div', 'bug', hdgTape);
const hdgTicks = [];
for (let p = 0; p < HDG_PERIODS; p++) {
for (let h = 0; h < 360; h += HDG_STEP) {
const t = el('div', 'tick', hdgTape);
if (h % HDG_MAJOR === 0) {
t.className = 'tick major';
t.textContent = String(h % 360);
} else {
t.textContent = '·';
}
t.style.left = (((p * 360 + h) / 360) * 100) + '%';
hdgTicks.push(t);
}
}
// Engine panel (bottom left) --------------------------------------------------
const engPanel = el('div', 'hud-engine', root);
function barRow(labelText, throttled) {
const row = el('div', 'bar-row', engPanel);
const lbl = el('span', null, row);
lbl.textContent = labelText;
const bar = el('div', 'bar', row);
const fill = el('div', throttled ? 'bar-fill throttle' : 'bar-fill', bar);
const val = el('span', null, row);
return { row, lbl, fill, val };
}
const rpmRow = barRow('RPM', false);
const thrRow = barRow('THR', true);
const fuelRow = barRow('FUEL', false);
// Lamps (bottom right) --------------------------------------------------------
const lamps = el('div', 'hud-lamps', root);
function lamp(label, variant) {
const L = el('div', variant ? 'lamp ' + variant : 'lamp', lamps);
L.textContent = label;
return L;
}
const lampFlap = lamp('FLAPS', null);
const lampGear = lamp('GEAR', 'lamp--gear');
const lampBrake = lamp('BRAKES', 'lamp--brakes');
const lampStall = lamp('STALL', 'lamp--stall');
// Stall warning card (centre) ---------------------------------------------------
const stallCard = el('div', 'hud-stall', root);
stallCard.textContent = 'STALL';
// Message banner (top centre) -----------------------------------------------------
const msgEl = el('div', 'hud-message', root);
// Debug panel (top left) ----------------------------------------------------------
const dbgEl = el('div', 'hud-debug', root);
const dbgLines = [];
// ---- per-frame cached state -------------------------------------------------------
const last = {
kts: -Infinity, ft: -Infinity, fpm: -Infinity,
hdg: -Infinity, rpm: -Infinity, thr: -Infinity, fuel: -Infinity,
asOffset: NaN, alOffset: NaN, hdgOffset: NaN, vz: NaN, vy: NaN,
};
let vsiH = 0, hdgW = 0;
const asH = 220, alH = 220; // .tape heights from style.css (px)
/** Text update helper: only touch the DOM when |new − old| ≥ threshold. */
function maybeText(node, value, rounded, key, th) {
if (Math.abs(value - last[key]) < th) return;
last[key] = value;
node.textContent = rounded;
}
function buildDebug(lines) {
if (dbgLines.length === 0) return;
for (let i = 0; i < dbgLines.length; i++) {
const line = dbgLines[i];
const src = lines[i] || '';
// wrap numeric tokens in <b> (pair lines gain a spacing ::after)
const html = src.replace(/(-?\d+(?:\.\d+)?%?)/g, '<b>$1</b>');
const pairs = (html.match(/<b>/g) || []).length;
line.className = pairs > 1 ? 'line pair' : 'line';
line.innerHTML = html;
}
}
// ---- public API -----------------------------------------------------------------
function update(data) {
if (!data) return;
const d = data;
// Attitude: translateY(pitchPx) rotate(−bankDeg) — CSS-documented contract.
const px = clamp(d.pitchDeg * HZ_PX_PER_DEG, -HZ_MAX_PX, HZ_MAX_PX);
horizon.style.transform =
'translateY(' + px.toFixed(1) + 'px) rotate(' + (-d.bankDeg).toFixed(1) + 'deg)';
// Airspeed tape + readout.
const kts = clamp(d.airspeedKts, AS_MIN_KTS, AS_MAX_KTS);
maybeText(asVal, kts, String(Math.round(kts)), 'kts', TH_KTS);
const asOff = Math.round(kts * AS_PX_PER_KT);
if (asOff !== last.asOffset) {
last.asOffset = asOff;
const dy = 'translateY(' + (-asOff) + 'px)';
for (let i = 0; i < asTicks.length; i++) asTicks[i].style.transform = dy;
}
// Altimeter tape + readout; VSI bar.
const ft = clamp(d.altFt, AL_MIN_FT, AL_MAX_FT);
maybeText(alVal, ft, String(Math.round(ft)), 'ft', TH_FT);
const alOff = Math.round(ft * AL_PX_PER_FT);
if (alOff !== last.alOffset) {
last.alOffset = alOff;
const dy = 'translateY(' + (-alOff) + 'px)';
for (let i = 0; i < alTicks.length; i++) alTicks[i].style.transform = dy;
}
if (vsiH <= 0) vsiH = vsi.clientHeight || 220;
const yf = clamp(d.vsFpm / VSI_RANGE_FPM, -1, 1);
const y = (vsiH / 2 - 1) - yf * (vsiH / 2 - 2);
const yInt = Math.round(y);
if (yInt !== last.vz) {
last.vz = yInt;
vsiFill.style.transform = 'translateY(' + yInt + 'px)';
}
// Heading tape + readout. The fixed lubber line (.bug) sits at the tape's
// CENTER (50%), so the tick for the current heading must be translated to
// screen-x W/2: tick h sits at (p*360+h)/360*W; shifting by (hdg+180)/360*W
// puts tick h=hdg at W/2 (verified by hand). Without the −180 the pointer
// marks hdg+180 (the reciprocal heading) instead.
let hdg = d.headingDeg || 0;
hdg = ((hdg % 360) + 360) % 360;
if (hdgW <= 0) hdgW = hdgTape.clientWidth || 310;
const off = Math.round((hdg + 180) * (hdgW / 360));
if (off !== last.hdgOffset) {
last.hdgOffset = off;
const dx = 'translateX(' + (-off) + 'px)';
for (let i = 0; i < hdgTicks.length; i++) hdgTicks[i].style.transform = dx;
}
maybeText(hdgVal, hdg, String(Math.round(hdg)), 'hdg', 0.5);
// Engine bars + fuel.
const rpmPct = Math.round(d.rpm * 100);
const thrPct = Math.round(d.throttle * 100);
if (rpmPct !== last.rpm) {
last.rpm = rpmPct;
rpmRow.fill.style.width = rpmPct + '%';
rpmRow.val.textContent = rpmPct + '%';
}
if (thrPct !== last.thr) {
last.thr = thrPct;
thrRow.fill.style.width = thrPct + '%';
thrRow.val.textContent = thrPct + '%';
}
const fuelKg = d.fuelKg;
if (Math.abs(fuelKg - last.fuel) >= TH_KG || last.fuel === -Infinity) {
const cap = 180; // cfg.fuelKg (display only)
const fpct = clamp(Math.round((fuelKg / cap) * 100), 0, 100);
fuelRow.fill.style.width = fpct + '%';
fuelRow.val.textContent = fuelKg.toFixed(0) + ' kg';
last.fuel = fuelKg;
}
// Lamps: toggle only on change.
const setLamp = (L, on) => { if (!!on !== (L.classList.contains('on'))) L.classList.toggle('on', !!on); };
setLamp(lampFlap, d.flapsPos > 0);
setLamp(lampGear, !!d.gearDown);
setLamp(lampBrake, !!d.brakes);
setLamp(lampStall, !!d.stall);
setLamp(stallCard, !!d.stall);
}
function message(text, kind) {
msgEl.textContent = text || '';
msgEl.className = 'hud-message visible'
+ (kind ? ' ' + kind : '');
}
function clearMessage() {
msgEl.className = msgEl.className.replace(/\s*visible/, '');
}
function debug(lines) {
if (!lines) {
dbgEl.classList.remove('visible');
return;
}
if (dbgLines.length === 0) {
for (let i = 0; i < 5; i++) dbgLines.push(el('div', 'line', dbgEl));
dbgEl.classList.add('visible');
}
buildDebug(lines);
}
function reset() {
clearMessage();
dbgEl.classList.remove('visible');
stallCard.classList.remove('on');
lampStall.classList.remove('on');
// force full refresh on the next update
last.kts = last.ft = last.fpm = last.hdg = last.rpm = last.thr = last.fuel = -Infinity;
last.asOffset = last.alOffset = last.hdgOffset = last.vz = NaN;
}
return { update, message, clearMessage, debug, reset };
}
+355
View File
@@ -0,0 +1,355 @@
// 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);
+548
View File
@@ -0,0 +1,548 @@
// src/physics.js — 6-DOF rigid-body flight model.
// Pure ESM, Node-safe (no DOM, no three.js). Implements SPEC §"physics model
// spec". Deterministic: no randomness anywhere.
//
// Conventions (SPEC, coords section):
// world : +Y up, +X east, +Z south (north = −Z)
// body : +X forward (nose), +Y starboard, +Z down
// quat : [w,x,y,z], body→world rotation (v_world = R(q)·v_body), matches
// three.js Object3D.quaternion (main.js copies q[1..3],q[0]).
// p/q/r : roll right / pitch up / yaw right (positive about body +X/+Y/+Z)
// inputs : elevator>0 nose-up, aileron>0 bank right, rudder>0 yaw right
//
// Sign notes (kept internally consistent, documented here):
// * Config Cmδe = −0.95, Cnδr = −0.11 are the standard "positive deflection
// (TE-down / TE-throws-left) → negative moment" derivatives for an aft
// tail. The control INPUT is the stick (elevator>0 means nose-up, rudder>0
// means yaw-right), so the physical deflections are δe = −elev·dE_max and
// δr = −rudder·dR_max. Aileron uses δa = +aileron·dA_max (Clδa > 0).
// * Engine torque about +X is negative (opposes prop spin → rolls left),
// matching config.propTorque "opposes prop spin". Gyro moment is −ω×H.
// * SPAWN_QUAT below deviates from the literal value printed in SPEC (which
// produces a 90°-banked spawn); this value is the unique quaternion that
// satisfies the SPEC's own body-frame + heading-0-north + wings-level
// definitions (verified against three.js r160).
import { cfg, atmo, atmoDensity, UNITS } from './config.js';
import { getGroundHeight, WORLD } from './world.js';
const SUBSTEP = 1 / 240; // internal integration step (s)
const MAX_DT = 0.1; // defensive cap on a single step() call
const V_DAMP = 1.0; // m/s floor for the (·/(2V)) damping quotients
const V_FORCE = 0.5; // m/s floor for vel/V unit-vector quotients
const RAD2DEG = 180 / Math.PI;
// Spawn attitude: heading 0 (north), wings level. physics [w,x,y,z] = [0.5,0.5,0.5,−0.5]
// maps body +X→(0,0,−1), +Y→(1,0,0), +Z→(0,−1,0). See header note.
const SPAWN_QUAT = [0.5, 0.5, 0.5, -0.5];
const NEUTRAL_INPUTS = Object.freeze({
elevator: 0, aileron: 0, rudder: 0, throttle: 0,
flaps: 0.25, gear: true, brakes: true,
});
function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
// ---- quaternion helpers (physics order [w,x,y,z]) --------------------------
function qMul(a, b) {
const [w1, x1, y1, z1] = a;
const [w2, x2, y2, z2] = b;
return [
w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2,
w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2,
w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2,
w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2,
];
}
function qInv(q) { return [q[0], -q[1], -q[2], -q[3]]; }
function qNorm(q) {
const l = Math.sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]);
if (!(l > 0)) return [1, 0, 0, 0];
const k = 1 / l;
return [q[0] * k, q[1] * k, q[2] * k, q[3] * k];
}
/** Rotate a vector by quaternion q (body → world). */
function qvRot(q, v) {
const [w, x, y, z] = q;
const [vx, vy, vz] = v;
const tx = 2 * (y * vz - z * vy);
const ty = 2 * (z * vx - x * vz);
const tz = 2 * (x * vy - y * vx);
return [
vx + w * tx + (y * tz - z * ty),
vy + w * ty + (z * tx - x * tz),
vz + w * tz + (x * ty - y * tx),
];
}
/** Rotate a vector by the inverse of q (world → body). */
function qvInv(q, v) { return qvRot(qInv(q), v); }
// ---- attitude --------------------------------------------------------------
function getAttitude(q) {
const nose = qvRot(q, [1, 0, 0]);
const star = qvRot(q, [0, 1, 0]);
const down = qvRot(q, [0, 0, 1]); // body +Z (belly) in world
let headingDeg = Math.atan2(nose[0], -nose[2]) * RAD2DEG; // clockwise from north
if (headingDeg < 0) headingDeg += 360;
const pitchDeg = Math.asin(clamp(nose[1], -1, 1)) * RAD2DEG;
const bankDeg = Math.atan2(-star[1], -down[1]) * RAD2DEG; // + = right wing down
return { headingDeg, pitchDeg, bankDeg, inverted: down[1] > 0 };
}
// ---- inertia -----------------------------------------------------------------
// Inertia tensor (symmetric, aircraft convention: off-diagonal −Ixz).
// I = [[Ixx, 0, −Ixz], [0, Iyy, 0], [−Ixz, 0, Izz]]
function angAccel(w, M) {
const { Ixx, Iyy, Izz, Ixz } = cfg;
const hx = Ixx * w[0] - Ixz * w[2];
const hy = Iyy * w[1];
const hz = -Ixz * w[0] + Izz * w[2];
const cx = w[1] * hz - w[2] * hy; // ω × (I·ω)
const cy = w[2] * hx - w[0] * hz;
const cz = w[0] * hy - w[1] * hx;
const tx = M[0] - cx;
const ty = M[1] - cy;
const tz = M[2] - cz;
const det = Ixx * Izz - Ixz * Ixz;
return [
(Izz * tx + Ixz * tz) / det,
ty / Iyy,
(Ixz * tx + Ixx * tz) / det,
];
}
// ---- aerodynamics -------------------------------------------------------------
/**
* Evaluate aero + engine forces/moments for the current state.
* Returns body-frame force F (aero + thrust), moment M (aero + prop torque +
* gyro), and the coefficient set used by step() and the summary functions.
*/
function aeroEval(a, ctrl, V, rho) {
const vx = a.vel[0], vy = a.vel[1], vz = a.vel[2];
// Angles of attack / sideslip.
// NOTE: SPEC writes α = atan2(−vel.z, vel.x), but with the "+Z down" body
// frame and CLalpha>0 the physically correct stability-axis angle is
// α = atan2(vel.z, vel.x) (positive α = nose up into the flow = positive
// lift). The SPEC sign yields negative lift for a nose-up climb, which
// inverts the whole longitudinal model (verified empirically: the trim
// equilibrium diverges). Corrected here; see file header.
const alpha = Math.atan2(vz, vx);
const beta = V > V_DAMP ? Math.asin(clamp(vy / V, -1, 1)) : 0;
const qbar = 0.5 * rho * V * V;
const stall = Math.abs(alpha) > cfg.alphaCrit;
// Physical control deflections (rad) — see file header sign notes.
const de = -clamp(ctrl.elevator, -1, 1) * cfg.dE_max;
const da = clamp(ctrl.aileron, -1, 1) * cfg.dA_max;
const dr = -clamp(ctrl.rudder, -1, 1) * cfg.dR_max;
// --- lift curve with smooth (continuous) stall blend ----------------------
// Linear CL up to |α| = aSat (where it equals CLmax), then smoothstep-blend
// to CLmax at α_crit, then exponential decay toward the flat-plate regime.
const linCl = cfg.CL0 + cfg.CLalpha * alpha;
const aAbs = Math.abs(alpha);
const aSat = (cfg.CLmax - cfg.CL0) / cfg.CLalpha; // ≈ 0.264 rad < αcrit
const sCLmax = Math.sign(alpha) * cfg.CLmax;
let clBase;
if (aAbs <= aSat) {
clBase = linCl;
} else if (aAbs <= cfg.alphaCrit) {
const t = (aAbs - aSat) / (cfg.alphaCrit - aSat);
const s = t * t * (3 - 2 * t);
clBase = linCl + (sCLmax - linCl) * s;
} else {
clBase = sCLmax * Math.exp(-(aAbs - cfg.alphaCrit) / 0.35);
}
// Flaps + deterministic ground effect.
const gH = getGroundHeight(a.pos[0], a.pos[2]);
const hAGL = a.pos[1] - gH;
const geFrac = clamp01(1 - hAGL / (1.5 * cfg.b));
const cl = clBase + cfg.flapEff * a.flaps + clBase * cfg.groundEff * geFrac;
// --- drag ------------------------------------------------------------------
const flapCd = a.flaps <= 0.25
? cfg.dCD_flapsMid * (a.flaps / 0.25)
: cfg.dCD_flapsMid + (cfg.dCD_flapsFull - cfg.dCD_flapsMid) * ((a.flaps - 0.25) / 0.75);
const flatPlate = 1.15 * clamp01((aAbs - cfg.alphaCrit) / 0.45);
const cd = cfg.CD0 + cfg.kInd * cl * cl + flapCd + (a.gearDown ? cfg.dCD_gear : 0) + flatPlate;
// --- side force -------------------------------------------------------------
const cy = cfg.CYβ * beta + cfg.CYδr * dr;
const sideN = qbar * cfg.S * cy;
// --- moments (rad conventions; damping quotients protected at low V) --------
const vD = Math.max(V, V_DAMP);
const p = a.angVel[0], q = a.angVel[1], r = a.angVel[2];
const Cm = cfg.Cm0 + cfg.CmAlpha * alpha + cfg.Cmδe * de + cfg.Cmq * (q * cfg.cBar / (2 * vD));
const Cl = cfg.Clβ * beta + cfg.Clδa * da + cfg.Clp * (p * cfg.b / (2 * vD)) + cfg.Clδr * dr;
const Cn = cfg.Cnβ * beta + cfg.Cnδr * dr + cfg.Cnδa * da + cfg.Cnr * (r * cfg.b / (2 * vD));
const Maero = [
qbar * cfg.S * cfg.b * Cl,
qbar * cfg.S * cfg.cBar * Cm,
qbar * cfg.S * cfg.b * Cn,
];
// --- engine: thrust, prop torque (rolls left), gyro precession ---------------
const rpm = a.rpm;
const denRatio = Math.pow(rho / atmo.rho0, 0.7);
const engineLive = a.engineOn && rpm > 0;
const T = engineLive ? cfg.Tmax * rpm * denRatio : 0;
const engMx = engineLive ? -cfg.propTorque * cfg.Tmax * cfg.propD * rpm : 0;
const Hx = engineLive ? cfg.Iprop * (rpm * cfg.propRpmMax * (2 * Math.PI / 60)) : 0;
const Mgyro = [0, -r * Hx, q * Hx]; // −ω × H, H = Iprop·ω_prop·[1,0,0]
// --- resultant force: lift ⊥ velocity (x-z plane), drag along −v ------------
const vxz = Math.hypot(vx, vz);
let liftDir = [0, 0, -1]; // guard: pure vertical flight
if (vxz > 1e-6) {
const inv = 1 / vxz;
liftDir = [vz * inv, 0, -vx * inv]; // up = −ẑ rotated by α about +Y
}
const L = qbar * cfg.S * cl; // signed coefficient → signed lift
const D = qbar * cfg.S * cd;
const vScale = 1 / Math.max(V, V_FORCE); // avoid 0/0 at rest
const F = [
L * liftDir[0] - D * vx * vScale + T,
L * liftDir[1] - D * vy * vScale + sideN,
L * liftDir[2] - D * vz * vScale,
];
const M = [Maero[0] + engMx + Mgyro[0], Maero[1] + Mgyro[1], Maero[2] + Mgyro[2]];
// Load factor along body up (−ẑ); ≈ 1 in steady level flight.
const gLoad = -(F[2]) / (cfg.mass * cfg.g);
return { alpha, beta, qbar, cl, cd, cy, stall, F, M, gLoad };
}
// ---- engine spool + fuel ------------------------------------------------------
function engineAndFuel(a, ctrl, h) {
const live = a.fuelKg > 0;
const target = live ? Math.max(cfg.rpmIdle, clamp(ctrl.throttle, 0, 1)) : 0;
a.rpm += (target - a.rpm) * (h / cfg.engTau);
if (a.rpm < 0) a.rpm = 0;
a.engineOn = live && a.rpm > 0.005;
if (live) {
a.fuelKg = Math.max(0, a.fuelKg - cfg.fuelBurn * a.rpm * clamp(ctrl.throttle, 0, 1) * h);
if (a.fuelKg <= 0) { a.engineOn = false; a.rpm = 0; }
}
}
// ---- ground handling ------------------------------------------------------------
// Main gears fore/aft of the CG (body x) and their wheel-bottom height.
// Taildragger config: main gear + tailwheel (no nose gear in the model).
const GEAR_MAIN = [0.55, 0, cfg.wheelH]; // wheel bottom, body coords
const GEAR_TAIL = [-3.0, 0, 0.88];
/** World-Y offsets of main and tail gear bottoms relative to the CG. */
function gearOffsetsY(q) {
return {
mainY: qvRot(q, GEAR_MAIN)[1],
tailY: qvRot(q, GEAR_TAIL)[1],
};
}
function integrateGround(a, ctrl, h, aeroUpY) {
const gH = getGroundHeight(a.pos[0], a.pos[2]);
const touchY = gH + cfg.wheelH;
if (a.pos[1] > touchY) {
a.groundContact = false;
a.landed = false; // airborne again → not landed (was sticky)
return;
}
const wasAirborne = !a.groundContact;
a.groundContact = true;
const vW = qvRot(a.quat, a.vel);
const sink = -vW[1];
// Crash classification only on the airborne → contact transition.
if (wasAirborne && !a.crashed) {
const att = getAttitude(a.quat);
const maxVz = a.gearDown ? cfg.maxTouchVz : cfg.maxTouchVzGearUp;
const attCrash = att.inverted || Math.abs(att.bankDeg) > cfg.crashRollDeg ||
att.pitchDeg < cfg.crashPitchDeg;
if (attCrash || sink > maxVz) {
a.crashed = true;
a.impactSpeed = Math.max(a.impactSpeed, sink);
}
}
// Three-point gear geometry: the CG rests on the LOWEST gear; nose-up
// rotation lifts the CG off the mains (tail down → CG rises), which the
// elevator must buy with real work. Prevents ground rotation to a stall.
const { mainY, tailY } = gearOffsetsY(a.quat);
const contY = gH - Math.min(mainY, tailY);
// Liftoff: when aero+thrust vertical force beats weight, release the
// vertical clamp so the aircraft can climb away (the classic rotate).
const liftOff = !a.crashed && aeroUpY > cfg.mass * cfg.g && vW[1] >= -0.5;
if (a.pos[1] < contY) a.pos[1] = contY;
if (liftOff) {
// leave vertical velocity intact → airplane leaves the ground
} else {
vW[1] = 0;
}
const vH = Math.hypot(vW[0], vW[2]);
// Rolling friction (brakes ×3) — also bleeds speed after a crash settle.
if (vH > 1e-6) {
const mu = a.brakes ? 3 * cfg.rollFriction : cfg.rollFriction;
const dv = Math.min(mu * cfg.g * h, vH);
vW[0] -= (vW[0] / vH) * dv;
vW[2] -= (vW[2] / vH) * dv;
} else {
vW[0] = 0;
vW[2] = 0;
}
// Ground steering: yaw moment ∝ rudder · speed (SPEC).
if (!a.crashed) {
// The main gear physically resists banking on the ground. Damp roll and
// pull the bank attitude back toward wings-level (PD), so prop torque
// does not roll the airframe over while taxiing. Pitch and yaw stay
// free for rotation/steering.
const bankNow = Math.atan2(-qvRot(a.quat, [0, 1, 0])[1], -qvRot(a.quat, [0, 0, 1])[1]);
const pTarget = -bankNow * 4; // /s, restore toward level
const k = 1 - Math.exp(-h * 8);
a.angVel[0] += (pTarget - a.angVel[0]) * k;
// Tail resting on the ground blocks further nose-up rotation (taildragger
// three-point); likewise the main gear blocks nothing nose-down here.
if (tailY < mainY - 0.02 && a.angVel[1] > 0) a.angVel[1] = 0;
const vH2 = Math.hypot(vW[0], vW[2]);
if (vH2 > 0.5) {
const steerM = cfg.steerGain * clamp(ctrl.rudder, -1, 1) * Math.min(vH2, 20) *
(cfg.mass * cfg.g * cfg.cBar) / 10;
a.angVel[2] += (steerM / cfg.Izz) * h;
}
}
a.vel = qvInv(a.quat, vW);
a.landed = !a.crashed && a.airTime > 0.5;
}
// ---- single substep ----------------------------------------------------------------
function substep(a, ctrl, h) {
const rho = atmoDensity(a.pos[1]);
const V = Math.hypot(a.vel[0], a.vel[1], a.vel[2]);
if (!a.crashed) {
const e = aeroEval(a, ctrl, V, rho);
// gravity in body frame
const gB = qvInv(a.quat, [0, -cfg.g, 0]);
// Rotating-frame (body) acceleration: a_body = F/m + g − ω×v
// (transport theorem; without −ω×v the world velocity would rotate with
// the airframe and the aircraft could never stall in a pull-up).
const wxv = [
a.angVel[1] * a.vel[2] - a.angVel[2] * a.vel[1],
a.angVel[2] * a.vel[0] - a.angVel[0] * a.vel[2],
a.angVel[0] * a.vel[1] - a.angVel[1] * a.vel[0],
];
a.vel[0] += (e.F[0] + gB[0] * cfg.mass) * (h / cfg.mass) - wxv[0] * h;
a.vel[1] += (e.F[1] + gB[1] * cfg.mass) * (h / cfg.mass) - wxv[1] * h;
a.vel[2] += (e.F[2] + gB[2] * cfg.mass) * (h / cfg.mass) - wxv[2] * h;
const acc = angAccel(a.angVel, e.M);
a.angVel[0] += acc[0] * h;
a.angVel[1] += acc[1] * h;
a.angVel[2] += acc[2] * h;
engineAndFuel(a, ctrl, h);
a._aero = e; // cached for instrument/summary callbacks within this step
} else {
// crashed: gravity + gentle settle (controls frozen).
const gB = qvInv(a.quat, [0, -cfg.g, 0]);
const wxv = [
a.angVel[1] * a.vel[2] - a.angVel[2] * a.vel[1],
a.angVel[2] * a.vel[0] - a.angVel[0] * a.vel[2],
a.angVel[0] * a.vel[1] - a.angVel[1] * a.vel[0],
];
a.vel[0] += gB[0] * h - wxv[0] * h;
a.vel[1] += gB[1] * h - wxv[1] * h;
a.vel[2] += gB[2] * h - wxv[2] * h;
a.angVel[0] *= 1 - 0.5 * h; // mild damping so the wreck settles
a.angVel[1] *= 1 - 0.5 * h;
a.angVel[2] *= 1 - 0.5 * h;
}
// Integrate quaternion (q̇ = ½·q⊗ω_q with ω_q = body rates [0,p,q,r];
// left-multiplication form would require world-frame ω) and normalize.
const wq = [0, a.angVel[0], a.angVel[1], a.angVel[2]];
const dq = qMul(a.quat, wq);
a.quat = qNorm([
a.quat[0] + dq[0] * h * 0.5,
a.quat[1] + dq[1] * h * 0.5,
a.quat[2] + dq[2] * h * 0.5,
a.quat[3] + dq[3] * h * 0.5,
]);
// Position from world velocity.
const vW = qvRot(a.quat, a.vel);
a.pos[0] += vW[0] * h;
a.pos[1] += vW[1] * h;
a.pos[2] += vW[2] * h;
// Vertical (world-Y) aero+thrust force for the liftoff release test.
const aeroUpY = a.crashed ? 0 : qvRot(a.quat, a._aero ? a._aero.F : [0, 0, 0])[1];
integrateGround(a, ctrl, h, aeroUpY);
if (a.groundContact) a.groundTime += h;
else a.airTime += h;
}
// ---- public API ------------------------------------------------------------------
export function createAircraft() {
const sx = WORLD.spawn.x;
const sz = WORLD.spawn.z;
const sy = getGroundHeight(sx, sz) + cfg.wheelH;
return {
pos: [sx, sy, sz],
quat: [...SPAWN_QUAT],
vel: [0, 0, 0],
angVel: [0, 0, 0],
throttle: 0,
rpm: cfg.rpmIdle,
flaps: 0.25,
gearDown: true,
brakes: true,
fuelKg: cfg.fuelKg,
engineOn: true,
crashed: false,
landed: false,
groundContact: true,
groundTime: 0,
airTime: 0,
impactSpeed: 0,
spawnPos: [sx, sy, sz],
spawnQuat: [...SPAWN_QUAT],
_in: { ...NEUTRAL_INPUTS },
_aero: null,
};
}
export function resetAircraft(a) {
a.pos[0] = a.spawnPos[0]; a.pos[1] = a.spawnPos[1]; a.pos[2] = a.spawnPos[2];
a.quat[0] = a.spawnQuat[0]; a.quat[1] = a.spawnQuat[1];
a.quat[2] = a.spawnQuat[2]; a.quat[3] = a.spawnQuat[3];
a.vel[0] = 0; a.vel[1] = 0; a.vel[2] = 0;
a.angVel[0] = 0; a.angVel[1] = 0; a.angVel[2] = 0;
a.throttle = 0;
a.rpm = cfg.rpmIdle;
a.flaps = 0.25;
a.gearDown = true;
a.brakes = true;
a.fuelKg = cfg.fuelKg;
a.engineOn = true;
a.crashed = false;
a.landed = false;
a.groundContact = true;
a.groundTime = 0;
a.airTime = 0;
a.impactSpeed = 0;
a._in = { ...NEUTRAL_INPUTS };
a._aero = null;
}
/**
* Advance the simulation. dt must be ≤ 1/120 (caller guarantee); internally
* the step is substepped to ≤ 1/240. Deterministic.
*/
export function step(aircraft, inputs, dt) {
if (!(dt > 0)) return;
const d = Math.min(dt, MAX_DT);
const n = Math.max(1, Math.ceil(d / SUBSTEP));
const h = d / n;
const ctrl = inputs && typeof inputs === 'object' ? inputs : NEUTRAL_INPUTS;
// Copy the (single) control snapshot into state for instruments/summary;
// sanitize so a NaN/±Infinity in the caller's inputs cannot poison the sim.
const fnum = (v, dflt) => (typeof v === 'number' && Number.isFinite(v)) ? v : dflt;
aircraft._in = {
elevator: fnum(ctrl.elevator, 0),
aileron: fnum(ctrl.aileron, 0),
rudder: fnum(ctrl.rudder, 0),
throttle: Math.max(0, Math.min(1, fnum(ctrl.throttle, 0))),
flaps: Math.max(0, Math.min(1, fnum(ctrl.flaps, aircraft.flaps))),
gear: ctrl.gear === undefined ? aircraft.gearDown : !!ctrl.gear,
brakes: ctrl.brakes === undefined ? aircraft.brakes : !!ctrl.brakes,
};
// Gear/flaps/brakes are inputs-driven state (undefined = keep current).
aircraft.gearDown = aircraft._in.gear;
aircraft.flaps = aircraft._in.flaps;
aircraft.brakes = aircraft._in.brakes;
aircraft.throttle = aircraft._in.throttle;
for (let i = 0; i < n; i++) substep(aircraft, aircraft._in, h);
}
export function getAeroSummary(aircraft) {
const V = Math.hypot(aircraft.vel[0], aircraft.vel[1], aircraft.vel[2]);
const rho = atmoDensity(aircraft.pos[1]);
const e = aeroEval(aircraft, aircraft._in || NEUTRAL_INPUTS, V, rho);
return {
alpha: e.alpha,
beta: e.beta,
q: e.qbar,
liftN: e.qbar * cfg.S * e.cl, // signed: negative = lift directed downward
dragN: e.qbar * cfg.S * e.cd, // magnitude opposing motion
sideN: e.qbar * cfg.S * e.cy, // signed along +Y (starboard)
cl: e.cl,
cd: e.cd,
stall: e.stall,
vtas: V,
};
}
export function getInstrumentData(aircraft) {
const V = Math.hypot(aircraft.vel[0], aircraft.vel[1], aircraft.vel[2]);
const rho = atmoDensity(aircraft.pos[1]);
const e = aeroEval(aircraft, aircraft._in || NEUTRAL_INPUTS, V, rho);
const vW = qvRot(aircraft.quat, aircraft.vel);
const att = getAttitude(aircraft.quat);
return {
airspeedMps: V,
airspeedKts: V * UNITS.ktsPerMps,
altM: aircraft.pos[1],
altFt: aircraft.pos[1] * UNITS.ftPerM,
vsMps: vW[1],
vsFpm: vW[1] * UNITS.fpmPerMps,
headingDeg: att.headingDeg,
pitchDeg: att.pitchDeg,
bankDeg: att.bankDeg,
rollRateDps: aircraft.angVel[0] * RAD2DEG,
pitchRateDps: aircraft.angVel[1] * RAD2DEG,
yawRateDps: aircraft.angVel[2] * RAD2DEG,
alphaDeg: e.alpha * RAD2DEG,
betaDeg: e.beta * RAD2DEG,
gLoad: e.gLoad,
stall: e.stall,
engineOn: aircraft.engineOn,
rpm: aircraft.rpm,
throttle: aircraft.throttle,
flapsPos: aircraft.flaps,
gearDown: aircraft.gearDown,
brakes: aircraft.brakes,
fuelKg: aircraft.fuelKg,
groundContact: aircraft.groundContact,
landed: aircraft.landed,
crashed: aircraft.crashed,
onGround: aircraft.groundContact,
rolling: aircraft.groundContact && Math.hypot(vW[0], vW[2]) > 0.5,
airTimeS: aircraft.airTime,
};
}
+416
View File
@@ -0,0 +1,416 @@
// src/terrain.js — Environment agent module.
// Builds sky dome, sun/hemisphere lights, elevation-mapped ground, runway
// carpet, water and wrap-around clouds. Elevation sampling ALWAYS delegates to
// world.getGroundHeight (single source of truth — shared with physics).
// Rendering-only module: no physics, no state mutation of aircraft.
//
// exports:
// buildEnvironment(scene, opts?) -> { update(camPos, dt), groundHeight(x,z), dispose() }
// opts = { shadows: boolean } — when true: 4096px shadow map on the sun,
// ground receive+cast, runway receive+cast, water receive. The integrator
// must ALSO set renderer.shadowMap.enabled = true (and e.g.
// renderer.shadowMap.type = THREE.PCFSoftShadowMap); terrain.js cannot reach
// the renderer. Default false => zero shadow cost.
import * as THREE from '../vendor/three.module.min.js';
import { WORLD, getGroundHeight } from './world.js';
// ---------------------------------------------------------------------------
// Constants (SI)
// ---------------------------------------------------------------------------
const SIZE = WORLD.size; // 8000 m ground extent (square)
const SEGMENTS = 192; // ground grid resolution (per side)
const CLOUD_COUNT = 28;
const CLOUD_BOX = 6000; // m: cloud tile period in x/z
const CLOUD_Y_MIN = 350; // m
const CLOUD_Y_MAX = 800; // m
const SKY_RADIUS = 14000; // m: sky dome (beyond fog far)
const WATER_EXTENT = 16000; // m: water sheet half-extent*2
const SUN_DIR = [0.6, 0.75, 0.4]; // light shining FROM this direction
const SUN_DIST = 3000; // m: light position along SUN_DIR
const SUN_INTENSITY = 2.2;
const HEMI_INTENSITY = 0.55;
const FOG_NEAR = 2000; // m
const FOG_FAR = 6500; // m
// Palette authored in sRGB; THREE.Color converts to the working (linear)
// space on construction (color management is on by default in r160).
const C_SAND = new THREE.Color(0xd6c494);
const C_GRASS = new THREE.Color(0x6f9a45);
const C_ROCK = new THREE.Color(0x8b837a);
const C_SNOW = new THREE.Color(0xf2f5f8);
const C_ZENITH = new THREE.Color(0x2b6fb3); // upper-sky blue
const C_HORIZON = new THREE.Color(0xd2e0ec); // haze — matches scene fog
const C_BELOW = new THREE.Color(0xb3bfbd); // dome below the horizon line
// ---------------------------------------------------------------------------
// Deterministic cosmetic PRNG (mulberry32, fixed seed). Cloud placement is
// static scenery, but keeping it deterministic honours SPEC rule 4 and makes
// every run look identical (stable screenshots, reproducible tests).
// ---------------------------------------------------------------------------
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function smoothstep01(t) {
const u = Math.min(1, Math.max(0, t));
return u * u * (3 - 2 * u);
}
function makeCanvas(w, h) {
const cv = document.createElement('canvas');
cv.width = w;
cv.height = h;
return cv;
}
// ---------------------------------------------------------------------------
// Terrain colour by height (with narrow blend bands so zones don't band).
// Actual world maxima are ~107 m, so the rock->snow taper starts near 96 m to
// give the highest peaks snow caps; pure >120 snow never occurs on this map.
// ---------------------------------------------------------------------------
function colorForHeight(h, wx, wz, out) {
let col;
if (h < 2) {
col = C_SAND;
} else if (h < 8) {
col = C_SAND.clone().lerp(C_GRASS, smoothstep01((h - 2) / 6));
} else if (h < 42) {
col = C_GRASS;
} else if (h < 52) {
col = C_GRASS.clone().lerp(C_ROCK, smoothstep01((h - 42) / 10));
} else if (h < 96) {
col = C_ROCK;
} else {
col = C_ROCK.clone().lerp(C_SNOW, smoothstep01((h - 96) / 12));
}
// deterministic mottle so big zones aren't dead-flat
const m = 1 + 0.09 * Math.sin(wx * 0.5236 + 1.7) * Math.cos(wz * 0.3142);
out.copy(col).multiplyScalar(Math.min(1.08, Math.max(0.92, m)));
return out;
}
// ---------------------------------------------------------------------------
// Canvas textures
// ---------------------------------------------------------------------------
function makeRunwayTexture() {
const rw = WORLD.runway;
const W = 2048;
const H = 4096;
const cv = makeCanvas(W, H);
const ctx = cv.getContext('2d');
const pxX = W / rw.width; // px per metre across (45.5)
const pxZ = H / rw.length; // px per metre along (2.05)
const cy = (zWorld) => H * (zWorld / rw.length + 0.5); // world z -> canvas y
// asphalt base
ctx.fillStyle = '#4c4c50';
ctx.fillRect(0, 0, W, H);
// subtle deterministic wear bands
for (let i = 0; i < 40; i++) {
const z = -rw.length / 2 + i * (rw.length / 40);
ctx.fillStyle = (i * 7 + 3) % 13 === 0 ? 'rgba(0,0,0,0.10)' : 'rgba(255,255,255,0.035)';
ctx.fillRect(0, cy(z), W, Math.max(1, pxZ * 3));
}
// solid white edge lines, ~1.2 m inboard of each edge
const edgeInset = pxX * 1.2;
const edgeW = Math.max(2, pxX * 0.35);
ctx.fillStyle = '#f2f2ea';
ctx.fillRect(edgeInset, 0, edgeW, H);
ctx.fillRect(W - edgeInset - edgeW, 0, edgeW, H);
// dashed centerline: 30 m on / 30 m off, 0.9 m wide
const half = W / 2;
const dashW = Math.max(2, pxX * 0.9);
for (let z = -rw.length / 2 + 45; z < rw.length / 2 - 45; z += 60) {
ctx.fillRect(half - dashW / 2, cy(z + 30), dashW, pxZ * 30);
}
// threshold stripes at both ends (6 bars, 2.2 m on / 2.2 m off, 8 m in)
const barLen = Math.max(2, pxZ * 2.2);
for (let k = 0; k < 6; k++) {
for (const end of [1, -1]) {
const z0 = end * (rw.length / 2 - 8 - k * 4.4 - 2.2);
ctx.fillRect(edgeInset, cy(z0 + 2.2), W - 2 * edgeInset, barLen);
}
}
// threshold numbers: '36' near north end, '18' near south end, painted
// upright for a pilot landing on that end (canvas top == world north).
ctx.font = 'bold 400px Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#f2f2ea';
ctx.fillText('36', half, cy(-rw.length / 2 + 95));
ctx.fillText('18', half, cy(rw.length / 2 - 95));
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 8;
return tex;
}
function makeWaterTexture() {
const S = 256;
const cv = makeCanvas(S, S);
const ctx = cv.getContext('2d');
ctx.fillStyle = '#3b6e92';
ctx.fillRect(0, 0, S, S);
// deterministic ripple streaks (cosmetic) — tile repeats over the sheet
ctx.lineWidth = 1.5;
for (let i = 0; i < 22; i++) {
const y = (i / 22) * S;
ctx.strokeStyle = i % 3 === 0
? 'rgba(168,208,235,0.28)'
: 'rgba(12,40,66,0.18)';
ctx.beginPath();
for (let x = 0; x <= S; x += 8) {
const yy = y + Math.sin((x + i * 13) / 17) * 2.2;
if (x === 0) ctx.moveTo(x, yy);
else ctx.lineTo(x, yy);
}
ctx.stroke();
}
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(8, 8);
tex.anisotropy = 4;
return tex;
}
function makeCloudTexture() {
const S = 128;
const cv = makeCanvas(S, S);
const ctx = cv.getContext('2d');
const g = ctx.createRadialGradient(S / 2, S / 2, S * 0.06, S / 2, S / 2, S / 2);
g.addColorStop(0, 'rgba(255,255,255,0.96)');
g.addColorStop(0.5, 'rgba(255,255,255,0.55)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, S, S);
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------
function buildSky(scene) {
const geo = new THREE.SphereGeometry(SKY_RADIUS, 48, 32);
const pos = geo.attributes.position;
const n = pos.count;
const cols = new Float32Array(n * 3);
const tmp = new THREE.Color();
for (let i = 0; i < n; i++) {
const ny = pos.getY(i) / SKY_RADIUS; // -1 bottom .. +1 top
if (ny <= 0) tmp.copy(C_BELOW).lerp(C_HORIZON, 1 + ny);
else tmp.copy(C_HORIZON).lerp(C_ZENITH, smoothstep01(ny));
cols[i * 3] = tmp.r;
cols[i * 3 + 1] = tmp.g;
cols[i * 3 + 2] = tmp.b;
}
geo.setAttribute('color', new THREE.BufferAttribute(cols, 3));
const mat = new THREE.MeshBasicMaterial({
vertexColors: true,
side: THREE.BackSide,
fog: false, // sky must NOT be fogged (it is beyond fog far)
depthWrite: false, // drawn first, never occludes scene geometry
});
const mesh = new THREE.Mesh(geo, mat);
mesh.renderOrder = -1;
scene.add(mesh);
return { mesh, geo, mat };
}
function buildGround(scene, shadows) {
const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEGMENTS, SEGMENTS);
geo.rotateX(-Math.PI / 2); // now lies in the x-z plane (world +Y up)
const pos = geo.attributes.position;
const count = pos.count;
const cols = new Float32Array(count * 3);
const tmp = new THREE.Color();
for (let i = 0; i < count; i++) {
const wx = pos.getX(i);
const wz = pos.getZ(i);
const h = getGroundHeight(wx, wz);
pos.setY(i, h);
colorForHeight(h, wx, wz, tmp);
cols[i * 3] = tmp.r;
cols[i * 3 + 1] = tmp.g;
cols[i * 3 + 2] = tmp.b;
}
geo.setAttribute('color', new THREE.BufferAttribute(cols, 3));
geo.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({
vertexColors: true,
roughness: 1,
metalness: 0,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.receiveShadow = shadows;
mesh.castShadow = true; // spec: ground casts too (harmless when shadows off)
scene.add(mesh);
return { mesh, geo, mat };
}
function buildRunway(scene, shadows) {
const rw = WORLD.runway;
const geo = new THREE.PlaneGeometry(rw.width, rw.length, 1, 1);
geo.rotateX(-Math.PI / 2);
const tex = makeRunwayTexture();
const mat = new THREE.MeshStandardMaterial({
map: tex,
roughness: 0.92,
metalness: 0,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(rw.cx, 0.02, rw.cz); // 2 cm above the flattened terrain
mesh.receiveShadow = shadows;
mesh.castShadow = shadows;
scene.add(mesh);
return { mesh, geo, mat, tex };
}
function buildWater(scene, shadows) {
const geo = new THREE.PlaneGeometry(WATER_EXTENT, WATER_EXTENT, 1, 1);
geo.rotateX(-Math.PI / 2);
const tex = makeWaterTexture();
const mat = new THREE.MeshStandardMaterial({
map: tex,
color: 0xffffff, // blue comes from the baked texture
transparent: true,
opacity: 0.6,
roughness: 0.18,
metalness: 0.1,
depthWrite: false, // transparent sheet; avoids sorting artefacts
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.y = WORLD.waterLevel; // -0.4 m
mesh.receiveShadow = shadows;
scene.add(mesh);
return { mesh, geo, mat, tex };
}
function buildClouds(scene) {
const tex = makeCloudTexture();
const mat = new THREE.SpriteMaterial({
map: tex,
transparent: true,
opacity: 0.85,
depthWrite: false,
});
const rnd = mulberry32(0xc10d5eed);
const bases = [];
const sprites = [];
for (let i = 0; i < CLOUD_COUNT; i++) {
const s = new THREE.Sprite(mat);
const size = 170 + rnd() * 300; // 170..470 m
s.scale.set(size, size, 1);
s.position.set(
(rnd() - 0.5) * CLOUD_BOX,
CLOUD_Y_MIN + rnd() * (CLOUD_Y_MAX - CLOUD_Y_MIN),
(rnd() - 0.5) * CLOUD_BOX
);
bases.push({ x: s.position.x, z: s.position.z });
sprites.push(s);
scene.add(s);
}
return { tex, mat, bases, sprites };
}
// ---------------------------------------------------------------------------
// buildEnvironment
// ---------------------------------------------------------------------------
export function buildEnvironment(scene, opts = {}) {
const shadows = !!opts.shadows;
const prevFog = scene.fog;
const prevBg = scene.background;
const sky = buildSky(scene);
const ground = buildGround(scene, shadows);
const runway = buildRunway(scene, shadows);
const water = buildWater(scene, shadows);
const clouds = buildClouds(scene);
// ---- lights ----
const sun = new THREE.DirectionalLight(0xfff1dc, SUN_INTENSITY);
const dl = Math.hypot(SUN_DIR[0], SUN_DIR[1], SUN_DIR[2]);
sun.position.set(
(SUN_DIR[0] / dl) * SUN_DIST,
(SUN_DIR[1] / dl) * SUN_DIST,
(SUN_DIR[2] / dl) * SUN_DIST
);
if (shadows) {
sun.castShadow = true;
sun.shadow.mapSize.set(4096, 4096);
sun.shadow.camera.near = 100;
sun.shadow.camera.far = 8000;
const d = 5200; // covers the whole 8 km world
sun.shadow.camera.left = -d;
sun.shadow.camera.right = d;
sun.shadow.camera.top = d;
sun.shadow.camera.bottom = -d;
sun.shadow.bias = -0.0006;
sun.shadow.normalBias = 0.6;
}
scene.add(sun);
scene.add(sun.target); // keep shadow/light matrices updated
const hemi = new THREE.HemisphereLight(0x9fc0e4, 0x7d7560, HEMI_INTENSITY);
scene.add(hemi);
// ---- fog + background (single haze colour, seamless with the sky dome) ----
scene.fog = new THREE.Fog(C_HORIZON.clone(), FOG_NEAR, FOG_FAR);
scene.background = C_HORIZON.clone();
const recenterClouds = (camPos) => {
const { sprites, bases } = clouds;
for (let i = 0; i < sprites.length; i++) {
const b = bases[i];
const sp = sprites[i];
// wrap every cloud into the 6000 m tile centred on the camera
sp.position.x = b.x + Math.round((camPos.x - b.x) / CLOUD_BOX) * CLOUD_BOX;
sp.position.z = b.z + Math.round((camPos.z - b.z) / CLOUD_BOX) * CLOUD_BOX;
}
};
function update(camPos, dt) {
recenterClouds(camPos);
const d = Math.min(Math.max(dt, 0), 0.1);
if (d > 0) {
// slow drifting water texture (no allocations, numbers only)
water.tex.offset.x = (water.tex.offset.x + d * 0.004) % 1;
water.tex.offset.y = (water.tex.offset.y + d * 0.002) % 1;
}
}
function dispose() {
scene.remove(sky.mesh, ground.mesh, runway.mesh, water.mesh, sun, sun.target, hemi);
for (const s of clouds.sprites) scene.remove(s);
sky.geo.dispose(); sky.mat.dispose();
ground.geo.dispose(); ground.mat.dispose();
runway.geo.dispose(); runway.mat.dispose(); runway.tex.dispose();
water.geo.dispose(); water.mat.dispose(); water.tex.dispose();
clouds.tex.dispose(); clouds.mat.dispose();
scene.fog = prevFog;
scene.background = prevBg;
}
return {
update,
groundHeight: (x, z) => getGroundHeight(x, z),
dispose,
};
}
+117
View File
@@ -0,0 +1,117 @@
// Procedural world: deterministic height field + runway. Pure module —
// Node-safe, no three.js, no DOM. Single source of truth for ground height,
// used by BOTH terrain rendering and physics ground collision.
export const WORLD = {
size: 8000, // world extent (m), -size/2..size/2
waterLevel: -0.4, // m; runway sits at y = 0, water slightly below
runway: {
cx: 0,
cz: 0, // runway runs along Z at x = 0, centered at origin
length: 2000, // m
width: 45, // m
elev: 0, // m, flattened runway elevation
},
spawn: {
x: 0,
z: 880, // south end of runway
headingDeg: 0, // facing north (-Z)
alt: 0,
},
flattenMargin: 70, // m of blending outside runway rectangle
};
// ---- deterministic value noise -------------------------------------------
function clamp01(v) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
function hash2(x, z) {
// 32-bit integer hash. Multiplicative pre-mix of x and z before the
// murmur-style finalizer so that SMALL integer cell indices (which is all a
// finite map window ever sees) still avalanche to a uniform 0..2^32 range.
let h = Math.imul(x | 0, 0x9E3779B1) ^ Math.imul(z | 0, 0x85EBCA77) ^ 0x7F4A7C15;
h = Math.imul(h ^ (h >>> 16), 2246822519);
h = Math.imul(h ^ (h >>> 13), 3266489917);
h ^= h >>> 16;
return (h >>> 0) / 4294967296; // 0..1
}
function smooth(t) {
return t * t * (3 - 2 * t);
}
function noise2(x, z) {
const xi = Math.floor(x);
const zi = Math.floor(z);
const fx = x - xi;
const fz = z - zi;
const a = hash2(xi, zi);
const b = hash2(xi + 1, zi);
const c = hash2(xi, zi + 1);
const d = hash2(xi + 1, zi + 1);
const ux = smooth(fx);
const uz = smooth(fz);
return a + (b - a) * ux + (c - a) * uz + (a - b - c + d) * ux * uz;
}
// Fractal value noise: broad rolling hills + mid hummocks + fine detail.
// Baseline +15 keeps most terrain above sea level with lakes in valleys.
function terrainNoise(x, z) {
const n1 = (noise2(x / 900, z / 900) - 0.5) * 150; // hilly backbone
const n2 = (noise2(x / 220, z / 220) - 0.5) * 40; // midsize hills
const n3 = (noise2(x / 55, z / 55) - 0.5) * 6; // fine detail
return 15 + n1 + n2 + n3;
}
// ---- runway flattening ----------------------------------------------------
export function onRunway(x, z, margin = 0) {
const r = WORLD.runway;
const hw = r.width / 2 + margin;
const hl = r.length / 2 + margin;
return Math.abs(x - r.cx) <= hw && Math.abs(z - r.cz) <= hl;
}
// Smooth blend factor 0 far outside runway, 1 inside (stays 1 on runway).
function runwayBlend(x, z) {
const r = WORLD.runway;
const m = WORLD.flattenMargin;
const dx = Math.abs(x - r.cx) - r.width / 2;
const dz = Math.abs(z - r.cz) - r.length / 2;
const tx = dx <= 0 ? 1 : 1 - smooth(Math.min(1, dx / m));
const tz = dz <= 0 ? 1 : 1 - smooth(Math.min(1, dz / m));
return Math.min(tx, tz); // rectangle overlap
}
// Broad "airfield envelope" around the runway: terrain amplitude is
// suppressed near the strip so a takeoff roll that drifts off the runway
// can't slam into a hillside (the old world had ~40 m terrain 90 m off the
// runway — a drift-off crashed the aircraft). 1 = full terrain, 0 = flat.
function airfieldEnvelope(x, z) {
const r = WORLD.runway;
const dx = Math.max(0, Math.abs(x - r.cx) - r.width / 2);
const dz = Math.max(0, Math.abs(z - r.cz) - r.length / 2);
const dist = Math.hypot(dx, dz);
const flatR = 130; // fully flat within this distance of the runway edge
const rampR = 380; // terrain amplitude returns by this distance
return clamp01((dist - flatR) / (rampR - flatR));
}
/** Ground elevation at world (x, z). Deterministic, continuous. */
export function getGroundHeight(x, z) {
const r = WORLD.runway;
const h = terrainNoise(x, z);
const t = runwayBlend(x, z);
const e = airfieldEnvelope(x, z);
// Blend terrain to runway elevation near the strip and fade the hills out
// under the airfield so approaches are gentle, not cliffs.
const scaled = h * e;
return r.elev + (scaled - r.elev) * (1 - t);
}
/** Small helper for terrain coloring / physics extras. */
export function isOnRunway(x, z, margin = 6) {
return onRunway(x, z, margin);
}
+74
View File
@@ -0,0 +1,74 @@
// Geometry sanity: the wing/stab chords must run AFT (−X), and flaps/
// ailerons/elevator must hinge at the actual trailing edges. Runs the vendored
// three.js in Node — no DOM needed.
import * as THREE from '../vendor/three.module.min.js';
import { buildAircraftModel } from '../src/aircraft-model.js';
const { group, animate } = buildAircraftModel();
const bbox = (m) => new THREE.Box3().setFromObject(m); // fresh Box3 per call
let fail = 0;
const check = (name, cond, detail) => {
console.log((cond ? 'PASS' : 'FAIL') + ' ' + name + (detail ? ' (' + detail + ')' : ''));
if (!cond) fail++;
};
// Identify meshes by geometry parameters.
const meshes = [];
group.traverse((o) => { if (o.isMesh) meshes.push(o); });
const byBox = (w, h, d) => meshes.filter((m) => {
const p = m.geometry.parameters;
return p && p.width === w && p.height === h && p.depth === d;
});
const wing = meshes.find((m) => m.geometry.type === 'ExtrudeGeometry' && Math.abs(m.position.y) < 0.001 && m.position.x > 0 && m.position.z > 0.5);
const stab = meshes.find((m) => m.geometry.type === 'ExtrudeGeometry' && m.position.x < -2 && m.position.z > -0.2);
const flaps = byBox(0.45, 1.15, 0.045);
const ails = byBox(0.42, 2.0, 0.045);
const elevs = byBox(0.32, 4.0, 0.06);
if (!wing || !stab) { console.log('FAIL could not locate wing/stab meshes'); process.exit(1); }
const wb = bbox(wing), sb = bbox(stab);
console.log('wing bbox: x [' + wb.min.x.toFixed(2) + ', ' + wb.max.x.toFixed(2) + '] z [' + wb.min.z.toFixed(2) + ', ' + wb.max.z.toFixed(2) + ']');
console.log('stab bbox: x [' + sb.min.x.toFixed(2) + ', ' + sb.max.x.toFixed(2) + ']');
check('wing chord runs AFT (LE x=0.65 > TE x≈−1.30)', Math.abs(wb.max.x - 0.65) < 0.02 && wb.min.x < -1.25, 'LE=' + wb.max.x.toFixed(2) + ' TE=' + wb.min.x.toFixed(2));
const wingTE = wb.min.x;
for (const f of flaps) {
const fb = bbox(f);
check('flap hinges at wing TE and sweeps AFT', Math.abs(fb.max.x - wingTE) < 0.03 && fb.min.x < fb.max.x - 0.4,
'hinge=' + fb.max.x.toFixed(2) + ' aftEnd=' + fb.min.x.toFixed(2));
check('flap sits behind the wing LEADING edge (not at the front!)', fb.max.x < wb.max.x - 1.5);
}
for (const a of ails) {
const ab = bbox(a);
check('aileron hinges at wing TE and sweeps AFT', ab.max.x > -1.35 && ab.max.x < -1.1 && ab.min.x < ab.max.x - 0.35,
'hinge=' + ab.max.x.toFixed(2));
check('aileron behind the wing LE', ab.max.x < wb.max.x - 1.5);
}
check('two flaps + two ailerons built', flaps.length === 2 && ails.length === 2, flaps.length + '/' + ails.length);
const elev = elevs[0];
if (elev) {
const eb = bbox(elev);
check('elevator hinges at stab TE (−3.08) and sweeps AFT',
Math.abs(eb.max.x - sb.min.x) < 0.03 && eb.min.x < eb.max.x - 0.25,
'hinge=' + eb.max.x.toFixed(2) + ' stabTE=' + sb.min.x.toFixed(2));
}
// Deflection directions: drive animate() and read the sign of each surface.
const inputs = { elevator: 1, aileron: 1, rudder: 0.5, flaps: 1, gear: true, brakes: false };
animate({ rpm: 0.5, flaps: 1, gearDown: true }, inputs, 0.1);
const find = (w, h, d) => byBox(w, h, d)[0];
const elevMesh = find(0.32, 4.0, 0.06);
const flapMesh = find(0.45, 1.15, 0.045);
const ailS = ails.find((m) => m.position.y > 0);
const ailP = ails.find((m) => m.position.y < 0);
check('elevator TE UP on nose-up command (rotation.y < 0)', elevMesh.rotation.y < 0, elevMesh.rotation.y.toFixed(3));
check('flaps TE DOWN when deployed (rotation.y > 0)', flapMesh.rotation.y > 0, flapMesh.rotation.y.toFixed(3));
check('starboard aileron TE UP on positive input (rotation.y < 0)', ailS.rotation.y < 0, ailS.rotation.y.toFixed(3));
check('port aileron TE DOWN on positive input (rotation.y > 0)', ailP.rotation.y > 0, ailP.rotation.y.toFixed(3));
console.log(fail === 0 ? '\nALL GEOMETRY CHECKS PASSED' : '\n' + fail + ' CHECK(S) FAILED');
process.exit(fail === 0 ? 0 : 1);
+451
View File
@@ -0,0 +1,451 @@
// tests/physics.test.mjs — deterministic unit tests for src/physics.js.
// Run: node --test tests/physics.test.mjs
//
// Covers the 7 required SPEC tests plus brutallly-honest extras:
// instrument completeness/ranges, 3000 m / 80% cruise envelope, rudder turn
// direction consistency, gear-up vs gear-down drag, monotonic fuel, and
// exact determinism across substep splits.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createAircraft, step, resetAircraft,
getInstrumentData, getAeroSummary,
} from '../src/physics.js';
import { cfg, atmoDensity } from '../src/config.js';
import { getGroundHeight, WORLD } from '../src/world.js';
const DT = 1 / 120; // caller-guaranteed fixed step (main.js FIXED_DT)
const KTS = 1.94384;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Quaternion [w,x,y,z]: level-north (heading 0, wings level) then pitch up. */
export function levelNoseUpQuat(pitchRad) {
const qp = Math.cos(pitchRad / 2), sp = Math.sin(pitchRad / 2);
const LQ = [0.5, 0.5, 0.5, -0.5]; // level-north (validated vs three.js r160)
return [
qp * LQ[0] - sp * LQ[1],
qp * LQ[1] + sp * LQ[0],
qp * LQ[2] + sp * LQ[3],
qp * LQ[3] - sp * LQ[2],
];
}
/**
* Build a steady level-cruise state at `altM`: lift = weight, thrust = drag,
* zero pitching moment, zero rates (analytic trim). Returns the aircraft, the
* inputs snapshot that holds the trim, and the design cruise speed Vt.
*/
export function cruiseState(altM, throttle = 0.7) {
const rho = atmoDensity(altM);
const W = cfg.mass * cfg.g;
const alphaT = 0.010; // rad trim alpha (small positive AOA)
const CLt = cfg.CL0 + cfg.CLalpha * alphaT;
const Vt = Math.sqrt((2 * W) / (rho * cfg.S * CLt)); // L = W
const CDt = cfg.CD0 + cfg.kInd * CLt * CLt;
const Dt = 0.5 * rho * Vt * Vt * cfg.S * CDt;
const rpmNeeded = Dt / (cfg.Tmax * Math.pow(rho / 1.225, 0.7));
const rpm = Math.max(throttle, rpmNeeded); // keep the engine spooled at set point
const deT = -(cfg.Cm0 + cfg.CmAlpha * alphaT) / cfg.Cmδe; // Cm = 0 at q̇=0
const elevT = -deT / cfg.dE_max; // δe = −elev·dE_max (input is the stick)
assert.ok(rpm <= 1, `cruise rpm ${rpm} must not exceed 1`);
const a = createAircraft();
a.pos = [0, altM, 0];
a.quat = levelNoseUpQuat(alphaT);
// Level flight at pitch αt: body velocity has downward-body component +V·sinαt.
a.vel = [Vt * Math.cos(alphaT), 0, Vt * Math.sin(alphaT)];
a.rpm = rpm;
a.throttle = rpm;
a.flaps = 0;
a.gearDown = false;
a.brakes = false;
const inputs = {
elevator: elevT, aileron: 0, rudder: 0, throttle: rpm,
flaps: 0, gear: false, brakes: false,
};
return { a, inputs, Vt };
}
function run(a, inputs, seconds, dt = DT) {
const n = Math.round(seconds / dt);
for (let i = 0; i < n; i++) step(a, inputs, dt);
}
/** Unwrapped heading change (deg) over the run, monitoring each step. */
function headingDrift(a, inputs, seconds) {
let unwrap = 0;
let prev = getInstrumentData(a).headingDeg;
for (let s = 0; s < Math.round(seconds); s++) {
run(a, inputs, 1);
const h = getInstrumentData(a).headingDeg;
unwrap += (h - prev + 540) % 360 - 180;
prev = h;
}
return unwrap;
}
// ---------------------------------------------------------------------------
// 1. Trim / cruise: 1000 m, throttle 0.7, 20 s → airspeed within ±5% of the
// analytic design-cruise speed, and inside 120–180 kts.
// ---------------------------------------------------------------------------
test('trim/cruise holds design airspeed at 1000 m (70% throttle)', () => {
const { a, inputs, Vt } = cruiseState(1000, 0.7);
assert.ok(Vt * KTS >= 120 && Vt * KTS <= 180,
`design cruise ${(Vt * KTS).toFixed(0)} kts must be in 120-180 kts`);
run(a, inputs, 20);
const d = getInstrumentData(a);
assert.ok(Math.abs(d.airspeedMps - Vt) / Vt <= 0.05,
`airspeed ${d.airspeedMps.toFixed(1)} m/s vs cruise ${Vt.toFixed(1)} ` +
`(${(Math.abs(d.airspeedMps - Vt) / Vt * 100).toFixed(1)}%)`);
assert.ok(d.airspeedKts >= 120 && d.airspeedKts <= 180,
`airspeed ${d.airspeedKts.toFixed(0)} kts must be within 120-180 kts`);
// altitude should stay near the cruise level (no uncontrolled dive/climb)
assert.ok(Math.abs(d.altM - 1000) < 150, `altitude ${d.altM.toFixed(0)} m drifted`);
});
// ---------------------------------------------------------------------------
// 2. Stall: full up elevator, 30% throttle → α > α_crit within 25 s, stall
// flag true.
// ---------------------------------------------------------------------------
test('full up elevator at 30% throttle stalls within 25 s', () => {
const { a, inputs } = cruiseState(1000, 0.7);
const stall = { elevator: 1, aileron: 0, rudder: 0, throttle: 0.3,
flaps: 0, gear: false, brakes: false };
let sawStall = false;
let maxAlpha = 0;
for (let s = 0; s < 25; s++) {
run(a, stall, 1);
const d = getInstrumentData(a);
maxAlpha = Math.max(maxAlpha, Math.abs(d.alphaDeg));
if (d.stall) sawStall = true;
}
assert.ok(maxAlpha > cfg.alphaCrit * (180 / Math.PI),
`alpha never exceeded alpha_crit (max |alpha| ${maxAlpha.toFixed(1)}°)`);
assert.ok(sawStall, 'stall flag never became true within 25 s');
});
// ---------------------------------------------------------------------------
// 3. Inverted sign: at α = −10° the lift coefficient (and lift force) is
// negative — lift points down.
// ---------------------------------------------------------------------------
test('inverted sign: CL < 0 at alpha = -10 deg (lift points down)', () => {
const a = createAircraft();
a.pos = [0, 1000, 0];
a.flaps = 0;
a.gearDown = false;
const V = 60;
const aN = -10 * Math.PI / 180;
a.vel = [V * Math.cos(aN), 0, V * Math.sin(aN)]; // vz > 0 → alpha = −10°
const s = getAeroSummary(a);
assert.ok(Math.abs(s.alpha - aN) < 1e-9, `alpha ${s.alpha} != -10 deg`);
assert.ok(s.cl < 0, `cl ${s.cl.toFixed(3)} must be negative at alpha=-10`);
assert.ok(s.liftN < 0, `liftN ${s.liftN.toFixed(0)} must point down`);
// mirror check: at +10° the sign flips
a.vel = [V * Math.cos(-aN), 0, V * Math.sin(-aN)];
const s2 = getAeroSummary(a);
assert.ok(s2.cl > 0 && s2.liftN > 0, 'lift must be positive at alpha=+10');
});
// ---------------------------------------------------------------------------
// 4. Ground stop: spawn on runway, gear down, brakes → rest in < 400 m and
// never sink below ground − 0.1.
// ---------------------------------------------------------------------------
test('braked ground stop from 30 m/s < 400 m, never below ground', () => {
const a = createAircraft(); // spawns on the runway, gear down, brakes on
a.rpm = 0; // dead-stick: brakes alone must stop the airframe
a.engineOn = false;
a.vel = [30, 0, 0]; // north-bound (body +X at heading 0 = world −Z)
const inputs = { elevator: 0, aileron: 0, rudder: 0, throttle: 0,
flaps: 0.25, gear: true, brakes: true };
const groundAt = getGroundHeight(a.pos[0], a.pos[2]);
const start = [a.pos[0], a.pos[2]];
let minY = Infinity;
for (let s = 0; s < 60; s++) {
run(a, inputs, 1);
minY = Math.min(minY, a.pos[1]);
}
const dist = Math.hypot(a.pos[0] - start[0], a.pos[2] - start[1]);
assert.ok(Math.hypot(...a.vel) < 0.05, `still moving: V=${Math.hypot(...a.vel)}`);
assert.ok(dist < 400, `stopping distance ${dist.toFixed(0)} m >= 400`);
assert.ok(minY >= groundAt - 0.1, `CG sank to ${minY.toFixed(3)} m`);
});
// ---------------------------------------------------------------------------
// 5. Heavy impact: vertical sink 8 m/s with gear up → crashed.
// ---------------------------------------------------------------------------
test('vertical impact at 8 m/s with gear up crashes', () => {
const a = createAircraft();
a.pos = [0, cfg.wheelH + 2, WORLD.spawn.z];
a.groundContact = false;
a.airTime = 1;
a.vel = [0, 0, 8]; // body +Z = world down at level attitude
a.gearDown = false;
const inputs = { elevator: 0, aileron: 0, rudder: 0, throttle: 0,
flaps: 0.25, gear: false, brakes: true };
run(a, inputs, 1);
assert.ok(a.crashed, 'aircraft must crash on a hard gear-up impact');
assert.ok(a.impactSpeed >= 7,
`impactSpeed ${a.impactSpeed.toFixed(2)} should be ~ the 8 m/s sink`);
// sanity: gear-down version of the same descent is survivable
const b = createAircraft();
b.pos = [0, cfg.wheelH + 2, WORLD.spawn.z];
b.groundContact = false;
b.airTime = 1;
b.vel = [0, 0, 8];
b.gearDown = true;
const insB = { elevator: 0, aileron: 0, rudder: 0, throttle: 0,
flaps: 0.25, gear: true, brakes: true };
run(b, insB, 1);
// 8 m/s > maxTouchVz (3) with gear down is also a crash by the SPEC rule —
// this is just a hard landing, allowed only ≤ 3 m/s.
assert.ok(b.crashed, '8 m/s still exceeds the gear-down 3 m/s limit');
});
// ---------------------------------------------------------------------------
// 6. Quaternion unit norm after 2000 substeps.
// ---------------------------------------------------------------------------
test('quaternion stays unit-norm after 2000 substeps', () => {
const a = createAircraft();
a.pos = [0, 1200, 0];
a.flaps = 0;
a.gearDown = false;
a.angVel = [1.2, -0.8, 1.5];
a.vel = [40, 5, -10];
const inputs = { elevator: 0.7, aileron: -0.9, rudder: 0.4, throttle: 0.5,
flaps: 0, gear: false, brakes: false };
for (let i = 0; i < 2000; i++) step(a, inputs, 1 / 240);
const n = Math.hypot(a.quat[0], a.quat[1], a.quat[2], a.quat[3]);
assert.ok(Math.abs(n - 1) <= 1e-6, `|q| = ${n} drifted by ${(n - 1).toExponential(2)}`);
});
// ---------------------------------------------------------------------------
// 7. Lateral symmetry: 3 s full roll right vs roll left → |bank| within 2%.
// (Engine off so prop torque/gyro asymmetry cannot bias the comparison.)
// ---------------------------------------------------------------------------
test('roll-right vs roll-left for 3 s give symmetric |bank| (≤2%)', () => {
function roll(sign) {
const a = createAircraft();
a.pos = [0, 1500, 0];
a.flaps = 0;
a.gearDown = false;
a.rpm = 0;
a.throttle = 0;
a.engineOn = false;
a.vel = [60, 0, 0];
const inputs = { elevator: 0, aileron: sign, rudder: 0, throttle: 0,
flaps: 0, gear: false, brakes: false };
run(a, inputs, 3);
return Math.abs(getInstrumentData(a).bankDeg);
}
const bR = roll(1);
const bL = roll(-1);
const base = Math.min(bR, bL);
assert.ok(base > 10, `roll was too weak (|bank| ${base.toFixed(1)}°)`);
assert.ok(Math.abs(bR - bL) / base <= 0.02,
`|bank| right ${bR.toFixed(1)}° vs left ${bL.toFixed(1)}° differ > 2%`);
});
// ---------------------------------------------------------------------------
// 8. getInstrumentData completeness + sane ranges (every SPEC field).
// ---------------------------------------------------------------------------
test('getInstrumentData returns every SPEC field with sane values', () => {
const required = [
'airspeedMps', 'airspeedKts', 'altM', 'altFt', 'vsMps', 'vsFpm',
'headingDeg', 'pitchDeg', 'bankDeg', 'rollRateDps', 'pitchRateDps', 'yawRateDps',
'alphaDeg', 'betaDeg', 'gLoad', 'stall', 'engineOn', 'rpm', 'throttle',
'flapsPos', 'gearDown', 'brakes', 'fuelKg', 'groundContact', 'landed',
'crashed', 'onGround', 'rolling', 'airTimeS',
];
// parked state
const parked = getInstrumentData(createAircraft());
for (const k of required) {
assert.ok(k in parked, `missing field ${k}`);
assert.ok(Number.isFinite(parked[k]) || typeof parked[k] === 'boolean',
`non-finite ${k}`);
}
// airborne cruise state
const { a, inputs } = cruiseState(1000, 0.7);
run(a, inputs, 5);
const d = getInstrumentData(a);
for (const k of required) {
assert.ok(Number.isFinite(d[k]) || typeof d[k] === 'boolean', `${k} bad`);
}
assert.ok(d.airspeedKts > 100 && d.airspeedKts < 200, `airspeedKts ${d.airspeedKts}`);
assert.ok(d.altM > 900 && d.altM < 1100, `altM ${d.altM}`);
assert.ok(d.rpm >= 0 && d.rpm <= 1, `rpm ${d.rpm}`);
assert.ok(d.throttle >= 0 && d.throttle <= 1, `throttle ${d.throttle}`);
assert.ok(d.flapsPos >= 0 && d.flapsPos <= 1, `flapsPos ${d.flapsPos}`);
assert.ok(d.fuelKg > 0 && d.fuelKg <= cfg.fuelKg, `fuelKg ${d.fuelKg}`);
assert.ok(Math.abs(d.alphaDeg) < 45, `alphaDeg ${d.alphaDeg}`);
assert.ok(Math.abs(d.betaDeg) < 45, `betaDeg ${d.betaDeg}`);
assert.ok(d.gLoad > 0.5 && d.gLoad < 3, `gLoad ${d.gLoad}`);
assert.ok(d.engineOn === true, 'engine must be running in cruise');
assert.ok(d.crashed === false && d.groundContact === false, 'must be airborne');
});
// ---------------------------------------------------------------------------
// 9. Envelope: cruise at 3000 m @ 80% throttle for 30 s stays on-trim,
// never stalls, fuel depletes monotonically.
// ---------------------------------------------------------------------------
test('3000 m / 80% cruise stays within envelope (V, alt, no stall, fuel)', () => {
const { a, inputs, Vt } = cruiseState(3000, 0.8);
assert.ok(Vt * KTS >= 120 && Vt * KTS <= 180, `3000 m cruise ${Vt * KTS} kts`);
let minAlt = Infinity, maxAlt = -Infinity, stallSeen = false;
const fuel0 = a.fuelKg;
let prevFuel = fuel0;
for (let s = 0; s < 60; s++) { // 60 × 0.5 s = 30 s
run(a, inputs, 0.5);
const d = getInstrumentData(a);
minAlt = Math.min(minAlt, d.altM);
maxAlt = Math.max(maxAlt, d.altM);
if (d.stall) stallSeen = true;
assert.ok(d.fuelKg <= prevFuel + 1e-12, 'fuel must never increase');
prevFuel = d.fuelKg;
}
const d = getInstrumentData(a);
assert.ok(Math.abs(d.airspeedMps - Vt) / Vt <= 0.05,
`airspeed ${d.airspeedMps.toFixed(1)} m/s vs ${Vt.toFixed(1)} m/s`);
assert.ok(Math.abs(maxAlt - 3000) <= 200 && Math.abs(3000 - minAlt) <= 200,
`altitude drifted to [${minAlt.toFixed(0)}, ${maxAlt.toFixed(0)}] m`);
assert.ok(!stallSeen, 'cruise must not stall');
const consumed = fuel0 - a.fuelKg;
assert.ok(consumed > 0.8,
`fuel barely burned (${consumed.toFixed(2)} kg in 30 s @ 80%)`);
});
// ---------------------------------------------------------------------------
// 10. Rudder: full-ish right rudder for 5 s → consistent right turn
// (unwrapped heading advance, every second segment still right).
// ---------------------------------------------------------------------------
test('right rudder 5 s produces a consistent clockwise turn', () => {
const a = createAircraft();
a.pos = [0, 1500, 0];
a.flaps = 0;
a.gearDown = false;
a.rpm = 0;
a.throttle = 0;
a.engineOn = false;
a.vel = [60, 0, 0];
const inputs = { elevator: 0, aileron: 0, rudder: 0.5, throttle: 0,
flaps: 0, gear: false, brakes: false };
let unwrap = 0, prev = getInstrumentData(a).headingDeg;
const segments = [];
for (let s = 0; s < 5; s++) {
run(a, inputs, 1);
const h = getInstrumentData(a).headingDeg;
const dh = (h - prev + 540) % 360 - 180;
segments.push(dh);
unwrap += dh;
prev = h;
}
assert.ok(unwrap > 20, `right rudder must turn right (>20°), got ${unwrap.toFixed(1)}°`);
assert.ok(segments.every((d) => d >= -1),
`heading reversed mid-turn: ${segments.map((x) => x.toFixed(1)).join(', ')}`);
// mirror: left rudder must turn left
const b = createAircraft();
b.pos = [0, 1500, 0];
b.flaps = 0; b.gearDown = false; b.rpm = 0; b.throttle = 0; b.engineOn = false;
b.vel = [60, 0, 0];
const insL = { elevator: 0, aileron: 0, rudder: -0.5, throttle: 0,
flaps: 0, gear: false, brakes: false };
const dLeft = headingDrift(b, insL, 5);
assert.ok(dLeft < -20, `left rudder must turn left, got ${dLeft.toFixed(1)}°`);
});
// ---------------------------------------------------------------------------
// 11. Gear drag: at the same state, drag with gear down is measurably larger.
// ---------------------------------------------------------------------------
test('gear-down drag exceeds gear-up drag (>30%) at cruise speed', () => {
const mk = (gear) => {
const a = createAircraft();
a.pos = [0, 1500, 0];
a.flaps = 0;
a.gearDown = gear;
a.rpm = 0.3; a.throttle = 0.3; a.engineOn = true;
a.vel = [70, 0, 0];
return a;
};
const up = getAeroSummary(mk(false));
const down = getAeroSummary(mk(true));
assert.ok(down.dragN > up.dragN * 1.3,
`gear drag too small: up ${up.dragN.toFixed(0)} N vs down ${down.dragN.toFixed(0)} N`);
// gear drag also raises total CD measurably
assert.ok(down.cd > up.cd + cfg.dCD_gear * 0.5,
`CD delta too small: ${up.cd.toFixed(4)} -> ${down.cd.toFixed(4)}`);
});
// ---------------------------------------------------------------------------
// 12. Fuel depletes monotonically (and monotonically over a long cruise).
// ---------------------------------------------------------------------------
test('fuel depletes monotonically; enough burned in 120 s cruise', () => {
const { a, inputs } = cruiseState(1000, 0.7);
const fuel0 = a.fuelKg;
let prev = fuel0;
for (let i = 0; i < 240; i++) { // 2 s at 1/120
step(a, inputs, 1 / 120);
assert.ok(a.fuelKg <= prev + 1e-12, `fuel increased at step ${i}`);
prev = a.fuelKg;
}
assert.ok(fuel0 - a.fuelKg > 0.05,
`only ${(fuel0 - a.fuelKg).toFixed(3)} kg burned in 2 s of cruise ` +
`(burn rate ${(cfg.fuelBurn * 0.7 * 0.7).toFixed(4)} kg/s)`);
});
// ---------------------------------------------------------------------------
// 13. Determinism: two runs split differently (1/120 vs 1/240 steps) produce
// bit-identical state; NaN/Infinity inputs cannot poison the sim.
// ---------------------------------------------------------------------------
test('deterministic across substep splits; NaN inputs are harmless', () => {
const mk = () => {
const a = createAircraft();
a.pos = [0, 1200, 0];
a.flaps = 0; a.gearDown = false; a.rpm = 0.5; a.throttle = 0.5;
a.vel = [50, 2, -3]; a.angVel = [0.1, -0.2, 0.3];
return a;
};
const inputs = { elevator: 0.2, aileron: 0.3, rudder: -0.1, throttle: 0.5,
flaps: 0, gear: false, brakes: false };
const a1 = mk(), a2 = mk();
for (let i = 0; i < 480; i++) step(a1, inputs, 1 / 240);
for (let i = 0; i < 240; i++) step(a2, inputs, 1 / 120);
for (const k of ['pos', 'vel', 'angVel', 'quat']) {
for (let j = 0; j < a1[k].length; j++) {
assert.ok(a1[k][j] === a2[k][j],
`${k}[${j}] differs: ${a1[k][j]} vs ${a2[k][j]}`);
}
}
// NaN inputs must not poison the state
const a3 = createAircraft();
step(a3, { elevator: NaN, aileron: Infinity, rudder: NaN, throttle: NaN }, 1 / 120);
const d = getInstrumentData(a3);
assert.ok(Number.isFinite(d.pitchDeg) && Number.isFinite(d.bankDeg) &&
Number.isFinite(d.airspeedMps), 'sim state must stay finite after bad inputs');
});
// ---------------------------------------------------------------------------
// 14. resetAircraft: returns the aircraft to the spawn envelope (parked,
// level, on the runway, fuel restored).
// ---------------------------------------------------------------------------
test('resetAircraft restores spawn state exactly', () => {
const a = createAircraft();
a.pos = [100, 2000, -500];
a.vel = [40, 5, -3];
a.angVel = [0.5, 0.2, -0.4];
a.fuelKg = 3;
a.crashed = true;
a.airTime = 99;
resetAircraft(a);
const d = getInstrumentData(a);
assert.deepEqual(a.pos, a.spawnPos);
assert.deepEqual(a.quat, a.spawnQuat);
assert.ok(Math.hypot(...a.vel) === 0 && Math.hypot(...a.angVel) === 0);
assert.ok(a.fuelKg === cfg.fuelKg && a.crashed === false && a.airTime === 0);
assert.ok(d.groundContact === true);
assert.ok(Math.abs(d.bankDeg) < 1 && Math.abs(d.pitchDeg) < 1,
`reset attitude off: pitch ${d.pitchDeg} bank ${d.bankDeg}`);
});
+6
View File
@@ -0,0 +1,6 @@
/**
* @license
* Copyright 2010-2023 Three.js Authors
* SPDX-License-Identifier: MIT
*/
const t="160",e={LEFT:0,MIDDLE:1,RIGHT:2,ROTATE:0,DOLLY:1,PAN:2},n={ROTATE:0,PAN:1,DOLLY_PAN:2,DOLLY_ROTATE:3},i=0,r=1,s=2,a=3,o=0,l=1,c=2,h=3,u=0,d=1,p=2,m=2,f=0,g=1,_=2,v=3,x=4,y=5,M=100,S=101,b=102,E=103,T=104,w=200,A=201,R=202,C=203,P=204,L=205,I=206,U=207,N=208,D=209,O=210,F=211,B=212,z=213,H=214,V=0,k=1,G=2,W=3,X=4,j=5,q=6,Y=7,Z=0,J=1,K=2,$=0,Q=1,tt=2,et=3,nt=4,it=5,rt=6,st="attached",at="detached",ot=300,lt=301,ct=302,ht=303,ut=304,dt=306,pt=1e3,mt=1001,ft=1002,gt=1003,_t=1004,vt=1004,xt=1005,yt=1005,Mt=1006,St=1007,bt=1007,Et=1008,Tt=1008,wt=1009,At=1010,Rt=1011,Ct=1012,Pt=1013,Lt=1014,It=1015,Ut=1016,Nt=1017,Dt=1018,Ot=1020,Ft=1021,Bt=1023,zt=1024,Ht=1025,Vt=1026,kt=1027,Gt=1028,Wt=1029,Xt=1030,jt=1031,qt=1033,Yt=33776,Zt=33777,Jt=33778,Kt=33779,$t=35840,Qt=35841,te=35842,ee=35843,ne=36196,ie=37492,re=37496,se=37808,ae=37809,oe=37810,le=37811,ce=37812,he=37813,ue=37814,de=37815,pe=37816,me=37817,fe=37818,ge=37819,_e=37820,ve=37821,xe=36492,ye=36494,Me=36495,Se=36283,be=36284,Ee=36285,Te=36286,we=2200,Ae=2201,Re=2202,Ce=2300,Pe=2301,Le=2302,Ie=2400,Ue=2401,Ne=2402,De=2500,Oe=2501,Fe=0,Be=1,ze=2,He=3e3,Ve=3001,ke=3200,Ge=3201,We=0,Xe=1,je="",qe="srgb",Ye="srgb-linear",Ze="display-p3",Je="display-p3-linear",Ke="linear",$e="srgb",Qe="rec709",tn="p3",en=0,nn=7680,rn=7681,sn=7682,an=7683,on=34055,ln=34056,cn=5386,hn=512,un=513,dn=514,pn=515,mn=516,fn=517,gn=518,_n=519,vn=512,xn=513,yn=514,Mn=515,Sn=516,bn=517,En=518,Tn=519,wn=35044,An=35048,Rn=35040,Cn=35045,Pn=35049,Ln=35041,In=35046,Un=35050,Nn=35042,Dn="100",On="300 es",Fn=1035,Bn=2e3,zn=2001;class Hn{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e<i;e++)n[e].call(this,t);t.target=null}}}const Vn=["00","01","02","03","04","05","06","07","08","09","0a","0b","0c","0d","0e","0f","10","11","12","13","14","15","16","17","18","19","1a","1b","1c","1d","1e","1f","20","21","22","23","24","25","26","27","28","29","2a","2b","2c","2d","2e","2f","30","31","32","33","34","35","36","37","38","39","3a","3b","3c","3d","3e","3f","40","41","42","43","44","45","46","47","48","49","4a","4b","4c","4d","4e","4f","50","51","52","53","54","55","56","57","58","59","5a","5b","5c","5d","5e","5f","60","61","62","63","64","65","66","67","68","69","6a","6b","6c","6d","6e","6f","70","71","72","73","74","75","76","77","78","79","7a","7b","7c","7d","7e","7f","80","81","82","83","84","85","86","87","88","89","8a","8b","8c","8d","8e","8f","90","91","92","93","94","95","96","97","98","99","9a","9b","9c","9d","9e","9f","a0","a1","a2","a3","a4","a5","a6","a7","a8","a9","aa","ab","ac","ad","ae","af","b0","b1","b2","b3","b4","b5","b6","b7","b8","b9","ba","bb","bc","bd","be","bf","c0","c1","c2","c3","c4","c5","c6","c7","c8","c9","ca","cb","cc","cd","ce","cf","d0","d1","d2","d3","d4","d5","d6","d7","d8","d9","da","db","dc","dd","de","df","e0","e1","e2","e3","e4","e5","e6","e7","e8","e9","ea","eb","ec","ed","ee","ef","f0","f1","f2","f3","f4","f5","f6","f7","f8","f9","fa","fb","fc","fd","fe","ff"];let kn=1234567;const Gn=Math.PI/180,Wn=180/Math.PI;function Xn(){const t=4294967295*Math.random()|0,e=4294967295*Math.random()|0,n=4294967295*Math.random()|0,i=4294967295*Math.random()|0;return(Vn[255&t]+Vn[t>>8&255]+Vn[t>>16&255]+Vn[t>>24&255]+"-"+Vn[255&e]+Vn[e>>8&255]+"-"+Vn[e>>16&15|64]+Vn[e>>24&255]+"-"+Vn[63&n|128]+Vn[n>>8&255]+"-"+Vn[n>>16&255]+Vn[n>>24&255]+Vn[255&i]+Vn[i>>8&255]+Vn[i>>16&255]+Vn[i>>24&255]).toLowerCase()}function jn(t,e,n){return Math.max(e,Math.min(n,t))}function qn(t,e){return(t%e+e)%e}function Yn(t,e,n){return(1-n)*t+n*e}function Zn(t){return 0==(t&t-1)&&0!==t}function Jn(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}function Kn(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return t/4294967295;case Uint16Array:return t/65535;case Uint8Array:return t/255;case Int32Array:return Math.max(t/2147483647,-1);case Int16Array:return Math.max(t/32767,-1);case Int8Array:return Math.max(t/127,-1);default:throw new Error("Invalid component type.")}}function $n(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return Math.round(4294967295*t);case Uint16Array:return Math.round(65535*t);case Uint8Array:return Math.round(255*t);case Int32Array:return Math.round(2147483647*t);case Int16Array:return Math.round(32767*t);case Int8Array:return Math.round(127*t);default:throw new Error("Invalid component type.")}}const Qn={DEG2RAD:Gn,RAD2DEG:Wn,generateUUID:Xn,clamp:jn,euclideanModulo:qn,mapLinear:function(t,e,n,i,r){Line truncated