[lane E] The game finds its voice: procedural synth bank, the WebAudio graph, the damage feed, and the medal/title/pause cards

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 13:57:38 +10:00
parent 0e2b5c10fe
commit 464f5c8a18
5 changed files with 5052 additions and 4 deletions

1747
web/js/audio/engine.js Normal file

File diff suppressed because it is too large Load Diff

1819
web/js/audio/synth.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -63,16 +63,47 @@ const combat = player ? createCombat({ scene, world, bus, rng, flags, player })
let ui = null; let ui = null;
if (player) { if (player) {
try { try {
const [hudMod, commsMod] = await Promise.all([import('./ui/hud.js'), import('./ui/comms.js')]); const [hudMod, commsMod, fbMod, cardsMod, audioMod] = await Promise.all([
import('./ui/hud.js'), import('./ui/comms.js'), import('./ui/feedback.js'),
import('./ui/cards.js'), import('./audio/engine.js'),
]);
const args = { bus, flags, assets, level: world.level, world };
// Each module is constructed independently: one that throws must not cost us the rest.
// A missing score readout is a blemish; a UI layer that fails to build in a game whose
// only other feedback is the wall rushing past is an unplayable build.
const parts = {};
for (const [key, make] of [
['hud', () => hudMod.createHUD({ bus, flags, level: world.level })],
['comms', () => commsMod.createComms({ bus, flags })],
['feedback', () => fbMod.createFeedback(args)],
['cards', () => cardsMod.createCards(args)],
['audio', () => audioMod.createAudio(args)],
]) {
try { parts[key] = make(); } catch (e) { console.info(`[boot] Lane E ${key} failed —`, e.message); }
}
const ticking = Object.values(parts).filter((p) => typeof p?.update === 'function');
ui = { ui = {
hud: hudMod.createHUD({ bus, flags, level: world.level }), ...parts,
comms: commsMod.createComms({ bus, flags }), // The HUD is bus-driven and needs no tick; the audio heartbeat, the corruption decay
// and the card animations do. Called from frame(), never from step(): step() is the
// deterministic sim that stepped-sim harnesses drive thousands of times a second, and
// it must not do DOM or WebAudio work.
update(dt) { for (const p of ticking) p.update(dt); },
dispose() { for (const p of Object.values(parts)) { try { p?.dispose?.(); } catch { /* keep going */ } } },
}; };
} catch (e) { } catch (e) {
console.info('[boot] Lane E UI not available —', e.message); console.info('[boot] Lane E UI not available —', e.message);
} }
} }
// Pause. cards.js raises ui:pause; somebody has to honour it or the button is a lie (its
// reviewer flagged exactly that, and it is boot's to fix — E cannot reach the loop).
// Gated in frame(), NOT in step(), so a paused game still renders and animates its overlay
// while the simulation holds still — and so stepped sims, which call step() directly, are
// unaffected by a UI state they have no business knowing about.
let paused = false;
bus.on('ui:pause', (e) => { paused = !!e?.paused; });
// --- level-event pump (owned by boot per round-2 ruling): emits C's events as the // --- level-event pump (owned by boot per round-2 ruling): emits C's events as the
// player crosses their s. Each event fires ONCE per run — respawn does not rewind the pump // player crosses their s. Each event fires ONCE per run — respawn does not rewind the pump
// (no double-spawns, collected pickups stay collected); hazards re-arm via combat.reset(), // (no double-spawns, collected pickups stay collected); hazards re-arm via combat.reset(),
@ -188,7 +219,8 @@ let last = performance.now();
function frame(now) { function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05); const dt = Math.min((now - last) / 1000, 0.05);
last = now; last = now;
step(dt); if (!paused) step(dt);
ui?.update?.(dt); // overlay keeps animating while paused — the pause card moves
renderer.render(scene, camera); renderer.render(scene, camera);
stats.draws = renderer.info.render.calls; stats.draws = renderer.info.render.calls;
stats.tris = renderer.info.render.triangles; stats.tris = renderer.info.render.triangles;

742
web/js/ui/cards.js Normal file
View File

@ -0,0 +1,742 @@
// 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();
},
};
}

708
web/js/ui/feedback.js Normal file
View File

