[lane B] Round 1: flight controller, cannon, enemy framework on the stub

Flight (js/flight/): tube-mode controller in spline space (s,x,y) — flow-locked
forward motion, throttle as a spring-back lean, boost + i-frames, banking chase
cam on the parallel-transport frame, arcade wall response (shove + graze damage,
never a hard stop). Twin-stick input: WASD/left-stick moves the ship in the disc,
mouse/right-stick aims, Shift/Ctrl is throttle. Gamepad supported.

Combat (js/combat/): lysozyme cannon (pooled, instanced, heat-limited with
hysteresis lockout) + antacid torpedo emitting level:neutralize. Enemy framework
with floater/seeker/turret and turn-rate-limited homing darts, spawned from C's
level:event. Coat/hull model and the full bus surface E builds against.

Measured (deterministic stepper): flow-lock exact (3s at flow 14 -> s=44.0);
boost peak 30.8 u/s; heat 4s fire -> 2s lockout; wall slam 13.3 coat at 11.3 u/s
with forward speed untouched; 10 combat draws at 55 live enemies (budget <=80);
0.10 ms/frame CPU; 0 leaks over 20 create/dispose cycles. fps NOT claimed — rAF
does not run in the automated pane. Evidence: docs/shots/laneB/.

Three findings escalated in LANE_B_NOTES:
- qa.sh gate #1 is a no-op: node --check silently exits 0 on ESM, so the syntax
  gate has never checked anything. I shipped a real SyntaxError past a GREEN qa.
  Fix + verification handed to F.
- Surf is structurally broken: the peristalsis wave (13.64 u/s) is slower than
  the player (19.6 u/s), so level 2's signature mechanic loses to mashing
  throttle. Not tunable from this lane; escalated to A/F with options.
- Custom ShaderMaterials need #include <colorspace_fragment>, else ART_BIBLE's
  hostile amber reaches the display as rgb(255,26,6). Fixed here; stub/walls
  inherit it.

js/flight/dev.html is a dev harness (boot.js has no player wiring yet); it
retires once F pastes the snippet in LANE_B_NOTES.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jing 2026-07-16 08:56:32 +10:00
parent 1b5ec767cd
commit 0a038582a7
17 changed files with 1843 additions and 0 deletions

236
docs/LANES/LANE_B_NOTES.md Normal file
View File

@ -0,0 +1,236 @@
# LANE B — Flight & Combat · NOTES
Round 1. Owner: Lane B. Territory: `web/js/flight/**`, `web/js/combat/**`.
Everything below was measured on the **stub** (`js/stub/world_stub.js`), per ROUND1 §Lane B.
---
## What landed
| file | what |
|---|---|
| `flight/tuning.js` | **every** flight feel number, with rationale. The deliverable. |
| `flight/input.js` | devices → one `intent`. Twin-stick: WASD/stick = disc, mouse/stick = aim. |
| `flight/player.js` | tube controller (s,x,y), chase cam, coat/hull, wall response, bus emissions |
| `flight/ship.js` | ENDO-1 procedural hull + reticle (`assets.get('models','endo1')` upgrade path) |
| `flight/emissive.js` | the one material behind every glowing thing B draws |
| `combat/balance.js` | every combat number |
| `combat/weapons.js` | lysozyme cannon + antacid torpedo, pooled/instanced, heat |
| `combat/enemies.js` | registry + floater/seeker/turret + homing darts |
| `combat/index.js` | `createCombat` — composes the above, consumes `level:event` |
| `flight/dev.html/.js` | **dev harness** (see → Lane F #1; retire once boot wires the player) |
Round-1 asks 14 are done. Arena/6DOF is round 2 as instructed.
---
## Feel numbers (the deliverable)
The full set with rationale lives in `flight/tuning.js` + `combat/balance.js`. The load-bearing ones:
- **Forward is never yours.** `ds/dt = flow × (throttle + surf + boost)`. Throttle ∈ [0.6, 1.4] and
springs back to 1.0 — it's a lean, not a button you hold. No stop, no reverse, ever.
- **Throttle is NOT WASD.** WASD/left-stick moves the ship in the cross-section disc; mouse/right-stick
aims; throttle is Shift/Ctrl (triggers). This is what makes "mouse-aim + WASD" and "gamepad
twin-stick" the same game instead of two.
- **Disc:** accel 78 u/s², max 13 u/s, damping 6.5/s → crosses the ~16 u tube in ~1.2 s. Dodgeable, not twitchy.
- **Boost:** +120% flow for 0.55 s, 2.4 s cooldown, **i-frames 0.35 s** — deliberately shorter than the
burst, so boosting *through* something is a read, not a get-out-of-jail.
- **Wall = shove + graze, never a hard stop** (arcade law). Damage `(impact 3.0) × 1.6`, capped 28,
0.25 s cooldown. Below 3.0 u/s is free, so you can lean on a fold. Forward speed is untouched.
- **Cannon heat is the gun's only cost.** 11 shots/s × 5.5 heat 30/s cooling = **+25 heat/s net** ⇒
~4 s of held fire, then a 2.0 s lockout (100 → resume at 40). Burst and you never see the meter.
### Measured (DBG.step, deterministic fixed timestep — see → Lane F #2)
| claim | measured |
|---|---|
| flow-lock exact | 3.0 s at flow 14 from s=2 → **s=44.0** (predicted 44.0) |
| boost peak | **30.8 u/s** = 14 × (1.0 + 1.2) ✓ · i-frames end at 0.35 s ✓ · cd 2.4 s ✓ |
| heat cycle | overheat at 4.0 s held fire → **2.0 s lockout** (60 heat ÷ 30/s) → resume ✓ |
| wall slam | impact 11.3 u/s → **13.3 coat** = (11.33.0)×1.6 ✓ · forward speed **14.0 → 14.0** ✓ · no tunnelling (rho 7.58 < wallRho 8.49) |
| scraping | one damage tick only; subsequent per-frame bounces fall under grazeFloor ✓ |
| turret mount | rho **6.13** = wallRho(7.73) radius(1.6) ✓ · darts 14 dmg each ✓ |
| cannon | 2 hits kill a 24 hp floater ✓ · `enemy:die {type:'floater', kind:'cannon', score:100}` |
| level pump | stub events fired: 4 floaters @ s=60, 3 seekers @ s=140 ✓ |
| **draws** | **10 combat draws** with **55 live enemies** + full cannon stream (budget ≤80) |
| tris | 107.8k worst case — **102k of that is the stub tube**; all of combat ≈ 5.7k |
| cpu | **0.10 ms/frame** (update+render, 55 enemies, firing) |
| **leak test** | **0** geometries / textures / scene-children leaked over 6 and 20 create→dispose cycles |
**fps is NOT claimed.** `requestAnimationFrame` does not run in the automated browser pane
(`document.visibilityState === 'hidden'`), so wall-clock fps could not be honestly measured this
round. The 0.10 ms CPU figure is real; the GPU side needs a human session once boot wires the player.
Evidence: `docs/shots/laneB/``round1_combat` (roster + pellet, banked), `round1_wallgraze`
(pinned to the fold, the 13.3 hit above), `round1_boost` (FOV kick at 30.8 u/s), `round1_enemies`
(wall-mounted turrets + floater). WebP not PNG: a 1280×720 PNG was 1.9 MB of base64 vs 25 KB WebP,
and the repo already standardises on WebP (ART_BIBLE). Say the word and I'll switch.
---
## ⚠ surf: the esophagus mechanic does not work, and I can't fix it in my lane
This is the round's most important finding. **`world.flowPulse` surf is implemented and fires
correctly — but riding a crest is strictly slower than ignoring the wave.**
```
crest phase speed = WAVE_W / WAVE_K = 3.0 / 0.22 = 13.64 u/s (stub, and A's shader will mirror it)
player top speed = flow × throttleMax = 14 × 1.4 = 19.6 u/s
```
A wave travelling 13.64 u/s **cannot carry anything faster than 13.64 u/s**. Measured: riding a crest
at phase-lock = 19.2 u/s vs just mashing throttle = 19.6 u/s. Surfing **loses**. The correct play is
to ignore the signature mechanic of level 2 and hold the throttle key.
It is not a tuning problem. The crest window is fixed in **space**, so a bigger `surf.gain` just
ejects you out the front sooner — measured distance gained per crest is **~3.0 units at gain 0.4,
0.75 AND 1.2** (2.96 / 3.04 / 3.08). The bonus cancels itself. I verified the ride directly: starting
*on* a crest at neutral throttle, the ship falls off after **0.6 s** because the surf bonus (24.5 u/s)
outruns the very wave granting it.
Shipped as an honest **peristaltic shove** (~0.30.6 s at 24.5 u/s as each crest sweeps over you).
It feels good and is truthful to what the wave physically is. It is not the GDD's "ride peristalsis
rings for boost" speed line. `WAVE_W`/`WAVE_K` live in F's stub and will live in A's wall shader
(gameplay wave must equal visual wave), so **B cannot fix this alone** → see → Lane A/F #1.
---
## ### → Lane F
**1. Wire the player into `boot.js`** (I own no shell). Paste-ready; `?fly=1` keeps A's noclip:
```js
// imports, top of boot.js
import { createPlayer } from './flight/player.js';
import { createCombat } from './combat/index.js';
// after: scene.add(world.group) / scene.background = ...
const rng = createRng(flags.seed ?? world.level?.seed ?? 0);
const player = flags.fly ? null
: createPlayer({ scene, world, bus, rng, flags, camera, dom: renderer.domElement });
const combat = player ? createCombat({ scene, world, bus, rng, flags, player }) : null;
// level-event pump — nobody owns this yet (see #3). Emits C's events as the player crosses them.
let _evts = [...(world.level?.events ?? [])].sort((a, b) => a.s - b.s), _ei = 0;
const pumpTo = (s) => { while (_ei < _evts.length && s >= _evts[_ei].s) bus.emit('level:event', _evts[_ei++]); };
// in frame(), REPLACING `world.update(dt, sCam); updateCamera(dt);`
if (player) {
player.update(dt); // must run first: combat hit-tests this frame's ship position
pumpTo(player.state.s);
combat.update(dt);
world.update(dt, player.state.s);
} else {
updateCamera(dt); // ?fly=1 noclip / title drift
world.update(dt, sCam);
}
```
The player drives `camera` (position, `up`, `lookAt`, FOV) — `updateCamera()` must not also run.
`DBG` line's `s=${sCam}` should read `player?.state.s ?? sCam`.
**2. `tools/qa.sh` gate #1 is a no-op — it passes every broken file in the repo.**
`node --check` **silently exits 0 on any file containing `import`/`export`** (ESM detection). Every
module in `web/js` is ESM, so the syntax gate has never checked anything. I found this the hard way:
I shipped a genuine `SyntaxError` (backticks inside a GLSL comment inside a template literal, closing
the literal early), qa.sh went GREEN, and the browser refused the module. Verified on node v26.5.0:
```bash
node --check broken_esm.js ; echo $? # -> 0 (!!)
node --check --input-type=module < broken.js ; echo $? # -> 1 (correct)
```
Fix — swap the loop body in gate #1:
```bash
node --check --input-type=module < "$f" 2>/dev/null || { node --check --input-type=module < "$f"; JSBAD=1; }
```
Confirmed it still passes all good modules (`rng.js`, `world_stub.js`) and fails real breakage. Worth
landing before round 2 — A/C/D/E are all writing ESM against a gate that cannot fail.
**3. Nobody owns the `level:event` emitter.** TECH specs the event and says "raw C events as player
crosses their `s`", but no lane owns the pump. B consumes it. I put a dev-only copy in my harness and
the snippet in #1 — please assign it (boot or C's registry) and delete whichever copy loses.
**4. Bus additions** (TECH §Bus says F referees names). All emitted faithfully already:
- `player:state {coat,coatMax,hull,hullMax,speed,flow,throttle,boostReady,boostCd,boostCdMax,surfing,iframes,s,length,progress,biome,alive}`**E cannot build a HUD without this.** Damage events
alone can't express regen, ambient drain, throttle or progress. Emitted every frame; treat as
read-only, don't retain.
- `combat:state {heat,heatMax,overheated,ammo,ammoMax,score,kills,enemies}` — same reason.
- `player:surf {active, s}` — edge-triggered.
- `enemy:die` carries an extra `score` and `kind` alongside the specced `{id,type,s}` (additive).
- **Damage rule I adopted:** `player:damage` = *discrete* hits only (wall/dart/contact/aura-tick).
Continuous drain (biome `coatDrain`) never emits — it would fire 60×/s and pin E's damage flash on.
Continuous state rides `player:state`. Floater auras tick at 0.2 s so they read as pulses.
**5. `?shots=1` respected** in my harness. My `DBG.step/leakTest/spawn/tp` helpers are harness-only.
---
## ### → Lane A (and F, who owns the stub today)
**1. The wave must outrun the player, or level 2's mechanic is dead.** See §surf above. For "ride the
crest" to be the fast line, **crest phase speed must exceed `flow × throttleMax` (19.6 u/s)**; today
it's 13.64. Options, your call (F settles design):
- **(a) Raise the wave** — `WAVE_W/WAVE_K > 19.6`, e.g. `WAVE_W 4.8 @ K 0.22` → 21.8 u/s. Then
riding pays ~+11% over mashing and the crest genuinely carries you. Cheapest fix; the visual
wave just travels faster (arguably better for a speed-run level).
- **(b) Expose the phase speed** so B can *speed-lock* the player to the crest while riding — the
truest "flow-locked" reading, and then the wave carries you at exactly its own speed.
Either way **B needs (b)'s data**: please add `biome.waveSpeed` (or `world.crestSpeed(s)`) to the
contract. Right now `flowPulse` gives amplitude only, so B can see *that* it's on a crest but not
*how fast the crest is going* — I had to hardcode 3.0/0.22 from the stub to analyse this.
**2. `world.flowPulse(s, t)` is missing from the TECH world contract** but is specced in both LANE_A
and LANE_B charters and in ROUND1 §A.4. I call it guarded (`world.flowPulse?.(s) ?? 0`). Please add it
to TECH §THE WORLD CONTRACT before F freezes it.
**3. `world.sample(s)` allocates** ~6 `Vector3`s per call. It's the hot path: I call it for the ship,
the aim frame, the camera (×2) and once per live enemy per frame — ~60 calls/frame at 55 enemies,
i.e. ~350 allocations/frame of pure GC churn. Not a problem yet (0.10 ms/frame), but it will be with
villi + a boss. Request: an out-param variant, `world.sample(s, out)` reusing a caller-owned frame
object, keeping the allocating form as sugar. I'll adopt it the round it exists.
**4. Frame convention I built against** (please keep): `worldPos = frame.pos + frame.nor·y + frame.bin·x`,
`theta = atan2(x, y)` — matches the stub's `project()` (`atan2(rel·bin, rel·nor)`). Camera `up` is
`frame.nor`, never world-up. Parallel transport must stay roll-stable or the chase cam rolls with it.
**5. Custom `ShaderMaterial` needs `#include <colorspace_fragment>` or ART_BIBLE colours are wrong.**
`ColorManagement` is on and `outputColorSpace` is `srgb`, so `THREE.Color(0xff5a2a)` is converted to
**linear** on the way in — but a hand-written fragment shader gets no conversion on the way out and
writes linear straight into the sRGB target. Measured: hostile amber `#ff5a2a` reached the display as
**rgb(255, 26, 6)** — blood red, not amber — silently breaking the emissive readability law. One line
at the end of `main()` fixes it. **The stub's wall shader has the same issue** (F: its teal is being
written linear too), and A's wall/villi shaders will inherit it. Worth an ART_BIBLE or TECH note so
every lane writing GLSL gets it right once.
---
## ### → Lane C
- **Ambient `coatDrain` does nothing in the esophagus as balanced.** Coat regen is 8/s (after a 2.5 s
no-damage delay) and esophagus `coatDrain` is 0.5/s, so out of combat you are always topped up —
ambient drain only bites while regen is suppressed, i.e. for 2.5 s after each hit. That's a
defensible design (it makes *taking* a hit hurt twice), but if you want standing ambient pressure in
a biome, `coatDrain` must exceed **8/s** — which is presumably exactly what the stomach should do.
Flagging so your biome numbers are chosen knowingly, not by accident.
- Spawn events: I fan `count` out over `s + i×3.5` so a count of 6 is an encounter, not a wall of
enemies at one arclength. Add `theta`/`rho` to a spawn event and I'll honour them exactly.
- Live archetype ids for your L2 encounter curve: `floater`, `seeker`, `turret`. Unknown `enemy`
values warn and no-op, so you can author ahead of me safely.
- Pool caps are the population caps: floater 24, seeker 32, turret 12 concurrent.
---
## Open questions / next round
- **Arena/6DOF controller** (round 2, as instructed). `modeAt()`/`arenaAt()` are guarded but unused.
- **Per-instance hit flash.** Today `uFlash` is one uniform per *type*, so hitting one floater blinks
the whole cohort. Needs an instanced colour attribute — round 2.
- **Enemies behind the player are never reaped.** They idle at their spawn `s` forever (pool-capped, so
bounded, but wasteful). Wants a despawn rule once C's checkpoints exist.
- **Dart readability:** a dart passing the camera fills a chunk of the screen with white-hot additive.
Correct per ART_BIBLE, possibly too dominant in motion. Wants a human eye once boot wires the player.
- **Torpedo** is built and emits `level:neutralize {s,radius,duration}` faithfully — **nothing consumes
it yet** (A/C). Ammo is fixed at 6 until pickups exist (round 2).
- **Real fps + real feel.** Everything above is deterministic-stepper truth. Numbers can't tell you
whether the bank rate feels good. First human session should re-tune `bank.gain`, `cam.discFollow`
and `aim.lag` by hand — those three are pure taste and I set them by reasoning, not by playing.

