guts/web/js/ui/comms.js
type-two 83c30bf344 [lane E] The instrument exists: a HUD, and a surgical team who talk to you
#ui had been an empty div since round 0 — E never ran. It has an owner now.

HUD (charter #1): coat/hull, heat, torpedo pips, speed/flow/throttle, boost,
score, combo, biome + segment name, and a 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, so the HUD 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 — on nobody's list. Argued for, then 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 turned out to be more than flavour: it is 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 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 rather than guessed — all three L2 kinds get bespoke lines,
zero generic fallbacks. A level's worth of dialogue is now a data edit in LINES.

Three defects, all mine, all found by the stepped sim, none visible to the eye:
- The progress rail was dead. I read `level.length` — a field that does not
  exist. The level carries segments, not a total. It now uses the runtime
  world.length that B already divides into p.progress.
- My clock was fiction. Comms counted `+= 0.1` per timer tick and called it
  seconds; a backgrounded tab throttles setTimeout to ~1s, so lines held ~10x too
  long. It now reads performance.now().
- Every hazard warning had the LOWEST priority in the game. PRI has a key `warn`,
  but say() is called with `warn_reflux_surge`, so PRI[key] was undefined and
  fell through to the default of 1 — the surge screaming "GO. GO." could be
  talked over by park admiring a specimen. Keys now resolve exact-then-family.
  The evidence shot IS the fix: same seed, same frame, the warning now sits where
  the checkpoint line used to be.

### -> Lane B — player:state stops emitting the moment you die

player.js:103 early-returns before the emit when !st.alive. So `alive` is
unobservable (E can only ever read true — the field is currently decorative), and
E cannot use player:state as a clock, because it stops exactly when the death UI
needs to run and through boot's whole 2.0s respawn window. Comms works around it
with its own wall-clock. The death/respawn feed-drop (charter #4) will not be
able to. Ask is in NOTES; not patching your file.

### -> Lane F — two contract items

I edited boot.js (~10 lines mirroring your own dynamic-import/try-catch/
console.info fallback) because #ui needed an owner and two modules nothing
constructs aren't worth much. Ratify or move it. Second: shot_sink.py cannot
photograph Lane E — it POSTs renderer.domElement.toDataURL(), and the HUD is a
DOM overlay, so the house evidence tool renders it invisible. This shot is a
composite (canvas -> 2D canvas, then #ui via an SVG foreignObject). Offer to lift
it into pipeline/ as DBG.shotUI() stands.

qa GREEN. Evidence: docs/shots/laneE/round2_hud_aortic.webp (s=1111, seed 7, live
hazard warn + x6 chain). Dev server guts-e on 8146.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 23:01:40 +10:00

222 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 somebodys 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 = `<div class="who"><span class="wave"><i></i><i></i><i></i><i></i><i></i></span>` +
`<span class="name"></span><span class="role"></span></div><div class="txt"></div>`;
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();
},
};
}