Back to Big Rig Rush
SOURCE / PINNED RELEASE

Made of little things.

Big Rig Rush

Release
ec9b6549f752…
Author-recorded commit
74b729258b1a…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-0a99f0cae40

Archive hash verified: 900e22810340ccb1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/game.ts
import type { Controls } from '../input.js';
import { City, DISTRICT_RECTS, MAP_H, MAP_W, type Structure, type Material, SURF_DIRT, SURF_GRASS, SURF_ROAD, SURF_SIDEWALK, SURF_WATER, type District, type Spot } from './city.js';
import { Effects, type Ground } from './effects.js';
import { angleDiff, clamp, poseAt, type Vec } from './geometry.js';
import { MapRenderer, renderMinimap } from './paint.js';
import { Wildlife } from './animals.js';
import { Dispatcher, type Repairable } from './dispatch.js';
import { Helicopters } from './helicopters.js';
import { Mob, type Building } from './mob.js';
import { Missiles } from './missiles.js';
import type { BlastKind } from './audio.js';
import { BOMB_NAMES, Airstrike, type BombKind, type Launch, type Lift } from './airstrike.js';
import { GUN_RECOIL, METERS } from './a10.js';
import { drawCityStatus, drawFlightHud, type CityStatus, type FlightHud } from './hud.js';
import { Civil } from './civil.js';
import { Raider, type RaiderTarget } from './raider.js';
import { Services, type ServiceKind, type ServiceVehicle } from './services.js';
import { Street } from './street.js';
import { Workers } from './workers.js';
import { Pedestrians, type Ped } from './peds.js';
import { boxContact, resolveContact, type Body } from './physics.js';
import { Traffic, inView, type Car, type CrashKind, type View } from './traffic.js';
import { Truck, type SurfaceFeel, type VehicleKind } from './truck.js';
import { drawA10, drawA10Shadow } from './a10-model.js';
import { COLORS, drawCar, drawDozer, drawTruck, makeLook, type CarDamage, type CarLook } from './vehicles.js';

export type Phase = 'title' | 'playing' | 'paused' | 'over';
export type JobKind = 'pickup' | 'deliver';
export type Tone = 'good' | 'info' | 'bad';

interface Job {
  kind: JobKind;
  spot: Spot;
  progress: number;
  damageAtStart: number;
  distance: number;
}

interface Floater {
  x: number;
  y: number;
  text: string;
  color: string;
  life: number;
  scale?: number;
}

export interface GameOptions {
  carCount: number;
  rudeness: number;
  shake: boolean;
  pedCount: number;
  animalCount: number;
}

export interface GameEvents {
  toast(text: string, tone: Tone): void;
  over(): void;
  /** Impact intensity scales with closing speed: ~0.15 scuff, 1 wreck, 2+ disaster. */
  crash?(intensity: number, kind: CrashKind, pan: number): void;
  /** An AI driver honks or shouts near the truck. */
  honk?(rude: boolean, pan: number): void;
  pickup?(): void;
  delivered?(points: number): void;
  totaled?(): void;
  shift?(gear: number): void;
  grind?(): void;
  pedHit?(pan: number): void;
  /** A wrecked car's alarm near the player (called about twice a second). */
  alarm?(pan: number): void;
  /** A fuel tank or the truck exploding. */
  explosion?(size: number, pan: number): void;
  /** A tumbling car's roof or side slamming the road. */
  rollHit?(energy: number, pan: number): void;
  /** The rig's wheels lift in a corner, and when it goes over. */
  tipping?(): void;
  /** Signature sound for the kind of vehicle that was hit. */
  carFlavor?(model: string, intensity: number, pan: number): void;
  animalHit?(kind: string, mass: number, pan: number): void;
  stallSmash?(pan: number): void;
  /** Music thumping from a party near the player; volume 0..1. */
  partyBeat?(pan: number, volume: number): void;
  /** A siren from an emergency vehicle near the player; volume 0..1. */
  siren?(kind: ServiceKind, pan: number, volume: number): void;
  /** The tow crew at work (winch and wrench). */
  towWork?(pan: number): void;
  /** A vehicle smashing into a building (masonry, not the vehicle). */
  structureHit?(impact: number, pan: number): void;
  hose?(pan: number): void;
  /** A missile's warhead going off. */
  missileHit?(pan: number): void;
  /** A bomb's blast: the biggest boom there is. */
  bigBlast?(pan: number, size: number): void;
  /** An explosion or collapse, with how far it is from the listener (map units). */
  blastSound?(kind: BlastKind, pan: number, distance: number): void;
  /** How loud the fires around the listener roar, 0..1. */
  fireLevel?(level: number): void;
  /** A big fire crackling and roaring nearby; volume 0..1. */
  fire?(volume: number): void;
  /** A bomb leaving the rack (a falling whistle). */
  bombDrop?(pan: number): void;
  /** A missile leaving the launcher. */
  missile?(pan: number): void;
  /** A helicopter's rotor thumping overhead; volume 0..1. */
  rotor?(pan: number, volume: number): void;
  /** A crew at work with tools (wrenches, compactor, jackhammer). */
  tools?(pan: number): void;
  /** A lamp post, hydrant or trash can knocked over. */
  knock?(kind: string, pan: number): void;
  /** One beat of rave music from a rave near the player. */
  raveBeat?(pan: number, volume: number): void;
  /** The crash drama tier (1 CRUNCH … 4 APOCALYPSE). */
  dramaTier?(tier: number, pan: number): void;
  rollover?(): void;
  /** The ballute on a high-drag bomb pops open. */
  ballute?(): void;
  /** A click in the cockpit: weapon selected, or an empty rack. */
  click?(): void;
  /** Ordnance loaded back aboard. */
  rearmed?(): void;
  /** The city's air-raid sirens wind up. */
  airRaid?(): void;
  /** A surface-to-air missile leaves its rail; a missile is tracking the jet (called each frame). */
  samLaunch?(pan: number): void;
  missileWarning?(on: boolean): void;
  /** Anti-aircraft guns hammering away, and their shells hitting the jet. */
  aaGuns?(pan: number, distance: number): void;
  jetHit?(): void;
  /** Flares popping. */
  flares?(): void;
  /** The jet's been shot down and hits the ground. */
  jetDown?(): void;
  /** The AI A-10 roaring past overhead (volume 0..1), and its gun heard from the ground. */
  flyby?(pan: number, volume: number): void;
  brrt?(pan: number, distance: number): void;
}


const NO_INPUT: Controls = { up: false, down: false, left: false, right: false, gun: false, throttleUp: false, throttleDown: false, bomb: false };
const LOAD_RADIUS = 24;
const LOAD_SPEED = 12;
const LOAD_SECONDS = 1.2;
/** Map units to metres for the HUD (the rig is about 18 m long). */
export const METERS_PER_UNIT = 0.33;
const VIEW_SPAN = 300;
/**
 * The A-10's camera hangs above the jet looking straight down: the jet, nearer the lens,
 * looks GROUND_SPREAD times bigger than the ground. JET_SPAN is how many map units span
 * the short side of the screen at the jet's own height.
 */
const JET_SPAN = 440;
const GROUND_SPREAD = 2.2;
const PICKUP = '#4ade80';
const DROP = '#ff8a3d';
const DANGER = '#ff4d5e';
const INSURANCE = 50;

export const DISTRICT_NAMES: Record<District, string> = {
  downtown: 'DOWNTOWN',
  uptown: 'UPTOWN',
  hood: 'THE HOOD',
  port: 'THE PORT',
  highway: 'THE INTERSTATE',
  country: 'THE COUNTRY',
  village: 'MILLBROOK',
  industrial: 'INDUSTRIAL PARK',
  docks: 'THE DOCKS',
  factory: 'THE STEELWORKS',
  rail: 'THE RAIL YARD',
  suburb: 'MAPLE GROVE',
  commercial: 'THE STRIP',
  military: 'FORT DUNMORE',
  rural: 'THE BACKCOUNTRY',
  town: 'HARLAN',
};

/**
 * How each kind of ground drives. Asphalt grips; a sidewalk is nearly as good but the
 * curbs jolt; packed dirt lots are loose, draggy and rutted; grass is slippery, soft and
 * lumpy; and the shallows at the shore bog you down with almost no grip.
 */
const FEEL: Record<number, SurfaceFeel> = {
  [SURF_ROAD]: { grip: 1, drag: 0, bump: 0 },
  [SURF_SIDEWALK]: { grip: 0.92, drag: 0.006, bump: 0.15 },
  [SURF_DIRT]: { grip: 0.62, drag: 0.035, bump: 0.6 },
  [SURF_GRASS]: { grip: 0.5, drag: 0.055, bump: 0.8 },
  [SURF_WATER]: { grip: 0.3, drag: 0.3, bump: 0.4 },
};

const pick = <T>(items: readonly T[]): T => items[Math.floor(Math.random() * items.length)];
const range = (a: number, b: number) => a + Math.random() * (b - a);

export type TowChoice = 'repair' | 'reposition' | 'both';

const MOB = '#b06cff';
const MAX_FAVORS = 3;

const DISPATCH_TOASTS: Partial<Record<ServiceKind | 'medevac' | 'chopper', string>> = {
  fire: 'Fire crews responding',
  ambulance: 'Ambulance on the way',
  police: 'Police responding',
  tow: 'Tow truck on the way to clear the wreck',
  medevac: 'Medevac helicopter inbound',
  chopper: 'Police helicopter overhead',
};

/** Market stalls and party gear: things you can plough through. */
interface Prop {
  kind: 'stall' | 'speaker' | 'table' | 'keg' | 'stage';
  body: Body;
  colors: [string, string];
  produce: string[];
  broken: boolean;
  venue: number;
}

/** Vehicles with a flat, tall front that shove people down instead of scooping them up. */
const FLAT_FRONT = new Set(['bus', 'van', 'pickup', 'icecream']);
const DAY_SECONDS = 240;

function inHours(hour: number, from: number, to: number): boolean {
  const h = to > 24 && hour < from ? hour + 24 : hour;
  return h >= from && h < to;
}

const DRAMA: [number, string, string][] = [
  [30, 'CRUNCH!', '#ffd84a'],
  [62, 'WRECKED!', '#ff8a3d'],
  [100, 'CARNAGE!!', '#ff4d5e'],
  [145, 'APOCALYPSE!!!', '#ff2bd6'],
];

export class Game {
  phase: Phase = 'title';
  score = 0;
  deliveries = 0;
  overReason = '';
  readonly city = new City();
  readonly effects = new Effects();
  readonly traffic: Traffic;
  readonly peds: Pedestrians;
  truck: Truck;
  job: Job;
  private readonly mapRenderer: MapRenderer;
  private time = 0;
  private shake = 0;
  private slowmo = 0;
  private dying = -1;
  private exhaust = 0;
  private alarmTick = 0;
  private lastWallHit = 0;
  private lastHint = -10;
  private rolledTime = 0;
  private flash = 0;
  private lastDrama = -10;
  private beat = 0;
  /** Time of day in hours (0-24). A full day takes DAY_SECONDS of real time. */
  hour = 7.5;
  private lightCanvas: HTMLCanvasElement | null = null;
  private pendingBlasts: { t: number; x: number; y: number; size: number }[] = [];
  private props: Prop[] = [];
  readonly wildlife: Wildlife;
  readonly services: Services;
  readonly workers: Workers;
  readonly street: Street;
  readonly helis: Helicopters;
  readonly dispatch: Dispatcher;
  readonly mob: Mob;
  readonly missiles: Missiles;
  readonly air: Airstrike;
  /** The city fighting back: utilities, people, emergency services, air defence. */
  readonly civil: Civil;
  /** For the rig, sports car and dozer: an A-10 flown by the computer, raiding the city. */
  raidEnabled = false;
  raider: Raider | null = null;
  private raidRespawn = 0;
  private flybyAt = -10;
  private brrtAt = -10;
  /** A-10 sorties: things destroyed. */
  destroyed = 0;

  /**
   * Subtle streaks behind the A-10: thin vapour trails off the wingtips that show up
   * as it pulls hard (and faintly at speed), and a heat shimmer behind each engine.
   */
  private drawTrails(ctx: CanvasRenderingContext2D): void {
    const f = this.truck.flight;
    if (!f) return;
    const speedFrac = clamp(f.v / 200, 0, 1);
    ctx.lineCap = 'round';
    this.trails.forEach((tr, i) => {
      const tip = i < 2;
      const keep = tip ? 1.6 : 0.7;
      for (let k = 1; k < tr.length; k++) {
        const a = tr[k - 1];
        const b = tr[k];
        const pa = this.lift(a.x, a.y, a.z);
        const pb = this.lift(b.x, b.y, b.z);
        if (!pa || !pb) continue;
        const age = (this.time - b.t) / keep;
        // Wingtip vortices condense as the wing pulls g; the engines leave a heat shimmer.
        const strength = tip ? (0.06 + b.bank * 0.9 + speedFrac * 0.05) * (1 - age) : (0.12 + speedFrac * 0.06) * (1 - age);
        if (strength <= 0.01) continue;
        ctx.strokeStyle = tip ? `rgba(255, 255, 255, ${Math.min(0.8, strength)})` : `rgba(215, 210, 205, ${strength})`;
        ctx.lineWidth = (tip ? 1 + age * 1.6 : 3 + age * 4) * pb.s;
        ctx.beginPath();
        ctx.moveTo(pa.x, pa.y);
        ctx.lineTo(pb.x, pb.y);
        ctx.stroke();
      }
    });
    ctx.lineCap = 'butt';
  }

  /** What's at a point: a building's construction if one's there, otherwise the ground's surface. */
  private groundAt(x: number, y: number): Ground {
    const st = this.city.structuresNear(x, y, 2).find((s) => !s.tree);
    if (st) return st.mat === 'wood' ? 'wood' : 'stone';
    switch (this.city.surfaceAt(x, y)) {
      case SURF_WATER:
        return 'water';
      case SURF_GRASS:
        return this.city.onDeck(x, y) ? 'tarmac' : 'grass';
      case SURF_DIRT:
        return 'earth';
      case SURF_SIDEWALK:
        return 'concrete';
      default:
        return 'tarmac';
    }
  }

  /** Play an explosion or collapse where it happened (anything within earshot). */
  private boom(kind: BlastKind, x: number, y: number): void {
    // From the air the sound has the height to climb, too.
    const d = Math.hypot(x - this.truck.x, y - this.truck.y, this.truck.altitude);
    if (d < 1600) this.events.blastSound?.(kind, this.panOf(x), d);
  }

  /** How much of the city lies in ruins: the share of its buildings destroyed (0..100). */
  ruins(): number {
    const all = this.city.buildings;
    let down = 0;
    for (const b of all) if (b.destroyed) down++;
    return all.length ? (down / all.length) * 100 : 0;
  }

  /** The A-10's gun, for its continuous sound: firing, and the current rounds per second. */
  gunState(): { firing: boolean; rate: number } {
    return { firing: this.firing && this.phase === 'playing', rate: 65 * (0.55 + 0.45 * this.air.spin) };
  }
  private firing = false;
  private lastBrrt = -10;
  /** The A-10 gun's heat (0..1) and whether it has overheated and is cooling down. */
  /** Re-arming countdown (s) after the jet calls for more ordnance. */
  private rearming = 0;
  /** Camera height above the ground (map units); Infinity for anything on the ground. */
  private camH = Infinity;
  private camCenter = { x: 0, y: 0 };
  private bombHeld = 0;
  private mobProgress = 0;
  private inTurf = false;
  private lastDisrespect = -10;
  private readonly disrespected = new WeakSet<object>();
  /** Buildings brought down, until the repair crews rebuild them. */
  private collapsed: Structure[] = [];
  /** Buildings and trees on fire. */
  private burning: Structure[] = [];
  private fireTick = 0;
  private crackle = 0;
  private sirenTimer = 0;
  private workTimer = 0;
  private lastDispatchToast = -20;
  private grindTimer = 0;
  private hitStop = 0;
  /** The A-10's wingtip vortices and engine shimmer: recent positions of each. */
  private trails: { x: number; y: number; z: number; t: number; bank: number }[][] = [[], [], [], []];
  private towChoice: TowChoice = 'both';
  private zoom = 3;
  private dpr = 1;
  private devW = 300;
  private devH = 300;
  private insetTop = 0;
  private insetBottom = 0;
  private floaters: Floater[] = [];
  private readonly wheelPrev = new Map<object, Vec[]>();
  private readonly cam = { x: 0, y: 0 };
  private minimap: HTMLCanvasElement | null = null;
  readonly view: View = { x: 0, y: 0, w: 300, h: 300 };

