From 64cb3edec66cabbeb7593877e4fa6a51c2c26d38 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 13:51:04 +1000 Subject: [PATCH] [screen] wildlife shimmer, boot title, and a longer awakening Mosquito swarms degrade a REGION via the growthMask template, seeded from the swarm's world position -- the mask's second customer, as intended. The game finally says its own name: a chromatic-ghosted WIMVEE FKTRY burn-in holding 1.5s then dissolving to NOTHING IS WRONG. The awakening grows from a single frame to a 0.47s full-canvas moment, so the first shipment lands as an event rather than a blink. Title and awakening are timed in seconds; the scram wipe stays frame-counted. Three clocks for three fictions. Co-Authored-By: Claude Opus 4.8 --- fktry/src/screen/glitchStack.ts | 69 +++++++++++++++--- fktry/src/screen/index.ts | 121 +++++++++++++++++++++++++++++--- fktry/src/screen/overlays.ts | 47 +++++++++++++ 3 files changed, 217 insertions(+), 20 deletions(-) diff --git a/fktry/src/screen/glitchStack.ts b/fktry/src/screen/glitchStack.ts index 747ebbd..ebb76d1 100644 --- a/fktry/src/screen/glitchStack.ts +++ b/fktry/src/screen/glitchStack.ts @@ -13,6 +13,7 @@ * would cost more than the whole effect is allowed to cost. */ import { BASE_H, BASE_W, type BaseFeed } from './baseTexture'; +import type { Era } from './eras'; import type { Overlays } from './overlays'; import { PARAMS, type Param, type ParamSet } from './params'; @@ -31,8 +32,10 @@ uniform float u_block, u_ring, u_moire; uniform float u_drift, u_brownout, u_flash; uniform float u_strain, u_fever, u_pallor; uniform vec2 u_moireSeed; -uniform sampler2D u_stampTex, u_scramTex, u_cardTex; -uniform float u_stampAmt, u_stampScale, u_scram, u_scramY, u_card; +uniform float u_wildlife; +uniform vec2 u_wildlifeSeed; +uniform sampler2D u_stampTex, u_scramTex, u_cardTex, u_titleTex; +uniform float u_stampAmt, u_stampScale, u_scram, u_scramY, u_card, u_title; float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); } float vnoise(vec2 p){ @@ -177,6 +180,17 @@ void main(){ float sn = hash(uv*vec2(431.0,917.0) + fract(tt)*13.0); col = mix(col, vec3(sn), u_noise * 0.7); + // ---- wildlife shimmer: a mosquito swarm degrading a REGION of the sky. Same growth-mask + // template as moire, seeded from the swarm's world position — high-frequency crawling + // luminance noise (literal mosquito noise) rather than the flat static above. + if(u_wildlife > 0.001){ + float wm = growthMask(vUv, u_wildlifeSeed, u_wildlife*0.7) * clamp(u_wildlife*1.6, 0.0, 1.0); + if(wm > 0.001){ + float sk = hash(floor(vUv*vec2(320.0,180.0)) + floor(tt*20.0)); // crawls fast, per-frame + col = mix(col, col*0.6 + vec3(sk)*0.5, wm*0.7); + } + } + // freeze dropout: grey flash lines float dropo = step(0.985 - u_freeze*0.12, hash(vec2(floor(uv.y*90.0), floor(tt*5.0)))); col = mix(col, vec3(0.45), dropo * u_freeze); @@ -243,12 +257,20 @@ void main(){ col = mix(col, texture2D(u_cardTex, vUv).rgb, u_card); } + // ---- boot title: WIMVEE FKTRY burn-in. Premultiplied-ish over black so it can dissolve. + if(u_title > 0.001){ + vec4 ti = texture2D(u_titleTex, vUv); + col = mix(col, ti.rgb, ti.a * u_title); + } + gl_FragColor = vec4(col, 1.0); }`; type UniformKey = | Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'moireSeed' - | 'stampTex' | 'scramTex' | 'cardTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card'; + | 'wildlife' | 'wildlifeSeed' + | 'stampTex' | 'scramTex' | 'cardTex' | 'titleTex' + | 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card' | 'title'; export interface DrawState { params: ParamSet; @@ -257,10 +279,12 @@ export interface DrawState { drift: number; /** 0..1 brownout severity */ brownout: number; - /** 0..1 one-frame white awakening */ + /** 0..1 awakening flash (round 5: ramped over ~0.5s, no longer a single frame) */ flash: number; - /** nothing shipped yet — the clean feed still carries its chyron */ - idle: boolean; + /** current substrate era for the base feed */ + era: Era; + /** chyron line for the base feed ('NOTHING IS WRONG', a radio subtitle, or '') */ + chyron: string; /** 0..1 tremor from the draining bandwidth reserve (weather, not climate) */ strain: number; /** 0..1 feverish shimmer from aggregate machine heat */ @@ -269,10 +293,18 @@ export interface DrawState { pallor: number; /** where the moire crystal grows from, in screen uv */ moireSeed: [number, number]; + /** 0..1 regional mosquito shimmer amount (wildlife swarms) */ + wildlife: number; + /** where the swarm shimmer grows from, in screen uv */ + wildlifeSeed: [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 boot title card */ + title: number; + /** set when the title canvas has been repainted and needs re-upload */ + titleDirty: boolean; /** 0..1 opacity of the shipment stamp */ stampAmt: number; /** stamp size multiplier — bigger shipments stamp harder */ @@ -342,13 +374,15 @@ export function createGlitchStack( return t; } - base.update(0, true); + base.update({ timeMs: 0, era: 'stream', chyron: 'NOTHING IS WRONG' }); overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front overlays.paintCard('reel', ''); + overlays.paintTitle(); const baseTex = makeTex(0, base.canvas); const stampTex = makeTex(1, overlays.stampCanvas); makeTex(2, overlays.scramCanvas); const cardTex = makeTex(3, overlays.cardCanvas); + const titleTex = makeTex(4, overlays.titleCanvas); const uni = {} as Record; uni.t = gl.getUniformLocation(prog, 't'); @@ -359,7 +393,10 @@ export function createGlitchStack( uni.fever = gl.getUniformLocation(prog, 'u_fever'); uni.pallor = gl.getUniformLocation(prog, 'u_pallor'); uni.card = gl.getUniformLocation(prog, 'u_card'); + uni.title = gl.getUniformLocation(prog, 'u_title'); uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed'); + uni.wildlife = gl.getUniformLocation(prog, 'u_wildlife'); + uni.wildlifeSeed = gl.getUniformLocation(prog, 'u_wildlifeSeed'); uni.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt'); uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale'); uni.scram = gl.getUniformLocation(prog, 'u_scram'); @@ -370,15 +407,17 @@ export function createGlitchStack( 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.uniform1i(gl.getUniformLocation(prog, 'u_titleTex'), 4); gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform1f(uni.drift, 1); return { - 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)) { + draw({ params, timeSec, drift, brownout, flash, era, chyron, strain, fever, pallor, + moireSeed, wildlife, wildlifeSeed, stampAmt, stampScale, scram, scramY, stampDirty, + card, cardDirty, title, titleDirty }) { + // Re-upload the clean feed only when it actually changed (timecode tick / era / chyron). + if (base.update({ timeMs: timeSec * 1000, era, chyron })) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, baseTex); gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, base.canvas); @@ -393,6 +432,11 @@ export function createGlitchStack( gl.bindTexture(gl.TEXTURE_2D, cardTex); gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.cardCanvas); } + if (titleDirty) { + gl.activeTexture(gl.TEXTURE0 + 4); + gl.bindTexture(gl.TEXTURE_2D, titleTex); + gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.titleCanvas); + } gl.uniform1f(uni.t, timeSec); gl.uniform1f(uni.drift, drift); gl.uniform1f(uni.brownout, brownout); @@ -401,7 +445,10 @@ export function createGlitchStack( gl.uniform1f(uni.fever, fever); gl.uniform1f(uni.pallor, pallor); gl.uniform1f(uni.card, card); + gl.uniform1f(uni.title, title); gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]); + gl.uniform1f(uni.wildlife, wildlife); + gl.uniform2f(uni.wildlifeSeed, wildlifeSeed[0], wildlifeSeed[1]); gl.uniform1f(uni.stampAmt, stampAmt); gl.uniform1f(uni.stampScale, stampScale); gl.uniform1f(uni.scram, scram ? 1 : 0); diff --git a/fktry/src/screen/index.ts b/fktry/src/screen/index.ts index bd98395..91423f4 100644 --- a/fktry/src/screen/index.ts +++ b/fktry/src/screen/index.ts @@ -17,6 +17,8 @@ import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from '. import { PARAMS, ease, zeroParams, type ParamSet } from './params'; import { computeStrain } from './strain'; import { freshness } from './boredom'; +import { ERA_ORDER, highestEra, type Era } from './eras'; +import { getSubtitle } from '../audio/subtitleBus'; /** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */ const EASE_TAU = 1.2; @@ -44,8 +46,14 @@ const PALLOR_TAU = 2.0; * that one is a tape artifact, and tape artifacts are quantized to frames.) */ const CARD_SECONDS = 1; +/** Boot title: hold the burn-in, then dissolve to the NOTHING IS WRONG feed. */ +const TITLE_HOLD = 1.5; +const TITLE_FADE = 0.6; +/** Round 5: the awakening is no longer one frame but a ~0.5s full-canvas moment. */ +const FLASH_SECONDS = 0.5; interface Stamp { name: string; count: number; color: string; age: number } +interface Swarm { pos: { x: number; y: number }; size: number } /** * Where the moire crystal takes root. Deterministic in the tick it first appeared, so a given @@ -60,12 +68,38 @@ function seedFromTick(tick: number): [number, number] { return [0.25 + h(tick + 1) * 0.5, 0.25 + h(tick + 99) * 0.5]; } +/** Swarms live on the factory floor; hash their world position to a stable spot on the sky. */ +function seedFromPos(pos: { x: number; y: number }): [number, number] { + const h = (n: number) => { + const x = Math.sin(n) * 43758.5453; + return x - Math.floor(x); + }; + return [0.2 + h(pos.x * 12.9898 + pos.y * 78.233) * 0.6, 0.2 + h(pos.x * 39.3468 + pos.y * 11.135) * 0.6]; +} + 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); } +/** Boot title: hold at full, then dissolve. Returns 0 once the sequence is over. */ +function titleOpacity(age: number): number { + if (age <= TITLE_HOLD) return 1; + return 1 - smoothstep(TITLE_HOLD, TITLE_HOLD + TITLE_FADE, age); +} + +/** + * The awakening. A blown-out hold then a fast decay — the sky is stunned for a moment rather + * than blinking once. Returns 0 when not flashing (age < 0). + */ +function flashEnvelope(age: number): number { + if (age < 0) return 0; + if (age >= FLASH_SECONDS) return 0; + if (age < 0.12) return 1; + return 1 - smoothstep(0.12, FLASH_SECONDS, age); +} + export function createScreenFX(): ScreenFX { let stack: GlitchStack | null = null; const overlays = createOverlays(); @@ -81,7 +115,6 @@ export function createScreenFX(): ScreenFX { let brownout = 0; let brownoutTarget = 0; - let pendingFlash = false; let hasShipped = false; const stampQueue: Stamp[] = []; @@ -102,7 +135,17 @@ export function createScreenFX(): ScreenFX { let cardAge = -1; // <0 = no card on screen let cardDirty = false; - const eras = new Map(); + const eras = new Map(); + + // Part A round 5: + let era: Era = ERA_ORDER[0]; // the world starts in the deepest stratum (reel beds) + let forcedEra: Era | null = null; // debug hook override + let wildlife = 0; // eased regional-shimmer amount + let wildlifeSeed: [number, number] = [0.5, 0.5]; + const swarms = new Map(); // event-tracked fallback when no snapshot + let titleAge = 0; // boot title sequence clock + let titleDirty = true; // painted once at stack creation; upload on the first frame + let flashAge = -1; // <0 = not flashing let lastMs = 0; let frameCost = 0; // rolling avg ms of this module's CPU work @@ -122,7 +165,7 @@ export function createScreenFX(): ScreenFX { markShip = true; // the sky stops forgetting the moment anything lands if (!hasShipped) { hasShipped = true; - pendingFlash = true; // the awakening + flashAge = 0; // the awakening — a ~0.5s moment, ramped in frame() } // The crystal takes root where and when it first arrived, and grows from there. if (!moireSeeded && (corruptionFor(item).weights.moire ?? 0) > 0) { @@ -161,6 +204,12 @@ export function createScreenFX(): ScreenFX { ship, brownout: (on: boolean) => (brownoutTarget = on ? 1 : 0), scram: () => { scramFrames = SCRAM_FRAMES; scramY = 0.5; }, + setEra: (e: Era) => { forcedEra = e; }, + skipTitle: () => { titleAge = TITLE_HOLD + TITLE_FADE + 1; }, + swarm: (on: boolean, id = 1, pos = { x: 3, y: -2 }) => { + if (on) swarms.set(id, { pos, size: 0.7 }); + else swarms.delete(id); + }, reset: () => { tally.clear(); ledgerDirty = true; @@ -173,8 +222,12 @@ export function createScreenFX(): ScreenFX { fever = 0; pallor = 0; cardAge = -1; + flashAge = -1; lastShipMs = 0; fresh = 1; + forcedEra = null; + swarms.clear(); + wildlife = 0; for (const p of PARAMS) eased[p] = 0; }, research: (tech: string) => { @@ -188,10 +241,14 @@ export function createScreenFX(): ScreenFX { displayTotal: +(total * fresh).toFixed(3), freshness: +fresh.toFixed(3), idle: !hasShipped, + era, strain: +strain.toFixed(3), fever: +fever.toFixed(3), pallor: +pallor.toFixed(3), + wildlife: +wildlife.toFixed(3), card: cardAge >= 0 ? 1 : 0, + title: +titleOpacity(titleAge).toFixed(3), + flash: +flashEnvelope(flashAge).toFixed(3), moireSeed: moireSeed.map((n) => +n.toFixed(3)), brownout: +brownout.toFixed(2), msPerFrame: +frameCost.toFixed(3), @@ -216,6 +273,11 @@ export function createScreenFX(): ScreenFX { overlays.paintCard(eras.get(e.tech) ?? 'reel', e.tech); cardDirty = true; cardAge = 0; + } else if (e.kind === 'wildlife' && e.wildlife === 'mosquito-swarm') { + // Event-tracked swarms are the fallback when no snapshot.wildlife is available + // (debug hook). When a snapshot IS present, frame() reads it as the source of truth. + if (e.on === 'spawned') swarms.set(e.id, { pos: e.pos, size: 0.6 }); + else swarms.delete(e.id); } } }, @@ -240,6 +302,19 @@ export function createScreenFX(): ScreenFX { fever = ease(fever, target.fever, dt, FEVER_TAU); pallor = ease(pallor, target.pallor, dt, PALLOR_TAU); + // The substrate ages with the highest ratified era. Corruption stays era-agnostic — + // only what it is painted ONTO changes. + era = forcedEra ?? (snap?.research ? highestEra(snap.research.unlocked, (t) => eras.get(t)) : era); + + // Jammed-rot: a factory stuck solid rots faster than one resting. Ratio of jammed + // machines, so a single stuck belt in a big base barely registers. + let rot = 0; + if (snap && snap.entities.length) { + let jammed = 0; + for (const e of snap.entities) if (e.jammed) jammed++; + rot = jammed / snap.entities.length; + } + // 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. @@ -247,7 +322,22 @@ export function createScreenFX(): ScreenFX { lastShipMs = timeMs; markShip = false; } - fresh = hasShipped ? freshness((timeMs - lastShipMs) / 1000) : 1; + fresh = hasShipped ? freshness((timeMs - lastShipMs) / 1000, rot) : 1; + + // Wildlife shimmer: drive the region from the WORST swarm — one legible infestation + // beats an averaged smear. Snapshot is truth when present; events are the fallback. + let worst: Swarm | null = null; + const live = snap?.wildlife; + if (live) { + for (const w of live) { + if (w.kind !== 'mosquito-swarm') continue; + if (!worst || w.size > worst.size) worst = { pos: w.pos, size: w.size }; + } + } else { + for (const w of swarms.values()) if (!worst || w.size > worst.size) worst = w; + } + if (worst) wildlifeSeed = seedFromPos(worst.pos); + wildlife = ease(wildlife, worst ? clamp01(worst.size) : 0, dt, 1.0); const timeSec = timeMs / 1000; const displayTotal = total * fresh; @@ -294,17 +384,30 @@ export function createScreenFX(): ScreenFX { if (cardAge >= CARD_SECONDS) cardAge = -1; } - const flash = pendingFlash ? 1 : 0; + // Boot title, then the awakening — both are timed moments, not single frames. + const title = titleOpacity(titleAge); + if (title > 0) titleAge += dt; + const flash = flashEnvelope(flashAge); + if (flashAge >= 0) { + flashAge += dt; + if (flashAge >= FLASH_SECONDS) flashAge = -1; + } + + // The chyron: a radio caption outranks the idle placard — when THE SPEAKER is talking, + // the sky subtitles it. Suppressed under the boot title (which covers the feed anyway). + const subtitle = getSubtitle(); + const chyron = title > 0.99 ? '' : subtitle || (hasShipped ? '' : 'NOTHING IS WRONG'); + stack.draw({ - params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped, - strain, fever, pallor, moireSeed, + params: out, timeSec, drift: 1, brownout, flash, era, chyron, + strain, fever, pallor, moireSeed, wildlife, wildlifeSeed, stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty, - card, cardDirty, + card, cardDirty, title, titleDirty, }); - pendingFlash = false; if (scramFrames > 0) scramFrames--; stampDirty = false; cardDirty = false; + titleDirty = 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 0a81d16..2e662f8 100644 --- a/fktry/src/screen/overlays.ts +++ b/fktry/src/screen/overlays.ts @@ -33,10 +33,13 @@ export interface Overlays { stampCanvas: HTMLCanvasElement; scramCanvas: HTMLCanvasElement; cardCanvas: HTMLCanvasElement; + titleCanvas: 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; + /** Paint the boot title card. Alpha channel matters — it dissolves over the feed. */ + paintTitle(): void; } function roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { @@ -87,10 +90,54 @@ export function createOverlays(): Overlays { cardCanvas.height = CARD_H; const cg = cardCanvas.getContext('2d')!; + const titleCanvas = document.createElement('canvas'); + titleCanvas.width = CARD_W; + titleCanvas.height = CARD_H; + const tg = titleCanvas.getContext('2d')!; + return { stampCanvas, scramCanvas, cardCanvas, + titleCanvas, + + paintTitle() { + // The game finally says its own name. A CRT burn-in: chunky phosphor letters with a + // hot-magenta / cyan chromatic ghost, over transparent black so it can dissolve to the + // NOTHING IS WRONG feed. Alpha is what the shader mixes on. + tg.clearRect(0, 0, CARD_W, CARD_H); + tg.fillStyle = 'rgba(3,2,8,0.92)'; + tg.fillRect(0, 0, CARD_W, CARD_H); + + const cx = CARD_W / 2; + tg.textAlign = 'center'; + tg.textBaseline = 'middle'; + tg.font = "bold 58px 'Courier New', monospace"; + // chromatic aberration ghosts (the prototype's signature look) + tg.fillStyle = '#3fffe0'; + tg.fillText('WIMVEE', cx - 3, 150); + tg.fillStyle = '#ff2222'; + tg.fillText('WIMVEE', cx + 3, 150); + tg.fillStyle = '#f0ffe8'; + tg.fillText('WIMVEE', cx, 150); + tg.fillStyle = '#3fffe0'; + tg.fillText('FKTRY', cx - 3, 214); + tg.fillStyle = '#ff2222'; + tg.fillText('FKTRY', cx + 3, 214); + tg.fillStyle = '#ff3fd4'; + tg.fillText('FKTRY', cx, 214); + + tg.fillStyle = '#4a6b4a'; + tg.font = "14px 'Courier New', monospace"; + tg.fillText('A DECODER DAEMON PRODUCTION', cx, 268); + tg.font = "11px 'Courier New', monospace"; + tg.fillText('PLEASE STAND BY', cx, 300); + + // faint scanlines baked in for the burn-in feel + tg.fillStyle = 'rgba(0,0,0,0.22)'; + for (let y = 0; y < CARD_H; y += 3) tg.fillRect(0, y, CARD_W, 1); + tg.textAlign = 'left'; // restore default for the other painters + }, paintCard(era: string, tech: string) { // The Correction wins the sky for one second: hospital white, green checkmark, and the