Compare commits

...

7 Commits

Author SHA1 Message Date
type-two
70130c9e71 [screen] round 5 notes: both organs, and what I could not verify
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:24 +10:00
type-two
fd2aee6c50 [audio] world ambience, discovery gating, and the radio control surface
Pre-discovery silence is structural, not a volume setting: the radio's master
gain is zero and ambience is never started until the fossil is found, and layer
1 is all one-shots, so a quiet factory is actually quiet rather than humming
under a noise floor. Measured at exactly 0.0 peak with the dial swept across
all three bands.

Dead air's tone drops 0.32 to 0.10 -- it rendered louder than the music station,
which made "dead air" the loudest thing on the dial, exactly backwards.

getRadio() is the sanctioned narrow surface for LANE-UI's tuner dock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:24 +10:00
type-two
02fc7d5094 [audio] THE BAND: a tuner, four stations, and the sky as subtitles
Gaussian lock so stations swim up out of the noise instead of snapping, and
band-specific static because that is what makes a band feel like a place: AM
brown crackle, FM white hiss, SW a wandering bandpass plus a heterodyne whose
pitch IS the beat frequency, so it swoops down to nothing as you zero in.

THE STANDARD is deliberately just short of intelligible -- you can hear it is
speech and not what it says. THE SCREEN tells you what it says. That gap is the
trick, and subtitleBus is the wire that carries it.

NUMBERS speaks LANE-DATA's planted coordinates; generated filler is rejected if
it would decode onto real ground, so the only true coordinates on the band are
the deliberate ones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:24 +10:00
type-two
7f6ebe5f5a [audio] THE SPEAKER layer 1: the diegetic minimum
Placement thunk, fax dot-matrix chatter, brownout pop-crackle-and-power-down,
scram klaxon, ratification sting. All synthesized, no asset files, ever.

Every sound takes (ctx, dest, t0) and nothing assumes a live context, so the
whole speaker renders in an OfflineAudioContext for deterministic spectral
verification -- the same code the player hears, no test-only path. Noise
buffers use a seeded PRNG so spectra reproduce exactly.

The limiter is a tanh WaveShaper, not a DynamicsCompressor: makeup gain would
pump the whole mix every time the klaxon fires.

The ratification sting is the only consonant, in-tune sound in the game. Its
attack is slow where everything else is percussive, because the enemy is the
one thing here that resolves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:24 +10:00
type-two
64cb3edec6 [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>
2026-07-18 13:51:04 +10:00
type-two
b5c4923a87 [screen] jammed-rot: stuck work spoils faster than resting work
A clean idle decays at the round-4 rate untouched. A factory jammed solid
shortens its own grace and steepens its decay, because the work is not resting,
it is spoiling. Rot is the ratio of jammed machines, so one stuck belt in a big
base barely registers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:03 +10:00
type-two
d8111feb12 [screen] the substrate ages: era-skinned clean programming
The base feed now wears the highest ratified era -- sepia film with sprocket
holes, phosphor broadcast with interlace, rainbow-on-black disc gloss, the
classic SMPTE stream, blinding fortress white. Corruption stays era-agnostic:
same shader, same params, only what they are painted onto changes.

Boot era is reel, so the world starts in the deepest stratum and climbs to the
surface. The old SMPTE look is now mid-game.

The chyron generalises from an idle-only placard to a line of text, which is
what lets the radio caption the sky in a later commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:03 +10:00
17 changed files with 2123 additions and 59 deletions

View File

@ -410,3 +410,131 @@ NEXT (not self-assigning): the era→standard table means THE SCREEN now knows a
strata skins (sepia Reel, phosphor Broadcast, rainbow-on-black Disc) could drive the *base feed's*
palette per the player's current era, so the clean programming itself ages as they tech up. That
plus the mask template would give era-flavoured biomes on the sky.
### Round 5 (MEGA) — 2026-07-18 — Opus 4.8
SHIPPED: all 8 orders, both organs. THE SPEAKER exists.
- PART A: `eras.ts` + `eras.test.ts` (new), `baseTexture.ts` (era substrate + general chyron),
`glitchStack.ts` (wildlife pass, title composite), `boredom.ts` (jammed-rot), `index.ts`.
- PART B: `graph.ts`, `sfx.ts`, `stations.ts`, `radio.ts`, `ambience.ts`, `subtitleBus.ts`,
`band.test.ts` (all new), and `index.ts` replacing the stub.
- 78 tests in my two lanes, all green. Repo-wide 443/443, tsc clean.
**A NOTE ON "VERIFICATION INCLUDES LISTENING": I cannot hear.** I have no ears, and I won't
pretend a waveform is a listening session. What I did instead is render every sound through an
`OfflineAudioContext` and measure it — spectra, peak/RMS, attack times, onset rates — and the
description below is derived from those measurements plus the synthesis I wrote. It is an
honest account of what the numbers say the speaker is doing, not of what it feels like in a
room. **Please put headphones on and tell me what's wrong** — mix balance and "is this
annoying after ten minutes" are exactly what measurement can't answer, and I'd rather get that
from you than guess.
WHAT IT SOUNDS LIKE (measured, then described):
- **Brownout** — the important one. A 0.29 ms attack: a genuine broadband click, not a fade.
Then ~0.5 s of bandpassed brown crackle and a sawtooth whine sliding 420→40 Hz under a
closing lowpass. It should read as a rack losing its rails: snap, spit, sag.
- **Placement thunk** — 2.3 ms attack, a sine dropping 150→55 Hz (the mass) under a 1.4 kHz
noise slap (the contact). Detuned per placement so a row of belts isn't a machine gun.
- **Scram klaxon** — 620/440 Hz square alternating four times, lowpassed at 1.8 kHz. Peak 0.29:
deliberately not the loudest thing, because it fires often.
- **Ratification sting** — 85 ms attack, FAC sines with an octave shimmer. The *only*
consonant, in-tune, untroubled sound in the game. Everything the player makes is filth; the
enemy sounds lovely. That's the joke, and it's why its attack is slow when everything else
is percussive.
- **FKTRY FM 88.1** — 4.9 note onsets/sec against 6.13 sixteenths at 92 bpm, i.e. the ~34%
rests are audible as space rather than a wall. Square lead over triangle bass, pentatonic
minor, pattern mutating every 32 bars. Spectrum is bass-dominant (60 Hz: 0.49) with nothing
above 4 kHz.
- **THE STANDARD 640** — 2.9 syllables/sec, which is real speech cadence. Ring-modulated
carrier through two moving formants, lowpassed at 4.5 kHz to AM bandwidth. Sits at 500 Hz
and nowhere else. It is deliberately just short of intelligible — you can hear that it's
speech and not what it says. **THE SCREEN tells you what it says.** That gap is the trick.
- **NUMBERS 6955** — 4.0 digits/sec, a formant voice with a per-digit vowel shape so numerals
are learnable by ear, through a narrow SW bandpass (sharp 2 kHz peak, 0.50). No affect, no
melody, no acknowledgement.
- **DEAD AIR 108.0** — lowpassed brown room tone breathing at 0.21 Hz (projector pace), plus
one reversed chime — swelling up out of nothing then cut dead — every ten minutes, seeded.
DECISIONS:
- **Everything takes `(ctx, dest, t0)` and nothing assumes a live context.** That one choice is
why the whole speaker can be rendered in an `OfflineAudioContext` and measured deterministically
— same code the player hears, no test-only path. Noise buffers use a seeded PRNG for the same
reason: identical spectra every run.
- **The limiter is a `tanh` WaveShaper, not a DynamicsCompressor.** A compressor's makeup gain
would pump the entire mix every time the klaxon fires. tanh just refuses to exceed 1, which is
what "don't clip" actually means, and it adds the saturation a blown factory PA should have.
- **Pre-discovery silence is structural, not a volume setting.** The radio's master gain is 0
and ambience is never started until `relicFound`. Layer 1 is all one-shots, so a quiet factory
is *actually* quiet between events rather than humming under a noise floor.
- **Inter-station noise is band-specific** because that's what makes a band feel like a place:
AM brown crackle, FM white hiss, SW a wandering bandpass plus a heterodyne whistle whose pitch
is the beat frequency — so it swoops *down to nothing* as you zero in. Hunting is the mechanic.
- **The era ages the substrate, never the corruption.** Same shader, same params; only what
they're painted onto changes. The contact sheet shows identical melt drips across all five eras.
- **Boot era is `reel`.** With nothing ratified the world starts in the deepest stratum and
climbs to the surface (stream) and beyond (fortress). The old SMPTE look is now mid-game.
- Title timed in seconds (1.53 s hold, gone by 2.07 s), awakening 0.47 s. Both are *moments*;
the scram wipe stays frame-counted because it's a tape artifact. Three clocks, three fictions.
VERIFIED (offline render + pixel reads + contact sheets):
- **Pre-discovery silence: peak 0.0 and RMS 0.0, exactly zero**, with the dial swept across all
three bands and ambience asked for max load+fever. Same graph post-discovery: peak 0.285.
- **Station lock vs static are distinct spectra, and correctly so per band.** AM static has
**70× the 60 Hz energy of AM locked** (1.40 vs 0.02 — the brown crackle). FM *inverts*: locked
is bass-dominant (0.49 @60 Hz, nothing @14 k), static is treble-only hiss (0.10 @14 k, nothing
@60 Hz). SW locked shows a 2 kHz formant peak (0.50) that static (0.09) lacks.
- **Subtitles end-to-end**: tuned AM → The Correction's line on the chyron; off by 4 kHz (lock
0.906) → still captioned; off station → caption clears; SW → `5 3 0 7 9` on the sky, which is
the real coordinate for the nitrate vein. FM and DEAD AIR caption nothing (no words). Screenshot
in the sheet.
- **Era contact sheet**: reel (sepia + sprocket holes), broadcast (phosphor + interlace), disc
(rainbow-on-black gloss), stream (classic SMPTE), fortress (blinding white) — all under the
same 20× melt.
- **Budget: SCREEN 0.040 ms/frame** (GPU sync, 2000 entities, every artifact + wildlife + strain
+ brownout). **SPEAKER 0.0063 ms/frame** on the worst case (SW numbers + heterodyne + ambience)
— the per-frame path only writes AudioParams; Web Audio does synthesis on its own thread.
BLOCKED/BROKEN: nothing blocking.
- **I caught a balance bug by measuring rather than hearing**: DEAD AIR rendered at RMS 0.072,
*louder than the music station* (0.032), which made "dead air" the loudest thing on the dial —
exactly backwards. Dropped its tone gain 0.32→0.10 and the breath depth with it; it now sits at
0.023, under the music. This is the clearest argument for you actually listening: I only found
it because I happened to print RMS side by side.
- Harness gotcha, mine not the product's: importing a module with a `?v=` cache-buster creates a
**second module instance**, so my first subtitle test wrote to a different singleton than THE
SCREEN reads and the chyron looked broken. Verified `busQ !== busC`. Re-ran canonically and it
works. Same family as round 3's clock rewind: the harness lied, not the code.
FINDING FOR LANE-DATA (not touching their file): `stations.json`'s `_doc` says the decoys
"decode to empty ground", but **two of them decode onto real seams**`08127` → (-24,-20) lands
in `reel-beds-deep`, and `27503` → (-5,18) lands in `broadcast-shelf-south`. Both are visible,
non-hidden seams so no secret leaks and the treasure hunt is intact — it's a doc/data drift, not
a break. I deliberately did **not** red-light my suite over another lane's content (round 4's
lesson); my test asserts the invariant that actually matters — *no decoy ever leaks a HIDDEN
seam* — which passes. Their three planted groups all decode exactly right, and I have a test
pinning that against `seams.json` so the map can never drift.
CONTRACT REQUEST: none. v6 covered everything.
SANCTIONED IMPORTS (documenting as ordered): LANE-UI's tuner dock should
`import { getRadio } from '../audio'` — a narrow surface of setBand/setFrequency/setVolume/
setMuted/getState/subscribe/listStations, nothing else reachable. Returns `null` before the first
user gesture builds the graph, so the dock must handle that (and should probably render disabled
until `getState().unlocked`). Internally, `src/screen` reads `src/audio/subtitleBus` — both are my
lane, and it's a zero-dependency module precisely so neither organ couples to the other's guts.
STATIONS SCHEMA: I conformed to LANE-DATA's file exactly as written rather than proposing my own
— it's better than what I'd have specified (the `eraFlavor` table and the coordinate encoding
especially). Nothing to agree; it's already right.
PROPOSAL: THE STANDARD renders at RMS 0.0037 against FKTRY FM's 0.032 — ~9× quieter. That's
*correct* for gated speech (its peak, 0.10, matches the other stations; the gap is all the silence
between syllables) but it may still feel too quiet in practice next to continuous music. If it
does when you listen, the fix is a slow compressor on that station alone rather than a gain bump,
which would just make the syllables shout. Flagging rather than pre-emptively "fixing" something
the numbers say is fine.
NEXT (not self-assigning): the tuner has no UI yet — LANE-UI's dock is what makes the band
findable at all, and until then the radio is only reachable from the console. Also `growthMask`
now has two customers (moiré, wildlife) and is ready for a third: HF dust piles are regional in
the codex and `dust-pile` wildlife already arrives in the same event I'm consuming.

