diff --git a/fktry/src/screen/index.ts b/fktry/src/screen/index.ts index d0b5428..61f032a 100644 --- a/fktry/src/screen/index.ts +++ b/fktry/src/screen/index.ts @@ -9,9 +9,10 @@ * 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, ItemDef, ScreenFX, SimEvent } from '../contracts'; import { createBaseFeed } from './baseTexture'; import { createGlitchStack, type GlitchStack } from './glitchStack'; +import { createOverlays } from './overlays'; import { resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap'; import { PARAMS, ease, zeroParams, type ParamSet } from './params'; @@ -23,10 +24,24 @@ const BROWNOUT_TAU = 0.12; 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]; +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; +/** A burst of shipments must not become a strobe: at most this many stamps wait their turn. */ +const STAMP_QUEUE_MAX = 2; + +interface Stamp { name: string; count: number; color: string; age: number } + +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); +} export function createScreenFX(): ScreenFX { let stack: GlitchStack | null = null; + const overlays = createOverlays(); + const items = new Map(); const tally = new Map(); const eased: ParamSet = zeroParams(); const out: ParamSet = zeroParams(); @@ -41,6 +56,12 @@ export function createScreenFX(): ScreenFX { let pendingFlash = false; let hasShipped = false; + const stampQueue: Stamp[] = []; + let stamp: Stamp | null = null; + let stampDirty = false; + let pendingScram = false; + let scramY = 0.5; + let lastMs = 0; let frameCost = 0; // rolling avg ms of this module's CPU work @@ -60,12 +81,22 @@ export function createScreenFX(): ScreenFX { hasShipped = true; pendingFlash = true; // the awakening } + // 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(); } return { init(canvas: HTMLCanvasElement, data: GameData) { const base = createBaseFeed(); - stack = createGlitchStack(canvas, base); + stack = createGlitchStack(canvas, base, overlays); + for (const it of data.items) items.set(it.id, it); const unmapped = unmappedItems(data); if (unmapped.length) { @@ -74,15 +105,19 @@ export function createScreenFX(): ScreenFX { ); } - // Debug/authoring hook — LANE-SIM does not ship yet, so THE SCREEN must be - // drivable by hand. Console: __fktryScreen.ship('melt', 40) + // 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: () => { pendingScram = true; scramY = 0.5; }, reset: () => { tally.clear(); ledgerDirty = true; hasShipped = false; + stamp = null; + stampQueue.length = 0; for (const p of PARAMS) eased[p] = 0; }, stats: () => ({ @@ -102,6 +137,12 @@ export function createScreenFX(): ScreenFX { for (const e of events) { if (e.kind === 'shipped') ship(e.item, e.count); 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. + pendingScram = true; + scramY = 0.15 + (((e.entity * 37 + e.tick * 13) % 100) / 100) * 0.7; + } } }, @@ -129,9 +170,32 @@ export function createScreenFX(): ScreenFX { 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; + } + const flash = pendingFlash ? 1 : 0; - stack.draw({ params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped }); + stack.draw({ + params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped, + stampAmt, stampScale, scram: pendingScram, scramY, stampDirty, + }); pendingFlash = false; + pendingScram = false; + stampDirty = false; frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05; }, diff --git a/fktry/src/screen/overlays.ts b/fktry/src/screen/overlays.ts new file mode 100644 index 0000000..1372b24 --- /dev/null +++ b/fktry/src/screen/overlays.ts @@ -0,0 +1,108 @@ +/** + * Transient overlays composited ON the feed (in screen space, so corruption never drags + * them): the shipment stamp and the scram bar. + * + * Both are painted into small canvases and uploaded only when their event fires — never + * per frame. The stamp is small (256x128) precisely so the per-event upload stays cheap. + */ + +export const STAMP_W = 256; +export const STAMP_H = 128; +export const SCRAM_W = 512; +export const SCRAM_H = 64; + +export interface Overlays { + stampCanvas: HTMLCanvasElement; + scramCanvas: HTMLCanvasElement; + /** Repaint the stamp for a landed shipment. OSHA-placard deadpan, in the item's chip colour. */ + paintStamp(name: string, count: number, color: string): void; +} + +function roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + g.beginPath(); + g.moveTo(x + r, y); + g.arcTo(x + w, y, x + w, y + h, r); + g.arcTo(x + w, y + h, x, y + h, r); + g.arcTo(x, y + h, x, y, r); + g.arcTo(x, y, x + w, y, r); + g.closePath(); +} + +function paintScramBar(g: CanvasRenderingContext2D) { + // A white tape-tracking bar with the deadpan legend punched out of it. + g.clearRect(0, 0, SCRAM_W, SCRAM_H); + g.fillStyle = '#f6f6f2'; + g.fillRect(0, 0, SCRAM_W, SCRAM_H); + // torn edges: the bar should not look like a clean rectangle + g.fillStyle = 'rgba(0,0,0,0)'; + g.globalCompositeOperation = 'destination-out'; + for (let x = 0; x < SCRAM_W; x += 7) { + const t = ((x * 37) % 11) / 11; + g.fillRect(x, 0, 7, 3 + t * 5); + g.fillRect(x, SCRAM_H - (3 + (1 - t) * 5), 7, 3 + (1 - t) * 5); + } + g.globalCompositeOperation = 'source-over'; + g.fillStyle = '#0a0a0c'; + g.font = "bold 30px 'Courier New', monospace"; + g.textBaseline = 'middle'; + const label = 'TRACKING LOST'; + const w = g.measureText(label).width; + g.fillText(label, (SCRAM_W - w) / 2, SCRAM_H / 2); +} + +export function createOverlays(): Overlays { + const stampCanvas = document.createElement('canvas'); + stampCanvas.width = STAMP_W; + stampCanvas.height = STAMP_H; + const sg = stampCanvas.getContext('2d')!; + + const scramCanvas = document.createElement('canvas'); + scramCanvas.width = SCRAM_W; + scramCanvas.height = SCRAM_H; + paintScramBar(scramCanvas.getContext('2d')!); + + return { + stampCanvas, + scramCanvas, + paintStamp(name: string, count: number, color: string) { + sg.clearRect(0, 0, STAMP_W, STAMP_H); + sg.save(); + // Stamped by hand, badly: a few degrees off true. + sg.translate(STAMP_W / 2, STAMP_H / 2); + sg.rotate(-0.045); + sg.translate(-STAMP_W / 2, -STAMP_H / 2); + + // dark backing so the legend survives on top of white SMPTE bars + sg.fillStyle = 'rgba(6,6,10,0.72)'; + roundRect(sg, 12, 22, STAMP_W - 24, STAMP_H - 44, 6); + sg.fill(); + + sg.strokeStyle = color; + sg.lineWidth = 5; + roundRect(sg, 12, 22, STAMP_W - 24, STAMP_H - 44, 6); + sg.stroke(); + + sg.fillStyle = color; + sg.textBaseline = 'middle'; + // Long codex names have to fit the placard — shrink until they do. + let size = 22; + sg.font = `bold ${size}px 'Courier New', monospace`; + while (sg.measureText(name).width > STAMP_W - 48 && size > 9) { + size -= 1; + sg.font = `bold ${size}px 'Courier New', monospace`; + } + const nw = sg.measureText(name).width; + sg.fillText(name, (STAMP_W - nw) / 2, STAMP_H / 2 - 12); + + const qty = `×${count}`; + sg.font = "bold 26px 'Courier New', monospace"; + const qw = sg.measureText(qty).width; + sg.fillText(qty, (STAMP_W - qw) / 2, STAMP_H / 2 + 20); + + sg.fillStyle = 'rgba(255,255,255,0.5)'; + sg.font = "9px 'Courier New', monospace"; + sg.fillText('SHIPPED / NO QUESTIONS', 22, 34); + sg.restore(); + }, + }; +}