View File

@ -0,0 +1,39 @@
# Lane B — progress log
## 2026-07-16 · Round 1 — flight + first blood, on the stub
Built the whole Lane B round-1 slice against `?stub=1`: `flight/{tuning,input,player,ship,emissive}.js`
and `combat/{balance,weapons,enemies,index}.js`. Round-1 asks 14 all landed; arena/6DOF deferred to
round 2 as instructed.
**Shipped**
- Tube controller in spline space `(s, x, y)`: flow-locked forward motion, throttle as a spring-back
lean, boost + i-frames, banking chase cam on the parallel-transport frame, arcade wall response
(shove + graze damage, never a hard stop).
- Twin-stick input (WASD/left-stick = disc, mouse/right-stick = aim, Shift/Ctrl = throttle), gamepad
supported. Throttle deliberately not on WASD.
- Lysozyme cannon (pooled, instanced, heat-limited with hysteresis lockout) + antacid torpedo emitting
`level:neutralize`. Enemy framework + floater/seeker/turret + turn-rate-limited homing darts.
- Coat/hull model and the full bus surface E builds against.
**Measured** (deterministic fixed-timestep stepper): flow-lock exact (3 s at flow 14 → s=44.0);
boost peak 30.8 u/s; heat cycle 4 s fire → 2 s lockout; wall slam 13.3 coat at 11.3 u/s impact with
forward speed untouched; **10 combat draws at 55 live enemies** (budget ≤80); ~5.7k combat tris;
0.10 ms/frame CPU; **0 leaks** across 20 create→dispose cycles. fps deliberately not claimed — rAF
doesn't run in the automated pane. Evidence in `docs/shots/laneB/`.
**Findings escalated** (detail in LANE_B_NOTES):
1. **`tools/qa.sh` gate #1 never checks anything** — `node --check` silently exits 0 on ESM. I shipped
a real SyntaxError past a GREEN qa. Fix + verification handed to F.
2. **Surf is structurally broken** — the peristalsis wave (13.64 u/s) is slower than the player
(19.6 u/s), so level 2's signature mechanic is a trap: riding a crest loses to mashing throttle.
Not tunable from my lane (measured: identical ~3.0 u gained at gain 0.4/0.75/1.2). Escalated to
A/F with two options and a request for `biome.waveSpeed`.
3. **Custom ShaderMaterials need `#include <colorspace_fragment>`** — without it ART_BIBLE's hostile
amber `#ff5a2a` reaches the display as rgb(255,26,6). Fixed in mine; F's stub and A's walls inherit it.
4. `flowPulse` missing from the TECH contract; nobody owns the `level:event` emitter; `world.sample()`
allocates on the hot path; `player:state`/`combat:state` bus additions needed for E's HUD.
**Blocked on nothing.** Built entirely against the stub per instructions; boot.js still has no player
wiring (F's file), so `web/js/flight/dev.html` is a dev harness that boots stub+player+combat. It
retires the moment F pastes the snippet in NOTES §→ Lane F #1.

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

59
web/js/combat/balance.js Normal file
View File

@ -0,0 +1,59 @@
// combat/balance.js (Lane B) — every combat number. Sibling of flight/tuning.js: that file
// decides how ENDO-1 handles, this one decides what the fight is worth.
//
// Sized against the stub esophagus (flow 14 u/s, playable disc radius ~7..10 u) and against
// the player's own numbers: disc top speed 13 u/s, coat 100 with 8/s regen after 2.5 s.
export const BALANCE = {
// Lysozyme cannon: infinite ammo, heat-limited (GDD). Heat is the whole design — the gun
// is free, so the cost has to be time. Sustained fire nets +25 heat/s (11 shots/s × 5.5,
// less 30/s cooling) => ~4 s of held fire before lockout, then ~2 s to recover to resumeAt.
// Burst and you never see the meter; hold and you're disarmed exactly when it's busiest.
cannon: {
rof: 11, // shots/s
speed: 95, // u/s in world space (closing speed ~75 with player flow)
life: 1.15, // s => ~109 u range, comfortably past the fog
damage: 12,
radius: 0.35,
heatPerShot: 5.5,
coolRate: 30, // /s
overheatAt: 100,
resumeAt: 40, // hysteresis — no stutter-firing on the edge of lockout
spread: 0.004, // rad, seeded — a hair of life, not a handicap
pool: 96, // rof × life = ~13 in flight; 96 covers spikes with room
},
// Antacid torpedo: the dual-use secondary (GDD). Also neutralizes an acid zone for ~10 s
// via the level:neutralize bus event — A/C consume it. Ammo-limited, pickup-fed (round 2).
torpedo: {
rof: 0.8, speed: 42, life: 3.2, damage: 45, splash: 9, radius: 0.7,
ammo: 6, ammoMax: 6,
neutralizeRadius: 14, neutralizeDuration: 10,
pool: 8,
},
enemies: {
// amylase droplet — slow floater with a corrosive contact aura. A soft obstacle: it
// doesn't chase, it taxes the lane it sits in. Aura ticks discretely (see enemies.js).
floater: { hp: 24, radius: 2.2, score: 100, drift: 1.6, bob: 0.5,
auraRadius: 4.2, auraDps: 18, auraTick: 0.2, pool: 24 },
// pepsin — small fast seeker, dissolves on contact. Chases in spline space (s,x,y), so
// a pursuit costs three floats. speed 19 > player flow 14: it WILL catch you from
// behind, and the answer is to shoot it or boost, not to outrun it at throttle 1.4.
seeker: { hp: 14, radius: 1.0, score: 150, speed: 19, turn: 2.4, contact: 16,
aggroS: 60, pool: 32 },
// antibody turret (Peyer's patch) — wall-mounted, fires homing darts. Dart turn radius
// is speed/turn = 24/1.8 = 13.3 u, wider than the tube itself: it can never corner you,
// only punish a straight line. Dodgeable by design (charter).
turret: { hp: 40, radius: 1.6, score: 250, fireEvery: 1.9, rangeS: 55,
dartSpeed: 24, dartTurn: 1.8, dartLife: 4.5, dartDamage: 14, pool: 12 },
},
darts: { pool: 48, radius: 0.45 },
// Chained kills feed the combo meter (GDD: score × style multiplier; E's music layer
// listens). Window is generous enough to cross a fold, tight enough to mean something.
combo: { window: 2.5 },
};

334
web/js/combat/enemies.js Normal file
View File

@ -0,0 +1,334 @@
// combat/enemies.js (Lane B) — the enemy framework and its round-1 archetypes.
//
// Enemies live in the SAME spline space as the player: (s, x, y). That's the whole trick —
// a seeker's pursuit is three float subtractions, not a pathfind, and "is it in front of
// me" is `e.s > player.s`. World position is derived once per frame for rendering and hit
// tests, never stored as truth.
//
// Behaviours are one flat update pass over a live array. Visuals are one InstancedMesh per
// type (ART_BIBLE emissive colours), so the whole roster is 3 draws + 1 for darts. When
// Lane D's GLBs land, swap the pool's geometry and the logic above never notices.
//
// Determinism: every random here comes from the seeded rng('enemies') stream.
import * as THREE from 'three';
import { BALANCE as B } from './balance.js';
import { emissiveMat, EMISSIVE } from '../flight/emissive.js';
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _scale = new THREE.Vector3();
const _dir = new THREE.Vector3(), _v = new THREE.Vector3();
const FORWARD = new THREE.Vector3(0, 0, 1);
const BUCKET = 20; // u of s per broad-phase bucket
// Round-1 roster. `geo` is the procedural fallback; D's model swaps in via assets.get.
const ARCHETYPES = {
floater: {
cfg: B.enemies.floater,
geo: () => new THREE.IcosahedronGeometry(B.enemies.floater.radius, 1),
color: EMISSIVE.hostile,
},
seeker: {
cfg: B.enemies.seeker,
geo: () => new THREE.ConeGeometry(B.enemies.seeker.radius, B.enemies.seeker.radius * 2.6, 6),
color: EMISSIVE.hostile,
},
turret: {
cfg: B.enemies.turret,
geo: () => new THREE.CylinderGeometry(B.enemies.turret.radius * 0.5, B.enemies.turret.radius, B.enemies.turret.radius * 1.6, 7),
color: EMISSIVE.hostile,
},
};
export function createEnemies({ scene, world, bus, rng, assets = null }) {
const rand = rng ? rng('enemies') : () => 0.5;
const pools = {};
let nextId = 1;
const live = [];
const buckets = new Map();
let onDeath = null, onBlast = null; // set by combat/index.js
const _playerPos = new THREE.Vector3(); // player world pos, resolved once per frame
for (const [type, def] of Object.entries(ARCHETYPES)) {
const glb = assets?.get?.('models', type) ?? null;
const geo = glb?.geometry ?? def.geo();
const mat = emissiveMat(def.color);
const mesh = new THREE.InstancedMesh(geo, mat, def.cfg.pool);
mesh.frustumCulled = false;
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.count = 0;
scene.add(mesh);
pools[type] = { mesh, geo, mat, cfg: def.cfg, slots: [] };
}
// --- darts: the turret's homing projectile (hostile => white-hot) ------------------
const dartGeo = new THREE.ConeGeometry(B.darts.radius, B.darts.radius * 3, 5);
dartGeo.rotateX(Math.PI / 2); // author along +Z like every projectile
const dartMat = emissiveMat(EMISSIVE.hostileShot, { additive: true });
const dartMesh = new THREE.InstancedMesh(dartGeo, dartMat, B.darts.pool);
dartMesh.frustumCulled = false;
dartMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
dartMesh.count = 0;
scene.add(dartMesh);
const darts = Array.from({ length: B.darts.pool }, () => ({
alive: false, pos: new THREE.Vector3(), vel: new THREE.Vector3(),
s: 0, sSpeed: 0, life: 0, turn: 0, damage: 0,
}));
const discToWorld = (out, frame, x, y) =>
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
// --- spawn ------------------------------------------------------------------------
// Charter API: spawn(type, {s, theta, rho}). Level events give us only an `s` and a
// count, so theta/rho are filled from the seeded stream — same seed, same encounter.
function spawn(type, { s = 0, theta = null, rho = null } = {}) {
const pool = pools[type];
if (!pool) { console.warn(`[combat] unknown enemy type "${type}"`); return null; }
if (pool.slots.length >= pool.cfg.pool) return null; // pool is the population cap
const cfg = pool.cfg;
s = THREE.MathUtils.clamp(s, 0, world.length);
const th = theta ?? rand() * Math.PI * 2;
const wall = world.wallRho(s, th) - cfg.radius;
// turrets bolt to the wall; everything else floats somewhere in the lumen
const r = rho ?? (type === 'turret' ? wall : wall * (0.25 + rand() * 0.6));
const e = {
id: nextId++, type, cfg,
s, x: Math.sin(th) * r, y: Math.cos(th) * r, // theta = atan2(x, y), per the frame convention
vs: 0, vx: 0, vy: 0,
hp: cfg.hp, radius: cfg.radius, alive: true,
pos: new THREE.Vector3(), flash: 0, phase: rand() * Math.PI * 2,
cooldown: rand() * (cfg.fireEvery ?? 1), // desync turret volleys
auraT: 0,
};
live.push(e);
pool.slots.push(e);
bus.emit('enemy:spawn', { id: e.id, type });
return e;
}
// --- damage / death ---------------------------------------------------------------
let comboN = 0, comboT = 0;
function hurt(e, dmg, kind = 'hit') {
if (!e.alive) return;
e.hp -= dmg;
e.flash = 1;
if (e.hp > 0) { bus.emit('audio:cue', { name: 'enemy_hit' }); return; }
kill(e, kind);
}
function kill(e, kind) {
e.alive = false;
comboN++; comboT = B.combo.window;
bus.emit('enemy:die', { id: e.id, type: e.type, s: e.s, score: e.cfg.score, kind });
bus.emit('combo', { n: comboN });
bus.emit('audio:cue', { name: 'enemy_die' });
onDeath?.(e);
}
// --- broad phase ------------------------------------------------------------------
function rebuildBuckets() {
buckets.clear();
for (const e of live) {
const b = Math.floor(e.s / BUCKET);
let arr = buckets.get(b);
if (!arr) buckets.set(b, (arr = []));
arr.push(e);
}
}
// Visits every live enemy whose s-bucket could contain something within `radius` of `s`.
// Callers still do the exact world-space distance test — this only narrows the field.
function forEachNear(s, radius, fn) {
const lo = Math.floor((s - radius - BUCKET) / BUCKET);
const hi = Math.floor((s + radius + BUCKET) / BUCKET);
for (let b = lo; b <= hi; b++) {
const arr = buckets.get(b);
if (!arr) continue;
for (const e of arr) if (e.alive) fn(e);
}
}
// --- behaviours -------------------------------------------------------------------
function update(dt, player) {
const ps = player.state;
// resolve the player's world position ONCE — darts and turrets all want it, and
// world.sample() allocates (see NOTES → Lane A: an out-param sample would kill the
// last of our per-frame garbage).
discToWorld(_playerPos, world.sample(ps.s), ps.x, ps.y);
for (const e of live) {
if (!e.alive) continue;
e.flash = Math.max(0, e.flash - dt * 6);
const cfg = e.cfg;
if (e.type === 'floater') {
// drifts and bobs; taxes the lane it occupies rather than chasing
e.phase += dt * cfg.bob;
e.s += Math.sin(e.phase) * cfg.drift * dt;
e.x += Math.cos(e.phase * 0.7) * cfg.drift * dt;
e.y += Math.sin(e.phase * 0.9) * cfg.drift * dt;
if (ps.alive) {
const d = Math.hypot(e.s - ps.s, e.x - ps.x, e.y - ps.y);
e.auraT -= dt;
if (d < cfg.auraRadius + player.radius && e.auraT <= 0) {
// discrete ticks, not per-frame: continuous player:damage would spam the bus
// and pin E's damage flash on (see flight/player.js damage/drain split)
player.damage(cfg.auraDps * cfg.auraTick, 'aura');
e.auraT = cfg.auraTick;
}
}
} else if (e.type === 'seeker') {
// pursue in spline space — a chase for three float subtractions
const ds = ps.s - e.s, dx = ps.x - e.x, dy = ps.y - e.y;
const d = Math.hypot(ds, dx, dy) || 1e-5;
if (ps.alive && Math.abs(ds) < cfg.aggroS) {
const k = Math.min(1, cfg.turn * dt);
e.vs += ((ds / d) * cfg.speed - e.vs) * k;
e.vx += ((dx / d) * cfg.speed - e.vx) * k;
e.vy += ((dy / d) * cfg.speed - e.vy) * k;
}
e.s += e.vs * dt; e.x += e.vx * dt; e.y += e.vy * dt;
if (ps.alive && d < cfg.radius + player.radius) {
player.damage(cfg.contact, 'contact');
onBlast?.(e.pos, 1.6, 0.25);
kill(e, 'contact'); // pepsin dissolves on the hit (GDD)
continue;
}
} else if (e.type === 'turret') {
// wall-mounted; fires only at what's in range and still ahead of it
e.cooldown -= dt;
if (ps.alive && e.cooldown <= 0 && Math.abs(e.s - ps.s) < cfg.rangeS) {
fireDart(e);
e.cooldown = cfg.fireEvery;
}
}
// everything stays inside the tube
const rho = Math.hypot(e.x, e.y);
const maxR = world.wallRho(e.s, Math.atan2(e.x, e.y)) - e.radius * 0.5;
if (rho > maxR && rho > 1e-5) {
const k = maxR / rho;
e.x *= k; e.y *= k;
if (e.type === 'seeker') { e.vx *= 0.4; e.vy *= 0.4; }
}
e.s = THREE.MathUtils.clamp(e.s, 0, world.length);
discToWorld(e.pos, world.sample(e.s), e.x, e.y);
}
updateDarts(dt, player);
reap();
rebuildBuckets();
render();
if (comboT > 0 && (comboT -= dt) <= 0 && comboN) { comboN = 0; bus.emit('combo', { n: 0 }); }
}
function fireDart(e) {
const d = darts.find((x) => !x.alive);
if (!d) return;
d.alive = true;
d.pos.copy(e.pos);
_dir.copy(_playerPos).sub(e.pos).normalize(); // aim at where the player is now
d.vel.copy(_dir).multiplyScalar(e.cfg.dartSpeed);
d.life = e.cfg.dartLife;
d.s = e.s;
d.sSpeed = d.vel.dot(world.sample(e.s).tan);
d.turn = e.cfg.dartTurn;
d.damage = e.cfg.dartDamage;
bus.emit('audio:cue', { name: 'dart' });
}
function updateDarts(dt, player) {
const ps = player.state;
let n = 0;
for (const d of darts) {
if (!d.alive) continue;
// Turn-rate-limited homing: swing the velocity toward the player by at most turn·dt
// radians. Turn radius (dartSpeed/dartTurn = 13.3 u) is wider than the tube itself,
// so a dart can never corner you — it only punishes flying straight. That's the design.
if (ps.alive) {
const speed = d.vel.length();
_dir.copy(_playerPos).sub(d.pos);
if (_dir.lengthSq() > 1e-8 && speed > 1e-5) {
_dir.normalize();
_v.copy(d.vel).divideScalar(speed); // current heading, unit
const angle = Math.acos(THREE.MathUtils.clamp(_v.dot(_dir), -1, 1));
if (angle > 1e-4) {
// nlerp toward the target heading, capped at the turn budget for this frame.
// At these step sizes it tracks slerp closely and costs no allocation.
const t = Math.min(1, (d.turn * dt) / angle);
_v.lerp(_dir, t).normalize();
d.vel.copy(_v).multiplyScalar(speed);
}
}
}
d.pos.addScaledVector(d.vel, dt);
d.s += d.sSpeed * dt;
d.life -= dt;
let done = d.life <= 0;
if (!done && ps.alive) {
const r = player.radius + B.darts.radius;
if (d.pos.distanceToSquared(_playerPos) <= r * r) {
if (player.damage(d.damage, 'dart')) onBlast?.(d.pos, 1.2, 0.2);
done = true;
}
}
if (!done && world.collide(d.pos, B.darts.radius)) done = true;
if (done) { d.alive = false; continue; }
_dir.copy(d.vel).normalize();
_q.setFromUnitVectors(FORWARD, _dir);
dartMesh.setMatrixAt(n++, _m.compose(d.pos, _q, _scale.set(1, 1, 1)));
}
dartMesh.count = n;
dartMesh.instanceMatrix.needsUpdate = true;
}
function reap() {
for (let i = live.length - 1; i >= 0; i--) if (!live[i].alive) live.splice(i, 1);
for (const pool of Object.values(pools))
for (let i = pool.slots.length - 1; i >= 0; i--) if (!pool.slots[i].alive) pool.slots.splice(i, 1);
}
function render() {
for (const [type, pool] of Object.entries(pools)) {
let n = 0;
for (const e of pool.slots) {
if (!e.alive) continue;
if (type === 'seeker') {
_dir.set(e.vs, e.vx, e.vy);
_q.setFromUnitVectors(FORWARD, _dir.lengthSq() > 1e-6 ? _dir.normalize() : FORWARD);
} else {
_q.setFromAxisAngle(FORWARD, e.phase);
}
pool.mesh.setMatrixAt(n++, _m.compose(e.pos, _q, _scale.setScalar(1)));
}
pool.mesh.count = n;
pool.mesh.instanceMatrix.needsUpdate = true;
// one flash uniform per type: a hit reads as the whole cohort blinking, which is
// wrong. Round 2: per-instance colour attribute. Noted in NOTES.
const hot = pool.slots.some((e) => e.alive && e.flash > 0.5);
pool.mat.uniforms.uFlash.value = hot ? 0.6 : 0;
}
}
return {
spawn, hurt, forEachNear, update,
get count() { return live.length; },
get live() { return live; },
setBlastFx(fn) { onBlast = fn; },
setOnDeath(fn) { onDeath = fn; },
clear() { for (const e of live) e.alive = false; for (const d of darts) d.alive = false; reap(); },
dispose() {
for (const pool of Object.values(pools)) {
scene.remove(pool.mesh); pool.mesh.dispose(); pool.geo.dispose(); pool.mat.dispose();
}
scene.remove(dartMesh); dartMesh.dispose(); dartGeo.dispose(); dartMat.dispose();
live.length = 0; buckets.clear();
},
};
}

58
web/js/combat/index.js Normal file
View File

@ -0,0 +1,58 @@
// combat/index.js (Lane B) — the createCombat factory. Composes enemies + weapons and
// feeds them Lane C's level events. All the wiring, none of the behaviour.
//
// Update order inside a frame is load-bearing:
// boot: player.update(dt) -> combat.update(dt)
// here: enemies.update(dt) -> weapons.update(dt)
// Enemies move first so weapons hit-test against this frame's positions, not last frame's.
import { createEnemies } from './enemies.js';
import { createWeapons } from './weapons.js';
export function createCombat({ scene, world, bus, rng, flags = {}, assets = null, player }) {
const enemies = createEnemies({ scene, world, bus, rng, assets });
const weapons = createWeapons({ scene, world, bus, rng, player, targets: enemies });
enemies.setBlastFx((pos, size, dur) => weapons.spawnBlast(pos, size, dur));
enemies.setOnDeath((e) => weapons.spawnBlast(e.pos, e.radius * 1.6, 0.3));
const score = { value: 0, kills: 0 };
// Lane C's events arrive on the bus as the player crosses their `s`. We only care about
// `spawn`; checkpoints/hazards/bosses belong to C and E this round. Unknown types are
// ignored on purpose — C may add event kinds without breaking combat.
const offEvent = bus.on('level:event', (ev) => {
if (!ev || ev.type !== 'spawn') return;
const n = ev.count ?? 1;
for (let i = 0; i < n; i++) {
// fan the group out over a short stretch of s so a count of 6 is an encounter,
// not a wall of enemies at one arclength
enemies.spawn(ev.enemy, { s: (ev.s ?? 0) + i * 3.5, theta: ev.theta ?? null, rho: ev.rho ?? null });
}
});
const offDie = bus.on('enemy:die', (e) => {
score.value += e.score ?? 0;
score.kills++;
});
function update(dt) {
enemies.update(dt, player);
weapons.update(dt);
// combat's slice of the HUD state (E reads player:state for the flight half)
bus.emit('combat:state', {
heat: weapons.heat, heatMax: weapons.heatMax, overheated: weapons.overheated,
ammo: weapons.ammo, ammoMax: weapons.ammoMax,
score: score.value, kills: score.kills, enemies: enemies.count,
});
}
return {
update,
enemies, weapons, score,
dispose() {
offEvent(); offDie();
enemies.dispose(); weapons.dispose();
},
};
}

216
web/js/combat/weapons.js Normal file
View File

@ -0,0 +1,216 @@
// combat/weapons.js (Lane B) — the lysozyme cannon and the antacid torpedo.
//
// Pooling law: nothing is allocated after construction. Each weapon owns a fixed-capacity
// InstancedMesh; firing wakes a slot, expiry sleeps it, and `count` is set to the live
// prefix each frame. One draw call per projectile type, forever, no matter the fight.
//
// Projectiles carry an `s` that is advanced LINEARLY (never re-projected). It is only a
// broad-phase hint for the s-bucket lookup — the actual hit test is a world-space distance
// compare, so a few units of drift over a pellet's 1.15 s life costs nothing and saves us a
// world.project() per pellet per frame.
import * as THREE from 'three';
import { BALANCE as B } from './balance.js';
import { emissiveMat, EMISSIVE } from '../flight/emissive.js';
const _v = new THREE.Vector3(), _q = new THREE.Quaternion(), _m = new THREE.Matrix4();
const _scale = new THREE.Vector3(), _dir = new THREE.Vector3();
const FORWARD = new THREE.Vector3(0, 0, 1);
// A fixed-capacity instanced pool. Every visual in combat/ is one of these.
function makePool(scene, geo, mat, cap, makeSlot) {
const mesh = new THREE.InstancedMesh(geo, mat, cap);
mesh.frustumCulled = false; // projectiles live around the camera anyway
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.count = 0;
scene.add(mesh);
const slots = Array.from({ length: cap }, makeSlot);
return {
mesh, slots,
take() { for (const s of slots) if (!s.alive) { s.alive = true; return s; } return null; },
// Pack live slots into the front of the instance buffer; count is the live prefix.
flush(write) {
let n = 0;
for (const s of slots) if (s.alive) write(s, n++);
mesh.count = n;
mesh.instanceMatrix.needsUpdate = true;
return n;
},
dispose() { scene.remove(mesh); mesh.dispose(); geo.dispose(); mat.dispose(); },
};
}
export function createWeapons({ scene, world, bus, rng, player, targets }) {
const rand = rng ? rng('weapons') : () => 0.5;
// --- lysozyme cannon --------------------------------------------------------------
const pelletGeo = new THREE.SphereGeometry(B.cannon.radius, 6, 4);
const cannon = makePool(scene, pelletGeo, emissiveMat(EMISSIVE.neutral, { additive: true }),
B.cannon.pool, () => ({ alive: false, pos: new THREE.Vector3(), vel: new THREE.Vector3(), s: 0, sSpeed: 0, life: 0 }));
// --- antacid torpedo --------------------------------------------------------------
const torpGeo = new THREE.SphereGeometry(B.torpedo.radius, 8, 6);
const torps = makePool(scene, torpGeo, emissiveMat(EMISSIVE.acid, { additive: true }),
B.torpedo.pool, () => ({ alive: false, pos: new THREE.Vector3(), vel: new THREE.Vector3(), s: 0, sSpeed: 0, life: 0 }));
// --- blasts (kills + torpedo detonations) -----------------------------------------
const blastGeo = new THREE.SphereGeometry(1, 10, 8);
const blasts = makePool(scene, blastGeo, emissiveMat(EMISSIVE.hostileShot, { additive: true, opacity: 0.7 }),
24, () => ({ alive: false, pos: new THREE.Vector3(), t: 0, dur: 0.35, size: 1 }));
const heat = { value: 0, locked: false };
let fireTimer = 0, torpTimer = 0;
let ammo = B.torpedo.ammo;
function spawnBlast(pos, size, dur = 0.35) {
const b = blasts.take();
if (!b) return;
b.pos.copy(pos); b.t = 0; b.size = size; b.dur = dur;
}
function fireCannon() {
const p = cannon.take();
if (!p) return;
_dir.copy(player.aimPoint).sub(player.position).normalize();
_dir.x += (rand() - 0.5) * B.cannon.spread;
_dir.y += (rand() - 0.5) * B.cannon.spread;
_dir.normalize();
p.pos.copy(player.position);
p.vel.copy(_dir).multiplyScalar(B.cannon.speed);
p.life = B.cannon.life;
p.s = player.state.s;
p.sSpeed = p.vel.dot(world.sample(p.s).tan);
heat.value += B.cannon.heatPerShot;
bus.emit('audio:cue', { name: 'cannon' });
}
function fireTorpedo() {
const t = torps.take();
if (!t) return;
_dir.copy(player.aimPoint).sub(player.position).normalize();
t.pos.copy(player.position);
t.vel.copy(_dir).multiplyScalar(B.torpedo.speed);
t.life = B.torpedo.life;
t.s = player.state.s;
t.sSpeed = t.vel.dot(world.sample(t.s).tan);
ammo--;
bus.emit('audio:cue', { name: 'torpedo' });
}
// The dual-use bit (GDD): a torpedo both damages and neutralizes acid for ~10 s.
// Lanes A and C consume this; we emit it faithfully whether or not anyone listens yet.
function detonate(t) {
spawnBlast(t.pos, B.torpedo.splash, 0.5);
const s = THREE.MathUtils.clamp(t.s, 0, world.length);
bus.emit('level:neutralize', { s, radius: B.torpedo.neutralizeRadius, duration: B.torpedo.neutralizeDuration });
bus.emit('audio:cue', { name: 'torpedo_blast' });
targets.forEachNear(s, B.torpedo.splash, (e) => {
const d = e.pos.distanceTo(t.pos);
if (d > B.torpedo.splash + e.radius) return;
const falloff = 1 - d / (B.torpedo.splash + e.radius);
targets.hurt(e, B.torpedo.damage * falloff, 'torpedo');
});
t.alive = false;
}
function stepProjectile(p, dt) {
p.pos.addScaledVector(p.vel, dt);
p.s += p.sSpeed * dt; // broad-phase hint only — see header
p.life -= dt;
}
function writeProjectile(p, i, mesh, stretch) {
_dir.copy(p.vel).normalize();
_q.setFromUnitVectors(FORWARD, _dir);
_scale.set(1, 1, stretch);
mesh.setMatrixAt(i, _m.compose(p.pos, _q, _scale));
}
function update(dt) {
const st = player.state;
// heat: cool always, lock at overheatAt, unlock at resumeAt (hysteresis)
heat.value = Math.max(0, heat.value - B.cannon.coolRate * dt);
if (heat.value >= B.cannon.overheatAt && !heat.locked) {
heat.locked = true;
bus.emit('audio:cue', { name: 'overheat' });
}
if (heat.locked && heat.value <= B.cannon.resumeAt) heat.locked = false;
const intent = player.intent;
fireTimer -= dt;
torpTimer -= dt;
if (st.alive && intent.fire && !heat.locked) {
// catch up in fixed steps so rate of fire is frame-rate independent
let guard = 8;
while (fireTimer <= 0 && !heat.locked && guard--) {
fireCannon();
fireTimer += 1 / B.cannon.rof;
if (heat.value >= B.cannon.overheatAt) heat.locked = true;
}
}
if (fireTimer < 0) fireTimer = 0;
if (st.alive && intent.fire2 && torpTimer <= 0 && ammo > 0) {
fireTorpedo();
torpTimer = 1 / B.torpedo.rof;
}
// --- cannon pellets: step, hit-test, expire ---
for (const p of cannon.slots) {
if (!p.alive) continue;
stepProjectile(p, dt);
let hit = false;
targets.forEachNear(p.s, B.cannon.radius, (e) => {
if (hit) return;
const r = e.radius + B.cannon.radius;
if (p.pos.distanceToSquared(e.pos) > r * r) return;
hit = true;
targets.hurt(e, B.cannon.damage, 'cannon');
spawnBlast(p.pos, 1.1, 0.16);
});
if (hit || p.life <= 0) p.alive = false;
}
cannon.flush((p, i) => writeProjectile(p, i, cannon.mesh, 3.2));
// --- torpedoes: step, proximity-detonate, expire ---
for (const t of torps.slots) {
if (!t.alive) continue;
stepProjectile(t, dt);
let boom = t.life <= 0;
if (!boom) {
targets.forEachNear(t.s, B.torpedo.radius, (e) => {
const r = e.radius + B.torpedo.radius;
if (t.pos.distanceToSquared(e.pos) <= r * r) boom = true;
});
}
if (!boom && world.collide(t.pos, B.torpedo.radius)) boom = true;
if (boom) detonate(t);
}
torps.flush((t, i) => writeProjectile(t, i, torps.mesh, 2.0));
// --- blasts: expand and fade ---
for (const b of blasts.slots) {
if (!b.alive) continue;
b.t += dt;
if (b.t >= b.dur) b.alive = false;
}
blasts.flush((b, i) => {
const k = b.t / b.dur;
const r = b.size * (0.35 + k * 0.9);
blasts.mesh.setMatrixAt(i, _m.compose(b.pos, _q.identity(), _scale.setScalar(r)));
});
}
return {
update,
spawnBlast,
get heat() { return heat.value; },
get heatMax() { return B.cannon.overheatAt; },
get overheated() { return heat.locked; },
get ammo() { return ammo; },
get ammoMax() { return B.torpedo.ammoMax; },
addAmmo(n) { ammo = Math.min(B.torpedo.ammoMax, ammo + n); },
get liveCount() { return cannon.mesh.count + torps.mesh.count; },
dispose() { cannon.dispose(); torps.dispose(); blasts.dispose(); },
};
}

44
web/js/flight/dev.html Normal file
View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GUTS — Lane B flight/combat harness</title>
<!--
Lane B's dev harness. NOT the game — web/index.html is, and it's Lane F's.
This exists because boot.js has no player wiring yet (round 0 shipped a drift camera) and
Lane B may not edit boot.js. It boots the stub world + createPlayer + createCombat so the
controller can be flown and screenshotted this round.
Retire this once F pastes the snippet in LANE_B_NOTES.md §"-> Lane F".
http://localhost:8140/js/flight/dev.html
-->
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #02100d; }
canvas { display: block; }
#dbg { position: fixed; left: 10px; bottom: 10px; color: #39e6ff;
font: 12px/1.5 ui-monospace, monospace; text-shadow: 0 0 4px #000;
pointer-events: none; z-index: 10; white-space: pre; }
#hint { position: fixed; inset: 0; display: grid; place-content: center; text-align: center;
color: #dff2ff; font: 14px/1.8 ui-monospace, monospace; background: #02100dcc;
z-index: 20; cursor: pointer; }
#hint b { color: #5affd2; font-size: 20px; letter-spacing: .2em; }
.gone { display: none !important; }
</style>
<script type="importmap">
{ "imports": { "three": "../../vendor/three.module.js",
"three/addons/": "../../vendor/addons/" } }
</script>
</head>
<body>
<div id="dbg"></div>
<div id="hint">
<b>ENDO-1</b>
<div>click to fly &middot; lane B harness</div>
<div style="opacity:.6">
WASD move in the tube &middot; mouse aim &middot; LMB cannon &middot; RMB/F torpedo<br>
Space boost &middot; Shift/Ctrl throttle &middot; ease off to surf the crest &middot; Esc release
</div>
</div>
<script type="module" src="./dev.js"></script>
</body>
</html>