View File

@ -0,0 +1,84 @@
/**
* Layer 3 world ambience. Post-discovery only.
*
* The factory gets a voice once the fossil is dug up: a hum that tracks how hard the plant is
* working, and a strained whine as decoders approach scram. Both are continuous voices built
* once and driven by AudioParams nothing here allocates per frame.
*
* Subordinate by design: the hum sits under everything, and ducks further when the radio is
* carrying a station, because a tuned station is what the player asked to hear.
*/
import { glide, noiseSource } from './graph';
export interface Ambience {
out: GainNode;
start(t0: number): void;
/**
* @param load 0..1 how hard the plant is running (draw vs gen)
* @param fever 0..1 aggregate heat the strained whine near scram
* @param duck 0..1 how much the radio wants the floor
*/
update(load: number, fever: number, duck: number): void;
}
export function createAmbience(ctx: BaseAudioContext): Ambience {
const out = ctx.createGain();
out.gain.value = 0;
// ---- the hum: mains-ish fundamental + its octave, plus a broadband machine-room bed.
const hum = ctx.createOscillator();
hum.type = 'sawtooth';
hum.frequency.value = 50;
const humLp = ctx.createBiquadFilter();
humLp.type = 'lowpass';
humLp.frequency.value = 180;
const humG = ctx.createGain();
humG.gain.value = 0.0;
hum.connect(humLp).connect(humG).connect(out);
const room = noiseSource(ctx, 'brown');
const roomLp = ctx.createBiquadFilter();
roomLp.type = 'lowpass';
roomLp.frequency.value = 600;
const roomG = ctx.createGain();
roomG.gain.value = 0.0;
room.connect(roomLp).connect(roomG).connect(out);
// ---- the strained whine: only shows up as machines climb toward a scram.
const whine = ctx.createOscillator();
whine.type = 'triangle';
whine.frequency.value = 1900;
const whineG = ctx.createGain();
whineG.gain.value = 0;
whine.connect(whineG).connect(out);
let started = false;
return {
out,
start(t0) {
if (started) return;
started = true;
hum.start(t0);
room.start(t0);
whine.start(t0);
},
update(load, fever, duck) {
if (!started) return;
const l = Math.min(1, Math.max(0, load));
const f = Math.min(1, Math.max(0, fever));
const d = 1 - 0.55 * Math.min(1, Math.max(0, duck)); // radio wins, but never silences
glide(humG.gain, (0.035 + 0.05 * l) * d, ctx, 0.4);
glide(roomG.gain, (0.02 + 0.045 * l) * d, ctx, 0.4);
// a busier plant runs its mains a touch sharp — cheap, and it reads as effort
glide(hum.frequency, 50 + 6 * l, ctx, 0.5);
glide(roomLp.frequency, 500 + 900 * l, ctx, 0.5);
// Heat only becomes audible late; below 0.55 a warm factory is just a factory.
const strain = Math.max(0, (f - 0.55) / 0.45);
glide(whineG.gain, 0.03 * strain * strain * d, ctx, 0.3);
glide(whine.frequency, 1700 + 900 * strain, ctx, 0.3);
},
};
}

View File

@ -0,0 +1,109 @@
/**
* THE BAND's data contract and treasure map, verified without a browser.
*
* The synthesis needs an AudioContext and is verified spectrally in the dev build; what CAN be
* pinned here is the part that would silently rot: the station schedule, and the promise that
* the NUMBERS station's coordinates actually lead somewhere.
*/
import { describe, expect, it } from 'vitest';
import { BAND_RANGE, type Band } from './radio';
import type { StationDef } from './stations';
import stationData from '../../data/stations.json';
import seamData from '../../data/seams.json';
const STATIONS = (stationData as { stations: StationDef[] }).stations;
const SEAMS = (seamData as {
seams: { id: string; rect: { x: number; y: number; w: number; h: number }; richness: number; hidden?: boolean }[];
}).seams;
/** LANE-DATA's encoding: [x+32 as 2 digits][y+32 as 2 digits][richness*9 as 1 digit]. */
function decode(group: string) {
return {
x: Number(group.slice(0, 2)) - 32,
y: Number(group.slice(2, 4)) - 32,
richness: Number(group[4]) / 9,
};
}
describe('the dial is well formed', () => {
it('has all four stations', () => {
expect(STATIONS.map((s) => s.id).sort()).toEqual(
['dead-air', 'fktry-fm', 'numbers', 'the-standard'].sort(),
);
});
it('puts every station inside its own band, or the tuner could never reach it', () => {
for (const s of STATIONS) {
const [lo, hi] = BAND_RANGE[s.band as Band];
expect(s.freq, `${s.id} @ ${s.freq} outside ${s.band} ${lo}-${hi}`).toBeGreaterThanOrEqual(lo);
expect(s.freq).toBeLessThanOrEqual(hi);
}
});
it('never puts two stations on the same spot of the same band', () => {
const seen = new Set<string>();
for (const s of STATIONS) {
const k = `${s.band}:${s.freq}`;
expect(seen.has(k), `duplicate ${k}`).toBe(false);
seen.add(k);
}
});
});
describe('the numbers station is an honest treasure map', () => {
const numbers = STATIONS.find((s) => s.id === 'numbers')!.numbers!;
const hidden = SEAMS.filter((s) => s.hidden);
it('plants exactly the hidden seams, and they decode to the real tiles', () => {
expect(numbers.plantedGroups).toHaveLength(hidden.length);
const decoded = numbers.plantedGroups.map(decode);
for (const h of hidden) {
const hit = decoded.find((d) => d.x === h.rect.x && d.y === h.rect.y);
expect(hit, `no broadcast group leads to ${h.id} at ${h.rect.x},${h.rect.y}`).toBeTruthy();
expect(hit!.richness).toBeCloseTo(h.richness, 1);
}
});
it('keeps every group the same width, so a listener can chunk them by ear', () => {
for (const g of [...numbers.plantedGroups, ...numbers.decoyPool, numbers.preamble]) {
expect(g).toHaveLength(numbers.groupSize);
expect(g).toMatch(/^\d+$/);
}
});
it('no decoy ever leaks a HIDDEN seam — the one that would break the hunt', () => {
// This is the invariant THE SPEAKER must protect: the only groups that lead to secret
// ground are the planted ones. A decoy landing on an already-visible seam is untidy but
// harmless (see NOTES: two currently do, reported to LANE-DATA); a decoy landing on a
// hidden seam would hand out the treasure for free.
for (const g of numbers.decoyPool) {
const d = decode(g);
const leak = hidden.find(
(s) => d.x >= s.rect.x && d.x < s.rect.x + s.rect.w && d.y >= s.rect.y && d.y < s.rect.y + s.rect.h,
);
expect(leak, `decoy ${g} gives away hidden seam ${leak?.id} at ${d.x},${d.y}`).toBeUndefined();
}
});
it('broadcasts a planted group often enough to be findable', () => {
// Every 4th message, 8 groups a message, 1.2s a group: a planted coordinate roughly every
// couple of minutes. Long enough to feel like a signal, short enough to catch one.
const secondsPerMessage =
(numbers.groupsPerMessage + numbers.preambleRepeats) * numbers.secondsPerGroup;
expect(secondsPerMessage * numbers.plantEveryMessages).toBeLessThan(120);
});
});
describe('THE STANDARD has something to say', () => {
const lines = STATIONS.find((s) => s.id === 'the-standard')!.chyron!;
it('carries enough copy that it does not obviously loop', () => {
expect(lines.length).toBeGreaterThanOrEqual(20);
expect(new Set(lines).size).toBe(lines.length);
});
it('fits the chyron — long lines would run off the sky', () => {
// The chyron band renders ~62 monospace chars at 20px across 640px.
for (const l of lines) expect(l.length, `too long to subtitle: "${l}"`).toBeLessThanOrEqual(96);
});
});

