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/main.ts
import { KEYS_DOMAIN, STORAGE_DOMAIN, keys, storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { settings, watchSettings } from './napplet-settings.js';
import { Sound } from './game/audio.js';
import { Gearbox } from './game/engine.js';
import { Radio } from './game/radio.js';
import { Renderer } from './game/render.js';
import { Recorder, type Replay } from './game/replay.js';
import { GETAWAY_GAP, Sim, forwardSpeed, type Input, type Outcome } from './game/sim.js';
import { setRoadSeed } from './game/road.js';
import { randomSuspectColor } from './game/colors.js';
import { Music } from './game/music.js';
import { drawTitleScene } from './title-scene.js';
import { drawTimeCard } from './time-cards.js';
import type { TimeOfDay } from './game/render.js';
import { DEFAULT_GARAGE, fitGetawayCar, parseGarage } from './game/garage.js';
import { GarageMenu } from './garage-menu.js';
import { createControllerInput, mixDriving, type ControllerAction, type Direction } from './controller-input.js';
import './styles.css';

/** Pursuit: chase and PIT the suspect. Free drive: just you and the traffic. */
// Getaway: you're the one running, from eight police cars.
type Mode = 'pursuit' | 'free' | 'getaway';

// After a PIT the chase runs on briefly ('pitting') so the spin finishes, then loops a replay.
// 'garage': customising the cars, opened from the menu.
type Phase = 'menu' | 'playing' | 'pitting' | 'replay' | 'paused' | 'over' | 'garage';

const STEP = 1 / 120;
const AFTER_PIT = 2.5; // seconds of chase recorded after the suspect spins out
const BEST_KEY = 'pursuit-pit.best-time';
const TRAFFIC_DENSITY = { light: 0.45, normal: 0.75, heavy: 1 };
// Test aids for screenshots: ?seed=N picks the road, ?start=M starts M metres down it.
const params = new URLSearchParams(location.search);
const FIXED_SEED = params.has('seed') ? Number(params.get('seed')) : null;
const START_AT = Number(params.get('start')) || 0;

const $ = <T extends HTMLElement>(selector: string): T => {
  const element = document.querySelector<T>(selector);
  if (!element) throw new Error(`Missing required element: ${selector}`);
  return element;
};

const ui = {
  view: $<HTMLDivElement>('#view'),
  clock: $<HTMLSpanElement>('#clock'),
  lights: $<HTMLDivElement>('#lights'),
  callout: $<HTMLOutputElement>('#callout'),
  overlay: $<HTMLElement>('#overlay'),
  kicker: $<HTMLParagraphElement>('#overlayKicker'),
  title: $<HTMLParagraphElement>('#overlayTitle'),
  body: $<HTMLParagraphElement>('#overlayBody'),
  best: $<HTMLParagraphElement>('#best'),
  titleScreen: $<HTMLElement>('#title'),
  modePursuit: $<HTMLButtonElement>('#modePursuit'),
  pursuitText: $<HTMLSpanElement>('#pursuitText'),
  titleBest: $<HTMLSpanElement>('#titleBest'),
  start: $<HTMLButtonElement>('#startButton'),
  free: $<HTMLButtonElement>('#freeButton'),
  getaway: $<HTMLButtonElement>('#getawayButton'),
  policeGap: $<HTMLSpanElement>('#policeGap'),
  menu: $<HTMLButtonElement>('#menuButton'),
  app: $<HTMLElement>('#app'),
  replayBanner: $<HTMLDivElement>('#replayBanner'),
  slowmo: $<HTMLSpanElement>('#slowmo'),
  radioLine: $<HTMLDivElement>('#radioLine'),
  radioWho: $<HTMLSpanElement>('#radioWho'),
  radioText: $<HTMLSpanElement>('#radioText'),
};

const renderer = new Renderer(ui.view);
const sound = new Sound();
// The getaway car's stereo (V): music instead of the police radio.
const music = new Music(() => sound.stereo());
const radio = new Radio(sound, (who, text) => {
  ui.radioLine.hidden = !text;
  ui.radioWho.textContent = who;
  ui.radioText.textContent = text;
});
let gearbox = new Gearbox();
// The next suspect's paint: named on the start card, and on the car behind it in attract mode.
let nextColor = randomSuspectColor();
const briefing = () => `${nextColor[0].toUpperCase() + nextColor.slice(1)} sedan fleeing on the interstate`;
ui.title.textContent = briefing();
let sim = new Sim(TRAFFIC_DENSITY[settings.traffic], true, nextColor);
let recorder = new Recorder();
let replay: Replay | null = null;
let mode: Mode = 'pursuit';
let pitTime = 0;
let muted = false;
let nearMissTimer = 0;
let phase: Phase = 'menu';
let bestTime: number | null = null;
let calloutTimer = 0;

// ---- garage: how the patrol car and the getaway car look, remembered in storage
const GARAGE_KEY = 'pursuit-pit.garage';
let garageFrom: Phase = 'menu';
renderer.garage = structuredClone(DEFAULT_GARAGE);
const garageMenu = new GarageMenu($<HTMLElement>('#garage'), renderer.garage, (spec) => {
  renderer.garage = spec;
  void saveGarage();
}, closeGarage);
$<HTMLButtonElement>('#garageButton').addEventListener('click', openGarage);
$<HTMLButtonElement>('#titleGarage').addEventListener('click', openGarage);

function openGarage(): void {
  garageFrom = phase;
  phase = 'garage';
  releaseAll();
  radio.stop();
  ui.overlay.hidden = true;
  ui.titleScreen.hidden = true;
  ui.app.classList.add('in-garage');
  garageMenu.open();
}

function closeGarage(): void {
  phase = garageFrom;
  garageMenu.close();
  ui.app.classList.remove('in-garage');
  renderer.rebuildCars(); // the cars on the road behind the menu get the new look too
  if (phase === 'menu') {
    showTitle();
    return;
  }
  ui.overlay.hidden = false;
  ui.start.focus({ preventScroll: true });
}

async function loadGarage(): Promise<void> {
  if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
  try {
    const spec = parseGarage(await storage.getItem(GARAGE_KEY));
    Object.assign(renderer.garage, spec);
    renderer.rebuildCars();
  } catch {
    // Storage may refuse in some hosts; the stock cars it is.
  }
}

async function saveGarage(): Promise<void> {
  if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
  try {
    await storage.setItem(GARAGE_KEY, JSON.stringify(renderer.garage));
  } catch {
    // Not fatal: the choices last for this session.
  }
}

// ---- time of day: picked after the mode; restarting keeps it
const timePicker = $<HTMLElement>('#timePicker');
const timeCards = [...document.querySelectorAll<HTMLButtonElement>('.time-card')];
const MODE_NAMES: Record<Mode, string> = { pursuit: 'Pursuit', getaway: 'Getaway', free: 'Free drive' };
let timeOfDay: TimeOfDay = 'day';
let timeMode: Mode = 'pursuit';
let timeFromOverlay = false;

function drawTimeCards(): void {
  for (const card of timeCards) drawTimeCard(card.querySelector('canvas')!, card.dataset.time as TimeOfDay);
}

/** Show the three times of day for the mode just picked. */
function chooseTime(next: Mode): void {
  releaseAll();
  timeMode = next;
  timeFromOverlay = !ui.overlay.hidden;
  $<HTMLParagraphElement>('#timeMode').textContent = MODE_NAMES[next];
  ui.overlay.hidden = true;
  timePicker.hidden = false;
  drawTimeCards();
  timeCards.find((c) => c.dataset.time === timeOfDay)?.focus({ preventScroll: true });
}

/** Back to wherever the mode was picked: the title screen (still underneath) or the game-over card. */
function closeTime(): void {
  releaseAll();
  timePicker.hidden = true;
  if (timeFromOverlay) {
    ui.overlay.hidden = false;
    ui.start.focus({ preventScroll: true });
  } else {
    ui.modePursuit.focus({ preventScroll: true });
  }
}

for (const card of timeCards) {
  card.addEventListener('click', () => {
    timeOfDay = card.dataset.time as TimeOfDay;
    timePicker.hidden = true;
    renderer.setTimeOfDay(timeOfDay);
    startPursuit(timeMode);
  });
}
$<HTMLButtonElement>('#timeBack').addEventListener('click', closeTime);

// ---- input: separate controller values are merged with keyboard/touch at readInput.
const controller = createControllerInput();
window.addEventListener('pagehide', () => controller.dispose(), { once: true });

/** Controller buttons run the same actions as the keys and the shell's NAP-KEYS shortcuts. */
function performAction(action: ControllerAction): void {
  if (action === 'pause') togglePause();
  else if (action === 'mute') toggleMute();
  else if (action === 'radio') audioButton();
  else if (action === 'siren') sirenButton();
  else if (action === 'confirm') toggleWipers();
}
const held = new Set<string>();
const KEYMAP: Record<string, string> = {
  KeyW: 'gas', ArrowUp: 'gas', KeyS: 'brake', ArrowDown: 'brake',
  KeyA: 'left', ArrowLeft: 'left', KeyD: 'right', ArrowRight: 'right',
};

window.addEventListener('keydown', (event) => {
  if (phase === 'replay') {
    // Any key ends the replay, except the driving keys (you're likely still holding or
    // tapping them from the chase), and nothing in its first second.
    if (!event.repeat && !KEYMAP[event.code] && replayStarted()) endReplay();
    event.preventDefault();
    return;
  }
  if (event.ctrlKey || event.altKey || event.metaKey) return;
  if (phase === 'garage') {
    garageMenu.key(event); // the garage's own keys; none of the driving ones
    return;
  }
  if (!timePicker.hidden) {
    // ←/→ move between the pictures (Enter on one presses it), Esc goes back.
    const step = event.code === 'ArrowLeft' || event.code === 'KeyA' ? -1 : event.code === 'ArrowRight' || event.code === 'KeyD' ? 1 : 0;
    if (step) {
      const at = timeCards.indexOf(document.activeElement as HTMLButtonElement);
      timeCards[(Math.max(0, at) + step + timeCards.length) % timeCards.length].focus({ preventScroll: true });
      event.preventDefault();
    } else if (event.code === 'Escape') {
      closeTime();
      event.preventDefault();
    }
    return;
  }
  const control = KEYMAP[event.code];
  if (control) {
    held.add(control);
    event.preventDefault();
  }
  // The keys shim (when injected) runs first, in the capture phase on document: it
  // preventDefaults a key the shell has bound to one of our actions and runs that
  // action, so skip it here rather than toggling it a second time.
  if (event.repeat || event.defaultPrevented) return;
  if (event.code === 'KeyP' || event.code === 'Escape') togglePause();
  else if (event.code === 'KeyM') toggleMute();
  else if (event.code === 'KeyV') audioButton();
  else if (event.code === 'KeyH') sirenButton();
  else if (event.code === 'KeyE') toggleWipers();
  else if (event.code === 'KeyR') restart();
  else if (event.code === 'KeyF' && (phase === 'menu' || phase === 'over')) chooseTime('free');
  else if (event.code === 'KeyG' && (phase === 'menu' || phase === 'over')) chooseTime('getaway');
  else if ((event.code === 'Enter' || event.code === 'Space') && phase !== 'playing'
    && !(event.target instanceof HTMLButtonElement) && !(event.target instanceof HTMLElement && event.target.tagName === 'SUMMARY')) {
    event.preventDefault();
    if (phase === 'paused') togglePause();
    else if (phase === 'menu') chooseTime('pursuit');
    else startPursuit(mode); // again, at the same time of day
  }
});
window.addEventListener('keyup', (event) => {
  keyRepeat = false;
  const control = KEYMAP[event.code];
  if (control) held.delete(control);
});
// A tap or click also ends the replay.
window.addEventListener('pointerdown', () => {
  if (phase === 'replay' && replayStarted()) endReplay();
});
const releaseAll = () => {
  held.clear();
  controller.reset();
  document.querySelectorAll('.touch .held').forEach((b) => b.classList.remove('held'));
};
window.addEventListener('blur', () => {
  releaseAll();
  if (phase === 'playing') togglePause();
});
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    releaseAll();
    if (phase === 'playing') togglePause();
  }
});

