guts/web/js/ui/cards.js
type-two 7455c03ae3 [lane B+C+E+F] Playtest blockers: the game never taught its controls, deleted bosses on death, and shipped an unwinnable tutorial
A five-way audit of the shipped build found the first thirty seconds are broken in three
independent ways. All three are verified, not inferred.

1. THE CONTROLS WERE NEVER STATED. The title said "press any key", and aim + fire are both gated
   on pointer lock (input.js:45 onMouseMove early-returns unlocked; input.js:50 the first
   mousedown only REQUESTS the lock) — so a player who dismissed with a key got no mouse look and
   a cannon that silently did nothing. Worse: in an ARENA the forward axis belongs solely to
   intent.throttle (player.js), so an untaught player coasts to a dead stop in front of L1's Mast
   Cell and reasonably concludes the game is broken. Six arenas across L1/L3/L5/LS are the same
   trap. The card now says CLICK TO FLY, takes the pointer lock on any dismissal, and prints the
   legend that has existed — written and styled — in flight/dev.html since round 1, visible only
   to whoever was running Lane B's harness.

2. DYING DELETED THE BOSS. boot's pump advanced `_ei` monotonically and never rewound. That
   reasoning is right for `spawn` and `pickup` (re-firing double-spawns survivors and resurrects
   banked pickups) but it silently applied to `boss` as well: die to any of the five, respawn,
   fly through an empty lair, cross the gate, and collect a graded medal for a fight that never
   happened. Dying was the fastest route past every boss in the game. Now a REARM set ({boss,
   acid}) re-fires on respawn while everything else is retired into `_spent`. Verified by event
   trace: start:mast_cell -> end:wipe -> start:mast_cell. Also despawns the sentinel's body on a
   wipe, which the fix exposed as a duplicate (1 -> 2 Mast Cells per death).

3. L1'S TIDE WAS UNWINNABLE, AND MINE. I authored it this morning at speed 5 in a flow-2 segment,
   reasoning "5 against 2 catches a dawdler". The player ceiling there is 2.8 u/s sustained
   (throttleMax 1.4 x flow 2); boost peaks 5.2 for 0.55s on a 2.4s cooldown. C's own --sim prints
   the verdict in plain text — "required 2.50x UNWINNABLE" — and I never ran it. Because
   hazards.js grazes on gap<=catchRadius and the gap stays negative once passed, it was a ~74 dps
   loop back onto the s=475 checkpoint: a brand-new player, in the default level, hard-stopped
   three minutes in. Retuned to 2.4 (required 1.20x — the same shape as L2's teaching burp).

   The validator was exempting it: `if (!c.lethal) continue` in levels/index.js meant qa.sh went
   green over a death loop. An unwinnable chase is a design failure whatever its lethality — a
   non-lethal one you cannot outrun is a continuous-damage trap. That exemption is gone, and all
   six levels still validate.

Also: DEFAULT_LEVEL was still 'L2_esophagus' from when L2 was the only finished level, so every
new player skipped the tutorial entirely and started mid-campaign. LANE_C_NOTES §→ Lane F #2
said "change it if you would rather it be L1". It is L1 now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 19:04:25 +10:00

762 lines
40 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:9px; letter-spacing:.16em; opacity:.5; margin-top:14px;
line-height:1.9; }
/* 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);
// CLICK, not "press any key". Aim and fire are both gated on pointer lock (input.js
// onMouseMove early-returns while unlocked, and the first mousedown only REQUESTS the lock),
// so a player who dismissed this with a key got no mouse look and a cannon that silently did
// nothing. Asking for the click is what makes the game work, not a stylistic choice.
press.textContent = 'click to fly';
// The controls have never been stated anywhere the player can see — the legend below has
// existed, written and styled, in flight/dev.html since round 1, visible only to whoever was
// running Lane B's harness. Shift/Ctrl especially: in an ARENA it is the only forward input
// there is (player.js gives the axis to intent.throttle), so an untaught player coasts to a
// dead stop in front of L1's Mast Cell and concludes the game is broken.
const keys = el('div', 'keys', t);
keys.innerHTML =
'WASD move &middot; mouse aim &middot; LMB cannon &middot; RMB torpedo<br>' +
'SHIFT / CTRL throttle &middot; SPACE boost &middot; ESC or 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');
// Take the pointer lock on the way out. input.js only ever requests it from its own
// mousedown, which meant a keyboard dismissal left the player with no aim at all; doing it
// here makes "click to fly" literally true however the card was dismissed. Must be inside a
// user gesture, which every dismissal path is. Wrapped: browsers reject the request if the
// document is not focused, and a rejected lock is not worth a thrown card.
try { document.getElementById('game')?.querySelector('canvas')?.requestPointerLock?.(); }
catch { /* no lock, keyboard still flies */ }
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();
},
};
}