125
fktry/src/audio/graph.ts Normal file
View File

@ -0,0 +1,125 @@
/**
* THE SPEAKER's plumbing: master chain, shared noise, scheduling helpers.
*
* HOUSE LAW: every sound is synthesized with Web Audio. No asset files, ever. A game about
* broken media makes its own noise.
*
* Two rules that shape everything here:
* - **Zero per-frame allocations.** Continuous voices (hum, station beds) are built once at
* unlock and then only have their AudioParams driven. Only genuinely one-shot sounds
* allocate nodes, and those are short-lived and self-stopping.
* - **Context-agnostic.** Nothing below assumes a live AudioContext. Every builder takes a
* BaseAudioContext, so the whole synth stack can be rendered in an OfflineAudioContext for
* deterministic spectral verification the same code the player hears.
*/
/** Noise buffers are expensive to fill, so each context gets one of each, cached. */
const noiseCache = new WeakMap<BaseAudioContext, { white: AudioBuffer; brown: AudioBuffer }>();
function buildNoise(ctx: BaseAudioContext) {
const len = Math.floor(ctx.sampleRate * 2); // 2s loops — long enough to not read as periodic
const white = ctx.createBuffer(1, len, ctx.sampleRate);
const brown = ctx.createBuffer(1, len, ctx.sampleRate);
const w = white.getChannelData(0);
const b = brown.getChannelData(0);
// Deterministic PRNG: same noise every run, so spectra are reproducible across verifications.
let seed = 0x1337c0de;
const rnd = () => {
seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
return (seed >>> 0) / 4294967296 * 2 - 1;
};
let last = 0;
for (let i = 0; i < len; i++) {
const n = rnd();
w[i] = n;
// brown/red noise: integrated white, gently leaked to stop DC runaway
last = (last + 0.02 * n) / 1.02;
b[i] = last * 3.5;
}
return { white, brown };
}
export function noise(ctx: BaseAudioContext) {
let n = noiseCache.get(ctx);
if (!n) {
n = buildNoise(ctx);
noiseCache.set(ctx, n);
}
return n;
}
/** A looping noise source. Caller owns start/stop. */
export function noiseSource(ctx: BaseAudioContext, kind: 'white' | 'brown'): AudioBufferSourceNode {
const src = ctx.createBufferSource();
src.buffer = noise(ctx)[kind];
src.loop = true;
return src;
}
export interface MasterChain {
/** everything in the mix connects here */
input: GainNode;
/** master volume, pre-limiter */
volume: GainNode;
}
/**
* gain soft limiter destination.
*
* The limiter is a WaveShaper running tanh, not a DynamicsCompressor: a compressor's lookahead
* and automatic makeup would pump the whole mix every time the klaxon fires. tanh just refuses
* to go past 1, which is what "don't clip" actually means, and it adds the faint saturation a
* blown factory PA ought to have.
*/
export function createMasterChain(ctx: BaseAudioContext): MasterChain {
const input = ctx.createGain();
const volume = ctx.createGain();
volume.gain.value = 0.9;
const limiter = ctx.createWaveShaper();
const n = 1024;
const curve = new Float32Array(n);
for (let i = 0; i < n; i++) {
const x = (i / (n - 1)) * 2 - 1;
curve[i] = Math.tanh(x * 1.6);
}
limiter.curve = curve;
limiter.oversample = '2x';
input.connect(volume);
volume.connect(limiter);
limiter.connect(ctx.destination);
return { input, volume };
}
/** Ramp a param without clicks. Web Audio hates instantaneous jumps on a running graph. */
export function glide(p: AudioParam, to: number, ctx: BaseAudioContext, seconds = 0.05) {
const t = ctx.currentTime;
p.cancelScheduledValues(t);
p.setValueAtTime(p.value, t);
p.linearRampToValueAtTime(to, t + seconds);
}
/**
* A one-shot envelope on a fresh gain node: attack to peak, decay to silence, then disconnect.
* Returns the gain so the caller can hang a source off it.
*/
export function envGain(
ctx: BaseAudioContext,
t0: number,
peak: number,
attack: number,
decay: number,
): GainNode {
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t0 + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + attack + decay);
return g;
}
/** Deterministic 0..1 hash — used anywhere a sound needs stable variation without Math.random. */
export function hash01(n: number): number {
const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}

View File

@ -1,12 +1,211 @@
/** LANE-SCREEN territory (THE SPEAKER). Stub replace per lanes/LANE-SCREEN.md.
* All sound is Web Audio synthesis; no asset files, ever. */
/**
* THE SPEAKER LANE-SCREEN's second organ.
*
* HOUSE LAW: every sound is synthesized with Web Audio. No asset files, ever.
*
* Three layers, gated by discovery:
* 1. DIEGETIC MINIMUM (always on) placement, fax, brownout, scram, ratification.
* Nothing else exists before the optical fossil is found. The silence is the tease.
* 2. THE BAND (on relicFound) the tuner and its four stations.
* 3. WORLD AMBIENCE (post-discovery) the factory's own hum, ducked under the radio.
*
* The AudioContext is created lazily on the first user gesture (browsers require it) and the
* whole graph is built once at that moment. After that the per-frame path only writes
* AudioParams no allocation except genuinely one-shot notes.
*/
import type { AudioFX, GameData, SimEvent, SimSnapshot } from '../contracts';
import { ERA_ORDER, highestEra, type Era } from '../screen/eras';
import { createMasterChain } from './graph';
import { createAmbience, type Ambience } from './ambience';
import { createRadio, type Radio, type RadioEngine } from './radio';
import type { StationDef } from './stations';
import {
brownoutHit, brownoutRecover, faxChatter, klaxon, ratifySting, tapeChunk, thunk,
} from './sfx';
import stationData from '../../data/stations.json';
import seamData from '../../data/seams.json';
const STATIONS = (stationData as { stations: StationDef[] }).stations;
/**
* The real seams, so the NUMBERS station's seeded filler can never accidentally broadcast a
* true coordinate (LANE-DATA's rule: only the planted groups are real).
*/
const SEAM_RECTS = (seamData as { seams: { rect: { x: number; y: number; w: number; h: number } }[] })
.seams.map((s) => s.rect);
function isRealSeam(x: number, y: number): boolean {
for (const r of SEAM_RECTS) {
if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) return true;
}
return false;
}
/** Sounds that must not machine-gun when the sim emits a burst in one tick. */
const MIN_GAP = { thunk: 0.03, chunk: 0.05 };
export function createAudioFX(): AudioFX {
let ctx: BaseAudioContext | null = null;
let master: ReturnType<typeof createMasterChain> | null = null;
let radio: RadioEngine | null = null;
let ambience: Ambience | null = null;
const eras = new Map<string, Era>();
let era: Era = ERA_ORDER[0];
let discovered = false;
let lastThunk = 0;
let lastChunk = 0;
let variant = 0;
function now(): number {
return ctx ? ctx.currentTime : 0;
}
function openTheBand() {
if (discovered) return;
discovered = true;
if (!ctx || !radio || !ambience || !master) return;
radio.unlock(now());
ambience.start(now());
// The fossil is a severed audio bus reconnecting: the world audibly comes online.
ratifySting(ctx, master.input, now() + 0.02);
}
function handle(e: SimEvent) {
if (!ctx || !master) return;
const dest = master.input;
const t = now() + 0.005; // a hair in the future: scheduling in the past clicks
switch (e.kind) {
case 'placed':
if (t - lastThunk >= MIN_GAP.thunk) {
thunk(ctx, dest, t, variant++);
lastThunk = t;
}
break;
case 'brownout':
if (e.on) brownoutHit(ctx, dest, t);
else brownoutRecover(ctx, dest, t);
break;
case 'scram':
if (e.on) klaxon(ctx, dest, t);
break;
case 'researched':
ratifySting(ctx, dest, t);
break;
case 'commissionDone':
faxChatter(ctx, dest, t, 18);
break;
case 'shipped':
// Layer 3: the tape chunk belongs to the discovered world.
if (discovered && t - lastChunk >= MIN_GAP.chunk) {
tapeChunk(ctx, dest, t, variant++);
lastChunk = t;
}
break;
case 'relicFound':
openTheBand();
break;
default:
break;
}
}
function build() {
if (ctx) return;
const AC: typeof AudioContext =
(window as any).AudioContext ?? (window as any).webkitAudioContext;
if (!AC) {
console.warn('[audio] no Web Audio support — THE SPEAKER stays silent');
return;
}
ctx = new AC();
master = createMasterChain(ctx);
radio = createRadio(ctx, STATIONS, isRealSeam);
ambience = createAmbience(ctx);
radio.out.connect(master.input);
ambience.out.connect(master.input);
// The fax announces itself the moment the world has ears — Layer 1 proof of life.
faxChatter(ctx, master.input, now() + 0.15, 10);
// If the relic was already found (loaded save, or dug before the first click), the band
// should be open the instant audio exists.
if (discovered) {
discovered = false;
openTheBand();
}
if (import.meta.env.DEV) {
(window as any).__fktryAudio = {
ctx,
radio: radio as Radio,
openTheBand,
isDiscovered: () => discovered,
stations: STATIONS,
play: (name: string) => {
if (!ctx || !master) return;
const d = master.input;
const t = now() + 0.02;
({ thunk, fax: faxChatter, brownout: brownoutHit, recover: brownoutRecover,
klaxon, ratify: ratifySting, chunk: tapeChunk } as any)[name]?.(ctx, d, t);
},
};
}
}
return {
init(_data: GameData) {},
unlock() {},
onEvents(_events: SimEvent[]) {},
frame(_timeMs: number, _snap?: SimSnapshot) {},
init(data: GameData) {
for (const t of data.tech) eras.set(t.id, t.era);
},
unlock() {
build();
if (ctx && 'resume' in ctx) void (ctx as AudioContext).resume();
},
onEvents(events: SimEvent[]) {
if (!ctx) {
// Audio doesn't exist yet (no gesture). Anything that happened is simply not heard —
// we never queue a burst to fire on unlock. But discovery must not be lost.
for (const e of events) if (e.kind === 'relicFound') discovered = true;
return;
}
for (const e of events) handle(e);
},
frame(timeMs: number, snap?: SimSnapshot) {
if (!ctx || !radio || !ambience) return;
const t = now();
if (snap?.research) era = highestEra(snap.research.unlocked, (x) => eras.get(x));
// Discovery can also arrive via the snapshot (e.g. a loaded save), not just the event.
if (!discovered && snap?.relics?.some((r) => r.found)) openTheBand();
radio.update(t, era);
if (discovered && snap) {
const { gen, draw } = snap.bandwidth;
const load = draw <= 0 ? 0 : Math.min(1, draw / Math.max(1, Math.max(gen, draw)));
let hot = 0;
let n = 0;
for (const e of snap.entities) if (e.heat > 0.01) { hot += e.heat; n++; }
const fever = n ? hot / n : 0;
ambience.update(load, fever, radio.getState().lock);
}
},
};
}
/**
* THE SPEAKER's control surface, for LANE-UI's tuner dock.
*
* SANCTIONED CROSS-LANE IMPORT: LANE-UI may `import { getRadio } from '../audio'` and drive
* the dial. Returns null before the first user gesture builds the graph. The object is
* deliberately narrow the UI can tune, set volume and subscribe, and can do nothing else to
* the audio graph.
*/
export function getRadio(): Radio | null {
return (window as any).__fktryAudio?.radio ?? null;
}