for (const button of document.querySelectorAll<HTMLButtonElement>('[data-touch]')) {
  const control = button.dataset.touch!;
  const press = (event: PointerEvent) => {
    event.preventDefault();
    button.setPointerCapture(event.pointerId);
    if (phase === 'menu' || phase === 'over') startPursuit();
    held.add(control);
    button.classList.add('held');
  };
  const release = () => {
    held.delete(control);
    button.classList.remove('held');
  };
  button.addEventListener('pointerdown', press);
  button.addEventListener('pointerup', release);
  button.addEventListener('pointercancel', release);
}

function toggleMute(): void {
  muted = !muted;
  applyVolume();
  callout(muted ? 'SOUND OFF' : 'SOUND ON', 0.8);
}

/** V: the police radio in a patrol car, the stereo in the getaway car. */
function audioButton(): void {
  if (mode === 'getaway' && phase !== 'menu') {
    callout(music.cycle(), 1.2);
    return;
  }
  radio.enabled = !radio.enabled;
  if (!radio.enabled) radio.stop();
  callout(radio.enabled ? 'RADIO ON' : 'RADIO OFF', 0.8);
}

function sirenButton(): void {
  if (mode === 'getaway' && phase !== 'menu') {
    callout('NO SIREN IN THIS CAR', 0.8);
    return;
  }
  sound.siren = !sound.siren;
  callout(sound.siren ? 'SIREN ON' : 'SIREN OFF', 0.8);
}

