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>
591 lines
25 KiB
TypeScript
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;
|
|
}
|