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 = `
` + + `
`; + host.appendChild(root); + const nameEl = root.querySelector('.name'); + const roleEl = root.querySelector('.role'); + const txtEl = root.querySelector('.txt'); + + const rot = new Map(); // key -> next index (deterministic rotation) + const last = new Map(); // key -> clock reading at last delivery + + // Wall-clock, read — never accumulated. Counting `+= 0.1` per timer tick assumes the timer + // is punctual; a backgrounded tab throttles setTimeout to ~1s and the count silently becomes + // fiction (lines then hold ~10x too long). It must be wall time and not sim time because the + // one moment this box MUST work — death — is the moment player:state stops emitting. + const now = () => performance.now() / 1000; + const t0 = now(); + let clock = 0, current = null, timer = null, disposed = false; + + const tick = () => { + if (disposed) return; + clock = now() - t0; + if (current && clock >= current.until) hide(); + timer = setTimeout(tick, 100); + }; + timer = setTimeout(tick, 100); + + function hide() { + current = null; + root.classList.remove('on'); + } + + function say(key, arg) { + const group = LINES[key]; + if (!group?.length) return; + const cd = cdOf(key); + if (cd && clock - (last.get(key) ?? -1e9) < cd) return; + const pri = priOf(key); + if (current && pri < current.pri) return; // never talk over something worse + + const i = rot.get(key) ?? 0; + rot.set(key, (i + 1) % group.length); + const [whoId, line] = group[i]; + const who = WHO[whoId]; + + nameEl.textContent = who.name; + roleEl.textContent = who.role; + root.style.color = who.color; + txtEl.textContent = typeof line === 'function' ? line(arg) : line; + root.classList.add('on'); + + last.set(key, clock); + current = { pri, until: clock + HOLD }; + bus.emit('audio:cue', { name: 'comms_open' }); // E's own audio engine picks this up + } + + const offs = []; + offs.push(bus.on('level:event', (ev) => { + if (ev.type === 'checkpoint' && ev.name) say('checkpoint', ev.name); + else if (ev.type === 'boss') say('boss'); + })); + offs.push(bus.on('hazard:warn', (e) => { + const k = `warn_${e?.kind}`; + say(LINES[k] ? k : 'warn', e?.kind); + })); + offs.push(bus.on('player:damage', (e) => say(e?.kind === 'hull_hit' ? 'hull_hit' : 'coat_hit'))); + offs.push(bus.on('player:death', () => say('death'))); + offs.push(bus.on('level:complete', () => say('complete'))); + offs.push(bus.on('pickup', (e) => { if (e?.kind === 'biopsy_sample') say('sample'); })); + offs.push(bus.on('combo', (e) => { if ((e?.n ?? 0) >= 5) say('combo', e.n); })); + offs.push(bus.on('player:surf', (e) => { if (e?.active) say('surf'); })); + + // coat_low is the one continuous signal worth a line — edge-triggered here so it fires on + // the crossing, not every frame we're under the threshold. + let wasLow = false; + offs.push(bus.on('player:state', (p) => { + const low = p.coatMax ? p.coat / p.coatMax < 0.25 : false; + if (low && !wasLow) say('coat_low'); + wasLow = low; + })); + + say('start'); + + return { + say, // exposed for the ?fakebus harness + round-3 scripting + dispose() { + disposed = true; + clearTimeout(timer); + for (const off of offs) off(); + root.remove(); + style.remove(); + }, + }; +} diff --git a/web/js/ui/hud.js b/web/js/ui/hud.js new file mode 100644 index 0000000..f382205 --- /dev/null +++ b/web/js/ui/hud.js @@ -0,0 +1,209 @@ +// ui/hud.js (Lane E) — the instrument overlay. ART_BIBLE §Screens: thin cyan line-work, +// small caps, data-noise ticks. An instrument reading a live feed, not a game HUD. +// +// Bus-driven only (LANE_E charter): player:state + combat:state arrive every frame and ARE +// the clock — no rAF of our own, so the HUD cannot tick while the game is paused or absent. +// Level data comes from C's registry (sanctioned: ROUND2 §Lane E #2), never from B. +// +// 60fps law: build the DOM once, then only transform + textContent-on-change. Bars scale, +// they never resize — a width:% write per bar per frame is a layout pass we can't afford. + +const C = { + line: '#39e6ff', // cyan line-work — ART_BIBLE emissive code, neutral/interactive + good: '#7dffb0', + warn: '#ffb13a', + bad: '#ff5a2a', + surf: '#b06aff', +}; + +const RAIL_W = 190; // px; shared by the CSS and the blip transform below + +const CSS = ` +.hud { position:absolute; inset:0; color:${C.line}; + font:11px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing:.14em; text-transform:uppercase; + text-shadow:0 0 6px rgba(0,0,0,.95); opacity:.92; } +.hud .q { position:absolute; display:flex; flex-direction:column; gap:5px; } +.hud .tl { top:16px; left:18px; } +.hud .tr { top:16px; right:18px; align-items:flex-end; } +.hud .bl { bottom:18px; left:18px; } +.hud .br { bottom:18px; right:18px; align-items:flex-end; } +.hud .bc { bottom:18px; left:50%; transform:translateX(-50%); align-items:center; } +.hud .dim { opacity:.5; } +.hud .row { display:flex; align-items:center; gap:8px; white-space:nowrap; } +.hud .lbl { font-size:9px; opacity:.65; letter-spacing:.2em; } + +/* bars: a hairline rail + a fill that only ever scales */ +.hud .rail { position:relative; width:132px; height:5px; + border:1px solid currentColor; opacity:.85; } +.hud .fill { position:absolute; inset:1px; background:currentColor; + transform-origin:left center; transform:scaleX(1); } +.hud .rail.thin { height:3px; width:96px; } + +/* progress rail — the gut-map's honest placeholder until the silhouette lands */ +.hud .prog { position:relative; width:${RAIL_W}px; height:2px; background:currentColor; + opacity:.35; margin-top:3px; } +.hud .blip { position:absolute; top:-3px; left:0; width:2px; height:8px; + background:${C.line}; box-shadow:0 0 5px ${C.line}; transform:translateX(0); } +.hud .tick { position:absolute; top:-2px; width:1px; height:6px; background:${C.surf}; opacity:.6; } + +.hud .big { font-size:15px; letter-spacing:.16em; } +.hud .chain { color:${C.warn}; font-size:12px; } +.hud .noise { font-size:9px; opacity:.28; letter-spacing:.3em; } +.hud .pips { display:flex; gap:3px; } +.hud .pip { width:6px; height:6px; border:1px solid currentColor; } +.hud .pip.on { background:currentColor; } +`; + +const el = (tag, cls, parent) => { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (parent) parent.appendChild(n); + return n; +}; + +// A label + rail whose fill scales. set() is the hot path: one transform write, and a color +// write only when the danger band actually changes. +function makeBar(parent, label, thin = false) { + const row = el('div', 'row', parent); + const lbl = el('span', 'lbl', row); + lbl.textContent = label; + const rail = el('div', `rail${thin ? ' thin' : ''}`, row); + const fill = el('div', 'fill', rail); + let lastV = -1, lastC = ''; + return { + set(v, color) { + v = v > 0 ? (v < 1 ? v : 1) : 0; + if (v !== lastV) { fill.style.transform = `scaleX(${v})`; lastV = v; } + if (color !== lastC) { rail.style.color = color; lastC = color; } + }, + }; +} + +function makeText(parent, cls) { + const n = el('span', cls, parent); + let last = null; + return { set(s) { if (s !== last) { n.textContent = s; last = s; } }, node: n }; +} + +export function createHUD({ bus, flags = {}, level = null, mount = null } = {}) { + const host = mount ?? document.getElementById('ui'); + if (!host) return { dispose() {} }; + + const style = el('style'); + style.textContent = CSS; + document.head.appendChild(style); + + const root = el('div', 'hud', host); + // ?shots=1 = clean plate for other lanes' world evidence (same contract boot's #dbg keeps). + // E's own HUD shots are taken without the flag. + if (flags.shots) root.style.display = 'none'; + + const tl = el('div', 'q tl', root); + const tr = el('div', 'q tr', root); + const bl = el('div', 'q bl', root); + const br = el('div', 'q br', root); + const bc = el('div', 'q bc', root); + + // --- top-left: where we are ----------------------------------------------------------- + const where = makeText(tl, 'big'); + where.set(level?.name ?? '—'); + const depth = makeText(tl, 'dim'); + const progWrap = el('div', null, tl); + const prog = el('div', 'prog', progWrap); + const blip = el('div', 'blip', prog); + + // Checkpoint ticks, drawn once from C's authored events. Deferred to the first player:state + // because the canal's length is a RUNTIME number (world.length, which B already divides into + // p.progress) — the level object carries segments, not a total, and asking it for one is how + // the rail silently rendered empty the first time. + const checkpoints = (level?.events ?? []).filter((ev) => ev.type === 'checkpoint'); + let ticksPlaced = false; + + // --- top-right: the scoreboard -------------------------------------------------------- + const score = makeText(tr, 'big'); + score.set('000000'); + const chain = makeText(tr, 'chain'); + const noise = makeText(tr, 'noise'); + + // --- bottom-left: what's keeping you alive -------------------------------------------- + const coat = makeBar(bl, 'coat'); + const hull = makeBar(bl, 'hull'); + + // --- bottom-right: what you're shooting with ------------------------------------------ + const heat = makeBar(br, 'heat', true); + const ammoRow = el('div', 'row', br); + const ammoLbl = el('span', 'lbl', ammoRow); + ammoLbl.textContent = 'torpedo'; + const pips = el('div', 'pips', ammoRow); + let pipEls = []; + + // --- bottom-centre: the flight strip -------------------------------------------------- + const strip = el('div', 'row', bc); + const spd = makeText(strip, 'big'); + const flowT = makeText(strip, 'dim'); + const boostT = makeText(strip, null); + + // --- bus ------------------------------------------------------------------------------ + const offs = []; + let frame = 0; + + offs.push(bus.on('player:state', (p) => { + const coatF = p.coatMax ? p.coat / p.coatMax : 0; + const hullF = p.hullMax ? p.hull / p.hullMax : 0; + coat.set(coatF, coatF < 0.25 ? C.warn : C.line); + hull.set(hullF, hullF < 0.34 ? C.bad : hullF < 0.67 ? C.warn : C.good); + + depth.set(`s ${(p.s | 0).toString().padStart(4, '0')} / ${p.length | 0} · ${p.biome ?? ''}`); + blip.style.transform = `translateX(${(p.progress > 1 ? 1 : p.progress) * RAIL_W}px)`; + + if (!ticksPlaced && p.length > 0) { + for (const ev of checkpoints) { + el('div', 'tick', prog).style.left = `${(ev.s / p.length) * 100}%`; + } + ticksPlaced = true; + } + + spd.set(`${p.speed.toFixed(1)}`); + spd.node.style.color = p.surfing ? C.surf : C.line; + flowT.set(`u/s · flow ${p.flow.toFixed(0)} · thr ${(p.throttle * 100) | 0}%`); + if (p.boostReady) { boostT.set('· boost rdy'); boostT.node.style.color = C.good; } + else { + boostT.set(`· boost ${(p.boostCd ?? 0).toFixed(1)}`); + boostT.node.style.color = C.line; + boostT.node.style.opacity = '.45'; + } + + // data-noise ticks — the feed is alive even when nothing is happening. 4 Hz-ish, and + // derived from s so it reads as telemetry rather than a random number generator. + if ((frame++ & 15) === 0) { + noise.set(`0x${(((p.s * 4099) ^ 0x5f3a) & 0xffff).toString(16).padStart(4, '0')}`); + } + })); + + offs.push(bus.on('combat:state', (c) => { + const heatF = c.heatMax ? c.heat / c.heatMax : 0; + heat.set(heatF, c.overheated ? C.bad : heatF > 0.7 ? C.warn : C.line); + score.set(((c.score | 0) % 1000000).toString().padStart(6, '0')); + + if (pipEls.length !== (c.ammoMax | 0)) { // rebuild only when the cap changes + pips.textContent = ''; + pipEls = Array.from({ length: c.ammoMax | 0 }, () => el('div', 'pip', pips)); + } + for (let i = 0; i < pipEls.length; i++) pipEls[i].classList.toggle('on', i < c.ammo); + })); + + offs.push(bus.on('combo', (e) => chain.set(e.n > 1 ? `×${e.n} chain` : ''))); + + offs.push(bus.on('level:event', (ev) => { + if (ev.type === 'checkpoint' && ev.name) where.set(ev.name); + })); + + return { + dispose() { + for (const off of offs) off(); + root.remove(); + style.remove(); + }, + }; +}