SOURCE / PINNED RELEASE
Made of little things.
Pursuit PIT
- Release
- 565f4981bdc3…
- Author-recorded commit
- 197afdbed44b…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-951414e7fd7
Archive hash verified: 3b0485589942fd5c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Police radio traffic for the pursuit: Dispatch, the player's unit (14), the helicopter
// (Air 1), a backup unit and the sergeant. Lines react to what's happening in the chase.
// The voices are recorded clips (radio-script.ts, rendered offline with Piper's neural voices),
// stitched together where a line needs a mile marker or a speed, and played through a radio
// filter with squelch, static and alert tones from Sound. Every line is also subtitled.
import voicesUrl from '../assets/radio-voices.bin?inline';
import type { Sound } from './audio.js';
import { decodeAdpcm } from './adpcm.js';
import radioIndex from './radio-index.json';
import type { SuspectColor } from './colors.js';
import { CLIPS, FAST, MILES, SPEEDS, type Speaker } from './radio-script.js';
import { mileAt } from './signs.js';
/** What each speaker's mic picks up around them. */
const SCENE: Record<Speaker, 'siren' | 'car' | 'heli' | 'room'> = {
DISPATCH: 'room',
'UNIT 14': 'siren',
'AIR 1': 'heli',
'UNIT 9': 'siren',
'SGT 20': 'car',
};
import { MPS_TO_MPH, RAMP_EVERY, forwardSpeed, laneX, rampAt, type Sim, type SimEvent } from './sim.js';
interface Line {
who: Speaker;
clips: string[];
alert?: boolean;
}
const pick = <T>(options: T[]): T => options[Math.floor(Math.random() * options.length)];
let voiceBytes: Uint8Array | null = null;
const decoded = new Map<string, Float32Array>();
/** Samples for a clip, decoded on first use. */
function clipSamples(key: string): Float32Array | null {
const entry = (radioIndex.clips as Record<string, number[]>)[key];
if (!entry) return null;
let samples = decoded.get(key);
if (!samples) {
if (!voiceBytes) {
const b64 = atob(voicesUrl.slice(voicesUrl.indexOf(',') + 1));
voiceBytes = new Uint8Array(b64.length);
for (let i = 0; i < b64.length; i++) voiceBytes[i] = b64.charCodeAt(i);
}
samples = decodeAdpcm(voiceBytes, entry[0], entry[1]);
decoded.set(key, samples);
}
return samples;
}
export class Radio {
enabled = true;
private queue: Line[] = [];
private talking: Line | null = null;
private gap = 0;
private chatter = 6;
private recent: string[] = [];
private cooldown = new Map<string, number>();
private endsAt = 0; // wall-clock seconds; audio runs in real time even if frames are slow
private wrecksCalled = new Set<string>(); // civilian crashes and fires already called in
private touched = new Set<number>(); // cars the patrol car has hit
private stopVoice: (() => void) | null = null;
constructor(private sound: Sound, private show: (who: string, text: string) => void) {}
/** Who is on the air right now (for the dash radio display). */
get speaker(): string {
return this.talking?.who ?? '';
}
reset(): void {
this.stop();
this.chatter = 7;
this.recent = [];
this.cooldown.clear();
this.wrecksCalled.clear();
this.touched.clear();
}
stop(): void {
this.queue = [];
if (this.talking) this.endTransmission();
}
private say(who: Speaker, clips: string[], alert = false, urgent = false): void {
if (!this.enabled) return;
const line = { who, clips, alert };
if (urgent) this.queue.unshift(line);
else if (this.queue.length < 4) this.queue.push(line);
}
private once(key: string, seconds: number): boolean {
const until = this.cooldown.get(key) ?? 0;
if (until > performance.now() / 1000) return false;
this.cooldown.set(key, performance.now() / 1000 + seconds);
return true;
}
/** The suspect's speed, rounded to the nearest recorded value (at least `min`). */
private speed(sim: Sim, min = 0): number {
const mph = Math.max(min, Math.abs(forwardSpeed(sim.suspect)) * MPS_TO_MPH);
return SPEEDS.filter((s) => s >= min).reduce((a, b) => (Math.abs(b - mph) < Math.abs(a - mph) ? b : a));
}
/** The suspect's paint, for Dispatch's description; set when a pursuit starts. */
private color: SuspectColor = 'orange';
/** Opening call when a pursuit starts. */
start(color: SuspectColor): void {
this.color = color;
this.say('DISPATCH', ['d.start', `d.start2.${color}`], true);
this.say('UNIT 14', [pick(['u.start1', 'u.start2'])]);
}
/** React to a sim event. */
onEvent(sim: Sim, e: SimEvent): void {
// The radio says nothing about the patrol car hitting civilians: it only remembers which
// cars it touched, so their wrecks and fires don't get called in either.
if (e.type === 'bump' && e.other && e.other.kind !== 'suspect') this.touched.add(e.other.id);
// Air 1 calls in civilian crashes and fires, in both modes.
const civilian = (e.type === 'crash' ? e.other?.id : e.type === 'explode' ? e.car : undefined);
if (civilian !== undefined && civilian !== sim.suspect.id) {
this.civilianCrash(sim, civilian, e.type === 'explode');
return;
}
// Free drive: otherwise the radio only speaks up about bird strikes.
if (!sim.pursuit && e.type !== 'birdsWarn' && e.type !== 'birds' && e.type !== 'bird') return;
if (e.type === 'explode') {
if (e.car === sim.suspect.id) this.say('AIR 1', ['a.fire'], false, true);
} else if (e.type === 'pit') {
this.say('UNIT 14', ['u.pit'], false, true);
} else if (e.type === 'birdsWarn') {
this.say('DISPATCH', [pick(['d.birds1', 'd.birds2'])], true, true);
} else if (e.type === 'birds') {
this.say('AIR 1', ['a.birds'], false, true);
} else if (e.type === 'bird' && e.car === sim.player.id && this.once('birdhit', 20)) {
this.say('UNIT 14', [pick(['u.birdhit1', 'u.birdhit2'])]);
}
}
/**
* "Air one, a civilian has crashed at mile marker 78. Requesting EMS and fire." A car that
* catches fire gets "The car is burning, we need EMS and fire, immediately!" instead (even
* if its crash was already called in). Cars the patrol car hit aren't reported.
*/
private civilianCrash(sim: Sim, id: number, burning: boolean): void {
if (this.touched.has(id)) return;
const key = `${id}:${burning ? 'fire' : 'crash'}`;
if (this.wrecksCalled.has(key)) return;
this.wrecksCalled.add(key);
if (!burning && !this.once('civcrash', 8)) return; // a pile-up is one call, not five
const mile = mileAt(sim.player.s);
const where = mile >= MILES.first && mile <= MILES.last ? ['a.civ', `a.mile${mile}`] : ['a.civ.near'];
this.say('AIR 1', [...where, burning ? 'a.civ.burning' : 'a.civ.ems'], false, burning);
}
outcome(outcome: string): void {
this.queue = [];
if (outcome === 'pit') {
this.say('UNIT 14', ['u.done'], false, true);
this.say('DISPATCH', ['d.pit']);
} else if (outcome === 'crash') {
this.say('AIR 1', ['a.crash'], false, true);
this.say('DISPATCH', ['d.crash']);
} else if (outcome === 'escaped') {
this.say('UNIT 14', ['u.lost'], false, true);
this.say('AIR 1', ['a.lost']);
this.say('DISPATCH', [`d.escape.${this.color}`]);
}
}
/** Background chatter between events, picked from what's true right now. */
private pickChatter(sim: Sim): Line | null {
const gap = sim.gap;
const mph = Math.abs(forwardSpeed(sim.suspect)) * MPS_TO_MPH;
const t = sim.suspect;
const lane = t.x < laneX(1) + 1 ? 'left' : t.x > laneX(4) - 1 ? 'right' : 'middle';
const options: [string, Speaker, string[]][] = [
['status', 'UNIT 14', [pick(['u.status1', 'u.status2', 'u.status3']), `u.speed${this.speed(sim)}`]],
['clear', 'DISPATCH', ['d.clear']],
['eye', 'AIR 1', [`a.eye.${lane}`]],
['backup', 'UNIT 9', [pick(['n.backup2', 'n.backup3', 'n.backup4'])]],
['stolen', 'DISPATCH', ['d.stolen']],
['pitok', 'SGT 20', ['s.pitok']],
['spikes', 'DISPATCH', ['d.spikes']],
['moving', 'AIR 1', ['a.moving']],
];
if (gap < 50 && gap > 0) options.push(['bumper', 'UNIT 14', [pick(['u.bumper1', 'u.bumper2'])]]);
if (gap > 350) {
options.push(['losing', 'UNIT 14', ['u.losing']]);
options.push(['airgap', 'AIR 1', ['a.gap']]);
}
if (mph > FAST - 5) options.push(['fast', 'AIR 1', [`a.fast${this.speed(sim, FAST)}`]]);
let nextRamp = rampAt(t.s);
if (nextRamp < t.s) nextRamp += RAMP_EVERY;
if (nextRamp - t.s < 600) options.push(['ramp', 'AIR 1', ['a.ramp']]);
const fresh = options.filter(([key]) => !this.recent.includes(key));
const choice = fresh[Math.floor(Math.random() * fresh.length)];
if (!choice) return null;
this.recent.push(choice[0]);
if (this.recent.length > 5) this.recent.shift();
return { who: choice[1], clips: choice[2] };
}
update(dt: number, sim: Sim, active: boolean): void {
if (!this.enabled) return;
if (active) {
this.chatter -= dt;
if (this.chatter <= 0 && !this.talking && !this.queue.length) {
const line = this.pickChatter(sim);
if (line) this.queue.push(line);
// Sometimes a reply follows straight away.
if (line?.who === 'UNIT 14' && Math.random() < 0.6) this.queue.push({ who: 'DISPATCH', clips: [pick(['d.copy1', 'd.copy2', 'd.copy3'])] });
this.chatter = 7 + Math.random() * 9;
}
}
if (this.talking) {
if (performance.now() / 1000 >= this.endsAt) this.endTransmission();
return;
}
this.gap -= dt;
if (this.gap <= 0 && this.queue.length) this.transmit(this.queue.shift()!);
}
private transmit(line: Line): void {
this.talking = line;
const subtitle = line.clips
.map((key) => CLIPS[key]?.show ?? CLIPS[key]?.say ?? '')
.join(' ')
// Digits read separately are shown as one number ("7 8" -> "78").
.replace(/(\d) (?=\d)/g, '$1')
.replace(/(\d) (?=[A-Z])/g, '$1. ');
const lead = line.alert ? 0.9 : 0;
if (line.alert) this.sound.radioAlert();
const samples = line.clips.map(clipSamples).filter((s): s is Float32Array => s !== null);
const voice = this.sound.radioVoice(samples, radioIndex.rate, lead + 0.12, SCENE[line.who]);
this.stopVoice = voice?.stop ?? null;
// Without audio, keep the subtitle up for a reading-speed duration.
const length = voice?.duration ?? subtitle.split(' ').length * 0.38;
this.endsAt = performance.now() / 1000 + lead + 0.12 + length + 0.15;
setTimeout(() => {
if (this.talking !== line) return;
this.sound.radioSquelch(true);
this.sound.radioCarrier(true);
this.show(line.who, subtitle);
}, lead * 1000);
}
private endTransmission(): void {
this.talking = null;
this.stopVoice?.();
this.stopVoice = null;
this.sound.radioCarrier(false);
this.sound.radioSquelch(false);
this.show('', '');
this.gap = 0.5 + Math.random() * 0.8;
}
}