  constructor(
    public options: GameOptions,
    private readonly events: GameEvents,
  ) {
    this.mapRenderer = new MapRenderer(this.city);
    this.effects.solid = (x, y) => this.city.resolveCircle(x, y, 1.2, 0);
    this.effects.groundAt = (x, y) => this.groundAt(x, y);
    // Only dry grass and timber burn (spilt fuel burns briefly wherever it lands).
    this.traffic = new Traffic(this.city, this.effects);
    this.peds = new Pedestrians(this.city, this.effects);
    this.wildlife = new Wildlife(this.city, this.effects);
    this.wildlife.onHit = (a, speed, mass) => {
      if (this.isNear(a.x, a.y)) this.events.animalHit?.(a.kind, mass, this.panOf(a.x));
      const truck = this.truck;
      if (Math.hypot(a.x - truck.x, a.y - truck.y) < 45 && Math.abs(truck.speed) > 10 && this.phase === 'playing') {
        const hurt = Math.max(0, speed - 15) * mass * 0.05;
        truck.damage = Math.min(100, truck.damage + hurt);
        if (mass > 1) truck.dent(a.x, a.y, Math.min(3, mass * 0.6));
        this.drama(a.x, a.y, speed * Math.min(1, mass / 2.5));
        this.floaters.push({ x: a.x, y: a.y, text: a.kind.toUpperCase() + '!', color: DANGER, life: 1.2, scale: 1 });
      }
    };
    this.services = new Services(this.city, this.effects);
    this.services.onTruckHit = (v, closing, px, py) => this.hitUnit(v, closing, px, py);
    this.services.onWrecked = (v) => {
      this.unitWrecked(v);
      this.tally(v.x, v.y, 60);
    };
    this.street = new Street(this.city, this.effects);
    this.street.onKnock = (f, speed) => {
      if (this.isNear(f.x, f.y)) this.events.knock?.(f.kind, this.panOf(f.x));
      this.peds.hearHorn(f.x, f.y);
      if (this.phase === 'playing' && speed > 20 && Math.hypot(f.x - this.truck.x, f.y - this.truck.y) < 40) {
        const text = f.kind === 'lamp' ? 'TIMBER!' : f.kind === 'hydrant' ? 'GEYSER!' : 'TRASHED!';
        this.floaters.push({ x: f.x, y: f.y, text, color: DANGER, life: 1.1, scale: 0.9 });
      }
    };
    this.helis = new Helicopters(this.city);
    this.helis.onRotor = (x, y) => {
      const d = Math.hypot(x - this.truck.x, y - this.truck.y);
      this.events.rotor?.(this.panOf(x), Math.max(0, 1 - d / 700));
    };
    this.missiles = new Missiles(this.city, this.effects);
    this.air = new Airstrike();
    this.air.onBomb = (x, y, kind) => this.bombBlast(x, y, kind);
    this.air.onRound = (x, y, hei, vx, vy) => this.gunRound(x, y, hei, vx, vy);
    this.air.onMaverick = (x, y) => this.maverickBlast(x, y);
    this.air.onBallute = () => this.events.ballute?.();
    this.traffic.onWrecked = (car) => this.tally(car.x, car.y, 25);
    this.missiles.onExplode = (x, y, layer) => this.missileBlast(x, y, layer);
    this.mob = new Mob(this.city, this.effects, this.traffic, this.services);
    this.mob.onMayhem = (x, y, closing) => {
      this.drama(x, y, closing);
      if (this.isNear(x, y)) this.events.explosion?.(1.6, this.panOf(x));
    };
    this.mob.onBlast = (x, y, size, delay) => this.pendingBlasts.push({ t: this.time + delay, x, y, size });
    this.mob.onDemolish = (st) => {
      // The family's bomb: it comes down, and what's inside goes up.
      this.collapse(st, 0, 0);
      this.burnFromBlast(st);
    };
    this.traffic.closures = this.services.closures;
    const game = this;
    this.dispatch = new Dispatcher({
      traffic: this.traffic,
      peds: this.peds,
      effects: this.effects,
      wildlife: this.wildlife,
      street: this.street,
      services: this.services,
      helis: this.helis,
      get truck() {
        return game.truck;
      },
      brokenProps: () => [...this.brokenProps(), ...this.rubble()],
      hour: () => this.hour,
      inTurf: (x, y) => this.mob.inTurf(x, y),
      burning: () => this.burningSpots(),
      douse: (x, y, r) => this.douse(x, y, r),
      pressure: (x, y) => this.civil.pressure(x, y),
      coordination: () => this.civil.coordination,
      rescueSites: () => this.civil.rescueSites(),
      dig: (site) => this.civil.dig(site as Parameters<Civil['dig']>[0]),
      sound: (kind, x, y) => {
        if (!this.isNear(x, y)) return;
        if (kind === 'hose') this.events.hose?.(this.panOf(x));
        else if (kind === 'tow') this.events.towWork?.(this.panOf(x));
        else this.events.tools?.(this.panOf(x));
      },
    });
    this.civil = new Civil(this.city, this.effects, this.services, this.peds, {
      hour: () => this.hour,
      burning: () => this.burning,
      douse: (x, y, r) => this.douse(x, y, r),
      toast: (text, tone) => {
        if (this.phase === 'playing') this.events.toast(text, tone);
      },
      jet: () => {
        const t = this.truck;
        if (this.phase !== 'playing') return null;
        // The raider, when the player's on the ground.
        if (!t.plane) return this.raider?.alive ? { x: this.raider.x, y: this.raider.y, vx: this.raider.vx, vy: this.raider.vy } : null;
        if (this.dying >= 0 || this.jetFalling !== false) return null;
        return { x: t.x, y: t.y, vx: t.cab.vx, vy: t.cab.vy };
      },
      hitJet: (damage) => (this.truck.plane ? this.hitJet(damage) : this.raider?.hit(damage)),
      explode: (x, y, size, air) => {
        if (air) {
          this.effects.flak(x, y);
          this.effects.detonation(x, y, size * 0.6, 0, 'metal');
          if (this.isNear(x, y)) this.boom('small', x, y);
        } else this.pendingBlasts.push({ t: this.time, x, y, size });
      },
      sound: (kind, x, y) => {
        const d = Math.hypot(x - this.truck.x, y - this.truck.y);
        if (kind === 'siren') this.events.airRaid?.();
        else if (kind === 'launch') this.events.samLaunch?.(this.panOf(x));
        else if (kind === 'guns' && d < 1600) this.events.aaGuns?.(this.panOf(x), d);
        else if (kind === 'heli' && this.isNear(x, y)) this.events.hose?.(this.panOf(x));
      },
      score: (points, x, y, label) => {
        if (!this.truck.plane || this.phase !== 'playing') return;
        this.score += points;
        if (this.isNear(x, y)) this.floaters.push({ x, y: y - 10, text: `${label} +${points}`, color: '#ffd84a', life: 1.6, scale: 1.1 });
      },
    });
    // Without power the junctions' lights go dark, and drivers have to feel their way through.
    this.traffic.powered = (x, y) => this.civil.hasPower(x, y);
    this.dispatch.onDispatch = (kind, x, y) => {
      const text = DISPATCH_TOASTS[kind];
      if (!text || this.phase !== 'playing' || Math.hypot(x - this.truck.x, y - this.truck.y) > 450 || this.time - this.lastDispatchToast < 9) return;
      this.lastDispatchToast = this.time;
      this.events.toast(text, 'info');
    };
    this.dispatch.onStage = (inc) => {
      const text = inc.stage === 'tow' ? 'Scene secure: tow trucks called' : inc.stage === 'clean' ? 'Wrecks cleared: road crew sweeping up' : inc.stage === 'done' ? 'Road cleaned up and reopened' : '';
      if (!text || this.phase !== 'playing' || Math.hypot(inc.x - this.truck.x, inc.y - this.truck.y) > 450 || this.time - this.lastDispatchToast < 5) return;
      this.lastDispatchToast = this.time;
      this.events.toast(text, inc.stage === 'done' ? 'good' : 'info');
    };
    this.traffic.onBoard = (_car, who) => this.peds.board(who as Ped);
    this.traffic.onDropOff = (_car, x, y) => this.peds.dropOff(x, y);
    this.workers = new Workers(this.city, this.peds, this.effects);
    this.workers.onCrash = (x, y, closing) => {
      if (this.isNear(x, y)) this.events.crash?.(closing / 100, 'car', this.panOf(x));
      this.drama(x, y, closing);
      if (this.phase === 'playing') this.floaters.push({ x, y, text: 'MY TRACTOR!', color: DANGER, life: 1.4, scale: 1 });
    };
    this.traffic.onEject = (car, vx, vy) => this.peds.launch(car.x, car.y, vx, vy, car.look.accent, car.look.color);
    this.traffic.onCrash = (x, y, intensity, kind, car) => {
      if (car.mob && kind === 'car' && intensity > 0.2 && !this.disrespected.has(car)) {
        this.disrespected.add(car);
        this.disrespect('You wrecked a family car!');
      }
      // A sliding wreck can bring a flimsy building down too.
      const struck = kind === 'wall' ? this.city.lastStructure : null;
      if (struck) this.hitStructure(struck, Math.max(0, intensity * 100 - (struck.mat === 'wood' ? 25 : 70)) * (car.body.mass / 3), struck.x - car.x, struck.y - car.y);
      if (kind === 'wall' && intensity > 0.3) {
        const material = this.city.lastMaterial;
        if (material === 'concrete') {
          const a = Math.atan2(car.y - y, car.x - x);
          this.effects.structureHit(x, y, intensity * 100, Math.cos(a), Math.sin(a), material);
          if (this.isNear(x, y)) this.events.structureHit?.(intensity * 100, this.panOf(x));
        }
      }
      if (this.isNear(x, y)) {
        this.events.crash?.(intensity, kind, this.panOf(x));
        if (intensity > 0.12) this.events.carFlavor?.(car.look.model, intensity, this.panOf(x));
        this.bump(Math.min(10, intensity * 6) * (kind === 'pileup' ? 0.5 : 1));
      }
    };
    this.traffic.onExplode = (x, y, size) => this.blast(x, y, size);
    this.traffic.onRollHit = (x, y, energy) => {
      if (this.isNear(x, y)) {
        this.events.rollHit?.(energy, this.panOf(x));
        this.bump(Math.min(6, energy / 20));
      }
    };
    this.traffic.onSay = (car, kind) => {
      if (kind !== 'wreck' && this.phase === 'playing' && this.isNear(car.x, car.y)) this.events.honk?.(car.rude, this.panOf(car.x));
    };
    this.peds.onHit = (ped) => {
      this.events.pedHit?.(this.panOf(ped.x));
      const byPlayer = !this.truck.plane && Math.hypot(ped.x - this.truck.x, ped.y - this.truck.y) < 45;
      if (this.phase !== 'playing' || !byPlayer) return;
      if (this.mob.isGoon(ped.lines)) this.disrespect('You ran down one of the family!');
      this.score = Math.max(0, this.score - INSURANCE);
      this.floaters.push({ x: ped.x, y: ped.y, text: `-$${INSURANCE} INSURANCE`, color: DANGER, life: 1.6 });
      this.events.toast(`Pedestrian hit! Insurance claim -$${INSURANCE}`, 'bad');
    };
    this.truck = this.newTruck();
    this.job = this.makeJob('pickup');
    this.snapCamera();
    this.populate();
  }

  private populate(): void {
    this.traffic.setCount(this.options.carCount, this.truck, this.view, this.options.rudeness);
    this.peds.setCount(this.options.pedCount, this.truck, this.view);
    this.peds.setupVenues(this.city.venues);
    this.workers.setup();
    this.mob.setupGoons(this.peds);
    this.wildlife.setCount(this.options.animalCount, this.truck, this.view);
    this.wildlife.setupHerds();
    this.buildProps();
  }

  private buildProps(): void {
    this.props = [];
    const body = (x: number, y: number, angle: number, halfL: number, halfW: number): Body => ({ x, y, vx: 0, vy: 0, angle, av: 0, mass: 1e6, inertia: 1e9, halfL, halfW });
    const awnings: [string, string][] = [
      ['#e74c3c', '#fdfdfd'],
      ['#2e86c1', '#fdfdfd'],
      ['#27ae60', '#f7dc6f'],
      ['#f39c12', '#fdfdfd'],
      ['#8e44ad', '#f5b7b1'],
    ];
    this.city.venues.forEach((v, i) => {
      const c = Math.cos(v.angle);
      const s = Math.sin(v.angle);
      const at = (u: number, w: number) => ({ x: v.x + c * u - s * w, y: v.y + s * u + c * w });
      if (v.kind === 'market') {
        for (const row of [-0.42, 0.42]) {
          for (const col of [-0.5, 0, 0.5]) {
            const p = at(col * v.r * 1.3, row * v.r);
            this.props.push({ kind: 'stall', body: body(p.x, p.y, v.angle, 7, 4.5), colors: pick(awnings), produce: [pick(['#e74c3c', '#f39c12', '#27ae60', '#f4d03f', '#8e44ad']), pick(['#e74c3c', '#27ae60', '#d35400'])], broken: false, venue: i });
          }
        }
      } else {
        const layout: readonly (readonly [number, number, Prop['kind']])[] =
          v.kind === 'rave'
            ? [
                [0, -0.7, 'stage'],
                [-0.62, -0.62, 'speaker'],
                [0.62, -0.62, 'speaker'],
                [-0.75, 0.3, 'speaker'],
                [0.75, 0.3, 'speaker'],
              ]
            : [
                [-0.55, -0.5, 'speaker'],
                [0.55, -0.5, 'speaker'],
                [0, -0.55, 'table'],
                [0.5, 0.45, 'keg'],
              ];
        for (const [u, w, kind] of layout) {
          const p = at(u * v.r, w * v.r);
          const size = kind === 'stage' ? [14, 5] : kind === 'speaker' ? [3, 3] : kind === 'table' ? [7, 3] : [2, 2];
          this.props.push({ kind, body: body(p.x, p.y, v.angle, size[0], size[1]), colors: ['#1b1b1f', '#3a3a40'], produce: [], broken: false, venue: i });
        }
      }
    });
  }

  private advanceClock(realDt: number): void {
    this.hour = (this.hour + (realDt * 24) / DAY_SECONDS) % 24;
    this.peds.hour = this.hour;
    this.city.venues.forEach((_, i) => this.peds.setVenueOpen(i, this.venueOpen(i)));
  }

  clockText(): string {
    const h = Math.floor(this.hour);
    const m = Math.floor((this.hour - h) * 60);
    return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
  }

  /** Markets run by day, parties in the evening, raves through the night. */
  venueOpen(i: number): boolean {
    const v = this.city.venues[i];
    if (v.kind === 'market') return inHours(this.hour, 7, 18);
    if (v.kind === 'party') return inHours(this.hour, 18, 27);
    return inHours(this.hour, 22, 29);
  }

  /** Traffic follows the clock: rush hours are packed, the small hours are empty. */
  private trafficLevel(): number {
    const h = this.hour;
    const peak = (c: number, w: number) => Math.exp(-(((h - c) / w) ** 2));
    return Math.min(1.35, 0.3 + 0.55 * this.daylight() + 0.5 * peak(8, 1.3) + 0.5 * peak(17.5, 1.6));
  }

  private pedLevel(): number {
    // Evacuated districts empty out.
    const evac = this.civil.evac.get(this.city.districtAt(this.cam.x, this.cam.y)) ?? 0;
    return (0.35 + 0.75 * this.daylight()) * (1 - evac * 0.85);
  }

  /** 1 at midday, 0 in the dead of night. */
  daylight(): number {
    const h = this.hour;
    if (h >= 7 && h < 17.5) return 1;
    if (h >= 17.5 && h < 20.5) return 1 - (h - 17.5) / 3;
    if (h >= 5 && h < 7) return (h - 5) / 2;
    return 0;
  }

  private readonly sprites = new Map<string, HTMLCanvasElement>();

  /** A soft round light, cached per colour, so lights cost one drawImage each. */
  private lightSprite(color: string): HTMLCanvasElement {
    let c = this.sprites.get(color);
    if (!c) {
      c = document.createElement('canvas');
      c.width = c.height = 64;
      const g2 = c.getContext('2d')!;
      const g = g2.createRadialGradient(32, 32, 0, 32, 32, 32);
      g.addColorStop(0, color);
      g.addColorStop(1, 'rgba(0, 0, 0, 0)');
      g2.fillStyle = g;
      g2.fillRect(0, 0, 64, 64);
      this.sprites.set(color, c);
    }
    return c;
  }

  /** Darken the world at night and punch holes in the dark for every light. */
  private drawNight(ctx: CanvasRenderingContext2D, vx: number, vy: number, zoom: number): void {
    const day = this.daylight();
    if (day >= 0.999) return;
    const dark = (1 - day) * 0.78;
    // The light map is soft, so a third of the screen resolution is plenty.
    const w = Math.max(1, Math.round(this.devW / 3));
    const h = Math.max(1, Math.round(this.devH / 3));
    if (!this.lightCanvas || this.lightCanvas.width !== w || this.lightCanvas.height !== h) {
      this.lightCanvas = document.createElement('canvas');
      this.lightCanvas.width = w;
      this.lightCanvas.height = h;
    }
    const lc = this.lightCanvas.getContext('2d')!;
    const k = (zoom * w) / this.devW;
    const sx = (x: number) => (x - vx) * k;
    const sy = (y: number) => (y - vy) * k;
    const visible = (x: number, y: number, m = 60) => inView(x, y, this.view, m);
    // Dusk and dawn are warm; night is deep blue.
    const warm = Math.max(0, 1 - Math.abs(day - 0.45) * 3);
    lc.globalCompositeOperation = 'source-over';
    lc.clearRect(0, 0, w, h);
    lc.fillStyle = `rgba(${Math.round(12 + warm * 70)}, ${Math.round(16 + warm * 20)}, ${Math.round(42 - warm * 10)}, ${dark})`;
    lc.fillRect(0, 0, w, h);
    lc.globalCompositeOperation = 'destination-out';
    const sprite = this.lightSprite('#000000');
    const light = (x: number, y: number, r: number, strength: number) => {
      const rr = r * k;
      lc.globalAlpha = Math.min(1, strength);
      lc.drawImage(sprite, sx(x) - rr, sy(y) - rr, rr * 2, rr * 2);
    };
    const beams = (x: number, y: number, a: number, reach: number, strength: number, steps = 3) => {
      for (const [d, r] of (steps === 3
        ? [
            [0.25, 0.22],
            [0.55, 0.32],
            [0.85, 0.4],
          ]
        : [
            [0.35, 0.3],
            [0.75, 0.42],
          ]) as [number, number][]) light(x + Math.cos(a) * reach * d, y + Math.sin(a) * reach * d, reach * r, strength * (1.1 - d * 0.6));
    };
    // Street lamps and lit windows, only where the power's still on: a blackout is a black hole.
    const lamps = this.street.lit(this.view).filter((p) => this.civil.hasPower(p.x, p.y));
    for (const p of lamps) light(p.x, p.y, 40, 0.8);
    let windows = 0;
    for (const b of this.city.buildings) {
      if (b.destroyed || windows > 260 || (b.id * 7) % 3 === 0 || !visible(b.x, b.y, 20) || !this.civil.hasPower(b.x, b.y)) continue;
      windows++;
      light(b.x, b.y, Math.min(b.w, b.d) * 0.45, 0.35);
    }
    for (const g of this.civil.lights(this.lift)) light(g.x, g.y, g.r, 0.9);
    const t = this.truck;
    if (!t.plane) beams(t.x + Math.cos(t.angle) * t.cab.halfL, t.y + Math.sin(t.angle) * t.cab.halfL, t.angle, t.sports ? 110 : 120, 0.95);
    for (const p of this.air.lights(this.lift)) light(p.x, p.y, 10, 0.6);
    if (this.raider) for (const p of this.raider.air.lights(this.lift)) light(p.x, p.y, 10, 0.6);
    light(t.x, t.y, 22, 0.5);
    for (const car of this.traffic.cars) {
      if (!visible(car.x, car.y)) continue;
      beams(car.x + Math.cos(car.angle) * car.body.halfL, car.y + Math.sin(car.angle) * car.body.halfL, car.angle, 60, 0.8, 2);
    }
    for (const v of this.services.vehicles) {
      if (!visible(v.x, v.y)) continue;
      beams(v.x + Math.cos(v.body.angle) * v.body.halfL, v.y + Math.sin(v.body.angle) * v.body.halfL, v.body.angle, 70, 0.85, 2);
    }
    for (const b of this.helis.beams(this.view)) light(b.x, b.y, b.r, 0.95);
    for (const g of this.effects.glows(this.view)) light(g.x, g.y, g.r, 0.9);
    for (const st of this.burning) if (visible(st.x, st.y, 80)) light(st.x, st.y, Math.max(st.w, st.d) * 1.3, 0.95);
    this.city.venues.forEach((v, i) => {
      if (v.kind !== 'market' && this.venueOpen(i) && visible(v.x, v.y, v.r + 60)) light(v.x, v.y, v.r * 1.6, 0.75);
    });
    lc.globalAlpha = 1;
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.imageSmoothingEnabled = true;
    ctx.drawImage(this.lightCanvas, 0, 0, this.devW, this.devH);
    // Coloured glows on top.
    const night = 1 - day;
    ctx.setTransform(zoom, 0, 0, zoom, -vx * zoom, -vy * zoom);
    ctx.globalCompositeOperation = 'lighter';
    const glow = (x: number, y: number, r: number, color: string) => {
      ctx.drawImage(this.lightSprite(color), x - r, y - r, r * 2, r * 2);
    };
    ctx.globalAlpha = night;
    for (const p of lamps) if (visible(p.x, p.y, 20)) glow(p.x, p.y, 10, 'rgba(255, 210, 140, 0.35)');
    for (const car of this.traffic.cars) {
      if (!visible(car.x, car.y, 20)) continue;
      const c = Math.cos(car.angle);
      const s = Math.sin(car.angle);
      glow(car.x - c * car.body.halfL, car.y - s * car.body.halfL, car.braking ? 6 : 3.5, 'rgba(255, 30, 30, 0.6)');
      glow(car.x + c * car.body.halfL, car.y + s * car.body.halfL, 5, 'rgba(255, 240, 200, 0.5)');
    }
    glow(t.x + Math.cos(t.angle) * (t.cab.halfL + 1), t.y + Math.sin(t.angle) * (t.cab.halfL + 1), 7, 'rgba(255, 245, 210, 0.6)');
    const tc = t.trailerCenter();
    const tail = t.solo ? t.cab.halfL : 19;
    glow(tc.x - Math.cos(t.trailerAngle) * tail, tc.y - Math.sin(t.trailerAngle) * tail, t.braking ? 8 : 4, 'rgba(255, 30, 30, 0.6)');
    for (const g of this.effects.glows(this.view)) glow(g.x, g.y, g.r * 0.6, g.color);
    for (const p of this.peds.lights(this.view)) glow(p.x, p.y, 3, p.color);
    for (const p of this.services.lights(this.view)) glow(p.x, p.y, 16, p.color);
    for (const p of this.helis.lights(this.view)) glow(p.x, p.y, 5, p.color);
    for (const m of this.missiles.lights(this.view)) glow(m.x, m.y, 7, 'rgba(255, 190, 70, 0.8)');
    for (const p of this.air.lights(this.lift)) glow(p.x, p.y, 4, 'rgba(255, 200, 80, 0.7)');
    for (const st of this.burning) if (visible(st.x, st.y, 40)) glow(st.x, st.y, Math.max(st.w, st.d) * 0.8, 'rgba(255, 120, 30, 0.45)');
    const jet = t.plane && this.dying < 0 ? this.lift(t.x, t.y, t.altitude) : null;
    if (jet) {
      // Wingtip nav lights and a strobe, up at the jet's height.
      const c = Math.cos(t.angle);
      const s = Math.sin(t.angle);
      const span = (8.7 / METERS) * Math.cos(t.bank * 0.9) * jet.s;
      glow(jet.x + s * span, jet.y - c * span, 4 * jet.s, 'rgba(255, 50, 50, 0.8)');
      glow(jet.x - s * span, jet.y + c * span, 4 * jet.s, 'rgba(60, 255, 110, 0.8)');
      if (Math.floor(this.time * 1.5) % 2 === 0 && this.time % (2 / 1.5) < 0.1) glow(jet.x, jet.y, 14 * jet.s, 'rgba(255, 255, 255, 0.9)');
    }
    for (const p of this.signalGlows()) glow(p.x, p.y, 4, p.color);
    ctx.globalAlpha = 1;
    ctx.globalCompositeOperation = 'source-over';
  }

