/** * LANE-SCREEN — THE SCREEN compositor. * * The sky-display everything ships to. Starts sterile, ends deranged. Shipped items * feed a corruption ledger (corruptionMap.ts) whose blend drives the 8 tuned glitch * params of the ported GLYTCH shader stack (glitchStack.ts). * * Design contract with the player: WHAT you ship decides WHICH artifact; HOW MUCH you * have ever shipped decides the intensity. Shipments arrive as waves, not switches — * every param eases toward its target. */ import type { GameData, ItemDef, ScreenFX, SimEvent, SimSnapshot } from '../contracts'; import { createBaseFeed } from './baseTexture'; import { createGlitchStack, type GlitchStack } from './glitchStack'; import { createOverlays } from './overlays'; import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap'; import { PARAMS, ease, zeroParams, type ParamSet } from './params'; import { computeStrain, sterileForBand } from './strain'; import { freshness } from './boredom'; import { ERA_ORDER, highestEra, type Era } from './eras'; import { getSubtitle } from '../audio/subtitleBus'; /** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */ const EASE_TAU = 1.2; /** Brownouts hit fast and lift fast — the factory's pain should be legible immediately. */ const BROWNOUT_TAU = 0.12; /** Above this total corruption, params start cross-modulating so a mature SCREEN looks alive. */ const WOBBLE_THRESHOLD = 0.8; const WOBBLE_DEPTH = 0.12; /** Distinct slow rates per param so the wobble never reads as one global pulse. */ const WOBBLE_RATE = [0.13, 0.19, 0.11, 0.23, 0.17, 0.29, 0.31, 0.07, 0.09, 0.37, 0.05]; /** Seconds a shipment stamp stays on the feed. */ const STAMP_LIFE = 0.4; /** The Correction's placard lingers a touch longer than a shipment stamp — read at leisure. */ const PLACARD_LIFE = 0.55; /** A burst of shipments must not become a strobe: at most this many stamps wait their turn. */ const STAMP_QUEUE_MAX = 2; /** Round 3: one frame was a flinch, not a legend. Three frames is readable. */ const SCRAM_FRAMES = 3; /** Tremor tracks the reserve fairly tightly; fever is a slow burn. */ const STRAIN_TAU = 0.35; const FEVER_TAU = 1.5; /** Pallor is chronic, so it should drift rather than react. */ const PALLOR_TAU = 2.0; /** * The ratification card is timed in SECONDS, not frames: it's a broadcast interruption, so it * must last one second whatever the refresh rate. (The scram wipe is frame-counted on purpose — * that one is a tape artifact, and tape artifacts are quantized to frames.) */ const CARD_SECONDS = 1; /** Boot title: hold the burn-in, then dissolve to the NOTHING IS WRONG feed. */ const TITLE_HOLD = 1.5; const TITLE_FADE = 0.6; /** Round 5: the awakening is no longer one frame but a ~0.5s full-canvas moment. */ const FLASH_SECONDS = 0.5; interface Stamp { name: string; count: number; color: string; age: number } /** * One entry in The Correction's placard queue. `kind` picks the painter, NOT the arithmetic: * a mite's repair and a gunship's mirror take exactly the same unit off exactly the same * ledger, and they share one overlay slot so the faction speaks with one voice. */ interface Placard { name: string; kind: 'repair' | 'mirror'; age: number } interface Swarm { pos: { x: number; y: number }; size: number } /** * Where the moire crystal takes root. Deterministic in the tick it first appeared, so a given * run always grows the same maze, while different runs grow it somewhere else. Kept off the * edges so the growth reads as spreading rather than creeping in from off-screen. */ function seedFromTick(tick: number): [number, number] { const h = (n: number) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); }; return [0.25 + h(tick + 1) * 0.5, 0.25 + h(tick + 99) * 0.5]; } /** Swarms live on the factory floor; hash their world position to a stable spot on the sky. */ function seedFromPos(pos: { x: number; y: number }): [number, number] { const h = (n: number) => { const x = Math.sin(n) * 43758.5453; return x - Math.floor(x); }; return [0.2 + h(pos.x * 12.9898 + pos.y * 78.233) * 0.6, 0.2 + h(pos.x * 39.3468 + pos.y * 11.135) * 0.6]; } const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); function smoothstep(e0: number, e1: number, x: number): number { const t = clamp01((x - e0) / (e1 - e0)); return t * t * (3 - 2 * t); } /** Boot title: hold at full, then dissolve. Returns 0 once the sequence is over. */ function titleOpacity(age: number): number { if (age <= TITLE_HOLD) return 1; return 1 - smoothstep(TITLE_HOLD, TITLE_HOLD + TITLE_FADE, age); } /** * The awakening. A blown-out hold then a fast decay — the sky is stunned for a moment rather * than blinking once. Returns 0 when not flashing (age < 0). */ function flashEnvelope(age: number): number { if (age < 0) return 0; if (age >= FLASH_SECONDS) return 0; if (age < 0.12) return 1; return 1 - smoothstep(0.12, FLASH_SECONDS, age); } export function createScreenFX(): ScreenFX { let stack: GlitchStack | null = null; const overlays = createOverlays(); const items = new Map(); const tally = new Map(); const eased: ParamSet = zeroParams(); const out: ParamSet = zeroParams(); let targets: ParamSet = zeroParams(); let total = 0; let units = 0; let ledgerDirty = false; let brownout = 0; let brownoutTarget = 0; let hasShipped = false; const stampQueue: Stamp[] = []; let stamp: Stamp | null = null; let stampDirty = false; const placardQueue: Placard[] = []; let placard: Placard | null = null; let placardDirty = false; let scramFrames = 0; let scramY = 0.5; let strain = 0; let fever = 0; let pallor = 0; let sterile = 0; /** Debt band from `debtBand` events — the fallback when no snapshot.correction is present. */ let eventBand = 0; /** * Live hash-auditor holds, keyed by the event's `lockHash`. A set rather than a flag so * overlapping audits nest correctly: two auditors both holding means one release cannot * restart the clock under the other one's feet. */ const stasisHolds = new Set(); /** * THE SKY'S OWN CLOCK. Everything the picture animates on reads this, not wall time: the * timecode, the drift, the shader's `t`, the mature-corruption wobble. A hash auditor's * stasis simply stops it accumulating — the feed is not damaged, it is administered — and * releasing resumes from the stopped value, so the sky comes back exactly where it left * rather than jumping forward by however long the audit took. */ let feedSec = 0; /** Last resolved values of the two Correction readings, for the DEV stats readout only. */ let heldNow = false; let bandNow = 0; let moireSeed: [number, number] = [0.5, 0.44]; let moireSeeded = false; let lastShipMs = 0; let markShip = false; let fresh = 1; let cardAge = -1; // <0 = no card on screen let cardDirty = false; const eras = new Map(); // Part A round 5: let era: Era = ERA_ORDER[0]; // the world starts in the deepest stratum (reel beds) let forcedEra: Era | null = null; // debug hook override let wildlife = 0; // eased regional-shimmer amount let wildlifeSeed: [number, number] = [0.5, 0.5]; const swarms = new Map(); // event-tracked fallback when no snapshot let mite = 0; // eased hospital-white sterile creep amount let miteSeed: [number, number] = [0.5, 0.5]; const mites = new Map(); // event-tracked parity-mite fallback let titleAge = 0; // boot title sequence clock let titleDirty = true; // painted once at stack creation; upload on the first frame let flashAge = -1; // <0 = not flashing let lastMs = 0; let frameCost = 0; // rolling avg ms of this module's CPU work function recompute() { const r = resolveLedger(tally); targets = r.targets; total = r.total; units = r.units; ledgerDirty = false; } function ship(item: string, count: number, tick = 0) { if (count <= 0) return; tally.set(item, (tally.get(item) ?? 0) + count); ledgerDirty = true; markShip = true; // the sky stops forgetting the moment anything lands if (!hasShipped) { hasShipped = true; flashAge = 0; // the awakening — a ~0.5s moment, ramped in frame() } // The crystal takes root where and when it first arrived, and grows from there. if (!moireSeeded && (corruptionFor(item).weights.moire ?? 0) > 0) { moireSeed = seedFromTick(tick); moireSeeded = true; } // One stamp per EVENT, not per unit: a 40-unit shipment is one big stamp. const def = items.get(item); stampQueue.push({ name: def?.name ?? item.toUpperCase(), count, color: def?.color ?? '#d8ffd8', age: 0, }); while (stampQueue.length > STAMP_QUEUE_MAX) stampQueue.shift(); } /** * THE CORRECTION TAKES ONE UNIT. The single decrement path — a parity mite's `repaired` and a * redundancy gunship's `mirrored` both come through here, because they are the same theft at * two scales and forking them would be how the two slowly stopped agreeing. * * This is THEFT, not decay: the LEDGER drops by exactly that item's per-unit contribution, a * real permanent removal — unlike boredom, which only fades the display and leaves the tally * intact to snap back. We deliberately do NOT refresh the boredom clock either way: a factory * being quietly eaten is not a factory that is producing. */ function correct(item: string, kind: 'repair' | 'mirror') { const cur = tally.get(item) ?? 0; if (cur > 0) { tally.set(item, cur - 1); ledgerDirty = true; } const def = items.get(item); placardQueue.push({ name: def?.name ?? item.toUpperCase(), kind, age: 0 }); while (placardQueue.length > STAMP_QUEUE_MAX) placardQueue.shift(); } let liveCanvas: HTMLCanvasElement | null = null; const api: ScreenFX = { init(canvas: HTMLCanvasElement, data: GameData) { liveCanvas = canvas; const base = createBaseFeed(); stack = createGlitchStack(canvas, base, overlays); for (const it of data.items) items.set(it.id, it); for (const t of data.tech) eras.set(t.id, t.era); const unmapped = unmappedItems(data); if (unmapped.length) { console.info( `[screen] ${unmapped.length} item(s) have no corruption profile, using generic noise: ${unmapped.join(', ')}`, ); } // Debug/authoring hook — dev-only (round 2 orders), stripped from prod builds. // Console: __fktryScreen.ship('macroblock-bricks', 40) if (!import.meta.env.DEV) return; (window as any).__fktryScreen = { ship, brownout: (on: boolean) => (brownoutTarget = on ? 1 : 0), scram: () => { scramFrames = SCRAM_FRAMES; scramY = 0.5; }, setEra: (e: Era) => { forcedEra = e; }, skipTitle: () => { titleAge = TITLE_HOLD + TITLE_FADE + 1; }, swarm: (on: boolean, id = 1, pos = { x: 3, y: -2 }) => { if (on) swarms.set(id, { pos, size: 0.7 }); else swarms.delete(id); }, mite: (on: boolean, id = 1, pos = { x: -4, y: 5 }, size = 0.7) => { if (on) mites.set(id, { pos, size }); else mites.delete(id); }, // Both drive the ONE decrement path, so the debug hook can never disagree with the // event handler about what a Correction costs. repair: (item: string, count = 1) => { for (let i = 0; i < count; i++) correct(item, 'repair'); }, mirror: (item: string, count = 1) => { for (let i = 0; i < count; i++) correct(item, 'mirror'); }, stasis: (on: boolean, lockHash = 'dev') => { if (on) stasisHolds.add(lockHash); else stasisHolds.delete(lockHash); }, band: (n: number) => { eventBand = n; }, reset: () => { tally.clear(); ledgerDirty = true; hasShipped = false; stamp = null; stampQueue.length = 0; moireSeeded = false; moireSeed = [0.5, 0.44]; strain = 0; fever = 0; pallor = 0; sterile = 0; eventBand = 0; bandNow = 0; heldNow = false; stasisHolds.clear(); feedSec = 0; cardAge = -1; flashAge = -1; lastShipMs = 0; fresh = 1; forcedEra = null; swarms.clear(); wildlife = 0; mites.clear(); mite = 0; placard = null; placardQueue.length = 0; for (const p of PARAMS) eased[p] = 0; }, research: (tech: string) => { overlays.paintCard(eras.get(tech) ?? 'reel', tech); cardDirty = true; cardAge = 0; }, stats: () => ({ units: +units.toFixed(2), total: +total.toFixed(3), displayTotal: +(total * fresh).toFixed(3), freshness: +fresh.toFixed(3), idle: !hasShipped, era, strain: +strain.toFixed(3), fever: +fever.toFixed(3), pallor: +pallor.toFixed(3), sterile: +sterile.toFixed(3), band: bandNow, held: heldNow, holds: stasisHolds.size, feedSec: +feedSec.toFixed(4), wildlife: +wildlife.toFixed(3), mite: +mite.toFixed(3), placard: placard ? placard.kind : null, card: cardAge >= 0 ? 1 : 0, title: +titleOpacity(titleAge).toFixed(3), flash: +flashEnvelope(flashAge).toFixed(3), moireSeed: moireSeed.map((n) => +n.toFixed(3)), brownout: +brownout.toFixed(2), msPerFrame: +frameCost.toFixed(3), tally: Object.fromEntries(tally), params: Object.fromEntries(PARAMS.map((p) => [p, +out[p].toFixed(3)])), }), escalationItems: ESCALATION_ITEMS, // Verification driver: a hidden harness tab throttles rAF, so pump the REAL module with // a synthetic timebase and read pixels in the same JS task (prior-round contact-sheet // trick — a hidden tab clears the WebGL buffer after compositing otherwise). frame: (ms: number, snap?: SimSnapshot) => api.frame(ms, snap), canvas: () => liveCanvas, }; }, onEvents(events: SimEvent[]) { for (const e of events) { if (e.kind === 'shipped') ship(e.item, e.count, e.tick); else if (e.kind === 'brownout') brownoutTarget = e.on ? 1 : 0; else if (e.kind === 'scram' && e.on) { // A machine scrammed: the tape loses tracking. Bar position is derived from the // event rather than random, so the same run always wipes the same way. scramFrames = SCRAM_FRAMES; scramY = 0.15 + (((e.entity * 37 + e.tick * 13) % 100) / 100) * 0.7; } else if (e.kind === 'researched') { // The Correction takes the sky for a second, every time the player does science. overlays.paintCard(eras.get(e.tech) ?? 'reel', e.tech); cardDirty = true; cardAge = 0; } else if (e.kind === 'wildlife' && e.wildlife === 'mosquito-swarm') { // Event-tracked swarms are the fallback when no snapshot.wildlife is available // (debug hook). When a snapshot IS present, frame() reads it as the source of truth. if (e.on === 'spawned') swarms.set(e.id, { pos: e.pos, size: 0.6 }); else swarms.delete(e.id); } else if (e.kind === 'wildlife' && e.wildlife === 'parity-mite') { // Parity-mites drive the sterile creep. Same fallback contract as the swarms above: // snapshot.wildlife is truth when present, these events cover the no-snapshot case. if (e.on === 'spawned') mites.set(e.id, { pos: e.pos, size: 0.6 }); else mites.delete(e.id); } else if (e.kind === 'repaired') { // A parity mite corrected one unit of your work, and signs it with the green tick. correct(e.item, 'repair'); } else if (e.kind === 'mirrored') { // A redundancy gunship re-encoded a whole sector. Same theft, same ledger, same // decrement — a different placard, because a sector is not a unit. correct(e.item, 'mirror'); } else if (e.kind === 'stasis') { // A hash auditor is holding a rectangle of the factory still while it reads it. // Keyed by lockHash so nested holds release independently. Stasis auto-releases by // contract, so the sky can trust it will be handed its clock back. if (e.on) stasisHolds.add(e.lockHash); else stasisHolds.delete(e.lockHash); } else if (e.kind === 'debtBand') { eventBand = e.band; } } }, frame(timeMs: number, snap?: SimSnapshot) { if (!stack) return; const t0 = performance.now(); // Clamp BOTH ends. A negative dt (clock rewind, a paused tab resuming oddly, a test // harness restarting its timebase) turns ease()'s 1-exp(-dt/tau) into a huge negative // multiplier and throws every eased value into orbit. The ceiling stops long stalls // from snapping params; the floor stops that. const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, Math.max(0, (timeMs - lastMs) / 1000)); lastMs = timeMs; // STASIS. Events are the truth here — this is an edge, and main.ts drains every frame — // while the snapshot corroborates for the case an edge can never cover: a save loaded // mid-audit, where the hold is a standing fact and no event will ever fire for it. let held = stasisHolds.size > 0; if (!held && snap?.correction) { for (const u of snap.correction.units) { if (u.phase === 'stasis') { held = true; break; } } } // The sky's clock stops. Nothing else does: the ledger still books shipments, stamps // still land, the strain still reads. The picture is held, the paperwork is not. if (!held) feedSec += dt; heldNow = held; if (ledgerDirty) recompute(); brownout = ease(brownout, brownoutTarget, dt, BROWNOUT_TAU); // v3: read-only peek. Collapsed to two scalars immediately — the snapshot is never // retained across frames, per the contract invariant. const target = computeStrain(snap); strain = ease(strain, target.tremor, dt, STRAIN_TAU); fever = ease(fever, target.fever, dt, FEVER_TAU); pallor = ease(pallor, target.pallor, dt, PALLOR_TAU); // The debt band. Snapshot is truth when The Correction exists at all; the `debtBand` edge // covers the no-snapshot path. Eased on the PALLOR tau because it is the same kind of // reading: chronic, a standing condition, never an event. const band = snap?.correction ? snap.correction.band : eventBand; bandNow = band; sterile = ease(sterile, sterileForBand(band), dt, PALLOR_TAU); // The substrate ages with the highest ratified era. Corruption stays era-agnostic — // only what it is painted ONTO changes. era = forcedEra ?? (snap?.research ? highestEra(snap.research.unlocked, (t) => eras.get(t)) : era); // Jammed-rot: a factory stuck solid rots faster than one resting. Ratio of jammed // machines, so a single stuck belt in a big base barely registers. let rot = 0; if (snap && snap.entities.length) { let jammed = 0; for (const e of snap.entities) if (e.jammed) jammed++; rot = jammed / snap.entities.length; } // Boredom: the DISPLAY decays, the ledger does not. `fresh` scales what's shown; the // tally underneath is untouched, so resuming production snaps the targets straight back // and the easing walks the picture home in about a second. if (markShip) { lastShipMs = timeMs; markShip = false; } fresh = hasShipped ? freshness((timeMs - lastShipMs) / 1000, rot) : 1; // Wildlife shimmer: drive the region from the WORST swarm — one legible infestation // beats an averaged smear. Snapshot is truth when present; events are the fallback. let worst: Swarm | null = null; const live = snap?.wildlife; if (live) { for (const w of live) { if (w.kind !== 'mosquito-swarm') continue; if (!worst || w.size > worst.size) worst = { pos: w.pos, size: w.size }; } } else { for (const w of swarms.values()) if (!worst || w.size > worst.size) worst = w; } if (worst) wildlifeSeed = seedFromPos(worst.pos); wildlife = ease(wildlife, worst ? clamp01(worst.size) : 0, dt, 1.0); // Mite sterility: same worst-of pattern, but for parity-mites. The sterile creep grows // around the heaviest infestation; snapshot is truth when present, events are the fallback. let worstMite: Swarm | null = null; if (live) { for (const w of live) { if (w.kind !== 'parity-mite') continue; if (!worstMite || w.size > worstMite.size) worstMite = { pos: w.pos, size: w.size }; } } else { for (const m of mites.values()) if (!worstMite || m.size > worstMite.size) worstMite = m; } if (worstMite) miteSeed = seedFromPos(worstMite.pos); mite = ease(mite, worstMite ? clamp01(worstMite.size) : 0, dt, 1.0); const timeSec = feedSec; const displayTotal = total * fresh; const wobble = displayTotal > WOBBLE_THRESHOLD ? (displayTotal - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD) : 0; for (let i = 0; i < PARAMS.length; i++) { const p = PARAMS[i]; eased[p] = ease(eased[p], targets[p] * fresh, dt, EASE_TAU); // Wobble rides on top of the eased value; it never feeds back into the ledger. const w = wobble > 0 ? Math.sin(timeSec * WOBBLE_RATE[i] * Math.PI * 2 + i) * WOBBLE_DEPTH * wobble : 0; out[p] = Math.min(1, Math.max(0, eased[p] + w)); } // Stamp lifecycle: promote the next queued shipment once the current one clears. if (!stamp && stampQueue.length) { stamp = stampQueue.shift()!; overlays.paintStamp(stamp.name, stamp.count, stamp.color); stampDirty = true; } let stampAmt = 0; let stampScale = 1; if (stamp) { const a = stamp.age / STAMP_LIFE; // Punch in hard, hold, then fade — a stamp, not a crossfade. stampAmt = Math.min(1, a / 0.12) * (1 - smoothstep(0.5, 1, a)); // Bigger shipments stamp bigger, with a slight overshoot on impact. stampScale = (0.72 + 0.28 * clamp01(stamp.count / 40)) * (1.18 - 0.18 * Math.min(1, a / 0.12)); stamp.age += dt; if (stamp.age >= STAMP_LIFE) stamp = null; } // The Correction's placard lifecycle — its own slot so it never fights the shipment stamp, // and ONE slot for both painters so repairs and mirrors queue rather than overlap. Same // punch-in-hold-fade shape, held a touch longer because it is a signature to be read. if (!placard && placardQueue.length) { placard = placardQueue.shift()!; if (placard.kind === 'mirror') overlays.paintMirror(placard.name); else overlays.paintRepair(placard.name); placardDirty = true; } let placardAmt = 0; let placardScale = 1; if (placard) { const a = placard.age / PLACARD_LIFE; placardAmt = Math.min(1, a / 0.1) * (1 - smoothstep(0.6, 1, a)); placardScale = 1.12 - 0.12 * Math.min(1, a / 0.1); // small, perfect, slight impact overshoot placard.age += dt; if (placard.age >= PLACARD_LIFE) placard = null; } // The card holds at full opacity and then cuts — no fade. The corruption crashes back in // rather than seeping back; a dissolve would make it look negotiable. let card = 0; if (cardAge >= 0) { card = 1; cardAge += dt; if (cardAge >= CARD_SECONDS) cardAge = -1; } // Boot title, then the awakening — both are timed moments, not single frames. const title = titleOpacity(titleAge); if (title > 0) titleAge += dt; const flash = flashEnvelope(flashAge); if (flashAge >= 0) { flashAge += dt; if (flashAge >= FLASH_SECONDS) flashAge = -1; } // The chyron: a radio caption outranks the idle placard — when THE SPEAKER is talking, // the sky subtitles it. Suppressed under the boot title (which covers the feed anyway). const subtitle = getSubtitle(); const chyron = title > 0.99 ? '' : subtitle || (hasShipped ? '' : 'NOTHING IS WRONG'); stack.draw({ params: out, timeSec, drift: 1, brownout, flash, era, chyron, held, strain, fever, pallor, sterile, moireSeed, wildlife, wildlifeSeed, mite, miteSeed, stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty, placard: placardAmt, placardScale, placardDirty, card, cardDirty, title, titleDirty, }); if (scramFrames > 0) scramFrames--; stampDirty = false; placardDirty = false; cardDirty = false; titleDirty = false; frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05; }, }; return api; }