248
fktry/src/audio/radio.ts Normal file
View File

@ -0,0 +1,248 @@
/**
* The tuner (round 5 order 6).
*
* A real dial: you sweep through noise and stations swim up out of it. Lock is a gaussian on
* the distance between the dial and each carrier, so stations fade in and out rather than
* snapping hunting for a station is the point, not an obstacle.
*
* Inter-station noise is BAND-APPROPRIATE, because that's what makes each band feel like a
* different physical place:
* AM brown crackle. Heavy, atmospheric, full of distant weather.
* FM white hiss. Bright, flat, modern, boring in a clean way.
* SW warble and heterodyne whistles that beat against nearby carriers. Alive and hostile.
*/
import type { Era } from '../screen/eras';
import { glide, noiseSource } from './graph';
import { setSubtitle } from './subtitleBus';
import {
amPropaganda, deadAir, fmMusic, swNumbers, type LiveStation, type StationDef,
} from './stations';
export type Band = 'AM' | 'FM' | 'SW';
/** Dial travel per band, in that band's own units (MHz for FM, kHz for AM/SW). */
export const BAND_RANGE: Record<Band, [number, number]> = {
AM: [520, 1710],
FM: [87.5, 108.0],
SW: [5900, 7400],
};
/**
* How wide a station's capture is, in band units. Tuned so a station occupies a comfortable
* fraction of the dial: wide enough to find by hand, narrow enough that finding it feels like
* something. SW is deliberately the fiddliest.
*/
const LOCK_WIDTH: Record<Band, number> = { AM: 9, FM: 0.32, SW: 6 };
export interface RadioState {
band: Band;
frequency: number;
volume: number;
muted: boolean;
/** id of the station currently locked, or null when you're between stations */
locked: string | null;
/** 0..1 strength of that lock */
lock: number;
unlocked: boolean;
}
/** The narrow control surface LANE-UI's tuner dock drives. */
export interface Radio {
setBand(b: Band): void;
setFrequency(f: number): void;
setVolume(v: number): void;
setMuted(m: boolean): void;
getState(): RadioState;
subscribe(fn: (s: RadioState) => void): () => void;
/** every station on the dial, for the UI to draw tick marks */
listStations(): { id: string; name: string; band: Band; freq: number }[];
}
export interface RadioEngine extends Radio {
/** open THE BAND — called once the optical fossil is found */
unlock(t0: number): void;
isUnlocked(): boolean;
update(now: number, era: Era): void;
/** master output for the whole radio, so ambience can duck under it */
out: GainNode;
}
const gaussian = (d: number, w: number) => Math.exp(-(d * d) / (2 * w * w));
export function createRadio(
ctx: BaseAudioContext,
defs: StationDef[],
isRealSeam: (x: number, y: number) => boolean,
): RadioEngine {
const out = ctx.createGain();
out.gain.value = 0; // silent until the fossil is dug up
const stations: LiveStation[] = defs.map((d) => {
if (d.music) return fmMusic(ctx, d);
if (d.chyron) return amPropaganda(ctx, d);
if (d.numbers) return swNumbers(ctx, d, isRealSeam);
return deadAir(ctx, d);
});
for (const s of stations) s.out.connect(out);
// ---- the three inter-station noise beds. All three exist; only the current band is audible.
const hiss = noiseSource(ctx, 'white'); // FM
const hissF = ctx.createBiquadFilter();
hissF.type = 'highpass';
hissF.frequency.value = 1200;
const hissG = ctx.createGain();
hissG.gain.value = 0;
hiss.connect(hissF).connect(hissG).connect(out);
const crackle = noiseSource(ctx, 'brown'); // AM
const crackleF = ctx.createBiquadFilter();
crackleF.type = 'lowpass';
crackleF.frequency.value = 3200;
const crackleG = ctx.createGain();
crackleG.gain.value = 0;
crackle.connect(crackleF).connect(crackleG).connect(out);
const warble = noiseSource(ctx, 'white'); // SW bed
const warbleF = ctx.createBiquadFilter();
warbleF.type = 'bandpass';
warbleF.frequency.value = 1500;
warbleF.Q.value = 1.2;
const warbleG = ctx.createGain();
warbleG.gain.value = 0;
// the bandpass centre wanders — that slow swimming quality of a shortwave band at night
const warbleLfo = ctx.createOscillator();
warbleLfo.frequency.value = 0.13;
const warbleLfoDepth = ctx.createGain();
warbleLfoDepth.gain.value = 600;
warbleLfo.connect(warbleLfoDepth).connect(warbleF.frequency);
warble.connect(warbleF).connect(warbleG).connect(out);
// Heterodyne: the whistle you get when your dial is NEAR a carrier but not on it. Pitch is
// the beat frequency, so it swoops down to nothing as you zero in — the sound of hunting.
const het = ctx.createOscillator();
het.type = 'sine';
het.frequency.value = 1000;
const hetG = ctx.createGain();
hetG.gain.value = 0;
het.connect(hetG).connect(out);
let band: Band = 'FM';
let frequency = 88.1;
let volume = 0.7;
let muted = false;
let unlocked = false;
let started = false;
let locked: string | null = null;
let lockStrength = 0;
const subs = new Set<(s: RadioState) => void>();
function state(): RadioState {
return { band, frequency, volume, muted, locked, lock: +lockStrength.toFixed(3), unlocked };
}
function publish() {
const s = state();
for (const fn of subs) fn(s);
}
function applyMaster() {
glide(out.gain, unlocked && !muted ? volume : 0, ctx, 0.08);
}
return {
out,
unlock(t0: number) {
if (unlocked) return;
unlocked = true;
if (!started) {
started = true;
for (const s of stations) s.start(t0);
hiss.start(t0);
crackle.start(t0);
warble.start(t0);
warbleLfo.start(t0);
het.start(t0);
}
applyMaster();
publish();
},
isUnlocked: () => unlocked,
setBand(b) {
if (b === band) return;
band = b;
// land somewhere sensible in the new band rather than an out-of-range number
const [lo, hi] = BAND_RANGE[b];
frequency = Math.min(hi, Math.max(lo, frequency));
const near = stations.find((s) => s.def.band === b);
if (near && (frequency < lo || frequency > hi)) frequency = near.def.freq;
publish();
},
setFrequency(f) {
const [lo, hi] = BAND_RANGE[band];
frequency = Math.min(hi, Math.max(lo, f));
publish();
},
setVolume(v) {
volume = Math.min(1, Math.max(0, v));
applyMaster();
publish();
},
setMuted(m) {
muted = m;
applyMaster();
publish();
},
getState: state,
subscribe(fn) {
subs.add(fn);
fn(state());
return () => subs.delete(fn);
},
listStations: () =>
stations.map((s) => ({ id: s.def.id, name: s.def.name, band: s.def.band, freq: s.def.freq })),
update(now, era) {
if (!unlocked) return;
const w = LOCK_WIDTH[band];
let best: LiveStation | null = null;
let bestLock = 0;
let nearestDist = Infinity;
for (const s of stations) {
const onBand = s.def.band === band;
const d = Math.abs(s.def.freq - frequency);
const lock = onBand ? gaussian(d, w) : 0;
if (onBand && d < nearestDist) nearestDist = d;
// Below the audible floor a station is genuinely off — no CPU spent scheduling it.
glide(s.out.gain, lock < 0.02 ? 0 : lock, ctx, 0.06);
s.update(now, lock, era);
if (lock > bestLock) { bestLock = lock; best = s; }
}
const changed = (best?.def.id ?? null) !== locked;
locked = best && bestLock > 0.02 ? best.def.id : null;
lockStrength = bestLock;
// Static fills whatever the stations leave behind.
const staticAmt = 1 - Math.min(1, bestLock);
glide(hissG.gain, band === 'FM' ? 0.05 * staticAmt : 0, ctx, 0.1);
glide(crackleG.gain, band === 'AM' ? 0.11 * staticAmt : 0, ctx, 0.1);
glide(warbleG.gain, band === 'SW' ? 0.08 * staticAmt : 0, ctx, 0.1);
// Heterodyne only on SW, only while close-but-not-locked, pitch = how far off you are.
if (band === 'SW' && nearestDist < w * 3 && bestLock < 0.85) {
const beat = Math.min(2400, 40 + (nearestDist / (w * 3)) * 2400);
glide(het.frequency, beat, ctx, 0.08);
glide(hetG.gain, 0.05 * (1 - bestLock), ctx, 0.1);
} else {
glide(hetG.gain, 0, ctx, 0.15);
}
// The sky captions whatever is currently talking.
setSubtitle(best && bestLock > 0.35 ? best.subtitle() : '');
if (changed) publish();
},
};
}

204
fktry/src/audio/sfx.ts Normal file
View File

