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/effects.ts
import type { Vec } from './geometry.js';
import type { View } from './traffic.js';

interface Spark {
  x: number;
  y: number;
  vx: number;
  vy: number;
  life: number;
  max: number;
}

interface Puff {
  x: number;
  y: number;
  vx: number;
  vy: number;
  r: number;
  grow: number;
  life: number;
  max: number;
  kind: 'smoke' | 'fire' | 'tyre' | 'dust' | 'steam' | 'water' | 'blast' | 'flash';
  tone: number;
  /** Seconds before it appears (smoke rolling in after a fireball). */
  delay?: number;
  /** Dust colour (grey masonry, brown timber or earth); default is a dusty tan. */
  rgb?: [number, number, number];
}

type PartKind = 'panel' | 'door' | 'bumper' | 'wheel' | 'hubcap' | 'glass' | 'chunk';

interface Part {
  kind: PartKind;
  x: number;
  y: number;
  z: number;
  vx: number;
  vy: number;
  vz: number;
  a: number;
  va: number;
  w: number;
  h: number;
  color: string;
  born: number;
  shape: 'rect' | 'round' | 'tri';
  burning: number;
}

interface Ring {
  x: number;
  y: number;
  r: number;
  speed: number;
  life: number;
  max: number;
  width: number;
  /** A condensation ring: white vapour flashing out with the shock. */
  vapor?: boolean;
}

interface Skid {
  x1: number;
  y1: number;
  x2: number;
  y2: number;
  w: number;
  born: number;
  blood?: boolean;
}

interface Decal {
  x: number;
  y: number;
  size: number;
  hole: boolean;
  rays: { a: number; l: number }[];
  angle: number;
}

/** Road damage: a blast hole or a scorched patch, until the road crew fixes it. */
interface Crater {
  x: number;
  y: number;
  r: number;
  hole: boolean;
  seed: number;
  /** A bomb crater: a deep pit, a raised rim and ejecta streaks thrown out around it. */
  big?: boolean;
  /** Blown into soil (a brown rim and spoil) rather than tarmac or concrete. */
  earth?: boolean;
}

/** A gouge torn out of the ground by a cannon round. */
interface Pit {
  x: number;
  y: number;
  r: number;
  angle: number;
  /** Torn earth (grass, dirt) rather than a pock-mark in tarmac. */
  earth: boolean;
  born: number;
}

/** What an explosion hits: the ground's surface, a building's construction, or a vehicle. */
export type Ground = 'earth' | 'grass' | 'tarmac' | 'concrete' | 'water' | 'wood' | 'stone' | 'metal';

/** The wind: smoke drifts downwind (px/s). */
const WIND = { x: 9, y: -6 };

/** Fresh asphalt where the road crew filled a hole. */
interface Patch {
  x: number;
  y: number;
  r: number;
  angle: number;
  born: number;
}

interface GroundFire {
  x: number;
  y: number;
  r: number;
  life: number;
  max: number;
}

/** A light in the night: position, radius and warm/colour tint. */
export interface Glow {
  x: number;
  y: number;
  r: number;
  color: string;
}

export type HitMaterial = 'car' | 'truck' | 'concrete' | 'barrier' | 'shore' | 'ground' | 'wood';

export interface CrashOptions {
  /** Number of wheels torn off and thrown. */
  wheels?: number;
  /** Extra body panels (doors, hood) thrown. */
  panels?: number;
  /** What the vehicle hit: changes the debris, dust and sparks. */
  material?: HitMaterial;
  /** Whether a windscreen shattered. */
  glass?: boolean;
  /** Engine damage: radiator steam, oily black smoke, or nothing. */
  smoke?: 'none' | 'steam' | 'black';
  /** The kind of vehicle, for its signature debris. */
  flavor?: string;
}

interface Splat {
  x: number;
  y: number;
  blobs: { dx: number; dy: number; r: number }[];
  born: number;
}

const MAX_SKIDS = 9000;
const SKID_FADE = 240;
const MAX_PARTS = 2200;
const MAX_PUFFS = 2800;
const MAX_FIRES = 260;

const range = (a: number, b: number) => a + Math.random() * (b - a);

/** Sparks, smoke, fire, flying parts, shockwaves and skid marks. Debris stays where it lands. */
export class Effects {
  private time = 0;
  /** What's at a point (set by the game), and whether there's anything there that burns. */
  groundAt: ((x: number, y: number) => Ground) | null = null;
  private sparks: Spark[] = [];
  private puffs: Puff[] = [];
  private parts: Part[] = [];
  private rings: Ring[] = [];
  private skids: Skid[] = [];
  private splats: Splat[] = [];
  private fires: GroundFire[] = [];
  private decals: Decal[] = [];
  private drops: Spark[] = [];
  private craters: Crater[] = [];
  /** Smoke columns rising from big blasts, for a while afterwards. */
  private columns: { x: number; y: number; life: number; timer: number }[] = [];
  /** Things that keep going after the blast: gas mains on fire, burst water mains, arcing transformers. */
  private emitters: { kind: 'gas' | 'geyser' | 'arc'; x: number; y: number; life: number; timer: number; angle: number }[] = [];
  /** Gouges from cannon fire. */
  private pits: Pit[] = [];
  /** Scorched ground around big blasts. */
  private burns: { x: number; y: number; r: number; born: number }[] = [];
  private patches: Patch[] = [];
  /** Optional wall test so debris bounces off buildings. */
  solid: ((x: number, y: number) => Vec) | null = null;

  clear(): void {
    this.sparks = [];
    this.puffs = [];
    this.parts = [];
    this.rings = [];
    this.skids = [];
    this.splats = [];
    this.drops = [];
    this.fires = [];
    this.decals = [];
    this.craters = [];
    this.patches = [];
    this.columns = [];
    this.emitters = [];
    this.burns = [];
    this.pits = [];
  }

  /** A ruptured gas main in the ruins: a roaring jet of flame that burns for a while. */
  gasFire(x: number, y: number, seconds: number): void {
    this.emitters.push({ kind: 'gas', x, y, life: seconds, timer: 0, angle: Math.random() * Math.PI * 2 });
    if (this.emitters.length > 60) this.emitters.shift();
  }

  /** A burst water main: a fountain of water out of the crater, running down the street. */
  geyser(x: number, y: number, seconds: number): void {
    this.emitters.push({ kind: 'geyser', x, y, life: seconds, timer: 0, angle: 0 });
    if (this.emitters.length > 60) this.emitters.shift();
  }

  /** A wrecked transformer or power station arcing: blue-white flashes and showers of sparks. */
  arcs(x: number, y: number, seconds: number): void {
    this.emitters.push({ kind: 'arc', x, y, life: seconds, timer: 0, angle: 0 });
    if (this.emitters.length > 60) this.emitters.shift();
  }

  /** An anti-aircraft shell bursting: a black puff with a flash at its heart. */
  flak(x: number, y: number): void {
    this.add({ x, y, vx: 0, vy: 0, r: 3, grow: 30, life: 0.12, max: 0.12, kind: 'flash', tone: 0 });
    for (let i = 0; i < 4; i++) this.add({ x: x + range(-4, 4), y: y + range(-4, 4), vx: range(-8, 8), vy: range(-8, 8), r: range(4, 7), grow: range(6, 10), life: range(1.5, 2.6), max: 2.6, kind: 'smoke', tone: range(18, 30) });
  }

  /** A surface-to-air missile's smoke trail. */
  missileTrail(x: number, y: number, z: number): void {
    const max = range(1.4, 2.4);
    this.add({ x, y, vx: range(-3, 3), vy: range(-3, 3), r: 1.5 + z * 0.01, grow: 6, life: max, max, kind: 'smoke', tone: range(200, 230) });
  }

  /** A towering column of black smoke that keeps billowing from a blast site. */
  smokeColumn(x: number, y: number, seconds: number): void {
    this.columns.push({ x, y, life: seconds, timer: 0 });
    if (this.columns.length > 60) this.columns.shift();
  }

  private scorch(x: number, y: number, r: number, hole: boolean, earth = false): void {
    if (this.craters.some((c) => Math.hypot(c.x - x, c.y - y) < Math.max(c.r, r) * 0.7)) return;
    this.craters.push({ x, y, r, hole, seed: Math.random() * 10, earth });
    if (this.craters.length > 140) this.craters.shift();
  }

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

  /**
   * A soft round particle in one colour, cached (colours are rounded so there are only
   * a few hundred). Glowing ones have a hot centre; smoke is denser and fluffier.
   */
  private soft(r: number, g: number, b: number, glow: boolean): HTMLCanvasElement {
    const q = (v: number) => Math.max(0, Math.min(255, Math.round(v / 12) * 12));
    const key = ((q(r) * 256 + q(g)) * 256 + q(b)) * 2 + (glow ? 1 : 0);
    let c = this.sprites.get(key);
    if (c) return c;
    c = document.createElement('canvas');
    c.width = c.height = 48;
    const x = c.getContext('2d')!;
    const grad = x.createRadialGradient(24, 24, 0, 24, 24, 24);
    const col = `${q(r)}, ${q(g)}, ${q(b)}`;
    if (glow) {
      grad.addColorStop(0, `rgba(255, 255, 240, 1)`);
      grad.addColorStop(0.25, `rgba(${col}, 0.95)`);
      grad.addColorStop(0.6, `rgba(${col}, 0.45)`);
    } else {
      grad.addColorStop(0, `rgba(${col}, 1)`);
      grad.addColorStop(0.5, `rgba(${col}, 0.85)`);
      grad.addColorStop(0.8, `rgba(${col}, 0.35)`);
    }
    grad.addColorStop(1, `rgba(${col}, 0)`);
    x.fillStyle = grad;
    x.fillRect(0, 0, 48, 48);
    this.sprites.set(key, c);
    return c;
  }

  /** A dust cloud of a given colour (masonry grey, timber brown, earth). */
  dustCloud(x: number, y: number, size: number, rgb: [number, number, number], vx = 0, vy = 0, delay = 0): void {
    const max = range(2, 4.5);
    this.add({ x, y, vx: vx + range(-12, 12), vy: vy + range(-12, 12), r: range(3, 5) * size, grow: range(5, 9) * size, life: max, max, kind: 'dust', tone: 0, rgb, delay });
  }

