3D flight simulator: 6-DOF physics, three.js r160, browser HUD
- Detailed flight dynamics: aero lift/drag/side-force with smooth stall, control-surface moments, stability derivatives, engine spool with prop torque and gyro precession, inertial coupling, ISA atmosphere, ground effect, 3-point gear handling, landing vs crash classification. - Pure ESM, zero build step; three.js vendored locally. Deterministic simulation; physics core is Node-testable (14 node:test cases). - Procedural 8 km world with runway, water, sky dome, cloud wrap; DOM instrument panel (attitude indicator, tapes, VSI, lamps, stall warning). - Controls: WASD/arrows, Q/E rudder, Shift/Ctrl throttle, F flaps, G gear, B brakes, C camera (chase/cockpit/orbit), R reset, P pause, D debug.
This commit is contained in:
commit
8c19cdaae6
18 files changed
+4499
No files matched your search
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.smoke*/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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 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>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "flight-sim",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
Vendored
+405
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
});
|
||||
Vendored
+6
@@ -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
|
||||
Reference in new issue
Block a user