function restart(): void {
  if (phase !== 'menu') startPursuit();
}

// ---- NAP-KEYS: the one-shot shortcuts as named actions the shell can list, rebind
// and trigger. Driving stays local (held keys need keyup; actions only fire on press),
// and the letters keep working without the domain.
const ACTIONS = [
  { id: 'game.pause', label: 'Pause / resume', defaultKey: 'p', run: togglePause },
  { id: 'sound.mute', label: 'Mute', defaultKey: 'm', run: toggleMute },
  { id: 'car.radio', label: 'Radio / car stereo', defaultKey: 'v', run: audioButton },
  { id: 'car.siren', label: 'Siren', defaultKey: 'h', run: sirenButton },
  { id: 'car.wipers', label: 'Wipers', defaultKey: 'e', run: toggleWipers },
  { id: 'game.restart', label: 'Restart', defaultKey: 'r', run: restart },
];

// The shim fires a bound action on every auto-repeated keydown; a held key toggles once.
let keyRepeat = false;
window.addEventListener('keydown', (event) => { keyRepeat = event.repeat; }, true);

/** The garage, the time picker and the replay have their own keys; the shortcuts wait. */
function canRunShortcut(): boolean {
  return phase !== 'replay' && phase !== 'garage' && timePicker.hidden;
}