@ -0,0 +1,708 @@
// ui/feedback.js (Lane E) — damage, death and re-acquire, rendered as the SCANNER FEED
// FAILING. ART_BIBLE §Screens is explicit and this file exists to obey it: "Damage = feed
// glitches (aberration, tearing), not red screen-edges." NO DAMAGE STATE IN THIS FILE IS AN
// EDGE TREATMENT: damage draws full-frame line patterns, static and tears, never a vignette,
// red or dark. Getting hurt does not tint the world, it degrades the transmission you are
// watching the world through.
// (Precision, because an earlier version of this comment overclaimed: there ARE two radial
// gradients in the file. `.fdbk-smear` is the boost effect — cyan, never driven by damage,
// and it is the DOM stand-in ART_BIBLE §Post asks for. `.fdbk-black` is the death blackout,
// whose radial is a *hole* punched for comms' subtitle box, not a rim. Neither is reachable
// from `hit` or `chronic`, which is the property that matters.)
//
// The scene-side half of this (real chromatic aberration in the post chain) is F/A's. We are
// the DOM half: layered absolutely-positioned divs, a low-res Canvas2D static plate, and
// transform/opacity writes only. That split is why the fringing here is a full-screen line
// pattern rather than an edge treatment — an edge treatment IS the banned vignette wearing
// a different hat, and it also fights whatever F does in the shader.
//
// Bus-only (House Law 1). Nothing here imports flight/ or combat/; every number arrives as
// an event payload whose field names were read off the emitters, not remembered.
//
// IDLE COST IS ZERO. While you are healthy the root is display:none, the rAF is cancelled,
// and the only work done is the handful of divides and compares in the player:state handler
// (which then does NOT wake us). A damage overlay that costs frames while nothing is wrong
// is a bug, not a feature.
// ART_BIBLE emissive code, duplicated locally — hud.js owns its own copy for the same reason
// (no shared helper module in web/js/ui/; each file stands alone).
const CY = '#39e6ff'; // neutral/interactive cyan — the feed's own colour
const CY_HI = '#7fdfff';
const WARM = '#ff5a2a'; // hostile. Legal as the WARM HALF OF A CHROMA PAIR (see FRINGE
// below); illegal as a screen edge. The distinction is the law.
// Static plate resolution. 128x72 upscaled with image-rendering:pixelated is chunky on
// purpose — datamosh, not film grain — and it is the whole reason this is affordable: a
// full-res per-pixel canvas at 60fps is roughly 500x the pixel work for a worse look.
const NW = 128, NH = 72, TILES = 6;
// Coat ratio at which the creeping corruption starts. comms.js uses 0.25 for its "coat low"
// line; this starts earlier so the picture is already degrading when adeyemi says it out
// loud. The voice confirms what you can already see. (At coatR 0.25 the raw ramp is 0.5 and
// chronicTgt lands at 0.4 after COAT_W — coat is deliberately weighted under hull loss.)
const COAT_START = 0.5;
// Damage amounts in the tree (combat/balance.js, flight/tuning.js) run 3.6 (aura tick) to 30
// (gate slam) with wall hits capped at 28. 30 is therefore "the worst single hit in the game"
// and makes a clean normaliser. The envelope below is scaled so 3.6 lands at ~0.19 — under
// TUNE.heavyAt, i.e. a flicker — while 28-30 pins at 1.0.
const HIT_NORM = 30;
// e-folds/s. Honest numbers, since the previous comment was wrong by ~3x: half-life 0.116s,
// so a full-strength hit is perceptually spent in ~0.3s (down to 0.16) and has crossed the
// 0.02 visibility floor by ~0.65s. "Gone in a third of a second" is the feel; 0.65s is the
// arithmetic. Do not quote the feel number as if it were the arithmetic one.
const HIT_DECAY = 6.0;
const DEATH_BURST = 0.30; // s of signal collapse before the picture is gone
// s of lock-on. Deliberately near audio/engine.js's reacquire(): its carrier/static ramps
// finish at 0.400 and its setTargetAtTime(tau 0.25) bed has settled by ~0.75. At 0.95 the
// ears were locked while the eyes were still stuttering. Both modules still invent this
// duration independently off player:spawn — see the integrator note in the round report.
const REACQ = 0.75;
// --- TUNE ---------------------------------------------------------------------------------
// Everything that decides the LOOK, in one place. These used to be ~30 literals buried in
// step(), which meant "make damage read harder but leave death alone" was a reverse-
// engineering job. Frozen so a typo'd key throws in dev instead of silently reading
// undefined and painting NaN. Read once per frame at most; no runtime cost.
const TUNE = Object.freeze({
// master mix
chronicW: 0.55, // how much sustained rot counts toward the master corruption `c`
coatW: 0.80, // coat ramp is worth less than hull loss, which carries full weight
tearChronicW: 0.18, // ...but tearing is nearly hit-only. See the note at `tearDrive`.
// scanlines
scanBase: 0.04, scanGain: 0.40, creepBase: 5, creepGain: 34,
// roll bar (chronic only)
rollGain: 0.85, rollBase: 7, rollDrift: 9,
// chroma fringe
fringeAt: 0.02, fringeA: 0.22, splitC: 7, splitHit: 5,
// tears
heavyAt: 0.30, tear2At: 0.45, tear3At: 0.75,
tearBase: 0.18, tearGain: 0.75, tearMax: 0.90, tearStep: 0.07,
// static plate
noiseAt: 0.02, noiseBase: 0.06, noiseGain: 0.62, noiseMax: 0.85,
dropoutAt: 0.30, dropout2At: 0.50, dropout3At: 0.75,
// boost smear
smearA: 0.90,
});
const CSS = `
/* z-index 4, not 5, ON PURPOSE: cards.js parks .crd at z-index:5 and both are inset:0
siblings under #ui, so at equal z the winner is whoever boot constructs last a coin
flip deciding whether the medal card and the pause panel are read through corruption.
4 puts us permanently underneath cards and above the HUD's chrome. */
.fdbk { position:absolute; inset:0; overflow:hidden; pointer-events:none; z-index:4;
contain:layout paint style; display:none; }
/* --- scanlines. One fixed 3px-period gradient, crept by translateY only. Regenerating the
gradient per frame would repaint the whole layer; translating it is a composite. --- */
.fdbk-scan { position:absolute; left:0; right:0; top:-6px; bottom:-6px; opacity:0;
background:repeating-linear-gradient(180deg,
rgba(0,0,0,.55) 0px, rgba(0,0,0,.55) 1px, rgba(0,0,0,0) 1px, rgba(0,0,0,0) 3px);
will-change:transform,opacity; }
/* --- analog roll bar: the soft horizontal band that drifts down a picture losing sync.
Chronic damage only it is the slow, dreadful one. --- */
.fdbk-roll { position:absolute; left:0; right:0; top:0; height:22%; opacity:0;
background:linear-gradient(180deg,
rgba(127,223,255,0) 0%, rgba(127,223,255,.05) 35%,
rgba(127,223,255,.12) 55%, rgba(0,0,0,.22) 88%, rgba(0,0,0,0) 100%);
will-change:transform,opacity; }
/* --- CHROMA FRINGE. The aberration pair: the same hairline pattern in a warm and a cool
copy with opposite X offsets. Uniform across the frame no radial falloff, no edge
weighting, because edge-weighted red is the thing ART_BIBLE bans by name. Offset is a
transform, so widening the split costs nothing.
WHAT mix-blend-mode ACTUALLY DOES HERE, because the naive reading is wrong and it cost us
an orange grille: it does NOT blend with the WebGL canvas and it never can. #game and #ui
are siblings, #ui is position:fixed (a stacking context on its own), and .fdbk adds two
more (z-index + contain). Blending is isolated to .fdbk's subtree by construction this
is not something a contain-property tweak reaches, so do not go hunting for it.
The blend is kept anyway because it earns its keep INSIDE the subtree: where the warm and
cool copies overlap they screen to near-white, which is exactly the read of one picture
separating rather than two grilles sliding. Real scene-space aberration is F/A's post
chain; this is the DOM's cheap tell that it is happening.
Consequence, and the reason for the alphas below: screen over transparent black is just
the source colour, so at full opacity a solid ${WARM} stop would paint a 1-in-4 opaque
orange grille over the whole frame the exact hostile-warm-everywhere this file exists to
refuse. The stops are therefore half-alpha and TUNE.fringeA caps the layer low; worst case
is ~11% warm on a quarter of the rows. A fringe you notice, not a filter you wear. --- */
.fdbk-fr { position:absolute; left:-24px; right:-24px; top:0; bottom:0; opacity:0;
mix-blend-mode:screen; will-change:transform,opacity; }
.fdbk-fr-w { background:repeating-linear-gradient(180deg,
rgba(255,90,42,.5) 0px, rgba(255,90,42,.5) 1px,
rgba(0,0,0,0) 1px, rgba(0,0,0,0) 4px); }
.fdbk-fr-c { background:repeating-linear-gradient(180deg,
rgba(57,230,255,.5) 0px, rgba(57,230,255,.5) 1px,
rgba(0,0,0,0) 1px, rgba(0,0,0,0) 4px); }
/* --- tear bands: displaced slabs of picture. A fixed 96px slab scaled with scaleY and slid
with translate3d; height is never written (that is a layout pass). --- */
/* No will-change here, deliberately, and none on .fdbk-card either. Tears only move on the
70ms step and only during heavy damage; the card changes twice a life. Promoting them
would add 4 more full-viewport GPU surfaces (~30MB at 1440p) to the exact frames the
renderer is already worst off on, to save a raster the compositor was going to do anyway.
The layers that DO get will-change are the ones written every single frame. */
.fdbk-tear { position:absolute; left:-40px; right:-40px; top:0; height:96px; opacity:0;
transform-origin:0 0;
background:linear-gradient(180deg,
rgba(127,223,255,.50) 0px, rgba(127,223,255,.50) 1px,
rgba(57,230,255,.10) 2px, rgba(0,0,0,.30) 60%, rgba(0,0,0,0) 100%); }
/* --- the static plate --- */
.fdbk-noise { position:absolute; inset:0; width:100%; height:100%; opacity:0;
image-rendering:pixelated; will-change:opacity; }
/* --- CRT collapse line: the picture folding into one bright scanline and going out.
Scales on X from a fixed full-width bar, so it is one transform write. --- */
.fdbk-line { position:absolute; left:0; right:0; top:50%; height:2px; margin-top:-1px;
opacity:0; background:${CY_HI}; box-shadow:0 0 18px ${CY}, 0 0 4px #fff;
transform-origin:50% 50%; will-change:transform,opacity; }
/* --- boost smear. Cyan, and ART_BIBLE §Post asks for "subtle radial blur on boost" this
is the DOM stand-in for it. Cool, so it cannot be misread as a damage vignette. --- */
.fdbk-smear { position:absolute; inset:-6%; opacity:0; will-change:transform,opacity;
background:radial-gradient(ellipse 58% 50% at 50% 50%,
rgba(57,230,255,0) 52%, rgba(57,230,255,.10) 76%, rgba(127,223,255,.30) 100%); }
/* --- feed drop. The mask is a deliberate coupling with comms.js, which parks its box at
left:18px/bottom:70px/width:330px: the one line that MUST stay legible while the screen is
black is voss saying "we lost the feed. re-acquiring." So the blackout holds back to ~35%
over that rectangle instead of burying it. Static declaration, zero per-frame cost.
THIS IS AN UNDECLARED DEPENDENCY IN COMMS' DIRECTION and it fails silently: move the comms
box and the mask stops protecting the line, with no error and nothing to grep. House Law 1
is why it is a hardcoded rectangle rather than a measurement, but whoever owns comms.js
should carry a matching comment. Flagged for the integrator. --- */
.fdbk-black { position:absolute; inset:0; opacity:0; background:#000;
-webkit-mask-image:radial-gradient(240px 96px at 190px calc(100% - 100px),
rgba(0,0,0,.34), #000 74%);
mask-image:radial-gradient(240px 96px at 190px calc(100% - 100px),
rgba(0,0,0,.34), #000 74%);
will-change:opacity; }
/* --- the readout. House type: mono, small, uppercase, wide tracking.
It carries a plate. "SIGNAL RE-ACQUIRED" is read at 19px/.42em against the live gut
interior WITH stepped static running over it a text-shadow alone loses that fight, and
cards.js gives its equivalents a panel for the same reason. Thin cyan hairline + near-
black fill is the house frame, not a new idiom. --- */
.fdbk-card { position:absolute; left:50%; top:44%; opacity:0;
transform:translate(-50%,-50%); text-align:center; color:${CY};
font:11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing:.28em; text-transform:uppercase;
padding:16px 26px 15px; background:rgba(2,16,13,.72);
border:1px solid rgba(57,230,255,.28);
box-shadow:0 0 0 1px rgba(0,0,0,.5), 0 0 26px rgba(0,0,0,.6);
text-shadow:0 0 8px rgba(0,0,0,.95); }
.fdbk-card .fdbk-big { display:block; font-size:19px; letter-spacing:.42em; }
.fdbk-card .fdbk-sub { display:block; margin-top:9px; font-size:9px; opacity:.55;
letter-spacing:.24em; }
.fdbk-card .fdbk-cause { display:block; margin-top:4px; font-size:9px; opacity:.38;
letter-spacing:.24em; }
`;
const el = (tag, cls, parent) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (parent) parent.appendChild(n);
return n;
};
const clamp01 = (v) => (v > 0 ? (v < 1 ? v : 1) : 0);
// Seeded, because this repo hashes and seeds everything (hud.js derives its data-noise from
// p.s rather than a wall-clock RNG for the same reason) — and because a bug you cannot reproduce
// in a full-screen glitch layer is a bug you never fix.
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Bake the static ONCE into a filmstrip, then the hot path is a single drawImage per frame.
// Generating 128x72 pixels of noise every frame is affordable, but it is ~37k typed-array
// writes we simply do not have to make, and pre-baked grain is what film and video have
// always done. Six tiles is NOT enough to be invisible on its own — each tile's hot rows are
// baked in, so stepping 0..5 in order gives you a 10Hz metronome. step() random-walks the
// tile index instead, which is what actually buys the invisibility; see the note there.
function bakeStatic(seed) {
const c = document.createElement('canvas');
c.width = NW * TILES; c.height = NH;
const g = c.getContext('2d');
if (!g) return null; // ancient/blocked canvas — caller degrades
const rnd = mulberry32(seed);
for (let t = 0; t < TILES; t++) {
const img = g.createImageData(NW, NH);
const d = img.data;
for (let y = 0; y < NH; y++) {
// Feed static is ROW-CORRELATED — a handful of lines per frame carry a hot streak.
// Uncorrelated per-pixel salt reads as film grain, which is the wrong medium entirely.
const hot = rnd() < 0.09 ? 1 : 0.28;
for (let x = 0; x < NW; x++) {
const n = rnd();
const i = (y * NW + x) * 4;
d[i] = 150; d[i + 1] = 245; d[i + 2] = 255; // cyan-white speck, on-palette
d[i + 3] = (n * n * n * hot * 255) | 0; // cubed => sparse, not a grey wash
}
}
g.putImageData(img, t * NW, 0);
}
return c;
}
// Style writes are cached per node. Every one of these runs up to 60x/s; assigning an
// identical string still dirties the node in some engines, and the compare is free.
function styled(node) {
let lo = -1, lt = '';
return {
node,
o(v) { const n = Math.round(v * 1000) / 1000; if (n !== lo) { node.style.opacity = n; lo = n; } },
t(s) { if (s !== lt) { node.style.transform = s; lt = s; } },
};
}
// assets/level/world are accepted but unused in round 1: the factory shape is uniform across
// Lane E so boot can construct us the same way it constructs the HUD, and `world` is the door
// through which world.flowPulse would arrive if we ever want tear/squelch on the peristaltic
// beat (LANE_E_UIHUD calls that round 2+, and House Law 1 forbids reaching for it ourselves).
export function createFeedback({
bus, flags = {}, assets = null, level = null, world = null, mount = null,
} = {}) {
const host = mount ?? document.getElementById('ui');
// ?shots=1 takes comms.js's route (build nothing) rather than hud.js's (build then hide):
// this module's entire output is noise over a clean plate, and a static plate we will never
// draw is pure waste. Both routes exist in-tree; this is the one that costs zero.
if (!host || !bus || flags.shots) return { update() {}, dispose() {} };
const style = el('style');
style.textContent = CSS;
document.head.appendChild(style);
const root = el('div', 'fdbk', host);
const scan = styled(el('div', 'fdbk-scan', root));
const roll = styled(el('div', 'fdbk-roll', root));
const frW = styled(el('div', 'fdbk-fr fdbk-fr-w', root));
const frC = styled(el('div', 'fdbk-fr fdbk-fr-c', root));
const tears = [styled(el('div', 'fdbk-tear', root)),
styled(el('div', 'fdbk-tear', root)),
styled(el('div', 'fdbk-tear', root))];
const noiseEl = el('canvas', 'fdbk-noise', root);
noiseEl.width = NW; noiseEl.height = NH;
const noise = styled(noiseEl);
const nctx = noiseEl.getContext('2d');
const line = styled(el('div', 'fdbk-line', root));
const smear = styled(el('div', 'fdbk-smear', root));
const black = styled(el('div', 'fdbk-black', root));
const cardEl = el('div', 'fdbk-card', root);
cardEl.innerHTML = '<span class="fdbk-big"></span><span class="fdbk-sub"></span>' +
'<span class="fdbk-cause"></span>';
const card = styled(cardEl);
const cardBig = cardEl.querySelector('.fdbk-big');
const cardSub = cardEl.querySelector('.fdbk-sub');
const cardCause = cardEl.querySelector('.fdbk-cause');
// Baked at construction, not on first damage: the first hit is exactly the frame you cannot
// afford a 2ms hitch on. 55k pixels is a one-off cost boot never notices.
const plate = bakeStatic(0x5f3a17);
// --- state ---------------------------------------------------------------------------
let hit = 0; // impulse envelope, snap-on / exp decay
let chronic = 0; // sustained corruption from coat+hull, smoothed
let chronicTgt = 0;
let boost = 0; // boost smear envelope
let phase = 0; // 0 live | 1 feed-drop (dead) | 2 re-acquire
let phaseT0 = 0;
let creep = 0, rollY = 0, tearT = 0, tile = 0;
let shown = false, raf = 0, lastT = 0, extAt = -1e9;
let disposed = false;
// The run is over (level:complete). Latched, because zeroing the envelopes in the handler
// is not enough on its own: boot calls player.update(dt) unconditionally after the gate
// (run.done gates run.t, not the player), so player:state keeps arriving at 60Hz and the
// very next frame would recompute chronicTgt from a half-empty coat and wake us straight
// back up ON TOP OF the medal card. One boolean is the difference between the handler
// working and the handler being decorative.
let done = false;
// Have we ever actually died? player.js emits player:spawn once at construction, before
// any death. Running the lock-on stutter there used to be excused as "a decent cold-open";
// with cards.js in the tree it lands "SIGNAL RE-ACQUIRED" at top:44% straight across the
// title screen. A re-acquire with nothing to re-acquire is a lie, so the first spawn only
// resets state.
let everDied = false;
let paused = false, pausedAt = 0;
const rnd = mulberry32(0x27d4eb);
const tearS = [{ y: 0, x: 0, h: 0.2 }, { y: 0, x: 0, h: 0.2 }, { y: 0, x: 0, h: 0.2 }];
// prefers-reduced-motion. This is a FULL-SCREEN flashing layer — creeping scanlines,
// 10Hz static, slamming tear bands and a stepped re-acquire ladder — which is exactly the
// class of effect the media query exists for. comms.js and cards.js both honour it; this
// file has more reason to than either. `calm` does not hide the corruption (you would lose
// the damage read entirely, which is a gameplay signal); it removes the FLICKER and the
// DRIFT and leaves the amplitude, so a heavy hit is still obviously a heavy hit.
// Live-updating rather than sampled once: users toggle this in the OS mid-session, and the
// listener is unsubscribed in dispose() like every other subscription.
const mq = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-reduced-motion:reduce)') : null;
let calm = !!(mq && mq.matches);
// Wall-clock READ, never accumulated — the same law comms.js documents. It matters twice
// here: a backgrounded tab throttles timers into fiction, and player:state (the only clock
// the HUD has) stops emitting the instant you die (player.js early-returns while !alive),
// which is precisely the window the feed-drop has to survive.
const nowS = () => performance.now() / 1000;
const idle = () => phase === 0 && hit < 0.004 && chronic < 0.004 && chronicTgt < 0.004 &&
boost < 0.004;
function sleep() {
if (raf) { cancelAnimationFrame(raf); raf = 0; }
if (!shown) return;
shown = false;
root.style.display = 'none'; // display:none => no paint, no promoted layers
if (nctx) nctx.clearRect(0, 0, NW, NH);
}
// True while an integrator is actively calling update(dt). Half a second of silence and we
// take the clock back.
const externallyDriven = () => nowS() - extAt < 0.5;
function wake() {
if (disposed || paused) return;
if (!shown) { shown = true; root.style.display = 'block'; }
if (raf) return;
lastT = nowS();
raf = requestAnimationFrame(loop);
}
// The rAF runs whenever we are visible — INCLUDING while an integrator drives update(dt).
// It just does not step() then. That looks redundant and is not: the earlier design had
// update() cancel the rAF outright, so an integrator that started driving and then stopped
// (which is precisely what pausing, or a scene teardown, does) left this layer frozen
// mid-glitch with static and tear bands nailed to the screen and nothing able to restart
// it — visibly worse than either running or hidden. A self-healing watchdog costs one
// callback and one subtraction per frame; the alternative costs a stuck screen.
// Only ONE of the two ever calls step(), which is the part that actually mattered: two
// clocks would decay every envelope at double rate and the glitch would read cheap.
function loop() {
raf = 0;
if (disposed || paused) return;
const t = nowS();
const dt = Math.min(0.05, Math.max(0, t - lastT)); // clamp: tab-return must not teleport
lastT = t;
if (!externallyDriven()) step(dt);
if (shown && !raf) raf = requestAnimationFrame(loop);
}
// --- the hot path --------------------------------------------------------------------
function step(dt) {
// envelopes
hit -= hit * Math.min(1, HIT_DECAY * dt);
boost -= boost * Math.min(1, 5.5 * dt);
chronic += (chronicTgt - chronic) * Math.min(1, 2.6 * dt);
const pT = nowS() - phaseT0;
let drop = 0, burst = 0, collapse = -1, cardA = 0;
if (phase === 1) {
// FEED DROP. Collapse, static burst, then near-black with the readout. It holds until
// player:spawn — boot sets a 2.0s respawn timer but that number is B's, not ours, and
// hardcoding it here is how the two drift apart.
burst = pT < DEATH_BURST ? 1 - (pT / DEATH_BURST) * 0.72
: 0.11 + 0.05 * Math.sin(pT * 6.3); // residual carrier crawl
drop = clamp01(pT / 0.22) * 0.9;
// the picture folding into one line, then out
if (pT < 0.46) collapse = pT < 0.18 ? 1 : 1 - clamp01((pT - 0.18) / 0.28);
cardA = clamp01((pT - 0.42) / 0.3);
} else if (phase === 2) {
// RE-ACQUIRE. Stepped rather than smooth: a receiver locking on stutters, and a linear
// fade out of static reads as a dissolve, which is a transition, not a lock.
const u = pT / REACQ;
// ...but the ladder IS a strobe, so reduced-motion gets the dissolve instead. Losing
// the lock-on read is the correct trade against six hard luminance steps in 0.5s.
burst = calm ? Math.max(0, 1 - u) * 0.55
: u < 0.10 ? 1 : u < 0.20 ? 0.30 : u < 0.32 ? 0.82
: u < 0.46 ? 0.16 : u < 0.56 ? 0.44 : Math.max(0, (1 - u) * 0.42);
drop = 0.9 * (1 - clamp01(u / 0.26));
if (u < 0.2) collapse = clamp01(u / 0.11);
cardA = u < 0.62 ? 1 : 1 - clamp01((u - 0.62) / 0.34);
if (pT >= REACQ) { phase = 0; cardA = 0; collapse = -1; burst = 0; drop = 0; }
}
// Master corruption. chronic is weighted below a fresh hit on purpose: dying slowly
// should be a texture you live inside, not a wall you cannot see past.
const c = clamp01(hit + chronic * TUNE.chronicW + burst);
// TEARING GETS ITS OWN DRIVE, and this is the important line in the function. Tears are
// the most aggressive, most sight-blocking effect here, and driving them off `c` meant
// low hull alone (chronicTgt 0.8 => c ~0.44) produced three slabs re-rolling every 70ms
// FOREVER while you flew. Low hull is exactly when you need to see, hud.js's hull bar and
// comms' "you are bleeding structure" already say it twice, and a permanent tear storm is
// a legibility death-spiral, not tension. The file already reasons this way about the
// roll bar ("a hit does not knock a picture out of vertical sync; rot does") — tearing is
// the same argument in the opposite direction: tearing is an EVENT, so it follows `hit`
// and the death/re-acquire burst, and chronic only nudges it.
const tearDrive = clamp01(hit + chronic * TUNE.tearChronicW + burst);
// No tear bands at all under reduced-motion: a 96px slab teleporting every 70ms is the
// single most vestibular thing in the file, and its information is carried by static,
// scanline density and chroma split anyway.
const heavy = !calm && tearDrive > TUNE.heavyAt;
// scanlines — always on once anything is wrong, and they CREEP. Static scanlines are
// decoration; drifting ones are a signal that is not locked. Under reduced-motion they
// stop creeping and become the decoration; the density read survives, the strobe (up to
// ~13 cycles/s of 1px lines over a 3px period at c=1) does not.
if (!calm) {
creep = (creep + dt * (TUNE.creepBase + c * TUNE.creepGain)) % 3;
scan.t(`translate3d(0,${creep.toFixed(2)}px,0)`);
}
scan.o(TUNE.scanBase + c * TUNE.scanGain);
// roll bar: chronic only. A hit does not knock a picture out of vertical sync; rot does.
const rollA = chronic * TUNE.rollGain;
if (rollA > 0.01) {
if (!calm) {
rollY = (rollY + dt * (TUNE.rollBase + chronic * TUNE.rollDrift)) % 130;
roll.t(`translate3d(0,${(rollY - 22).toFixed(1)}%,0)`);
}
roll.o(rollA);
} else roll.o(0);
// chroma split. Widens with damage; the two copies always move in opposition, which is
// what makes it read as one picture separating rather than two layers sliding.
const split = c * TUNE.splitC + hit * TUNE.splitHit;
if (c > TUNE.fringeAt) {
frW.o(c * TUNE.fringeA); frW.t(`translate3d(${split.toFixed(2)}px,0,0)`);
frC.o(c * TUNE.fringeA); frC.t(`translate3d(${(-split).toFixed(2)}px,0,0)`);
} else { frW.o(0); frC.o(0); }
// tearing. Positions re-roll on a ~70ms step, not per frame: smoothly-moving tears look
// like a screensaver, stepped ones look like dropped lines.
if (heavy) {
tearT -= dt;
if (tearT <= 0) {
tearT = TUNE.tearStep;
for (const s of tearS) {
// 94, not 100: the slab is 96px tall pre-scale and ends up 3-67px, anchored at
// top:0 with transform-origin 0 0, so anything past ~94vh is rolled entirely below
// the viewport. ~7% of rolls were being spent drawing offscreen and the tear
// density was quietly lower than every number here implies.
s.y = rnd() * 94;
s.x = (rnd() - 0.5) * 2 * (10 + tearDrive * 90);
s.h = 0.03 + rnd() * rnd() * 0.5 * (0.4 + tearDrive);
}
}
const tearA = Math.min(TUNE.tearMax, TUNE.tearBase + tearDrive * TUNE.tearGain);
for (let i = 0; i < 3; i++) {
const s = tearS[i];
// Tear 3 only shows up when it is genuinely bad — the count itself is a readout.
const on = i < 1 + (tearDrive > TUNE.tear2At ? 1 : 0) + (tearDrive > TUNE.tear3At ? 1 : 0);
tears[i].o(on ? tearA : 0);
if (on) tears[i].t(`translate3d(${s.x.toFixed(1)}px,${s.y.toFixed(1)}vh,0) scaleY(${s.h.toFixed(3)})`);
}
} else {
// Indexed, not for-of: this branch runs on healthy-ish frames too and the iterator
// protocol allocates a result object per element per frame for nothing.
for (let i = 0; i < 3; i++) tears[i].o(0);
}
// static plate: one drawImage from the baked filmstrip + a few dropout bands. Dropout is
// drawn INTO the same canvas (black bars, normal blend) so the whole layer stays one
// composited element instead of a second full-screen div.
if (nctx && plate && c > TUNE.noiseAt) {
nctx.clearRect(0, 0, NW, NH);
// Tile order is RANDOM-WALKED, not sequential. Sequential meant the 6-tile strip
// repeated at a metronomic 10Hz, and since each tile's 9%-of-rows hot streaks are baked
// in, that turned dropout into a rhythmic pulse you can tap along to — the one thing
// static must never be. Stepping 1..TILES-1 also guarantees no tile repeats back to
// back, which a plain random pick would do 1-in-6 frames and which reads as a freeze.
// Frozen entirely under reduced-motion: a still noise plate still says "bad signal".
if (!calm) tile = (tile + 1 + ((rnd() * (TILES - 1)) | 0)) % TILES;
nctx.globalAlpha = 1;
nctx.drawImage(plate, tile * NW, 0, NW, NH, 0, 0, NW, NH);
if (c > TUNE.dropoutAt && !calm) {
// dropout: whole rows of the transmission simply not arriving
const bars = c > TUNE.dropout3At ? 3 : c > TUNE.dropout2At ? 2 : 1;
nctx.fillStyle = '#000';
nctx.globalAlpha = Math.min(0.95, (c - TUNE.dropoutAt) * 1.6);
for (let i = 0; i < bars; i++) {
nctx.fillRect(0, ((rnd() * NH) | 0), NW, 1 + ((rnd() * c * 6) | 0));
}
nctx.globalAlpha = 1;
}
noise.o(Math.min(TUNE.noiseMax, TUNE.noiseBase + c * TUNE.noiseGain));
} else noise.o(0);
// collapse line
if (collapse >= 0) {
line.o(collapse);
// width collapses toward a point as the signal dies; expands as it locks back on
const sx = phase === 1 ? Math.max(0.02, collapse) : collapse;
line.t(`scale(${sx.toFixed(3)},${(0.6 + collapse * 1.8).toFixed(2)})`);
} else line.o(0);
smear.o(boost * TUNE.smearA);
if (boost > 0.01) smear.t(`scale(${(1 + boost * 0.05).toFixed(3)},1)`);
black.o(drop);
card.o(cardA);
if (idle()) sleep();
}
// --- bus ------------------------------------------------------------------------------
const offs = [];
// player:damage {amount, kind} — amount is applied coat-first by the emitter; kind is the
// damage SOURCE ('acid'|'squeeze'|'gate'|'aura'|'contact'|'dart'|wall kind), NOT which layer
// took it. (comms.js tests kind === 'hull_hit', which no emitter ever sets — do not copy
// that.) Severity therefore comes off `amount` alone, and the hull-vs-coat truth comes off
// the player:state hull latch below. The emitter early-returns during i-frames, so there is
// no "suppress while invulnerable" case to handle here.
// THE ENVELOPE IS SCALED OFF THE ACTUAL DAMAGE TABLE, not off a feel-good floor. The old
// shape (hit*0.45 + 0.28 + amt/30*0.78) put a flat 0.28 under everything, so the lightest
// damage in the game and a gate slam both landed in the top half of the scale: an amylase
// aura tick (3.6) came out at 0.374, over the heavy threshold, and since the aura ticks
// every 0.2s in radius the carry-over settled around 0.46 — near-death corruption for
// brushing a soft obstacle, in flat contradiction of this file's own "an aura tick reads as
// a flicker".
// Now: floor 0.06, carry-over 0.35, and the amount term does nearly all the work.
// aura 3.6 -> 0.174 single, ~0.19 sustained at 5Hz (under TUNE.heavyAt: a flicker)
// dart/contact mid-teens -> ~0.5 (tears, briefly)
// wall 28 -> 0.95, gate slam 30 -> 1.0 (whites you out)
// The carry-over is kept (a second hit during the ring-out must stack) but cut, because at
// 0.45 it was the term deciding the steady state rather than the damage was.
offs.push(bus.on('player:damage', (e) => {
const amt = typeof e?.amount === 'number' ? e.amount : 8;
hit = Math.min(1, hit * 0.35 + 0.06 + clamp01(amt / HIT_NORM) * 0.95);
wake();
}));
// player:state — the sustained read. Emitted every frame while alive, and STOPS at death;
// never treat it as a clock.
let hullWas = 1;
offs.push(bus.on('player:state', (p) => {
// Optional-chained even though player.js:275 always passes a full object: this is the
// one handler that runs 60x/s, so it is the one where a payload-less emit from some
// future caller would throw 60 times a second inside somebody else's frame loop.
const coatR = p?.coatMax ? p.coat / p.coatMax : 1;
const hullR = p?.hullMax ? p.hull / p.hullMax : 1;
// The gate has been passed — the medal card owns the screen now. Returning here (rather
// than only zeroing in the level:complete handler) is the whole fix: boot keeps stepping
// the player after run.done, so this handler is still being called, and without the latch
// it re-derives chronicTgt from a half-empty coat and wakes us back over the card one
// frame later.
if (done) return;
// Coat is the consumable — it creeps in. Hull is structure: any hull loss at all is the
// feed already failing, so it carries full weight and no ramp-in.
const coatTerm = coatR >= COAT_START ? 0 : (COAT_START - coatR) / COAT_START;
chronicTgt = clamp01(Math.max(coatTerm * TUNE.coatW, 1 - hullR));
// A hull delta is the one hit worth punching above its `amount` — this is the only place
// the coat/hull split is actually knowable from the bus.
if (hullR < hullWas - 0.001) hit = Math.min(1, hit + 0.3);
hullWas = hullR;
if (chronicTgt > 0.004 || hit > 0.004) wake();
}));
// player:death — {s} from hull depletion, {s, kind} from kill(). kind is optional; read it
// defensively or the readout says "undefined" on the most-looked-at screen in the game.
offs.push(bus.on('player:death', (e) => {
everDied = true;
phase = 1; phaseT0 = nowS(); hit = 1; boost = 0;
cardBig.textContent = 'signal lost';
cardSub.textContent = 'endo-1 telemetry — no carrier';
cardCause.textContent = e?.kind ? `cause: ${String(e.kind).replace(/_/g, ' ')}` : '';
wake();
}));
// player:spawn — the ONLY signal that the respawn completed (boot owns the 2.0s timer).
// It also fires once at construction, before any death; see `everDied`.
offs.push(bus.on('player:spawn', () => {
chronic = 0; chronicTgt = 0; hullWas = 1; hit = 0; boost = 0;
done = false; // a fresh run un-latches the level:complete freeze
if (!everDied) { phase = 0; sleep(); return; } // cold open: reset only, no lock-on
phase = 2; phaseT0 = nowS();
cardBig.textContent = 'signal re-acquired';
cardSub.textContent = 'endo-1 telemetry — carrier locked';
cardCause.textContent = '';
wake();
}));
// player:boost {s} — a cool smear, never a damage colour.
offs.push(bus.on('player:boost', () => { boost = 1; wake(); }));
// level:complete — the run is over; drop everything so the medal card is not read through
// a dying picture. `done` is what makes it stick (see the player:state handler).
offs.push(bus.on('level:complete', () => {
done = true;
phase = 0; hit = 0; chronic = 0; chronicTgt = 0; boost = 0;
step(0); sleep();
}));
// ui:pause {paused} — cards.js owns the pause panel and emits this. Nobody owned the
// interaction, and the default was bad: the roll bar kept drifting, static kept tiling and
// tear bands kept slamming underneath a backdrop-filtered panel, which both fights the blur
// and makes a *stopped* game look like it is still taking damage.
// We freeze rather than hide. Hiding would pop the death screen out from under a pause
// taken while dead; a held frame under the panel reads correctly as "the feed is paused
// too". phaseT0 is shifted by the paused duration on resume, because it is anchored to
// wall-clock — without the shift, unpausing after 30s of menu would find pT past REACQ and
// skip the entire lock-on (or, worse, snap the death card straight to gone).
offs.push(bus.on('ui:pause', (e) => {
const p = !!(e && e.paused);
if (p === paused) return;
paused = p;
if (paused) {
pausedAt = nowS();
if (raf) { cancelAnimationFrame(raf); raf = 0; }
} else {
phaseT0 += nowS() - pausedAt;
lastT = nowS();
if (shown) wake();
}
}));
const onMotionPref = (ev) => { calm = !!(ev && ev.matches); };
if (mq) {
if (mq.addEventListener) {
mq.addEventListener('change', onMotionPref);
offs.push(() => mq.removeEventListener('change', onMotionPref));
} else if (mq.addListener) { // Safari < 14 still ships the deprecated pair
mq.addListener(onMotionPref);
offs.push(() => mq.removeListener(onMotionPref));
}
}
return {
// Nothing in boot's step() drives Lane E today (the HUD is bus-clocked), so we self-clock
// with rAF. If an integrator does start calling this, extAt stands our step() down and we
// run on their dt instead — never both. The rAF keeps ticking as a watchdog so that an
// integrator who stops calling us does not leave the layer frozen mid-glitch; see loop().
update(dt) {
// The disposed guard is not paranoia. dispose() only ever set a flag that loop() read;
// an integrator driving update() would keep running the whole hot path against removed
// nodes forever, and step()'s only self-heal is idle()->sleep(), which is unreachable
// while phase===1 — i.e. exactly the "torn down while the player was dead" case that a
// level teardown produces.
if (disposed) return;
// extAt is stamped even while paused, deliberately: it is the "somebody else owns the
// clock" marker, and letting it go stale under a long pause would hand the first frame
// after resume to BOTH our rAF and the integrator.
extAt = nowS();
if (paused || !shown) return; // idle: one boolean, nothing else
step(typeof dt === 'number' && dt > 0 ? Math.min(dt, 0.05) : 1 / 60);
},
dispose() {
disposed = true;
sleep(); // cancels the rAF and drops the promoted layers
for (const off of offs) off();
offs.length = 0;
root.remove();
style.remove();
},
};
}