Audit item 14. THE DEPLOY VERIFIED THE ONE URL A PLAYER NEVER LOADS. Every check in deploy.sh appends ?v=$BUST — which is exactly what makes it bypass Cloudflare's edge — so all six could pass green while every actual player was still being served the previous build from a POP. The script's answer was a line of prose at the end asking a human to remember to purge. It now fetches js/boot.js the way a browser does, with no query string, and byte-compares it to what was just shipped; a stale edge fails the deploy and prints the purge steps. No credentials needed, and it asks the question the prose was only gesturing at. This is the same mistake as the `assets` bug and the unwinnable saliva tide: verifying the mechanism rather than the path the player actually takes through it. Third time, so it is written into the tool this time rather than into a comment. QUALITY SWITCH. renderer.setPixelRatio was hardcoded to min(dpr, 2), so a retina laptop drew four times the pixels of a 1x one with no way for its owner to decline — and this game is fill-bound, not geometry-bound (fogged tube, additive glows, a full-screen damage layer), so that is the lever that moves. `performance` caps it at 1. On the title beside difficulty, saved, same reload path (the pixel ratio is set at boot, before any card exists). save.js's two one-line setters collapsed into setOpt(key, value) now that there are two options and a third is obvious. Note on verification: the chip persists and boot reads it, confirmed live. The resolution change itself is not observable in the harness — that browser pane runs at devicePixelRatio 1, where min(1,1) and min(1,2) are the same number. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
939 lines
50 KiB
JavaScript
939 lines
50 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.
|
||
|
||
// The one import in this file. cards.js owns the grade a run is given, so it is also the
|
||
// honest place to file it — routing the record through the bus so boot could re-derive the
|
||
// same grade would be two copies of the medal table waiting to disagree.
|
||
import * as store from '../core/save.js';
|
||
|
||
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; }
|
||
/* A dimmed screen is MODAL, and pointer-events is what makes that true rather than decorative.
|
||
Without this, a click while paused fell through to the canvas, whose mousedown handler
|
||
(flight/input.js:50) re-requests pointer lock whenever it is not held — so clicking next to
|
||
the RESUME button re-captured the pointer, hid the cursor, and left the player paused with
|
||
no pointer to press RESUME with. Our own dismiss handlers are capture-phase on window, so
|
||
they still see every click; only the canvas underneath stops seeing them. The TITLE has no
|
||
dimmer (it uses .tscrim) and is deliberately still click-through. */
|
||
.crd .scr.on .dimmer { pointer-events:auto; }
|
||
.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; }
|
||
/* --- title menu: level select + difficulty. Real <button>s, so they are tab-reachable and
|
||
announce themselves; the title is the only screen a player may arrive at cold. */
|
||
.crd .title .menu { margin:26px auto 0; display:flex; flex-wrap:wrap; gap:5px;
|
||
justify-content:center; max-width:min(560px, 84vw); }
|
||
/* pointer-events:auto, per the header note — #ui is pointer-events:none and it inherits, so a
|
||
button without this is a button that renders, highlights nothing, and silently lets the click
|
||
through to the canvas (which, on the title, starts the run). */
|
||
.crd .title .chip { pointer-events:auto;
|
||
font:inherit; font-size:9px; letter-spacing:.2em; text-transform:uppercase;
|
||
padding:5px 9px; border:1px solid currentColor; border-radius:2px; background:rgba(2,16,13,.35);
|
||
color:${C.ink}; opacity:.5; cursor:pointer; transition:opacity .12s linear; }
|
||
.crd .title .chip:hover, .crd .title .chip:focus-visible { opacity:1; outline:none; }
|
||
.crd .title .chip[disabled] { opacity:.15; cursor:default; }
|
||
.crd .title .chip .m { margin-left:7px; font-size:11px; line-height:0; vertical-align:-1px; }
|
||
.crd .title .foot { font-size:9px; letter-spacing:.28em; opacity:.42; margin-top:16px; }
|
||
|
||
@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');
|
||
|
||
// A medal on a title-screen chip is one dot. Colour carries the tier — the same ART_BIBLE
|
||
// emissive code the rest of the game reads by, so nobody has to learn a second language.
|
||
const MEDAL_DOT = { platinum: C.surf, gold: C.warn, silver: C.ink, bronze: '#c8813c' };
|
||
|
||
/**
|
||
* Go somewhere, keeping every flag the player is already carrying.
|
||
*
|
||
* A full page load, deliberately, and for the same reason boot.js reloads between segments:
|
||
* rebuilding world + player + combat + UI in place to save a second on a transition that
|
||
* happens a handful of times is a leak hunt nobody asked for. A key with a null value is
|
||
* REMOVED, which is how the difficulty toggle drops a stale ?diff= override.
|
||
*/
|
||
/**
|
||
* Take the pointer lock, and treat a refusal as the nothing it is.
|
||
*
|
||
* requestPointerLock() RETURNS A PROMISE in current browsers. A try/catch around it catches
|
||
* nothing, and the rejection surfaces as an `unhandledrejection` — which boot.js, correctly,
|
||
* turns into a full-screen FEED LOST card. So the browser declining a lock for any of its many
|
||
* ordinary reasons (the document isn't focused, or Chrome's ~1s lockout after the user leaves
|
||
* a lock with Escape) presented to the player as the game crashing. Caught live, on the pause
|
||
* card, by a lock request that was refused: "the root document of this element is not valid
|
||
* for pointer lock" over the top of a game that was running perfectly well underneath.
|
||
*
|
||
* A refused lock costs the player one click on the canvas — flight/input.js re-requests it from
|
||
* its own mousedown — so there is nothing here worth telling anyone about.
|
||
*/
|
||
function grabPointer() {
|
||
try {
|
||
// The optional-call chain can yield undefined on browsers that predate the promise form
|
||
// (they throw synchronously instead), hence both the ?. and the try.
|
||
document.getElementById('game')?.querySelector('canvas')?.requestPointerLock?.()?.catch?.(() => {});
|
||
} catch { /* no lock; the player clicks and input.js takes it from there */ }
|
||
}
|
||
|
||
function go(patch) {
|
||
const q = new URLSearchParams(location.search);
|
||
for (const [k, v] of Object.entries(patch)) {
|
||
if (v == null) q.delete(k); else q.set(k, v);
|
||
}
|
||
location.search = q.toString();
|
||
}
|
||
|
||
/**
|
||
* 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, campaign = [],
|
||
} = {}) {
|
||
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 · mouse aim · LMB cannon · RMB torpedo<br>' +
|
||
'SHIFT / CTRL throttle · SPACE boost · ESC or P pause';
|
||
|
||
// ---- level select + difficulty ------------------------------------------------------
|
||
// The campaign has been six levels long for two rounds with exactly one way to reach any
|
||
// of them: hand-editing ?lvl= in the address bar. And levels/difficulty.js has carried
|
||
// three complete tiers since round 2 that no player could select. Both live here, on the
|
||
// one screen every player already sees, rather than behind a menu system this game does
|
||
// not otherwise need. (ponytail: no menu stack, no focus trap, no options screen — six
|
||
// buttons and a toggle. Build the stack when there is a fourth thing to put in it.)
|
||
const S = store.load();
|
||
const menu = el('div', 'menu', t);
|
||
const nameOf = (id) => id.replace(/^L[S\d]+_/, '').replace(/_/g, ' ');
|
||
// A level is open once the one before it has been filed. `LS_` is C's secret prefix — the
|
||
// appendix is reached by earning it at the ileocecal valve, so it stays a row of question
|
||
// marks until you have actually been there. Naming it in the menu would give it away.
|
||
const secret = (id) => id.startsWith('LS_');
|
||
const open = (id, i) => !!S.best[id] || (!secret(id) && (i === 0 || !!S.best[campaign[i - 1]]));
|
||
|
||
for (let i = 0; i < campaign.length; i++) {
|
||
const id = campaign[i];
|
||
const isOpen = open(id, i);
|
||
const b = el('button', 'chip', menu);
|
||
b.type = 'button';
|
||
b.textContent = isOpen ? nameOf(id) : (secret(id) ? '???' : `${nameOf(id)} · locked`);
|
||
if (id === lvl?.id) b.style.opacity = '1';
|
||
if (!isOpen) { b.disabled = true; continue; }
|
||
const rec = S.best[id];
|
||
if (rec && MEDAL_DOT[rec.medal]) {
|
||
const m = el('span', 'm', b);
|
||
m.textContent = '●';
|
||
m.style.color = MEDAL_DOT[rec.medal];
|
||
b.title = `best ${mmss(rec.time ?? 0)} · ${num(rec.score)} · ${rec.medal} · ${rec.runs} run${rec.runs === 1 ? '' : 's'}`;
|
||
}
|
||
b.addEventListener('click', () => go({ lvl: id }));
|
||
}
|
||
|
||
// Difficulty reloads rather than re-applying live: difficulty.js is applied at getLevel(),
|
||
// before world generation, so enemy counts are already baked into the world standing behind
|
||
// this card. A reload is two lines and always correct; re-authoring a live level is neither.
|
||
const dOrder = ['easy', 'normal', 'hard'];
|
||
const dName = { easy: 'endoscopy', normal: 'clinical', hard: 'terminal' };
|
||
const dBtn = el('button', 'chip', menu);
|
||
dBtn.type = 'button';
|
||
dBtn.style.color = C.warn;
|
||
const paintDiff = () => { dBtn.textContent = `difficulty · ${dName[S.diff] ?? S.diff}`; };
|
||
paintDiff();
|
||
dBtn.addEventListener('click', () => {
|
||
S.diff = dOrder[(dOrder.indexOf(S.diff) + 1) % dOrder.length];
|
||
store.setOpt('diff', S.diff);
|
||
paintDiff();
|
||
go({ diff: null }); // drop any ?diff= override so the save is what takes effect
|
||
});
|
||
|
||
// Quality. Same reload path as difficulty for the same reason — the renderer's pixel ratio
|
||
// is set once at boot, before anything this card can reach exists.
|
||
const qBtn = el('button', 'chip', menu);
|
||
qBtn.type = 'button';
|
||
qBtn.style.color = C.soft;
|
||
qBtn.textContent = `quality · ${S.quality === 'low' ? 'performance' : 'full'}`;
|
||
qBtn.title = 'caps the render resolution — the one lever that moves the frame rate on a laptop';
|
||
qBtn.addEventListener('click', () => {
|
||
store.setOpt('quality', S.quality === 'low' ? 'high' : 'low');
|
||
go({});
|
||
});
|
||
|
||
// The one thing a save is FOR: proof you finished. Nothing in this game acknowledged a
|
||
// completed campaign anywhere the player could see it.
|
||
if (S.cleared > 0) {
|
||
const f = el('div', 'foot', t);
|
||
f.style.color = C.good;
|
||
f.textContent = S.cleared === 1 ? 'canal cleared' : `canal cleared × ${S.cleared}`;
|
||
}
|
||
}
|
||
|
||
// ============================ 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);
|
||
// THE POINTER HAS TO EXIST FOR THE BUTTON TO MEAN ANYTHING. Pausing with P kept the pointer
|
||
// lock, so the cursor stayed captured and invisible — the card showed a RESUME button that
|
||
// could not be pointed at. (Pausing with Escape looked fine only because the BROWSER drops
|
||
// the lock for you, which is also why the two keys behaved differently for no stated reason.)
|
||
// Coming back, we ask for it again from inside the resume gesture, so mouse-look is live the
|
||
// instant the card goes; if the browser refuses — Chrome rate-limits a re-lock for about a
|
||
// second after an Escape exit — the canvas click path in input.js is still there as it was.
|
||
if (paused) {
|
||
if (document.pointerLockElement) document.exitPointerLock?.();
|
||
paintPause();
|
||
pause.resumeBtn.focus?.();
|
||
} else {
|
||
grabPointer();
|
||
}
|
||
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),
|
||
};
|
||
// File it. The latch above guarantees once per segment per run, so this cannot double-count
|
||
// attempts. (ponytail: one best per level, not one per difficulty — a difficulty-split
|
||
// record table is three times the storage to answer a question nobody has asked yet.)
|
||
store.record(result.id, result.stats, result.g.grade.medal);
|
||
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('');
|
||
// A gate with no `to` is C's end-of-campaign marker (the anal verge is the only one). This
|
||
// used to read "any key", which is what the card says when it has nothing to tell you —
|
||
// finishing the whole game was acknowledged in exactly the same words as clearing a segment.
|
||
D.next(e?.to ? `next · ${e.to}` : 'canal cleared · you are out');
|
||
// 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.
|
||
grabPointer();
|
||
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;
|
||
// A tabbed-to level chip must be activatable. Without this, "any key starts the run" ate
|
||
// the Enter that was meant to press the button under the focus ring — the menu would have
|
||
// been keyboard-visible and keyboard-unusable, which is worse than not having it.
|
||
if (root.contains(document.activeElement) && (code === 'Enter' || code === 'Space')) 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);
|
||
|
||
// PAUSE ON BLUR, and it has to live HERE. This started in boot.js emitting ui:pause directly,
|
||
// which halted the loop and left this file's `paused` still false — so the game stopped with
|
||
// no card to say why, two owners disagreed about the pause state, and the next P press showed
|
||
// a card that then took a SECOND press to leave. The pause state has one owner: setPaused.
|
||
// It already refuses while the title or the debrief is up, so alt-tabbing away from a card
|
||
// does nothing, which is right.
|
||
const onAway = () => setPaused(true);
|
||
addEventListener('blur', onAway);
|
||
const onHidden = () => { if (document.hidden) onAway(); };
|
||
document.addEventListener('visibilitychange', onHidden);
|
||
|
||
// ---- 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);
|
||
removeEventListener('blur', onAway);
|
||
document.removeEventListener('visibilitychange', onHidden);
|
||
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();
|
||
},
|
||
};
|
||
}
|