Back to Pursuit PIT
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.

src/game/radio.ts
// 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;
  }
}