From 0a038582a71fc7753ac8deeb70126642f6e34c6b Mon Sep 17 00:00:00 2001 From: jing Date: Thu, 16 Jul 2026 08:56:32 +1000 Subject: [PATCH] [lane B] Round 1: flight controller, cannon, enemy framework on the stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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 --- docs/LANES/LANE_B_NOTES.md | 236 +++++++++++++++++ docs/progress/b-progress.md | 39 +++ docs/shots/laneB/round1_boost.webp | Bin 0 -> 22924 bytes docs/shots/laneB/round1_combat.webp | Bin 0 -> 24780 bytes docs/shots/laneB/round1_enemies.webp | Bin 0 -> 24648 bytes docs/shots/laneB/round1_wallgraze.webp | Bin 0 -> 23644 bytes web/js/combat/balance.js | 59 +++++ web/js/combat/enemies.js | 334 +++++++++++++++++++++++++ web/js/combat/index.js | 58 +++++ web/js/combat/weapons.js | 216 ++++++++++++++++ web/js/flight/dev.html | 44 ++++ web/js/flight/dev.js | 162 ++++++++++++ web/js/flight/emissive.js | 71 ++++++ web/js/flight/input.js | 148 +++++++++++ web/js/flight/player.js | 277 ++++++++++++++++++++ web/js/flight/ship.js | 86 +++++++ web/js/flight/tuning.js | 113 +++++++++ 17 files changed, 1843 insertions(+) create mode 100644 docs/LANES/LANE_B_NOTES.md create mode 100644 docs/progress/b-progress.md create mode 100644 docs/shots/laneB/round1_boost.webp create mode 100644 docs/shots/laneB/round1_combat.webp create mode 100644 docs/shots/laneB/round1_enemies.webp create mode 100644 docs/shots/laneB/round1_wallgraze.webp create mode 100644 web/js/combat/balance.js create mode 100644 web/js/combat/enemies.js create mode 100644 web/js/combat/index.js create mode 100644 web/js/combat/weapons.js create mode 100644 web/js/flight/dev.html create mode 100644 web/js/flight/dev.js create mode 100644 web/js/flight/emissive.js create mode 100644 web/js/flight/input.js create mode 100644 web/js/flight/player.js create mode 100644 web/js/flight/ship.js create mode 100644 web/js/flight/tuning.js 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 0000000000000000000000000000000000000000..6bf2ea3664eb6f5d631d473f78166d980a339e1d GIT binary patch literal 22924 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