  /** Big crashes get bigger: a headline, slow motion, a flash and, at the top, chain explosions. */
  private drama(x: number, y: number, closing: number, chain = true): void {
    let tier = 0;
    DRAMA.forEach(([min], i) => {
      if (closing >= min) tier = i + 1;
    });
    if (!tier || this.time - this.lastDrama < 0.4) return;
    this.lastDrama = this.time;
    const [, text, color] = DRAMA[tier - 1];
    this.floaters.push({ x, y: y - 6, text, color, life: 1.2 + tier * 0.3, scale: 1 + tier * 0.4 });
    if (this.options.shake) {
      // From the air the bombs keep coming: shorter slow motion so the sortie keeps flowing.
      this.slowmo = Math.max(this.slowmo, [0, 0, 0.25, 0.6, 1.4][tier] * (this.truck.plane ? 0.3 : 1));
      this.flash = Math.max(this.flash, [0, 0, 0.12, 0.3, 0.65][tier] * (this.truck.plane ? 0.3 : 1));
      this.zoom *= 1 + tier * 0.05;
      this.bump(4 + tier * 5);
    }
    this.events.dramaTier?.(tier, this.panOf(x));
    if (tier >= 3) {
      this.dispatch.major(x, y);
      // Shockwave: everything nearby gets thrown.
      const radius = 40 + tier * 18;
      // (A bomb brings its own fireball: no extra one on top.)
      if (chain) {
        if (tier === 3) this.effects.explosion(x, y, 0.5);
        else this.effects.fireball(x, y, 1.1);
      }
      for (const car of this.traffic.all()) {
        const dx = car.x - x;
        const dy = car.y - y;
        const d = Math.hypot(dx, dy);
        if (d > radius || d < 0.1 || car.layer !== this.truck.layer) continue;
        const force = (1 - d / radius) * tier * 35;
        if (car.mode === 'drive') this.traffic.syncBody(car);
        car.body.vx += (dx / d) * force;
        car.body.vy += (dy / d) * force;
        car.rollVel += (Math.random() - 0.5) * tier * 3;
        this.traffic.applyHit(car, force, car.x - (dx / d) * 4, car.y - (dy / d) * 4, -dx / d, -dy / d, { material: 'car', color: '#ff9a3a' }, 'pileup');
      }
      this.peds.blast(x, y, radius);
      this.blastStructures(x, y, radius * 0.8, tier * 40);
      this.wildlife.blast(x, y, radius);
    }
    if (tier >= 4 && chain) {
      const count = 3 + Math.floor(Math.random() * 3);
      for (let i = 0; i < count; i++) this.pendingBlasts.push({ t: this.time + range(0.15, 1.2), x: x + range(-35, 35), y: y + range(-35, 35), size: range(0.7, 1.3) });
    }
  }

  /** Anything moving on the ground smashes stalls and party gear and knocks over street furniture. */
  private updateProps(): void {
    const truck = this.truck;
    const movers: Body[] = truck.layer === 0 ? [truck.cab, truck.trailer] : [];
    for (const w of this.traffic.wrecks) if (w.layer === 0 && w.dmg.z < 6 && Math.hypot(w.body.vx, w.body.vy) > 9) movers.push(w.body);
    for (const b of movers) this.street.strike(b, 0);
    for (const prop of this.props) {
      if (prop.broken) continue;
      for (const b of movers) {
        if (Math.abs(prop.body.x - b.x) > 30 || Math.abs(prop.body.y - b.y) > 30) continue;
        const c = boxContact(b, prop.body);
        if (!c) continue;
        const speed = Math.hypot(b.vx, b.vy);
        if (speed < 8) continue;
        prop.broken = true;
        b.vx *= 0.93;
        b.vy *= 0.93;
        const k = Math.min(1.4, speed / 90);
        this.effects.crash(prop.body.x, prop.body.y, k, prop.kind === 'stall' ? [prop.colors[0], prop.colors[1], '#8a6a44'] : ['#1b1b1f', '#55524a'], { x: b.vx / speed, y: b.vy / speed }, { material: 'wood', glass: prop.kind === 'keg' });
        if (prop.kind === 'stall') {
          // Produce everywhere.
          for (let i = 0; i < 3; i++) this.effects.crash(prop.body.x, prop.body.y, k * 0.6, prop.produce, { x: b.vx / speed, y: b.vy / speed }, { material: 'ground', glass: false, flavor: 'compact' });
        }
        if (this.isNear(prop.body.x, prop.body.y)) this.events.stallSmash?.(this.panOf(prop.body.x));
        this.peds.hearHorn(prop.body.x, prop.body.y);
        if (this.phase === 'playing' && this.isNear(prop.body.x, prop.body.y)) this.floaters.push({ x: prop.body.x, y: prop.body.y, text: prop.kind === 'stall' ? 'MY STALL!' : 'THE PARTY!', color: DANGER, life: 1.3, scale: 1 });
        break;
      }
    }
  }

  setViewport(devW: number, devH: number, dpr: number, insetTop: number, insetBottom: number): void {
    this.devW = devW;
    this.devH = devH;
    this.dpr = dpr;
    this.insetTop = insetTop;
    this.insetBottom = insetBottom;
    this.updateView();
  }

  /** What the player drives this shift. */
  vehicle: VehicleKind = 'rig';
  private readonly sportsLook: CarLook = makeLook('sports', '#e8262f', '#1b1b1f', false);
  private readonly sportsDmg: CarDamage = { crumple: [0, 0, 0, 0], wheels: [true, true, true, true], flipped: false, z: 0, roll: 0, hazard: false, burnt: 0, glass: false };

  start(vehicle: VehicleKind = this.vehicle): void {
    this.vehicle = vehicle;
    this.truck = this.newTruck();
    this.score = 0;
    this.deliveries = 0;
    this.overReason = '';
    this.dying = -1;
    this.slowmo = 0;
    this.rolledTime = 0;
    this.floaters = [];
    this.effects.clear();
    this.wheelPrev.clear();
    this.snapCamera();
    this.traffic.clear();
    this.peds.clear();
    this.wildlife.clear();
    this.services.clear();
    this.helis.clear();
    this.dispatch.clear();
    this.mob.clear();
    this.missiles.clear();
    this.air.clear();
    this.civil.reset();
    this.jetFalling = false;
    this.raider = null;
    this.raidRespawn = vehicle !== 'a10' && this.raidEnabled ? 4 : 0;
    this.rearming = 0;
    this.bombHeld = 0;
    for (const tr of this.trails) tr.length = 0;
    for (const st of this.burning) st.burning = 0;
    this.burning = [];
    this.destroyed = 0;
    this.mobProgress = 0;
    for (const st of this.collapsed) this.city.rebuild(st);
    this.collapsed = [];
    this.street.reset();
    this.pendingBlasts = [];
    this.flash = 0;
    this.populate();
    this.job = this.makeJob('pickup');
    this.phase = 'playing';
  }

  shiftUp(): void {
    // In the A-10, M is the throttle: a tap nudges the lever, holding pushes it.
    if (this.truck.flight) this.truck.flight.nudge(0.04);
    else this.manualShift(() => this.truck.shiftUp());
  }

  shiftDown(): void {
    if (this.truck.flight) this.truck.flight.nudge(-0.04);
    else this.manualShift(() => this.truck.shiftDown());
  }

  private manualShift(shift: () => void): void {
    if (this.phase !== 'playing' || this.dying >= 0) return;
    if (this.truck.automatic) {
      if (this.time - this.lastHint > 4) {
        this.lastHint = this.time;
        this.events.toast(this.truck.dozer ? 'The dozer has no gears to shift: W forward, S back' : 'The sports car is automatic: just drive', 'info');
      }
      return;
    }
    shift();
  }

  horn(): void {
    if (this.phase !== 'playing') return;
    this.traffic.hearHorn(this.truck.x, this.truck.y);
    this.peds.hearHorn(this.truck.x, this.truck.y);
  }

  targetDistance(): number {
    return Math.round(Math.hypot(this.job.spot.x - this.truck.x, this.job.spot.y - this.truck.y) * METERS_PER_UNIT);
  }

  /** Speed for the HUD in km/h. */
  speedKmh(): number {
    return Math.round(Math.abs(this.truck.speed) * METERS_PER_UNIT * 3.6);
  }

  rpmFraction(): number {
    return clamp(this.truck.rpm / this.truck.redline, 0, 1.05);
  }

  update(realDt: number, input: Controls): void {
    if (this.phase === 'paused') return;
    // Hit-stop: hold the frame for an instant on the biggest impacts.
    if (this.hitStop > 0) {
      this.hitStop -= realDt;
      this.shake = Math.max(0, this.shake - realDt * 10);
      return;
    }
    this.slowmo = Math.max(0, this.slowmo - realDt);
    const dt = realDt * (this.slowmo > 0 ? 0.3 : 1);
    this.time += dt;
    const active = this.phase === 'playing' && this.dying < 0;
    const truck = this.truck;

    if (active && !truck.dozer) this.smashAhead(dt);
    const waiting = !!this.services.active('mytow');
    // Up on the interstate deck it's all asphalt, whatever lies underneath.
    const feel = (x: number, y: number) => (truck.layer === 1 ? FEEL[SURF_ROAD] : (FEEL[this.city.surfaceAt(x, y)] ?? FEEL[SURF_ROAD]));
    const hits = truck.update(dt, active && !waiting ? input : NO_INPUT, feel, this.city);
    if (waiting) {
      // Waiting for the tow: parking brake on.
      for (const b of [truck.cab, truck.trailer]) {
        b.vx *= Math.exp(-4 * dt);
        b.vy *= Math.exp(-4 * dt);
        b.av *= Math.exp(-4 * dt);
      }
    }
    this.groundSpray(dt);
    if (active) this.dozerGrind(dt, input);
    this.updateLayer();
    for (const e of truck.events.splice(0)) {
      if (e.type === 'shift') this.events.shift?.(e.gear);
      else if (e.type === 'grind') {
        this.events.grind?.();
        this.events.toast('Grinding gears! Slow down first', 'bad');
      } else if (e.type === 'tipping') {
        this.events.tipping?.();
        this.events.toast('Wheels lifting! Ease off!', 'bad');
      } else if (e.type === 'rollover') {
        this.rollOver();
      }
    }
    if (active && !truck.automatic && input.up && truck.gear >= 4 && Math.abs(truck.speed) < 4 && this.time - this.lastHint > 6) {
      this.lastHint = this.time;
      this.events.toast(`Bogging down in ${truck.gearName} — shift down with N`, 'info');
    }
    const wall = hits.reduce<(typeof hits)[number] | null>((m, h) => (!m || h.impact > m.impact ? h : m), null);
    if (wall && wall.impact > 14 && this.phase === 'playing') {
      this.wallCrash(wall.impact, wall.x, wall.y, wall.material);
      if (wall.structure) this.hitStructure(wall.structure, this.rigPunch(wall.impact, wall.structure), wall.x - this.truck.x, wall.y - this.truck.y);
    }
    if (truck.rolled) {
      const v = Math.hypot(truck.cab.vx, truck.cab.vy);
      if (v > 15 && Math.random() < 0.8) {
        this.effects.scrape(truck.x, truck.y, truck.cab.vx, truck.cab.vy);
        this.effects.scrape(truck.trailer.x, truck.trailer.y, truck.trailer.vx, truck.trailer.vy);
      }
      if (v < 3 && this.dying < 0 && this.phase === 'playing') {
        this.rolledTime += realDt;
        if (this.rolledTime > 1.5 && this.rolledTime - realDt <= 1.5) this.events.toast('On your side! Press R to call a tow truck', 'bad');
      }
    }

    this.updateCamera(realDt);
    this.traffic.obstacles = [...this.peds.roadObstacles(), ...this.wildlife.roadObstacles()];
    this.advanceClock(realDt);
    this.traffic.setCount(Math.round(this.options.carCount * this.trafficLevel()), truck, this.view, this.options.rudeness);
    this.traffic.update(dt, truck, this.view, this.options.rudeness);
    this.collideTruckWithCars(active);
    this.peds.setCount(Math.round(this.options.pedCount * this.pedLevel()), truck, this.view);
    this.peds.update(dt, truck, this.view);
    if (truck.layer === 0) {
      this.peds.strike(truck.cab, 0, 'flat');
      this.peds.strike(truck.trailer, 0, 'flat');
    }
    for (const w of this.traffic.wrecks) {
      if (Math.hypot(w.body.vx, w.body.vy) > 15) {
        this.peds.strike(w.body, w.layer, FLAT_FRONT.has(w.look.model) ? 'flat' : 'low');
        this.wildlife.strike(w.body, w.layer);
      }
    }
    this.wildlife.update(dt, truck, this.view);
    if (truck.layer === 0) {
      this.wildlife.strike(truck.cab, 0);
      this.wildlife.strike(truck.trailer, 0);
    }
    for (const car of this.traffic.cars) {
      if (car.layer !== 0 || car.speed < 15) continue;
      this.wildlife.strike(car.body, 0, true);
      this.peds.strike(car.body, 0, FLAT_FRONT.has(car.look.model) ? 'flat' : 'low', true);
    }
    this.traffic.blockers = this.services.vehicles.map((v) => ({ body: v.body, layer: v.layer, parked: v.state === 'work' }));
    this.traffic.hailers = this.peds.hailing();
    this.services.update(dt, { traffic: this.traffic, truck, obstacles: this.traffic.obstacles, view: this.view });
    for (const v of this.services.vehicles) {
      const speed = Math.abs(v.speed);
      if (speed < 5) continue;
      this.peds.strike(v.body, v.layer, FLAT_FRONT.has(v.look.model) || v.body.mass > 3 ? 'flat' : 'low', true);
      if (speed > 15) this.wildlife.strike(v.body, v.layer, true);
    }
    this.updateProps();
    this.helis.update(dt, this.view, truck);
    this.dispatch.update(dt);
    this.mob.update(dt, this.view);
    this.missiles.update(dt, this.missileTargets(), this.view);
    this.updateFires(dt);
    const turf = this.mob.inTurf(truck.x, truck.y);
    if (turf !== this.inTurf && this.phase === 'playing' && !truck.plane) this.events.toast(turf ? 'Family turf: the cops don\u2019t come here' : 'Leaving family turf', turf ? 'bad' : 'info');
    this.inTurf = turf;
    this.street.update(dt, 1 / (DAY_SECONDS * 3), this.view);
    this.workers.update(dt, truck, this.view, this.hour);
    this.sirenTimer -= realDt;
    if (this.sirenTimer <= 0) {
      this.sirenTimer = 0.9;
      // At most two sirens, nearest first, fading with distance.
      const loud = this.services.vehicles
        .filter((v) => v.siren)
        .map((v) => ({ v, d: Math.hypot(v.x - truck.x, v.y - truck.y) }))
        .filter((e) => e.d < 650)
        .sort((a, b) => a.d - b.d)
        .slice(0, 2);
      for (const { v, d } of loud) this.events.siren?.(v.kind, this.panOf(v.x), 1 - d / 650);
    }
    for (const b of this.pendingBlasts) {
      if (b.t > this.time) continue;
      this.effects.fireball(b.x, b.y, b.size);
      if (b.size >= 2) {
        // A tank farm, a bunker or a tanker going up: the full layered blast, and a column of smoke.
        this.effects.bombBurst(b.x, b.y, 40 * b.size, b.size, 'metal', false);
        this.effects.smokeColumn(b.x, b.y, 16 + b.size * 4);
        this.effects.blastMark(b.x, b.y, 22 * b.size);
      }
      this.blastStructures(b.x, b.y, 45 * b.size, 110 * b.size);
      this.civil.onBlast(b.x, b.y, 45 * b.size, 110 * b.size, false);
      this.effects.crash(b.x, b.y, 1.4, ['#ff9a3a', '#1b1b1f', '#c9ccd1'], { x: 0, y: 0 }, { panels: 1, material: 'ground', glass: false });
      this.boom(b.size >= 2 ? 'huge' : b.size > 1.1 ? 'medium' : 'small', b.x, b.y);
      this.peds.blast(b.x, b.y, 40);
      this.wildlife.blast(b.x, b.y, 40);
      this.bump(10);
    }
    this.pendingBlasts = this.pendingBlasts.filter((b) => b.t > this.time);
    this.flash = Math.max(0, this.flash - realDt * 1.6);
    // Music from any party within earshot.
    this.beat -= realDt;
    if (this.beat <= 0) {
      this.beat = 0.47;
      // Mix the two most audible venues instead of stacking every nearby kick.
      const audible = this.city.venues.flatMap((v, i) => {
        if (v.kind === 'market' || !this.venueOpen(i)) return [];
        const d = Math.hypot(v.x - truck.x, v.y - truck.y);
        const reach = v.kind === 'rave' ? 480 : 320;
        return d < reach ? [{ v, volume: 1 - d / reach }] : [];
      }).sort((a, b) => b.volume - a.volume).slice(0, 2);
      for (const { v, volume } of audible) {
        if (v.kind === 'rave') this.events.raveBeat?.(this.panOf(v.x), volume);
        else this.events.partyBeat?.(this.panOf(v.x), volume);
      }
    }
    if (truck.layer < 2) this.effects.kick(truck.x, truck.y, 9, truck.cab.vx, truck.cab.vy);
    this.flight(dt, input);
    this.updateRaid(dt);
    this.civil.update(dt);
    this.events.missileWarning?.(this.civil.missileWarning && this.phase === 'playing');
    this.layTracks();
    this.updateEffects(dt, realDt, active);

    if (active) {
      // The A-10 flies sorties, not deliveries: it scores what it destroys.
      if (!truck.plane) this.updateJob(dt);
      if (truck.damage >= 100 && !truck.plane) {
        truck.damage = 100;
        this.dying = 2.4;
        this.effects.fireball(truck.x, truck.y, 1.8);
        const tc = truck.trailerCenter();
        this.effects.crash(tc.x, tc.y, 2, [COLORS.trailer, COLORS.cab], { x: 0, y: 0 }, { panels: 3, wheels: 2 });
        this.bump(20);
        if (this.options.shake) this.slowmo = 1.2;
        this.events.totaled?.();
      }
    }
    if (this.dying >= 0) {
      this.dying -= realDt;
      if (Math.random() < dt * 30) this.effects.puff(truck.x + (Math.random() - 0.5) * 12, truck.y + (Math.random() - 0.5) * 12, true, Math.random() < 0.5, 2.2);
      if (this.dying < 0) this.end(this.truck.plane ? 'SHOT DOWN' : this.truck.sports ? 'CAR TOTALED' : this.truck.dozer ? 'DOZER WRECKED' : 'TRUCK TOTALED');
    }
  }

  /**
   * The dozer's blade grinds into whatever building it's pushing against: timber gives
   * way in a moment, stone in a few seconds of shoving. Backing into one works too,
   * just slower.
   */
  private dozerGrind(dt: number, input: Controls): void {
    const t = this.truck;
    if (!t.dozer || t.layer !== 0) return;
    const dir = t.speed < -0.5 || (t.gear === 0 && input.down) ? -1 : 1;
    const c = Math.cos(t.angle) * dir;
    const s = Math.sin(t.angle) * dir;
    const px = t.x + c * (t.cab.halfL + 3);
    const py = t.y + s * (t.cab.halfL + 3);
    const push = this.city.resolveCircle(px, py, 6, 0);
    const st = this.city.lastStructure;
    if (!st || (!push.x && !push.y) || !(input.up || input.down || Math.abs(t.speed) > 2)) return;
    const wood = st.mat === 'wood';
    this.hitStructure(st, (wood ? 420 : 170) * (dir > 0 ? 1 : 0.4) * dt, c, s);
    this.grindTimer -= dt;
    if (this.grindTimer <= 0) {
      this.grindTimer = 0.12;
      this.effects.crash(px, py, 0.25, wood ? ['#8a6a44', '#a0703c', '#5d4037'] : ['#9e978a', '#6f6a62', '#b8b2a6'], { x: -c, y: -s }, { material: wood ? 'wood' : 'concrete', glass: Math.random() < 0.2 });
      this.effects.dust(px, py, 1.4);
      this.bump(1.5);
      if (Math.random() < 0.35 && this.isNear(px, py)) this.events.structureHit?.(45, this.panOf(px));
    }
    if (st.destroyed && this.phase === 'playing') this.floaters.push({ x: st.x, y: st.y, text: 'BULLDOZED!', color: DANGER, life: 1.5, scale: 1.2 });
  }

