[screen] ratification card + pallor pass

The Correction takes the sky for one second every time the player does science:
hospital white, green tick, the standard's real name from codex 1. Composited
dead last in screen space so not one pixel of it is corrupted -- that is the
joke. Hard cut on exit; a dissolve would make it look negotiable.

Timed in seconds, not frames: a broadcast interruption must last a second at any
refresh rate (verified 30 frames at 30fps, 60 at 60fps). The scram wipe stays
frame-counted -- tape artifacts are quantized to frames.

Pallor drains saturation and closes the corners in, per codex 8: colour is the
resource, so a factory with no reserve literally has less of it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 20:01:38 +10:00
parent bc992a99d4
commit 6404c44fcc
3 changed files with 175 additions and 10 deletions

View File

@ -29,10 +29,10 @@ uniform float t;
uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze; uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze;
uniform float u_block, u_ring, u_moire; uniform float u_block, u_ring, u_moire;
uniform float u_drift, u_brownout, u_flash; uniform float u_drift, u_brownout, u_flash;
uniform float u_strain, u_fever; uniform float u_strain, u_fever, u_pallor;
uniform vec2 u_moireSeed; uniform vec2 u_moireSeed;
uniform sampler2D u_stampTex, u_scramTex; uniform sampler2D u_stampTex, u_scramTex, u_cardTex;
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY; uniform float u_stampAmt, u_stampScale, u_scram, u_scramY, u_card;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); } float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
float vnoise(vec2 p){ float vnoise(vec2 p){
@ -201,6 +201,16 @@ void main(){
// fever runs warm: the picture itself develops a temperature // fever runs warm: the picture itself develops a temperature
col = mix(col, col*vec3(1.12,0.98,0.88), u_fever*0.55); col = mix(col, col*vec3(1.12,0.98,0.88), u_fever*0.55);
// ---- pallor: the second dread. Not urgency — malaise. An empty reserve drains the colour
// out of the sky and closes the corners in. Codex §8: colour IS the resource, so a factory
// with no cushion literally has less of it. Chronic, and it sits there in surplus too.
if(u_pallor > 0.001){
col = mix(col, vec3(lu(col)), u_pallor*0.5);
vec2 pd = vUv - 0.5; pd.x *= 1.78;
float vig = smoothstep(0.34, 0.95, length(pd));
col *= 1.0 - vig*u_pallor*0.75;
}
// faint scanlines for vibe (constant, not scored) // faint scanlines for vibe (constant, not scored)
col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415); col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415);
@ -227,12 +237,18 @@ void main(){
// the awakening: one frame of full white on the first shipment ever // the awakening: one frame of full white on the first shipment ever
col = mix(col, vec3(1.0), u_flash); col = mix(col, vec3(1.0), u_flash);
// ---- ratification: The Correction takes the sky. Composited dead last and in screen space,
// so not one pixel of it is corrupted — that is the entire point of the joke.
if(u_card > 0.001){
col = mix(col, texture2D(u_cardTex, vUv).rgb, u_card);
}
gl_FragColor = vec4(col, 1.0); gl_FragColor = vec4(col, 1.0);
}`; }`;
type UniformKey = type UniformKey =
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'moireSeed' | Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'moireSeed'
| 'stampTex' | 'scramTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY'; | 'stampTex' | 'scramTex' | 'cardTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card';
export interface DrawState { export interface DrawState {
params: ParamSet; params: ParamSet;
@ -249,8 +265,14 @@ export interface DrawState {
strain: number; strain: number;
/** 0..1 feverish shimmer from aggregate machine heat */ /** 0..1 feverish shimmer from aggregate machine heat */
fever: number; fever: number;
/** 0..1 pallor from an empty reserve — chronic, unlike tremor */
pallor: number;
/** where the moire crystal grows from, in screen uv */ /** where the moire crystal grows from, in screen uv */
moireSeed: [number, number]; moireSeed: [number, number];
/** 0..1 opacity of the ratification card */
card: number;
/** set when the card canvas has been repainted and needs re-upload */
cardDirty: boolean;
/** 0..1 opacity of the shipment stamp */ /** 0..1 opacity of the shipment stamp */
stampAmt: number; stampAmt: number;
/** stamp size multiplier — bigger shipments stamp harder */ /** stamp size multiplier — bigger shipments stamp harder */
@ -322,9 +344,11 @@ export function createGlitchStack(
base.update(0, true); base.update(0, true);
overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front
overlays.paintCard('reel', '');
const baseTex = makeTex(0, base.canvas); const baseTex = makeTex(0, base.canvas);
const stampTex = makeTex(1, overlays.stampCanvas); const stampTex = makeTex(1, overlays.stampCanvas);
makeTex(2, overlays.scramCanvas); makeTex(2, overlays.scramCanvas);
const cardTex = makeTex(3, overlays.cardCanvas);
const uni = {} as Record<UniformKey, WebGLUniformLocation | null>; const uni = {} as Record<UniformKey, WebGLUniformLocation | null>;
uni.t = gl.getUniformLocation(prog, 't'); uni.t = gl.getUniformLocation(prog, 't');
@ -333,6 +357,8 @@ export function createGlitchStack(
uni.flash = gl.getUniformLocation(prog, 'u_flash'); uni.flash = gl.getUniformLocation(prog, 'u_flash');
uni.strain = gl.getUniformLocation(prog, 'u_strain'); uni.strain = gl.getUniformLocation(prog, 'u_strain');
uni.fever = gl.getUniformLocation(prog, 'u_fever'); uni.fever = gl.getUniformLocation(prog, 'u_fever');
uni.pallor = gl.getUniformLocation(prog, 'u_pallor');
uni.card = gl.getUniformLocation(prog, 'u_card');
uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed'); uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed');
uni.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt'); uni.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt');
uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale'); uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale');
@ -343,13 +369,14 @@ export function createGlitchStack(
gl.uniform1i(gl.getUniformLocation(prog, 'tex'), 0); gl.uniform1i(gl.getUniformLocation(prog, 'tex'), 0);
gl.uniform1i(gl.getUniformLocation(prog, 'u_stampTex'), 1); gl.uniform1i(gl.getUniformLocation(prog, 'u_stampTex'), 1);
gl.uniform1i(gl.getUniformLocation(prog, 'u_scramTex'), 2); gl.uniform1i(gl.getUniformLocation(prog, 'u_scramTex'), 2);
gl.uniform1i(gl.getUniformLocation(prog, 'u_cardTex'), 3);
gl.viewport(0, 0, canvas.width, canvas.height); gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform1f(uni.drift, 1); gl.uniform1f(uni.drift, 1);
return { return {
draw({ params, timeSec, drift, brownout, flash, idle, strain, fever, moireSeed, draw({ params, timeSec, drift, brownout, flash, idle, strain, fever, pallor, moireSeed,
stampAmt, stampScale, scram, scramY, stampDirty }) { stampAmt, stampScale, scram, scramY, stampDirty, card, cardDirty }) {
// Re-upload the clean feed only when it actually changed (timecode tick / chyron). // Re-upload the clean feed only when it actually changed (timecode tick / chyron).
if (base.update(timeSec * 1000, idle)) { if (base.update(timeSec * 1000, idle)) {
gl.activeTexture(gl.TEXTURE0); gl.activeTexture(gl.TEXTURE0);
@ -361,12 +388,19 @@ export function createGlitchStack(
gl.bindTexture(gl.TEXTURE_2D, stampTex); gl.bindTexture(gl.TEXTURE_2D, stampTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.stampCanvas); gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.stampCanvas);
} }
if (cardDirty) {
gl.activeTexture(gl.TEXTURE0 + 3);
gl.bindTexture(gl.TEXTURE_2D, cardTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.cardCanvas);
}
gl.uniform1f(uni.t, timeSec); gl.uniform1f(uni.t, timeSec);
gl.uniform1f(uni.drift, drift); gl.uniform1f(uni.drift, drift);
gl.uniform1f(uni.brownout, brownout); gl.uniform1f(uni.brownout, brownout);
gl.uniform1f(uni.flash, flash); gl.uniform1f(uni.flash, flash);
gl.uniform1f(uni.strain, strain); gl.uniform1f(uni.strain, strain);
gl.uniform1f(uni.fever, fever); gl.uniform1f(uni.fever, fever);
gl.uniform1f(uni.pallor, pallor);
gl.uniform1f(uni.card, card);
gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]); gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]);
gl.uniform1f(uni.stampAmt, stampAmt); gl.uniform1f(uni.stampAmt, stampAmt);
gl.uniform1f(uni.stampScale, stampScale); gl.uniform1f(uni.stampScale, stampScale);

View File

@ -16,6 +16,7 @@ import { createOverlays } from './overlays';
import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap'; import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
import { PARAMS, ease, zeroParams, type ParamSet } from './params'; import { PARAMS, ease, zeroParams, type ParamSet } from './params';
import { computeStrain } from './strain'; import { computeStrain } from './strain';
import { freshness } from './boredom';
/** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */ /** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */
const EASE_TAU = 1.2; const EASE_TAU = 1.2;
@ -35,6 +36,14 @@ const SCRAM_FRAMES = 3;
/** Tremor tracks the reserve fairly tightly; fever is a slow burn. */ /** Tremor tracks the reserve fairly tightly; fever is a slow burn. */
const STRAIN_TAU = 0.35; const STRAIN_TAU = 0.35;
const FEVER_TAU = 1.5; const FEVER_TAU = 1.5;
/** Pallor is chronic, so it should drift rather than react. */
const PALLOR_TAU = 2.0;
/**
* The ratification card is timed in SECONDS, not frames: it's a broadcast interruption, so it
* must last one second whatever the refresh rate. (The scram wipe is frame-counted on purpose
* that one is a tape artifact, and tape artifacts are quantized to frames.)
*/
const CARD_SECONDS = 1;
interface Stamp { name: string; count: number; color: string; age: number } interface Stamp { name: string; count: number; color: string; age: number }
@ -83,9 +92,18 @@ export function createScreenFX(): ScreenFX {
let strain = 0; let strain = 0;
let fever = 0; let fever = 0;
let pallor = 0;
let moireSeed: [number, number] = [0.5, 0.44]; let moireSeed: [number, number] = [0.5, 0.44];
let moireSeeded = false; let moireSeeded = false;
let lastShipMs = 0;
let markShip = false;
let fresh = 1;
let cardAge = -1; // <0 = no card on screen
let cardDirty = false;
const eras = new Map<string, string>();
let lastMs = 0; let lastMs = 0;
let frameCost = 0; // rolling avg ms of this module's CPU work let frameCost = 0; // rolling avg ms of this module's CPU work
@ -101,6 +119,7 @@ export function createScreenFX(): ScreenFX {
if (count <= 0) return; if (count <= 0) return;
tally.set(item, (tally.get(item) ?? 0) + count); tally.set(item, (tally.get(item) ?? 0) + count);
ledgerDirty = true; ledgerDirty = true;
markShip = true; // the sky stops forgetting the moment anything lands
if (!hasShipped) { if (!hasShipped) {
hasShipped = true; hasShipped = true;
pendingFlash = true; // the awakening pendingFlash = true; // the awakening
@ -126,6 +145,7 @@ export function createScreenFX(): ScreenFX {
const base = createBaseFeed(); const base = createBaseFeed();
stack = createGlitchStack(canvas, base, overlays); stack = createGlitchStack(canvas, base, overlays);
for (const it of data.items) items.set(it.id, it); for (const it of data.items) items.set(it.id, it);
for (const t of data.tech) eras.set(t.id, t.era);
const unmapped = unmappedItems(data); const unmapped = unmappedItems(data);
if (unmapped.length) { if (unmapped.length) {
@ -151,14 +171,27 @@ export function createScreenFX(): ScreenFX {
moireSeed = [0.5, 0.44]; moireSeed = [0.5, 0.44];
strain = 0; strain = 0;
fever = 0; fever = 0;
pallor = 0;
cardAge = -1;
lastShipMs = 0;
fresh = 1;
for (const p of PARAMS) eased[p] = 0; for (const p of PARAMS) eased[p] = 0;
}, },
research: (tech: string) => {
overlays.paintCard(eras.get(tech) ?? 'reel', tech);
cardDirty = true;
cardAge = 0;
},
stats: () => ({ stats: () => ({
units: +units.toFixed(2), units: +units.toFixed(2),
total: +total.toFixed(3), total: +total.toFixed(3),
displayTotal: +(total * fresh).toFixed(3),
freshness: +fresh.toFixed(3),
idle: !hasShipped, idle: !hasShipped,
strain: +strain.toFixed(3), strain: +strain.toFixed(3),
fever: +fever.toFixed(3), fever: +fever.toFixed(3),
pallor: +pallor.toFixed(3),
card: cardAge >= 0 ? 1 : 0,
moireSeed: moireSeed.map((n) => +n.toFixed(3)), moireSeed: moireSeed.map((n) => +n.toFixed(3)),
brownout: +brownout.toFixed(2), brownout: +brownout.toFixed(2),
msPerFrame: +frameCost.toFixed(3), msPerFrame: +frameCost.toFixed(3),
@ -178,6 +211,11 @@ export function createScreenFX(): ScreenFX {
// event rather than random, so the same run always wipes the same way. // event rather than random, so the same run always wipes the same way.
scramFrames = SCRAM_FRAMES; scramFrames = SCRAM_FRAMES;
scramY = 0.15 + (((e.entity * 37 + e.tick * 13) % 100) / 100) * 0.7; scramY = 0.15 + (((e.entity * 37 + e.tick * 13) % 100) / 100) * 0.7;
} else if (e.kind === 'researched') {
// The Correction takes the sky for a second, every time the player does science.
overlays.paintCard(eras.get(e.tech) ?? 'reel', e.tech);
cardDirty = true;
cardAge = 0;
} }
} }
}, },
@ -200,14 +238,27 @@ export function createScreenFX(): ScreenFX {
const target = computeStrain(snap); const target = computeStrain(snap);
strain = ease(strain, target.tremor, dt, STRAIN_TAU); strain = ease(strain, target.tremor, dt, STRAIN_TAU);
fever = ease(fever, target.fever, dt, FEVER_TAU); fever = ease(fever, target.fever, dt, FEVER_TAU);
pallor = ease(pallor, target.pallor, dt, PALLOR_TAU);
// Boredom: the DISPLAY decays, the ledger does not. `fresh` scales what's shown; the
// tally underneath is untouched, so resuming production snaps the targets straight back
// and the easing walks the picture home in about a second.
if (markShip) {
lastShipMs = timeMs;
markShip = false;
}
fresh = hasShipped ? freshness((timeMs - lastShipMs) / 1000) : 1;
const timeSec = timeMs / 1000; const timeSec = timeMs / 1000;
const displayTotal = total * fresh;
const wobble = const wobble =
total > WOBBLE_THRESHOLD ? (total - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD) : 0; displayTotal > WOBBLE_THRESHOLD
? (displayTotal - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD)
: 0;
for (let i = 0; i < PARAMS.length; i++) { for (let i = 0; i < PARAMS.length; i++) {
const p = PARAMS[i]; const p = PARAMS[i];
eased[p] = ease(eased[p], targets[p], dt, EASE_TAU); eased[p] = ease(eased[p], targets[p] * fresh, dt, EASE_TAU);
// Wobble rides on top of the eased value; it never feeds back into the ledger. // Wobble rides on top of the eased value; it never feeds back into the ledger.
const w = const w =
wobble > 0 wobble > 0
@ -234,15 +285,26 @@ export function createScreenFX(): ScreenFX {
if (stamp.age >= STAMP_LIFE) stamp = null; if (stamp.age >= STAMP_LIFE) stamp = null;
} }
// The card holds at full opacity and then cuts — no fade. The corruption crashes back in
// rather than seeping back; a dissolve would make it look negotiable.
let card = 0;
if (cardAge >= 0) {
card = 1;
cardAge += dt;
if (cardAge >= CARD_SECONDS) cardAge = -1;
}
const flash = pendingFlash ? 1 : 0; const flash = pendingFlash ? 1 : 0;
stack.draw({ stack.draw({
params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped, params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped,
strain, fever, moireSeed, strain, fever, pallor, moireSeed,
stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty, stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty,
card, cardDirty,
}); });
pendingFlash = false; pendingFlash = false;
if (scramFrames > 0) scramFrames--; if (scramFrames > 0) scramFrames--;
stampDirty = false; stampDirty = false;
cardDirty = false;
frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05; frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05;
}, },

View File

@ -10,12 +10,33 @@ export const STAMP_W = 256;
export const STAMP_H = 128; export const STAMP_H = 128;
export const SCRAM_W = 512; export const SCRAM_W = 512;
export const SCRAM_H = 64; export const SCRAM_H = 64;
export const CARD_W = 640;
export const CARD_H = 360;
/**
* The eras as the codex names them (§1). The card announces the STANDARD, not the tech id
* The Correction does not care what you unlocked, only which format it just blessed.
*/
const ERA_STANDARD: Record<string, string> = {
reel: 'OPTICAL FILM',
broadcast: 'NTSC / PAL ANALOG',
disc: 'MPEG-2 / DVD',
stream: 'H.264 / HEVC / AV1',
fortress: 'DCP',
};
export function eraStandard(era: string): string {
return ERA_STANDARD[era] ?? era.toUpperCase();
}
export interface Overlays { export interface Overlays {
stampCanvas: HTMLCanvasElement; stampCanvas: HTMLCanvasElement;
scramCanvas: HTMLCanvasElement; scramCanvas: HTMLCanvasElement;
cardCanvas: HTMLCanvasElement;
/** Repaint the stamp for a landed shipment. OSHA-placard deadpan, in the item's chip colour. */ /** Repaint the stamp for a landed shipment. OSHA-placard deadpan, in the item's chip colour. */
paintStamp(name: string, count: number, color: string): void; paintStamp(name: string, count: number, color: string): void;
/** Repaint the ratification card. The Correction's uniform: hospital white, green tick. */
paintCard(era: string, tech: string): void;
} }
function roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { function roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
@ -61,9 +82,57 @@ export function createOverlays(): Overlays {
scramCanvas.height = SCRAM_H; scramCanvas.height = SCRAM_H;
paintScramBar(scramCanvas.getContext('2d')!); paintScramBar(scramCanvas.getContext('2d')!);
const cardCanvas = document.createElement('canvas');
cardCanvas.width = CARD_W;
cardCanvas.height = CARD_H;
const cg = cardCanvas.getContext('2d')!;
return { return {
stampCanvas, stampCanvas,
scramCanvas, scramCanvas,
cardCanvas,
paintCard(era: string, tech: string) {
// The Correction wins the sky for one second: hospital white, green checkmark, and the
// absolute confidence of a compliance notice. Nothing here is corrupted — that's the joke.
cg.fillStyle = '#f2f5f2';
cg.fillRect(0, 0, CARD_W, CARD_H);
cg.strokeStyle = '#cfd8cf';
cg.lineWidth = 2;
cg.strokeRect(24, 24, CARD_W - 48, CARD_H - 48);
cg.textBaseline = 'middle';
cg.fillStyle = '#12160f';
cg.font = "bold 40px 'Courier New', monospace";
const title = 'STANDARD RATIFIED';
cg.fillText(title, (CARD_W - cg.measureText(title).width) / 2, 132);
const standard = eraStandard(era);
cg.fillStyle = '#2f7d3f';
cg.font = "bold 28px 'Courier New', monospace";
cg.fillText(standard, (CARD_W - cg.measureText(standard).width) / 2, 186);
// green tick — The Correction's faction uniform (codex §8)
cg.strokeStyle = '#2f7d3f';
cg.lineWidth = 7;
cg.lineCap = 'round';
cg.lineJoin = 'round';
cg.beginPath();
cg.moveTo(CARD_W / 2 - 26, 236);
cg.lineTo(CARD_W / 2 - 8, 254);
cg.lineTo(CARD_W / 2 + 28, 218);
cg.stroke();
cg.fillStyle = '#5c6b5c';
cg.font = "13px 'Courier New', monospace";
const sub = 'CONFORMANCE VERIFIED · NO ACTION REQUIRED';
cg.fillText(sub, (CARD_W - cg.measureText(sub).width) / 2, 292);
cg.font = "11px 'Courier New', monospace";
cg.fillText(tech.toUpperCase().replace(/-/g, ' '), 40, 322);
const auth = 'THE CORRECTION';
cg.fillText(auth, CARD_W - 40 - cg.measureText(auth).width, 322);
},
paintStamp(name: string, count: number, color: string) { paintStamp(name: string, count: number, color: string) {
sg.clearRect(0, 0, STAMP_W, STAMP_H); sg.clearRect(0, 0, STAMP_W, STAMP_H);
sg.save(); sg.save();