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>
743 lines
38 KiB
JavaScript
743 lines
38 KiB
JavaScript
// 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();
|
||
},
|
||
};
|
||
}
|