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.
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);
