[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 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 {
|
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 {
|
return {
|
||||||
init(canvas: HTMLCanvasElement, _data: GameData) {
|
init(canvas: HTMLCanvasElement, data: GameData) {
|
||||||
ctx = canvas.getContext('2d');
|
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) {
|
frame(timeMs: number) {
|
||||||
if (!ctx) return;
|
if (!stack) return;
|
||||||
// placeholder: sterile clean playback awaiting corruption
|
const t0 = performance.now();
|
||||||
ctx.fillStyle = '#101018';
|
const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, (timeMs - lastMs) / 1000);
|
||||||
ctx.fillRect(0, 0, 640, 360);
|
lastMs = timeMs;
|
||||||
ctx.fillStyle = '#2a2440';
|
|
||||||
ctx.font = '20px monospace';
|
if (ledgerDirty) recompute();
|
||||||
ctx.fillText('THE SCREEN · 0 corruption', 140, 180 + Math.sin(timeMs / 800) * 4);
|
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