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