[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:
parent
ed823a09ff
commit
7f9de9c8b1
@ -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;
|
||||
},
|
||||
|
||||
108
fktry/src/screen/overlays.ts
Normal file
108
fktry/src/screen/overlays.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user