guts/web/js/ui/cards.js
type-two 464f5c8a18 [lane E] The game finds its voice: procedural synth bank, the WebAudio graph, the damage feed, and the medal/title/pause cards
Round-1 Lane E audio + the two remaining UI surfaces, consolidated from the JING5
clone onto main and made determinism-clean:
  - audio/synth.js  — procedural voice bank (primary path; audible with an empty manifest)
  - audio/engine.js — WebAudio graph, cue router, bed, heartbeat; now the listener for the
                      11 gameplay bus events that were previously firing into the void
  - ui/feedback.js  — feed-corruption damage overlay (an ART_BIBLE law previously unmet)
  - ui/cards.js     — title / medal / pause cards; boot now honours ui:pause
  - boot.js         — mounts all five modules with per-module failure isolation and a
                      frame() tick (never step(), so stepped sims stay deterministic)

Determinism gate: threaded engine's seeded rnd (mulberry32 off ?seed=) into createSynth,
replacing every Math.random in synth.js. Audio texture is now reproducible per-seed and
qa.sh is green.

Verified: QA green; runtime smoke on ?seed=7 scheduled 182 osc + 91 buffer voices + 268
envelope ramps during play, zero console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 13:57:38 +10:00

743 lines
38 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/cards.js (Lane E) — the full-screen screens: TITLE, PAUSE, and the post-run DEBRIEF.
//
// House position on what a "medal card" is here: this is not STAGE CLEAR. The fiction is that
// you are a surgical drone and the crew in comms.js are watching a feed. So the end-of-level
// screen is the operative record — an instrument printout with a par column and an attending's
// assessment. The score is still the score; it just wears a lab coat. (Design intent was
// pre-declared in LANE_E_NOTES as "a pathology report, not a Star Fox medal" — built to that.)
//
// CLOCK: self-clocked, deliberately.
// 1. player:state stops emitting the instant you die (player.js early-returns while !alive),
// so it cannot clock anything that must animate across death or the level-complete freeze.
// 2. boot.js does now tick the UI (`ui.update(dt)` from frame(), boot.js:223), but a screen
// whose animation depends on being ticked is a screen that shows a half-filled printout in
// any harness or ?fly build that constructs us without a loop. The count-up is ours.
// So it runs on our own rAF, which SELF-CANCELS the moment the animation lands — no setInterval
// (House Law 5), and zero cost in the steady state where these screens are either hidden or
// holding a static readout. update() stays a no-op rather than a second driver: two clocks on
// one animation is the double-speed bug.
//
// INPUT: the window listeners are CAPTURE-phase and they consume ONLY the event they act on.
// KEYS: boot.js and flight/input.js both bind keydown, so stopPropagation()+preventDefault() on
// the key that starts/dismisses a screen is what stops it also reaching the game.
// POINTER: we listen for `pointerdown` but flight/input.js binds `mousedown` (input.js:65) — two
// different events, so stopPropagation() on ours cannot reach theirs anyway, and we do NOT need
// it to: input.js:50 early-returns on the unlocked click (`if (!intent.locked) { requestPointerLock;
// return; }`), so the click that dismisses the title could never have fired the cannon. We must
// therefore NOT preventDefault() the title click — that suppresses the compatibility `mousedown`
// and eats the one and only requestPointerLock() site in the codebase, costing the player a
// second click before mouse-look works. Earlier comment here claimed the opposite; it was wrong.
//
// POINTER-EVENTS: #ui is pointer-events:none and that inherits. Only the buttons re-enable it.
// Never put pointer-events:auto on the full-screen root — that swallows the canvas mouse-look
// and aim that flight/input.js binds to renderer.domElement, i.e. the whole game.
const C = {
line: '#39e6ff', // ART_BIBLE emissive code — neutral/interactive cyan
soft: '#7fdfff',
good: '#7dffb0',
warn: '#ffb13a',
bad: '#ff5a2a',
surf: '#b06aff',
ink: '#dff2ff',
};
// Grade table. Deterministic, no RNG, no hidden fudge — see gradeOf() for the criteria.
// The medal tier is in the comment so scoring discussions can map to the GDD's Star-Fox
// language, but what the player READS is the clinical assessment, which is the point.
const GRADES = [
{ key: 'EXEMPLARY', medal: 'platinum', color: C.surf, note: 'i have nothing to add. file it as the reference run.' },
{ key: 'CLEAN', medal: 'gold', color: C.good, note: 'clean segment. the patient will never know you were there.' },
{ key: 'ACCEPTABLE', medal: 'silver', color: C.line, note: 'acceptable. the tissue disagrees, but acceptable.' },
{ key: 'MARGINAL', medal: 'bronze', color: C.warn, note: 'you got through. i would not call it surgery.' },
{ key: 'SURVIVED', medal: 'none', color: C.bad, note: 'the feed survived. that is the kindest reading available.' },
];
const COUNT_S = 1.35; // seconds the printout takes to fill in
const ROW_STAG = 0.085; // per-row head start, so the numbers land one after another
// Grade cut-offs as met/total ratios, hoisted out of gradeOf() so retuning the curve is a
// one-line edit next to the grade table it selects from.
const CUT_CLEAN = 0.75;
const CUT_ACCEPTABLE = 0.5;
// Every shipped level carries par.samples:3, but par can be absent entirely (stub level,
// ?localassets=0). ONE constant so the pause card and the grader can never disagree about
// what "all of them" means — they used to hardcode 3 independently.
const DEFAULT_SAMPLES = 3;
// Root class is `crd` — the ONLY collision surface, since (house style, per hud.js/comms.js)
// descendants are short and unprefixed but scoped under the root. `.hud` and `.comms` are
// taken. Keyframes ARE globally scoped, so every one of ours is crd-prefixed.
const CSS = `
/* z-index 5, and it is COORDINATED, not arbitrary: .fdbk was also 5 for a while, under the same
#ui parent, which left the stacking to boot's construction order — a coin flip over whether
the damage layer painted across the medal card. feedback.js has since taken 4 and says so in
its own comment, naming 5 as ours. So 5 stays: it is above .fdbk and above the unlayered
.hud/.comms, and changing it would silently falsify a sibling's stated assumption. */
.crd { position:absolute; inset:0; z-index:5; pointer-events:none;
color:${C.line}; font:11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing:.14em; text-transform:uppercase;
font-variant-numeric:tabular-nums; font-feature-settings:"tnum" 1;
text-shadow:0 0 6px rgba(0,0,0,.95); }
/* A screen = a full-bleed scrim + a panel. Hidden screens are display:none so they cost
nothing — an opacity:0 overlay still composites every frame. */
.crd .scr { position:absolute; inset:0; display:none; align-items:center; justify-content:center; }
.crd .scr.on { display:flex; animation:crdIn .22s ease-out both; }
.crd .dimmer { position:absolute; inset:0; background:rgba(2,16,13,.62);
backdrop-filter:blur(2.5px) saturate(.7); -webkit-backdrop-filter:blur(2.5px) saturate(.7); }
/* the "feed": static repeating gradient, painted once, never animated */
.crd .scan { position:absolute; inset:0; opacity:.5; mix-blend-mode:screen;
background:repeating-linear-gradient(180deg,
rgba(57,230,255,.055) 0 1px, rgba(0,0,0,0) 1px 3px); }
/* WIDTH, not min-width + max-width: min-width WINS over max-width in CSS, so the old
old min-width:430px + max-width:min(560px,88vw) overflowed the viewport below ~490px and ran
the par/verdict columns off the right edge — the 88vw was decorative. One width clamp cannot
contradict itself. box-sizing so the 26px padding + border stay inside that width. */
.crd .panel { position:relative; box-sizing:border-box; padding:22px 26px 18px;
width:min(560px, 92vw);
border:1px solid rgba(57,230,255,.30); background:rgba(2,16,13,.66); }
/* two corner brackets — cheaper than four nodes and reads as an instrument frame */
.crd .panel::before, .crd .panel::after { content:''; position:absolute; width:13px; height:13px;
border:1px solid ${C.line}; opacity:.85; }
.crd .panel::before { top:-1px; left:-1px; border-right:0; border-bottom:0; }
.crd .panel::after { bottom:-1px; right:-1px; border-left:0; border-top:0; }
.crd .hdr { display:flex; justify-content:space-between; align-items:baseline; gap:16px;
font-size:9px; letter-spacing:.28em; opacity:.6; white-space:nowrap; }
.crd .rule { height:1px; background:currentColor; opacity:.22; margin:9px 0; }
.crd .rule.hair { opacity:.12; }
.crd .name { font-size:19px; letter-spacing:.3em; margin-top:8px; }
.crd .sub { font-size:9.5px; letter-spacing:.2em; opacity:.5; text-transform:none; margin-top:3px; }
.crd .dim { opacity:.45; }
/* readout rows: label | value | par | verdict. Grid so the columns line up like a printout
and so no row ever reflows when a number changes width (tabular-nums + fixed tracks). */
.crd .row { display:grid; grid-template-columns:9.5em 1fr 6.5em 5.5em; align-items:baseline;
gap:10px; padding:2.5px 0; font-size:11px; }
.crd .row .k { font-size:9px; letter-spacing:.22em; opacity:.55; }
.crd .row .v { text-align:right; font-size:13px; letter-spacing:.1em; color:${C.ink}; }
/* par and verdict were the smallest, faintest things on the card — an inverted hierarchy, since
the par is half of the headline comparison and the verdict chip IS the "here is the criterion
you missed" promise. Both lifted to a readable size/opacity; the value still leads. */
.crd .row .p { text-align:right; font-size:10.5px; opacity:.62; letter-spacing:.12em; }
.crd .row .t { font-size:9.5px; letter-spacing:.14em; text-align:right; opacity:.95; }
.crd .row.hero { padding:6px 0 7px; }
.crd .row.hero .v { font-size:25px; letter-spacing:.06em; }
.crd .row.hero .k { font-size:10px; opacity:.75; }
.crd .grade { display:flex; align-items:baseline; justify-content:space-between; gap:14px; }
.crd .grade .g { font-size:22px; letter-spacing:.34em; }
.crd .grade .m { font-size:8.5px; letter-spacing:.24em; opacity:.45; }
.crd .note { text-transform:none; letter-spacing:.05em; font-size:11.5px; color:${C.ink};
opacity:.85; margin-top:7px; }
.crd .note b { font-weight:400; text-transform:uppercase; font-size:9px; letter-spacing:.22em;
opacity:.55; margin-right:7px; }
.crd .foot { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-top:13px; }
/* pointer-events re-enabled HERE and nowhere else */
.crd .btn { pointer-events:auto; cursor:pointer; background:none; color:inherit; font:inherit;
letter-spacing:.22em; text-transform:uppercase; font-size:10px;
border:1px solid currentColor; padding:6px 15px; opacity:.8;
transition:opacity .12s linear, background-color .12s linear; }
.crd .btn:hover, .crd .btn:focus-visible { opacity:1; background:rgba(57,230,255,.13); outline:none; }
.crd .hint { font-size:8.5px; letter-spacing:.24em; opacity:.35; }
/* The four fixed tracks are ~21.5em wide before the 1fr; on a phone that leaves the value
column nothing. Tighten the tracks rather than let the printout wrap. */
@media (max-width:520px) {
.crd .panel { padding:16px 14px 14px; }
.crd .row { grid-template-columns:7.2em 1fr 4.6em 4.6em; gap:6px; }
.crd .row .p, .crd .row .t { letter-spacing:.06em; }
.crd .row.hero .v { font-size:21px; }
}
/* --- title ------------------------------------------------------------------------------ */
/* The title is the one screen with no dimmer — big type over the LIVE canal is the ART_BIBLE
ask. But near-white 132px type and a 10px call-to-action over a lit, moving, warm interior
need some floor under them, so instead of a flat scrim the screen gets a soft radial pool:
it lifts contrast behind the type and fades to nothing well before the edges, so the drift
is still the background. Painted once, never animated. */
.crd .tscrim { background:radial-gradient(ellipse 70% 55% at 50% 46%,
rgba(2,16,13,.62) 0%, rgba(2,16,13,.34) 48%, rgba(2,16,13,0) 82%); }
.crd .title { text-align:center; }
.crd .title .big { font-size:clamp(54px, 13vw, 132px); line-height:.94; letter-spacing:.22em;
margin-left:.22em; color:${C.ink}; text-shadow:0 0 26px rgba(57,230,255,.35), 0 0 3px #000; }
.crd .title .tag { font-size:10px; letter-spacing:.42em; opacity:.6; margin-top:14px; }
.crd .title .press { font-size:10px; letter-spacing:.34em; margin-top:34px; opacity:.75;
animation:crdPulse 1.9s ease-in-out infinite; }
.crd .title .keys { font-size:8.5px; letter-spacing:.22em; opacity:.28; margin-top:11px; }
/* the title sits over the live view; a hairline top/bottom band frames it without hiding it */
.crd .title .band { width:min(520px, 76vw); height:1px; background:currentColor; opacity:.3;
margin:0 auto; }
@keyframes crdIn { from { opacity:0; transform:translateY(7px) } to { opacity:1; transform:none } }
/* Floor at .55, not .28: the ONLY call-to-action on the screen must never be near-invisible
for half of every cycle. The pulse still reads as a pulse; it just never disappears. */
@keyframes crdPulse { 0%,100% { opacity:.55 } 50% { opacity:1 } }
@media (prefers-reduced-motion:reduce) {
.crd .scr.on { animation:none }
.crd .title .press { animation:none; opacity:.7 }
}
`;
const el = (tag, cls, parent) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (parent) parent.appendChild(n);
return n;
};
// textContent-on-change only. Every number on these cards ticks up over ~1.3s; writing an
// unchanged string 60×/s for 5 rows is 300 pointless DOM writes a second.
function makeCell(node) {
let last = null;
return (s) => { if (s !== last) { node.textContent = s; last = s; } };
}
const clamp01 = (v) => (v > 0 ? (v < 1 ? v : 1) : 0);
const easeOut = (k) => 1 - (1 - k) * (1 - k) * (1 - k);
// Two clocks on purpose: the run's own duration keeps a tenth (it is the thing being counted
// up, and a headline that ticks in whole seconds looks broken), the par is a round authored
// number and printing "3:00.0" next to it just adds a digit that never varies.
// QUANTISE FIRST, THEN SPLIT. The naive `(s/60|0) : (s%60).toFixed(1)` rounds the seconds AFTER
// choosing the minute, so 59.97 prints "0:60.0" and 119.98 prints "1:60.0" — an impossible clock.
// The count-up sweeps continuously through that window on every run over a minute, so the
// headline would visibly flash a bad time mid-animation. Round to tenths in one integer domain.
const mmss = (sec) => {
const tenths = Math.round(Math.max(0, sec || 0) * 10);
const m = (tenths / 600) | 0;
const s = (tenths - m * 600) / 10;
return `${m}:${s.toFixed(1).padStart(4, '0')}`;
};
const mmssPar = (sec) => {
const s = Math.max(0, Math.round(sec || 0));
return `${(s / 60) | 0}:${(s % 60).toString().padStart(2, '0')}`;
};
const num = (v) => Math.round(v || 0).toLocaleString('en-US');
/**
* The grade rule, in one place, deterministic, and printed on the card as the four verdict
* chips so the player can see exactly which criterion they missed.
*
* 1. DURATION stats.time <= par.time
* 2. SCORE stats.score >= par.score
* 3. SPECIMENS stats.samples >= par.samples (3 in every shipped level)
* 4. NO LOSSES stats.deaths === 0 (the GDD's "damageless-gate bonus" — true
* damagelessness isn't emitted anywhere, deaths is the honest proxy)
*
* met/total maps to: 4-of-4 => EXEMPLARY, >=.75 => CLEAN, >=.5 => ACCEPTABLE, >0 => MARGINAL,
* 0 => SURVIVED.
*
* par can be null (stub level, ?localassets=0, an unpar'd level) — then criteria 1 and 2 are
* not gradable and simply do not exist. That leaves total=2, and since EXEMPLARY requires
* four gradable criteria you can top out at CLEAN. That is intentional: an unpar'd run has no
* evidence for the top grade, and silently awarding it would make the medal meaningless.
*/
function gradeOf(stats, par) {
const crit = [];
if (par && typeof par.time === 'number') {
crit.push({ id: 'time', pass: (stats.time ?? Infinity) <= par.time });
}
if (par && typeof par.score === 'number') {
crit.push({ id: 'score', pass: (stats.score ?? 0) >= par.score });
}
const needS = (par && typeof par.samples === 'number') ? par.samples : DEFAULT_SAMPLES;
crit.push({ id: 'samples', pass: (stats.samples ?? 0) >= needS });
crit.push({ id: 'deaths', pass: (stats.deaths ?? 0) === 0 });
const met = crit.reduce((n, c) => n + (c.pass ? 1 : 0), 0);
const r = crit.length ? met / crit.length : 0;
const g = (met === crit.length && crit.length >= 4) ? GRADES[0]
: r >= CUT_CLEAN ? GRADES[1]
: r >= CUT_ACCEPTABLE ? GRADES[2]
: r > 0 ? GRADES[3]
: GRADES[4];
return { grade: g, crit, met, total: crit.length, needS };
}
export function createCards({
bus, flags = {}, assets = null, level = null, world = null, mount = null,
} = {}) {
const host = mount ?? document.getElementById('ui');
// ?shots=1 => construct nothing at all (comms.js's route, not the HUD's display:none one —
// a screen that exists but is hidden can still be un-hidden by a stray level:complete on a
// screenshot pass, and the whole point of the flag is a guaranteed clean plate).
if (!host || flags.shots) return { update() {}, dispose() {} };
// world is accepted so the title can name the canal it is drifting through; world.level is
// the fallback when boot hands us a level we didn't get directly. `assets` is accepted for
// the factory contract and deliberately unused — these screens are pure line-work DOM, so
// the assets-optional law costs us nothing: an empty manifest changes nothing here.
const lvl = level ?? world?.level ?? null;
// ONE <style> per document, refcounted. A second createCards() used to inject a duplicate
// copy of the whole sheet; worse, either instance's dispose() would then have removed a sheet
// the other was still using if we ever shared by id without counting. Count, then remove at
// zero — correct under double-mount and identical to before under the normal single mount.
const STYLE_ID = 'crd-css';
let style = document.getElementById(STYLE_ID);
if (style) {
style.dataset.refs = String((Number(style.dataset.refs) || 1) + 1);
} else {
style = el('style');
style.id = STYLE_ID;
style.dataset.refs = '1';
style.textContent = CSS;
document.head.appendChild(style);
}
const root = el('div', 'crd', host);
// ---- shared screen scaffold ------------------------------------------------------------
function makeScreen(extraCls) {
const scr = el('div', 'scr', root);
el('div', 'dimmer', scr);
el('div', 'scan', scr);
const panel = el('div', extraCls ? `panel ${extraCls}` : 'panel', scr);
return { scr, panel };
}
// ============================ TITLE =====================================================
// Deliberately NOT a panel: the ART_BIBLE asks for big type over a live esophagus drift, so
// this screen is transparent apart from two hairlines. It shows once, at boot, and can never
// come back — a title that can reappear mid-run is a trap.
const titleS = el('div', 'scr on tscrim', root);
{
const t = el('div', 'title', titleS);
el('div', 'band', t);
const big = el('div', 'big', t);
big.textContent = 'GUTS';
el('div', 'band', t);
const tag = el('div', 'tag', t);
tag.textContent = lvl?.name
? `${lvl.name}${lvl.tagline ? `${lvl.tagline}` : ''}`
: 'a flight through the alimentary canal';
const press = el('div', 'press', t);
press.textContent = 'press any key';
const keys = el('div', 'keys', t);
keys.textContent = 'esc / p — pause';
}
// ============================ PAUSE =====================================================
const pause = makeScreen();
const pauseRows = {};
{
const p = pause.panel;
const hdr = el('div', 'hdr', p);
el('span', null, hdr).textContent = 'feed held';
el('span', null, hdr).textContent = lvl?.id ?? 'endo-1';
const nm = el('div', 'name', p);
nm.textContent = 'paused';
el('div', 'rule', p);
const row = (key, label) => {
const r = el('div', 'row', p);
el('span', 'k', r).textContent = label;
const v = el('span', 'v', r);
el('span', 'p', r); // spacer — keeps the grid identical to the debrief card
el('span', 't', r);
pauseRows[key] = makeCell(v);
return v;
};
row('pos', 'position');
row('score', 'score');
row('spec', 'specimens');
row('integ', 'integrity');
row('losses', 'feed losses');
row('audio', 'audio');
el('div', 'rule hair', p);
const foot = el('div', 'foot', p);
const resume = el('button', 'btn', foot);
resume.type = 'button';
resume.textContent = 'resume';
resume.addEventListener('click', () => setPaused(false));
el('span', 'hint', foot).textContent = 'esc / p';
pause.resumeBtn = resume;
}
// ============================ DEBRIEF (the medal card) ==================================
const debrief = makeScreen();
const D = {};
let nRows = 0; // filled by row() below; the stagger remap is derived from it
{
const p = debrief.panel;
const hdr = el('div', 'hdr', p);
D.hdrL = makeCell(el('span', null, hdr));
D.hdrR = makeCell(el('span', null, hdr));
D.hdrL('operative record');
D.name = makeCell(el('div', 'name', p));
D.sub = makeCell(el('div', 'sub', p));
el('div', 'rule', p);
// label | value | par | verdict — the four columns of the printout.
// Each row remembers its OWN stagger index, and nRows counts them. Both used to be
// hardcoded at the call sites (and `4` was baked into the remap denominator), so adding or
// removing a row silently stopped the last one ever reaching k=1 — its final number would
// have been wrong AND its verdict chip would never have rendered, looking like a data bug.
const row = (key, label, hero = false) => {
const r = el('div', hero ? 'row hero' : 'row', p);
el('span', 'k', r).textContent = label;
const v = el('span', 'v', r);
const par = el('span', 'p', r);
const t = el('span', 't', r);
D[key] = { v: makeCell(v), p: makeCell(par), t: makeCell(t), tn: t, i: nRows++, col: null };
return D[key];
};
row('time', 'duration', true); // TIME vs PAR is the headline comparison (GDD §Scoring)
row('score', 'score');
row('spec', 'specimens');
row('kills', 'pathogens');
row('deaths', 'feed losses');
el('div', 'rule', p);
const g = el('div', 'grade', p);
D.gradeEl = el('span', 'g', g);
D.gradeTxt = makeCell(D.gradeEl);
D.medal = makeCell(el('span', 'm', g));
const note = el('div', 'note', p);
el('b', null, note).textContent = 'voss';
D.note = makeCell(el('span', null, note));
el('div', 'rule hair', p);
const foot = el('div', 'foot', p);
const cont = el('button', 'btn', foot);
cont.type = 'button';
cont.textContent = 'continue';
cont.addEventListener('click', () => closeDebrief());
D.contBtn = cont;
D.next = makeCell(el('span', 'hint', foot));
}
// ---- state -----------------------------------------------------------------------------
let titleUp = true; // title is up right now
let paused = false;
let debriefUp = false;
let debriefFor = null; // level id whose record we have ALREADY printed — see openDebrief()
let settled = false; // the debrief count-up has reached its final numbers
let disposed = false;
let raf = 0;
let animT0 = 0;
let result = null; // the frozen level:complete payload we are printing
let deaths = 0; // counted locally: no bus event carries a running death total
const live = { // last-seen scalars. COPIED out — player:state says don't retain it.
s: 0, length: 0, progress: 0, hull: 1, coat: 1,
score: 0, kills: 0, samples: 0,
}; // no `biome`: nothing on these screens prints it, and a dead store
// in a 60 Hz handler is still a write every frame.
const now = () => performance.now() / 1000;
// ---- pause -----------------------------------------------------------------------------
function paintPause() {
pauseRows.pos(live.length
? `s ${live.s | 0} / ${live.length | 0} · ${(clamp01(live.progress) * 100) | 0}%`
: '—');
pauseRows.score(`${num(live.score)} · ${live.kills | 0} kills`);
pauseRows.spec(`${live.samples | 0} of ${lvl?.par?.samples ?? DEFAULT_SAMPLES}`);
pauseRows.integ(`hull ${(clamp01(live.hull) * 100) | 0}% · coat ${(clamp01(live.coat) * 100) | 0}%`);
// No bus event carries a running death total (boot keeps run.deaths private until
// level:complete), so this is counted locally off player:death.
pauseRows.losses(`${deaths}`);
pauseRows.audio(mutedNow() ? 'muted' : 'active');
}
// ?mute is authoritative and permanent for the session; otherwise read the SAME key the audio
// engine persists under — audio/engine.js:219/234, `guts.audio` holding
// JSON {master, muted} — so the indicator agrees with the engine without either module
// importing the other. (This previously read a `guts.mute` key that nothing in the tree has
// ever written, so the row said ACTIVE forever no matter what the player muted.)
// Wrapped: storage throws in some privacy modes and JSON.parse throws on corrupt data, and a
// settings readout is not worth a boot failure — the catch covers both.
function mutedNow() {
if (flags.mute) return true;
try {
const raw = localStorage.getItem('guts.audio');
return raw ? !!JSON.parse(raw).muted : false;
} catch { return false; }
}
// ── THE THREE EVENTS THIS FILE RAISES, and who owes them a consumer ───────────────────────
// Bus-only: the screens announce and stop, they never reach into the loop.
// ui:pause { paused } — WIRED. boot.js gates frame() on it and feedback.js freezes.
// ui:start {} — the title was dismissed. STILL UNCONSUMED: boot builds the
// player and steps from frame one, so the run is already live
// behind the title card and run.t is already accumulating. That
// corrupts THIS CARD'S OWN HEADLINE — DURATION vs PAR currently
// includes however long the player spent reading the title.
// ui:continue { from, to } — CONTINUE was pressed; `to` is the next segment id from the
// level:complete payload, null when this was the last one.
// STILL UNCONSUMED: dismissing the record leaves the player
// drifting in a finished level, with the foot hint naming a
// segment nothing will load.
function setPaused(v) {
// A pause you cannot leave is the worst bug this file could ship, so the only guard on
// UN-pausing is "are we paused". Pausing, by contrast, is refused whenever another screen
// owns the input (title / debrief) — otherwise Escape stacks two modals.
if (v === paused) return;
if (v && (titleUp || debriefUp)) return;
paused = v;
pause.scr.classList.toggle('on', paused);
if (paused) { paintPause(); pause.resumeBtn.focus?.(); }
bus.emit('ui:pause', { paused });
}
// ---- debrief ---------------------------------------------------------------------------
// One paint at animation progress k in [0,1]. Rows are staggered so the printout fills in
// top-down; each row's own k is remapped, not delayed by a timer.
function paintDebrief(k) {
if (!result) return;
const st = result.stats ?? {};
const par = result.par ?? null;
// Denominator = the span left after the LAST row's head start, derived from nRows rather
// than a hardcoded 4, so every row still lands exactly on 1.0 whatever the row count is.
// Floored: a huge ROW_STAG must degrade to "everything lands together", never divide by <=0.
const span = Math.max(0.05, 1 - ROW_STAG * Math.max(0, nRows - 1));
const rk = (i) => easeOut(clamp01((k - i * ROW_STAG) / span));
D.time.v(mmss((st.time ?? 0) * rk(D.time.i)));
D.score.v(num((st.score ?? 0) * rk(D.score.i)));
D.spec.v(`${Math.round((st.samples ?? 0) * rk(D.spec.i))}`);
D.kills.v(`${Math.round((st.kills ?? 0) * rk(D.kills.i))}`);
D.deaths.v(`${Math.round((st.deaths ?? 0) * rk(D.deaths.i))}`);
// Verdict chips only resolve once their row has finished counting — a chip that says
// UNDER while the clock is still ticking past par reads as a lie.
const g = result.g;
const chip = (cell, on, yes, no, yesColor, noColor) => {
if (rk(cell.i) < 1) { cell.t(''); return; }
cell.t(on ? yes : no);
// Same change-guard discipline as makeCell: once a row lands, this runs on every one of
// the remaining ~28 frames, and re-assigning an identical style.color is a wasted write.
const col = on ? yesColor : noColor;
if (col !== cell.col) { cell.tn.style.color = col; cell.col = col; }
};
const pass = (id) => g.crit.find((c) => c.id === id)?.pass;
// A criterion that could not be graded still gets a chip. Leaving the cell blank looked
// identical to "still counting" and made the card's promise — four chips, one per criterion
// — quietly untrue on any unpar'd level.
if (par && typeof par.time === 'number') chip(D.time, pass('time'), 'under par', 'over par', C.good, C.warn);
else chip(D.time, false, '', 'no par', C.soft, C.soft);
if (par && typeof par.score === 'number') chip(D.score, pass('score'), 'par met', 'short', C.good, C.warn);
else chip(D.score, false, '', 'no par', C.soft, C.soft);
chip(D.spec, pass('samples'), 'complete', 'incomplete', C.good, C.warn);
// The fail string was EMPTY: die, and the one criterion you actually missed printed nothing
// at all — the card went silent about the exact thing it exists to tell you.
chip(D.deaths, pass('deaths'), 'no losses', 'feed lost', C.good, C.bad);
if (k >= 1) {
D.gradeTxt(g.grade.key.toLowerCase());
D.gradeEl.style.color = g.grade.color;
D.medal(`assessment · ${g.met}/${g.total} criteria · ${g.grade.medal}`);
D.note(g.grade.note);
}
}
function tickAnim() {
raf = 0;
if (disposed || !debriefUp) return;
const k = clamp01((now() - animT0) / COUNT_S);
paintDebrief(k);
if (k < 1) raf = requestAnimationFrame(tickAnim); // self-cancelling: no idle rAF
else settled = true; // landed on its own; input closes now
}
function openDebrief(e) {
// level:complete fires exactly once (boot latches run.done), but latch HERE too — this card
// freezes a payload and a second open would restart the count with the same numbers. The
// old guard was `if (debriefUp)` alone, which closeDebrief() clears, so the defence-in-depth
// the comment advertised did not actually exist: a repeat event after dismissal reopened it.
// Latched BY LEVEL ID, not by a bare boolean: now that CONTINUE emits ui:continue and an
// integrator can advance us, a hard once-per-instance latch would swallow every later
// segment's record. Same id twice = the duplicate we are defending against; a new id = a
// new segment, which is exactly what this card is for.
const id = e?.id ?? lvl?.id ?? '—';
if (debriefUp || debriefFor === id) return;
debriefFor = id;
result = {
id: e?.id ?? lvl?.id ?? null,
to: e?.to ?? null, // kept so closeDebrief can name where the player asked to go
stats: e?.stats ?? {},
par: e?.par ?? null,
g: gradeOf(e?.stats ?? {}, e?.par ?? null),
};
if (paused) setPaused(false); // a completion beats a pause; never stack them
titleUp = false;
titleS.classList.remove('on');
const st = result.stats, par = result.par;
D.hdrR(e?.id ?? lvl?.id ?? '—');
D.name(lvl?.name ?? e?.id ?? 'segment');
D.sub(lvl?.tagline ?? 'segment cleared');
D.time.p(par && typeof par.time === 'number' ? `par ${mmssPar(par.time)}` : 'par —');
D.score.p(par && typeof par.score === 'number' ? `par ${num(par.score)}` : 'par —');
D.spec.p(`of ${result.g.needS}`);
D.kills.p('');
D.deaths.p('');
D.next(e?.to ? `next · ${e.to}` : 'any key');
// deaths from the payload is authoritative; our local count is only for the pause card
deaths = st.deaths ?? deaths;
debriefUp = true;
settled = false;
debrief.scr.classList.add('on');
animT0 = now();
paintDebrief(0);
if (!raf) raf = requestAnimationFrame(tickAnim);
D.contBtn.focus?.();
}
function closeDebrief() {
if (!debriefUp) return;
debriefUp = false;
debrief.scr.classList.remove('on');
if (raf) { cancelAnimationFrame(raf); raf = 0; }
// Dismissing the record is the player ASKING TO ADVANCE, and that intent has to leave this
// file or CONTINUE just deletes the card and strands them in a finished level — the foot
// hint even names the next segment. We do not load levels (bus-only, no reaching in), so we
// announce and let boot route it. `to` is the next-level id from the level:complete payload,
// or null when this was the last segment. INTEGRATOR: nothing consumes ui:continue yet.
bus.emit('ui:continue', { from: result?.id ?? lvl?.id ?? null, to: result?.to ?? null });
}
// If the player dismisses mid-count, snap to the final numbers instead of freezing a
// half-counted printout — the card is a record, and a record is never partial.
function settleDebrief() {
if (raf) { cancelAnimationFrame(raf); raf = 0; }
// A FLAG, not a re-derived k. The input handlers used to ask "is (now-animT0)/COUNT_S < 1?"
// on every press, which was wrong twice over: settleDebrief never advanced animT0, so the
// second press got k<1 again and re-settled instead of closing — the card could not be
// dismissed until 1.35s of REAL time had passed, however hard the player mashed. And even
// backdating animT0 leaves the close gated on a float landing exactly on 1.0, which it need
// not (a clock with a large epoch loses the last bits of a 1.35 subtraction). What the
// handlers actually want to know is "is the printout finished", which is a fact we own.
settled = true;
animT0 = now() - COUNT_S; // keep the clock agreeing with the screen for any later read
paintDebrief(1);
}
// ---- input -----------------------------------------------------------------------------
const dismissTitle = () => {
if (!titleUp) return false;
titleUp = false;
titleS.classList.remove('on');
bus.emit('ui:start', {});
return true;
};
function onKey(e) {
if (disposed || e.repeat) return;
const code = e.code;
if (titleUp) {
// Any key. Modifiers alone don't count — holding shift to look around shouldn't start
// the run, and neither should a browser shortcut the user is halfway through.
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (code === 'ShiftLeft' || code === 'ShiftRight' || code === 'Tab') return;
if (dismissTitle()) { e.preventDefault(); e.stopPropagation(); }
return;
}
if (debriefUp) {
if (code === 'Tab') return; // leave focus traversal alone
if (!settled) settleDebrief(); // first press finishes the count…
else closeDebrief(); // …second dismisses. Never traps.
e.preventDefault();
e.stopPropagation();
return;
}
if (code === 'Escape' || code === 'KeyP') {
setPaused(!paused);
e.preventDefault();
e.stopPropagation();
}
}
function onPointer(e) {
if (disposed) return;
// Clicks on our own buttons are handled by their click listeners; ignore them here or the
// RESUME button would both resume and be swallowed.
if (e.target && root.contains(e.target)) return;
if (titleUp) {
// stopPropagation ONLY — no preventDefault. preventDefault() on a pointerdown suppresses
// the compatibility `mousedown`, and input.js:65 listens for exactly that, on the one and
// only requestPointerLock() call in the codebase (input.js:50). Eating it costs the player
// a second click before mouse-look works. It also bought nothing: that same handler
// early-returns while unlocked, so this click could never have fired the cannon.
if (dismissTitle()) e.stopPropagation();
return;
}
if (debriefUp) {
if (!settled) settleDebrief(); else closeDebrief();
e.preventDefault();
e.stopPropagation();
}
// While paused, a click on the world does nothing — the RESUME button and esc/p are the
// only ways out, and both are always reachable.
}
// Capture phase: boot.js and flight/input.js both bind keydown/mousedown on window, and we
// must be able to eat the one event we consume BEFORE they see it.
addEventListener('keydown', onKey, true);
addEventListener('pointerdown', onPointer, true);
// ---- bus -------------------------------------------------------------------------------
const offs = [];
offs.push(bus.on('player:state', (p) => {
// Hot path, every frame: copy scalars, touch no DOM. The pause card is repainted when it
// OPENS, not while it is closed — writing five cells 60×/s into a display:none subtree is
// pure waste, and while paused the numbers are frozen by definition.
live.s = p.s ?? 0;
live.length = p.length ?? 0;
live.progress = p.progress ?? 0;
live.hull = p.hullMax ? p.hull / p.hullMax : 0;
live.coat = p.coatMax ? p.coat / p.coatMax : 0;
}));
offs.push(bus.on('combat:state', (c) => {
live.score = c.score ?? 0;
live.kills = c.kills ?? 0;
live.samples = c.samples ?? 0;
}));
offs.push(bus.on('player:death', () => { deaths++; }));
offs.push(bus.on('level:complete', openDebrief));
// Safety valve: taking damage while a title card is still up means the run is live and the
// card is in the way, so it leaves. This is the ONLY auto-dismiss, and the choice of event
// is load-bearing — the two obvious alternatives are both wrong:
// player:state emits from frame one, before the player has touched anything;
// level:event fires off POSITION, and L2's first checkpoint sits at s=20, which the flow
// carries you past in about a second and a half. Either one would rip the title away
// while the player was still reading it.
// Nothing else auto-dismisses: "press any key" waiting forever for a key is correct, and
// is not a trap — every key and every click is the exit.
offs.push(bus.on('player:damage', () => dismissTitle()));
return {
// No-op by design, and here to satisfy the TECH factory contract. boot.js DOES tick us
// (frame() -> ui.update(dt)), and this staying empty is the point: the screens are bus-driven
// plus one self-cancelling rAF for the count-up, so a second driver would only double-advance
// that animation. Free every frame, and free while paused, which is when it runs most.
update() {},
dispose() {
disposed = true;
if (raf) { cancelAnimationFrame(raf); raf = 0; }
removeEventListener('keydown', onKey, true);
removeEventListener('pointerdown', onPointer, true);
for (const off of offs) off();
// If we disposed mid-pause, the loop is still holding for us — release it, or disposing
// the UI freezes the game with no screen left to un-freeze it.
if (paused) { paused = false; bus.emit('ui:pause', { paused: false }); }
root.remove();
const refs = (Number(style.dataset.refs) || 1) - 1;
if (refs > 0) style.dataset.refs = String(refs); else style.remove();
},
};
}