  /** Dust off dirt and grass, spray from the shallows, kicked up by the driven wheels. */
  private groundSpray(dt: number): void {
    const t = this.truck;
    const v = Math.abs(t.speed);
    const g = t.ground;
    if (g.grip >= 0.9 || v < 15 || t.layer !== 0) return;
    const bx = t.x - Math.cos(t.angle) * t.cab.halfL * 0.7;
    const by = t.y - Math.sin(t.angle) * t.cab.halfL * 0.7;
    if (g.grip < 0.4) {
      if (Math.random() < dt * 25) this.effects.water(bx, by, bx - t.cab.vx * 0.15 + (Math.random() - 0.5) * 10, by - t.cab.vy * 0.15 + (Math.random() - 0.5) * 10);
    } else if (Math.random() < dt * (6 + v / 10)) {
      this.effects.dust(bx + (Math.random() - 0.5) * 6, by + (Math.random() - 0.5) * 6, 0.7 + v / 120);
    }
  }

  /** The rig is on the elevated deck after climbing a ramp, and on the ground after leaving it. */
  private updateLayer(): void {
    const t = this.truck;
    const onDeck = this.city.onDeck(t.x, t.y);
    const ramp = this.city.onRampMiddle(t.x, t.y);
    if (t.layer === 1 && !onDeck && !ramp) t.layer = 0;
    else if (t.layer === 0 && ramp) t.layer = 1;
  }

  /** Over she goes: the loaded trailer drags the cab onto its side. */
  private rollOver(): void {
    const truck = this.truck;
    truck.damage = Math.min(99, truck.damage + 45);
    this.rolledTime = 0;
    for (const b of [truck.cab, truck.trailer]) {
      this.effects.crash(b.x, b.y, 1.3, [COLORS.cab, COLORS.trailer, '#55524a'], { x: b.vx / 150, y: b.vy / 150 }, { panels: 1, glass: true, material: 'ground' });
    }
    this.bump(16);
    if (this.options.shake) this.slowmo = Math.max(this.slowmo, 1);
    this.events.rollover?.();
    this.events.crash?.(1.6, 'wall', this.panOf(truck.x));
    this.events.toast('ROLLOVER!', 'bad');
  }

  /** A fuel-tank explosion shoves and damages everything nearby. */
  private blast(x: number, y: number, size: number, sound = true): void {
    const truck = this.truck;
    if (sound) this.boom(size > 1.1 ? 'medium' : 'small', x, y);
    if (this.isNear(x, y)) {
      this.bump(10 * size);
      if (this.options.shake && Math.hypot(x - truck.x, y - truck.y) < 160) this.slowmo = Math.max(this.slowmo, 0.6);
    }
    for (const b of truck.plane ? [] : [truck.cab, truck.trailer]) {
      const dx = b.x - x;
      const dy = b.y - y;
      const d = Math.hypot(dx, dy);
      if (d > 60 || d < 0.1) continue;
      const push = (1 - d / 60) * 40 * size;
      b.vx += (dx / d) * push;
      b.vy += (dy / d) * push;
      if (this.phase === 'playing') {
        truck.damage = Math.min(100, truck.damage + (1 - d / 60) * 18 * size * (truck.dozer ? 0.3 : 1));
        truck.dent(b.x - (dx / d) * 6, b.y - (dy / d) * 6, 2.5);
      }
    }
    this.peds.blast(x, y, 55 * size);
    this.blastStructures(x, y, 40 * size, 80 * size);
  }

  /** Everything a missile can hit. */
  private missileTargets(): { body: Body; layer: number }[] {
    return [...this.traffic.all().map((c) => ({ body: c.body, layer: c.layer })), ...this.services.vehicles.map((v) => ({ body: v.body, layer: v.layer }))];
  }

  /** The sports car fires a missile from its nose. */
  fireMissile(): void {
    const t = this.truck;
    if (this.phase !== 'playing' || this.dying >= 0 || !(t.sports || t.plane)) return;
    if (t.plane) {
      // From the A-10: an AGM-65 Maverick off the outboard pylons.
      if (this.rearming > 0 || this.jetFalling !== false) return;
      const bodies = this.missileTargets().filter((m) => m.layer === 0).map((m) => m.body);
      if (this.air.launchMaverick(this.launch(), bodies)) this.events.missile?.(this.panOf(t.x));
      else if (this.air.mavericks <= 0) {
        this.events.click?.();
        this.events.toast('No Mavericks left (R to re-arm)', 'bad');
      }
      return;
    }
    const nose = t.cab.halfL + 3;
    const side = 0;
    const px = t.x + Math.cos(t.angle) * nose - Math.sin(t.angle) * side;
    const py = t.y + Math.sin(t.angle) * nose + Math.cos(t.angle) * side;
    const targets = this.missileTargets().filter((m) => !t.plane || m.layer === 0);
    if (!this.missiles.fire(px, py, t.angle, t.speed, t.plane ? 0 : t.layer, targets, t.plane)) return;
    this.events.missile?.(this.panOf(t.x));
  }

  /** A missile goes off: fireball, a shockwave that wrecks and throws cars, and a hole in whatever it hit. */
  private missileBlast(x: number, y: number, layer: number): void {
    this.civil.onBlast(x, y, 42, 380, false);
    // What did it hit? A vehicle, a building, or the ground itself.
    const hitCar = this.traffic.all().some((c) => c.layer === layer && Math.hypot(c.x - x, c.y - y) < c.body.halfL + 3);
    this.effects.detonation(x, y, 1.25, 1.2, hitCar ? 'metal' : this.groundAt(x, y));
    this.effects.blastMark(x, y, 30);
    this.blast(x, y, 0.9, false);
    this.boom('medium', x, y);
    const radius = 42;
    for (const car of this.traffic.all()) {
      if (car.layer !== layer) continue;
      const dx = car.x - x;
      const dy = car.y - y;
      const d = Math.hypot(dx, dy);
      if (d > radius) continue;
      const nx = d > 0.1 ? dx / d : Math.cos(car.angle);
      const ny = d > 0.1 ? dy / d : Math.sin(car.angle);
      const push = (1 - d / radius) * 150 + 30;
      if (car.mode === 'drive') this.traffic.syncBody(car);
      car.body.vx += nx * push;
      car.body.vy += ny * push;
      car.vz = Math.max(car.vz, (1 - d / radius) * 60);
      car.rollVel += (Math.random() - 0.5) * 8;
      this.traffic.applyHit(car, push, car.x - nx * car.body.halfW, car.y - ny * car.body.halfW, -nx, -ny, { material: 'car', color: '#ff9a3a' }, 'pileup');
      if (d < radius * 0.5) car.fire = Math.max(car.fire, 12);
    }
    this.services.blast(x, y, radius, layer);
    this.wildlife.blast(x, y, radius);
    this.street.blast(x, y, radius * 0.6);
    this.breakProps(x, y, radius * 0.6);
    this.blastStructures(x, y, radius * 0.7, 380);
    this.drama(x, y, 80);
  }

  /**
   * Set a building or tree alight. Timber goes up readily and burns hot; stone needs a
   * real blaze. Returns true if it caught.
   */
  private ignite(st: Structure, chance = 1, force = false): boolean {
    // Timber and trees burn; brick, stone and concrete only catch rarely (what's inside them).
    const odds = force ? chance : chance * (st.tree ? 1 : st.mat === 'wood' ? 0.85 : 0.12);
    if ((st.burning ?? 0) > 0 || Math.random() > odds) return false;
    if (this.burning.length > 450) return false;
    st.burning = st.tree ? 14 + Math.random() * 10 : st.mat === 'wood' ? 35 + Math.random() * 25 : 50 + Math.random() * 40;
    this.burning.push(st);
    return true;
  }

  private firestormAt = -60;

  /**
   * How fire moves through a city (Hamada's model and the ember studies): flames and
   * radiant heat reach the buildings next door, more readily downwind; embers carried on
   * the wind start spot fires well ahead of the front (most building ignitions); and where
   * enough fires merge they start to feed each other, a firestorm, and everything spreads
   * faster and further.
   */
  private spread(st: Structure, r: number): void {
    const wx = 0.83;
    const wy = -0.55;
    const nearby = this.burning.filter((o) => Math.abs(o.x - st.x) < 220 && Math.abs(o.y - st.y) < 220).length;
    const storm = nearby >= 10;
    if (storm && this.time - this.firestormAt > 40 && this.isNear(st.x, st.y)) {
      this.firestormAt = this.time;
      this.events.toast('FIRESTORM: the fires have merged and are feeding each other', 'bad');
    }
    const boost = (storm ? 2 : 1) * (st.destroyed ? 0.6 : 1);
    const reach = (st.tree ? 18 : st.mat === 'wood' ? 40 : 30) * (storm ? 1.5 : 1);
    for (const n of this.city.structuresNear(st.x, st.y, r + reach, true)) {
      if (n === st || (n.burning ?? 0) > 0 || (n.destroyed && !n.tree)) continue;
      const dx = n.x - st.x;
      const dy = n.y - st.y;
      const d = Math.hypot(dx, dy) || 1;
      const gap = Math.max(0, d - r - Math.max(n.w, n.d) / 2);
      if (gap > reach) continue;
      const downwind = (dx * wx + dy * wy) / d;
      this.ignite(n, (st.tree ? 0.16 : 0.14) * (1 - gap / reach) * (1 + 0.9 * downwind) * boost);
    }
    // Embers on the wind: spot fires out ahead of the front.
    if (Math.random() < (st.mat === 'wood' || st.tree ? 0.18 : 0.1) * boost) {
      const d = range(40, 170) * (storm ? 1.8 : 1);
      const ex = st.x + wx * d + range(-40, 40);
      const ey = st.y + wy * d + range(-40, 40);
      for (const n of this.city.structuresNear(ex, ey, 14)) this.ignite(n, 0.45);
    }
    // In a firestorm the flames whirl up in columns.
    if (storm && Math.random() < 0.025 && inView(st.x, st.y, this.view, 60)) this.effects.gasFire(st.x + range(-r, r) * 0.5, st.y + range(-r, r) * 0.5, range(3, 7));
  }

  /** Burning spots for the fire brigade. */
  burningSpots(): Vec[] {
    return this.burning.map((st) => ({ x: st.x, y: st.y }));
  }

  /** The fire crews hose a spot: buildings and trees there stop burning. */
  douse(x: number, y: number, r: number): void {
    for (const st of this.burning) if (Math.hypot(st.x - x, st.y - y) < r + Math.max(st.w, st.d) / 2) st.burning = 0;
  }

  /**
   * Fire: burning buildings and trees eat away at themselves until they come down (and
   * keep burning as rubble), set their neighbours alight (timber and trees easily, stone
   * rarely), ignite wrecks beside them and send people running. Ground fires and burning
   * wrecks start new ones.
   */
  private updateFires(dt: number): void {
    this.fireTick -= dt;
    const tick = this.fireTick <= 0;
    if (tick) this.fireTick = 0.5;
    let nearest = Infinity;
    for (const st of this.burning) {
      st.burning = (st.burning ?? 0) - dt;
      const r = Math.max(st.w, st.d) / 2;
      if (!st.destroyed) {
        // Fire eats it from within, and anything that burns long enough comes down.
        st.burnTime = (st.burnTime ?? 0) + dt;
        const rate = st.tree ? 0.13 : st.mat === 'wood' ? 0.085 : 0.04;
        const limit = st.tree ? 8 : st.mat === 'wood' ? 12 : 25;
        this.hitStructure(st, st.burnTime > limit ? st.hp + 1 : st.maxHp * rate * dt, 0, 0);
      }
      if (inView(st.x, st.y, this.view, r + 20)) this.effects.flames(st.x, st.y, st.w, st.d, st.angle, dt, st.destroyed ? 0.5 : 1);
      nearest = Math.min(nearest, Math.hypot(st.x - this.truck.x, st.y - this.truck.y));
      if (!tick) continue;
      if (Math.random() < 0.3) this.peds.hearHorn(st.x, st.y);
      this.spread(st, r);
    }
    if (tick) {
      // Burning wrecks set what's beside them alight too.
      for (const w of this.traffic.wrecks) {
        if (w.fire <= 0 || w.layer !== 0) continue;
        for (const st of this.city.structuresNear(w.x, w.y, 10)) this.ignite(st, 0.06);
      }
    }
    for (const st of this.burning) if ((st.burning ?? 0) <= 0) {
      st.burning = 0;
      // The ruins smoulder long after the flames are out.
      if (st.destroyed && inView(st.x, st.y, this.view, 60)) this.effects.smokeColumn(st.x, st.y, 18);
    }
    this.burning = this.burning.filter((st) => (st.burning ?? 0) > 0);
    // The roar and crackle of a big fire nearby.
    this.crackle -= dt;
    if (this.crackle <= 0 && nearest < 400) {
      this.crackle = 0.25;
      this.events.fire?.(Math.max(0, 1 - nearest / 400));
    }
    // The steady roar of every fire within earshot, scaled by how much is burning.
    let roar = 0;
    for (const st of this.burning) {
      const d = Math.hypot(st.x - this.truck.x, st.y - this.truck.y);
      if (d < 600) roar += (1 - d / 600) * (st.tree ? 0.3 : 1);
    }
    this.events.fireLevel?.(Math.min(1, roar / 5));
  }

  /** Destruction points, for A-10 sorties. */
  private tally(x: number, y: number, points: number): void {
    if (!this.truck.plane || this.phase !== 'playing') return;
    this.score += points;
    this.destroyed++;
    // Only the big kills get a number in the sky; the rest just add up.
    if (points >= 60 && this.isNear(x, y) && Math.random() < 0.5) this.floaters.push({ x, y: y - 4, text: `+${points}`, color: '#ffd84a', life: 0.9, scale: 0.8 });
  }

  /**
   * The pall: with enough of the city burning, smoke blots out the sun, a brown-grey
   * murk over everything, thickening as the fires grow.
   */
  private drawPall(ctx: CanvasRenderingContext2D): void {
    const n = this.burning.length;
    if (n < 6) return;
    const k = Math.min(1, (n - 6) / 140);
    ctx.save();
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.fillStyle = `rgba(52, 40, 32, ${0.06 + k * 0.3})`;
    ctx.fillRect(0, 0, this.devW, this.devH);
    // Ash drifting across, flecks of grey and the odd glowing ember.
    const flecks = Math.round(20 + k * 120);
    for (let i = 0; i < flecks; i++) {
      const seed = i * 97.31;
      const x = ((Math.sin(seed) * 0.5 + 0.5) * this.devW + this.time * (30 + (i % 7) * 9) * this.dpr) % this.devW;
      const y = ((Math.cos(seed * 1.7) * 0.5 + 0.5) * this.devH - this.time * (14 + (i % 5) * 6) * this.dpr + this.devH * 4) % this.devH;
      ctx.fillStyle = i % 9 === 0 ? `rgba(255, 140, 50, ${0.5 * k + 0.2})` : `rgba(190, 185, 178, ${0.25 + k * 0.35})`;
      ctx.fillRect(x, y, 1.6 * this.dpr, 1.6 * this.dpr);
    }
    ctx.restore();
  }

  /** The city's vital signs for the panel. */
  cityStatus(): CityStatus {
    const c = this.civil;
    const hospitals = this.city.stations.filter((s) => s.kind === 'hospital');
    return {
      alert: c.alertName,
      level: c.alert,
      power: c.powerShare(),
      water: c.waterShare(),
      comms: c.count('comms'),
      hospitals: [hospitals.filter((s) => !s.structure?.destroyed).length, hospitals.length],
      engines: [this.services.available('fire'), this.services.total('fire')],
      fires: this.burning.length,
      evacuated: c.evacuated(),
      dead: c.dead,
      trapped: c.trapped(),
      rescued: c.rescued,
    };
  }

  /**
   * The air raid, when the player's driving: an A-10 arrives a few seconds in, goes
   * after the city round the player, and if the air defences bring it down another
   * comes in a while later.
   */
  private updateRaid(dt: number): void {
    if (this.truck.plane || this.phase !== 'playing') return;
    if (!this.raider) {
      if (!this.raidEnabled || this.raidRespawn <= 0) return;
      this.raidRespawn -= dt;
      if (this.raidRespawn <= 0) this.spawnRaider();
      return;
    }
    const r = this.raider;
    const wasFiring = r.firing;
    r.update(dt);
    if (r.fall >= 2) {
      this.raider = null;
      this.raidRespawn = this.raidEnabled ? 40 : 0;
      return;
    }
    // Heard from the ground: the roar as it passes, and the gun's tearing BRRRT.
    const d = Math.hypot(r.x - this.truck.x, r.y - this.truck.y);
    if (d < 520 && this.time - this.flybyAt > 2.5) {
      this.flybyAt = this.time;
      this.events.flyby?.(this.panOf(r.x), 1 - d / 520);
    }
    if (r.firing && !wasFiring && d < 1800 && this.time - this.brrtAt > 0.8) {
      this.brrtAt = this.time;
      this.events.brrt?.(this.panOf(r.x), d);
    }
    if (r.damage >= 50 && Math.random() < dt * 20) r.air.smoke(r.x, r.y, r.altitude, r.damage >= 80);
  }

  private spawnRaider(): void {
    const r = new Raider({
      focus: () => ({ x: this.truck.x, y: this.truck.y }),
      pickTarget: (near) => this.raidTarget(near),
      carsNear: (x, y, rad) => this.traffic.all().filter((c) => c.layer === 0 && Math.abs(c.x - x) < rad && Math.abs(c.y - y) < rad).map((c) => c.body),
      shotDown: (x, y) => {
        const ground = this.groundAt(x, y);
        this.effects.detonation(x, y, 3, 1.6, ground);
        this.effects.bombBurst(x, y, 120, 2.6, ground, false);
        this.effects.bombCrater(x, y, 26, ground);
        this.effects.smokeColumn(x, y, 30);
        this.blastStructures(x, y, 120, 1600);
        this.civil.onBlast(x, y, 120, 1600, false);
        this.peds.blast(x, y, 120);
        this.boom('huge', x, y);
        this.events.toast('The A-10 is down! Another will be along', 'info');
      },
      toast: (text) => this.events.toast(text, 'info'),
    });
    r.air.onBomb = (x, y, kind) => this.bombBlast(x, y, kind);
    r.air.onRound = (x, y, hei, vx, vy) => this.gunRound(x, y, hei, vx, vy);
    r.air.onMaverick = (x, y) => this.maverickBlast(x, y);
    this.raider = r;
    this.events.toast('AIR RAID! An A-10 is attacking the city', 'bad');
    this.events.airRaid?.();
  }

  /** What the raider goes for: the city's infrastructure first, then buildings, then the traffic. */
  private raidTarget(near: Vec): RaiderTarget | null {
    const roll = Math.random();
    const within = (x: number, y: number, r: number) => Math.hypot(x - near.x, y - near.y) < r;
    if (roll < 0.3) {
      const infra = this.city.infra.filter((i) => !i.st.destroyed && within(i.st.x, i.st.y, 2600));
      if (infra.length) {
        const i = pick(infra);
        return { x: i.st.x, y: i.st.y, how: i.kind === 'power' || i.kind === 'waterworks' || i.kind === 'shelter' ? 'jdam' : 'bomb' };
      }
    }
    if (roll < 0.75) {
      const blds = this.city.buildings.filter((b) => !b.destroyed && within(b.x, b.y, 1300));
      if (blds.length) {
        const b = pick(blds);
        return { x: b.x, y: b.y, how: 'bomb' };
      }
    }
    if (roll < 0.85) {
      const units = this.services.vehicles.filter((v) => v.layer === 0 && within(v.x, v.y, 1500));
      if (units.length) {
        const v = pick(units);
        return { x: v.x, y: v.y, how: 'maverick', body: v.body };
      }
    }
    const cars = this.traffic.all().filter((c) => c.layer === 0 && within(c.x, c.y, 1200));
    if (cars.length) {
      const c = pick(cars);
      return { x: c.x, y: c.y, how: 'gun', body: c.body };
    }
    return null;
  }

  /** The jet is going down (shot down): seconds into the fall, or false. */
  private jetFalling: number | false = false;

  /** Anti-aircraft fire finds the jet. */
  private hitJet(damage: number): void {
    const t = this.truck;
    if (!t.plane || this.jetFalling !== false) return;
    t.damage = Math.min(100, t.damage + damage);
    this.events.jetHit?.();
    if (this.options.shake) this.shake = Math.max(this.shake, damage > 20 ? 9 : 2.5);
    if (damage > 20) {
      this.flash = Math.max(this.flash, 0.25);
      this.events.toast(`HIT! Airframe ${Math.round(t.damage)}% damaged`, 'bad');
    }
    if (t.damage >= 100) {
      this.jetFalling = 0;
      this.events.toast('She’s going down!', 'bad');
    }
  }