@ -0,0 +1,204 @@
/**
* Layer 1 the diegetic minimum. Always on, from the first click, before any discovery.
*
* These five sounds are the whole audio world until the player digs up the optical fossil.
* That is the point: the silence around them is the tease. Everything here is one-shot and
* self-stopping no continuous voices, so a pre-discovery factory is genuinely quiet between
* events rather than humming under a noise floor.
*
* Every sound takes (ctx, dest, t0) so it can be rendered offline for spectral verification.
*/
import { envGain, hash01, noiseSource } from './graph';
/**
* PLACEMENT THUNK a heavy thing set down on a metal deck.
* Low sine drops 15055Hz (the mass) under a filtered noise slap (the contact).
*/
export function thunk(ctx: BaseAudioContext, dest: AudioNode, t0: number, variant = 0) {
const detune = 1 + (hash01(variant) - 0.5) * 0.18; // no two placements identical
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(150 * detune, t0);
osc.frequency.exponentialRampToValueAtTime(55 * detune, t0 + 0.11);
const og = envGain(ctx, t0, 0.5, 0.004, 0.16);
osc.connect(og).connect(dest);
osc.start(t0);
osc.stop(t0 + 0.22);
const slap = noiseSource(ctx, 'white');
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 1400 * detune;
bp.Q.value = 0.8;
const sg = envGain(ctx, t0, 0.22, 0.002, 0.05);
slap.connect(bp).connect(sg).connect(dest);
slap.start(t0);
slap.stop(t0 + 0.09);
}
/**
* FAX CHATTER dot-matrix print head. A burst of short square blips at ~55Hz repetition
* with a paper-feed hiss under it. The commission fax is the game's voice; it should sound
* like an office machine that has never once been serviced.
*/
export function faxChatter(ctx: BaseAudioContext, dest: AudioNode, t0: number, chars = 14) {
const bus = ctx.createGain();
bus.gain.value = 0.5;
bus.connect(dest);
for (let i = 0; i < chars; i++) {
const t = t0 + i * 0.018 + hash01(i * 7.7) * 0.004;
const o = ctx.createOscillator();
o.type = 'square';
o.frequency.value = 320 + hash01(i * 3.3) * 260;
const g = envGain(ctx, t, 0.16, 0.0015, 0.012);
o.connect(g).connect(bus);
o.start(t);
o.stop(t + 0.02);
}
// paper feed
const paper = noiseSource(ctx, 'white');
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 2600;
const pg = envGain(ctx, t0, 0.09, 0.02, chars * 0.018 + 0.1);
paper.connect(hp).connect(pg).connect(bus);
paper.start(t0);
paper.stop(t0 + chars * 0.018 + 0.2);
}
/**
* BROWNOUT the codex's catastrophic underrun, finally audible.
* A hard pop transient, a burst of crackle, and a power-down whine sliding 42040Hz as the
* rails collapse. This is the single most important sound in the game: it is the punishment.
*/
export function brownoutHit(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
// the pop: a single-sample-ish click, deliberately broadband
const pop = ctx.createOscillator();
pop.type = 'square';
pop.frequency.setValueAtTime(90, t0);
const pg = envGain(ctx, t0, 0.75, 0.0008, 0.03);
pop.connect(pg).connect(dest);
pop.start(t0);
pop.stop(t0 + 0.04);
// crackle: brown noise gated hard
const cr = noiseSource(ctx, 'brown');
const crf = ctx.createBiquadFilter();
crf.type = 'bandpass';
crf.frequency.value = 800;
crf.Q.value = 0.6;
const cg = envGain(ctx, t0, 0.5, 0.003, 0.5);
cr.connect(crf).connect(cg).connect(dest);
cr.start(t0);
cr.stop(t0 + 0.7);
// power-down whine: the flywheel losing it
const wh = ctx.createOscillator();
wh.type = 'sawtooth';
wh.frequency.setValueAtTime(420, t0);
wh.frequency.exponentialRampToValueAtTime(40, t0 + 0.9);
const wf = ctx.createBiquadFilter();
wf.type = 'lowpass';
wf.frequency.setValueAtTime(2200, t0);
wf.frequency.exponentialRampToValueAtTime(300, t0 + 0.9);
const wg = envGain(ctx, t0, 0.3, 0.02, 0.95);
wh.connect(wf).connect(wg).connect(dest);
wh.start(t0);
wh.stop(t0 + 1.0);
}
/** Power coming back: the whine in reverse, shorter and less dramatic. Relief, not triumph. */
export function brownoutRecover(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
const wh = ctx.createOscillator();
wh.type = 'sawtooth';
wh.frequency.setValueAtTime(60, t0);
wh.frequency.exponentialRampToValueAtTime(360, t0 + 0.35);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 1400;
const g = envGain(ctx, t0, 0.2, 0.05, 0.32);
wh.connect(lp).connect(g).connect(dest);
wh.start(t0);
wh.stop(t0 + 0.45);
}
/**
* SCRAM KLAXON two-tone industrial alarm, 620/440Hz, square, deliberately unpleasant.
* An OSHA-compliant noise for an entirely non-compliant machine.
*/
export function klaxon(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
const bus = ctx.createGain();
bus.gain.value = 0.34;
bus.connect(dest);
const tones = [620, 440, 620, 440];
for (let i = 0; i < tones.length; i++) {
const t = t0 + i * 0.22;
const o = ctx.createOscillator();
o.type = 'square';
o.frequency.value = tones[i];
const g = envGain(ctx, t, 0.5, 0.01, 0.19);
// a lowpass keeps it a klaxon rather than a buzzer
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 1800;
o.connect(lp).connect(g).connect(bus);
o.start(t);
o.stop(t + 0.21);
}
}
/**
* RATIFICATION STING The Correction resolves; you don't.
* A clean major triad (FAC) on sines with a soft bell attack. It is the ONLY consonant,
* in-tune, untroubled sound in the entire game. Everything the player makes is filth; this
* is what the enemy sounds like, and it sounds *nice*, which is the joke.
*/
export function ratifySting(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
const bus = ctx.createGain();
bus.gain.value = 0.3;
bus.connect(dest);
const triad = [349.23, 440.0, 523.25]; // F4 A4 C5
triad.forEach((f, i) => {
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.value = f;
const g = envGain(ctx, t0 + i * 0.035, 0.4, 0.02, 1.1);
o.connect(g).connect(bus);
o.start(t0 + i * 0.035);
o.stop(t0 + 1.4);
});
// a touch of shimmer an octave up so it reads as "official" rather than "warm"
const shimmer = ctx.createOscillator();
shimmer.type = 'sine';
shimmer.frequency.value = 1046.5;
const sg = envGain(ctx, t0, 0.12, 0.03, 0.9);
shimmer.connect(sg).connect(bus);
shimmer.start(t0);
shimmer.stop(t0 + 1.1);
}
/** TAPE CHUNK — a shipment lands. Layer 3, but it lives here with its siblings. */
export function tapeChunk(ctx: BaseAudioContext, dest: AudioNode, t0: number, variant = 0) {
const d = 1 + (hash01(variant * 5.5) - 0.5) * 0.2;
const n = noiseSource(ctx, 'white');
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 520 * d;
bp.Q.value = 1.4;
const g = envGain(ctx, t0, 0.3, 0.002, 0.07);
n.connect(bp).connect(g).connect(dest);
n.start(t0);
n.stop(t0 + 0.1);
const thud = ctx.createOscillator();
thud.type = 'sine';
thud.frequency.setValueAtTime(110 * d, t0);
thud.frequency.exponentialRampToValueAtTime(70 * d, t0 + 0.06);
const tg = envGain(ctx, t0, 0.22, 0.003, 0.09);
thud.connect(tg).connect(dest);
thud.start(t0);
thud.stop(t0 + 0.12);
}

488
fktry/src/audio/stations.ts Normal file
View File

