[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:
parent
b5c4923a87
commit
64cb3edec6
@ -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);
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user