  /** F in the A-10: flares against the missiles. */
  popFlares(): void {
    if (this.phase !== 'playing' || !this.truck.plane) return;
    if (this.civil.popFlares()) this.events.flares?.();
    else if (this.civil.flareCount <= 0 && this.time - this.lastHint > 2) {
      this.lastHint = this.time;
      this.events.toast('Out of flares', 'bad');
    }
  }

  /** The jet's position and velocity, where its weapons leave from. */
  private launch(): Launch {
    const t = this.truck;
    return { x: t.x, y: t.y, z: t.altitude, vx: t.cab.vx, vy: t.cab.vy, heading: t.angle };
  }

  /**
   * The A-10 in flight: wingtip and engine trails, the edge-of-map autopilot, the gun
   * (with its recoil), rippled bombs and re-arming.
   */
  private flight(dt: number, input: Controls): void {
    const t = this.truck;
    const f = t.flight;
    this.air.update(dt);
    this.firing = false;
    if (!t.plane || !f || this.phase !== 'playing') {
      this.air.cease();
      for (const tr of this.trails) tr.length = 0;
      return;
    }
    f.stores = this.air.storesKg;
    // Hit: smoke streams from the damaged jet, then fire.
    if (t.damage >= 45 && Math.random() < dt * (t.damage >= 80 ? 30 : 12)) this.air.smoke(t.x - Math.cos(t.angle) * 30, t.y - Math.sin(t.angle) * 30, t.altitude, t.damage >= 80);
    if (this.jetFalling !== false) {
      // Going down: rolled over, trailing fire, falling out of the sky.
      this.jetFalling += dt;
      f.lever = 0;
      f.bank = Math.min(1.4, f.bank + dt * 1.5);
      f.fall = Math.min(1, this.jetFalling / 2.4);
      t.syncFlight();
      if (f.fall >= 1 && this.dying < 0) {
        const ground = this.groundAt(t.x, t.y);
        this.effects.detonation(t.x, t.y, 3, 1.6, ground);
        this.effects.bombBurst(t.x, t.y, 120, 2.6, ground, false);
        this.effects.bombCrater(t.x, t.y, 26, ground);
        this.effects.smokeColumn(t.x, t.y, 30);
        this.blastStructures(t.x, t.y, 120, 1600);
        this.peds.blast(t.x, t.y, 120);
        this.boom('huge', t.x, t.y);
        this.events.jetDown?.();
        this.flash = Math.max(this.flash, 0.5);
        this.dying = 3.4;
      }
      this.air.cease();
      return;
    }
    // Record where the wingtips and engines have been (matching how the plane is drawn).
    const c = Math.cos(t.angle);
    const sn = Math.sin(t.angle);
    const span = (8.6 / METERS) * Math.cos(t.bank * 0.7);
    const z = t.altitude;
    const at = (lx: number, ly: number) => ({ x: t.x + c * lx - sn * ly, y: t.y + sn * lx + c * ly, z });
    const pull = clamp((f.g - 1.3) / 2.5, 0, 1);
    [at(-1, -span), at(-1, span), at(-14, -4.2), at(-14, 4.2)].forEach((p, i) => {
      const tr = this.trails[i];
      tr.push({ ...p, t: this.time, bank: pull });
      const keep = i < 2 ? 1.6 : 0.7;
      while (tr.length && this.time - tr[0].t > keep) tr.shift();
    });
    // Over the edge of the map: the autopilot banks it back toward the city.
    const margin = 200;
    if (t.x < margin || t.y < margin || t.x > MAP_W - margin || t.y > MAP_H - margin) {
      const home = Math.atan2(MAP_H / 2 - t.y, MAP_W / 2 - t.x);
      f.heading += Math.max(-0.9 * dt, Math.min(0.9 * dt, angleDiff(home, f.heading)));
      t.syncFlight();
    }

    // The GAU-8: hold G. 45 kN of recoil, more than one engine's thrust.
    if (input.gun && this.rearming <= 0) {
      if (this.air.rounds > 0) {
        const fired = this.air.fire(dt, this.launch());
        if (fired > 0) {
          this.firing = true;
          f.recoil = GUN_RECOIL * (0.55 + 0.45 * this.air.spin);
          if (this.options.shake) this.shake = Math.max(this.shake, 0.8);
          if (this.time - this.lastBrrt > 2.5) {
            this.lastBrrt = this.time;
            const p = this.lift(t.x + c * 90, t.y + sn * 90, z);
            if (p) this.floaters.push({ x: p.x, y: p.y, text: 'BRRRRRT!', color: '#ffd84a', life: 1, scale: 1.2 });
          }
        }
      } else if (this.time - this.lastHint > 2) {
        this.lastHint = this.time;
        this.events.click?.();
        this.events.toast('Gun empty: Winchester on 30 mm (R to re-arm)', 'bad');
      }
    } else this.air.cease();

    // Hold B to ripple: a bomb every 0.12 s for as long as it's held.
    if (input.bomb) {
      this.bombHeld += dt;
      if (this.bombHeld > 0.3) this.dropBomb(true);
    } else this.bombHeld = 0;

    if (this.rearming > 0) {
      this.rearming -= dt;
      if (this.rearming <= 0) {
        this.air.rearm();
        this.civil.flareCount = 30;
        // The ground crew patches the worst of the holes while they're at it.
        t.damage = Math.max(0, t.damage - 40);
        this.events.rearmed?.();
        this.events.toast('Re-armed and patched: 1,150 rounds, 6 × Mk 82, GBU-31, 2 × Maverick, 30 flares', 'good');
      }
    }
  }

  /** R in the A-10: call up more ordnance. */
  rearm(): void {
    const t = this.truck;
    if (this.phase !== 'playing' || !t.plane || this.rearming > 0) return;
    this.rearming = 5;
    this.events.toast('Ordnance on the way: weapons cold for 5 seconds', 'info');
  }

  /** V: step to the next bomb mode. */
  cycleWeapon(): void {
    if (this.phase !== 'playing' || !this.truck.plane) return;
    const kind = this.air.cycleMode();
    this.events.click?.();
    this.events.toast(`${BOMB_NAMES[kind]} · ${this.air.stock(kind)} left`, 'info');
  }

  /** The HUD's view of the jet and its weapons (null on the ground). */
  flightHud(): FlightHud | null {
    const t = this.truck;
    const f = t.flight;
    if (!f || this.phase === 'title') return null;
    const from = this.launch();
    const bomb = this.air.stock() > 0 ? this.air.ccipBomb(from) : null;
    const gun = this.air.ccipGun(from);
    const screen = (x: number, y: number, z = 0) => {
      const p = this.lift(x, y, z);
      return p ? { x: (p.x - this.view.x) * this.zoom, y: (p.y - this.view.y) * this.zoom } : null;
    };
    return {
      speedKmh: f.v * 3.6,
      heading: f.heading,
      g: f.g,
      lever: f.lever,
      spool: f.spool,
      jet: screen(t.x, t.y, t.altitude),
      bomb: bomb ? { at: screen(bomb.x, bomb.y), t: bomb.t } : null,
      gun: screen(gun.x, gun.y),
      mode: this.air.mode,
      rounds: this.air.rounds,
      mk82: this.air.mk82,
      gbu31: this.air.gbu31,
      mavericks: this.air.mavericks,
      rearming: this.rearming,
      firing: this.firing,
      missile: this.civil.missileWarning,
      flares: this.civil.flareCount,
      time: this.time,
    };
  }

  /** Release a bomb (B, or held B to ripple). */
  dropBomb(ripple = false): void {
    const t = this.truck;
    if (this.phase !== 'playing' || !t.plane || this.dying >= 0 || this.rearming > 0 || this.jetFalling !== false) return;
    if (this.air.stock() <= 0) {
      if (!ripple) {
        this.events.click?.();
        this.events.toast(this.air.winchester ? 'Winchester: nothing left on the racks (R to re-arm)' : `No ${BOMB_NAMES[this.air.mode]} left: V to switch`, 'bad');
      }
      return;
    }
    const from = this.launch();
    const aim = this.air.mode === 'gbu31' ? this.air.ccipBomb(from, 'gbu31') : null;
    if (!this.air.drop(from, aim)) return;
    this.events.bombDrop?.(this.panOf(t.x));
    this.truck.flight!.stores = this.air.storesKg;
  }

  /**
   * A bomb goes off. The tritonal (TNT with 20 % aluminium) flashes white-hot, the steel
   * case swells and shatters into a spray of fragments that pepper everything far beyond
   * the fireball, the shock wave flattens what's close, a cone of earth is thrown up and
   * rains back in a ring, the dust column slumps and rolls out low along the ground, and
   * on a humid day a vapour ring flashes out with the shock. A Mk 82 carries 87 kg of
   * explosive; the GBU-31's Mk 84 carries 429 kg, and everything scales with it.
   */
  private bombBlast(x: number, y: number, kind: BombKind): void {
    const big = kind === 'gbu31';
    // Blast radius scales with the cube root of the charge: about 1.7 × for the Mk 84.
    const R = big ? 165 : 98;
    this.civil.onBlast(x, y, R, big ? 1400 : 700, this.city.surfaceAt(x, y) === SURF_ROAD);
    const size = big ? 3.8 : 2.8;
    const ground = this.groundAt(x, y);
    const wet = ground === 'water';
    this.effects.detonation(x, y, size, 1.6, ground);
    this.effects.bombBurst(x, y, R, size, ground, this.humid());
    this.effects.smokeColumn(x, y, big ? 34 : 22);
    if (!wet) {
      this.effects.blastMark(x, y, R * 0.75);
      this.effects.bombCrater(x, y, big ? 40 : 24, ground);
    }
    this.boom('huge', x, y);
    this.events.bigBlast?.(this.panOf(x), big ? 2.2 : 1.4);
    const t = this.truck;
    const slant = Math.hypot(x - t.x, y - t.y, t.altitude) * METERS;
    if (this.isNear(x, y)) {
      // From the cockpit it's a flash below, not a wall of light.
      this.flash = Math.max(this.flash, (big ? 0.7 : 0.5) * (t.plane ? 0.45 : 1));
      // Felt through the airframe: the closer, the harder the jolt.
      this.bump(clamp((big ? 34 : 24) * (1 - slant / 1600), 4, big ? 34 : 24));
      this.hitStop = Math.max(this.hitStop, (big ? 0.12 : 0.08) * (t.plane ? 0.4 : 1));
    }
    // On the ground under someone else's bombs: the blast throws the player's ride too.
    if (!t.plane && t.layer === 0) {
      const d = Math.hypot(t.x - x, t.y - y);
      if (d < R * 1.3) {
        const k = 1 - d / (R * 1.3);
        const nx = d > 0.1 ? (t.x - x) / d : 1;
        const ny = d > 0.1 ? (t.y - y) / d : 0;
        for (const b of [t.cab, t.trailer]) {
          b.vx += nx * k * 220;
          b.vy += ny * k * 220;
        }
        t.damage = Math.min(100, t.damage + k * (big ? 70 : 45) * this.armor());
        this.events.crash?.(1 + k * 1.5, 'wall', this.panOf(x));
      }
    }
    for (const car of this.traffic.all()) {
      if (car.layer !== 0) continue;
      const dx = car.x - x;
      const dy = car.y - y;
      const d = Math.hypot(dx, dy);
      if (d > R * 2.6) continue;
      const nx = d > 0.1 ? dx / d : 1;
      const ny = d > 0.1 ? dy / d : 0;
      if (car.mode === 'drive') this.traffic.syncBody(car);
      if (d > R) {
        // Out past the fireball the fragments still arrive: glass gone, panels holed.
        if (Math.random() < 1 - (d - R) / (R * 1.6)) {
          this.traffic.applyHit(car, 18 + Math.random() * 30, car.x - nx * car.body.halfW, car.y - ny * car.body.halfW, -nx, -ny, { material: 'car', color: '#ffb35a' }, 'pileup');
          if (Math.random() < 0.12) car.fire = Math.max(car.fire, 10);
        }
        continue;
      }
      const push = (1 - d / R) * (big ? 340 : 260) + 40;
      car.body.vx += nx * push;
      car.body.vy += ny * push;
      car.vz = Math.max(car.vz, (1 - d / R) * (big ? 260 : 210) + 20);
      car.dmg.z = Math.max(car.dmg.z, 0.5);
      car.rollVel += (Math.random() - 0.5) * 14;
      this.traffic.applyHit(car, push * 1.2, car.x - nx * car.body.halfW, car.y - ny * car.body.halfW, -nx, -ny, { material: 'car', color: '#ff9a3a' }, 'pileup');
      // Caught in the blast, cars burn rather than going off one after another.
      car.fuse = 0;
      car.exploded = true;
      if (d < R * 0.7) car.fire = Math.max(car.fire, 18);
    }
    this.services.blast(x, y, R, 0);
    this.peds.blast(x, y, R);
    // Fragments reach people well beyond the blast itself.
    this.peds.blast(x, y, R * 2.2);
    this.wildlife.blast(x, y, R * 1.6);
    this.street.blast(x, y, R * 0.8);
    this.breakProps(x, y, R * 0.9);
    this.blastStructures(x, y, R * 1.05, big ? 2600 : 1700);
    // Nothing near ground zero is left standing.
    for (const st of this.city.structuresNear(x, y, R * 0.45)) this.hitStructure(st, st.hp + 1, st.x - x, st.y - y, true);
    this.drama(x, y, big ? 200 : 160, false);
  }

  /** On a damp morning or at night the shock wave can condense a vapour ring. */
  private humid(): boolean {
    const dampHours = this.hour < 9 || this.hour > 20;
    return Math.random() < (dampHours ? 0.6 : 0.2);
  }

  /** An AGM-65 Maverick's warhead: the missile blast, a size up. */
  private maverickBlast(x: number, y: number): void {
    this.missileBlast(x, y, 0);
    this.effects.detonation(x, y, 1.1, 1, this.groundAt(x, y));
    this.drama(x, y, 120, false);
  }

  /**
   * One 30 mm round. Five in six are armour-piercing incendiary: a small hole in, a
   * spark and a flash of burning uranium, and real harm only if it finds the engine or
   * the fuel tank (or the soft side of something). Every sixth is high-explosive
   * incendiary: a little blast that sets fuel and timber alight. All of them gouge the
   * ground, so a strafing run leaves its scar.
   */
  private gunRound(x: number, y: number, hei: boolean, vx: number, vy: number): void {
    const ground = this.city.surfaceAt(x, y);
    const earth = ground === SURF_GRASS || ground === SURF_DIRT;
    const near = this.isNear(x, y);
    const what = this.groundAt(x, y);
    const angle = Math.atan2(vy, vx);
    if (what === 'water') {
      // Rounds into water: spouts of spray instead of gouges.
      if (near) this.effects.water(x, y, x + (Math.random() - 0.5) * 8, y - 6);
    } else if (what === 'wood' || what === 'stone') {
      // Into a building: splinters or chips of masonry.
      if (near) this.effects.crash(x, y, hei ? 0.25 : 0.12, what === 'wood' ? ['#8a6a44', '#a0703c'] : ['#9e978a', '#6f6a62'], { x: 0, y: 0 }, { material: what === 'wood' ? 'wood' : 'concrete', glass: Math.random() < 0.2 });
    } else this.effects.gouge(x, y, angle, earth, near);
    if (near) {
      if (hei) {
        this.effects.puff(x, y, false, true, 1.1);
        if (!earth) this.effects.scrape(x, y, (Math.random() - 0.5) * 80, (Math.random() - 0.5) * 80);
      } else {
        // The uranium penetrator flashes as it strikes; sparks skip off anything hard.
        if (!earth || Math.random() < 0.3) this.effects.scrape(x, y, Math.cos(angle) * 120 + (Math.random() - 0.5) * 60, Math.sin(angle) * 120 + (Math.random() - 0.5) * 60);
        if (earth && Math.random() < 0.5) this.effects.dustSpurt(x, y, angle);
      }
    }
    for (const car of this.traffic.all()) {
      if (car.layer !== 0 || Math.abs(car.x - x) > 9 || Math.abs(car.y - y) > 9) continue;
      const lx = (x - car.x) * Math.cos(car.angle) + (y - car.y) * Math.sin(car.angle);
      const ly = -(x - car.x) * Math.sin(car.angle) + (y - car.y) * Math.cos(car.angle);
      if (Math.abs(lx) > car.body.halfL + 1.5 || Math.abs(ly) > car.body.halfW + 1.5) continue;
      if (car.mode === 'drive') this.traffic.syncBody(car);
      // Engine up front, fuel tank at the back: hits there are what kill a car.
      const vital = Math.abs(lx) > car.body.halfL * 0.45;
      const push = hei ? 55 : vital ? 38 : 16;
      this.traffic.applyHit(car, push, x, y, Math.cos(angle) * 0.6, Math.sin(angle) * 0.6, { material: 'car', color: hei ? '#ffd84a' : '#fff1c4' }, 'pileup');
      if (hei && Math.random() < 0.3) car.fire = Math.max(car.fire, 12);
      else if (!hei && vital && Math.random() < 0.12) car.fire = Math.max(car.fire, 10);
      if (vital && Math.random() < (hei ? 0.1 : 0.04)) car.fuse = Math.max(car.fuse, 0.3);
    }
    const r = hei ? 7 : 3;
    if (hei) this.civil.onBlast(x, y, 10, 14, false);
    // The raider's rounds find the player's ride too.
    const t = this.truck;
    if (!t.plane && t.layer === 0 && Math.hypot(t.x - x, t.y - y) < t.cab.halfL + 3) {
      t.damage = Math.min(100, t.damage + (hei ? 4 : 2) * this.armor());
      t.dent(x, y, hei ? 1.4 : 0.7);
      if (near) this.effects.scrape(x, y, (Math.random() - 0.5) * 120, (Math.random() - 0.5) * 120);
    }
    this.services.blast(x, y, r + 1, 0);
    this.peds.blast(x, y, r);
    this.wildlife.blast(x, y, r);
    this.street.blast(x, y, hei ? 5 : 2.5);
    this.breakProps(x, y, hei ? 6 : 3);
    // Armour-piercing rounds punch holes; the explosive ones chew. A building takes a long burst.
    for (const st of this.city.structuresNear(x, y, hei ? 4 : 2, true)) {
      // Only the explosive rounds can set what they bring down alight.
      if (!st.destroyed) this.hitStructure(st, st.tree ? (hei ? 12 : 5) : st.station ? (hei ? 8 : 3) : hei ? 14 : 5, st.x - x, st.y - y, hei);
    }
  }

  /** Market stalls and party gear caught in a blast. */
  private breakProps(x: number, y: number, r: number): void {
    for (const prop of this.props) {
      if (prop.broken || Math.hypot(prop.body.x - x, prop.body.y - y) > r) continue;
      prop.broken = true;
      const dir = { x: prop.body.x - x, y: prop.body.y - y };
      const l = Math.hypot(dir.x, dir.y) || 1;
      this.effects.crash(prop.body.x, prop.body.y, 0.8, prop.kind === 'stall' ? [prop.colors[0], prop.colors[1], '#8a6a44'] : ['#1b1b1f', '#55524a'], { x: dir.x / l, y: dir.y / l }, { material: 'wood', glass: false });
    }
  }

  /** How much a knock hurts: the sports car is fragile, the dozer is armour plate. */
  private armor(): number {
    return this.truck.sports ? 1.5 : this.truck.dozer ? 0.1 : 1;
  }

  /** How hard the rig hits a building: speed beyond what the wall can shrug off, times the rig's weight. */
  private rigPunch(impact: number, st: Structure): number {
    // A sports car weighs a fraction of the rig: it takes real speed to go through a wall.
    const weight = this.truck.sports ? 0.45 : this.truck.dozer ? 4 : this.truck.loaded ? 1.6 : 1.2;
    return Math.max(0, impact - (st.mat === 'wood' ? 15 : 45)) * weight;
  }