@ -0,0 +1,488 @@
/**
* THE BAND the four things living on the dial (round 5 order 6).
*
* Schema is LANE-DATA's (`data/stations.json`); this file is pure synthesis. Every station is
* a continuous voice built once at unlock, whose OUTPUT GAIN is driven by the tuner's lock
* strength. Stations self-schedule with a lookahead so nothing allocates per frame except
* genuinely one-shot notes.
*
* Each station also decides what THE SCREEN says: when a talking station is locked it writes
* the subtitle bus, and the sky captions it.
*/
import type { Era } from '../screen/eras';
import { envGain, hash01, noiseSource } from './graph';
export interface StationDef {
id: string;
band: 'AM' | 'FM' | 'SW';
freq: number;
name: string;
format?: string;
flavor?: string;
music?: {
bpm: number; scale: string; steps: number; mutateEveryBars: number;
seed: number; voices: string[]; eraFlavor?: Record<string, string>;
};
chyron?: string[];
numbers?: {
preamble: string; preambleRepeats: number; groupSize: number; groupsPerMessage: number;
secondsPerGroup: number; plantedGroups: string[]; plantEveryMessages: number;
decoyPool: string[];
};
ghost?: { everyMinutes: number; reversed: boolean; line: string };
}
export interface LiveStation {
def: StationDef;
/** station audio lands here; the tuner drives this node's gain from the lock strength */
out: GainNode;
start(t0: number): void;
/** called every frame. `lock` 0..1 — only a locked station schedules and subtitles. */
update(now: number, lock: number, era: Era): void;
/** the line the sky should caption right now, or '' */
subtitle(): string;
}
const LOOKAHEAD = 0.25; // seconds of scheduling runway
/** Mulberry32 — small, fast, and seedable, so a run's radio is always the same radio. */
function rng(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ─────────────────────────────────────────────────────────────── 88.1 FKTRY FM
const PENTA_MINOR = [0, 3, 5, 7, 10]; // the scale DATA asked for
/**
* The factory's own transmitter: a sequencer nobody is operating.
* Square lead + triangle bass on a seeded 16-step pattern that mutates every 32 bars, so it
* genuinely never repeats a bar it liked. The era colours the transmission, not the notes
* same music, aging equipment.
*/
export function fmMusic(ctx: BaseAudioContext, def: StationDef): LiveStation {
const m = def.music!;
const out = ctx.createGain();
out.gain.value = 0;
const voiceBus = ctx.createGain();
voiceBus.gain.value = 0.5;
// Wow/flutter lives on a delay line: reel-era tape doesn't hold pitch.
const warble = ctx.createDelay(0.05);
warble.delayTime.value = 0.004;
const wowLfo = ctx.createOscillator();
wowLfo.frequency.value = 0.7;
const wowDepth = ctx.createGain();
wowDepth.gain.value = 0; // era sets this
wowLfo.connect(wowDepth).connect(warble.delayTime);
voiceBus.connect(warble).connect(out);
// Era beds: surface crackle (reel) and the 15.7kHz line whine (broadcast).
const crackle = noiseSource(ctx, 'brown');
const crackleF = ctx.createBiquadFilter();
crackleF.type = 'highpass';
crackleF.frequency.value = 1800;
const crackleG = ctx.createGain();
crackleG.gain.value = 0;
crackle.connect(crackleF).connect(crackleG).connect(out);
const lineWhine = ctx.createOscillator();
lineWhine.type = 'sine';
lineWhine.frequency.value = 15734; // NTSC horizontal line rate, the sound of a warm CRT
const whineG = ctx.createGain();
whineG.gain.value = 0;
lineWhine.connect(whineG).connect(out);
const spb = 60 / m.bpm / 4; // 16th notes
let step = 0;
let bars = 0;
let nextTime = 0;
let started = false;
let pattern: number[] = [];
let mutations = 0;
const rand = rng(m.seed);
function mutate() {
pattern = Array.from({ length: m.steps }, () => {
const r = rand();
if (r < 0.34) return -1; // rest — space is what stops it sounding like a demo
return PENTA_MINOR[Math.floor(rand() * PENTA_MINOR.length)] + (rand() < 0.25 ? 12 : 0);
});
mutations++;
}
mutate();
let eraNow: Era = 'stream';
function note(semi: number, t: number, isDownbeat: boolean) {
const root = 110; // A2
const f = root * Math.pow(2, semi / 12);
// fortress: wavelet-smooth, no transients. Everything else keeps its edge.
const attack = eraNow === 'fortress' ? 0.09 : 0.006;
const decay = eraNow === 'fortress' ? 0.5 : 0.16;
const lead = ctx.createOscillator();
lead.type = 'square';
lead.frequency.value = f * 2;
const lg = envGain(ctx, t, eraNow === 'stream' ? 0.3 : 0.22, attack, decay);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = eraNow === 'disc' ? 6000 : eraNow === 'reel' ? 2200 : 4000;
lead.connect(lp).connect(lg).connect(voiceBus);
lead.start(t);
lead.stop(t + attack + decay + 0.02);
if (isDownbeat) {
const bass = ctx.createOscillator();
bass.type = 'triangle';
bass.frequency.value = f / 2;
const bg = envGain(ctx, t, 0.34, attack, decay * 1.8);
bass.connect(bg).connect(voiceBus);
bass.start(t);
bass.stop(t + attack + decay * 1.8 + 0.02);
}
}
return {
def,
out,
start(t0) {
wowLfo.start(t0);
crackle.start(t0);
lineWhine.start(t0);
nextTime = t0;
started = true;
},
update(now, lock, era) {
if (era !== eraNow) {
eraNow = era;
// The transmission ages even though the tune doesn't.
wowDepth.gain.value = era === 'reel' ? 0.0022 : 0;
crackleG.gain.value = era === 'reel' ? 0.055 : 0;
whineG.gain.value = era === 'broadcast' ? 0.012 : 0;
}
if (!started || lock <= 0.02) {
// Keep the clock rolling while off-station so the music is a continuous broadcast
// you tune INTO, not a track that starts when you arrive.
while (nextTime < now) { nextTime += spb; step = (step + 1) % m.steps; }
return;
}
while (nextTime < now + LOOKAHEAD) {
const semi = pattern[step];
if (semi >= 0) note(semi, nextTime, step % 4 === 0);
nextTime += spb;
step++;
if (step >= m.steps) {
step = 0;
bars++;
if (bars % m.mutateEveryBars === 0) mutate();
}
}
},
subtitle: () => '',
};
}
// ────────────────────────────────────────────────────────── AM 640 THE STANDARD
/**
* The Correction, with a transmitter.
*
* A ring-modulated mumble: a low carrier multiplied by a formant-swept tone, gated into
* syllables. It is deliberately just short of intelligible you can hear that it is speech
* and not what it says. THE SCREEN tells you what it says. That gap is the whole trick.
*/
export function amPropaganda(ctx: BaseAudioContext, def: StationDef): LiveStation {
const lines = def.chyron ?? [];
const out = ctx.createGain();
out.gain.value = 0;
// carrier * modulator = ring mod. The modulator is what makes it sound institutional.
const carrier = ctx.createOscillator();
carrier.type = 'sawtooth';
carrier.frequency.value = 118;
const ring = ctx.createGain();
ring.gain.value = 0; // driven by the modulator; 0 = silence between syllables
const modulator = ctx.createOscillator();
modulator.type = 'sine';
modulator.frequency.value = 61;
const modDepth = ctx.createGain();
modDepth.gain.value = 0.5;
modulator.connect(modDepth).connect(ring.gain);
// two formants: a mouth shape that never quite resolves into a vowel
const f1 = ctx.createBiquadFilter();
f1.type = 'bandpass';
f1.frequency.value = 520;
f1.Q.value = 4;
const f2 = ctx.createBiquadFilter();
f2.type = 'bandpass';
f2.frequency.value = 1180;
f2.Q.value = 6;
const syllable = ctx.createGain();
syllable.gain.value = 0;
carrier.connect(ring);
ring.connect(f1).connect(syllable);
ring.connect(f2).connect(syllable);
// AM band means AM bandwidth: nothing above ~4.5k survives the transmitter.
const band = ctx.createBiquadFilter();
band.type = 'lowpass';
band.frequency.value = 4500;
syllable.connect(band).connect(out);
let lineIdx = 0;
let nextSyllable = 0;
let syllablesLeft = 0;
let lineEndsAt = 0;
let started = false;
const rand = rng(6400);
function beginLine(t: number) {
lineIdx = (lineIdx + 1) % Math.max(1, lines.length);
const words = (lines[lineIdx] ?? '').split(/\s+/).length;
syllablesLeft = Math.max(6, Math.round(words * 1.6));
lineEndsAt = t + syllablesLeft * 0.17 + 1.4; // + the pause it leaves for effect
nextSyllable = t + 0.25;
}
return {
def,
out,
start(t0) {
carrier.start(t0);
modulator.start(t0);
started = true;
beginLine(t0);
},
update(now, lock) {
if (!started) return;
if (now > lineEndsAt) beginLine(now);
if (lock <= 0.02) return;
while (nextSyllable < now + LOOKAHEAD && syllablesLeft > 0) {
const t = nextSyllable;
const dur = 0.075 + rand() * 0.075;
// syllable envelope
syllable.gain.setValueAtTime(0.0001, t);
syllable.gain.exponentialRampToValueAtTime(0.5, t + 0.02);
syllable.gain.exponentialRampToValueAtTime(0.0001, t + dur);
// formant motion per syllable — the mouth moves, the meaning doesn't arrive
f1.frequency.setValueAtTime(380 + rand() * 320, t);
f2.frequency.setValueAtTime(950 + rand() * 700, t);
carrier.frequency.setValueAtTime(104 + rand() * 28, t);
nextSyllable = t + dur + 0.06 + rand() * 0.05;
syllablesLeft--;
}
},
// The flagship: the sky captions the radio.
subtitle: () => lines[lineIdx] ?? '',
};
}
// ─────────────────────────────────────────────────────────── SW 6955 NUMBERS
/**
* The unlisted station. Cold formant digits, no affect, no acknowledgement of a listener.
* Some groups are coordinates (LANE-DATA plants the real ones); the rest are decoys and
* seeded filler. The chyron shows the digits, so a player with a pencil can map the ground.
*/
export function swNumbers(
ctx: BaseAudioContext,
def: StationDef,
isRealSeam: (x: number, y: number) => boolean,
): LiveStation {
const n = def.numbers!;
const out = ctx.createGain();
out.gain.value = 0;
// A formant voice: buzz source + three resonances. Vowel-ish, sexless, entirely uninterested.
const buzz = ctx.createOscillator();
buzz.type = 'sawtooth';
buzz.frequency.value = 196;
const voiceGain = ctx.createGain();
voiceGain.gain.value = 0;
const fs = [720, 1240, 2600].map((f) => {
const b = ctx.createBiquadFilter();
b.type = 'bandpass';
b.frequency.value = f;
b.Q.value = 8;
return b;
});
const sum = ctx.createGain();
buzz.connect(voiceGain);
for (const f of fs) voiceGain.connect(f).connect(sum);
// SW bandwidth is narrow and awful
const band = ctx.createBiquadFilter();
band.type = 'bandpass';
band.frequency.value = 1400;
band.Q.value = 0.7;
sum.connect(band).connect(out);
const rand = rng(69550);
/** Vowel formants per digit so each numeral has a consistent, learnable shape. */
const DIGIT_FORMANTS: [number, number, number][] = [
[520, 1000, 2400], [300, 2200, 2900], [600, 1700, 2500], [500, 1750, 2450],
[640, 1200, 2300], [430, 2100, 2800], [560, 900, 2350], [400, 1900, 2600],
[700, 1150, 2400], [350, 2300, 3000],
];
function decodes(group: string): { x: number; y: number } | null {
if (group.length !== 5) return null;
const x = Number(group.slice(0, 2)) - 32;
const y = Number(group.slice(2, 4)) - 32;
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return { x, y };
}
/** Seeded filler that never accidentally gives away real ground. */
function fillerGroup(): string {
for (let tries = 0; tries < 24; tries++) {
const g = Array.from({ length: n.groupSize }, () => Math.floor(rand() * 10)).join('');
const d = decodes(g);
if (d && isRealSeam(d.x, d.y)) continue; // never leak a coordinate by accident
return g;
}
return '00000';
}
let messages = 0;
let queue: string[] = [];
let nextAt = 0;
let started = false;
let showing = '';
function buildMessage() {
const groups: string[] = [];
for (let i = 0; i < n.preambleRepeats; i++) groups.push(n.preamble);
const planted = messages % n.plantEveryMessages === 0;
for (let i = 0; i < n.groupsPerMessage; i++) {
if (planted && i === Math.floor(n.groupsPerMessage / 2)) {
groups.push(n.plantedGroups[(messages / n.plantEveryMessages) % n.plantedGroups.length]);
} else if (rand() < 0.35) {
groups.push(n.decoyPool[Math.floor(rand() * n.decoyPool.length)]);
} else {
groups.push(fillerGroup());
}
}
messages++;
queue = groups;
}
function speakGroup(group: string, t0: number) {
const per = n.secondsPerGroup / (n.groupSize + 1);
for (let i = 0; i < group.length; i++) {
const d = Number(group[i]);
const t = t0 + i * per;
const [a, b, c] = DIGIT_FORMANTS[d] ?? DIGIT_FORMANTS[0];
fs[0].frequency.setValueAtTime(a, t);
fs[1].frequency.setValueAtTime(b, t);
fs[2].frequency.setValueAtTime(c, t);
buzz.frequency.setValueAtTime(190 + d * 2, t); // barely any melody: no affect
voiceGain.gain.setValueAtTime(0.0001, t);
voiceGain.gain.exponentialRampToValueAtTime(0.34, t + 0.03);
voiceGain.gain.setValueAtTime(0.34, t + per * 0.6);
voiceGain.gain.exponentialRampToValueAtTime(0.0001, t + per * 0.92);
}
}
return {
def,
out,
start(t0) {
buzz.start(t0);
started = true;
nextAt = t0 + 0.5;
buildMessage();
},
update(now, lock) {
if (!started) return;
while (nextAt < now + LOOKAHEAD) {
if (!queue.length) buildMessage();
const g = queue.shift()!;
showing = g;
if (lock > 0.02) speakGroup(g, nextAt);
nextAt += n.secondsPerGroup;
}
},
// Digits on the sky — the treasure map, for anyone bothering to write them down.
subtitle: () => (showing ? showing.split('').join(' ') : ''),
};
}
// ──────────────────────────────────────────────────────────── 108.0 DEAD AIR
/**
* The top of the dial. Room tone from a room that should not have any, breathing at the pace
* of a projector, and once in a very long while a chime played backwards.
*/
export function deadAir(ctx: BaseAudioContext, def: StationDef): LiveStation {
const out = ctx.createGain();
out.gain.value = 0;
const tone = noiseSource(ctx, 'brown');
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 420;
const toneG = ctx.createGain();
// Deliberately well under a station: this is a room you strain to hear, not a channel.
// (Measured at 0.32 it rendered LOUDER than FKTRY FM, which made "dead air" the loudest
// thing on the dial — exactly backwards.)
toneG.gain.value = 0.1;
tone.connect(lp).connect(toneG).connect(out);
// the breathing: a slow swell at roughly a projector's pace
const breath = ctx.createOscillator();
breath.type = 'sine';
breath.frequency.value = 0.21;
const breathDepth = ctx.createGain();
breathDepth.gain.value = 0.05;
breath.connect(breathDepth).connect(toneG.gain);
let started = false;
let nextGhost = 0;
const ghostEvery = (def.ghost?.everyMinutes ?? 10) * 60;
/** A reversed chime: swell up out of nothing, then stop dead. Wrong-way-round on purpose. */
function ghost(t0: number) {
const dur = 2.2;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(0.28, t0 + dur); // the swell
g.gain.setValueAtTime(0.0001, t0 + dur + 0.01); // the cut
g.connect(out);
[523.25, 783.99, 1046.5].forEach((f, i) => {
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.value = f * (1 + i * 0.001);
o.connect(g);
o.start(t0);
o.stop(t0 + dur + 0.05);
});
}
return {
def,
out,
start(t0) {
tone.start(t0);
breath.start(t0);
started = true;
// seeded, so the ghost always arrives at the same moment in a given run
nextGhost = t0 + ghostEvery * (0.35 + hash01(1080) * 0.4);
},
update(now, lock) {
if (!started || lock <= 0.02) return;
if (now >= nextGhost) {
ghost(now + 0.05);
nextGhost = now + ghostEvery;
}
},
subtitle: () => '',
};
}

View File

@ -0,0 +1,23 @@
/**
* The subtitle bus THE SPEAKER captions THE SCREEN.
*
* The flagship cross-organ trick (round 5 order 6): when the radio is tuned to a talking
* station, the sky's chyron subtitles the audio. Both organs are LANE-SCREEN, so this is a
* sanctioned in-lane channel: THE SPEAKER (src/audio) writes, THE SCREEN (src/screen) reads.
* A module-level singleton keeps it out of the contract main.ts never has to marshal it.
*
* Deliberately trivial and dependency-free so both sides can import it without coupling to
* each other's guts.
*/
let current = '';
/** Set the line the sky should be showing (''/empty clears it). Called by the tuned station. */
export function setSubtitle(line: string): void {
current = line ?? '';
}
/** Read the current caption. Called by THE SCREEN each frame when building its chyron. */
export function getSubtitle(): string {
return current;
}

View File

@ -1,25 +1,34 @@
/**
* THE SCREEN's base content: procedural "clean programming".
* Sterile SMPTE-ish test pattern + camcorder overlay, ported from the prototype's
* makeBaseTexture and widened to 16:9. This is what the factory's output is painted
* ONTO it must be bland enough that any corruption reads instantly.
*
* Cost control: the pattern is static, so we only repaint when a visible glyph changes
* (timecode ticks ~1/sec, chyron toggles once). Motion comes from the shader's slow
* drift, not from re-uploading 640x360 pixels every frame.
* A sterile SMPTE-ish test pattern + camcorder overlay, ported from the prototype and widened
* to 16:9. This is what the factory's output is painted ONTO bland enough that any corruption
* reads instantly. Round 5: the SUBSTRATE ages with the highest ratified era (eras.ts); the
* corruption on top stays era-agnostic.
*
* Cost control: the pattern is static, so we only repaint when a visible input changes (timecode
* ~1/sec, era, chyron text). Motion comes from the shader's slow drift, not from re-uploading
* 640x360 pixels every frame.
*/
import { eraSkin, type Era, type EraSkin } from './eras';
export const BASE_W = 640;
export const BASE_H = 360;
export interface BaseState {
timeMs: number;
era: Era;
/** chyron line: 'NOTHING IS WRONG' idle era, a radio subtitle when tuned, or '' for none */
chyron: string;
}
export interface BaseFeed {
canvas: HTMLCanvasElement;
/** Repaint if anything visible changed. Returns true when the texture needs re-upload. */
update(timeMs: number, idle: boolean): boolean;
update(state: BaseState): boolean;
}
function timecode(timeMs: number): string {
// Tape-style running timecode, starting at a suspiciously specific point.
const total = Math.floor(timeMs / 1000) + 754; // 0:12:34
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
@ -27,29 +36,77 @@ function timecode(timeMs: number): string {
return `SP ${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function applyTreatment(g: CanvasRenderingContext2D, skin: EraSkin) {
const t = skin.treatment;
if (t.kind === 'none') return;
g.save();
if (t.kind === 'sepia') {
// warm the whole frame + gate-weave sprocket holes down both edges
g.globalCompositeOperation = 'overlay';
g.fillStyle = 'rgba(150,110,60,0.28)';
g.fillRect(0, 0, BASE_W, BASE_H);
g.globalCompositeOperation = 'source-over';
g.fillStyle = '#0d0904';
g.fillRect(0, 0, 22, BASE_H);
g.fillRect(BASE_W - 22, 0, 22, BASE_H);
g.fillStyle = '#efe2c0';
for (let y = 6; y < BASE_H; y += 34) {
g.fillRect(5, y, 12, 18);
g.fillRect(BASE_W - 17, y, 12, 18);
}
} else if (t.kind === 'phosphor') {
// green cathode wash + heavy interlace lines
g.globalCompositeOperation = 'multiply';
g.fillStyle = 'rgba(120,255,150,0.35)';
g.fillRect(0, 0, BASE_W, BASE_H);
g.globalCompositeOperation = 'source-over';
g.fillStyle = 'rgba(0,0,0,0.28)';
for (let y = 0; y < BASE_H; y += 2) g.fillRect(0, y, BASE_W, 1);
} else if (t.kind === 'gloss') {
// rainbow pit-and-land sheen sweeping across, plus a specular band
const grad = g.createLinearGradient(0, 0, BASE_W, BASE_H);
grad.addColorStop(0.0, 'rgba(255,0,180,0.10)');
grad.addColorStop(0.35, 'rgba(0,220,255,0.06)');
grad.addColorStop(0.7, 'rgba(120,255,120,0.10)');
grad.addColorStop(1.0, 'rgba(255,180,0,0.08)');
g.globalCompositeOperation = 'screen';
g.fillStyle = grad;
g.fillRect(0, 0, BASE_W, BASE_H);
g.fillStyle = 'rgba(255,255,255,0.10)';
g.fillRect(0, BASE_H * 0.2, BASE_W, 26);
g.globalCompositeOperation = 'source-over';
} else if (t.kind === 'bleach') {
// wash toward blinding white, kill contrast
g.fillStyle = 'rgba(250,250,250,0.6)';
g.fillRect(0, 0, BASE_W, BASE_H);
}
g.restore();
}
export function createBaseFeed(): BaseFeed {
const canvas = document.createElement('canvas');
canvas.width = BASE_W;
canvas.height = BASE_H;
const g = canvas.getContext('2d')!;
let lastTC = '';
let lastIdle: boolean | null = null;
let last = '';
function paint(tc: string, idle: boolean) {
const bars = ['#c0c0c0', '#c0c000', '#00c0c0', '#00c000', '#c000c0', '#c00000', '#0000c0'];
const bw = BASE_W / bars.length;
bars.forEach((col, i) => {
function paint(state: BaseState) {
const skin = eraSkin(state.era);
g.fillStyle = skin.bg;
g.fillRect(0, 0, BASE_W, BASE_H);
const bw = BASE_W / skin.bars.length;
skin.bars.forEach((col, i) => {
g.fillStyle = col;
g.fillRect(i * bw, 0, bw + 1, BASE_H * 0.62);
});
// castellations strip
const casts = ['#0000c0', '#131313', '#c000c0', '#131313', '#00c0c0', '#131313', '#c0c0c0'];
casts.forEach((col, i) => {
skin.casts.forEach((col, i) => {
g.fillStyle = col;
g.fillRect(i * bw, BASE_H * 0.62, bw + 1, BASE_H * 0.1);
});
// pluge / ramp
g.fillStyle = '#101010';
g.fillStyle = skin.bg;
g.fillRect(0, BASE_H * 0.72, BASE_W, BASE_H * 0.28);
const ramp = g.createLinearGradient(0, 0, BASE_W, 0);
ramp.addColorStop(0, '#000');
@ -58,36 +115,38 @@ export function createBaseFeed(): BaseFeed {
g.fillRect(BASE_W * 0.55, BASE_H * 0.8, BASE_W * 0.4, 18);
// camcorder overlay
g.fillStyle = '#eee';
g.fillStyle = skin.ink;
g.font = "bold 22px 'Courier New', monospace";
g.fillText('PLAY ▶', 20, BASE_H * 0.86);
g.fillText(tc, 20, BASE_H * 0.95);
g.fillText(timecode(state.timeMs), 20, BASE_H * 0.95);
g.font = "bold 18px 'Courier New', monospace";
g.fillStyle = '#f4f4f4';
g.fillText('WIMVEE FKTRY', BASE_W * 0.55, BASE_H * 0.95);
g.fillStyle = '#ff4444';
g.fillStyle = skin.accent;
g.beginPath();
g.arc(BASE_W - 26, BASE_H * 0.83, 7, 0, Math.PI * 2);
g.fill();
// Idle chyron: OSHA-poster deadpan. Disappears forever once the factory ships.
if (idle) {
applyTreatment(g, skin);
// Chyron band. Carries 'NOTHING IS WRONG' while idle, or a radio subtitle when tuned.
// Painted AFTER the era treatment so captions stay legible on any substrate.
if (state.chyron) {
g.fillStyle = 'rgba(8,8,12,0.82)';
g.fillRect(0, BASE_H * 0.5, BASE_W, 34);
g.fillStyle = '#d8ffd8';
g.font = "bold 20px 'Courier New', monospace";
g.fillText('NOTHING IS WRONG', 18, BASE_H * 0.5 + 24);
g.fillText(state.chyron, 18, BASE_H * 0.5 + 24);
}
}
return {
canvas,
update(timeMs: number, idle: boolean) {
const tc = timecode(timeMs);
if (tc === lastTC && idle === lastIdle) return false;
lastTC = tc;
lastIdle = idle;
paint(tc, idle);
update(state: BaseState) {
// Cache key of every visible input; repaint only when one changes.
const key = `${timecode(state.timeMs)}|${state.era}|${state.chyron}`;
if (key === last) return false;
last = key;
paint(state);
return true;
},
};

View File

@ -45,3 +45,35 @@ describe('boredom decays the display, slowly', () => {
expect(freshness(-10)).toBe(1);
});
});
describe('jammed-rot: stuck work spoils faster than resting work', () => {
const IDLE = BOREDOM_GRACE_SECONDS + 60;
it('leaves a clean idle exactly as gentle as before', () => {
// rot defaults to 0, so round 4 behaviour is untouched for a player who walked away.
expect(freshness(IDLE, 0)).toBe(freshness(IDLE));
});
it('forgets faster the more of the factory is jammed', () => {
const clean = freshness(IDLE, 0);
const half = freshness(IDLE, 0.5);
const solid = freshness(IDLE, 1);
expect(half).toBeLessThan(clean);
expect(solid).toBeLessThan(half);
});
it('still gives a fully jammed factory a grace window', () => {
// Even total gridlock should not punish instantly — a jam you fix in a few seconds is free.
expect(freshness(10, 1)).toBe(1);
});
it('makes a wholly-stuck line sag in tens of seconds, not minutes', () => {
expect(freshness(90, 1)).toBeLessThan(0.5);
expect(freshness(90, 0)).toBeGreaterThan(0.8); // ...while a clean idle has barely moved
});
it('clamps nonsense rot values instead of inverting the curve', () => {
expect(freshness(IDLE, -5)).toBe(freshness(IDLE, 0));
expect(freshness(IDLE, 99)).toBe(freshness(IDLE, 1));
});
});

View File

@ -20,8 +20,18 @@ const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
/**
* How much of the ledger's corruption is still on display, 0..1.
* 1 = everything you earned is showing; 0 = the sky has forgotten (the tallies have not).
*
* `rot` (round 5 jammed-rot) accelerates forgetting. A CLEAN idle the player walked away
* decays at the base rate. A factory JAMMED SOLID rots faster: the work isn't resting, it's
* spoiling, and the sky should say so. rot 0 = clean idle; rot 1 = everything jammed.
*/
export function freshness(idleSeconds: number): number {
export function freshness(idleSeconds: number, rot = 0): number {
if (!(idleSeconds > BOREDOM_GRACE_SECONDS)) return 1; // NaN-safe: unknown idle reads as fresh
return 1 - clamp01((idleSeconds - BOREDOM_GRACE_SECONDS) / BOREDOM_HORIZON_SECONDS);
// A jammed factory shortens the grace and steepens the decay. At full rot the horizon is
// roughly a third, so a wholly-stuck line visibly sags in tens of seconds, not minutes.
const r = clamp01(rot);
const grace = BOREDOM_GRACE_SECONDS * (1 - 0.5 * r);
const horizon = BOREDOM_HORIZON_SECONDS * (1 - 0.66 * r);
if (idleSeconds <= grace) return 1;
return 1 - clamp01((idleSeconds - grace) / horizon);
}

View File

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { ERA_ORDER, eraSkin, highestEra, type Era } from './eras';
const eraOf = (t: string): Era | undefined =>
({ a: 'reel', b: 'broadcast', c: 'disc', d: 'stream', e: 'fortress' } as Record<string, Era>)[t];
describe('era skins cover the codex', () => {
it('has a distinct skin for every era', () => {
const bars = new Set<string>();
for (const era of ERA_ORDER) {
const skin = eraSkin(era);
expect(skin.bars).toHaveLength(7); // the substrate keeps SMPTE structure; only palette ages
expect(skin.casts).toHaveLength(7);
bars.add(skin.bars.join(','));
}
expect(bars.size).toBe(ERA_ORDER.length); // no two eras look alike
});
it('is deterministic — the same era always paints the same substrate', () => {
expect(eraSkin('disc')).toEqual(eraSkin('disc'));
});
it('runs oldest to newest, reel first and fortress last', () => {
expect(ERA_ORDER[0]).toBe('reel');
expect(ERA_ORDER[ERA_ORDER.length - 1]).toBe('fortress');
});
});
describe('highest ratified era', () => {
it('starts in the deepest stratum when nothing is ratified', () => {
expect(highestEra([], eraOf)).toBe('reel');
});
it('takes the newest era present, not the last listed', () => {
expect(highestEra(['c', 'a', 'b'], eraOf)).toBe('disc');
expect(highestEra(['e', 'a'], eraOf)).toBe('fortress');
});
it('ignores tech it cannot place rather than falling over', () => {
// LANE-DATA can add tech ahead of me; unknown ids must not crash or reset the era.
expect(highestEra(['b', 'who-knows'], eraOf)).toBe('broadcast');
});
it('never goes backwards as more tech is ratified', () => {
let prev = -1;
const acc: string[] = [];
for (const t of ['a', 'b', 'c', 'd', 'e']) {
acc.push(t);
const idx = ERA_ORDER.indexOf(highestEra(acc, eraOf));
expect(idx).toBeGreaterThanOrEqual(prev);
prev = idx;
}
});
});

104
fktry/src/screen/eras.ts Normal file
View File

@ -0,0 +1,104 @@
/**
* Era skins for the clean programming (round 5 Part A).
*
* THE SCREEN's SUBSTRATE ages with the highest ratified era; the corruption on top stays
* era-agnostic. Media history is geology (codex §1), so the test pattern itself is a fossil
* of whatever format the player has climbed to. Codex §8 era skins drive the palette.
*
* Pure and data-only so it is trivially unit-testable and carries no DOM the base feed
* consumes an EraSkin, it does not decide one.
*/
export type Era = 'reel' | 'broadcast' | 'disc' | 'stream' | 'fortress';
/** Deepest → shallowest, i.e. oldest → newest. "Highest ratified" = last of these unlocked. */
export const ERA_ORDER: readonly Era[] = ['reel', 'broadcast', 'disc', 'stream', 'fortress'];
export interface EraSkin {
/** the seven SMPTE bars, era-recoloured */
bars: string[];
/** the castellation strip under the bars */
casts: string[];
/** backdrop behind the pattern (matters where bars don't cover) */
bg: string;
/** overlay text + timecode colour */
ink: string;
/** rec-dot / accent colour */
accent: string;
/**
* A full-canvas post-treatment applied after the pattern is drawn. Kept as a tagged union
* (not a callback) so it stays pure data the base feed interprets it.
*/
treatment:
| { kind: 'none' }
| { kind: 'sepia'; sprockets: true } // reel: warm film, sprocket holes
| { kind: 'phosphor' } // broadcast: green cast + interlace
| { kind: 'gloss' } // disc: rainbow-on-black sheen
| { kind: 'bleach' }; // fortress: blinding matte white
}
const STREAM_BARS = ['#c0c0c0', '#c0c000', '#00c0c0', '#00c000', '#c000c0', '#c00000', '#0000c0'];
const STREAM_CASTS = ['#0000c0', '#131313', '#c000c0', '#131313', '#00c0c0', '#131313', '#c0c0c0'];
const SKINS: Record<Era, EraSkin> = {
reel: {
bars: ['#b9a67c', '#c7b06a', '#9c9a72', '#8f9a63', '#a98c6e', '#a06a4e', '#6e5c46'],
casts: ['#5a4a34', '#241d14', '#6e5240', '#241d14', '#5c5a44', '#241d14', '#b9a67c'],
bg: '#181008',
ink: '#f0e0c0',
accent: '#d8a24a',
treatment: { kind: 'sepia', sprockets: true },
},
broadcast: {
// NTSC bars, but everything sunk into a phosphor-green cathode glow.
bars: ['#8fce8f', '#b6c664', '#4fb7b7', '#4fb44f', '#8a5aa8', '#9a4a4a', '#3a4fb0'],
casts: ['#12300f', '#0a140a', '#2a3a20', '#0a140a', '#1a3a30', '#0a140a', '#8fce8f'],
bg: '#03120a',
ink: '#c8ffce',
accent: '#8fffa0',
treatment: { kind: 'phosphor' },
},
disc: {
// Rigid, glossy, rainbow-on-black. DVD menu energy.
bars: ['#d0d0d0', '#d8d800', '#00d8d8', '#00d800', '#d800d8', '#e00000', '#2020ff'],
casts: ['#2020ff', '#000000', '#d800d8', '#000000', '#00d8d8', '#000000', '#d0d0d0'],
bg: '#000000',
ink: '#ffffff',
accent: '#ff3fd4',
treatment: { kind: 'gloss' },
},
stream: {
// The surface world / the original prototype look — the game's "now".
bars: STREAM_BARS,
casts: STREAM_CASTS,
bg: '#101010',
ink: '#f4f4f4',
accent: '#ff4444',
treatment: { kind: 'none' },
},
fortress: {
// The DCP citadel: mathematically perfect, blinding matte white, near-invisible bars.
bars: ['#f7f7f7', '#f2f2e8', '#e8f2f2', '#e8f2e8', '#f2e8f2', '#f2e8e8', '#e8e8f4'],
casts: ['#e6e6ee', '#f4f4f4', '#eee6ee', '#f4f4f4', '#e6eeee', '#f4f4f4', '#f7f7f7'],
bg: '#fbfbfb',
ink: '#20242a',
accent: '#2f7d3f',
treatment: { kind: 'bleach' },
},
};
/** The highest ratified era from a set of unlocked tech, given a tech→era lookup. */
export function highestEra(unlocked: readonly string[], eraOf: (tech: string) => Era | undefined): Era {
let best = 0; // default: reel (the world starts in the deepest stratum you can reach)
for (const t of unlocked) {
const e = eraOf(t);
if (e === undefined) continue;
const idx = ERA_ORDER.indexOf(e);
if (idx > best) best = idx;
}
return ERA_ORDER[best];
}
export function eraSkin(era: Era): EraSkin {
return SKINS[era];
}

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