162
web/js/flight/dev.js Normal file
View File

@ -0,0 +1,162 @@
// flight/dev.js (Lane B) — harness boot for dev.html. Mirrors the parts of Lane F's boot.js
// that Lane B needs, and NOTHING else. Not shipped; not the game's entry point.
//
// It exists so flight/combat can be flown, tuned and screenshotted while boot.js still has
// no player wiring. The exact snippet F needs to paste into boot.js is in LANE_B_NOTES.md;
// when that lands, this file's only remaining job is isolated feel-tuning.
import * as THREE from 'three';
import { parseFlags } from '../core/flags.js';
import { createRng } from '../core/rng.js';
import { createBus } from '../core/bus.js';
import { createWorld as createStubWorld, STUB_LEVEL } from '../stub/world_stub.js';
import { createPlayer } from './player.js';
import { createCombat } from '../combat/index.js';
const flags = parseFlags();
const bus = createBus();
const rng = createRng(flags.seed ?? STUB_LEVEL.seed);
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 600);
const world = await createStubWorld(STUB_LEVEL, { rng });
scene.add(world.group);
scene.background = new THREE.Color(world.biomeAt(0).palette.void);
const player = createPlayer({ scene, world, bus, rng, flags, camera, dom: renderer.domElement });
const combat = createCombat({ scene, world, bus, rng, flags, player });
// --- dev-only level pump ----------------------------------------------------------------
// Emits `level:event` as the player crosses each event's s. NOBODY owns this in the shipped
// build yet — TECH.md specifies the event but not its emitter. Flagged to F in NOTES; this
// local copy keeps Lane B unblocked without implementing another lane's side.
function createLevelPump(level, b) {
const evts = [...(level.events ?? [])].sort((a, x) => a.s - x.s);
let i = 0;
return {
update(s) { while (i < evts.length && s >= evts[i].s) b.emit('level:event', evts[i++]); },
reset() { i = 0; },
};
}
const pump = createLevelPump(world.level, bus);
// --- DBG (mirrors boot.js so NOTES numbers are measured the same way) --------------------
const dbgEl = document.getElementById('dbg');
const hintEl = document.getElementById('hint');
let frames = 0, fpsT = 0, fps = 0;
renderer.info.autoReset = false;
const stats = { draws: 0, tris: 0 };
let hud = null;
bus.on('player:state', (s) => (hud = s));
let cstate = null;
bus.on('combat:state', (s) => (cstate = s));
window.DBG = {
get draws() { return stats.draws; },
get tris() { return stats.tris; },
get fps() { return fps; },
world, player, combat, bus, flags, renderer, scene, camera,
// Leak test (charter: "everything pooled + disposed, leak-test enter/exit"). Builds and
// tears down N player+combat pairs and reports GPU resource deltas via renderer.info.
leakTest(cycles = 5) {
// Render FIRST: renderer.info.memory only counts geometries actually uploaded to the
// GPU, so a baseline taken before any render reads 0 and the harness's own persistent
// objects then look like a leak on the next sample. Ask me how I know.
renderer.render(scene, camera);
const before = { ...renderer.info.memory, children: scene.children.length };
for (let i = 0; i < cycles; i++) {
const p = createPlayer({ scene, world, bus, rng, flags, camera, dom: renderer.domElement });
const c = createCombat({ scene, world, bus, rng, flags, player: p });
c.enemies.spawn('floater', { s: 10 }); c.enemies.spawn('turret', { s: 20 });
p.update(1 / 60); c.update(1 / 60);
renderer.render(scene, camera);
c.dispose(); p.dispose();
}
const after = { ...renderer.info.memory, children: scene.children.length };
return { before, after,
leaked: { geometries: after.geometries - before.geometries,
textures: after.textures - before.textures,
children: after.children - before.children } };
},
shot(name = 'shot') {
const a = document.createElement('a');
a.download = `${name}.png`;
a.href = renderer.domElement.toDataURL('image/png');
a.click();
},
// helpers for eyeballing behaviours without flying to them
spawn: (type, at = 12) => combat.enemies.spawn(type, { s: player.state.s + at }),
tp: (s) => { player.state.s = s; pump.reset(); },
// Synchronous fixed-timestep advance. Two reasons this exists:
// 1. requestAnimationFrame does not run in a hidden/on-demand-rendered tab, so the loop
// can't be exercised from an automated browser session without it.
// 2. It's deterministic — DBG.step(120, 1/60) is exactly 2 s of game time every time,
// independent of frame pacing, so measurements quoted in NOTES are reproducible.
step(n = 1, dt = 1 / 60) {
for (let i = 0; i < n; i++) tick(dt);
renderer.render(scene, camera);
stats.draws = renderer.info.render.calls;
stats.tris = renderer.info.render.triangles;
renderer.info.reset();
return { s: player.state.s, draws: stats.draws, tris: stats.tris };
},
get state() { return hud; },
get combatState() { return cstate; },
};
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// The one true update order (mirror it in boot.js — see LANE_B_NOTES §-> Lane F):
// player -> level pump -> combat -> world
// player first so combat hit-tests against this frame's ship position; world last so its
// shader time matches the s we just moved to.
function tick(dt) {
player.update(dt);
pump.update(player.state.s);
combat.update(dt);
world.update(dt, player.state.s);
}
let last = performance.now();
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
tick(dt);
renderer.render(scene, camera);
stats.draws = renderer.info.render.calls;
stats.tris = renderer.info.render.triangles;
renderer.info.reset();
hintEl.classList.toggle('gone', !!document.pointerLockElement || flags.shots);
frames++; fpsT += dt;
if (fpsT >= 0.5) {
fps = Math.round(frames / fpsT); frames = 0; fpsT = 0;
if (!flags.shots && hud) {
dbgEl.textContent =
`${fps} fps · ${stats.draws} draws · ${(stats.tris / 1000) | 0}k tris\n` +
`s ${hud.s.toFixed(0)}/${hud.length | 0} spd ${hud.speed.toFixed(1)} (flow ${hud.flow}) thr ${hud.throttle.toFixed(2)}` +
`${hud.surfing ? ' ◄SURF►' : ''}${hud.iframes ? ' [i]' : ''}\n` +
`coat ${hud.coat.toFixed(0)}/${hud.coatMax} hull ${hud.hull.toFixed(0)}/${hud.hullMax} ` +
`boost ${hud.boostReady ? 'READY' : hud.boostCd.toFixed(1)}\n` +
(cstate ? `heat ${cstate.heat.toFixed(0)}/${cstate.heatMax}${cstate.overheated ? ' OVERHEAT' : ''} ` +
`torp ${cstate.ammo}/${cstate.ammoMax} score ${cstate.score} kills ${cstate.kills} live ${cstate.enemies}` : '');
}
}
requestAnimationFrame(frame);
}
if (flags.shots) dbgEl.style.display = 'none';
requestAnimationFrame(frame);

