diff --git a/docs/LANES/LANE_B_NOTES.md b/docs/LANES/LANE_B_NOTES.md new file mode 100644 index 0000000..09e2b2a --- /dev/null +++ b/docs/LANES/LANE_B_NOTES.md @@ -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 1–4 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.3−3.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.3–0.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 ` 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. diff --git a/docs/progress/b-progress.md b/docs/progress/b-progress.md new file mode 100644 index 0000000..12080f1 --- /dev/null +++ b/docs/progress/b-progress.md @@ -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 1–4 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 `** — 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. diff --git a/docs/shots/laneB/round1_boost.webp b/docs/shots/laneB/round1_boost.webp new file mode 100644 index 0000000..6bf2ea3 Binary files /dev/null and b/docs/shots/laneB/round1_boost.webp differ diff --git a/docs/shots/laneB/round1_combat.webp b/docs/shots/laneB/round1_combat.webp new file mode 100644 index 0000000..745a38b Binary files /dev/null and b/docs/shots/laneB/round1_combat.webp differ diff --git a/docs/shots/laneB/round1_enemies.webp b/docs/shots/laneB/round1_enemies.webp new file mode 100644 index 0000000..7ca8578 Binary files /dev/null and b/docs/shots/laneB/round1_enemies.webp differ diff --git a/docs/shots/laneB/round1_wallgraze.webp b/docs/shots/laneB/round1_wallgraze.webp new file mode 100644 index 0000000..abfdeaf Binary files /dev/null and b/docs/shots/laneB/round1_wallgraze.webp differ diff --git a/web/js/combat/balance.js b/web/js/combat/balance.js new file mode 100644 index 0000000..5dedc16 --- /dev/null +++ b/web/js/combat/balance.js @@ -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 }, +}; diff --git a/web/js/combat/enemies.js b/web/js/combat/enemies.js new file mode 100644 index 0000000..b39efef --- /dev/null +++ b/web/js/combat/enemies.js @@ -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(); + }, + }; +} diff --git a/web/js/combat/index.js b/web/js/combat/index.js new file mode 100644 index 0000000..7093913 --- /dev/null +++ b/web/js/combat/index.js @@ -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(); + }, + }; +} diff --git a/web/js/combat/weapons.js b/web/js/combat/weapons.js new file mode 100644 index 0000000..8aa76e4 --- /dev/null +++ b/web/js/combat/weapons.js @@ -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(); }, + }; +} diff --git a/web/js/flight/dev.html b/web/js/flight/dev.html new file mode 100644 index 0000000..fd333ec --- /dev/null +++ b/web/js/flight/dev.html @@ -0,0 +1,44 @@ + + + + + +GUTS — Lane B flight/combat harness + + + + + +
+
+ ENDO-1 +
click to fly · lane B harness
+
+ WASD move in the tube · mouse aim · LMB cannon · RMB/F torpedo
+ Space boost · Shift/Ctrl throttle · ease off to surf the crest · Esc release +
+
+ + + diff --git a/web/js/flight/dev.js b/web/js/flight/dev.js new file mode 100644 index 0000000..f0d58c6 --- /dev/null +++ b/web/js/flight/dev.js @@ -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); diff --git a/web/js/flight/emissive.js b/web/js/flight/emissive.js new file mode 100644 index 0000000..fd89a51 --- /dev/null +++ b/web/js/flight/emissive.js @@ -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 + }`, + }); +} diff --git a/web/js/flight/input.js b/web/js/flight/input.js new file mode 100644 index 0000000..8a4a8f9 --- /dev/null +++ b/web/js/flight/input.js @@ -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?.(); + }, + }; +} diff --git a/web/js/flight/player.js b/web/js/flight/player.js new file mode 100644 index 0000000..8997a12 --- /dev/null +++ b/web/js/flight/player.js @@ -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(); + }, + }; +} diff --git a/web/js/flight/ship.js b/web/js/flight/ship.js new file mode 100644 index 0000000..2516960 --- /dev/null +++ b/web/js/flight/ship.js @@ -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(); }, + }; +} diff --git a/web/js/flight/tuning.js b/web/js/flight/tuning.js new file mode 100644 index 0000000..1d1a063 --- /dev/null +++ b/web/js/flight/tuning.js @@ -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) + }, +};