  /**
   * A 30 mm round hitting the ground: it gouges a pit (torn earth, or a pock-mark in
   * tarmac), throws up clods and a spurt of dirt or grit.
   */
  gouge(x: number, y: number, angle: number, earth: boolean, near: boolean): void {
    this.pits.push({ x, y, r: range(1.8, 3), angle, earth, born: this.time });
    if (this.pits.length > 1800) this.pits.shift();
    if (!near) return;
    const rgb: [number, number, number] = earth ? [120, 92, 60] : [140, 138, 132];
    this.add({ x, y, vx: range(-20, 20), vy: range(-20, 20), r: range(1.2, 2), grow: range(6, 10), life: range(0.6, 1.1), max: 1.1, kind: 'dust', tone: 0, rgb });
    if (Math.random() < 0.5) this.part('chunk', x, y, 60, { x: 0, y: 0 }, earth ? '#5d4630' : '#55524a', 60, range(0.6, 1.2), range(0.5, 1), 'rect');
  }

  /** A bomb crater: deep, with a raised rim and ejecta streaks; the road crews fill it in later. */
  bombCrater(x: number, y: number, r: number, ground: Ground = 'tarmac'): void {
    if (ground === 'water') return;
    const earth = ground === 'earth' || ground === 'grass';
    this.craters = this.craters.filter((c) => Math.hypot(c.x - x, c.y - y) > r * 0.8);
    this.craters.push({ x, y, r, hole: true, seed: Math.random() * 10, big: true, earth });
    if (this.craters.length > 140) this.craters.shift();
    // Clods of earth and broken tarmac thrown out in a ring.
    for (let i = 0; i < 26; i++) {
      const a = Math.random() * Math.PI * 2;
      this.part('chunk', x + Math.cos(a) * r * 0.5, y + Math.sin(a) * r * 0.5, range(60, 140), { x: Math.cos(a), y: Math.sin(a) }, earth ? (i % 3 ? '#4a3a2a' : '#5d4630') : i % 3 ? '#2b2b2e' : '#55524a', range(40, 90), range(1.2, 3), range(1, 2.4), Math.random() < 0.4 ? 'tri' : 'rect');
    }
  }

  /** A small scorched pock-mark in the road (cannon rounds). */
  scorchMark(x: number, y: number, r: number): void {
    this.scorch(x, y, r, false);
  }

  /** Cracks and holes in buildings, for the repair crews. */
  structureDamage(): Vec[] {
    return this.decals.map((d) => ({ x: d.x, y: d.y }));
  }

  /** Builders patch up walls near a point. Returns how many were fixed. */
  repairStructure(x: number, y: number, r: number): number {
    const before = this.decals.length;
    this.decals = this.decals.filter((d) => Math.hypot(d.x - x, d.y - y) > r);
    const fixed = before - this.decals.length;
    if (fixed) for (let i = 0; i < 5; i++) this.dust(x + range(-3, 3), y + range(-3, 3), 0.8);
    return fixed;
  }

  /** Blast holes and scorch marks in the road, for the road crews. */
  roadDamage(): Vec[] {
    return this.craters.map((c) => ({ x: c.x, y: c.y }));
  }

  /** Fill and resurface the road near a point. */
  patchRoad(x: number, y: number, r: number): void {
    for (const c of this.craters) {
      if (Math.hypot(c.x - x, c.y - y) > r) continue;
      this.patches.push({ x: c.x, y: c.y, r: c.r * 1.15, angle: Math.random() * Math.PI, born: this.time });
    }
    this.craters = this.craters.filter((c) => Math.hypot(c.x - x, c.y - y) > r);
    if (this.patches.length > 160) this.patches.shift();
    for (let i = 0; i < 4; i++) this.steam(x + range(-3, 3), y + range(-3, 3), 0.7);
  }

  /** Physical ground fire footprint for civilian road avoidance. */
  roadFires(): { x: number; y: number; r: number }[] {
    return this.fires.map((f) => ({ x: f.x, y: f.y, r: f.r + 4 }));
  }

  /** Every fire burning on the ground or in a building. */
  allFires(): Vec[] {
    return this.fires.map((f) => ({ x: f.x, y: f.y }));
  }

  /** Spots littered with debris, glass or blood, for the street sweepers. */
  messSpots(): { x: number; y: number; n: number }[] {
    const cells = new Map<number, { x: number; y: number; n: number }>();
    const add = (x: number, y: number, w: number) => {
      const key = Math.floor(y / 40) * 1000 + Math.floor(x / 40);
      const c = cells.get(key);
      if (c) {
        c.x += x * w;
        c.y += y * w;
        c.n += w;
      } else cells.set(key, { x: x * w, y: y * w, n: w });
    };
    for (const p of this.parts) if (p.z <= 0.5) add(p.x, p.y, 1);
    for (const sp of this.splats) add(sp.x, sp.y, 1);
    for (const k of this.skids) if (k.blood) add(k.x1, k.y1, 0.3);
    const out: { x: number; y: number; n: number }[] = [];
    for (const c of cells.values()) if (c.n >= 4) out.push({ x: c.x / c.n, y: c.y / c.n, n: c.n });
    return out;
  }

  /**
   * A vehicle smashes into a building or other structure. (nx, ny) points out of
   * the wall toward the vehicle. Cracks, then holes with rubble, then fire.
   */
  structureHit(x: number, y: number, impact: number, nx: number, ny: number, material: HitMaterial): void {
    const k = impact / 100;
    const out = { x: nx, y: ny };
    const masonry = material === 'concrete';
    if (masonry) {
      for (let i = 0; i < 4 + k * 16; i++) this.part('chunk', x, y, 40 + k * 90, out, Math.random() < 0.5 ? '#9e978a' : '#6f6a62', 50 + k * 60, range(0.8, 2.6), range(0.8, 2), Math.random() < 0.3 ? 'tri' : 'rect');
      for (let i = 0; i < 3 + k * 10; i++) this.part('glass', x, y, 40 + k * 70, out, 'rgba(205, 232, 255, 0.9)', 50 + k * 40, range(0.6, 1.4), range(0.5, 1));
      for (let i = 0; i < 3 + k * 9; i++) this.dust(x + range(-4, 4), y + range(-4, 4), 1.2 + k);
    }
    if (impact < 30) return;
    const hole = impact > 75;
    this.decals.push({
      x: x - nx * 2,
      y: y - ny * 2,
      size: (hole ? 5 : 3) + k * (hole ? 8 : 5),
      hole,
      angle: Math.random() * Math.PI,
      rays: Array.from({ length: 5 + Math.floor(Math.random() * 4) }, () => ({ a: Math.random() * Math.PI * 2, l: range(0.6, 1.3) })),
    });
    if (this.decals.length > 220) this.decals.shift();
    if (impact > 115 && material === 'wood') {
      // Timber catches fire.
      const life = range(25, 45);
      this.fires.push({ x: x - nx * 5, y: y - ny * 5, r: 6 + k * 4, life, max: life });
      for (let i = 0; i < 10; i++) this.puff(x, y, false, true, range(1.5, 2.5));
    }
  }

  /**
   * A building comes down: timber splinters into planks and shingles, stone into
   * blocks and dust. (dx, dy) is the push of whatever smashed it (zero for a bomb).
   */
  collapse(x: number, y: number, w: number, d: number, angle: number, mat: 'wood' | 'stone', dx: number, dy: number): void {
    const wood = mat === 'wood';
    const colors = wood ? ['#8a6a44', '#a0703c', '#5d4037', '#c9a27a', '#6b3f2a'] : ['#9e978a', '#6f6a62', '#b8b2a6', '#55524a', '#8d9096'];
    const n = Math.min(70, 20 + (w * d) / 25);
    const c = Math.cos(angle);
    const s = Math.sin(angle);
    for (let i = 0; i < n; i++) {
      const u = (Math.random() - 0.5) * w;
      const v = (Math.random() - 0.5) * d;
      const px = x + c * u - s * v;
      const py = y + s * u + c * v;
      const dir = { x: dx + (Math.random() - 0.5) * 0.8, y: dy + (Math.random() - 0.5) * 0.8 };
      if (wood) this.part('panel', px, py, range(40, 120), dir, colors[i % colors.length], range(40, 110), range(2, 5.5), range(0.5, 1), 'rect', Math.random() < 0.08 ? range(3, 8) : 0);
      else this.part('chunk', px, py, range(30, 100), dir, colors[i % colors.length], range(40, 120), range(1.2, 3.4), range(1, 2.6), Math.random() < 0.4 ? 'tri' : 'rect');
      if (i % 3 === 0) this.dust(px, py, range(1.5, 3));
    }
    // Big slabs of wall and roof sliding out: they stay where they land as rubble.
    for (let i = 0; i < Math.min(10, 3 + (w * d) / 200); i++) {
      const u = (Math.random() - 0.5) * w;
      const v = (Math.random() - 0.5) * d;
      this.part(wood ? 'door' : 'panel', x + c * u - s * v, y + s * u + c * v, range(15, 45), { x: dx + (Math.random() - 0.5), y: dy + (Math.random() - 0.5) }, wood ? colors[i % 2] : colors[i % 3], range(20, 50), range(5, 10), range(2.5, 4.5), 'rect');
    }
    // The dust cloud billowing out of the collapse.
    for (let i = 0; i < Math.min(44, 14 + (w * d) / 60); i++) {
      const a = Math.random() * Math.PI * 2;
      const max = range(4, 8);
      this.add({ x: x + range(-w, w) * 0.35, y: y + range(-d, d) * 0.35, vx: Math.cos(a) * range(25, 70) + dx * 15, vy: Math.sin(a) * range(25, 70) + dy * 15, r: range(6, 10), grow: range(8, 14), life: max * 1.4, max: max * 1.4, kind: 'dust', tone: 0, rgb: wood ? [128, 104, 78] : [150, 146, 138], delay: range(0, 0.4) });
    }
    for (let i = 0; i < 12; i++) this.part('glass', x + range(-w, w) * 0.4, y + range(-d, d) * 0.4, range(40, 90), { x: dx, y: dy }, 'rgba(205, 232, 255, 0.9)', 60, range(0.6, 1.4), range(0.5, 1));
    for (let i = 0; i < 18; i++) this.puff(x + range(-w, w) * 0.5, y + range(-d, d) * 0.5, true, false, range(2.5, 4.5));
    // The dust settles slowly: a haze hangs over the ruins and drifts off downwind.
    this.haze(x, y, Math.max(w, d) * 0.7, wood ? [140, 116, 88] : [160, 156, 148], Math.min(14, 5 + (w * d) / 300));
    if (this.parts.length > MAX_PARTS) this.parts.splice(0, this.parts.length - MAX_PARTS);
  }

