From f31e36de72a376761e360c094aa9ed0143fe57a9 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 16:11:35 +1000 Subject: [PATCH] [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 --- fktry/src/screen/index.ts | 142 ++++++++++++++++++++++++++++++++++---- 1 file changed, 130 insertions(+), 12 deletions(-) diff --git a/fktry/src/screen/index.ts b/fktry/src/screen/index.ts index c0bb405..d0b5428 100644 --- a/fktry/src/screen/index.ts +++ b/fktry/src/screen/index.ts @@ -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(); + 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; }, }; }