async function registerShortcuts(): Promise<void> {
  if (!runtimeHasDomain(KEYS_DOMAIN)) return;
  for (const { run, ...action } of ACTIONS) {
    try {
      await keys.register(action, () => {
        if (!keyRepeat && canRunShortcut()) run();
      });
    } catch {
      // Refused, duplicate or timed out: that key still works locally.
    }
  }
}

/** Windscreen wipers: the only way to get the blood off the glass. */
const wipeButton = $<HTMLButtonElement>('#wipeButton');
function toggleWipers(): void {
  renderer.wipers = !renderer.wipers;
  wipeButton.classList.toggle('on', renderer.wipers);
  callout(renderer.wipers ? 'WIPERS ON' : 'WIPERS OFF', 0.8);
}

wipeButton.addEventListener('pointerdown', (event) => {
  event.preventDefault();
  toggleWipers();
});

function readInput(): Input {
  return mixDriving(held, controller.driving);
}

ui.start.addEventListener('click', () => {
  if (phase === 'paused') togglePause();
  else startPursuit(phase === 'over' ? mode : 'pursuit');
});
ui.free.addEventListener('click', () => chooseTime('free'));
ui.modePursuit.addEventListener('click', () => chooseTime('pursuit'));
$<HTMLButtonElement>('#modeGetaway').addEventListener('click', () => chooseTime('getaway'));
$<HTMLButtonElement>('#modeFree').addEventListener('click', () => chooseTime('free'));
ui.getaway.addEventListener('click', () => chooseTime('getaway'));
ui.menu.addEventListener('click', showMenu);

