[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:
parent
bc992a99d4
commit
6404c44fcc
@ -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_block, u_ring, u_moire;
|
||||
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 sampler2D u_stampTex, u_scramTex;
|
||||
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY;
|
||||
uniform sampler2D u_stampTex, u_scramTex, u_cardTex;
|
||||
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 vnoise(vec2 p){
|
||||
@ -201,6 +201,16 @@ void main(){
|
||||
// fever runs warm: the picture itself develops a temperature
|
||||
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)
|
||||
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
|
||||
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);
|
||||
}`;
|
||||
|
||||
type UniformKey =
|
||||
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'moireSeed'
|
||||
| 'stampTex' | 'scramTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY';
|
||||
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'moireSeed'
|
||||
| 'stampTex' | 'scramTex' | 'cardTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card';
|
||||
|
||||
export interface DrawState {
|
||||
params: ParamSet;
|
||||
@ -249,8 +265,14 @@ export interface DrawState {
|
||||
strain: number;
|
||||
/** 0..1 feverish shimmer from aggregate machine heat */
|
||||
fever: number;
|
||||
/** 0..1 pallor from an empty reserve — chronic, unlike tremor */
|
||||
pallor: number;
|
||||
/** where the moire crystal grows from, in screen uv */
|
||||
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 */
|
||||
stampAmt: number;
|
||||
/** stamp size multiplier — bigger shipments stamp harder */
|
||||
@ -322,9 +344,11 @@ export function createGlitchStack(
|
||||
|
||||
base.update(0, true);
|
||||
overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front
|
||||
overlays.paintCard('reel', '');
|
||||
const baseTex = makeTex(0, base.canvas);
|
||||
const stampTex = makeTex(1, overlays.stampCanvas);
|
||||
makeTex(2, overlays.scramCanvas);
|
||||
const cardTex = makeTex(3, overlays.cardCanvas);
|
||||
|
||||
const uni = {} as Record<UniformKey, WebGLUniformLocation | null>;
|
||||
uni.t = gl.getUniformLocation(prog, 't');
|
||||
@ -333,6 +357,8 @@ export function createGlitchStack(
|
||||
uni.flash = gl.getUniformLocation(prog, 'u_flash');
|
||||
uni.strain = gl.getUniformLocation(prog, 'u_strain');
|
||||
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.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt');
|
||||
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, 'u_stampTex'), 1);
|
||||
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.uniform1f(uni.drift, 1);
|
||||
|
||||
return {
|
||||
draw({ params, timeSec, drift, brownout, flash, idle, strain, fever, moireSeed,
|
||||
stampAmt, stampScale, scram, scramY, stampDirty }) {
|
||||
draw({ params, timeSec, drift, brownout, flash, idle, strain, fever, pallor, moireSeed,
|
||||
stampAmt, stampScale, scram, scramY, stampDirty, card, cardDirty }) {
|
||||
// Re-upload the clean feed only when it actually changed (timecode tick / chyron).
|
||||
if (base.update(timeSec * 1000, idle)) {
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
@ -361,12 +388,19 @@ export function createGlitchStack(
|
||||
gl.bindTexture(gl.TEXTURE_2D, stampTex);
|
||||
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.drift, drift);
|
||||
gl.uniform1f(uni.brownout, brownout);
|
||||
gl.uniform1f(uni.flash, flash);
|
||||
gl.uniform1f(uni.strain, strain);
|
||||
gl.uniform1f(uni.fever, fever);
|
||||
gl.uniform1f(uni.pallor, pallor);
|
||||
gl.uniform1f(uni.card, card);
|
||||
gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]);
|
||||
gl.uniform1f(uni.stampAmt, stampAmt);
|
||||
gl.uniform1f(uni.stampScale, stampScale);
|
||||
|
||||
@ -16,6 +16,7 @@ import { createOverlays } from './overlays';
|
||||
import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
|
||||
import { PARAMS, ease, zeroParams, type ParamSet } from './params';
|
||||
import { computeStrain } from './strain';
|
||||
import { freshness } from './boredom';
|
||||
|
||||
/** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */
|
||||
const EASE_TAU = 1.2;
|
||||
@ -35,6 +36,14 @@ const SCRAM_FRAMES = 3;
|
||||
/** Tremor tracks the reserve fairly tightly; fever is a slow burn. */
|
||||
const STRAIN_TAU = 0.35;
|
||||
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 }
|
||||
|
||||
@ -83,9 +92,18 @@ export function createScreenFX(): ScreenFX {
|
||||
|
||||
let strain = 0;
|
||||
let fever = 0;
|
||||
let pallor = 0;
|
||||
let moireSeed: [number, number] = [0.5, 0.44];
|
||||
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 frameCost = 0; // rolling avg ms of this module's CPU work
|
||||
|
||||
@ -101,6 +119,7 @@ export function createScreenFX(): ScreenFX {
|
||||
if (count <= 0) return;
|
||||
tally.set(item, (tally.get(item) ?? 0) + count);
|
||||
ledgerDirty = true;
|
||||
markShip = true; // the sky stops forgetting the moment anything lands
|
||||
if (!hasShipped) {
|
||||
hasShipped = true;
|
||||
pendingFlash = true; // the awakening
|
||||
@ -126,6 +145,7 @@ export function createScreenFX(): ScreenFX {
|
||||
const base = createBaseFeed();
|
||||
stack = createGlitchStack(canvas, base, overlays);
|
||||
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);
|
||||
if (unmapped.length) {
|
||||
@ -151,14 +171,27 @@ export function createScreenFX(): ScreenFX {
|
||||
moireSeed = [0.5, 0.44];
|
||||
strain = 0;
|
||||
fever = 0;
|
||||
pallor = 0;
|
||||
cardAge = -1;
|
||||
lastShipMs = 0;
|
||||
fresh = 1;
|
||||
for (const p of PARAMS) eased[p] = 0;
|
||||
},
|
||||
research: (tech: string) => {
|
||||
overlays.paintCard(eras.get(tech) ?? 'reel', tech);
|
||||
cardDirty = true;
|
||||
cardAge = 0;
|
||||
},
|
||||
stats: () => ({
|
||||
units: +units.toFixed(2),
|
||||
total: +total.toFixed(3),
|
||||
displayTotal: +(total * fresh).toFixed(3),
|
||||
freshness: +fresh.toFixed(3),
|
||||
idle: !hasShipped,
|
||||
strain: +strain.toFixed(3),
|
||||
fever: +fever.toFixed(3),
|
||||
pallor: +pallor.toFixed(3),
|
||||
card: cardAge >= 0 ? 1 : 0,
|
||||
moireSeed: moireSeed.map((n) => +n.toFixed(3)),
|
||||
brownout: +brownout.toFixed(2),
|
||||
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.
|
||||
scramFrames = SCRAM_FRAMES;
|
||||
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);
|
||||
strain = ease(strain, target.tremor, dt, STRAIN_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 displayTotal = total * fresh;
|
||||
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++) {
|
||||
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.
|
||||
const w =
|
||||
wobble > 0
|
||||
@ -234,15 +285,26 @@ export function createScreenFX(): ScreenFX {
|
||||
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;
|
||||
stack.draw({
|
||||
params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped,
|
||||
strain, fever, moireSeed,
|
||||
strain, fever, pallor, moireSeed,
|
||||
stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty,
|
||||
card, cardDirty,
|
||||
});
|
||||
pendingFlash = false;
|
||||
if (scramFrames > 0) scramFrames--;
|
||||
stampDirty = false;
|
||||
cardDirty = false;
|
||||
|
||||
frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05;
|
||||
},
|
||||
|
||||
@ -10,12 +10,33 @@ export const STAMP_W = 256;
|
||||
export const STAMP_H = 128;
|
||||
export const SCRAM_W = 512;
|
||||
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 {
|
||||
stampCanvas: HTMLCanvasElement;
|
||||
scramCanvas: HTMLCanvasElement;
|
||||
cardCanvas: 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;
|
||||
/** 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) {
|
||||
@ -61,9 +82,57 @@ export function createOverlays(): Overlays {
|
||||
scramCanvas.height = SCRAM_H;
|
||||
paintScramBar(scramCanvas.getContext('2d')!);
|
||||
|
||||
const cardCanvas = document.createElement('canvas');
|
||||
cardCanvas.width = CARD_W;
|
||||
cardCanvas.height = CARD_H;
|
||||
const cg = cardCanvas.getContext('2d')!;
|
||||
|
||||
return {
|
||||
stampCanvas,
|
||||
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) {
|
||||
sg.clearRect(0, 0, STAMP_W, STAMP_H);
|
||||
sg.save();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user