  /**
   * A slow, low haze of dust: big faint clouds that hang over a blast or a collapse long
   * after it and drift away on the wind.
   */
  haze(x: number, y: number, r: number, rgb: [number, number, number], n: number): void {
    for (let i = 0; i < n; i++) {
      const a = Math.random() * Math.PI * 2;
      const d = Math.sqrt(Math.random()) * r;
      const max = range(10, 18);
      this.add({ x: x + Math.cos(a) * d, y: y + Math.sin(a) * d, vx: WIND.x * range(0.6, 1.4), vy: WIND.y * range(0.6, 1.4), r: range(10, 16), grow: range(2, 4), life: max, max, kind: 'dust', tone: 0, rgb, delay: range(0.6, 2.5) });
    }
  }

  /** A jet of water from a fire hose. */
  water(x1: number, y1: number, x2: number, y2: number): void {
    if (this.puffs.length > MAX_PUFFS) this.puffs.shift();
    const dx = x2 - x1;
    const dy = y2 - y1;
    const d = Math.hypot(dx, dy) || 1;
    const speed = d * range(1.6, 2.2);
    const max = 0.45;
    this.puffs.push({ x: x1, y: y1, vx: (dx / d) * speed + range(-6, 6), vy: (dy / d) * speed + range(-6, 6), r: 0.6, grow: 6, life: max, max, kind: 'water', tone: 0 });
  }

  /** Put out every fire within r. Returns how many were burning. */
  extinguish(x: number, y: number, r: number): number {
    const before = this.fires.length;
    for (const f of this.fires) if (Math.hypot(f.x - x, f.y - y) <= r && f.r > 6) this.scorch(f.x, f.y, f.r, false);
    this.fires = this.fires.filter((f) => Math.hypot(f.x - x, f.y - y) > r);
    for (let i = 0; i < 8; i++) this.steam(x + range(-r, r) * 0.5, y + range(-r, r) * 0.5, 1.5);
    return before - this.fires.length;
  }

  /** Roadside crews sweep up debris, glass and blood around a scene. */
  cleanup(x: number, y: number, r: number): void {
    const far = (px: number, py: number) => Math.hypot(px - x, py - y) > r;
    this.parts = this.parts.filter((p) => far(p.x, p.y));
    this.splats = this.splats.filter((p) => far(p.x, p.y));
    this.skids = this.skids.filter((k) => !k.blood || far(k.x1, k.y1));
  }

  /** Burning spots near a point (for the fire brigade). */
  firesNear(x: number, y: number, r: number): Vec[] {
    return this.fires.filter((f) => Math.hypot(f.x - x, f.y - y) < r).map((f) => ({ x: f.x, y: f.y }));
  }

  /** A fuel fireball: a huge blast that leaves the ground burning for a while. */
  /** A fuel-tank fireball (a vehicle going up). */
  fireball(x: number, y: number, size = 1, ground: Ground = 'metal'): void {
    this.detonation(x, y, size, 1, ground);
  }

  private add(p: Puff): void {
    if (this.puffs.length > MAX_PUFFS) this.puffs.shift();
    this.puffs.push(p);
  }

