// 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