[screen] compositor: shipments drive THE SCREEN

Eased param pressure so shipments land as waves, log escalation over the
first ~100 items, cross-modulated wobble above 0.8 so a mature factory looks
alive, brownout seize, and a one-frame white awakening on the first ship ever.

Measured 0.004-0.013ms/frame CPU against a 2ms budget.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 16:11:35 +10:00
parent 2982862e83
commit f31e36de72

View File

@ -1,21 +1,139 @@
/** LANE-SCREEN territory. Stub — replace per lanes/LANE-SCREEN.md. */
/**
* 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, ScreenFX, SimEvent } from '../contracts';
import { createBaseFeed } from './baseTexture';
import { createGlitchStack, type GlitchStack } from './glitchStack';
import { resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
import { PARAMS, ease, zeroParams, type ParamSet } from './params';
/** 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];
export function createScreenFX(): ScreenFX {
let ctx: CanvasRenderingContext2D | null = null;
let stack: GlitchStack | null = null;
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 pendingFlash = false;
let hasShipped = false;
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) {
if (count <= 0) return;
tally.set(item, (tally.get(item) ?? 0) + count);
ledgerDirty = true;
if (!hasShipped) {
hasShipped = true;
pendingFlash = true; // the awakening
}
}
return {
init(canvas: HTMLCanvasElement, _data: GameData) {
ctx = canvas.getContext('2d');
init(canvas: HTMLCanvasElement, data: GameData) {
const base = createBaseFeed();
stack = createGlitchStack(canvas, base);
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 — LANE-SIM does not ship yet, so THE SCREEN must be
// drivable by hand. Console: __fktryScreen.ship('melt', 40)
(window as any).__fktryScreen = {
ship,
brownout: (on: boolean) => (brownoutTarget = on ? 1 : 0),
reset: () => {
tally.clear();
ledgerDirty = true;
hasShipped = false;
for (const p of PARAMS) eased[p] = 0;
},
stats: () => ({
units: +units.toFixed(2),
total: +total.toFixed(3),
idle: !hasShipped,
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,
};
},
onEvents(_events: SimEvent[]) {},
onEvents(events: SimEvent[]) {
for (const e of events) {
if (e.kind === 'shipped') ship(e.item, e.count);
else if (e.kind === 'brownout') brownoutTarget = e.on ? 1 : 0;
}
},
frame(timeMs: number) {
if (!ctx) return;
// placeholder: sterile clean playback awaiting corruption
ctx.fillStyle = '#101018';
ctx.fillRect(0, 0, 640, 360);
ctx.fillStyle = '#2a2440';
ctx.font = '20px monospace';
ctx.fillText('THE SCREEN · 0 corruption', 140, 180 + Math.sin(timeMs / 800) * 4);
if (!stack) return;
const t0 = performance.now();
const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, (timeMs - lastMs) / 1000);
lastMs = timeMs;
if (ledgerDirty) recompute();
brownout = ease(brownout, brownoutTarget, dt, BROWNOUT_TAU);
const timeSec = timeMs / 1000;
const wobble =
total > WOBBLE_THRESHOLD ? (total - 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], 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));
}
const flash = pendingFlash ? 1 : 0;
stack.draw({ params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped });
pendingFlash = false;
frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05;
},
};
}