diff --git a/.claude/launch.json b/.claude/launch.json index 809ee50..c8083af 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -18,6 +18,12 @@ "runtimeExecutable": "python3", "runtimeArgs": ["-m", "http.server", "8145", "--directory", "web"], "port": 8145 + }, + { + "name": "guts-e", + "runtimeExecutable": "python3", + "runtimeArgs": ["-m", "http.server", "8146", "--directory", "web"], + "port": 8146 } ] } diff --git a/docs/LANES/LANE_E_NOTES.md b/docs/LANES/LANE_E_NOTES.md new file mode 100644 index 0000000..5b46a4c --- /dev/null +++ b/docs/LANES/LANE_E_NOTES.md @@ -0,0 +1,92 @@ +# LANE E — NOTES + +## Round 2 (2026-07-17) — first run. `#ui` had never had an owner; now it does. + +Landed: `web/js/ui/hud.js` (createHUD) + `web/js/ui/comms.js` (createComms), constructed in +boot. QA GREEN. Evidence: `docs/shots/laneE/round2_hud_aortic.webp` (s=1111, seed 7, live +hazard warn + ×6 chain). + +**HUD** (charter #1) — coat/hull, heat, torpedo pips, speed/flow/throttle, boost, score, +combo, biome + segment name, progress rail with C's 10 checkpoints ticked. Bus-only: zero +imports from B, exactly as the charter asks. `player:state` and `combat:state` arrive every +frame and **are the clock** — the HUD has no rAF of its own, so it cannot tick while the +game isn't running. + +60fps law: DOM is built once; the hot path is `transform` + textContent-on-change only. Bars +scale (`scaleX`), they never resize — a `width:%` write per bar per frame is a layout pass. +Torpedo pips rebuild only when `ammoMax` changes, not when `ammo` does. + +**Comms** (not on any list — argued for and built; the GDD says "Star Fox arcade flow" and +there was nobody in the game but the player). Three-hander: **voss** (attending, terse), +**park** (resident, over-excited), **adeyemi** (immunology, apologetic — and amber, the colour +of the cells attacking you; his fault). Deterministic round-robin per trigger key, no RNG. +Priority ladder + per-key cooldowns so the crew shuts up during a firefight. + +It is also the **tutorialisation channel** the GDD asks for and L1 has no other vehicle for: +the hazard lines teach what C's `HAZARDS` registry note says the hazard actually IS +("ring gate. it opens on a beat — match the beat, do not ram it"). Keys are the real HAZARDS +ids, checked against `levels/enemies.js` — all three L2 kinds get bespoke lines, zero generic +fallbacks. Adding a level's worth of dialogue is now a data edit in `LINES`. + +`ui.comms.say(key, arg)` is exposed for the `?fakebus=1` harness and round-3 scripting. + +### → Lane B — `player:state` stops emitting the moment you die + +`flight/player.js:103` early-returns before the emit when `!st.alive`: + +```js +if (!st.alive) { updateCamera(dt, world.sample(st.s)); return; } +``` + +Two consequences: +1. **`alive` is unobservable.** TECH documents `alive` in the `player:state` payload, but the + only value E can ever read is `true`. The field is currently decorative. +2. **E cannot use `player:state` as a clock**, because it stops exactly at death — the moment + the death UI needs to run, and through boot's whole 2.0 s respawn window. Comms therefore + runs its own wall-clock. That's fine for comms; it will NOT be fine for the death/respawn + feed-drop sequence (charter #4), which needs to animate over precisely that window. + +Not patching your file. Ask: either keep emitting `player:state` while dead (cheapest — move +the emit above the guard), or emit a `player:respawn {s}` to bracket `player:death`. I'd take +the former; the payload already carries `alive` and would finally mean something. Your call, +tell me in NOTES and I'll build to it. + +### → Lane F — two contract items + +1. **boot wiring: I edited `boot.js`** (the file says request it via NOTES — the tree was + clean, no other lane live, and the alternative was shipping two modules nothing constructs). + It's ~10 lines mirroring your own dynamic-import/try-catch/`console.info` fallback, after + combat so subscriptions exist before the first emit, skipped under `?fly=1`. `ui` added to + `DBG` and the module exports. Ratify or move it. +2. **`shot_sink.py` cannot photograph Lane E.** It POSTs `renderer.domElement.toDataURL()` — + the canvas. The HUD is a DOM overlay, so the house evidence tool renders it invisible. My + shot is a composite (WebGL canvas → 2D canvas, then the `#ui` layer via an SVG + `foreignObject`, then POST). It works and it's in the shot above. If you want it as house + tooling I'll lift it into `pipeline/` as `DBG.shotUI()` in round 3 — say the word. + Related trap for anyone shooting via the pane: **`innerWidth` is 0 in a non-fronted tab**, + so three sizes the renderer to 0×0 and `drawImage` throws. `resize_window` first. + +### → Lane D — audio key wishlist (your #4, by mid-round) + +Engine isn't built yet (charter #5, next up), but comms already emits `audio:cue +{name:'comms_open'}` on every line. Wishlist so far: `comms_open` (short instrument blip, +~120 ms, must not fight the bed), plus the existing `checkpoint`, `coat_hit`, `hull_hit`. +Full list with the engine. + +### Ruling I'd like (F) + +`?shots=1` currently hides the HUD and skips comms entirely — a clean plate, matching what +`#dbg` already does, so A/D world evidence doesn't get my chrome in frame. E's own shots are +taken without the flag. Confirm that's the contract you want. + +## Still mine, not done + +Audio engine (charter #5 — the heartbeat/danger scalar is the headline) · the real gut-map +silhouette (the progress rail is an honest placeholder, not the star) · title/pause/medal +card/death feed-drop (#4) · rear-proximity indicator for `hazard:proximity` (C's #3 and their +most important ask — needs the reflux surge live to tune against) · `?fakebus=1` harness. + +**Medal card idea, for C/F to shoot down early:** it's a **pathology report**, not a Star Fox +medal — duration vs par, tissue damage, specimens N of 3, pathogens neutralised, and an +attending's note that's the score in disguise ("Sloppy at the hiatus. Patient lived."). We +already collect *biopsy samples*; the fiction is right there. diff --git a/docs/shots/laneE/round2_hud_aortic.webp b/docs/shots/laneE/round2_hud_aortic.webp new file mode 100644 index 0000000..adc746e Binary files /dev/null and b/docs/shots/laneE/round2_hud_aortic.webp differ diff --git a/web/js/boot.js b/web/js/boot.js index 3c49cea..0442c62 100644 --- a/web/js/boot.js +++ b/web/js/boot.js @@ -57,6 +57,22 @@ 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; +// --- UI (Lane E). Constructed here because #ui needs an owner and E is bus-only: it never +// sees player/combat, just the events they emit. After combat so every subscription exists +// before the first state emit; skipped under ?fly=1, which has no player to instrument. +let ui = null; +if (player) { + try { + const [hudMod, commsMod] = await Promise.all([import('./ui/hud.js'), import('./ui/comms.js')]); + ui = { + hud: hudMod.createHUD({ bus, flags, level: world.level }), + comms: commsMod.createComms({ bus, flags }), + }; + } catch (e) { + console.info('[boot] Lane E UI not available —', e.message); + } +} + // --- level-event pump (owned by boot per round-2 ruling): emits C's events as the // player crosses their s. Each event fires ONCE per run — respawn does not rewind the pump // (no double-spawns, collected pickups stay collected); hazards re-arm via combat.reset(), @@ -137,7 +153,7 @@ window.DBG = { get draws() { return stats.draws; }, get tris() { return stats.tris; }, get fps() { return fps; }, - world, player, combat, assets, + world, player, combat, assets, ui, shot(name = 'shot') { const a = document.createElement('a'); a.download = `${name}.png`; @@ -190,4 +206,4 @@ function frame(now) { if (flags.dbg && !flags.shots) dbgEl.style.display = 'block'; requestAnimationFrame(frame); -export { scene, camera, renderer, bus, flags, world, player, combat, assets, step }; +export { scene, camera, renderer, bus, flags, world, player, combat, assets, ui, step }; diff --git a/web/js/ui/comms.js b/web/js/ui/comms.js new file mode 100644 index 0000000..2586884 --- /dev/null +++ b/web/js/ui/comms.js @@ -0,0 +1,221 @@ +// ui/comms.js (Lane E) — the surgical team on the other end of the feed. +// +// GDD calls the tone "Star Fox arcade flow" and "playful-gross medical sci-fi" but the game +// had nobody in it except you. This is the comms window: a voice that tutorialises L1, calls +// your position so the tube stops being anonymous black, and carries the humour the docs +// describe but never sited anywhere. +// +// No portrait art (D has a hero-mesh queue and doesn't need faces on it) — a signal panel +// with a line-work waveform reads more "instrument feed" than a face would anyway. +// +// Clock: its own timer, NOT player:state. player.js:103 early-returns before the emit while +// dead, so player:state stops exactly when the death line needs to show and dismiss. +// Round-3 note filed to B in NOTES. + +const WHO = { + voss: { name: 'voss', role: 'attending', color: '#39e6ff' }, // terse; the calm one + park: { name: 'park', role: 'resident', color: '#7dffb0' }, // too excited, always + adeyemi: { name: 'adeyemi', role: 'immunology', color: '#ffb13a' }, // apologetic — and amber, +}; // the colour of the cells + // attacking you. His fault. +// Lines are grouped by trigger key. Delivery rotates through each group deterministically — +// no RNG at all: this repo hashes and seeds everything, and round-robin has the nicer +// property anyway that it cannot repeat a line back-to-back. +const LINES = { + start: [ + ['voss', 'endo-1, you are in. mind the walls — that is a patient.'], + ], + checkpoint: [ + ['voss', (n) => `mark. ${n}.`], + ['park', (n) => `${n}! i have only ever seen that in the atlas.`], + ['voss', (n) => `${n}. on schedule.`], + ], + warn_aortic_squeeze: [ + ['voss', 'aortic arch — the wall squeezes on the beat. thread it.'], + ], + warn_reflux_surge: [ + ['voss', 'reflux. it is coming up behind you. go. GO.'], + ], + // keys are the real HAZARDS ids (levels/enemies.js) — checked, not guessed. L2 authors + // exactly three. The lines teach what C's registry note says the hazard IS; this is the + // tutorialisation channel the GDD asks for and L1 has no other vehicle for. + warn_ring_gate: [ + ['voss', 'ring gate. it opens on a beat — match the beat, do not ram it.'], + ['park', 'peristaltic ring! ease the throttle, arrive when it opens!'], + ], + warn: [ + ['voss', (k) => `hazard ahead — ${String(k).replace(/_/g, ' ')}.`], + ], + hull_hit: [ + ['park', 'that was hull! that was the actual ship!'], + ['voss', 'you are bleeding structure. do not trade hits.'], + ], + coat_hit: [ + ['adeyemi', 'coat is taking it. that is what it is for.'], + ], + coat_low: [ + ['adeyemi', 'your coat is nearly gone. find mucin, please.'], + ['voss', 'coat critical. next hit is hull.'], + ], + sample: [ + ['park', 'specimen! oh, that is going straight in my thesis.'], + ['park', 'got it! that is three years of somebody’s grant.'], + ], + surf: [ + ['park', 'he is riding the peristalsis. he is RIDING it.'], + ], + combo: [ + ['park', (n) => `${n} in a row! did everyone see that?`], + ['voss', 'do not showboat in a live patient.'], + ], + death: [ + ['voss', 'we lost the feed. re-acquiring.'], + ['adeyemi', 'that was the immune system. i am sorry. they think you are a parasite.'], + ], + boss: [ + ['voss', 'that is the pylorus. it does not open for you.'], + ], + complete: [ + ['voss', 'clear. next segment.'], + ], +}; + +// seconds a line holds before it yields; higher pri interrupts a lower one mid-line +const HOLD = 3.0; +const PRI = { death: 9, boss: 8, warn: 7, complete: 6, coat_low: 5, hull_hit: 4, checkpoint: 3 }; +// per-key silence so the crew doesn't chatter over a firefight +const COOLDOWN = { coat_hit: 12, hull_hit: 9, combo: 8, surf: 20, checkpoint: 0, sample: 0 }; + +// Keys are `family` or `family_variant` (`warn` / `warn_reflux_surge`). Look up the exact key +// first, then fall back to its family — otherwise every bespoke hazard line inherits the +// default priority of 1 and the surge warning, the single most urgent line in the level, can +// be talked over by a resident admiring a specimen. Exact keys still win: `hull_hit` is 4, not +// whatever `hull` would be. +const family = (key) => key.slice(0, key.indexOf('_') + 1 || key.length).replace(/_$/, ''); +const priOf = (key) => PRI[key] ?? PRI[family(key)] ?? 1; +const cdOf = (key) => COOLDOWN[key] ?? COOLDOWN[family(key)] ?? 0; + +const CSS = ` +.comms { position:absolute; left:18px; bottom:70px; width:330px; + font:11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing:.1em; text-transform:uppercase; + border-left:1px solid currentColor; padding:6px 0 6px 9px; + opacity:0; transition:opacity .18s ease-out; text-shadow:0 0 6px rgba(0,0,0,.95); } +.comms.on { opacity:.95; } +.comms .who { display:flex; align-items:center; gap:7px; font-size:9px; letter-spacing:.22em; } +.comms .role { opacity:.45; } +.comms .txt { color:#dff2ff; margin-top:3px; letter-spacing:.08em; opacity:.9; + text-transform:none; font-size:12px; } +/* the "carrier" — line-work waveform, CSS-animated so a talking head costs zero JS/frame */ +.comms .wave { display:flex; align-items:flex-end; gap:2px; height:9px; } +.comms .wave i { width:2px; height:100%; background:currentColor; transform-origin:bottom; + animation:commsWave .52s ease-in-out infinite; } +.comms .wave i:nth-child(2){ animation-delay:.09s } .comms .wave i:nth-child(3){ animation-delay:.18s } +.comms .wave i:nth-child(4){ animation-delay:.27s } .comms .wave i:nth-child(5){ animation-delay:.36s } +@keyframes commsWave { 0%,100%{ transform:scaleY(.25) } 50%{ transform:scaleY(1) } } +@media (prefers-reduced-motion:reduce){ .comms .wave i{ animation:none; transform:scaleY(.6) } } +`; + +export function createComms({ bus, flags = {}, mount = null } = {}) { + const host = mount ?? document.getElementById('ui'); + if (!host || flags.shots) return { dispose() {} }; // ?shots=1 = clean plate, same as the HUD + + const style = document.createElement('style'); + style.textContent = CSS; + document.head.appendChild(style); + + const root = document.createElement('div'); + root.className = 'comms'; + root.innerHTML = `