guts/web/js/ui/feedback.js
type-two 464f5c8a18 [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>
2026-07-19 13:57:38 +10:00

709 lines
38 KiB
JavaScript

// 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();
},
};
}