  /**
   * Look a moment ahead of the moving rig: if it's about to hit a building hard enough
   * to bring it down, bring it down first, so the rig ploughs through instead of bouncing.
   */
  private smashAhead(dt: number): void {
    const t = this.truck;
    if (t.layer !== 0) return;
    for (const b of [t.cab, t.trailer]) {
      const v = Math.hypot(b.vx, b.vy);
      if (v < 25) continue;
      const ux = b.vx / v;
      const uy = b.vy / v;
      const reach = b.halfL * 0.8 + v * dt * 2;
      const px = b.x + ux * reach;
      const py = b.y + uy * reach;
      const push = this.city.resolveCircle(px, py, b.halfW, 0);
      const st = this.city.lastStructure;
      if (!st || (!push.x && !push.y)) continue;
      const l = Math.hypot(push.x, push.y);
      const closing = -(b.vx * push.x + b.vy * push.y) / l;
      if (closing < 20 || st.hp > this.rigPunch(closing, st)) continue;
      this.hitStructure(st, this.rigPunch(closing, st), ux, uy);
      // Through the wall: it costs speed (more for stone) and hurts.
      const keep = st.mat === 'wood' ? 0.82 : 0.55;
      for (const body of [t.cab, t.trailer]) {
        body.vx *= keep;
        body.vy *= keep;
      }
      t.damage = Math.min(100, t.damage + (st.mat === 'wood' ? 3 : 12));
      t.dent(px, py, st.mat === 'wood' ? 1.2 : 2.5);
      if (this.phase === 'playing') this.floaters.push({ x: st.x, y: st.y, text: st.mat === 'wood' ? 'SMASHED THROUGH!' : 'DEMOLISHED!', color: DANGER, life: 1.5, scale: 1.2 });
      return;
    }
  }

  /** A building takes a hit; enough and it comes down. (dx, dy): direction of the blow. */
  /**
   * A building or tree takes a hit; enough and it comes down. `blast`: an explosion did
   * it, and an explosion that brings down something that burns sets it alight.
   */
  private hitStructure(st: Structure, amount: number, dx: number, dy: number, blast = false): void {
    if (!this.city.damageStructure(st, amount)) return;
    const l = Math.hypot(dx, dy) || 1;
    this.collapse(st, dx / l, dy / l);
    if (blast) this.burnFromBlast(st);
  }

  /** Blown down: trees and timber always go up; brick and stone burn on what was inside. */
  private burnFromBlast(st: Structure): void {
    if (st.tree || st.mat === 'wood') this.ignite(st, 1, true);
    else if (st.building || st.station) this.ignite(st, 0.7, true);
  }

  /** Down it comes: debris everywhere, dust, maybe fire, and a crater-free hole in the skyline. */
  private collapse(st: Structure, dx: number, dy: number): void {
    st.destroyed = true;
    this.civil.onCollapse(st);
    if (st.fuel) {
      // Fuel, ammunition, aircraft: it goes up a moment later, and can set off its neighbours.
      this.pendingBlasts.push({ t: this.time + 0.05 + Math.random() * 0.3, x: st.x, y: st.y, size: Math.min(3.6, 1.1 + st.fuel) });
      this.ignite(st, 1, true);
    }
    // Things fall away from whatever knocked them down.
    st.fallAngle = dx || dy ? Math.atan2(dy, dx) : Math.random() * Math.PI * 2;
    st.fellAt = this.time;
    if (st.tree) {
      // A tree goes over in a burst of leaves and splinters.
      this.effects.crash(st.x, st.y, 0.45, ['#2f6a35', '#6db454', '#4a3222', '#1f4d2c'], { x: dx, y: dy }, { material: 'wood', glass: false });
      if (!this.collapsed.includes(st)) this.collapsed.push(st);
      this.tally(st.x, st.y, 5);
      return;
    }
    this.effects.collapse(st.x, st.y, st.w, st.d, st.angle, st.mat, dx * 1.2, dy * 1.2);
    if (st.mat === 'wood' && Math.random() < 0.35) this.effects.structureHit(st.x, st.y, 130, dx, dy, 'wood');
    this.boom(st.mat === 'wood' ? 'collapse-wood' : 'collapse-stone', st.x, st.y);
    if (this.isNear(st.x, st.y)) {
      this.bump(st.mat === 'wood' ? 8 : 14);
      if (st.building && Math.hypot(st.x - this.truck.x, st.y - this.truck.y) < 250) this.hitStop = Math.max(this.hitStop, 0.04);
    }
    // Falling masonry and timber batter whatever's right next door.
    for (const n of this.city.structuresNear(st.x, st.y, Math.max(st.w, st.d) / 2 + 6)) {
      if (n === st || n.tree) continue;
      this.hitStructure(n, n.maxHp * (st.mat === 'stone' ? 0.35 : 0.2), n.x - st.x, n.y - st.y);
    }
    this.peds.blast(st.x, st.y, Math.max(st.w, st.d) * 0.6);
    this.peds.hearHorn(st.x, st.y);
    if (!this.collapsed.includes(st)) this.collapsed.push(st);
    this.tally(st.x, st.y, st.tree ? 5 : st.building ? 120 : 20);
  }

  /** Explosions knock buildings about too: within r, up to `power` damage. */
  private blastStructures(x: number, y: number, r: number, power: number): void {
    for (const st of this.city.structuresNear(x, y, r)) {
      const d = Math.max(0, Math.hypot(st.x - x, st.y - y) - Math.max(st.w, st.d) / 2);
      const amount = power * (1 - d / r) * (st.mat === 'wood' ? 1.4 : 0.8);
      this.hitStructure(st, amount, st.x - x, st.y - y, true);
    }
  }

  /** Collapsed buildings for the repair crews: rebuilt once nothing's standing in the way. */
  private rubble(): Repairable[] {
    // Felled trees stay down; the crews rebuild the rest.
    return this.collapsed.filter((st) => !st.tree).map((st) => ({
      x: st.x,
      y: st.y,
      fix: () => {
        const r = Math.max(st.w, st.d) / 2 + 6;
        const blocked = Math.hypot(this.truck.x - st.x, this.truck.y - st.y) < r || this.traffic.all().some((c) => Math.hypot(c.x - st.x, c.y - st.y) < r);
        if (blocked) return;
        this.city.rebuild(st);
        this.collapsed.splice(this.collapsed.indexOf(st), 1);
        for (let i = 0; i < 6; i++) this.effects.dust(st.x + (Math.random() - 0.5) * st.w, st.y + (Math.random() - 0.5) * st.d, 2);
      },
    }));
  }

  /** Rubble where buildings stood: splintered timber or broken stone. */
  private drawRubble(ctx: CanvasRenderingContext2D): void {
    for (const st of this.collapsed) {
      if (!inView(st.x, st.y, this.view, Math.max(st.w, st.d))) continue;
      if (st.tree) {
        // A felled, scorched tree: blackened ground, a stump and the trunk lying across it.
        const r = st.w / 2;
        ctx.fillStyle = 'rgba(38, 32, 26, 0.85)';
        ctx.beginPath();
        ctx.arc(st.x, st.y, r, 0, Math.PI * 2);
        ctx.fill();
        const a = st.fallAngle ?? st.id * 2.399;
        ctx.strokeStyle = '#4a3222';
        ctx.lineWidth = 1.6;
        ctx.beginPath();
        ctx.moveTo(st.x, st.y);
        ctx.lineTo(st.x + Math.cos(a) * r * 1.3, st.y + Math.sin(a) * r * 1.3);
        ctx.stroke();
        ctx.fillStyle = '#2b1d14';
        ctx.beginPath();
        ctx.arc(st.x, st.y, 1.6, 0, Math.PI * 2);
        ctx.fill();
        continue;
      }
      const wood = st.mat === 'wood';
      ctx.save();
      ctx.translate(st.x, st.y);
      ctx.rotate(st.angle);
      ctx.fillStyle = wood ? '#3a2c22' : '#4a4642';
      ctx.fillRect(-st.w / 2, -st.d / 2, st.w, st.d);
      const n = Math.min(40, 8 + (st.w * st.d) / 60);
      for (let i = 0; i < n; i++) {
        const u = Math.sin(i * 12.9898 + st.id) * 0.46 * st.w;
        const v = Math.sin(i * 78.233 + st.id * 3.1) * 0.46 * st.d;
        ctx.fillStyle = wood ? ['#8a6a44', '#5d4037', '#a0703c'][i % 3] : ['#9e978a', '#6f6a62', '#b8b2a6'][i % 3];
        ctx.save();
        ctx.translate(u, v);
        ctx.rotate(i * 0.7);
        ctx.fillRect(-2, -0.6, wood ? 5 : 3, wood ? 1.2 : 2.4);
        ctx.restore();
      }
      ctx.strokeStyle = 'rgba(0, 0, 0, 0.45)';
      ctx.lineWidth = 0.8;
      ctx.strokeRect(-st.w / 2, -st.d / 2, st.w, st.d);
      // The collapse itself: the roof slumps and caves in, cracking apart, over a second.
      const fall = st.fellAt === undefined ? 1 : (this.time - st.fellAt) / 1.1;
      if (fall < 1) {
        const k = 1 - fall;
        const sink = 1 - fall * 0.25;
        ctx.globalAlpha = k;
        ctx.fillStyle = wood ? '#7a5a3a' : '#8a857c';
        ctx.fillRect((-st.w / 2) * sink, (-st.d / 2) * sink, st.w * sink, st.d * sink);
        ctx.strokeStyle = 'rgba(20, 16, 14, 0.8)';
        ctx.lineWidth = 0.9;
        ctx.beginPath();
        for (let i = 0; i < 6; i++) {
          const a = st.id + i * 1.7;
          ctx.moveTo(0, 0);
          ctx.lineTo(Math.cos(a) * st.w * 0.5 * sink * (0.4 + fall), Math.sin(a) * st.d * 0.5 * sink * (0.4 + fall));
        }
        ctx.stroke();
        ctx.fillStyle = `rgba(20, 16, 14, ${0.5 * fall})`;
        ctx.beginPath();
        ctx.ellipse(0, 0, st.w * 0.3 * fall, st.d * 0.3 * fall, 0, 0, Math.PI * 2);
        ctx.fill();
        ctx.globalAlpha = 1;
      }
      ctx.restore();
    }
  }

  private wallCrash(impact: number, x: number, y: number, material: Material): void {
    const truck = this.truck;
    const k = impact / 100;
    const hard = material === 'concrete' ? 1.15 : material === 'barrier' ? 0.75 : 1;
    truck.damage = Math.min(100, truck.damage + Math.max(0, impact - 16) * 0.16 * hard * this.armor());
    truck.dent(x, y, k * 2.2 * hard);
    const dir = { x: x - truck.x, y: y - truck.y };
    const l = Math.hypot(dir.x, dir.y) || 1;
    this.effects.crash(x, y, k * hard, [COLORS.cab, COLORS.trailer, '#8d9096', '#55524a'], { x: -dir.x / l, y: -dir.y / l }, { panels: impact > 90 ? 1 : 0, material, glass: impact > 60 });
    this.drama(x, y, impact * hard);
    if (material === 'concrete' || material === 'wood') {
      this.effects.structureHit(x, y, impact * hard, -dir.x / l, -dir.y / l, material);
      if (impact > 30 && this.isNear(x, y)) this.events.structureHit?.(impact * hard, this.panOf(x));
    }
    this.bump(2 + k * 8);
    if (impact > 120 && this.options.shake) this.slowmo = Math.max(this.slowmo, 0.5);
    if (this.time - this.lastWallHit > 0.15) this.events.crash?.(k, 'wall', this.panOf(x));
    this.lastWallHit = this.time;
  }

  private collideTruckWithCars(active: boolean): void {
    const truck = this.truck;
    for (const car of this.traffic.all()) {
      if (car.layer !== truck.layer || car.dmg.z > 8) continue;
      if (Math.abs(car.x - truck.x) > 70 || Math.abs(car.y - truck.y) > 70) continue;
      for (const body of [truck.cab, truck.trailer]) {
        if (car.mode === 'drive') this.traffic.syncBody(car);
        const c = boxContact(body, car.body);
        if (!c) continue;
        if (car.mode === 'drive' && Math.hypot(body.vx, body.vy) < 8) {
          // A driver nosing into the parked rig: a bump, they wait.
          this.traffic.bump(car, c.depth);
          continue;
        }
        const hit = resolveContact(body, car.body, c, 0.12, 0.45);
        if (hit.closing < 6) continue;
        const k = this.traffic.applyHit(car, hit.closing, c.px, c.py, -c.nx, -c.ny, { material: 'truck', color: COLORS.cab }, 'car');
        if (k <= 0.12 || !active) continue;
        truck.damage = Math.min(100, truck.damage + Math.max(0, hit.closing - 14) * 0.06 * (car.body.mass / 1.5) * (truck.sports ? 2.2 : this.armor()));
        truck.dent(c.px, c.py, k * 1.6);
        this.drama(c.px, c.py, hit.closing * Math.min(1.5, 0.6 + car.body.mass / 3));
      }
    }
  }

  private isNear(x: number, y: number): boolean {
    return inView(x, y, this.view, 80);
  }

  private panOf(x: number): number {
    return Math.max(-1, Math.min(1, ((x - this.view.x) / Math.max(1, this.view.w)) * 2 - 1));
  }

  /** Every shift starts out in the country or the industrial park. */
  private newTruck(): Truck {
    const starts = this.city.lanes.filter(
      (l) => l.kind === 'street' && (l.district === 'country' || l.district === 'industrial') && l.layer0 === 0 && l.len > 80,
    );
    const lane = starts.length ? pick(starts) : null;
    if (lane) {
      const p = poseAt(lane, lane.len * 0.5);
      return new Truck(p.x, p.y, p.angle, this.vehicle);
    }
    const spot = pick(this.city.pickupSpots);
    return new Truck(spot.x, spot.y, spot.angle, this.vehicle);
  }

  towBusy(): boolean {
    return !!this.services.active('mytow');
  }

  /** What each tow option costs right now: it's your own truck, so mates' rates. */
  towQuote(): Record<TowChoice, number> {
    const repair = Math.round(this.truck.damage * 1.5) + 20;
    return { repair, reposition: 25, both: repair + 20 };
  }

  requestTow(choice: TowChoice): void {
    if (this.phase !== 'playing' || this.towBusy()) return;
    if (this.truck.plane) {
      this.events.toast('No tow trucks up here, Warthog', 'info');
      return;
    }
    const cost = this.towQuote()[choice];
    this.towChoice = choice;
    // Your own tow truck: it's never far away and nothing gets in its way.
    const ok = this.services.dispatchExpress(this.truck.x, this.truck.y, {
        points: () => [{ x: this.truck.x, y: this.truck.y }],
        work: 1.2,
        onWorking: (crew, point) => {
          if (Math.random() < 0.3) this.effects.scrape(point.x, point.y, crew.x - point.x, crew.y - point.y);
          this.workTimer -= 1 / 60;
          if (this.workTimer <= 0) {
            this.workTimer = 0.8;
            this.events.towWork?.(this.panOf(point.x));
          }
        },
        onPointDone: () => this.applyTow(this.towChoice),
    });
    if (!ok) {
      this.events.toast('Your tow truck can\u2019t find a road to you here', 'bad');
      return;
    }
    this.score = Math.max(0, this.score - cost);
    this.events.toast(`Your tow truck is on its way (-$${cost})`, 'info');
  }

  private applyTow(choice: TowChoice): void {
    const t = this.truck;
    if (choice !== 'reposition') {
      t.damage = 0;
      t.cabDents.length = 0;
      t.trailerDents.length = 0;
    }
    if (choice !== 'repair') {
      const p = this.services.roadPointNear(t.x, t.y);
      if (p) {
        for (const b of [t.cab, t.trailer]) {
          b.vx = b.vy = 0;
          b.av = 0;
          b.angle = p.angle;
        }
        t.cab.x = p.x;
        t.cab.y = p.y;
        t.trailer.x = p.x - Math.cos(p.angle) * 24;
        t.trailer.y = p.y - Math.sin(p.angle) * 24;
        t.rolled = false;
        t.roll = 0;
        t.rollVel = 0;
        t.layer = p.layer;
        this.rolledTime = 0;
      }
    }
    const text = choice === 'repair' ? 'GOOD AS NEW!' : choice === 'reposition' ? 'BACK ON THE ROAD!' : 'FIXED & ON THE ROAD!';
    this.floaters.push({ x: t.x, y: t.y, text, color: PICKUP, life: 1.8, scale: 1.2 });
  }

  /** The rig rammed a city service unit. */
  private hitUnit(v: ServiceVehicle, closing: number, px: number, py: number): void {
    if (this.phase !== 'playing' || this.dying >= 0) return;
    const truck = this.truck;
    truck.damage = Math.min(100, truck.damage + Math.max(0, closing - 14) * 0.06 * (v.body.mass / 1.5));
    truck.dent(px, py, Math.min(3, closing / 60));
    this.drama(px, py, closing * Math.min(1.5, 0.6 + v.body.mass / 3));
    if (this.isNear(px, py)) {
      this.events.crash?.(closing / 100, 'car', this.panOf(px));
      if (closing > 20) this.events.carFlavor?.(v.look.model, closing / 100, this.panOf(px));
    }
  }

  /** A unit written off: its shell joins the wrecks (and gets towed like any other). */
  private unitWrecked(v: ServiceVehicle): void {
    const car = this.traffic.adoptWreck(v.look, v.body, v.layer, v.dmg);
    car.engine = 60;
    car.smokeKind = 'black';
    car.smokeLevel = 0.7;
    car.alarm = 0;
    if (v.towing) {
      // Whatever it was towing drops off the hook.
      const back = v.body.halfL + v.towing.look.length / 2;
      const towed = this.traffic.adoptWreck(v.towing.look, { ...v.body, x: v.x - Math.cos(v.body.angle) * back, y: v.y - Math.sin(v.body.angle) * back }, v.layer, v.towing.dmg);
      towed.wreckTime = 999;
    }
    if (this.phase === 'playing' && Math.hypot(v.x - this.truck.x, v.y - this.truck.y) < 300) {
      this.floaters.push({ x: v.x, y: v.y, text: 'UNIT DOWN!', color: DANGER, life: 1.5, scale: 1.1 });
    }
  }

  /** Smashed stalls and party gear, for the repair crews. */
  private brokenProps(): Repairable[] {
    return this.props
      .filter((p) => p.broken)
      .map((p) => ({
        x: p.body.x,
        y: p.body.y,
        fix: () => {
          p.broken = false;
        },
      }));
  }

  private end(reason: string): void {
    this.phase = 'over';
    this.overReason = reason;
    this.dying = -1;
    this.events.over();
  }

  private bump(amount: number): void {
    // Up in the A-10 the blasts are far below: a jolt, not an earthquake.
    if (this.options.shake) this.shake = Math.max(this.shake, amount * (this.truck.plane ? 0.18 : 1));
  }

  private makeJob(kind: JobKind): Job {
    const from = this.truck;
    let pool: Spot[];
    let minD: number;
    if (kind === 'pickup') {
      pool = this.city.pickupSpots;
      minD = 260;
    } else {
      // Anywhere people want things, old city or new: the docks, the Strip, the suburb, the town.
      const weights: [District, number][] = [
        ['uptown', 18],
        ['hood', 16],
        ['port', 12],
        ['village', 10],
        ['downtown', 12],
        ['docks', 8],
        ['commercial', 8],
        ['suburb', 8],
        ['factory', 4],
        ['town', 4],
      ];
      let roll = Math.random() * weights.reduce((t, [, w]) => t + w, 0);
      let district: District = 'downtown';
      for (const [d, w] of weights) {
        roll -= w;
        if (roll <= 0) {
          district = d;
          break;
        }
      }
      pool = this.city.dropSpots.filter((s) => s.district === district);
      if (!pool.length) pool = this.city.dropSpots;
      minD = 600;
    }
    let spot = pick(pool);
    for (let attempt = 0; attempt < 60; attempt++) {
      const candidate = pick(pool);
      const d = Math.hypot(candidate.x - from.x, candidate.y - from.y);
      // Pickups close by; deliveries a decent haul, but not across the whole map.
      if (d >= minD && (kind === 'deliver' ? d < 9000 : d < 1000)) {
        spot = candidate;
        break;
      }
    }
    return { kind, spot, progress: 0, damageAtStart: this.truck.damage, distance: Math.hypot(spot.x - from.x, spot.y - from.y) };
  }

