[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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 13:51:04 +10:00
parent b5c4923a87
commit 64cb3edec6
3 changed files with 217 additions and 20 deletions

View File

@ -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<UniformKey, WebGLUniformLocation | null>;
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);

View File

@ -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<string, string>();
const eras = new Map<string, Era>();
// 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<number, Swarm>(); // 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;
},

View File

@ -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