[screen] shipment stamps + scram wipe, debug hook gated to DEV

One stamp per shipped event (not per unit), 0.4s, sized by count, in the item's
chip colour. Composited in screen space so a melting feed never drags the placard.
Scram events get the one-frame TRACKING LOST bar, positioned from entity+tick so
a run always wipes the same way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 17:22:32 +10:00
parent ed823a09ff
commit 7f9de9c8b1
2 changed files with 178 additions and 6 deletions

View File

@ -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<string, ItemDef>();
const tally = new Map<string, number>();
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;
},

View File

@ -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();
},
};
}