/** Focus only actionable elements in the current napplet screen. */
function navigateMenu(root: HTMLElement, direction: Direction): void {
  const items = [...root.querySelectorAll<HTMLElement>('button:not(:disabled), summary')]
    .filter((el) => el.getClientRects().length > 0);
  if (!items.length) return;
  const at = items.indexOf(document.activeElement as HTMLElement);
  const step = direction === 'up' || direction === 'left' ? -1 : 1;
  const next = items[at < 0 ? 0 : (at + step + items.length) % items.length];
  next.focus({ preventScroll: true });
  next.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}

function pollController(now: number): void {
  const input = controller.poll(now);
  for (const el of document.querySelectorAll<HTMLElement>('.controller-status')) {
    if (el.textContent !== input.status) el.textContent = input.status;
  }
  if (input.lost) {
    if (phase === 'playing') togglePause();
    callout('CONTROLLER DISCONNECTED', 2);
    return;
  }
  if (input.joined) { callout('CONTROLLER CONNECTED', 1.5); return; }
  const { pressed, navigation } = input;
  if (phase === 'playing') {
    // A pause must not also trigger another action or resume within this frame.
    if (pressed.has('pause')) { performAction('pause'); return; }
    for (const action of ['confirm', 'siren', 'radio', 'mute'] as const) {
      if (pressed.has(action)) performAction(action);
    }
    return;
  }
  if (phase === 'pitting') return;
  if (phase === 'replay') {
    if (pressed.has('confirm') && replayStarted()) { endReplay(); controller.reset(); }
    return;
  }
  if (phase === 'garage') {
    if (pressed.has('back') || pressed.has('pause')) { closeGarage(); controller.reset(); return; }
    if (pressed.has('previousCar') || pressed.has('nextCar')) garageMenu.switchCar();
    else if (navigation) garageMenu.navigate(navigation);
    else if (pressed.has('confirm')) garageMenu.confirm();
    return;
  }
  if (!timePicker.hidden && pressed.has('back')) { closeTime(); return; }
  if (phase === 'paused' && (pressed.has('back') || pressed.has('pause'))) { togglePause(); return; }
  const root = !timePicker.hidden ? timePicker : phase === 'menu' ? ui.titleScreen : ui.overlay;
  const details = root.querySelector<HTMLDetailsElement>('details[open]');
  if (pressed.has('back') && details) { details.open = false; controller.reset(); return; }
  if (navigation) navigateMenu(root, navigation);
  if (pressed.has('confirm')) {
    const focused = document.activeElement;
    if (focused instanceof HTMLElement && root.contains(focused) && focused.getClientRects().length
      && !focused.matches(':disabled')) {
      if (focused.tagName === 'SUMMARY') {
        const owner = focused.parentElement as HTMLDetailsElement;
        owner.open = !owner.open;
      } else focused.click();
      controller.reset();
    }
  }
}

// ---- game flow
function applyVolume(): void {
  sound.setVolume(settings.volume / 100, muted || settings.volume === 0);
}

function startPursuit(next: Mode = mode): void {
  releaseAll();
  mode = next;
  sound.start();
  applyVolume();
  setRoadSeed(FIXED_SEED ?? Math.random() * 2 ** 32); // a new stretch of highway every pursuit
  // Getaway: lighter traffic (60%), so the chase has room to run.
  const density = TRAFFIC_DENSITY[settings.traffic] * (mode === 'getaway' ? 0.6 : 1);
  sim = new Sim(density, mode === 'pursuit', nextColor, mode === 'getaway');
  // The getaway car is the one from the garage; the patrol car's look is all in the renderer.
  renderer.playerIsPatrol = mode !== 'getaway';
  if (mode === 'getaway') fitGetawayCar(sim.player, renderer.garage);
  if (START_AT) for (const car of sim.cars) car.s += START_AT;
  nextColor = randomSuspectColor();
  gearbox = new Gearbox();
  recorder = new Recorder();
  replay = null;
  phase = 'playing';
  ui.overlay.hidden = true;
  ui.titleScreen.hidden = true;
  timePicker.hidden = true;
  ui.app.classList.toggle('free-drive', mode === 'free');
  ui.app.classList.toggle('getaway', mode === 'getaway');
  radio.reset();
  if (mode !== 'getaway') music.off();
  if (mode === 'getaway') {
    // A civilian car: no police radio, no siren, just the stereo if you want it.
    callout('GETAWAY!', 1.5);
    sound.siren = false;
    radio.enabled = false;
  } else if (mode === 'pursuit') {
    callout('PURSUIT!', 1.5);
    sound.siren = true;
    radio.enabled = settings.radio;
    radio.start(sim.suspectColor);
  } else {
    // No chase to call: the radio only speaks up for bird strikes, and the siren is off until you want it (H).
    callout('FREE DRIVE', 1.5);
    sound.siren = false;
    radio.enabled = settings.radio;
  }
}