71
web/js/flight/emissive.js Normal file
View File

@ -0,0 +1,71 @@
// flight/emissive.js (Lane B) — the one material behind every glowing thing B draws.
//
// ART_BIBLE: gameplay-relevant things glow, and the scene has NO lights (walls fake theirs
// with fresnel). So our primitives are unlit: a core colour with a rim that hots up at
// grazing angles, which reads as a volume against the dark tint instead of a flat decal.
// One material per (colour, rim) pair + InstancedMesh = 1 draw call per enemy/projectile
// type, which is how the whole combat layer fits in its 80-draw budget.
//
// Lives in flight/ rather than combat/ to keep the dependency arrow one-way: combat imports
// flight (it needs the player), flight never imports combat.
import * as THREE from 'three';
// ART_BIBLE §Emissive code — the readability law. Never hand-pick a colour outside this.
export const EMISSIVE = {
hostile: { core: 0xff5a2a, rim: 0xffb488 }, // hot amber -> red core
hostileShot: { core: 0xffe9d0, rim: 0xffffff }, // white-hot
neutral: { core: 0x39e6ff, rim: 0xdff2ff }, // cyan (player + flora + interactive)
pickup: { core: 0x7dffb0, rim: 0xdaffe9 },
gate: { core: 0xb06aff, rim: 0xe6d0ff },
acid: { core: 0xc8ff3a, rim: 0xeaffb0 },
};
export function emissiveMat({ core, rim }, { additive = false, opacity = 1 } = {}) {
return new THREE.ShaderMaterial({
transparent: opacity < 1 || additive,
depthWrite: !additive,
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
uniforms: {
uCore: { value: new THREE.Color(core) },
uRim: { value: new THREE.Color(rim) },
uOpacity: { value: opacity },
uFlash: { value: 0 }, // driven on hit: 0..1 blows the whole body to rim colour
},
vertexShader: /* glsl */`
varying vec3 vN; varying vec3 vV;
void main() {
// three declares the instanceMatrix attribute for us whenever the drawn object is
// an InstancedMesh (USE_INSTANCING); this same material still works on a plain Mesh.
// (Careful: no backticks in here — this is a JS template literal.)
#ifdef USE_INSTANCING
mat4 m = modelViewMatrix * instanceMatrix;
#else
mat4 m = modelViewMatrix;
#endif
vec4 mv = m * vec4(position, 1.0);
// mat3(m) is only a true normal matrix under uniform scale — every instance we
// emit is uniformly scaled, and normalize() eats the leftover constant.
vN = normalize(mat3(m) * normal);
vV = -mv.xyz;
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: /* glsl */`
varying vec3 vN; varying vec3 vV;
uniform vec3 uCore; uniform vec3 uRim; uniform float uOpacity; uniform float uFlash;
void main() {
float f = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 2.0);
vec3 col = mix(uCore, uRim, f) + uRim * f * 0.6;
col = mix(col, uRim * 1.6, uFlash);
gl_FragColor = vec4(col, uOpacity);
// MANDATORY for any custom ShaderMaterial in this project. ColorManagement is on,
// so THREE.Color(0xff5a2a) is converted to LINEAR working space on the way in, but
// a hand-written fragment shader gets no automatic conversion on the way out — it
// would write linear straight into the sRGB target. Measured: hostile amber landed
// as rgb(255,26,6) blood-red instead of rgb(255,90,42), quietly breaking the
// ART_BIBLE emissive readability law. This chunk applies linear -> sRGB.
#include <colorspace_fragment>
}`,
});
}

148
web/js/flight/input.js Normal file
View File

@ -0,0 +1,148 @@
// flight/input.js (Lane B) — raw devices -> one normalized intent object. No game logic
// here; player.js reads `intent` and never touches an event listener.
//
// The scheme is twin-stick, which is what makes the charter's "mouse-aim + WASD" and
// "gamepad twin-stick" the same game rather than two:
// left hand (WASD / left stick) -> move the ship inside the cross-section disc
// right hand (mouse / right stick) -> aim the reticle (the ship's nose follows it, lagged)
// Shift/Ctrl (or LT/RT) -> throttle, springs back to neutral when released
// Space / A / RB -> boost
// LMB / RT / X -> fire
// WASD is therefore NOT throttle. Throttle is a modifier because it lives in [0.6,1.4] and
// spends most of its life at 1.0 — it's a lean, not a stick.
const DEADZONE = 0.18;
const dz = (v) => (Math.abs(v) < DEADZONE ? 0 : (v - Math.sign(v) * DEADZONE) / (1 - DEADZONE));
export function createInput(dom = document.body) {
const keys = new Set();
const intent = {
disc: { x: 0, y: 0 }, // 1..1 desired disc movement (x = lateral/bin, y = up/nor)
aimDelta: { x: 0, y: 0 }, // mouse pixels accumulated since the last read (consumed)
aimStick: { x: 0, y: 0 }, // 1..1 right stick
throttle: 0, // 1..1 (Ctrl .. Shift)
boost: false, // edge-triggered: true for exactly one read
fire: false, // held
fire2: false, // edge-triggered
locked: false, // pointer lock state (the "am I flying" signal)
};
// Held inputs are tracked per-device and OR'd each sample. Sharing one flag would latch:
// the trigger sets it true, and only mouse-up ever sets it false again.
let mouseFire = false, padFire = false;
let boostEdge = false, fire2Edge = false, padBoostWas = false, padFire2Was = false;
const onKeyDown = (e) => {
keys.add(e.code);
if (e.code === 'Space') { boostEdge = true; e.preventDefault(); }
if (e.code === 'KeyF') fire2Edge = true;
};
const onKeyUp = (e) => keys.delete(e.code);
const onBlur = () => keys.clear(); // alt-tab must not leave a key stuck down
const onMouseMove = (e) => {
if (!intent.locked) return;
intent.aimDelta.x += e.movementX;
intent.aimDelta.y += e.movementY;
};
const onMouseDown = (e) => {
if (!intent.locked) { dom.requestPointerLock?.(); return; }
if (e.button === 0) mouseFire = true;
if (e.button === 2) fire2Edge = true;
};
const onMouseUp = (e) => { if (e.button === 0) mouseFire = false; };
const onLockChange = () => {
intent.locked = document.pointerLockElement === dom;
if (!intent.locked) { mouseFire = false; keys.clear(); }
};
const onContext = (e) => e.preventDefault(); // RMB is the torpedo, not a menu
addEventListener('keydown', onKeyDown);
addEventListener('keyup', onKeyUp);
addEventListener('blur', onBlur);
addEventListener('mousemove', onMouseMove);
dom.addEventListener('mousedown', onMouseDown);
addEventListener('mouseup', onMouseUp);
document.addEventListener('pointerlockchange', onLockChange);
dom.addEventListener('contextmenu', onContext);
function readPad() {
padFire = false;
const pads = navigator.getGamepads?.() || [];
for (const p of pads) {
if (!p?.connected) continue;
const [lx, ly, rx, ry] = p.axes;
// left stick wins over WASD only when actually deflected, so both can coexist
const dx = dz(lx ?? 0), dy = dz(ly ?? 0);
if (dx || dy) { intent.disc.x = dx; intent.disc.y = -dy; }
intent.aimStick.x = dz(rx ?? 0);
intent.aimStick.y = -dz(ry ?? 0);
const lt = p.buttons[6]?.value ?? 0, rt = p.buttons[7]?.value ?? 0;
if (lt > 0.05 || rt > 0.05) intent.throttle = rt - lt;
padFire = rt > 0.5;
const padBoost = !!(p.buttons[0]?.pressed || p.buttons[5]?.pressed);
if (padBoost && !padBoostWas) boostEdge = true;
padBoostWas = padBoost;
const padFire2 = !!p.buttons[2]?.pressed;
if (padFire2 && !padFire2Was) fire2Edge = true;
padFire2Was = padFire2;
return;
}
}
return {
intent,
// Called once per frame by player.js, before it reads `intent`.
sample() {
intent.disc.x = (keys.has('KeyD') ? 1 : 0) - (keys.has('KeyA') ? 1 : 0);
intent.disc.y = (keys.has('KeyW') ? 1 : 0) - (keys.has('KeyS') ? 1 : 0);
intent.aimStick.x = 0; intent.aimStick.y = 0;
intent.throttle = (keys.has('ShiftLeft') || keys.has('ShiftRight') ? 1 : 0) -
(keys.has('ControlLeft') || keys.has('ControlRight') ? 1 : 0);
readPad();
intent.fire = mouseFire || padFire;
// normalize diagonal so corner-dodging isn't 41% faster than a cardinal one
const m = Math.hypot(intent.disc.x, intent.disc.y);
if (m > 1) { intent.disc.x /= m; intent.disc.y /= m; }
intent.boost = boostEdge; boostEdge = false;
intent.fire2 = fire2Edge; fire2Edge = false;
return intent;
},
// player.js consumes the accumulated mouse delta exactly once per frame
takeAim() {
const d = { x: intent.aimDelta.x, y: intent.aimDelta.y };
intent.aimDelta.x = 0; intent.aimDelta.y = 0;
return d;
},
// Harness/test hook (flight/dev.html). Keyboard intents can be driven with real
// KeyboardEvents, but firing is gated behind pointer lock, which an automated session
// can't be granted — so the trigger needs a door. Dev-only; nothing ships against it.
_debug: {
setFire(v) { mouseFire = !!v; },
pulseFire2() { fire2Edge = true; },
pulseBoost() { boostEdge = true; },
key(code, down = true) { down ? keys.add(code) : keys.delete(code); },
},
dispose() {
removeEventListener('keydown', onKeyDown);
removeEventListener('keyup', onKeyUp);
removeEventListener('blur', onBlur);
removeEventListener('mousemove', onMouseMove);
dom.removeEventListener('mousedown', onMouseDown);
removeEventListener('mouseup', onMouseUp);
document.removeEventListener('pointerlockchange', onLockChange);
dom.removeEventListener('contextmenu', onContext);
if (document.pointerLockElement === dom) document.exitPointerLock?.();
},
};
}

277
web/js/flight/player.js Normal file
View File

@ -0,0 +1,277 @@
// flight/player.js (Lane B) — ENDO-1's controller, camera rig and durability.
//
// THE MODEL (GDD "flow-locked hybrid", tube mode). The player does not live in world space;
// it lives in the world's spline space and is rendered into world space each frame:
//
// s arclength down the canal. Peristalsis owns it: ds/dt = flow·(throttle+surf+boost).
// You can lean on it ±40%, burst past it, or surf it. You cannot stop or reverse.
// x, y the cross-section disc offset — x along frame.bin (lateral), y along frame.nor
// (vertical). This is where ALL your agency is, and it is fully analog.
//
// worldPos = frame.pos + frame.nor·y + frame.bin·x (frame = world.sample(s))
// theta = atan2(x, y) — matches world.project()'s atan2(rel·bin, rel·nor)
//
// Everything downstream falls out of this: collision is one radius compare against
// world.wallRho, the camera is the same three numbers lagged, and enemies chase in the same
// space for pennies. Arena/6DOF mode is round 2 (see NOTES); modeAt() is guarded below.
import * as THREE from 'three';
import { TUNING as T } from './tuning.js';
import { createInput } from './input.js';
import { buildShip, buildReticle } from './ship.js';
export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null, camera, dom }) {
const input = createInput(dom);
const ship = buildShip(assets);
const reticle = buildReticle();
scene.add(ship.group, reticle.mesh);
const st = {
s: 2, x: 0, y: 0, // spline-space position
vx: 0, vy: 0, // disc velocity (u/s)
ax: 0, ay: 0, // aim offset in the disc plane at s + aim.distance
throttle: 1,
boostT: 0, boostCd: 0, boostBlend: 0, iframeT: 0,
surfBlend: 0, onCrest: false,
coat: T.coat.max, hull: T.hull.max, sinceDamage: 99, wallCd: 0,
roll: 0, pitch: 0,
speed: 0, alive: true,
};
// scratch — this runs 60×/s; nothing in the loop allocates
const _pos = new THREE.Vector3(), _aim = new THREE.Vector3(), _look = new THREE.Vector3();
const _aimLag = new THREE.Vector3(), _camPos = new THREE.Vector3(), _tmp = new THREE.Vector3();
let aimLagInit = false;
const discToWorld = (out, frame, x, y) =>
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
const clampS = (s) => THREE.MathUtils.clamp(s, 0, world.length);
// --- durability -------------------------------------------------------------------
// Discrete hits emit player:damage. Continuous drain (biome coatDrain, enemy auras) does
// NOT — it would spam the bus 60×/s and E would render a permanent damage flash. Lane E
// reads continuous state off player:state instead. Auras tick discretely (combat/enemies).
function damage(amount, kind = 'hit') {
if (!st.alive || st.iframeT > 0 || amount <= 0) return false;
st.sinceDamage = 0;
const toCoat = Math.min(st.coat, amount);
st.coat -= toCoat;
const leak = amount - toCoat;
if (leak > 0) st.hull = Math.max(0, st.hull - leak);
bus.emit('player:damage', { amount, kind });
bus.emit('audio:cue', { name: leak > 0 ? 'hull_hit' : 'coat_hit' });
if (st.hull <= 0 && st.alive) { st.alive = false; bus.emit('player:death', { s: st.s }); }
return true;
}
function drain(amount) { // continuous, silent — coat only, never kills
if (!st.alive || amount <= 0) return;
st.coat = Math.max(0, st.coat - amount);
}
// --- the loop ---------------------------------------------------------------------
function update(dt) {
const intent = input.sample();
const mouse = input.takeAim();
const biome = world.biomeAt(st.s);
if (!st.alive) { updateCamera(dt, world.sample(st.s)); return; }
// throttle: a lean, springing back to neutral
if (intent.throttle) st.throttle += intent.throttle * T.flow.throttleRate * dt;
else st.throttle += (1 - st.throttle) * Math.min(1, T.flow.throttleReturn * dt * 4);
st.throttle = THREE.MathUtils.clamp(st.throttle, T.flow.throttleMin, T.flow.throttleMax);
// boost
st.wallCd = Math.max(0, st.wallCd - dt);
st.boostCd = Math.max(0, st.boostCd - dt);
st.boostT = Math.max(0, st.boostT - dt);
st.iframeT = Math.max(0, st.iframeT - dt);
if (intent.boost && st.boostCd <= 0) {
st.boostT = T.boost.duration;
st.boostCd = T.boost.cooldown;
st.iframeT = T.boost.iframes;
bus.emit('player:boost', { s: st.s });
bus.emit('audio:cue', { name: 'boost' });
}
const boostTarget = st.boostT > 0 ? 1 : 0;
st.boostBlend += (boostTarget - st.boostBlend) * Math.min(1, (boostTarget ? 14 : 3.5) * dt);
// surf: ride the peristalsis crest. flowPulse is Lane A's JS mirror of the wall's vertex
// displacement — see NOTES, it's charter-specified but missing from the TECH contract.
const pulse = world.flowPulse ? world.flowPulse(st.s) : 0;
st.onCrest = pulse > T.surf.threshold;
const surfTarget = st.onCrest ? 1 : 0;
if (Math.abs(surfTarget - st.surfBlend) > 0.001) {
const was = st.surfBlend > 0.5;
st.surfBlend += (surfTarget - st.surfBlend) * Math.min(1, T.surf.ramp * dt);
if (was !== st.surfBlend > 0.5) bus.emit('player:surf', { active: st.surfBlend > 0.5, s: st.s });
}
// forward motion — the flow carries you; you only ever scale it
st.speed = biome.flow * (st.throttle + st.surfBlend * T.surf.gain + st.boostBlend * T.boost.gain);
st.s = clampS(st.s + st.speed * dt);
// disc movement
st.vx += intent.disc.x * T.disc.accel * dt;
st.vy += intent.disc.y * T.disc.accel * dt;
const damp = Math.exp(-T.disc.damping * dt);
st.vx *= damp; st.vy *= damp;
const sp = Math.hypot(st.vx, st.vy);
if (sp > T.disc.maxSpeed) { st.vx *= T.disc.maxSpeed / sp; st.vy *= T.disc.maxSpeed / sp; }
st.x += st.vx * dt;
st.y += st.vy * dt;
const frame = world.sample(st.s);
discToWorld(_pos, frame, st.x, st.y);
collideWall(frame);
// aim
const aimIn = {
x: mouse.x * T.aim.sensitivity + intent.aimStick.x * T.aim.padSpeed * dt,
y: -mouse.y * T.aim.sensitivity + intent.aimStick.y * T.aim.padSpeed * dt,
};
st.ax += aimIn.x; st.ay += aimIn.y;
if (!aimIn.x && !aimIn.y) { // hands off: drift back to centre
const k = Math.min(1, T.aim.recenter * dt);
st.ax -= st.ax * k; st.ay -= st.ay * k;
}
const ar = Math.hypot(st.ax, st.ay);
if (ar > T.aim.range) { st.ax *= T.aim.range / ar; st.ay *= T.aim.range / ar; }
const aimFrame = world.sample(clampS(st.s + T.aim.distance));
discToWorld(_aim, aimFrame, st.ax, st.ay);
reticle.mesh.position.copy(_aim);
if (camera) reticle.mesh.lookAt(camera.position);
reticle.setHot(intent.fire ? 1 : 0);
// the ship's nose chases the reticle rather than snapping to it (Star Fox lag)
if (!aimLagInit) { _aimLag.copy(_aim); aimLagInit = true; }
_aimLag.lerp(_aim, Math.min(1, T.aim.lag * dt));
// coat: biome ambient drain, then delayed regen
st.sinceDamage += dt;
drain((biome.coatDrain ?? 0) * dt);
if (st.sinceDamage > T.coat.regenDelay)
st.coat = Math.min(T.coat.max, st.coat + T.coat.regen * dt);
poseShip(dt, frame);
updateCamera(dt, frame);
emitState(biome);
}
// Arcade wall response: shove + graze damage, never a hard stop. Forward motion is the
// flow's and is untouched — scraping a fold costs coat and control, not your run.
function collideWall(frame) {
const hit = world.collide(_pos, T.ship.radius);
if (!hit) return;
const rho = Math.hypot(st.x, st.y);
if (rho < 1e-5) return;
const ux = st.x / rho, uy = st.y / rho; // outward radial, in disc coords
st.x -= ux * hit.push.length(); // back inside the wall
st.y -= uy * hit.push.length();
const vr = st.vx * ux + st.vy * uy; // >0 = travelling into the wall
if (vr > 0) {
st.vx -= (1 + T.wall.restitution) * vr * ux;
st.vy -= (1 + T.wall.restitution) * vr * uy;
}
st.vx -= ux * T.wall.shoveOut;
st.vy -= uy * T.wall.shoveOut;
if (vr > T.wall.grazeFloor && st.wallCd <= 0) {
damage(Math.min(T.wall.maxHit, (vr - T.wall.grazeFloor) * T.wall.damagePerSpeed), hit.kind);
st.wallCd = T.wall.cooldown;
bus.emit('audio:cue', { name: 'wall_scrape' });
}
discToWorld(_pos, frame, st.x, st.y);
}
function poseShip(dt, frame) {
const rollTarget = THREE.MathUtils.clamp(-st.vx * T.bank.gain, -T.bank.max, T.bank.max);
st.roll += (rollTarget - st.roll) * Math.min(1, T.bank.rate * dt);
st.pitch += (st.vy * T.bank.pitchGain - st.pitch) * Math.min(1, T.bank.rate * dt);
ship.group.position.copy(_pos);
ship.group.up.copy(frame.nor);
ship.group.lookAt(_aimLag); // hull is authored nose-toward Z, so this aims it
ship.group.rotateZ(st.roll); // roll about the view axis = bank
ship.group.rotateX(st.pitch);
ship.setThrust(THREE.MathUtils.clamp((st.throttle - 0.6) / 0.8, 0, 1) * 0.6 + st.boostBlend * 0.4);
}
// Chase cam on the parallel-transport frame. up = frame.nor, ALWAYS — never world-up.
// If this ever rolls or snaps, the frame is the suspect, not this function (charter: file
// it to Lane A rather than patching here).
const cam = { s: 0, x: 0, y: 0, fov: T.cam.fov, init: false };
function updateCamera(dt, frame) {
if (!camera) return;
if (!cam.init) { cam.s = st.s - T.cam.back; cam.x = st.x; cam.y = st.y; cam.init = true; }
cam.s = clampS(st.s - T.cam.back);
const k = Math.min(1, T.cam.discLag * dt);
cam.x += (st.x * T.cam.discFollow - cam.x) * k;
cam.y += (st.y * T.cam.discFollow - cam.y) * k;
const cf = world.sample(cam.s);
discToWorld(_camPos, cf, cam.x, cam.y + T.cam.up);
camera.position.copy(_camPos);
const lf = world.sample(clampS(st.s + T.cam.lookAhead));
discToWorld(_look, lf, st.x * 0.5, st.y * 0.5);
_look.lerp(_tmp.copy(_aimLag), T.cam.aimInfluence);
camera.up.copy(frame.nor);
camera.lookAt(_look);
const fovTarget = T.cam.fov + st.boostBlend * T.boost.fovKick;
if (Math.abs(fovTarget - cam.fov) > 0.01) {
cam.fov += (fovTarget - cam.fov) * Math.min(1, T.cam.fovRate * dt);
camera.fov = cam.fov;
camera.updateProjectionMatrix();
}
}
// Lane E's HUD is bus-driven only, so continuous state has to arrive as an event. One
// small object per frame; treat it as read-only, don't retain it.
function emitState(biome) {
bus.emit('player:state', {
coat: st.coat, coatMax: T.coat.max,
hull: st.hull, hullMax: T.hull.max,
speed: st.speed, flow: biome.flow, throttle: st.throttle,
boostReady: st.boostCd <= 0, boostCd: st.boostCd, boostCdMax: T.boost.cooldown,
surfing: st.surfBlend > 0.5, iframes: st.iframeT > 0,
s: st.s, length: world.length, progress: world.length ? st.s / world.length : 0,
biome: biome.id, alive: st.alive,
});
}
bus.emit('player:spawn', { s: st.s });
return {
update,
state: st,
// combat/ reads this for fire/fire2. Safe because input.sample() is called exactly once
// per frame, here, and boot runs player.update() before combat.update() — so the
// edge-triggered flags (fire2) are still live when the weapons read them this frame.
get intent() { return input.intent; },
get _input() { return input; }, // harness only (flight/dev.js) — see input.js._debug
get position() { return _pos; },
get aimPoint() { return _aim; },
get radius() { return T.ship.radius; },
get forward() { return world.sample(st.s).tan; },
damage,
drain,
respawn(s = 0) {
Object.assign(st, { s, x: 0, y: 0, vx: 0, vy: 0, coat: T.coat.max, hull: T.hull.max, alive: true });
cam.init = false; aimLagInit = false;
bus.emit('player:spawn', { s });
},
dispose() {
scene.remove(ship.group, reticle.mesh);
ship.dispose(); reticle.dispose(); input.dispose();
},
};
}

86
web/js/flight/ship.js Normal file
View File

@ -0,0 +1,86 @@
// flight/ship.js (Lane B) — ENDO-1, the probe you fly, and the reticle you aim with.
//
// Procedural primitives merged into ONE geometry: assets are optional by law (TECH §manifest),
// so this is the shipping look until Lane D's GLB lands, and the permanent fallback after.
// Convention: the hull is built nose-toward Z so Object3D.lookAt() aims it with no fudge.
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { emissiveMat, EMISSIVE } from './emissive.js';
// D's GLB swaps in here when it exists; logic above never learns which one it got.
export function buildShip(assets = null) {
const group = new THREE.Group();
group.name = 'endo1';
const glb = assets?.get?.('models', 'endo1') ?? null;
const hull = glb ? glb.scene ?? glb : proceduralHull();
group.add(hull);
// engine bloom at the tail: additive, unlit, reads as thrust without a light
const glowGeo = new THREE.SphereGeometry(0.34, 12, 8);
const glowMat = emissiveMat(EMISSIVE.neutral, { additive: true, opacity: 0.9 });
const glow = new THREE.Mesh(glowGeo, glowMat);
glow.position.z = 0.95;
group.add(glow);
const disposables = [glowGeo, glowMat];
if (!glb) { disposables.push(hull.geometry, hull.material); }
return {
group,
glow,
setThrust(t) { glow.scale.setScalar(0.7 + t * 0.9); glowMat.uniforms.uOpacity.value = 0.5 + t * 0.5; },
setFlash(v) { if (hull.material?.uniforms?.uFlash) hull.material.uniforms.uFlash.value = v; },
dispose() { for (const d of disposables) d?.dispose?.(); },
};
}
function proceduralHull() {
const parts = [];
const body = new THREE.CapsuleGeometry(0.4, 0.9, 4, 12);
body.rotateX(Math.PI / 2); // capsule is +Y by default; we fly down Z
parts.push(body);
const nose = new THREE.ConeGeometry(0.4, 0.85, 12);
nose.rotateX(-Math.PI / 2);
nose.translate(0, 0, -1.08);
parts.push(nose);
for (const side of [-1, 1]) {
const fin = new THREE.BoxGeometry(0.62, 0.07, 0.44);
fin.translate(side * 0.52, -0.04, 0.36);
fin.rotateZ(side * -0.22);
parts.push(fin);
}
const collar = new THREE.TorusGeometry(0.42, 0.06, 6, 14);
collar.translate(0, 0, -0.28);
parts.push(collar);
const geo = mergeGeometries(parts, false);
for (const p of parts) p.dispose();
return new THREE.Mesh(geo, emissiveMat(EMISSIVE.neutral));
}
// The reticle floats TUNING.aim.distance ahead in the disc plane. Depth-test off: it is an
// instrument reading, not an object in the gut — it must never be swallowed by a fold.
export function buildReticle() {
const geo = new THREE.RingGeometry(0.55, 0.72, 20);
const mat = emissiveMat(EMISSIVE.neutral, { additive: true, opacity: 0.85 });
mat.depthTest = false;
const mesh = new THREE.Mesh(geo, mat);
mesh.renderOrder = 10;
const dotGeo = new THREE.CircleGeometry(0.12, 10);
const dot = new THREE.Mesh(dotGeo, mat);
dot.renderOrder = 10;
mesh.add(dot);
return {
mesh,
setHot(v) { mat.uniforms.uFlash.value = v; }, // hot while the cannon is firing
dispose() { geo.dispose(); dotGeo.dispose(); mat.dispose(); },
};
}

113
web/js/flight/tuning.js Normal file
View File

@ -0,0 +1,113 @@
// flight/tuning.js (Lane B) — THE FEEL. Every number that decides how GUTS handles lives
// here and nowhere else. Tuned by hand on the stub esophagus; re-tune per biome later.
//
// Stub reference frame (js/stub/world_stub.js), which all of these are sized against:
// biome.flow = 14 u/s · radius 10 ±15% wobble => 8.5..11.5
// wallRho = radius waveAmp(0.9) skin(0.6) => a ~7.0..10.0 playable disc radius
// peristalsis crest travels at WAVE_W/WAVE_K = 3.0/0.22 = 13.64 u/s
//
// SURF IS A SHOVE, NOT YET A RIDE — and that is a world-side problem, not a tuning one.
// Measured in-engine this round (numbers in LANE_B_NOTES §surf):
// crest phase speed = WAVE_W/WAVE_K = 3.0/0.22 = 13.64 u/s
// player top speed = flow × throttleMax = 14 × 1.4 = 19.6 u/s
// A wave travelling 13.64 u/s cannot carry anything faster than 13.64 u/s, so "ride the
// crest" can never be the fast line while the wave is slower than the player. Worse, the
// bonus is self-cancelling: the crest window is fixed in SPACE, so a bigger surf.gain just
// ejects you out the front sooner. Measured distance gained per crest is ~3.0 units at
// gain 0.4, 0.75 AND 1.2 — identical. Tuning this number cannot fix it.
//
// So surf currently reads as what the wave physically is: a rhythmic peristaltic SHOVE as
// each crest sweeps over you (~0.3-0.6 s at 24.5 u/s), which is honest and feels good, but
// it is not the level-2 speed line the GDD asks for. The fix belongs to whoever owns the
// wave constants (stub = F, world = A): crest speed must exceed 19.6 u/s. Escalated in
// LANE_B_NOTES §"-> Lane A / F" with options. Do not paper over it here.
//
// gain below is therefore chosen for FEEL (punch of the shove), not for speed economy.
export const TUNING = {
ship: {
radius: 0.9, // collision sphere (hull reads ~1.9 long, 1.2 wide)
},
// --- forward motion along s -------------------------------------------------------
flow: {
throttleMin: 0.6, // GDD: throttle is ±40% of biome flow, never a full stop
throttleMax: 1.4,
throttleRate: 1.8, // throttle units/s while held (0.6 -> 1.4 in ~0.45 s)
throttleReturn: 0.9, // /s spring back to neutral 1.0 when hands off
},
boost: {
gain: 1.2, // +120% of biome flow (GDD)
duration: 0.55, // s of burst
cooldown: 2.4, // s from spend until available again
iframes: 0.35, // s invulnerable "at spend" (GDD) — shorter than the burst,
// so boosting *through* something is a read, not a get-out
fovKick: 13, // degrees of extra FOV at full burst (speed you feel)
},
surf: {
threshold: 0.45, // world.flowPulse(s,t) above this = on the crest.
// pulse=pow(sin,3) so 0.45 => sin>0.766 => the peak 22% of
// each wave: a ~6.3 u window in a 28.6 u wavelength.
gain: 0.75, // +75% flow while on a crest => a 24.5 u/s shove for ~0.3-0.6 s.
// Feel-tuned only; see the surf note in the header before
// touching it expecting a speed change.
ramp: 3.0, // /s blend in/out so cresting doesn't pop
},
// --- movement inside the cross-section disc ---------------------------------------
disc: {
accel: 78, // u/s²
maxSpeed: 13, // u/s — crosses the ~16 u tube in ~1.2 s: dodgeable, not twitchy
damping: 6.5, // /s exponential drag => coasts to rest in ~0.3 s
},
// --- aim: the reticle leads, the ship follows (Star Fox lag) -----------------------
aim: {
distance: 26, // u ahead (in s) that the reticle plane floats
sensitivity: 0.022, // disc units per pixel of mouse movement
padSpeed: 20, // u/s at full right-stick deflection
range: 9, // max |aim| from the centreline (≈ the widest wallRho)
recenter: 0.8, // /s drift back to centre when hands off
lag: 9, // /s spring — the ship's nose chases the reticle, never snaps
},
// --- attitude: bank into lateral motion -------------------------------------------
bank: {
gain: 0.055, // rad of roll per u/s of lateral disc velocity
max: 0.75, // rad (~43°)
rate: 7, // /s spring toward target roll
pitchGain: 0.03, // rad of pitch per u/s of vertical disc velocity
},
// --- chase camera ------------------------------------------------------------------
cam: {
back: 7.2, // u behind the ship (along tan, measured in s)
up: 1.4, // u along the frame normal — ship sits low-centre
lookAhead: 16, // u ahead of the ship for the look target
discLag: 8.0, // /s spring on the camera's own disc offset
discFollow: 0.82, // camera tracks only 82% of the ship's offset, so hard dodges
// visibly move the ship within the frame instead of gluing it
aimInfluence: 0.35, // how much the look target drifts toward the reticle
fov: 75, // matches boot.js's camera so wiring in changes nothing
fovRate: 4.5, // /s toward target fov
},
// --- durability (GDD: regenerating mucus coat over a pickup-only hull) -------------
coat: {
max: 100,
regen: 8, // /s
regenDelay: 2.5, // s after the last damage before regen resumes
// ambient drain is the biome's: world.biomeAt(s).coatDrain (esophagus = 0.5/s)
},
hull: { max: 100 },
// --- wall contact: arcade. Shove + graze damage, NEVER a hard stop -----------------
wall: {
restitution: 0.35, // fraction of inward velocity returned on contact
grazeFloor: 3.0, // impact u/s below this is free — you can lean on the wall
damagePerSpeed: 1.6, // coat damage per u/s of impact above grazeFloor
maxHit: 28, // cap: a full-speed slam is a mistake, not a death
shoveOut: 1.0, // extra u/s pushed off the wall
cooldown: 0.25, // s between wall damage ticks (scraping ≠ machine-gun damage)
},
};