[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:
parent
0e2b5c10fe
commit
464f5c8a18
1747
web/js/audio/engine.js
Normal file
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
1819
web/js/audio/synth.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
742
web/js/ui/cards.js
Normal 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
708
web/js/ui/feedback.js
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user