bv6SYU5UwI6m|aCEQVH9afMlYP3++9Mr>JC5OD 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 Ukm?vIqyt7RQFC;Ta32&v0L%d4>;M1& literal 0 HcmV?d00001 diff --git a/docs/shots/laneB/round1_combat.webp b/docs/shots/laneB/round1_combat.webp new file mode 100644 index 0000000000000000000000000000000000000000..745a38be9f38b2f46aebf7fb5bf46339bc7853d4 GIT binary patch literal 24780 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 G0002pqRi_6 literal 0 HcmV?d00001 diff --git a/docs/shots/laneB/round1_enemies.webp b/docs/shots/laneB/round1_enemies.webp new file mode 100644 index 0000000000000000000000000000000000000000..7ca85780ae49b31d43647b023795649764aba0fa GIT binary patch literal 24648 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 fF2)65Dh4_|%lSRe1K$;1YPm4MYRor8 zp%tldqYVP7jM+js#v94i`1?Y!3Hrl|ZYTBCUK3BK&Q2ehMMGlmco~umW(XT0V4mZ0 zDw^wNNrT62LpK)z4hm5HTKb291D!hc_Y<$smCtsjMoVb%4T;X;;H^c@nTVDMD%gza zR`T(d$-?~(oP0#E8B=O1^ZU^;xQm}7qWZ?Li*VTjlri&QMT}3$77|*Oa%&Lk$hfVvx^w^`Q~OKK`{5f~2f03a~GK`P9K0wq2M^oitB3X~_eEPWoqWai9) zI0Gf{e(72#NZ47+lOBTUgHwdY#q%%8p4)tS;}lM=0fU45#5JsZ4(K^gY z;^xfv?og5cZ0|-L$W&c0XIsGaJv$+U2t+#X3i8OSTM1Zu9FpEmPNErZSCZ4XrmWsN zRR`_sq6xkIMJ~;}l=0eJrONyBj3}TcVPW~Q?>ppX!f7=*<&qftvu?MdFuP^Uo@nqS~|W(SFcj zyjm%x=RSk0R?tWBj3tPw?9SY3XTH5(!0AIt6L|OQd%BW2*I5VvAaUm9@Frn|wp%KX zXI>~^?zO^Q6Y}5%P_=Tb19c28jJIosgo-fuNy3as6SwG5iAdXt@>dK!{Ao91HM8i7 zpdwCl2^|5{Q;>b{SXQ}dcf$jb2{JzyUK0H}%QyqO1?N=i+0hIgOK70#oOsqlRoX^4^|^ zRt~h~9n$YEHH3A43h-sTSF}qF_rrs04+g1kTJjy+(?XhUg~?Vk;P>~VQUH_)AnPV# zJcC*6rJ|MRr^)G7C={$7KES({E7o(`J}PDu*CXZCm$c!mlQ#Q@uQklydlYp^wI;a> zo2QbjTFj3#S@IzY=g>L!2pnxF1+0Tc=R_)B)mMbz(%QYhYYq2R2s+&yIB}toc!T_8 zf^1W0;3$x*jacZw6eDfIcn;d6s zkQFko0ZC2Z5~A3(p-u~V0Sem6M6{euYuZ!r^rQ;EEPdV|jgsR$6fVKQcwaH;al)M^ z!ky1|VoPwqm@aUgp5-$7F7%Q-TOyS=2Y2!K-|wt{$zW@ikPtv4=ySzt{(5*lmNL*i2Z&qyo=;1Zk zOrue2d+$w}Yf#nIJ4;z36WFVN^T^Go$|T7g4|O5^7x#p!gB$JpfEpCL!s!Ed~57(lJg~nE{dFw(#0%(kA=oPJDnQ zT=qwuC(_L~>3NOwqlc|wH@Ll!3EGkNv}i8~=0u# zi!fRfKT-BFXJ*FhVUVZqm27E72qUC2EK}h-n|$h8Q@rl z4M@KTD`uFQKVTiD;f)Np19k|1kqCO?6ORHqB0|lC&<|~RaM@dV;}~J04?ipg^y87z zWAeP^ja32_>d}x?@6hVav6b!y6$*bGscrW!>^sab6_rB8^2X!To=ws* z4$`~Yl~W9oXJ>uyEg3Jn53XKltH~wK5HjJs#%rnR3mRx_eet&)v;_VGF1+n^8d=mJaTf^7O8nsWOVK>{$y?z>OQ_w+#l6-f+j6c*M44TT`o!B{ zLvGtsf)tdW;|XOnRvGWLRGr7?z7~{L*oYn$mYj2rH2G=Nu!z2{RO|>US;%6=&aY8z zp3P6@`W@2=P<&mS4)%0POD@fMRIVMkBpnRpEG5K2K{pOUrDM=7&9KGA5owaaZGjeP z_b@7P0FYtYgUOF~0Yjr4d>xJ-U2dC& z>I<6;>NB4kQa;_9U|qV4q_VDxfLU(gzeYa6-(Q4*Hip**@BQ_JWgBizS6rfev{8_J z_i>h zMf|opzHNqh2M09}O>!nDcGqUhgMtVlpWH$DJM-&-*M9elPkD*G9q*|GpJ&wtW2wWy z@F01iPV9$vK-vQiN?{qoXj3C3U;)52p_;$MW6FL12^P1aIUAl5wr;!nyXtj8Wct(b zo4J|e3i8q&<7%z0Ah|#N=&mko)sZ%i|3S&)7gB)43a*d|LnIGbeu*8a9L3;h5$iCS=-kR>TRXA=jb9csBhoS{CHWW8}F5*M;8%8j6HFoE%Gl=DWcuHWb9LJ=3Y{#wuo zAd=CGl1KoF#)fsT@h&4Vn)lgub;f`BgvollZJNDzKCl!P09p#S)Kjf~s5@p=rmb+UDCY?!y$A*Pvxcpr_VtE8XVgU9OpCQQ{Ck+VbqXE^gymfy6hvri}41Qh&&BLAI>kPN(yz`4&<(;Z8UMH@;pm z=mEZi>7N&kM2!E=Tke~eSc{ZYR!3fYSm$fRUT_o$o4JhghxarL_`Pw6t%$6AY4O`@d>b<3Cv`o4Rzvg@LYKdE03Yli6xhdO;iH0)0ClOvc{( zL&(X{j$>XPGFsIQti9fW+yP@$4r19~9OlCWu1Hi?Y(z zF8)<8r5XGU9C{=Dj7=?;XujJ&3W*y&w0-0KLj<+hhDxe)eCC9aw{dOXZ2pl@F2!U~ zJT7CzLgvgBM90TMv=$6ax>wrnXs09j^`rGh=@QOEJU4r%*o{tJ!T42>00>zvd4%UD z=J(x^CyjWiCUGx;!|v8`Zq%5i;DwduYMK>-C+5mli|Xw6eO+SW-Zoy^s~`v8SM@%6 zjW{DB=IxK|;5tZqz4uODDq~j_;4T{M)}y*BjYqt}M@C`2KV(0x@-)TcuJzzW`lDzX zlyLW|?HY2#4{T7qA2CMmzEFli-CYw0P5<|qn(Yx@YK2lWjg_I&5>83uZaTAj*?M=B z*Q%wRAJ}i@Ih*=U>~^X*S4BeR^qHBC_46g7?lBurrhg?mH!P&}w*}cYi6uFTjzMlhe)LsW#RNOK zm){!kLl7CIJY48-|J*_OnbkueR7OBYSOE-SduTL+D_kcH*6~|d_OMP?%qNkVl)D*s zwS>~G{tn`L`+ic|>(YtKLV*Rfjm5Jp+^XlAwOn%Ckf(W4t-KvBYhf@a7gsgU8?uR> zkPnqF`cYJWCol97yVRR#x#OPcOnCyS*LMNZ*L*5p{N1!F6kCzP;tuGl$t8`u=owiv zFr#dguENu92lmN$>hsGPmt%$%&QCnV$H%T+ zwG;g9^?P-m2(0jF?dwro95cuOoduS z1qsVOBq0RuUY_GmJy-+hhc*B0ojGCw*qM__QCz0-1jXkR{|>Gc(7lRJJv8O-b$E8s z_7lXNs>5PsFF<%cvO3A261HNa-XTY`6rOZVa2NqHStrLu0k}Isd;za;fDx?SZUWG} zxGI5Zz8J|dBV%b1Mq_*pX4Xq!wO7cmP)q|w-aH$+5%|eDyg}TT7b$8E)cf{=k|Q$mG+v-k=An3KA9%ecw&+fz@D$;1`kH;| z+O*$>pRFRSV_XV%4}xD`Okuhug^Spo?Zxs<16iSnpfwNFWIOh%m4q&B&Zju9!D&Sp zCX}x#+F3ulrYk0SJ9ebbhYBi~3b8_{o92N&JTJr$FH9Ym@~1hF?(@hstl2 z4iv$FeL8v^r>}<|vaA?@AxH^Ipzd6o8uurDF|v(u)`XrQcfOYV(DT*vCNCvlVlkE0_pw>kqtFjVXS5J=b1JB!gd)ioWX zc_N#G`>tT|6HX@QW8=W7mV!x?O#yl={!waX^L*gh;2kFDK(S?UQ`JJ z5nG-Fg$V(>8D4Q+_5^m7ypEp1Ehj?#d1|Fbkd<+ULKaUY9u7e(j`FE;ma+t$5}^^b zxgxxx$>+~mIMH4LFCr6%%CNET16D4KgH`mTGkpuTPqIoRhs37dGJ9CtA43;Z zCjFHW^b+An*hZ&i=h$(csfpI@#9i!?hA${odnp% zv8RBfVkkR#H|pFFyl}`GSdJHpN2g9FaU<(WE}rXJ4HM;#9xoE4F5mGN zs7+8M%pf%E0`_v5@39$2+0a99Br<#q)Y-7I+HX6%K!F`6n%^z=dt%pEU!mq2rhU`! zGXWNpa=EWiXb+rgVa1XRQTC(P~fwv zXQ=cj<1GHhH9Lo8XBcmR(- zKq)zsojkN+n47R3(}3f^n9|&088f8wR1b^wnbkka1r6(U$k3Obr6=sSL~|U&*)1dl zePrCa%y(uN?K)Qv-#N#>?Usk{c305gkM9nC3L9$Y86E~N=xj%HZGX)oMUr@ArNLqs zzLoIZLqF-;TBw)er&T7bs#Z?2!M%JD zh=k?Az0O3Ra=Pnj`!4#9dHd1u+Iwrcx7XM{uco#n2#oG#R=Eh?fqX7kM(BVe)P^zw z%QHPU))AxM#EZp@eKf|pA!5VDItJt1$<_Pd-WX1R>pS*j`&puFgjIA;J@8KANmOI0 z{hM&7wTG*B1zQ*SFI8AOZR>S%Mp6A3a{(U4zA(F8V@bmT?7=xuFbn3No>D)4PrFNR~uUd=ufTgVYRYw2zwC%P&ATSG;6J3 zcE9H7mKHwA0}A13Sb?H3j*wg{37MbQjsEE5vdmYgcK&k({-MD|CN{l_P|q|5vG`Aa zeT)ArR6k(CPAE4U(h3YS=+I8EnNa6`LN+$6;`R?qg6H3o%LwAwA$ulx^naMK#+ZJK zpN}UQ+do*jN}_*(Xc(_67WK{EWd`0zgr0*3tJ@e>soxqS(oG7wUmx5*M&K`w2QfmI zF-!MmZ%VfZ4(_HYY1IhW`KlU;l>NA2O+f_?WIaT!3n(<#nHLNTM6Gl05GqDoj7eha zjk~#bdGWS8WQIKY=onQNxoRXi~NNIoz;1A`s9Z{VNWvQ8u^CE zZ83gIM2&il74Bzf+6qJ&*3jh-;A7s!Q6RYv@J($@JmbgY1!KV-?r-O7c>Jd|R?(C9 zrYF#@tZ>oCM|-R$-`y#E$X4>Sl!$phfFr!mhg$rCJ=!GYo!gpi>T2TBB1z@q0v;@I zxMoV`6DteO4nfE4$6*8+3*8Ze)&rc~>Mo6jgi4zeS!XbX^I>w%`_T*o-QrsoGz8g) z(rK~m*p!q66zo8&+89-d=@i!s=7BHFsguX3qm6^f)m{pKULWj5hQz5T#Jah1`-S+V zRx-TTMa2}EE(}QqhnL-vEA+JSz^>w_59`!l6GVXVV2q`v6Y&b7jih~^&;&lw4DN0v z!d1C}XvJ*|F?H|C3Ng+>@>q!`4tf$(d!ypF5w*-?-oVT0we>T5Uxu$Oty)RR&}!4m z^=0`d7<7S=T**jP8MyjQdPe~^TnBZ9-f)R4U1hE1`ArG2y(ZEobDfOxpTrYdN+K6+ zkq-68Jt!^2q`}4G5`=^Dy0&Hdrxzv2%5ZK&BhClp#h?hxk!TMKxh@wu6jK)+J6X1; zV~?3SA1o4k3~UvN-F6GQeb}P?i|m(?qVEK))ASm{DnTAMW3Rty4HkVg+gRLA^oLEB z2ilg{%W@R=c4U4vCM28LVnR}nYZ`QBllU2q6KKz^q*3p1*^xiqluTp4fe4RQO$LZdrvdX2bxBoFaM^Q3+>GIy}P8j|hTav3Q>k zsN3oq$gIxkpIuKbz(NE*6u+u5fPmD_J-3US!_^Oq%wBdZ6p3OYs4Z6vZP;S3#%c zGrV#NCj+Ef<6Z_tAdK!oE$S&wc(8nTYu%j4V7$O`RgB1^XB*pdCe3q$0e52)!Jj1C zC=fTk`nV%Ij-20}-_1RNoRC7NwxrYwKG0tdZJb&pRr!aP6huKRo!SLYrj18Nzb-Rq zD;|mz`?`(oFmoU-N~?srN@_vt$f=B%B#%gLmLmbD2$pBS3m) zbZOPQ;n6OmG#YHSGgwHXCxm8MysUs(0yX9-FBQ>}&3ghRRAA&iWctHVuqsRq@0DEq z3!<<5!E9n+D$ii%nSYFx4VVPpyX#YyyMy@rHAg=wy2VAe}R}rtPszX7LH%E;6$yz)NJv_Ok!M~ow6sTl(OXRX6^#}f8 zg+a6Mg@L+A*w4WS4~?jv%9fky`MDHHp%=InRK62nCugpHRpa6+u5PeDE;gEVTLpWL zF}e0Bi!djZaoIIS)ogazZxny8PHW}lC72Yq_6wBoK4Ss8iAeIiJD_qK6eqQ*qVn7Q z+|v#PAJ|qqRNelv{?%$s!NZ<-?`}PTlC3I`3KM?F=$Yu*v93wU{{w{~RG!U-a5as0 z2&$rD%04o#M}buA>z6>?Jobjt8lro#>rF1b6TKIuKaqZ1Brd+$ zQ@o4C#pSH^tFE-4gN)&DPn&g_tDO%+`G$Ooo5p>QlU z(R}{ix}E!khYm-dXa~lOI#Sv)O@KD*fsVj5(n40UQwlbhEn>W(h@gfC+n{X;MHxk{ zLar|hCqgSOC3ou|I3h47;dRk|N7XJK1p4gtn$-Zigb)p?J9m7_viw=4#1vYdF1=PI z1qaXlPY7y{L*NMLYjD_lme?M^l3r1LZ%jkm{+mjA<#X0T)Du!<76Z>;{jA~SyLhT( zV=rN0h*tALttEfk#L-7*a)dSFNQ@Tb(2Gz@Vrtu^7V6_m0z11(Kf{YKmkVxnnX!wI z+w+}^k}MF9{Fxhdt zfd-(t3%K>+G4QGG{FVTcf0Eq&nNWU}vCS}oBHSCcBZ+X~eZDI-=af?H*^Fa2{0$}B zxAGnCc39ns+31Ac6r0qM9i3^~P2#(OreAK74UIh%5S@46&^jpO9NBVvOJFu18Q8kWrn?7J!iH+V2&r|e94Q7HUz*O z&OP;$5R^(fF~k(_kcF-W{YEt5#S<>Zyk{^8@oPC&1}ZmTTOPmLILkrMmr58mUf$0m zu|RfWtV#gejxuU0`GR~h*t?t~UX#=sQ4M@!R`FOj>g$2rO{{`df*Obys9rUX$%HQi z+?XwhZt*a%^z_kqE_O z`|AWIPHEKlff0-(b3bHGY^$qlc9pr$wKK}4(51%J_HBAoSOPz8vOW1bEVCZ=N4T3~ zUDR2nfi$~4xHV=y?+dgaelBR=IQ|ZAcXS3-!iU`m&%}$Oe>JGlWfDYAU1ukGv{i(5x870+DUu=@s+0=Fc78AHp{E{7o&A zN_5p?&p>BV$HQKZPw76dt_hsy3xBRvz`zcIH;0tmojQM29k9rz7Z(4Y04^WV;D_#X z_%yXPkCbT0lKob#S9(J}%gZd3%YLKer)^Om4fql3OnNHvyrX%q{3Re%W|3nzkyye1 z@71f}Iph|Y2x-U?$+$_i?H(^j5w9-U4hWW)MLoi%SE$jQpCOhLo<#gJ{iWa({x)2e zl=CbRjn6WM+uarIYYw_# zbKP{r>FA7R`a`i8;-@%*wdJyhG#5Pzsm(geoE6#I+kD*8oWxk&-2#P2*tj=PEB986 z6gNe(@zU)KglfD%zpFOS)D+PQq419Q1nX{M8apNcCph1sVVm-Rrz13*qCf`}h=lk*4Y_$z4M+ocZvDf!QM1W`2gAhbV%->xzQ>gBCVX5jKhOpRcuE;3_IBP^1`A z6=(H}^2entaaDA%$XI%u_eD@$>xVO%yf{8LO=y_HCrUSQqGG@Or7rLN{VDifdKF1I zp}cec_7C6FM~2uz#zNc2ExhB7I9^M@{CxZjw~?qpN3y5dAK;Bj1`{$BR)VS*`dx=f zstK8{^1Zjd-2U9YPU%wtk3Ri3U5E3Erb2N4)O&SEGP;JLGN#siWy0YcD$yP$Gz#Gk zW3uq}i(ce5Q1zudrb9Y5f0fpie3)u+J*{-@j?=}^-<%}Q0tsAsprj}R#?VfO((+{e z9gHru<~+^>$Uvv}i972fi+K+-yi18Zchvt|E;~i*VndUxz%CB! z+gJi`l`A8x&@S=9dG9r1au zZFPa}KjR%%05w5t-+u%xkY|ofB(K>%#sX}ptb$KFEiQYy)}+oWE%UhgFJRi9#KgkV zGDWCE+?eSe8etG`>NW~*>`dlHfbj+6ZjQ)GQ8)M3gN>IfuUvi@7@fuT4L))VS%?Kp zTt#{!9gHMhFjWm4$->_SqG3GE(D3#LKI6I_h_eqQY(wTB^*>`b_tR#%<}mh4`J2Ln zQOjO^KYc;cU48mdz9LsRXQj36D0Hjr!F|4Z+<-*Inxt9|$@}ru|4mq<=)A0qq(!v< zzyxqYm3V%Oqf;z$w?ug?1mO)&R)}i_$iQwp*2uDkE4G3A=b#_%KA;a=1HN{hUH%|d zk|$MMC0#JsG7vdU&f^Q))f(^{cK3eOznp}k)#YNrAPeuaenetfZ!^KyA>-69(coRe z74z9(k#wJ-X}i%bD8a|aG`qEH3#?TA9*%k!2T97gn1?fn#HG5_I1r;HyWj=v@kTpR z7%VS~FV1ZR5Qy|!Zv`f>Ckv@|6`LXhyip^^720K3(&s@UxZfF1UO+MteTJBE4c!5D zL#1wWSBx(p@^H)N66auis94$o4)P7N0T0|6oQzOxr<-g)nM>QgF4x0SwF^1f{8_kF z>uCafPY3gSqZg0IMv8!3bSvuz>Xl6u0ozvrjbG&ipF6tI!pFW3sTPW49tTK?LV2(_ zRPzWn(v|giE$rD4hsg=KSO$#_@FAU%y_=vfg@EDupAO&a z=VuE|E9^2{xhe^VgKR6#N#y1_XmwDI@UOwd!RLC>JkKFB;PV?IsN9l8PhL9{a_cp^ z{8vjRp`R|pu*L9P;f9Z zvN|MR<6}Mq!ujO(ByX&{=&4?s#}cIXO%`iuHHK)?TPZcnwOAWUkRGiJ%>FV@tnU7* z_FRnxG=;kVd-AUNfnP$aLDP>nzYS=QT|i~~vOh4o_xl`e%hzX!%tmIb+WsjGL{r`x zj#g{{8%YBBnrCu&gk&j8%z9LbWRabiz}p=Is=NK_`~&$(za}Q2>l~$<4ssKWNic8n za1Nf=@8$_Y7Uyl?#4XH-?z&Y5w5!x$U!I&N3A#dUrkK}$ag~QZpLYz)}FSYet zb5DtkRSr?MPUg&Tq?=PdcpCF4MEy!LBGxp=@D0tcEqMaEhCU0i`>Sc5Bi|lJ(y| z>WF|ETYu8+ucj^PG{`@No$Eqcsp3ZO?RtS_aPo=-~Fr*GdnYUwtk(ni>}WX!B4g*OHjhp~WU!HzL$@ zJbzfBfJ|Srz?~QLK7^f!Sqhr?G+nUssJPvt_q8+9-MO}KlJZKF34iwEb+w^s-o7Xu zIyVN)cYWu&VLVhBVgC*C(~#K2E`TK;RmZ)cZ8DqfG;t#1_mg}Bm@KH5cT4pwpCVq|eNRGyBQ)T~; zp@Z6{z=iW`glPLpiviuNjN3is2L{CA^Bi0B=-X)>2IDpHEYfaE5iBX{TtORnI~6Ma z)2Ulq6zyOx_Si~%H2gZsHEW6w!G$_EM2}lE{5480#I=M>z-Yuc$`?yU0(h|Ua7eX> zs+dV;oe3Z!+^-YGAN|~%3-xz`XR^amY9BJ`NzzoZ0cIJh%&cERdn^UmvT=WI&k2dA zatX#l`kt&x$WqmD!0OM?i-_r=N;uWPEKuFmt)DU@ zf<(eg;Q}oc2^C4{l8s3@JPiKRW!X69gwNE|}%9 zuMXf}u<<0fvOR`0JYM{6iV{e$dQS5XxQ%7m36;6Dpj%Lio;kihXBvDf;En0nP-2-vlzVcT;% zF7f%n|J=Ud<^70Q66~<}rCw%aNl9sBzgb4q+q4(+DYJ6luS34WAB(vSh4R&8UYar4 zqAs)W{B}iBV~C6ZJ-w`(R3I*FukKE)kI7IdM8P1`9$K(M**@s>X{Vf`KnJ>hTT30- zx!VaQL+F05s)4oSR07ABjnGONW}OctuZZQs)ZiJl#tp)p3)qQ;+^OpkMZs&}P{i4U zIt<4hKX*m$VMKX17x}9aUv3P~&Hsx}T2JXm*2)T;i~CDG~BU;X-O z-{;cqQ5P1d8;8zeUbP4TalCRc1>)N^s5;SvhBD*3XWUBeEcRJ{Io#rqxj#-$(JZaK zRhwT!0Ol{^g&jw?Xpo!=%ywt?cfi}JA3$7cPnCNa6k@lEdo%zJ`Y%R_6)4`#Q0P9v z@!-4QP&eEY=rA2frh32@rt^)?JdkLi6nV&Vn#}&*GHR%yLX}5wL!)G386YeXBr(71 zLvcyXd+}vUSfY%OXF0fvzrrYm&}PIxY@oH4^o*Px7E`cV&(q`6~(j9Ujs$>yQ6dZ079XH zIq9s_M!+1hIq`-}snMlRp}SMn&7@ac&n*xq zs{4*U=(+ZWG?om(ddXN%#2!aHY3h6Ns0r2r`hMOes$53}zig>&?q{I~|1D-Vy-U)! z83-D6Nx*bsZ>7Z5PP$oua)H0(K3OtH5fh!)3P23%izW4i)cxZTvgE14-E*T3V~d+Q z>d8h1k zHRplD04xEo-Tz!}+d=GbrPA8OpoivXnXVlwF{FE*S>f}8CarhfvT*>6R#+6qFpVIm zxfB@!8YamEA$Jq_g%F|{fUcF;6?jEX));Zp+7tStdiG}b8QU76GF?D}6hgcIKT++| z2m-JOYJQYP1z*lMaQFw4ESbMe;Kf;dnH}AQo{}Fspbq0#a~ikx)O7FtqmTZ`DSqKo z(ft~-$?gUid(D^|+BZAtC2W9nl<=8Hszn!w#&a3PXF^KRUB-u5pypn(O#J#v2!Ri? zuFiYMQSI4c^*Vg96Tysxb-C+Lr+cN+cyTTv=xOjlyr9aCv9)3`U#NjcRSAcmyBL!ege@m&^n!#f%AqA_N_*Qt$+qE-DUTu?&VP+@-`5w&e`k%tDmw8)R-8f>8%p zM(ZK|8xJC0ANYgPwzMJl(yS3;&VS@=Zhcvq&L9FT0IZ6^&_*{6ZBrYq3Cb~a>C2EE zDJWhKD3up#KAGn$%sIEOD4aX7)_wyi0uI5?-z5RLfCm+txL2C*v42Ws0YtJKnTj>J z&_{k4V2t&h(020}-VDrcM3 z*o32VA<&-g2WHIafA~MdLL__qh{U52y6GN=oU4!lV3PF!zMxl)a#tHbzNOoTCMEetyF66Mltcvz?V;BJqA|0`y7RsC6VR4B!(~*M^ zYGcmG6iSI7U=hFw8%45b)bop6i6Rw@jxFxA=qURghS!~*Y_8Cc!GMP$1 zVXxPG{d8y0B(Glt=r-tPLD2EXq|RbRTv>E|&&xB|DCx(va+?WJGZ|x3Q4LsGAcy3` zR*B=iR?rsBXSy$!9+CKN3$eN=_8L3hsd-q#w6c*IvE)?e$qexR^7Z<4aP{V^fB*s) zgT@BJ=;p4m38Fk_ISM>Z?r{>v9;T>ELztwwkAHu;C+tgbT*h5&FBHPJ2ssCM&KTPT zIfeXS(H)lj>QNdT3Bf*ICmi}Q$(mXT11k7#!Xs4!OAJzgEdrKI`LC9_ej6xiPc2&9 zqEBtRh-6N?a1X4o_1%Wb&8HD&I*aNP=#j7ifM!NvFEf6OhHT}j*L(rwSc0^r{Pz_c z3t(q3I4>A8 zT&tzquW0u_#jv0q_=94;PU-oHfLjBDXkqRQOm>i#zGhTQoyh8Cpt-qX_5tzrb}b(c z(ky(GObIkr13bfjzVW47XzuL*5{q#wF>)*C=SUQCk`xf2xTt02=Gk#gz2if0a-_Dw zac6P0Z-7PQx5pPB&6l;>cUe-)o!Rmm0;Exb`^%^gBuQ)8PeW;DWaj=Ks3fC^drF@Z z)D6fDgfy~vpQpJh-K|N^Nh!?ki7wLA$*gUHs0=;Z-T+H|LMfUmA`{nmXQAxlGlBc?l26u82d`32dzY{JFqd2kQKmHX~sL!*oa@yCVo?UcK4G~Xjl)h`^tEv@5BJmP~KOw4x|OK0CR|2GXbK6c)QF4y2iRn`ZuJotTbHAg0 z1CTV|X0?1LL81YHZ^74@-cS zr3{zY&XXXkno%p6{jmhptXdxOfMiWnXdlfECmiL;$5r=sz8lu*m|y{*g%-W?KjU-@ z#$y_+(+daWFx&Ekp_3dIWvzq?V=;kmLVTV&c=;`2sd0?7r!#k3A*@?<3Q zow4j78Tn*-LBCYa^H4Jp!_f}@bhU(*toLn~LLXrMU(R^?{MXlXkTwP!$PU|o&ZRA-Qo`2K zAh8vu*vtf%d_-h(9;HMd{k86CyzP|i(Xo#GD^|coSKABpZ>QWT)ZdNAq}nfT9Be|# z<(W{}zaT+q=0+L8B6dS?lVEFCb3uk!4KgY}k$MBPoH$wfW{JCrV|KD(^B)q5bo7f) z{fhptD;krt=Ee(VO#rVXxZBU(#76?M7-bs)&xJriiK{KMj-o9#fX!ynf@e_`h9fC4 zvMg;vLx&_Kj;WnjubpG%nShj2Q%bteS@{HFLB0BNu*KKl2koi}Cwcfx#e|5B>mXH& zpAISrP=hrCAegU@#Z>_RnW}m!Xlf&41|9K(CFSeTIVB0+e!9iP=ZDNws4LvVILQE4 zAO?!#)i4U?C;V$KoWO3WPN_;|quFN>NpzNg8f*RHlW<5E7qq5VmPArmb_9+CRpFa} zzsIo3cKg%a_>Zx$7@2LymH1U`x1n!6P$IO!`PQnyqw^`0fp4m{N?pXWHS={Z8c*^( z;IQ=r-TvxoEfOt$77jqMqWx@FWa`gb1lCV&rq&r+N|#6Zb@|IjwQ!TWz0vFq?Lb*F z#SF|-Wi756ZtcR|Oo{@zGwng3EUEyW&J2?^VY`W-UWTMcG}TY%?TV0Ss-uUzXDioF zaX@-Hrk{g_JgI~_7(JUe1M1ok^z))y?+NXrTb9k z62mpdZSX(>3`tb_K6ML7^XuLrSvdjp>~Ue&G5$kMq5$F^>VdE;jWq-s`$`scwpu;* zP)zaiYQj+Gu+?t$kNEl>FazEJ?cxo}lr*Qqe7KvsKg)?ZCnn#W{7Qr^5BetCX!AoI zWjiFI=geT8Cp8Bzd|wuoS0hH|WYu6Z$OEB*9$z!$fTc&KTZr))z|;O(bv_NvQIFqU z<=zRRzIc@6EbciL4s^2sX*lI900X-!tWdAICkp#>l4S@7OhQO~o}Ecc5FeL0Jv<{pT!dpktqY-tN^?> zRd7$SfXMmZ}bES@QL5-b?fuKeN-ORE!>oTUOVFUfl^^Vv`VNp0jd z5;zy@CQATA?#T9zEdXIPye=3@T+2a>@1G!idYsd^E%@(Av)X|)&|&}zW&-e5XCp9C zZ2&>?9vQUog!Z^?$dxS>E4NYX#pLqp4d_>NBi$v5juh+(pd!v`o}8zy4y&HwOR};NUROEx$s0StAe#3!}Tle8sVmj{7O|K=*M72@$kBd41ra z(ExfX5CtLV=mBvJPpgInIIa!SvDEqe(G4H~rU|fjh#yt&F&DMfaV&WbGzk7m8W)lT zfxO3N1bD^U)V^`KhXheN1`mC|gd4trakMJEfB_(&3C5OA(K$Cnox`g+VBM=S_S3&! aU)2S(>I`9(zyOmTI4i7c6;Yx<00014)Hr+q literal 0 HcmV?d00001 diff --git a/docs/shots/laneB/round1_wallgraze.webp b/docs/shots/laneB/round1_wallgraze.webp new file mode 100644 index 0000000000000000000000000000000000000000..abfdeaf041cf8ad8da76860e8132f446b95294b3 GIT binary patch literal 23644 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