  private updateJob(dt: number): void {
    const job = this.job;
    // Loaded: the family will take the cargo off your hands instead, for a favor.
    if (job.kind === 'deliver') {
      const hq = this.mob.hq;
      const dm = Math.hypot(this.truck.x - hq.x, this.truck.y - hq.y);
      if (dm < LOAD_RADIUS && Math.abs(this.truck.speed) < LOAD_SPEED && this.truck.layer === 0) {
        this.mobProgress += dt / LOAD_SECONDS;
        if (this.mobProgress >= 1) {
          this.mobProgress = 0;
          this.mobDelivery();
          return;
        }
      } else {
        this.mobProgress = Math.max(0, this.mobProgress - dt * 2);
      }
    }
    const d = Math.hypot(this.truck.x - job.spot.x, this.truck.y - job.spot.y);
    if (d < LOAD_RADIUS && Math.abs(this.truck.speed) < LOAD_SPEED && this.truck.layer === 0) {
      job.progress += dt / LOAD_SECONDS;
      if (job.progress >= 1) this.completeJob();
    } else {
      job.progress = Math.max(0, job.progress - dt * 2);
    }
  }

  private completeJob(): void {
    const spot = this.job.spot;
    if (this.job.kind === 'pickup') {
      this.floaters.push({ x: spot.x, y: spot.y, text: 'LOADED!', color: PICKUP, life: 1.4 });
      this.truck.setLoaded(true);
      this.job = this.makeJob('deliver');
      this.events.toast(this.truck.dozer ? `Load in the blade — push it to ${DISTRICT_NAMES[this.job.spot.district]}` : this.truck.sports ? `Parcel aboard — deliver to ${DISTRICT_NAMES[this.job.spot.district]}` : `Loaded (heavier now!) — deliver to ${DISTRICT_NAMES[this.job.spot.district]}`, 'info');
      this.events.pickup?.();
      return;
    }
    const clean = this.truck.damage - this.job.damageAtStart < 3;
    const points = 150 + Math.round(this.job.distance / 6) + (clean ? 100 : 0);
    this.score += points;
    this.deliveries += 1;
    this.truck.damage = Math.max(0, this.truck.damage - 10);
    this.truck.setLoaded(false);
    this.floaters.push({ x: spot.x, y: spot.y, text: `+${points}`, color: DROP, life: 1.6 });
    this.events.toast(clean ? `Clean delivery! +${points}` : `Delivered! +${points}`, 'good');
    this.events.delivered?.(points);
    this.job = this.makeJob('pickup');
  }

  /** Supplies delivered to the family: they owe you one. */
  private mobDelivery(): void {
    const hq = this.mob.hq;
    this.truck.setLoaded(false);
    this.score += 80;
    const capped = this.mob.favors >= MAX_FAVORS;
    this.mob.favors = Math.min(MAX_FAVORS, this.mob.favors + 1);
    this.floaters.push({ x: hq.x, y: hq.y, text: capped ? '+$80' : '+1 FAVOR', color: MOB, life: 1.8, scale: 1.2 });
    this.events.toast(capped ? 'The family thanks you (favors maxed) +$80' : `The family owes you one. Favors: ${this.mob.favors} — press F to call a hit`, 'good');
    this.events.delivered?.(80);
    this.job = this.makeJob('pickup');
  }

  private disrespect(why: string): void {
    if (this.time - this.lastDisrespect < 4) return;
    this.lastDisrespect = this.time;
    const lost = this.mob.favors > 0;
    this.mob.favors = Math.max(0, this.mob.favors - 1);
    this.events.toast(`${why} The family is not happy${lost ? ' (-1 favor)' : ''}`, 'bad');
  }

  favors(): number {
    return this.mob.favors;
  }

  /** Who the family could take care of right now: the car ahead, and the nearest building. */
  hitTargets(): { car: Car | null; building: Building | null } {
    const t = this.truck;
    const fx = Math.cos(t.angle);
    const fy = Math.sin(t.angle);
    let car: Car | null = null;
    let best = Infinity;
    for (const c of this.traffic.cars) {
      // Only cars you can see: you'll want to watch.
      if (c.mode !== 'drive' || c.marked || c.layer !== 0 || !inView(c.x, c.y, this.view, -15)) continue;
      const dx = c.x - t.x;
      const dy = c.y - t.y;
      const d = Math.hypot(dx, dy);
      const along = dx * fx + dy * fy;
      if (d > 260 || along < -40) continue;
      const score = d - along * 0.6;
      if (score < best) {
        best = score;
        car = c;
      }
    }
    let building: Building | null = null;
    best = Infinity;
    for (const b of this.city.buildings) {
      const d = Math.hypot(b.x - t.x, b.y - t.y);
      if (d < 260 && d < best && !b.destroyed && inView(b.x, b.y, this.view, -10)) {
        best = d;
        building = b;
      }
    }
    return { car, building };
  }

  /** Spend a favor: the family takes care of a car or a building. */
  orderHit(kind: 'car' | 'building'): void {
    if (this.phase !== 'playing' || this.mob.favors <= 0) return;
    const { car, building } = this.hitTargets();
    if (kind === 'car' && car) {
      this.mob.favors--;
      this.mob.hitCar(car, this.view);
      this.events.toast('Consider it done. Watch that car.', 'info');
    } else if (kind === 'building' && building) {
      this.mob.favors--;
      this.mob.hitBuilding(building);
      this.events.toast('The boys are on their way. Stand well back.', 'info');
    }
  }

  /** Skid marks and tyre smoke from sliding wheels. */
  private layTracks(): void {
    const track = (key: object, wheels: Vec[], width: number) => {
      const prev = this.wheelPrev.get(key);
      if (!wheels.length) {
        if (prev) this.wheelPrev.delete(key);
        return;
      }
      if (prev && prev.length === wheels.length) {
        for (let i = 0; i < wheels.length; i++) {
          const a = prev[i];
          const b = wheels[i];
          const d = Math.hypot(b.x - a.x, b.y - a.y);
          if (d > 0.4 && d < 20) this.effects.skid(a.x, a.y, b.x, b.y, width);
        }
      }
      this.wheelPrev.set(key, wheels);
    };
    const t = this.truck;
    const tw = t.slidingWheels();
    track(t, Math.abs(t.speed) > 6 || t.slip[1] ? tw : [], 1.9);
    if (tw.length && Math.random() < 0.6) {
      const w = tw[Math.floor(Math.random() * tw.length)];
      this.effects.tyreSmoke(w.x, w.y, 1 + Math.min(1, Math.abs(t.speed) / 80));
    }
    for (const car of this.traffic.all()) {
      if (!inView(car.x, car.y, this.view, 60)) {
        this.wheelPrev.delete(car);
        continue;
      }
      const wheels = car.skidding && car.dmg.z <= 0 ? this.carWheels(car) : [];
      track(car, wheels, 1.3);
      if (wheels.length && Math.random() < 0.3) this.effects.tyreSmoke(car.x, car.y, 0.7);
    }
  }

  private carWheels(car: Car): Vec[] {
    const c = Math.cos(car.angle);
    const s = Math.sin(car.angle);
    const out: Vec[] = [];
    car.dmg.wheels.forEach((on, i) => {
      if (!on || (car.mode === 'drive' && i < 2)) return;
      const ax = (i < 2 ? 1 : -1) * car.body.halfL * 0.6;
      const ay = (i % 2 === 0 ? -1 : 1) * 3.4;
      out.push({ x: car.x + c * ax - s * ay, y: car.y + s * ax + c * ay });
    });
    return out;
  }

  private updateEffects(dt: number, realDt: number, active: boolean): void {
    const truck = this.truck;
    this.exhaust -= dt;
    if (active && truck.throttle && this.exhaust <= 0 && !truck.plane) {
      this.exhaust = 0.09 - this.rpmFraction() * 0.05;
      const c = Math.cos(truck.angle);
      const s = Math.sin(truck.angle);
      const side = Math.random() < 0.5 ? -5.4 : 5.4;
      this.effects.puff(truck.x - c * 6 - s * side, truck.y - s * 6 + c * side, true, false, 0.6 + this.rpmFraction() * 0.4);
    }
    if (truck.damage > 50 && Math.random() < dt * (truck.damage / 18)) {
      this.effects.puff(truck.x + Math.cos(truck.angle) * 6, truck.y + Math.sin(truck.angle) * 6, true, truck.damage > 85, 1.2);
    }
    // Car alarms near the player.
    this.alarmTick -= realDt;
    if (this.alarmTick <= 0) {
      this.alarmTick = 0.5;
      const alarm = this.traffic.wrecks.filter((w) => w.alarm > 0 && this.isNear(w.x, w.y)).sort((a, b) => Math.hypot(a.x - truck.x, a.y - truck.y) - Math.hypot(b.x - truck.x, b.y - truck.y))[0];
      if (alarm) this.events.alarm?.(this.panOf(alarm.x));
    }
    this.effects.update(dt);
    for (const fl of this.floaters) {
      fl.life -= realDt;
      fl.y -= 12 * realDt;
    }
    this.floaters = this.floaters.filter((fl) => fl.life > 0);
    this.shake = Math.max(0, this.shake - realDt * 30);
  }

  private snapCamera(): void {
    this.cam.x = this.truck.x;
    this.cam.y = this.truck.y;
    this.zoom = Math.min(this.devW, this.devH) / VIEW_SPAN;
    this.camH = Infinity;
    if (this.truck.plane) {
      const lead = this.jetLead();
      this.cam.x += lead.x;
      this.cam.y += lead.y;
      this.camLead = Math.hypot(lead.x, lead.y);
      this.zoom = this.jetZoom() / GROUND_SPREAD;
      this.camH = this.cameraHeight();
    }
    this.updateView();
  }

  /** The camera height that makes the jet look exactly GROUND_SPREAD times ground size. */
  private cameraHeight(): number {
    const alt = Math.max(1, this.truck.altitude);
    const f = GROUND_SPREAD;
    return (alt * f) / (f - 1);
  }

  /** Map units per screen at the jet's height: its size on screen never changes. */
  private jetZoom(): number {
    return Math.min(this.devW, this.devH) / JET_SPAN;
  }

  private camLead = 0;

  /** The camera sits ahead of the jet, so there's more to see where it's going. */
  private jetLead(): Vec {
    const t = this.truck;
    const want = JET_SPAN * 0.26;
    return { x: Math.cos(t.angle) * want, y: Math.sin(t.angle) * want };
  }

  /**
   * Where something at height z (map units) is drawn, as a point on the ground plane
   * seen through the camera, and how much bigger it looks than ground-level things.
   */
  readonly lift: Lift = (x, y, z) => {
    if (z <= 0) return { x, y, s: 1 };
    // On the ground the camera's higher above it all: a passing jet is only a bit bigger.
    const h = isFinite(this.camH) ? this.camH : 900;
    const above = h - z;
    if (above < 4) return null;
    const s = h / above;
    return { x: this.camCenter.x + (x - this.camCenter.x) * s, y: this.camCenter.y + (y - this.camCenter.y) * s, s };
  };

  private updateCamera(dt: number): void {
    const truck = this.truck;
    if (truck.plane) {
      // Hang above the jet, pulling back a little as it goes faster.
      const spread = GROUND_SPREAD * (1 + 0.3 * clamp((truck.flight!.v - 100) / 100, 0, 1));
      const target = this.jetZoom() / spread;
      this.zoom += (target - this.zoom) * Math.min(1, dt * 10);
      // The lift projection must agree with the zoom actually used, so the jet keeps its size.
      const f = this.jetZoom() / this.zoom;
      const alt = Math.max(1, truck.altitude);
      this.camH = f > 1.0001 ? (alt * f) / (f - 1) : alt + 1e5;
      const want = this.jetLead();
      this.camLead += (Math.hypot(want.x, want.y) - this.camLead) * Math.min(1, dt * 2.5);
      const lead = { x: Math.cos(truck.angle) * this.camLead, y: Math.sin(truck.angle) * this.camLead };
      const k = Math.min(1, dt * 6);
      this.cam.x += (truck.x + lead.x - this.cam.x) * k;
      this.cam.y += (truck.y + lead.y - this.cam.y) * k;
      // It flies fast: never let the camera fall far behind.
      this.cam.x = truck.x + lead.x + clamp(this.cam.x - truck.x - lead.x, -40, 40);
      this.cam.y = truck.y + lead.y + clamp(this.cam.y - truck.y - lead.y, -40, 40);
      this.updateView();
      return;
    }
    this.camH = Infinity;
    const speedFrac = clamp(Math.abs(truck.speed) / truck.maxSpeed, 0, 1);
    // From the cockpit you see much more of the city.
    const target = Math.min(this.devW, this.devH) / VIEW_SPAN / (1 + 0.75 * speedFrac) / (truck.plane ? 1.7 : 1);
    this.zoom += (target - this.zoom) * Math.min(1, dt * 1.4);
    const tx = truck.x + truck.cab.vx * 0.6;
    const ty = truck.y + truck.cab.vy * 0.6;
    const k = Math.min(1, dt * 3);
    this.cam.x += (tx - this.cam.x) * k;
    this.cam.y += (ty - this.cam.y) * k;
    this.updateView();
  }

  private updateView(): void {
    this.view.w = this.devW / this.zoom;
    this.view.h = this.devH / this.zoom;
    if (isFinite(this.camH)) {
      // In the air the camera stays with the jet, even out over the edge of the map.
      this.view.x = this.cam.x - this.view.w / 2;
      this.view.y = this.cam.y - this.view.h / 2;
    } else {
      const axis = (center: number, size: number, max: number) => (size >= max ? (max - size) / 2 : clamp(center - size / 2, 0, max - size));
      this.view.x = axis(this.cam.x, this.view.w, MAP_W);
      this.view.y = axis(this.cam.y, this.view.h, MAP_H);
    }
    this.camCenter.x = this.view.x + this.view.w / 2;
    this.camCenter.y = this.view.y + this.view.h / 2;
  }

  /**
   * The world, ground up: map, ground effects, people and traffic, the freeway deck and
   * what's on it, smoke and fire, and the A-10's ordnance in the air. Used for the main
   * view and (pod = true) for the targeting pod's picture.
   */
  private drawScene(ctx: CanvasRenderingContext2D, vx: number, vy: number, zoom: number, w: number, h: number, pod: boolean): void {
    const world = () => ctx.setTransform(zoom, 0, 0, zoom, -vx * zoom, -vy * zoom);
    const screen = () => ctx.setTransform(1, 0, 0, 1, 0, 0);
    const truck = this.truck;
    screen();
    this.mapRenderer.draw(ctx, 'ground', vx, vy, zoom, w, h);
    world();
    this.effects.drawGround(ctx, this.view);
    if (this.phase !== 'title' && !truck.plane) this.drawZone(ctx);
    // From high up, people, animals and street furniture are smaller than a pixel: skip them.
    const fine = zoom > 0.4;
    if (zoom > 0.3) this.street.draw(ctx, this.view);
    this.drawProps(ctx);
    if (fine) this.workers.draw(ctx, this.view);
    if (fine) this.wildlife.draw(ctx, this.view);
    if (fine) this.peds.draw(ctx, this.view, this.time);
    this.drawRubble(ctx);
    this.drawCars(ctx, 0);
    this.civil.drawGround(ctx, this.view, this.time);
    this.drawSignals(ctx);
    this.missiles.draw(ctx, this.view, this.time, 0);
    this.mob.draw(ctx, this.view);
    this.services.drawDepots(ctx, this.view);
    this.helis.drawBases(ctx, this.view);
    this.services.draw(ctx, this.view, this.time, 0);
    if (truck.layer === 0) this.drawRig(ctx);
    if (truck.plane && this.phase !== 'title') {
      // The jet's shadow races over the ground beneath it.
      const h = truck.altitude;
      drawA10Shadow(ctx, truck.x + h * 0.35, truck.y + h * 0.5, truck.angle, truck.bank, 0.24);
    }
    const r = this.raider;
    if (r && r.fall < 2) {
      const h = r.altitude;
      drawA10Shadow(ctx, r.x + h * 0.35, r.y + h * 0.5, r.flight.heading, r.flight.bank, 0.24);
    }

    screen();
    const under = truck.layer === 0 && (this.city.onDeck(truck.x, truck.y) || this.city.onDeck(truck.trailer.x, truck.trailer.y));
    this.mapRenderer.draw(ctx, 'deck', vx, vy, zoom, w, h, under ? 0.45 : 1);
    world();
    this.drawCars(ctx, 1);
    this.missiles.draw(ctx, this.view, this.time, 1);
    if (truck.layer === 1) this.drawRig(ctx);
    this.services.draw(ctx, this.view, this.time, 1);
    this.effects.drawAir(ctx, this.view);
    this.workers.drawAir(ctx, this.view);
    this.helis.draw(ctx, this.view, this.time);
    this.air.draw(ctx, this.view, this.lift, 1 / zoom, this.time);
    if (!pod) this.civil.drawAir(ctx, this.view, this.lift, 1 / zoom, this.time);
    if (r && r.fall < 2 && !pod) {
      r.air.draw(ctx, this.view, this.lift, 1 / zoom, this.time);
      const at = this.lift(r.x, r.y, r.altitude);
      if (at) drawA10(ctx, at.x, at.y, r.flight.heading, r.flight.bank, at.s, r.flight.spool, this.time, r.firing, r.air);
    }
    if (truck.plane && !pod) this.drawTrails(ctx);
  }

  render(ctx: CanvasRenderingContext2D): void {
    const zoom = this.zoom;
    const sx = this.shake ? (Math.random() - 0.5) * this.shake * this.dpr : 0;
    const sy = this.shake ? (Math.random() - 0.5) * this.shake * this.dpr : 0;
    const vx = this.view.x - sx / zoom;
    const vy = this.view.y - sy / zoom;
    const screen = () => ctx.setTransform(1, 0, 0, 1, 0, 0);
    const truck = this.truck;
    this.mapRenderer.beginFrame();

    this.drawScene(ctx, vx, vy, zoom, this.devW, this.devH, false);
    // The smoke over the city goes under the jet, which flies above it.
    this.drawPall(ctx);
    if (truck.plane && this.dying < 0) {
      const at = this.lift(truck.x, truck.y, truck.altitude);
      if (at) drawA10(ctx, at.x, at.y, truck.angle, truck.bank, at.s, truck.flight!.spool, this.time, this.firing, this.air);
    }
    this.drawNight(ctx, vx, vy, zoom);

    screen();
    const toScreen = (x: number, y: number): Vec => ({ x: (x - vx) * zoom, y: (y - vy) * zoom });
    // From the air nobody's shouting loud enough to read: no speech bubbles.
    const chatter = zoom > 0.6 && !truck.plane;
    for (const car of chatter ? this.traffic.all() : []) {
      if (car.bubbleTime <= 0 || !inView(car.x, car.y, this.view, 20)) continue;
      const p = toScreen(car.x, car.y);
      this.drawBubble(ctx, car.bubble, p.x, p.y - 7 * zoom, car.rude, Math.min(1, car.bubbleTime * 4));
    }
    for (const car of chatter ? this.traffic.cars : []) {
      if (car.bubbleTime > 0 || car.alert <= 0 || !car.rude || !inView(car.x, car.y, this.view, 20)) continue;
      const p = toScreen(car.x, car.y);
      this.drawLabel(ctx, '!', p.x, p.y - 8 * zoom, DANGER, '#ffffff');
    }
    for (const ped of chatter ? this.peds.peds : []) {
      if (ped.bubbleTime <= 0 || !inView(ped.x, ped.y, this.view, 20)) continue;
      const p = toScreen(ped.x, ped.y);
      this.drawBubble(ctx, ped.bubble, p.x, p.y - (5 + ped.z * 0.3) * zoom, false, Math.min(1, ped.bubbleTime * 4));
    }
    for (const fl of this.floaters) {
      const p = toScreen(fl.x, fl.y);
      this.drawLabel(ctx, fl.text, p.x, p.y - 14 * zoom, fl.color, COLORS.ink, fl.scale ?? 1.3);
    }
    if (this.phase === 'playing' && this.job.progress > 0) {
      const p = toScreen(truck.x, truck.y);
      const w = 60 * this.dpr;
      const h = 7 * this.dpr;
      const bx = p.x - w / 2;
      const by = p.y - 16 * zoom - h;
      ctx.fillStyle = COLORS.ink;
      ctx.beginPath();
      ctx.roundRect(bx - 2 * this.dpr, by - 2 * this.dpr, w + 4 * this.dpr, h + 4 * this.dpr, 4 * this.dpr);
      ctx.fill();
      ctx.fillStyle = this.job.kind === 'pickup' ? PICKUP : DROP;
      ctx.beginPath();
      ctx.roundRect(bx, by, w * Math.min(1, this.job.progress), h, 3 * this.dpr);
      ctx.fill();
    }
    if (this.phase !== 'title' && !truck.plane) {
      this.drawTargetArrow(ctx, toScreen);
      if (this.job.kind === 'deliver') this.drawTargetArrow(ctx, toScreen, this.mob.hq, MOB, 0.75);
    }
    for (const b of this.mob.countdowns()) {
      if (!inView(b.x, b.y, this.view, 20)) continue;
      const p = toScreen(b.x, b.y);
      this.drawLabel(ctx, `BOMB ${b.text}`, p.x, p.y - 10 * zoom, '#ff2d2d', COLORS.ink, 1.3);
    }
    for (const m of this.mob.marked()) {
      if (!inView(m.x, m.y, this.view, 20)) continue;
      const p = toScreen(m.x, m.y);
      this.drawLabel(ctx, 'MARKED', p.x, p.y - 12 * zoom, '#ff2d2d', COLORS.ink, 0.9);
    }
    if (this.slowmo > 0) {
      ctx.fillStyle = `rgba(255, 255, 255, ${Math.min(0.12, this.slowmo * 0.2)})`;
      ctx.fillRect(0, 0, this.devW, this.devH);
    }
    if (this.flash > 0) {
      ctx.fillStyle = `rgba(255, 244, 220, ${Math.min(0.7, this.flash)})`;
      ctx.fillRect(0, 0, this.devW, this.devH);
    }
    // During a raid the driver sees the city's vital signs too.
    if (!truck.plane && (this.raider || this.raidRespawn > 0) && this.raidEnabled && (this.phase === 'playing' || this.phase === 'paused')) drawCityStatus(ctx, this.cityStatus(), this.devW, this.devH, this.dpr, this.insetTop, this.insetBottom);
    const hud = truck.plane && this.dying < 0 && (this.phase === 'playing' || this.phase === 'paused') ? this.flightHud() : null;
    if (hud) {
      drawFlightHud(ctx, hud, this.devW, this.devH, this.dpr, this.insetTop, this.insetBottom);
      drawCityStatus(ctx, this.cityStatus(), this.devW, this.devH, this.dpr, this.insetTop, this.insetBottom);
    }
  }

