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