/** Back to the start screen to pick a mode. */
function showMenu(): void {
  releaseAll();
  phase = 'menu';
  radio.stop();
  music.off();
  sound.sirenDistance = null;
  sound.suspend(); // nothing plays behind the title screen
  showTitle();
}

const titleCanvas = $<HTMLCanvasElement>('#titleScene');
// The backdrop is a still picture: redrawn only when the frame changes size.
let titleResize = 0;
window.addEventListener('resize', () => {
  clearTimeout(titleResize);
  titleResize = window.setTimeout(() => {
    if (!ui.titleScreen.hidden) drawTitleScene(titleCanvas);
    if (!timePicker.hidden) drawTimeCards();
  }, 120);
});

/** The title screen: three modes, the garage and the controls. Nothing runs behind it. */
function showTitle(): void {
  controller.reset();
  ui.overlay.hidden = true;
  ui.titleScreen.hidden = false;
  drawTitleScene(titleCanvas);
  const article = /^[aeiou]/.test(nextColor) ? 'An' : 'A';
  ui.pursuitText.textContent = `${article} ${nextColor} sedan is fleeing. PIT it off the road before it gets 700 m ahead.`;
  renderBest();
  ui.modePursuit.focus({ preventScroll: true });
}

function togglePause(): void {
  releaseAll();
  if (phase === 'playing') {
    phase = 'paused';
    radio.stop();
    sound.suspend();
    if (mode === 'pursuit') showOverlay('PAUSED', 'Pursuit on hold', 'The suspect waits for no one… except you, right now.', 'Resume');
    else if (mode === 'getaway') showOverlay('PAUSED', 'Getaway on hold', 'The cops are still back there. Catch your breath.', 'Resume');
    else showOverlay('PAUSED', 'Free drive paused', 'Take a breather. The traffic will wait.', 'Resume');
  } else if (phase === 'paused') {
    phase = 'playing';
    sound.start();
    ui.overlay.hidden = true;
  }
}

/** The replay has been on long enough that a key or tap means "skip it". */
let replayAt = 0;
const replayStarted = () => performance.now() - replayAt > 1000;

function startReplay(): void {
  replayAt = performance.now();
  replay = recorder.replay(sim.lastRearHit);
  phase = 'replay';
  releaseAll();
  ui.app.classList.add('replaying');
  ui.replayBanner.hidden = false;
  ui.callout.classList.remove('show');
}

function endReplay(): void {
  replay = null;
  ui.app.classList.remove('replaying');
  ui.replayBanner.hidden = true;
  finish('pit', pitTime);
  // The tap that ended the replay must not also land on the button that just appeared under it.
  ui.start.disabled = true;
  setTimeout(() => (ui.start.disabled = false), 450);
}

function finish(outcome: Outcome, time = sim.time): void {
  if (mode === 'getaway') {
    phase = 'over';
    sound.sirenDistance = null;
    releaseAll();
    if (outcome === 'busted') {
      showOverlay('BUSTED', "You're under arrest", `The police caught you after ${formatTime(time)}. Keep moving, and watch your rear quarters.`, 'Try again');
    } else {
      const how = outcome === 'disabled' ? `All eight police cars are out of the chase after ${formatTime(time)}.`
        : `You left the police ${GETAWAY_GAP} m behind in ${formatTime(time)}.`;
      showOverlay('YOU GOT AWAY', outcome === 'disabled' ? 'Police disabled' : 'Outran the police', how, 'Run again');
    }
    return;
  }
  if (outcome !== 'pit') radio.outcome(outcome); // a PIT was already called on the radio
  phase = 'over';
  sound.siren = sound.siren && outcome !== 'escaped';
  releaseAll();
  if (outcome === 'escaped') {
    showOverlay('DISPATCH', 'Suspect escaped', `They got more than 700 m ahead after ${formatTime(time)}. Stay closer next time.`, 'Try again');
    return;
  }
  const isBest = bestTime === null || time < bestTime;
  if (isBest) {
    bestTime = time;
    void saveBest(time);
  }
  const how = outcome === 'pit'
    ? `Clean PIT in ${formatTime(time)}.`
    : `The suspect lost control and crashed after ${formatTime(time)}. A PIT is cleaner.`;
  showOverlay('SUSPECT STOPPED', outcome === 'pit' ? 'PIT maneuver successful' : 'Suspect crashed',
    how + (isBest ? ' New best time!' : ''), 'New pursuit');
}