  private drawProps(ctx: CanvasRenderingContext2D): void {
    this.city.venues.forEach((v, vi) => {
      if (v.kind !== 'rave' || !this.venueOpen(vi) || !inView(v.x, v.y, this.view, v.r + 120)) return;
      // Lasers fanning out from the stage, and a strobe.
      const sx = v.x + Math.cos(v.angle) * 0 - Math.sin(v.angle) * -v.r * 0.7;
      const sy = v.y + Math.sin(v.angle) * 0 + Math.cos(v.angle) * -v.r * 0.7;
      ctx.globalCompositeOperation = 'lighter';
      ctx.lineCap = 'round';
      for (let i = 0; i < 8; i++) {
        const a = v.angle + Math.PI / 2 + Math.sin(this.time * (1.1 + i * 0.13) + i) * 0.9;
        ctx.strokeStyle = ['rgba(60, 255, 120, 0.55)', 'rgba(255, 40, 200, 0.55)', 'rgba(60, 180, 255, 0.55)'][i % 3];
        ctx.lineWidth = 0.8;
        ctx.beginPath();
        ctx.moveTo(sx, sy);
        ctx.lineTo(sx + Math.cos(a) * v.r * 2.4, sy + Math.sin(a) * v.r * 2.4);
        ctx.stroke();
      }
      if (Math.floor(this.time * 8) % 5 === 0) {
        ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
        ctx.beginPath();
        ctx.arc(v.x, v.y, v.r, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.lineCap = 'butt';
      ctx.globalCompositeOperation = 'source-over';
    });
    for (const [vi, v] of this.city.venues.entries()) {
      if (v.kind !== 'party' || !this.venueOpen(vi) || !inView(v.x, v.y, this.view, v.r + 20)) continue;
      // Disco lights sweeping the lawn.
      ctx.globalCompositeOperation = 'lighter';
      for (let i = 0; i < 4; i++) {
        const a = this.time * (1.5 + i * 0.4) + i * 1.6;
        const r = v.r * 0.55;
        ctx.fillStyle = ['rgba(255, 60, 180, 0.22)', 'rgba(60, 200, 255, 0.22)', 'rgba(255, 220, 60, 0.22)', 'rgba(120, 255, 90, 0.22)'][i];
        ctx.beginPath();
        ctx.arc(v.x + Math.cos(a) * r, v.y + Math.sin(a * 1.3) * r, 12, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.globalCompositeOperation = 'source-over';
      // String lights around the yard.
      for (let i = 0; i < 24; i++) {
        const a = (i / 24) * Math.PI * 2;
        ctx.fillStyle = ['#ff4fa3', '#ffd400', '#3fd0ff', '#7dff6a'][(i + Math.floor(this.time * 4)) % 4];
        ctx.beginPath();
        ctx.arc(v.x + Math.cos(a) * v.r * 0.95, v.y + Math.sin(a) * v.r * 0.95, 1, 0, Math.PI * 2);
        ctx.fill();
      }
    }
    for (const prop of this.props) {
      const b = prop.body;
      if (!inView(b.x, b.y, this.view, 20)) continue;
      ctx.save();
      ctx.translate(b.x, b.y);
      ctx.rotate(b.angle);
      if (prop.broken) {
        ctx.fillStyle = '#8a6a44';
        for (let i = 0; i < 4; i++) ctx.fillRect(-b.halfL + i * 3.5, -1 + (i % 2) * 2, 3, 0.8);
        ctx.fillStyle = prop.colors[0];
        ctx.fillRect(-b.halfL * 0.6, -b.halfW * 0.4, b.halfL, b.halfW * 0.5);
      } else if (prop.kind === 'stage') {
        ctx.fillStyle = '#26262c';
        ctx.fillRect(-b.halfL, -b.halfW, b.halfL * 2, b.halfW * 2);
        ctx.strokeStyle = '#9a9ca2';
        ctx.lineWidth = 0.5;
        ctx.strokeRect(-b.halfL + 0.5, -b.halfW + 0.5, b.halfL * 2 - 1, b.halfW * 2 - 1);
        ctx.fillStyle = ['#ff2bd6', '#2bffb4', '#2b9bff'][Math.floor(this.time * 2) % 3];
        ctx.fillRect(-2.5, -1, 5, 2);
      } else if (prop.kind === 'stall' && !this.venueOpen(prop.venue)) {
        // Closed for the night: covered with a tarp.
        ctx.fillStyle = '#6b6f5a';
        ctx.fillRect(-b.halfL, -b.halfW, b.halfL * 2, b.halfW * 2);
        ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
        ctx.lineWidth = 0.4;
        ctx.strokeRect(-b.halfL, -b.halfW, b.halfL * 2, b.halfW * 2);
      } else if (prop.kind === 'stall') {
        ctx.fillStyle = 'rgba(0, 0, 0, 0.25)';
        ctx.fillRect(-b.halfL + 1.2, -b.halfW + 1.6, b.halfL * 2, b.halfW * 2);
        for (let i = 0; i < 6; i++) {
          ctx.fillStyle = prop.colors[i % 2];
          ctx.fillRect(-b.halfL + (i * b.halfL * 2) / 6, -b.halfW, (b.halfL * 2) / 6 + 0.1, b.halfW * 2);
        }
        ctx.fillStyle = '#8a6a44';
        ctx.fillRect(-b.halfL, b.halfW, b.halfL * 2, 1.6);
        for (let i = 0; i < 5; i++) {
          ctx.fillStyle = prop.produce[i % prop.produce.length];
          ctx.beginPath();
          ctx.arc(-b.halfL + 1.6 + i * 2.6, b.halfW + 0.8, 0.8, 0, Math.PI * 2);
          ctx.fill();
        }
      } else if (prop.kind === 'speaker') {
        ctx.fillStyle = '#1b1b1f';
        ctx.fillRect(-b.halfL, -b.halfW, b.halfL * 2, b.halfW * 2);
        const pulse = 1.2 + Math.abs(Math.sin(this.time * 13)) * 0.6;
        ctx.fillStyle = '#4a4a52';
        ctx.beginPath();
        ctx.arc(0, 0, pulse, 0, Math.PI * 2);
        ctx.fill();
      } else if (prop.kind === 'table') {
        ctx.fillStyle = '#5a5650';
        ctx.fillRect(-b.halfL, -b.halfW, b.halfL * 2, b.halfW * 2);
        ctx.fillStyle = ['#ff4fa3', '#3fd0ff'][Math.floor(this.time * 3) % 2];
        ctx.fillRect(-2, -1, 4, 2);
      } else {
        ctx.fillStyle = '#b7bcc2';
        ctx.beginPath();
        ctx.arc(0, 0, b.halfL, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.restore();
    }
  }

  /** Traffic lights at the stop line of each approach. */
  private drawSignals(ctx: CanvasRenderingContext2D): void {
    for (const sig of this.city.signals) {
      if (!inView(sig.x, sig.y, this.view, 40)) continue;
      for (const a of sig.approaches) {
        const light = this.traffic.light(sig, a.phase);
        ctx.save();
        ctx.translate(a.x, a.y);
        ctx.rotate(a.angle);
        ctx.fillStyle = '#1b1b1f';
        ctx.beginPath();
        ctx.roundRect(-1.5, -3.4, 3, 6.8, 0.8);
        ctx.fill();
        const dots: [number, string, boolean][] = [
          [-2.1, '#ff3b3b', light === 'red'],
          [0, '#ffb000', light === 'amber'],
          [2.1, '#3dff7a', light === 'green'],
        ];
        for (const [y, color, on] of dots) {
          ctx.fillStyle = on ? color : 'rgba(255, 255, 255, 0.12)';
          ctx.beginPath();
          ctx.arc(0, y, 0.9, 0, Math.PI * 2);
          ctx.fill();
        }
        ctx.restore();
      }
    }
  }

  private signalGlows(): { x: number; y: number; color: string }[] {
    const out: { x: number; y: number; color: string }[] = [];
    for (const sig of this.city.signals) {
      if (!inView(sig.x, sig.y, this.view, 30)) continue;
      for (const a of sig.approaches) {
        const light = this.traffic.light(sig, a.phase);
        if (light === 'dark') continue;
        out.push({ x: a.x, y: a.y, color: light === 'red' ? 'rgba(255, 50, 50, 0.6)' : light === 'amber' ? 'rgba(255, 176, 0, 0.6)' : 'rgba(60, 255, 120, 0.55)' });
      }
    }
    return out;
  }

  private drawCars(ctx: CanvasRenderingContext2D, layer: number): void {
    for (const car of this.traffic.wrecks) {
      if (car.layer !== layer || !inView(car.x, car.y, this.view, 30)) continue;
      drawCar(ctx, car.look, car.x, car.y, car.angle, true, false, car.dmg);
    }
    for (const car of this.traffic.cars) {
      if (car.layer !== layer || !inView(car.x, car.y, this.view, 30)) continue;
      drawCar(ctx, car.look, car.x, car.y, car.angle, false, car.braking, car.broken ? car.dmg : undefined);
    }
  }

  private drawRig(ctx: CanvasRenderingContext2D): void {
    const t = this.truck;
    if (t.dozer) drawDozer(ctx, t.x, t.y, t.angle, t.odometer, t.cabDents, t.braking);
    else if (t.sports) {
      // Dents crumple the bodywork where they landed: nose, tail or a side.
      const d = this.sportsDmg;
      d.crumple = [0, 0, 0, 0];
      for (const dent of t.cabDents) {
        const zone = Math.abs(dent.x) / t.cab.halfL > Math.abs(dent.y) / t.cab.halfW ? (dent.x > 0 ? 0 : 1) : dent.y < 0 ? 2 : 3;
        d.crumple[zone] = Math.min(zone < 2 ? 5 : 3, d.crumple[zone] + dent.r * 0.5);
      }
      d.glass = t.damage > 60;
      d.burnt = t.damage >= 100 ? 0.7 : 0;
      drawCar(ctx, this.sportsLook, t.x, t.y, t.angle, false, t.braking, d);
    } else drawTruck(ctx, t.pose());
    if (this.phase === 'over') {
      const tc = this.truck.trailerCenter();
      ctx.fillStyle = 'rgba(20, 16, 14, 0.6)';
      for (const p of [this.truck, tc]) {
        ctx.beginPath();
        ctx.ellipse(p.x, p.y, 12, 8, this.truck.angle, 0, Math.PI * 2);
        ctx.fill();
      }
    }
  }

  private drawZone(ctx: CanvasRenderingContext2D): void {
    const { spot, kind } = this.job;
    this.drawZoneAt(ctx, spot, kind === 'pickup' ? PICKUP : DROP, kind === 'pickup' ? 'LOAD HERE' : 'DROP HERE');
    // Loaded: the family will take it instead.
    if (kind === 'deliver') this.drawZoneAt(ctx, this.mob.hq, MOB, 'THE FAMILY');
  }

  private drawZoneAt(ctx: CanvasRenderingContext2D, spot: Spot, color: string, label: string): void {
    ctx.save();
    ctx.translate(spot.x, spot.y);
    ctx.rotate(spot.angle);
    ctx.globalAlpha = 0.2 + 0.14 * Math.sin(this.time * 6);
    ctx.fillStyle = color;
    ctx.beginPath();
    ctx.roundRect(-26, -9, 52, 18, 3);
    ctx.fill();
    ctx.globalAlpha = 1;
    ctx.strokeStyle = color;
    ctx.lineWidth = 1.2;
    ctx.setLineDash([4, 3]);
    ctx.lineDashOffset = -this.time * 12;
    ctx.stroke();
    ctx.setLineDash([]);
    ctx.lineDashOffset = 0;
    ctx.lineWidth = 1.4;
    ctx.lineCap = 'round';
    for (let i = -1; i <= 1; i++) {
      const x = i * 10 + ((this.time * 10) % 10) - 5;
      ctx.beginPath();
      ctx.moveTo(x - 2, -4);
      ctx.lineTo(x + 2, 0);
      ctx.lineTo(x - 2, 4);
      ctx.stroke();
    }
    ctx.lineCap = 'butt';
    ctx.restore();
    const zoom = this.zoom;
    ctx.save();
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    const p = { x: (spot.x - this.view.x) * zoom, y: (spot.y - this.view.y) * zoom };
    const bob = Math.abs(Math.sin(this.time * 4)) * 3 * this.dpr;
    this.drawLabel(ctx, label, p.x, p.y - 14 * zoom - bob, color, COLORS.ink);
    ctx.restore();
  }

  private font(size: number): string {
    return `800 ${Math.round(size * this.dpr)}px ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace`;
  }

  private drawLabel(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, fg: string, bg: string, scale = 1): void {
    ctx.font = this.font(11 * scale);
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    const w = ctx.measureText(text).width + 12 * this.dpr;
    const h = 18 * this.dpr * scale;
    ctx.fillStyle = bg;
    ctx.beginPath();
    ctx.roundRect(x - w / 2, y - h / 2, w, h, 5 * this.dpr);
    ctx.fill();
    ctx.fillStyle = fg;
    ctx.fillText(text, x, y + this.dpr * 0.5);
  }

  private drawBubble(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, rude: boolean, alpha: number): void {
    const d = this.dpr;
    ctx.globalAlpha = alpha;
    ctx.font = this.font(10.5);
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    const w = ctx.measureText(text).width + 14 * d;
    const h = 20 * d;
    const bx = x - w / 2;
    const by = y - h - 6 * d;
    ctx.fillStyle = rude ? '#fff4dc' : '#ffffff';
    ctx.strokeStyle = COLORS.ink;
    ctx.lineWidth = 1.6 * d;
    ctx.beginPath();
    ctx.roundRect(bx, by, w, h, 8 * d);
    ctx.moveTo(x - 5 * d, by + h);
    ctx.lineTo(x, by + h + 6 * d);
    ctx.lineTo(x + 3 * d, by + h);
    ctx.fill();
    ctx.stroke();
    ctx.fillRect(x - 4 * d, by + h - 2 * d, 6 * d, 3 * d);
    ctx.fillStyle = rude ? '#c0182b' : COLORS.ink;
    ctx.fillText(text, x, by + h / 2 + 0.5 * d);
    ctx.globalAlpha = 1;
  }

  private drawTargetArrow(ctx: CanvasRenderingContext2D, toScreen: (x: number, y: number) => Vec, spot: Vec = this.job.spot, color = this.job.kind === 'pickup' ? PICKUP : DROP, size = 1): void {
    const t = toScreen(spot.x, spot.y);
    const d = this.dpr;
    const margin = 22 * d;
    const top = this.insetTop + margin;
    const bottom = this.devH - this.insetBottom - margin;
    if (t.x > margin && t.x < this.devW - margin && t.y > top && t.y < bottom) return;
    const cx = this.devW / 2;
    const cy = (top + bottom) / 2;
    const dx = t.x - cx;
    const dy = t.y - cy;
    const k = Math.min((this.devW / 2 - margin) / Math.max(1, Math.abs(dx)), Math.max(4, (bottom - top) / 2) / Math.max(1, Math.abs(dy)));
    const angle = Math.atan2(dy, dx);
    const bob = Math.sin(this.time * 6) * 3 * d;
    ctx.save();
    ctx.translate(cx + dx * k - Math.cos(angle) * bob, cy + dy * k - Math.sin(angle) * bob);
    ctx.rotate(angle);
    ctx.scale(size, size);
    ctx.beginPath();
    ctx.moveTo(14 * d, 0);
    ctx.lineTo(-8 * d, -11 * d);
    ctx.lineTo(-3 * d, 0);
    ctx.lineTo(-8 * d, 11 * d);
    ctx.closePath();
    ctx.fillStyle = color;
    ctx.strokeStyle = COLORS.ink;
    ctx.lineWidth = 2.5 * d;
    ctx.lineJoin = 'round';
    ctx.stroke();
    ctx.fill();
    ctx.restore();
  }

  /** Overview map with the truck, the view and the current target. */
  drawMinimap(ctx: CanvasRenderingContext2D, w: number, h: number): void {
    if (!this.minimap || this.minimap.width !== w || this.minimap.height !== h) this.minimap = renderMinimap(this.city, w, h);
    const scale = Math.min(w / MAP_W, h / MAP_H);
    ctx.drawImage(this.minimap, 0, 0);
    ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
    ctx.lineWidth = 1;
    ctx.strokeRect(this.view.x * scale, this.view.y * scale, this.view.w * scale, this.view.h * scale);
    if (this.phase !== 'title' && !this.truck.plane) {
      const pulse = 3 + Math.abs(Math.sin(this.time * 4)) * 3;
      ctx.fillStyle = this.job.kind === 'pickup' ? PICKUP : DROP;
      ctx.strokeStyle = COLORS.ink;
      ctx.beginPath();
      ctx.arc(this.job.spot.x * scale, this.job.spot.y * scale, pulse, 0, Math.PI * 2);
      ctx.fill();
      ctx.stroke();
    }
    // The family's turf, their club and any hitmen out on a job.
    const [, tx0, ty0, tx1, ty1] = DISTRICT_RECTS.find((r) => r[0] === 'hood')!;
    ctx.strokeStyle = MOB;
    ctx.lineWidth = 1.5;
    ctx.setLineDash([3, 2]);
    ctx.strokeRect(tx0 * scale, ty0 * scale, (tx1 - tx0) * scale, (ty1 - ty0) * scale);
    ctx.setLineDash([]);
    ctx.fillStyle = MOB;
    ctx.beginPath();
    ctx.arc(this.mob.hq.x * scale, this.mob.hq.y * scale, this.job.kind === 'deliver' ? 4 : 2.5, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = '#111318';
    for (const h of this.mob.hitmen()) ctx.fillRect(h.x * scale - 2, h.y * scale - 2, 4, 4);
    // City units out on jobs.
    for (const v of this.services.vehicles) {
      ctx.fillStyle = v.spec.siren ? (v.kind === 'fire' ? '#ff4d5e' : v.kind === 'police' ? '#5b8cff' : '#ffffff') : '#ffb000';
      ctx.fillRect(v.x * scale - 1.5, v.y * scale - 1.5, 3, 3);
    }
    for (const h of this.helis.helis) {
      ctx.fillStyle = '#ffffff';
      ctx.beginPath();
      ctx.arc(h.x * scale, h.y * scale, 2.2, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.save();
    ctx.translate(this.truck.x * scale, this.truck.y * scale);
    ctx.rotate(this.truck.angle);
    ctx.fillStyle = COLORS.cab;
    ctx.strokeStyle = '#ffffff';
    ctx.lineWidth = 1.2;
    ctx.beginPath();
    ctx.moveTo(6, 0);
    ctx.lineTo(-4, -4);
    ctx.lineTo(-4, 4);
    ctx.closePath();
    ctx.fill();
    ctx.stroke();
    ctx.restore();
  }
}