diff --git a/fktry/src/screen/glitchStack.ts b/fktry/src/screen/glitchStack.ts index 222d161..747ebbd 100644 --- a/fktry/src/screen/glitchStack.ts +++ b/fktry/src/screen/glitchStack.ts @@ -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; 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); diff --git a/fktry/src/screen/index.ts b/fktry/src/screen/index.ts index 277c0dd..bd98395 100644 --- a/fktry/src/screen/index.ts +++ b/fktry/src/screen/index.ts @@ -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(); + 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; }, diff --git a/fktry/src/screen/overlays.ts b/fktry/src/screen/overlays.ts index 1372b24..0a81d16 100644 --- a/fktry/src/screen/overlays.ts +++ b/fktry/src/screen/overlays.ts @@ -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 = { + 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();