function showOverlay(kicker: string, title: string, body: string, button: string): void {
  controller.reset();
  ui.kicker.textContent = kicker;
  ui.title.textContent = title;
  ui.body.textContent = body;
  ui.start.textContent = button;
  // Paused: resume or go back to the menu. Otherwise: pick pursuit or free drive.
  ui.free.hidden = phase === 'paused';
  ui.getaway.hidden = phase === 'paused';
  ui.menu.hidden = phase !== 'paused';
  $<HTMLButtonElement>('#garageButton').hidden = phase === 'paused'; // not mid-chase
  renderBest();
  ui.overlay.hidden = false;
  ui.start.focus({ preventScroll: true });
}

function callout(text: string, seconds: number): void {
  ui.callout.textContent = text;
  ui.callout.classList.add('show');
  calloutTimer = seconds;
}

function formatTime(t: number): string {
  const m = Math.floor(t / 60);
  const s = Math.floor(t % 60);
  return `${m}:${String(s).padStart(2, '0')}`;
}

// ---- best time (optional storage domain; the game works without it)
function renderBest(): void {
  ui.best.textContent = bestTime === null ? '' : `Best stop: ${formatTime(bestTime)}`;
  ui.titleBest.textContent = bestTime === null ? '' : `Best stop ${formatTime(bestTime)}`;
}

async function loadBest(): Promise<void> {
  if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
  try {
    const raw = await storage.getItem(BEST_KEY);
    const value = raw === null ? NaN : Number(raw);
    if (Number.isFinite(value) && value > 0) bestTime = value;
    renderBest();
  } catch {
    // Storage may refuse in some hosts; the best time just isn't remembered.
  }
}

async function saveBest(time: number): Promise<void> {
  if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
  try {
    await storage.setItem(BEST_KEY, time.toFixed(2));
  } catch {
    // Not fatal: the best time stays in memory for this session.
  }
}

// ---- HUD
function updateHud(): void {
  ui.clock.textContent = formatTime(sim.time);
  if (mode === 'getaway') {
    // How far back the nearest police car is, and how many are still after you.
    const gap = sim.policeGap, left = sim.police.filter((c) => !c.wrecked).length;
    ui.policeGap.textContent = !Number.isFinite(gap) ? 'NONE' : gap < 0 ? 'AHEAD!' : `${Math.round(gap)} m · ${left}`;
  }
  const flash = Math.floor(performance.now() / 180) % 2;
  // The light bar only flashes while the siren is on.
  ui.lights.className = sound.siren ? `lights ${flash ? 'on-a' : 'on-b'}` : 'lights';
}


// ---- main loop
let last = performance.now();
let acc = 0;
let resizeW = 0;
let resizeH = 0;