h>;kqclO0mIPyYW6-#{|KDX$YM z1fiwi7JbQ)K;`R?{|%)Du&BV1x=Xl)wdJ-E--+7T2zhL3pu;mEXIRUs|A=IfkY{}; z$jcS277;=hLapY87&w!xClrp7C;s&4QnB`cPB2)U6PckT=%sQjowIehNlQgNrnXnM z?ikj|F^tPf?Wu!!zakhR?4v>@eB?!5$yyP~4vVcUE7k)2J{q5Yo~T|WA+pC~0mtI3 zt(HzIKC@cm1nO4glleR=j8MU1#8jC)PXIV7jOYLPdhKwjq94>wW3in5eNDaO!C_uU z0m9mIX$w;~ioB9lAl@^NamX{)AW-p-?}xN zZHv(&SbM`R62wrP7n=AcDxf3wcUTDqAyqVvtG1>{bWM2fxn+xibXAMYCjfn2=pX__ zLg0^u@+KhC6yU^-(JYa45w#;;&o{Wkap=m`ww9U?fTKj#>1eL{%r>x|cJ1mg0L!CY zr+rip#Y!dw!6k5nC!5(pH;vv^EncUe285LXg?e@?c{ zSuW$$P%X$}kf%=9_HXg%{g23XI$YF4aW{s_B}z8w!@ihu55ZDc|t0g&%4 zrQgZm)@vQ|uAp;z|AAZ-I-MpTTea*&w!y)la0et#b!c`=Pco#Z`C zfH~rSW&pbY7@duNFQXe`ss3+)um`_l7*>S+kkw zZEhZP0!PlAcuzx0Gg-`1v=)BT0FZ3$D`m4c%YVPEzMca|>P~F6hz1-9311zV`hOjy znoFLcxNas*vO8?(F=Tv!E>>qq`)XwSLM6C#jIHAmLl4F7Hy1j|NVf;Xswie8X;htF zxM7R5al~`~^3APYL`O4Nsc{bMSN!SL@8^xHk@Et{6X3*NJ^SA6c;-5dFF_q^AjGJi z^m>LZqOXV8j3b literal 0 HcmV?d00001 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) + }, +};