[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:
parent
2982862e83
commit
f31e36de72
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user