function frame(now: number): void {
  const dt = Math.min(0.1, (now - last) / 1000);
  last = now;
  pollController(now);

  const w = ui.view.clientWidth, h = ui.view.clientHeight;
  if (w !== resizeW || h !== resizeH) {
    resizeW = w;
    resizeH = h;
    renderer.resize(w, h);
  }

  if (phase === 'garage') {
    // The game behind the menu waits; the car being customised turns on the turntable.
    renderer.renderGarage(garageMenu.which, dt, now / 1000);
    requestAnimationFrame(frame);
    return;
  }

  if (phase === 'menu') {
    requestAnimationFrame(frame);
    return;
  }

  if (phase === 'playing' || phase === 'pitting') {
    acc += dt;
    // Once the suspect is down the player's hands come off the wheel while it plays out.
    const input = phase === 'playing' ? readInput() : { throttle: 0, steer: 0 };
    sim.sirenOn = sound.siren;
    // Getaway: the chasing sirens get louder as the nearest police car closes in.
    const behind = sim.policeGap;
    sound.sirenDistance = mode === 'getaway' && Number.isFinite(behind) ? Math.abs(behind) : null;
    let steps = 0;
    while (acc >= STEP && steps++ < 12) {
      sim.step(STEP, input);
      acc -= STEP;
    }
    acc = Math.min(acc, STEP);
    recorder.record(sim);
    renderer.handleEvents(sim.events, sim.cars);
    for (const event of sim.events) {
      radio.onEvent(sim, event);
      const ours = (event.type === 'bump' || event.type === 'pit' || event.type === 'wall') && (event.type === 'wall' || Math.hypot(event.x - sim.player.x, event.s - sim.player.s) < 6);
      if (ours) renderer.bump(event.strength);
      if (event.type === 'bird' && event.car === sim.player.id) renderer.bump(3);
      sound.event(sim, event);
      if (event.type === 'pit') {
        if (mode !== 'getaway') callout('PIT!', 1.2);
        else callout(event.car === sim.player.id ? "YOU'VE BEEN PIT'D!" : 'COP SPUN OUT!', 1.2);
      }
    }
    sim.events.length = 0;
    nearMissTimer -= dt;
    if (nearMissTimer <= 0) {
      nearMissTimer = 0.25;
      // Cutting in close in front of traffic gets you honked at.
      const p = sim.player;
      for (const car of sim.cars) {
        if (car.kind !== 'traffic' || car.wrecked) continue;
        const ahead = p.s - car.s;
        if (ahead > 2 && ahead < 9 && Math.abs(p.x - car.x) < 2.2 && forwardSpeed(car) - forwardSpeed(p) > 3) sound.nearMiss(sim, car);
      }
    }
    if (sim.gap > 550 && calloutTimer <= 0 && sim.outcome === 'running') callout('LOSING SUSPECT', 1.2);
    if (phase === 'pitting') {
      if (sim.time >= pitTime + AFTER_PIT) startReplay();
    } else if (sim.outcome === 'pit') {
      phase = 'pitting';
      pitTime = sim.time;
      radio.outcome('pit');
      callout('SUSPECT DOWN!', AFTER_PIT);
    } else if (sim.outcome !== 'running') {
      finish(sim.outcome);
    }
  }

  if (calloutTimer > 0) {
    calloutTimer -= dt;
    if (calloutTimer <= 0) ui.callout.classList.remove('show');
  }

  if (replay) {
    const events = replay.advance(dt);
    const state = replay.state();
    renderer.handleEvents(events, state.cars, true);
    for (const event of events) sound.event({ player: renderer.replayListener }, event);
    sound.silence();
    radio.update(dt, sim, false);
    renderer.setRadio('CH2 PURSUIT', radio.speaker);
    ui.slowmo.hidden = !replay.slowMotion;
    renderer.render(state, dt, now / 1000, gearbox, settings.units, replay.camera());
    requestAnimationFrame(frame);
    return;
  }
  gearbox.update(forwardSpeed(sim.player), phase === 'playing' ? sim.input.throttle : 0, dt);
  if (phase === 'over') sound.silence(); // the chase is over: engine and road go quiet
  else sound.update(sim, gearbox, dt, phase === 'playing' || phase === 'pitting');
  radio.update(dt, sim, phase === 'playing' && mode === 'pursuit');
  if (mode === 'getaway') renderer.setRadio(music.playing ? music.label : 'RADIO OFF', '', music.playing ? 'STEREO' : 'V TO TUNE');
  else renderer.setRadio(radio.enabled ? 'CH2 PURSUIT' : 'RADIO OFF', radio.speaker);
  sim.scrape = 0;
  sim.rumble = false;
  renderer.mirror = settings.mirror;
  renderer.render(sim, dt, now / 1000, gearbox, settings.units);
  updateHud();
  requestAnimationFrame(frame);
}

watchSettings(() => {
  updateHud();
  applyVolume();
});
showTitle(); // start on the title screen
void loadBest();
void loadGarage();
void registerShortcuts();
requestAnimationFrame(frame);