  /**
   * A fireball whose character depends on what it hits:
   *   earth / grass  an eruption of soil, clods and torn turf; a brown-rimmed crater
   *   tarmac         broken asphalt, grit and sparks; a dark pit
   *   concrete       pale dust and slabs
   *   water          a geyser of spray and steam; no crater, nothing left burning
   *   wood           splinters and a blaze
   *   stone          masonry chunks and a cloud of grey dust
   *   metal          a vehicle: torn metal, a shower of sparks and a fuel fireball
   * Fire is only left behind where something can burn (dry grass, timber, spilt fuel).
   */
  detonation(x: number, y: number, size = 1, burn = 1, ground?: Ground): void {
    const s = size;
    const kind = ground ?? this.groundAt?.(x, y) ?? 'tarmac';
    const wet = kind === 'water';
    const earthy = kind === 'earth' || kind === 'grass';
    const fuel = kind === 'metal';
    if (!wet) this.scorch(x, y, 4 + s * 6, true, earthy);
    this.rings.push({ x, y, r: 6, speed: 200 * Math.sqrt(s), life: 0.4, max: 0.4, width: 3 * s });
    this.add({ x, y, vx: 0, vy: 0, r: 16 * s, grow: 60 * s, life: 0.16, max: 0.16, kind: 'flash', tone: 0 });
    // The fireball: smaller over water and soft earth (it soaks up the blast), bigger with fuel or timber.
    const flame = wet ? 0.4 : earthy ? 0.75 : fuel || kind === 'wood' ? 1.3 : 1;
    for (let i = 0; i < 40 * s * flame; i++) {
      const a = Math.random() * Math.PI * 2;
      const sp = range(10, 80) * s;
      const max = range(1, 2.2) * Math.min(1.5, 0.7 + s * 0.4);
      this.add({ x: x + Math.cos(a) * range(0, 6) * s, y: y + Math.sin(a) * range(0, 6) * s, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 6, r: range(4, 8) * s, grow: range(5, 11) * s, life: max, max, kind: 'blast', tone: 0 });
    }
    this.ejecta(x, y, s, kind);
    // Big ones kick a ring of dust (or spray) racing outward along the ground, and boil up a mushroom of smoke.
    if (s >= 1.4) {
      const ring: [number, number, number] = earthy ? [122, 98, 66] : kind === 'concrete' || kind === 'stone' ? [160, 156, 148] : [132, 124, 110];
      for (let i = 0; i < 46; i++) {
        const a = (i / 46) * Math.PI * 2 + range(-0.05, 0.05);
        const sp = range(150, 240) * Math.sqrt(s) * 1.4;
        const max = range(1.4, 2.5);
        this.add({ x: x + Math.cos(a) * 8 * s, y: y + Math.sin(a) * 8 * s, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, r: range(3, 5) * s, grow: 9 * s, life: max, max, kind: wet ? 'water' : 'dust', tone: 0, rgb: ring });
      }
      if (!wet) {
        for (let i = 0; i < 18; i++) {
          const max = range(4, 8);
          this.add({ x: x + range(-8, 8) * s, y: y + range(-8, 8) * s, vx: range(-6, 6) + 4, vy: range(-6, 6) - 10, r: range(6, 10) * s, grow: range(4, 7) * s, life: max, max, kind: 'smoke', tone: fuel ? range(12, 24) : range(18, 34), delay: range(0.5, 1.4) });
        }
      }
    }
    // Smoke rolls in after it: black and oily from fuel, browner from earth, steam from water.
    for (let i = 0; i < 38 * s * (earthy ? 0.7 : 1); i++) {
      const a = Math.random() * Math.PI * 2;
      const d = range(0, 26) * s;
      const max = range(5, 11);
      const tone = fuel ? range(14, 30) : earthy ? range(55, 80) : range(26, 55);
      this.add({ x: x + Math.cos(a) * d, y: y + Math.sin(a) * d, vx: Math.cos(a) * range(5, 25) + 6, vy: Math.sin(a) * range(5, 25) - 8, r: range(4, 8) * s, grow: range(3, 7) * s, life: max, max, kind: wet ? 'steam' : 'smoke', tone, delay: range(0.25, 0.9) });
    }
    // Sparks: a shower off metal and tarmac, few from soft earth or water.
    const sparks = (fuel ? 150 : kind === 'tarmac' || kind === 'stone' || kind === 'concrete' ? 90 : wet ? 10 : 40) * s;
    for (let i = 0; i < sparks; i++) {
      const a = Math.random() * Math.PI * 2;
      const sp = range(80, 360) * Math.sqrt(s);
      this.sparks.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: range(0.3, 1.1), max: 1.1 });
    }
    if (this.sparks.length > 1400) this.sparks.splice(0, this.sparks.length - 1400);
    if (this.parts.length > MAX_PARTS) this.parts.splice(0, this.parts.length - MAX_PARTS);
    // Fire left behind only by something that burns: a vehicle's spilt fuel. Everything
    // else an explosion hits is left cratered, scorched and smoking, not burning (the
    // game sets alight what the blast destroys: trees, buildings).
    if (fuel) {
      for (let i = 0; i < 3 + Math.floor(s * 3); i++) {
        const life = range(4, 9) * burn;
        this.fires.push({ x: x + range(-14, 14) * s, y: y + range(-14, 14) * s, r: range(5, 10) * Math.min(2, s), life, max: life });
      }
    }
    if (this.fires.length > MAX_FIRES) this.fires.splice(0, this.fires.length - MAX_FIRES);
  }

  /**
   * The extra layers of a bomb going off, on top of its detonation(): the white-hot core
   * of burning aluminium, the case's fragments peppering the ground far beyond the
   * fireball, a cone of earth thrown up that rains back down in a ring, the dust column
   * slumping into a low skirt that rolls outward, and (in damp air) a vapour ring
   * flashing out with the shock. R is the blast radius in map units.
   */
  bombBurst(x: number, y: number, R: number, s: number, kind: Ground, humid: boolean): void {
    const wet = kind === 'water';
    const earthy = kind === 'earth' || kind === 'grass';
    const soil: [number, number, number] = earthy ? [112, 88, 60] : kind === 'concrete' || kind === 'stone' ? [164, 160, 152] : [128, 122, 112];
    // White-hot core, then the flash spreading.
    this.add({ x, y, vx: 0, vy: 0, r: R * 0.12, grow: R * 1.8, life: 0.2, max: 0.2, kind: 'flash', tone: 0 });
    this.add({ x, y, vx: 0, vy: 0, r: R * 0.08, grow: R * 0.9, life: 0.38, max: 0.38, kind: 'flash', tone: 0 });
    // Fragments: streaks racing out, and little spurts where they strike.
    for (let i = 0; i < 160; i++) {
      const a = Math.random() * Math.PI * 2;
      const sp = R * range(7, 13);
      this.sparks.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: range(0.08, 0.28), max: 0.28 });
    }
    for (let i = 0; i < 130; i++) {
      const a = Math.random() * Math.PI * 2;
      const d = R * (0.55 + Math.pow(Math.random(), 0.7) * 2.1);
      const fx = x + Math.cos(a) * d;
      const fy = y + Math.sin(a) * d;
      const max = range(0.5, 1.1);
      this.add({ x: fx, y: fy, vx: Math.cos(a) * 8, vy: Math.sin(a) * 8, r: range(0.8, 1.6), grow: range(3, 6), life: max, max, kind: wet ? 'water' : 'dust', tone: 0, rgb: soil, delay: d / (R * 12) });
    }
    if (this.sparks.length > 1600) this.sparks.splice(0, this.sparks.length - 1600);
    if (wet) {
      // A white column of water instead of an earth cone.
      for (let i = 0; i < 40 * s; i++) {
        const a = Math.random() * Math.PI * 2;
        const d = range(0, R * 0.5);
        const max = range(2, 4);
        this.add({ x: x + Math.cos(a) * d, y: y + Math.sin(a) * d, vx: Math.cos(a) * range(10, 40), vy: Math.sin(a) * range(10, 40), r: range(5, 9) * s, grow: range(8, 14) * s, life: max, max, kind: 'water', tone: 0, delay: range(0, 0.4) });
      }
    } else {
      // The ejecta curtain: clods flung up and out at a steep angle...
      const clods = earthy ? ['#4a3824', '#5d4630', '#3a2c1c', '#6b543a'] : ['#2b2b2e', '#55524a', '#8a857b'];
      for (let i = 0; i < 46 * s; i++) this.part('chunk', x, y, R * 1.4, { x: 0, y: 0 }, clods[i % clods.length], range(140, 260), range(1, 2.6), range(1, 2.2), Math.random() < 0.4 ? 'tri' : 'rect');
      // ...raining back down in a ring around the crater.
      for (let i = 0; i < 40; i++) {
        const a = (i / 40) * Math.PI * 2 + range(-0.06, 0.06);
        const d = R * range(0.85, 1.55);
        const max = range(2.4, 4);
        this.add({ x: x + Math.cos(a) * d, y: y + Math.sin(a) * d, vx: Math.cos(a) * range(10, 25), vy: Math.sin(a) * range(10, 25), r: range(3, 5) * s, grow: range(5, 9) * s, life: max, max, kind: 'dust', tone: 0, rgb: soil, delay: range(0.6, 1.3) });
      }
      // The column slumps: a low, heavy skirt of dust rolls out long after the bang.
      for (let i = 0; i < 34; i++) {
        const a = Math.random() * Math.PI * 2;
        const max = range(6, 10);
        const sp = range(25, 55);
        this.add({ x: x + Math.cos(a) * R * 0.25, y: y + Math.sin(a) * R * 0.25, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, r: range(7, 11) * s, grow: range(7, 11) * s, life: max, max, kind: 'dust', tone: 0, rgb: soil, delay: range(1.6, 3) });
      }
    }
    if (humid) this.rings.push({ x, y, r: R * 0.25, speed: R * 7, life: 0.3, max: 0.3, width: R * 0.3, vapor: true });
    // Long after the bang a haze of dust and smoke hangs over the whole blast area.
    this.haze(x, y, R * 1.3, soil, Math.round(10 + s * 5));
    for (let i = 0; i < 16 * s; i++) {
      const a = Math.random() * Math.PI * 2;
      const d = Math.sqrt(Math.random()) * R * 0.6;
      const max = range(9, 16);
      this.add({ x: x + Math.cos(a) * d, y: y + Math.sin(a) * d, vx: WIND.x * range(0.8, 1.6), vy: WIND.y * range(0.8, 1.6), r: range(8, 14), grow: range(4, 7), life: max, max, kind: 'smoke', tone: range(30, 60), delay: range(0.8, 2.5) });
    }
  }

  /** A puff of dirt kicked forward where a round strikes soft ground. */
  dustSpurt(x: number, y: number, angle: number): void {
    const max = range(0.6, 1.1);
    const sp = range(20, 45);
    this.add({ x, y, vx: Math.cos(angle) * sp, vy: Math.sin(angle) * sp, r: range(1.2, 2), grow: range(4, 7), life: max, max, kind: 'dust', tone: 0, rgb: [112, 88, 60] });
  }

  /** What a blast throws out, by what it hit. */
  private ejecta(x: number, y: number, s: number, kind: Ground): void {
    const up = (n: number, rgb: [number, number, number], speed: number, grow: number, life: number) => {
      for (let i = 0; i < n; i++) {
        const a = Math.random() * Math.PI * 2;
        const sp = range(0.3, 1) * speed * Math.sqrt(s);
        const max = range(0.7, 1.3) * life;
        this.add({ x: x + range(-4, 4) * s, y: y + range(-4, 4) * s, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - range(10, 40) * s, r: range(3, 6) * s, grow: grow * s, life: max, max, kind: 'dust', tone: 0, rgb });
      }
    };
    const chunks = (n: number, colors: string[], speed: number, kindOf: PartKind = 'chunk', w: [number, number] = [1.2, 3.6]) => {
      for (let i = 0; i < n; i++) this.part(kindOf, x, y, speed * Math.sqrt(s), { x: 0, y: 0 }, colors[i % colors.length], speed, range(w[0], w[1]), range(1, 2.6), Math.random() < 0.35 ? 'tri' : 'rect', kindOf === 'chunk' && Math.random() < 0.15 ? range(2, 5) : 0);
    };
    switch (kind) {
      case 'earth':
      case 'grass':
        // A column of soil erupting, clods raining back down, torn turf on grass.
        up(36 * s, [104, 80, 54], 120, 10, 2.4);
        up(14 * s, [140, 116, 84], 60, 14, 3.4);
        chunks(26 * s, ['#4a3824', '#5d4630', '#3a2c1c'], 190);
        if (kind === 'grass') chunks(10 * s, ['#4d7a35', '#3f6a2c'], 150, 'chunk', [1, 2.4]);
        break;
      case 'tarmac':
        up(14 * s, [118, 116, 112], 90, 9, 2);
        chunks(18 * s, ['#2b2b2e', '#3a3a3e', '#55524a'], 180);
        break;
      case 'concrete':
      case 'stone':
        up(22 * s, [168, 164, 156], 100, 11, 2.8);
        chunks(20 * s, ['#9e978a', '#6f6a62', '#b8b2a6'], 170, 'chunk', [1.5, 4]);
        break;
      case 'wood':
        up(10 * s, [120, 98, 72], 80, 9, 2);
        chunks(22 * s, ['#8a6a44', '#a0703c', '#5d4037'], 180, 'panel', [2, 5]);
        break;
      case 'metal':
        chunks(16 * s, ['#8d9096', '#c9ccd1', '#1b1b1f'], 200, 'panel', [1.5, 4]);
        break;
      case 'water':
        // A geyser of white water, a ring of spray and a cloud of steam.
        for (let i = 0; i < 50 * s; i++) {
          const a = Math.random() * Math.PI * 2;
          const sp = range(10, 110) * Math.sqrt(s);
          const max = range(0.8, 1.8);
          this.add({ x: x + range(-3, 3) * s, y: y + range(-3, 3) * s, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - range(10, 50), r: range(3, 6) * s, grow: range(6, 12) * s, life: max, max, kind: 'water', tone: 0 });
        }
        for (let i = 0; i < 14 * s; i++) this.steam(x + range(-10, 10) * s, y + range(-10, 10) * s, 1.5 + s);
        break;
    }
  }

  /**
   * Grass and brush fires creep outward: each healthy fire may light the ground beside
   * it (wherever `burnable` says there's something to burn), so a blaze in a field or a
   * row of yards keeps growing until it hits a road, water, or the fire crews.
   */
  spreadGroundFires(dt: number, burnable: (x: number, y: number) => boolean): void {
    if (this.fires.length >= MAX_FIRES) return;
    const fresh: GroundFire[] = [];
    for (const f of this.fires) {
      if (f.life < f.max * 0.35 || Math.random() > dt * 0.5) continue;
      const a = Math.random() * Math.PI * 2;
      const d = f.r + range(5, 10);
      const x = f.x + Math.cos(a) * d;
      const y = f.y + Math.sin(a) * d;
      if (!burnable(x, y) || this.fires.some((o) => Math.abs(o.x - x) < 6 && Math.abs(o.y - y) < 6)) continue;
      const life = range(10, 20);
      fresh.push({ x, y, r: range(5, 8), life, max: life });
      if (this.fires.length + fresh.length >= MAX_FIRES) break;
    }
    this.fires.push(...fresh);
  }

  /** A wide charred blast mark around a big explosion, fading over several minutes. */
  blastMark(x: number, y: number, r: number): void {
    this.burns.push({ x, y, r, born: this.time });
    if (this.burns.length > 40) this.burns.shift();
  }

  /** Flames licking out of a burning building or tree (call every frame it burns, while in view). */
  flames(x: number, y: number, w: number, d: number, angle: number, dt: number, intensity: number): void {
    const c = Math.cos(angle);
    const sn = Math.sin(angle);
    const rate = Math.min(28, 4 + (w * d) / 60) * intensity;
    for (let n = Math.floor(rate * dt + Math.random()); n > 0; n--) {
      const u = range(-0.45, 0.45) * w;
      const v = range(-0.45, 0.45) * d;
      const px = x + c * u - sn * v;
      const py = y + sn * u + c * v;
      const max = range(0.5, 1.1);
      this.add({ x: px, y: py, vx: range(-6, 6) + 6, vy: range(-6, 6) - 10, r: range(2, 4) * Math.min(1.6, 0.6 + w / 30), grow: 5, life: max, max, kind: 'blast', tone: 0 });
      // Thick smoke rolling off the blaze and away downwind.
      if (Math.random() < 0.75) this.puff(px, py, true, false, range(2.4, 4.8));
      // Embers drifting up out of the blaze.
      if (Math.random() < 0.35) this.sparks.push({ x: px, y: py, vx: range(-12, 12) + 8, vy: range(-30, -8), life: range(0.8, 1.8), max: 1.8 });
    }
  }

  /** A body sliding along the road leaves a red smear. */
  smear(x1: number, y1: number, x2: number, y2: number): void {
    this.skids.push({ x1, y1, x2, y2, w: 1.9, born: this.time, blood: true });
    if (this.skids.length > MAX_SKIDS) this.skids.splice(0, this.skids.length - MAX_SKIDS);
  }

  /** Every light source the effects own: fires, fireballs and flashes. */
  glows(view: View): Glow[] {
    const out: Glow[] = [];
    const vis = (x: number, y: number) => x > view.x - 60 && x < view.x + view.w + 60 && y > view.y - 60 && y < view.y + view.h + 60;
    for (const f of this.fires) if (vis(f.x, f.y)) out.push({ x: f.x, y: f.y, r: f.r * 4 * (0.8 + Math.random() * 0.3), color: 'rgba(255, 140, 40, 0.5)' });
    for (const r of this.rings) if (vis(r.x, r.y)) out.push({ x: r.x, y: r.y, r: r.r * 2.5, color: 'rgba(255, 220, 160, 0.6)' });
    let n = 0;
    for (const p of this.puffs) {
      if (p.kind !== 'fire' || n > 40 || !vis(p.x, p.y)) continue;
      n++;
      out.push({ x: p.x, y: p.y, r: p.r * 4, color: 'rgba(255, 120, 30, 0.35)' });
    }
    for (const e of this.emitters) if (e.kind !== 'geyser' && vis(e.x, e.y)) out.push({ x: e.x, y: e.y, r: e.kind === 'gas' ? 70 : 40, color: e.kind === 'gas' ? 'rgba(255, 140, 40, 0.6)' : 'rgba(170, 210, 255, 0.6)' });
    return out;
  }

  private part(kind: PartKind, x: number, y: number, speed: number, dir: Vec, color: string, lift: number, w: number, h: number, shape: Part['shape'] = 'rect', burning = 0): void {
    const a = Math.random() * Math.PI * 2;
    this.parts.push({
      shape,
      burning,
      kind,
      x,
      y,
      z: 0.5,
      vx: Math.cos(a) * speed * range(0.3, 1) + dir.x * speed * 0.8,
      vy: Math.sin(a) * speed * range(0.3, 1) + dir.y * speed * 0.8,
      vz: lift * range(0.6, 1.2),
      a: Math.random() * Math.PI,
      va: range(-20, 20),
      w,
      h,
      color,
      born: this.time,
    });
  }

  /**
   * A collision. `intensity` comes from closing speed: ~0.2 is a scuff, 0.5 a solid
   * hit, 1 a wreck, 2 a high-speed disaster. Everything scales with it.
   */
  crash(x: number, y: number, intensity: number, colors: string[], dir: Vec = { x: 0, y: 0 }, opts: CrashOptions = {}): void {
    const k = Math.min(3, Math.max(0.1, intensity));
    const mat = opts.material ?? 'car';
    // Every crash rolls its own dice so no two look alike, and the drama climbs
    // steeply with speed.
    const drama = 1 + Math.pow(Math.max(0, k - 0.5), 1.5) * 1.6;
    const vary = () => range(0.55, 1.5) * drama;
    const burnShare = Math.max(0, (k - 1.1) * 0.45);
    const speed = (40 + k * 110) * vary();
    const sparkBias = mat === 'barrier' ? 2.2 : mat === 'concrete' ? 0.7 : mat === 'truck' ? 1.4 : 1;
    const sparkCount = (6 + k * 40) * sparkBias * vary();
    for (let i = 0; i < sparkCount; i++) {
      const a = Math.random() * Math.PI * 2;
      const s = range(60, 220) * (0.5 + k * 0.6);
      // Barrier scrapes fan sparks along the rail instead of in a ring.
      const along = mat === 'barrier' ? 1.6 : 0.8;
      this.sparks.push({ x, y, vx: Math.cos(a) * s * (mat === 'barrier' ? 0.4 : 1) + dir.x * 80 * k * along, vy: Math.sin(a) * s * (mat === 'barrier' ? 0.4 : 1) + dir.y * 80 * k * along, life: range(0.15, 0.6), max: 0.6 });
    }
    if (k > 0.3) this.rings.push({ x, y, r: 3, speed: 90 * vary(), life: 0.25, max: 0.25, width: 2.4 });
    if (k > 1.1) this.rings.push({ x, y, r: 8, speed: 260 * Math.min(1.6, k), life: 0.5, max: 0.5, width: 4 });
    if (opts.glass ?? k > 0.3) {
      for (let i = 0; i < (3 + k * 14) * vary(); i++) this.part('glass', x, y, speed * 1.1, dir, 'rgba(205, 232, 255, 0.9)', 60 + k * 50, range(0.6, 1.5), range(0.5, 1.1));
    }
    for (let i = 0; i < (1 + k * 6) * vary(); i++) this.part('chunk', x, y, speed, dir, colors[Math.floor(Math.random() * colors.length)], 50 + k * 60, range(1, 2.6), range(0.8, 1.8), 'rect', Math.random() < burnShare ? range(1.5, 4) : 0);
    if (k > 0.4 && Math.random() < 0.3 + k * 0.4) this.part('bumper', x, y, speed * 0.8, dir, Math.random() < 0.5 ? '#1c1c20' : colors[0], 60 + k * 70, range(6, 9), 1.4);
    const panels = (k > 0.6 ? Math.floor(k * range(0.6, 2.4)) : 0) + (opts.panels ?? 0);
    for (let i = 0; i < panels; i++) {
      this.part(Math.random() < 0.5 ? 'door' : 'panel', x, y, speed * 0.9, dir, colors[i % colors.length], 80 + k * 80, range(4, 7), range(3, 5), 'rect', Math.random() < burnShare ? range(2, 5) : 0);
    }
    for (let i = 0; i < (opts.wheels ?? 0); i++) this.part('wheel', x, y, speed * 1.2, dir, '#141416', 90 + k * 90, 3.6, 1.8);
    if (k > 0.7 && Math.random() < 0.5) this.part('hubcap', x, y, speed * 1.3, dir, '#cfd3d8', 70 + k * 60, 2.4, 2.4);
    // Concrete throws grit and a dust cloud.
    if (mat === 'concrete' || mat === 'shore') {
      for (let i = 0; i < 3 + k * 8; i++) this.part('chunk', x, y, speed * 0.6, { x: -dir.x, y: -dir.y }, Math.random() < 0.5 ? '#9e978a' : '#7d776d', 40 + k * 40, range(0.8, 2), range(0.8, 1.6));
      for (let i = 0; i < 2 + k * 6; i++) this.dust(x + range(-4, 4), y + range(-4, 4), 1 + k);
    }
    if (mat === 'wood') {
      for (let i = 0; i < 4 + k * 10; i++) this.part('chunk', x, y, speed * 0.7, { x: -dir.x, y: -dir.y }, Math.random() < 0.5 ? '#7a5532' : '#a07a4c', 40 + k * 50, range(2, 4), range(0.4, 0.8));
      for (let i = 0; i < 6 + k * 14; i++) this.part('chunk', x + range(-8, 8), y + range(-8, 8), range(10, 40), { x: 0, y: 0 }, Math.random() < 0.5 ? '#4f8a3e' : '#6db454', range(30, 80), range(1, 1.8), range(0.8, 1.4), 'round');
    }
    this.flavorDebris(x, y, k * drama, speed, dir, opts.flavor ?? '', colors[0]);
    if (this.parts.length > MAX_PARTS) this.parts.splice(0, this.parts.length - MAX_PARTS);
    // Smoke only when the engine took it.
    if (opts.smoke === 'steam') for (let i = 0; i < 3 + k * 5; i++) this.steam(x, y, 1 + k * 0.5);
    if (opts.smoke === 'black') for (let i = 0; i < 4 + k * 10; i++) this.puff(x + range(-4, 4) * k, y + range(-4, 4) * k, true, false, range(1.4, 2.6) * (0.7 + k * 0.4));
    if (k > 1.15 && Math.random() < 0.5 + (k - 1.15)) for (let i = 0; i < k * 9; i++) this.puff(x + range(-6, 6), y + range(-6, 6), false, true, range(1.3, 2.4));
  }

  /** Signature debris for each kind of vehicle. */
  private flavorDebris(x: number, y: number, k: number, speed: number, dir: Vec, flavor: string, color: string): void {
    const n = (base: number) => Math.round(base * Math.min(3, 0.4 + k) * range(0.6, 1.3));
    const spray = (count: number, shape: Part['shape'], colors: string[], w: [number, number], h: [number, number], lift = 70) => {
      for (let i = 0; i < count; i++) this.part('chunk', x, y, speed * range(0.6, 1.2), dir, colors[i % colors.length], lift + k * 60, range(w[0], w[1]), range(h[0], h[1]), shape);
    };
    switch (flavor) {
      case 'icecream':
        spray(n(12), 'round', ['#ffd1e6', '#fff3c4', '#c8f0d8', '#d7b48a', '#8a5a3c'], [1.6, 2.6], [1.6, 2.6], 90);
        spray(n(4), 'tri', ['#d9a75b'], [2, 3], [1.2, 1.6]);
        break;
      case 'police':
        spray(n(5), 'round', ['#ff2d2d', '#1e4bff'], [1, 1.6], [1, 1.6], 100);
        spray(n(2), 'rect', ['#f4f4f4', '#15161c'], [4, 6], [2.5, 4]);
        break;
      case 'bus':
        spray(n(20), 'rect', ['rgba(205, 232, 255, 0.9)'], [0.8, 1.6], [0.6, 1.1], 90);
        spray(n(6), 'rect', ['#3b4a6b', '#2c3650'], [2.4, 3.4], [2, 2.6]);
        break;
      case 'pickup':
        spray(n(6), 'rect', Math.random() < 0.5 ? ['#e2c25a', '#d4b04a'] : ['#9a6b3c', '#8a5b2c'], [2.4, 4], [2, 3.2], 80);
        break;
      case 'van':
        spray(n(9), 'rect', ['#c8a26a', '#b88f55', '#d8b47a'], [2, 3.6], [2, 3.2], 80);
        break;
      case 'limo':
        spray(n(6), 'rect', ['#2f6b3a', '#1f4d2a'], [0.6, 0.9], [2, 2.6], 90);
        spray(n(24), 'rect', ['#ff4fa3', '#ffd400', '#3fd0ff', '#7dff6a', '#ffffff'], [0.5, 0.9], [0.5, 0.9], 120);
        break;
      case 'beater':
        spray(n(10), 'rect', ['#8a4a2a', '#6b3a22', '#a2673a'], [0.8, 1.8], [0.6, 1.2], 60);
        break;
      case 'sports':
        spray(n(8), 'tri', ['#1b1b1f', '#33333a'], [1.6, 3], [1, 2], 90);
        break;
      case 'taxi':
        spray(1, 'rect', ['#fff7c2'], [3, 3.4], [2.6, 3]);
        break;
      case 'compact':
        spray(n(8), 'round', [color, '#2b2b2f'], [0.8, 1.6], [0.8, 1.6], 70);
        break;
      case 'motorcycle':
        spray(n(4), 'rect', ['#9a9ca2', '#1b1b1f'], [1.6, 3], [0.6, 1]);
        break;
    }
  }

  /** Radiator steam: bright white, quick to fade. */
  steam(x: number, y: number, size = 1): void {
    if (this.puffs.length > MAX_PUFFS) this.puffs.shift();
    const max = range(0.5, 1.1);
    this.puffs.push({ x: x + range(-2, 2), y: y + range(-2, 2), vx: range(-12, 12), vy: range(-14, 4), r: 1.4 * size, grow: 9, life: max, max, kind: 'steam', tone: 245 });
  }

  /** Brown-grey dust kicked off concrete or dirt. */
  dust(x: number, y: number, size = 1): void {
    if (this.puffs.length > MAX_PUFFS) this.puffs.shift();
    const max = range(0.8, 1.6);
    this.puffs.push({ x, y, vx: range(-15, 15), vy: range(-15, 15), r: 2 * size, grow: 8 * size, life: max, max, kind: 'dust', tone: 160 });
  }

  /** A red splat on the road where a pedestrian was hit, with flying droplets. */
  splat(x: number, y: number, size: number, vx = 0, vy = 0): void {
    const blobs = Array.from({ length: 5 + Math.floor(size * 6) }, () => {
      const a = Math.random() * Math.PI * 2;
      const d = Math.pow(Math.random(), 0.7) * size * 4.5;
      return { dx: Math.cos(a) * d + vx * 0.02 * Math.random(), dy: Math.sin(a) * d + vy * 0.02 * Math.random(), r: range(0.6, 1.6) * (1 + size * 0.4) };
    });
    this.splats.push({ x, y, blobs, born: this.time });
    if (this.splats.length > 160) this.splats.shift();
    for (let i = 0; i < 10 + size * 14; i++) {
      const a = Math.random() * Math.PI * 2;
      const s = range(20, 90) * size;
      this.drops.push({ x, y, vx: Math.cos(a) * s + vx * 0.4, vy: Math.sin(a) * s + vy * 0.4, life: range(0.2, 0.5), max: 0.5 });
    }
  }

  /** A fireball: the truck or a car going up. */
  explosion(x: number, y: number, size = 1): void {
    this.scorch(x, y, 4 + size * 6, true);
    this.rings.push({ x, y, r: 6, speed: 340 * size, life: 0.6, max: 0.6, width: 6 });
    this.rings.push({ x, y, r: 4, speed: 140 * size, life: 0.4, max: 0.4, width: 3 });
    for (let i = 0; i < 40 * size; i++) this.puff(x + range(-10, 10) * size, y + range(-10, 10) * size, false, true, range(2, 4) * size);
    for (let i = 0; i < 30 * size; i++) this.puff(x + range(-14, 14) * size, y + range(-14, 14) * size, true, false, range(2.5, 4.5) * size);
    for (let i = 0; i < 70 * size; i++) {
      const a = Math.random() * Math.PI * 2;
      const s = range(80, 320);
      this.sparks.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: range(0.3, 0.9), max: 0.9 });
    }
    for (let i = 0; i < 14 * size; i++) this.part('chunk', x, y, 160, { x: 0, y: 0 }, i % 2 ? '#2a2826' : '#5a5650', 160, range(1.5, 3.5), range(1, 2.5));
  }

  puff(x: number, y: number, dark = false, fire = false, size = 1): void {
    if (this.puffs.length > MAX_PUFFS) this.puffs.shift();
    // Dark smoke hangs around; grey puffs (exhaust, steam) clear quickly.
    const max = fire ? range(0.35, 0.7) : dark ? range(2.6, 5.5) * Math.min(1.6, 0.7 + size * 0.2) : range(1.2, 2.6);
    this.puffs.push({
      x,
      y,
      vx: range(-10, 10) + 7,
      vy: range(-10, 10) - 6,
      r: (fire ? 2 : 2.4) * size,
      grow: fire ? 5 : range(5, 10) * Math.min(2, size),
      life: max,
      max,
      kind: fire ? 'fire' : 'smoke',
      tone: dark ? range(30, 60) : range(110, 150),
    });
  }

  /** White tyre smoke from a sliding or spinning wheel. */
  tyreSmoke(x: number, y: number, amount = 1): void {
    if (this.puffs.length > MAX_PUFFS) this.puffs.shift();
    const max = range(0.6, 1.2);
    this.puffs.push({ x, y, vx: range(-6, 6), vy: range(-6, 6), r: 1.6 * amount, grow: 7 * amount, life: max, max, kind: 'tyre', tone: 225 });
  }

  /** A shower of sparks from metal dragging on asphalt. */
  scrape(x: number, y: number, vx: number, vy: number): void {
    for (let i = 0; i < 2; i++) {
      this.sparks.push({ x, y, vx: -vx * range(0.2, 0.6) + range(-30, 30), vy: -vy * range(0.2, 0.6) + range(-30, 30), life: range(0.1, 0.3), max: 0.3 });
    }
  }

  skid(x1: number, y1: number, x2: number, y2: number, w: number): void {
    this.skids.push({ x1, y1, x2, y2, w, born: this.time });
    if (this.skids.length > MAX_SKIDS) this.skids.splice(0, this.skids.length - MAX_SKIDS);
  }

  /** Vehicles shove debris they drive over. */
  kick(x: number, y: number, r: number, vx: number, vy: number): void {
    for (const p of this.parts) {
      if (p.z > 2) continue;
      const dx = p.x - x;
      const dy = p.y - y;
      if (dx * dx + dy * dy > r * r) continue;
      const d = Math.hypot(dx, dy) || 1;
      p.vx += vx * 0.6 + (dx / d) * 20;
      p.vy += vy * 0.6 + (dy / d) * 20;
      if (Math.hypot(vx, vy) > 60) {
        p.vz = range(20, 60);
        p.va = range(-15, 15);
      }
    }
  }

  update(dt: number): void {
    this.time += dt;
    for (const c of this.columns) {
      c.life -= dt;
      c.timer -= dt;
      if (c.timer > 0) continue;
      c.timer = 0.06;
      const k = Math.min(1, c.life / 8);
      this.puff(c.x + range(-7, 7), c.y + range(-7, 7), true, false, range(3, 5.5) * (0.55 + k * 0.6));
      if (Math.random() < 0.3 * k) this.puff(c.x + range(-4, 4), c.y + range(-4, 4), false, true, range(1.5, 2.5));
    }
    this.columns = this.columns.filter((c) => c.life > 0);
    for (const e of this.emitters) {
      e.life -= dt;
      e.timer -= dt;
      if (e.timer > 0) continue;
      if (e.kind === 'gas') {
        // A jet of burning gas, licking downwind, with a pall of smoke over it.
        e.timer = 0.04;
        e.angle += range(-0.15, 0.15);
        const sp = range(40, 90);
        const max = range(0.4, 0.8);
        this.add({ x: e.x, y: e.y, vx: Math.cos(e.angle) * sp + WIND.x, vy: Math.sin(e.angle) * sp + WIND.y, r: range(3, 5), grow: 9, life: max, max, kind: 'blast', tone: 0 });
        if (Math.random() < 0.3) this.puff(e.x + Math.cos(e.angle) * 12, e.y + Math.sin(e.angle) * 12, true, false, range(2, 4));
      } else if (e.kind === 'geyser') {
        e.timer = 0.05;
        for (let k = 0; k < 2; k++) {
          const a = Math.random() * Math.PI * 2;
          const max = range(0.6, 1.2);
          this.add({ x: e.x, y: e.y, vx: Math.cos(a) * range(8, 30), vy: Math.sin(a) * range(8, 30) - 10, r: range(2, 3.5), grow: range(4, 7), life: max, max, kind: 'water', tone: 0 });
        }
      } else {
        // Arcing: irregular blue-white flashes and spark showers.
        e.timer = range(0.08, 0.5);
        this.add({ x: e.x + range(-10, 10), y: e.y + range(-10, 10), vx: 0, vy: 0, r: range(3, 6), grow: 40, life: 0.08, max: 0.08, kind: 'flash', tone: 0 });
        for (let k = 0; k < 14; k++) {
          const a = Math.random() * Math.PI * 2;
          const sp = range(60, 200);
          this.sparks.push({ x: e.x, y: e.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: range(0.2, 0.6), max: 0.6 });
        }
      }
    }
    this.emitters = this.emitters.filter((e) => e.life > 0);
    for (const s of this.sparks) {
      s.life -= dt;
      s.x += s.vx * dt;
      s.y += s.vy * dt;
      s.vx *= 0.9;
      s.vy *= 0.9;
    }
    this.sparks = this.sparks.filter((s) => s.life > 0);
    if (this.sparks.length > 900) this.sparks.splice(0, this.sparks.length - 900);
    for (const d of this.drops) {
      d.life -= dt;
      d.x += d.vx * dt;
      d.y += d.vy * dt;
      d.vx *= 0.9;
      d.vy *= 0.9;
      if (d.life <= 0 && Math.random() < 0.35) {
        this.splats.push({ x: d.x, y: d.y, blobs: [{ dx: 0, dy: 0, r: range(0.4, 0.9) }], born: this.time });
      }
    }
    this.drops = this.drops.filter((d) => d.life > 0);
    for (const f of this.fires) {
      f.life -= dt;
      if (Math.random() < dt * 14) this.puff(f.x + range(-f.r, f.r) * 0.6, f.y + range(-f.r, f.r) * 0.6, false, true, range(1, 2) * (f.life / f.max + 0.4));
      if (Math.random() < dt * 5) this.puff(f.x, f.y, true, false, 1.5);
    }
    for (const f of this.fires) if (f.life <= 0 && f.r > 6) this.scorch(f.x, f.y, f.r, false);
    this.fires = this.fires.filter((f) => f.life > 0);
    if (this.splats.length > 400) this.splats.splice(0, this.splats.length - 400);
    for (const p of this.puffs) {
      if (p.delay && p.delay > 0) {
        p.delay -= dt;
        continue;
      }
      p.life -= dt;
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      p.r += p.grow * dt;
      if (p.kind === 'smoke' || p.kind === 'steam') {
        // Smoke climbs, spreads and is carried off downwind.
        const k = Math.min(1, dt * 0.7);
        p.vx += (WIND.x - p.vx) * k;
        p.vy += (WIND.y - p.vy) * k;
        p.grow *= Math.exp(-0.15 * dt);
      } else if (p.kind === 'dust') {
        // Dust is heavy: it billows out fast, then hangs low and settles.
        const drag = Math.exp(-2.6 * dt);
        p.vx = p.vx * drag + WIND.x * 0.15 * dt;
        p.vy = p.vy * drag + WIND.y * 0.15 * dt;
        p.grow *= Math.exp(-0.9 * dt);
      } else {
        p.vx *= 0.98;
        p.vy *= 0.98;
      }
    }
    this.puffs = this.puffs.filter((p) => p.life > 0);
    for (const r of this.rings) {
      r.life -= dt;
      r.r += r.speed * dt;
    }
    this.rings = this.rings.filter((r) => r.life > 0);
    for (const p of this.parts) {
      const airborne = p.z > 0 || p.vz > 0;
      if (airborne) {
        p.vz -= 340 * dt;
        p.z += p.vz * dt;
        if (p.z <= 0) {
          p.z = 0;
          p.vz = Math.abs(p.vz) > 40 ? -p.vz * 0.35 : 0;
          p.vx *= 0.7;
          p.vy *= 0.7;
          p.va *= p.kind === 'wheel' ? 0.9 : 0.5;
        }
      }
      // Wheels roll on; everything else scrapes to a stop.
      const friction = airborne ? 0.996 : Math.exp((p.kind === 'wheel' ? -0.6 : p.kind === 'hubcap' ? -1.2 : -4) * dt);
      p.vx *= friction;
      p.vy *= friction;
      if (p.burning > 0) {
        p.burning -= dt;
        if (Math.random() < dt * 30) this.puff(p.x, p.y, false, true, 0.7);
        if (Math.random() < dt * 12) this.puff(p.x, p.y, true, false, 0.8);
      }
      if (p.kind === 'wheel' && !airborne && Math.hypot(p.vx, p.vy) > 5) p.a = Math.atan2(p.vy, p.vx);
      else p.a += p.va * dt;
      if (!airborne) p.va *= Math.exp(-3 * dt);
      const nx = p.x + p.vx * dt;
      const ny = p.y + p.vy * dt;
      const push = this.solid && p.z < 4 && Math.hypot(p.vx, p.vy) > 1 ? this.solid(nx, ny) : null;
      if (push && (push.x || push.y)) {
        const l = Math.hypot(push.x, push.y);
        const into = (p.vx * push.x + p.vy * push.y) / l;
        p.vx -= (push.x / l) * into * 1.5;
        p.vy -= (push.y / l) * into * 1.5;
        p.x = nx + push.x;
        p.y = ny + push.y;
      } else {
        p.x = nx;
        p.y = ny;
      }
    }
    if (this.skids.length && this.time - this.skids[0].born > SKID_FADE) {
      this.skids = this.skids.filter((s) => this.time - s.born < SKID_FADE);
    }
  }

  /** Skid marks and debris lying on the road (under vehicles). */
  drawGround(ctx: CanvasRenderingContext2D, view: View): void {
    const buckets: Skid[][] = [[], [], [], []];
    const blood: Skid[] = [];
    for (const s of this.skids) {
      if (s.x1 < view.x - 10 || s.x1 > view.x + view.w + 10 || s.y1 < view.y - 10 || s.y1 > view.y + view.h + 10) continue;
      if (s.blood) {
        blood.push(s);
        continue;
      }
      buckets[Math.min(3, Math.floor(((this.time - s.born) / SKID_FADE) * 4))].push(s);
    }
    ctx.lineCap = 'round';
    buckets.forEach((list, i) => {
      if (!list.length) return;
      ctx.strokeStyle = `rgba(18, 17, 20, ${0.55 - i * 0.12})`;
      for (const w of [1.3, 1.9]) {
        ctx.lineWidth = w;
        ctx.beginPath();
        for (const s of list) {
          if (s.w !== w) continue;
          ctx.moveTo(s.x1, s.y1);
          ctx.lineTo(s.x2, s.y2);
        }
        ctx.stroke();
      }
    });
    if (blood.length) {
      ctx.strokeStyle = 'rgba(150, 12, 18, 0.75)';
      ctx.lineWidth = 2.2;
      ctx.beginPath();
      for (const s of blood) {
        ctx.moveTo(s.x1, s.y1);
        ctx.lineTo(s.x2, s.y2);
      }
      ctx.stroke();
    }
    ctx.lineCap = 'butt';
    const near = (x: number, y: number, m: number) => x > view.x - m && x < view.x + view.w + m && y > view.y - m && y < view.y + view.h + m;
    for (const b of this.burns) {
      if (!near(b.x, b.y, b.r)) continue;
      const fade = Math.max(0, 1 - (this.time - b.born) / 420);
      if (fade <= 0) continue;
      ctx.globalAlpha = 0.75 * fade;
      ctx.drawImage(this.soft(22, 18, 16, false), b.x - b.r, b.y - b.r, b.r * 2, b.r * 2);
      ctx.globalAlpha = 1;
    }
    for (const p of this.patches) {
      if (!near(p.x, p.y, 30)) continue;
      const age = Math.min(1, (this.time - p.born) / 400);
      ctx.save();
      ctx.translate(p.x, p.y);
      ctx.rotate(p.angle);
      ctx.fillStyle = `rgba(28, 28, 32, ${0.75 * (1 - age * 0.8)})`;
      ctx.fillRect(-p.r, -p.r * 0.8, p.r * 2, p.r * 1.6);
      ctx.strokeStyle = `rgba(150, 150, 150, ${0.35 * (1 - age)})`;
      ctx.lineWidth = 0.4;
      ctx.strokeRect(-p.r, -p.r * 0.8, p.r * 2, p.r * 1.6);
      ctx.restore();
    }
    // Cannon gouges: a dark pit with a lighter lip of torn earth or broken tarmac.
    for (const p of this.pits) {
      if (!near(p.x, p.y, 5)) continue;
      const fade = Math.max(0, 1 - (this.time - p.born) / 360);
      if (fade <= 0) continue;
      ctx.globalAlpha = fade;
      ctx.fillStyle = p.earth ? '#6b5236' : '#7c7a74';
      ctx.beginPath();
      ctx.ellipse(p.x, p.y, p.r * 2, p.r * 1.25, p.angle, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = p.earth ? '#2e2216' : '#1d1c1c';
      ctx.beginPath();
      ctx.ellipse(p.x, p.y, p.r, p.r * 0.7, p.angle, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalAlpha = 1;
    for (const c of this.craters) {
      if (!c.big || !near(c.x, c.y, c.r * 2.4)) continue;
      // Ejecta: dark streaks of soil and debris flung out radially.
      ctx.strokeStyle = c.earth ? 'rgba(62, 44, 26, 0.6)' : 'rgba(30, 30, 32, 0.5)';
      ctx.lineCap = 'round';
      for (let i = 0; i < 22; i++) {
        const a = c.seed * 3 + i * 0.29 + Math.sin(i * 7.1 + c.seed) * 0.15;
        const len = c.r * (1.4 + ((Math.sin(i * 12.9898 + c.seed) + 1) / 2) * 1);
        ctx.lineWidth = 1.2 + ((i * 7) % 5) * 0.5;
        ctx.beginPath();
        ctx.moveTo(c.x + Math.cos(a) * c.r * 0.9, c.y + Math.sin(a) * c.r * 0.9);
        ctx.lineTo(c.x + Math.cos(a) * len, c.y + Math.sin(a) * len);
        ctx.stroke();
      }
      ctx.lineCap = 'butt';
      // The raised rim, then the pit falling away into darkness.
      ctx.fillStyle = c.earth ? '#6a5236' : '#4a4846';
      ctx.beginPath();
      for (let i = 0; i < 16; i++) {
        const a = (i / 16) * Math.PI * 2;
        const rr = c.r * (1.05 + 0.12 * Math.sin(c.seed + i * 2.1));
        if (i === 0) ctx.moveTo(c.x + Math.cos(a) * rr, c.y + Math.sin(a) * rr);
        else ctx.lineTo(c.x + Math.cos(a) * rr, c.y + Math.sin(a) * rr);
      }
      ctx.closePath();
      ctx.fill();
      ctx.drawImage(this.soft(14, 11, 10, false), c.x - c.r * 1.05, c.y - c.r * 1.05, c.r * 2.1, c.r * 2.1);
      ctx.fillStyle = 'rgba(10, 8, 8, 0.85)';
      ctx.beginPath();
      ctx.arc(c.x + c.r * 0.08, c.y + c.r * 0.1, c.r * 0.45, 0, Math.PI * 2);
      ctx.fill();
    }
    for (const c of this.craters) {
      if (c.big || !near(c.x, c.y, 30)) continue;
      ctx.fillStyle = c.earth ? 'rgba(58, 40, 22, 0.6)' : c.hole ? 'rgba(18, 15, 14, 0.55)' : 'rgba(22, 18, 16, 0.42)';
      ctx.beginPath();
      for (let i = 0; i < 9; i++) {
        const a = (i / 9) * Math.PI * 2;
        const rr = c.r * (0.8 + 0.35 * Math.sin(c.seed + i * 2.3));
        if (i === 0) ctx.moveTo(c.x + Math.cos(a) * rr, c.y + Math.sin(a) * rr);
        else ctx.lineTo(c.x + Math.cos(a) * rr, c.y + Math.sin(a) * rr);
      }
      ctx.closePath();
      ctx.fill();
      if (!c.hole) continue;
      ctx.fillStyle = '#1a1716';
      ctx.beginPath();
      ctx.arc(c.x, c.y, c.r * 0.5, 0, Math.PI * 2);
      ctx.fill();
      ctx.strokeStyle = 'rgba(15, 12, 10, 0.7)';
      ctx.lineWidth = 0.5;
      ctx.beginPath();
      for (let i = 0; i < 6; i++) {
        const a = c.seed + i * 1.05;
        ctx.moveTo(c.x + Math.cos(a) * c.r * 0.5, c.y + Math.sin(a) * c.r * 0.5);
        ctx.lineTo(c.x + Math.cos(a + 0.2) * c.r * 1.4, c.y + Math.sin(a + 0.2) * c.r * 1.4);
      }
      ctx.stroke();
    }
    for (const f of this.fires) {
      ctx.fillStyle = 'rgba(20, 16, 14, 0.5)';
      ctx.beginPath();
      ctx.arc(f.x, f.y, f.r * 1.2, 0, Math.PI * 2);
      ctx.fill();
    }
    for (const d of this.decals) {
      if (d.x < view.x - 30 || d.x > view.x + view.w + 30 || d.y < view.y - 30 || d.y > view.y + view.h + 30) continue;
      ctx.strokeStyle = 'rgba(25, 22, 20, 0.75)';
      ctx.lineWidth = 0.6;
      ctx.beginPath();
      for (const r of d.rays) {
        const mx = d.x + Math.cos(r.a) * d.size * r.l * 0.6;
        const my = d.y + Math.sin(r.a) * d.size * r.l * 0.6;
        ctx.moveTo(d.x, d.y);
        ctx.lineTo(mx + Math.sin(r.a) * 1.2, my - Math.cos(r.a) * 1.2);
        ctx.lineTo(d.x + Math.cos(r.a) * d.size * r.l * (d.hole ? 1.6 : 1.1), d.y + Math.sin(r.a) * d.size * r.l * (d.hole ? 1.6 : 1.1));
      }
      ctx.stroke();
      if (d.hole) {
        ctx.fillStyle = '#1c1a19';
        ctx.beginPath();
        d.rays.forEach((r, i) => {
          const rr = d.size * (0.55 + r.l * 0.3);
          const px = d.x + Math.cos(d.angle + (i / d.rays.length) * Math.PI * 2) * rr;
          const py = d.y + Math.sin(d.angle + (i / d.rays.length) * Math.PI * 2) * rr;
          if (i === 0) ctx.moveTo(px, py);
          else ctx.lineTo(px, py);
        });
        ctx.closePath();
        ctx.fill();
        ctx.strokeStyle = '#8a4a2a';
        ctx.lineWidth = 0.35;
        ctx.beginPath();
        for (let i = 0; i < 4; i++) {
          const a = d.angle + i * 0.8;
          ctx.moveTo(d.x - Math.cos(a) * d.size * 0.6, d.y - Math.sin(a) * d.size * 0.6);
          ctx.lineTo(d.x + Math.cos(a) * d.size * 0.6, d.y + Math.sin(a) * d.size * 0.6);
        }
        ctx.stroke();
      }
    }
    for (const sp of this.splats) {
      if (sp.x < view.x - 20 || sp.x > view.x + view.w + 20 || sp.y < view.y - 20 || sp.y > view.y + view.h + 20) continue;
      const age = Math.min(1, (this.time - sp.born) / 20);
      ctx.fillStyle = `rgb(${Math.round(170 - age * 70)}, ${Math.round(14 - age * 6)}, ${Math.round(20 - age * 8)})`;
      ctx.beginPath();
      for (const b of sp.blobs) {
        ctx.moveTo(sp.x + b.dx + b.r, sp.y + b.dy);
        ctx.arc(sp.x + b.dx, sp.y + b.dy, b.r, 0, Math.PI * 2);
      }
      ctx.fill();
    }
    for (const p of this.parts) {
      if (p.z > 0.5 || p.x < view.x - 10 || p.x > view.x + view.w + 10 || p.y < view.y - 10 || p.y > view.y + view.h + 10) continue;
      this.drawPart(ctx, p, 1);
    }
  }

  /** Airborne parts, smoke, fire, sparks and shockwaves (above vehicles). */
  drawAir(ctx: CanvasRenderingContext2D, view: View): void {
    const visible = (x: number, y: number) => x > view.x - 40 && x < view.x + view.w + 40 && y > view.y - 40 && y < view.y + view.h + 40;
    for (const p of this.parts) {
      if (p.z <= 0.5 || !visible(p.x, p.y)) continue;
      ctx.fillStyle = 'rgba(0, 0, 0, 0.22)';
      ctx.beginPath();
      ctx.ellipse(p.x + p.z * 0.3, p.y + p.z * 0.45, p.w / 2, p.h / 2 + 0.3, p.a, 0, Math.PI * 2);
      ctx.fill();
      this.drawPart(ctx, p, 1 + p.z / 50);
    }
    // Puffs are soft, glowing balls (cached radial sprites) rather than flat discs.
    // Flame and flashes add light; smoke, dust and steam are drawn over the top.
    for (const p of this.puffs) {
      if ((p.delay && p.delay > 0) || !visible(p.x, p.y)) continue;
      const t = p.life / p.max;
      let r = 0;
      let g = 0;
      let b = 0;
      let a = 0;
      let glow = false;
      if (p.kind === 'flash') {
        [r, g, b, a, glow] = [255, 250, 230, t, true];
      } else if (p.kind === 'blast') {
        // A ball of flame cooling as it ages: white-hot, yellow, orange, deep red, then soot.
        const age = 1 - t;
        if (age < 0.1) [r, g, b, a, glow] = [255, 240, 200, 0.85, true];
        else if (age < 0.38) [r, g, b, a, glow] = [255, Math.round(200 - (age - 0.1) * 420), Math.round(70 - (age - 0.1) * 200), 0.75, true];
        else if (age < 0.6) [r, g, b, a] = [Math.round(220 - (age - 0.38) * 450), Math.round(80 - (age - 0.38) * 220), 22, 0.75];
        else [r, g, b, a] = [34, 28, 26, (0.85 * (1 - age)) / 0.4];
      } else if (p.kind === 'fire') {
        [r, g, b, a, glow] = [255, Math.round(110 + 110 * t), 40, 0.75 * t, true];
      } else if (p.kind === 'tyre') {
        [r, g, b, a] = [235, 235, 238, 0.4 * t];
      } else if (p.kind === 'steam') {
        [r, g, b, a] = [250, 252, 255, 0.55 * t];
      } else if (p.kind === 'water') {
        [r, g, b, a] = [175, 215, 255, 0.75 * t];
      } else if (p.kind === 'dust') {
        const c = p.rgb ?? [176, 164, 144];
        [r, g, b, a] = [c[0], c[1], c[2], 0.6 * Math.min(1, t * 1.4)];
      } else {
        [r, g, b, a] = [p.tone, p.tone, p.tone + 6, 0.7 * Math.min(1, t * 1.6)];
      }
      if (a <= 0.01) continue;
      ctx.globalCompositeOperation = glow ? 'lighter' : 'source-over';
      ctx.globalAlpha = Math.min(1, a);
      const size = p.r * 2.6;
      ctx.drawImage(this.soft(r, g, b, glow), p.x - size / 2, p.y - size / 2, size, size);
    }
    ctx.globalAlpha = 1;
    ctx.globalCompositeOperation = 'source-over';
    ctx.fillStyle = '#b5121b';
    for (const d of this.drops) {
      ctx.beginPath();
      ctx.arc(d.x, d.y, 0.7, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalCompositeOperation = 'lighter';
    ctx.lineCap = 'round';
    for (const s of this.sparks) {
      const t = s.life / s.max;
      ctx.strokeStyle = `rgba(255, ${Math.round(190 + 65 * t)}, ${Math.round(80 + 150 * t)}, ${Math.min(1, t * 1.6)})`;
      ctx.lineWidth = 0.9;
      ctx.beginPath();
      ctx.moveTo(s.x, s.y);
      ctx.lineTo(s.x - s.vx * 0.035, s.y - s.vy * 0.035);
      ctx.stroke();
    }
    for (const r of this.rings) {
      const t = r.life / r.max;
      if (r.vapor) {
        ctx.strokeStyle = `rgba(250, 252, 255, ${0.45 * t})`;
        ctx.lineWidth = r.width * (0.4 + t);
        ctx.beginPath();
        ctx.arc(r.x, r.y, r.r, 0, Math.PI * 2);
        ctx.stroke();
        ctx.fillStyle = `rgba(255, 255, 255, ${0.16 * t})`;
        ctx.fill();
        continue;
      }
      ctx.strokeStyle = `rgba(255, 245, 210, ${0.5 * t})`;
      ctx.lineWidth = r.width * t + 0.4;
      ctx.beginPath();
      ctx.arc(r.x, r.y, r.r, 0, Math.PI * 2);
      ctx.stroke();
      ctx.fillStyle = `rgba(255, 240, 200, ${0.07 * t})`;
      ctx.fill();
    }
    ctx.lineCap = 'butt';
    ctx.globalCompositeOperation = 'source-over';
  }

  private drawPart(ctx: CanvasRenderingContext2D, p: Part, scale: number): void {
    ctx.save();
    ctx.translate(p.x, p.y);
    ctx.rotate(p.a);
    ctx.scale(scale, scale);
    const hw = p.w / 2;
    const hh = p.h / 2;
    switch (p.kind) {
      case 'wheel':
        ctx.fillStyle = '#141416';
        ctx.beginPath();
        ctx.roundRect(-hw, -hh, p.w, p.h, 0.7);
        ctx.fill();
        ctx.fillStyle = '#9a9ca2';
        ctx.fillRect(-0.5, -hh - 0.2, 1, p.h + 0.4);
        break;
      case 'hubcap':
        ctx.fillStyle = p.color;
        ctx.beginPath();
        ctx.arc(0, 0, hw, 0, Math.PI * 2);
        ctx.fill();
        ctx.fillStyle = '#7a7f86';
        ctx.beginPath();
        ctx.arc(0, 0, hw * 0.4, 0, Math.PI * 2);
        ctx.fill();
        break;
      case 'door':
        ctx.fillStyle = p.color;
        ctx.fillRect(-hw, -hh, p.w, p.h);
        ctx.fillStyle = '#1d2a3c';
        ctx.fillRect(-hw + 0.8, -hh + 0.6, p.w * 0.6, p.h * 0.35);
        ctx.strokeStyle = 'rgba(0, 0, 0, 0.4)';
        ctx.lineWidth = 0.3;
        ctx.strokeRect(-hw, -hh, p.w, p.h);
        break;
      case 'panel':
        ctx.fillStyle = p.color;
        ctx.beginPath();
        ctx.moveTo(-hw, -hh);
        ctx.lineTo(hw * 0.6, -hh * 1.1);
        ctx.lineTo(hw, hh * 0.2);
        ctx.lineTo(hw * 0.3, hh);
        ctx.lineTo(-hw * 0.8, hh * 0.7);
        ctx.closePath();
        ctx.fill();
        ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
        ctx.lineWidth = 0.3;
        ctx.stroke();
        break;
      default:
        ctx.fillStyle = p.burning > 0 ? '#ff9a3a' : p.color;
        if (p.shape === 'round') {
          ctx.beginPath();
          ctx.ellipse(0, 0, hw, hh, 0, 0, Math.PI * 2);
          ctx.fill();
        } else if (p.shape === 'tri') {
          ctx.beginPath();
          ctx.moveTo(hw, 0);
          ctx.lineTo(-hw, -hh);
          ctx.lineTo(-hw, hh);
          ctx.closePath();
          ctx.fill();
        } else {
          ctx.fillRect(-hw, -hh, p.w, p.h);
        }
    }
    ctx.restore();
  }
}