glytch/fktry/src/screen/index.ts
type-two de5a1b5770 [screen] round 7: the gunship's theft, the auditor's hold, and one note at 440.000 Hz
mirrored and repaired now share ONE decrement path (correct()) and ONE placard slot —
the gunship's card is architectural (drafted sector, registration marks, no tick) where
the mite's is clerical. A sector theft and a unit theft move the ledger identically;
there is a test pinning it, because that is what a fork would break.

Stasis is implemented as a clock, not an effect: the sky gets its own feedSec, and a
hash auditor simply stops it accumulating. Release is byte-identical to the moment the
hold began — 5 seconds of audit cost the feed exactly zero — with none of the brownout's
seizure vocabulary. The transport legend flips PLAY to HOLD; that is the only pixel a
hold changes.

THE COMPLIANCE TONE: one voice of the ratify triad, A4, frequency set and never ramped.
Measured single partial, harmonics at the double-precision floor, pitch wander 0.000 Hz,
zero sidebands, bit-identical across renders — generated, not recorded. Gated on
discovered exactly as tapeChunk is: 146 million notices over 6s of audio time produce
exactly 5 notes.

Debt band >= 3 wears the pallor machinery with its sign flipped, white instead of grey.
Bands 0-2 render byte-identical: a factory in good standing wears no mark.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:38:54 +10:00

591 lines
25 KiB
TypeScript

/**
* 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<string, ItemDef>();
const tally = new Map<string, number>();
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<string>();
/**
* 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<string, Era>();
// 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<number, Swarm>(); // 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<number, Swarm>(); // 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;
}