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/vehicles.ts
// Smooth vector vehicles. Local space faces +x; (0, 0) is the vehicle centre.

export type CarModel = 'sedan' | 'taxi' | 'sports' | 'suv' | 'beater' | 'compact' | 'pickup' | 'van' | 'bus' | 'motorcycle' | 'police' | 'limo' | 'icecream' | 'tow' | 'ambulance' | 'firetruck' | 'garbage' | 'sweeper' | 'works' | 'dump' | 'roadside';

export interface CarLook {
  model: CarModel;
  color: string;
  accent: string;
  rude: boolean;
  length: number;
  width: number;
  seed: number;
}

export interface Dent {
  x: number;
  y: number;
  r: number;
}

export interface TruckPose {
  x: number;
  y: number;
  angle: number;
  trailer: { x: number; y: number };
  trailerAngle: number;
  braking: boolean;
  cabDents: Dent[];
  trailerDents: Dent[];
  steer?: number;
  roll?: number;
}

/** Visible crash damage on an AI car. */
export interface CarDamage {
  /** Crush depth at the front, rear, left and right (px). */
  crumple: [number, number, number, number];
  /** Front-left, front-right, rear-left, rear-right wheels still attached. */
  wheels: [boolean, boolean, boolean, boolean];
  /** Lying on its roof. */
  flipped: boolean;
  /** Height above the road while airborne, and tumble angle. */
  z: number;
  roll: number;
  hazard: boolean;
  /** 0..1, how burnt-out the shell is. */
  burnt: number;
  glass: boolean;
  /** Motorcycles: the rider has been thrown off. */
  riderGone?: boolean;
}

export const COLORS = {
  ink: '#15141b',
  glass: '#1d2a3c',
  glint: 'rgba(170, 205, 255, 0.55)',
  headlight: '#fff2b0',
  taillight: '#ff3b3b',
  cab: '#d8402f',
  trailer: '#efeae0',
  chrome: '#d4d8dd',
};

const DIMS: Record<CarModel, [number, number]> = {
  sedan: [16, 8.6],
  taxi: [16, 8.6],
  sports: [17, 8.4],
  suv: [17, 9.4],
  beater: [15.5, 8.6],
  compact: [13.5, 8],
  pickup: [18, 9],
  van: [19, 9.6],
  bus: [34, 11],
  motorcycle: [7.5, 3.4],
  police: [16.5, 8.8],
  limo: [27, 9],
  icecream: [18.5, 10],
  tow: [21, 10],
  ambulance: [20, 10],
  firetruck: [30, 11],
  garbage: [24, 10.5],
  sweeper: [15, 9],
  works: [18, 9.2],
  dump: [24, 10.5],
  roadside: [17, 9],
};

export function makeLook(model: CarModel, color: string, accent: string, rude: boolean): CarLook {
  const [length, width] = DIMS[model];
  return { model, color, accent, rude, length, width, seed: Math.random() };
}

/** Lighten (amount > 0) or darken (amount < 0) a #rrggbb colour. */
export function shade(hex: string, amount: number): string {
  const n = parseInt(hex.slice(1), 16);
  const channel = (v: number) => {
    const out = amount >= 0 ? v + (255 - v) * amount : v * (1 + amount);
    return Math.max(0, Math.min(255, Math.round(out)));
  };
  const r = channel((n >> 16) & 255);
  const g = channel((n >> 8) & 255);
  const b = channel(n & 255);
  return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}

function poly(ctx: CanvasRenderingContext2D, pts: number[], fill: string): void {
  ctx.beginPath();
  ctx.moveTo(pts[0], pts[1]);
  for (let i = 2; i < pts.length; i += 2) ctx.lineTo(pts[i], pts[i + 1]);
  ctx.closePath();
  ctx.fillStyle = fill;
  ctx.fill();
}

function rrect(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  w: number,
  h: number,
  r: number,
  fill: string | CanvasGradient,
): void {
  ctx.beginPath();
  ctx.roundRect(x, y, w, h, r);
  ctx.fillStyle = fill;
  ctx.fill();
}

function bodyGloss(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
  const g = ctx.createLinearGradient(0, y, 0, y + h);
  g.addColorStop(0, 'rgba(255, 255, 255, 0.22)');
  g.addColorStop(0.45, 'rgba(255, 255, 255, 0)');
  g.addColorStop(1, 'rgba(0, 0, 0, 0.25)');
  rrect(ctx, x, y, w, h, r, g);
}

function drawDents(ctx: CanvasRenderingContext2D, dents: Dent[]): void {
  for (const d of dents) {
    ctx.fillStyle = 'rgba(40, 32, 28, 0.35)';
    ctx.beginPath();
    ctx.ellipse(d.x, d.y, d.r, d.r * 0.7, d.x, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)';
    ctx.lineWidth = 0.35;
    ctx.beginPath();
    ctx.moveTo(d.x - d.r, d.y - d.r * 0.3);
    ctx.lineTo(d.x + d.r * 0.8, d.y + d.r * 0.4);
    ctx.stroke();
  }
}

export function drawCar(
  ctx: CanvasRenderingContext2D,
  look: CarLook,
  x: number,
  y: number,
  angle: number,
  wrecked = false,
  braking = false,
  dmg?: CarDamage,
): void {
  const L = look.length;
  const W = look.width;
  const hl = L / 2;
  const hw = W / 2;
  const radius = look.model === 'sports' ? 3.4 : look.model === 'suv' ? 2 : 2.6;
  const z = dmg?.z ?? 0;
  const roll = dmg?.roll ?? 0;
  const lift = 1 + z / 45;
  const squash = Math.max(0.15, Math.abs(Math.cos(roll)));
  const underside = (dmg?.flipped ?? false) !== Math.cos(roll) < 0;

  // Ground shadow (offset in world space), further away the higher the car flies.
  ctx.save();
  ctx.translate(x + 1.3 + z * 0.35, y + 1.9 + z * 0.5);
  ctx.rotate(angle);
  rrect(ctx, -hl, -hw, L, W, radius, `rgba(0, 0, 0, ${0.3 / (1 + z / 40)})`);
  ctx.restore();

  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(angle);
  ctx.scale(lift, lift * squash);
  if (Math.abs(Math.cos(roll)) < 0.5) {
    ctx.scale(1, 1 / squash);
    drawSide(ctx, look, dmg!, Math.sin(roll) > 0 ? 1 : -1);
    ctx.restore();
    return;
  }
  if (underside) {
    drawUnderside(ctx, look, dmg!);
    ctx.restore();
    return;
  }
  const color = wrecked ? shade(look.color, -0.3) : look.color;
  if (look.model === 'motorcycle') {
    drawBike(ctx, look, color, !(dmg?.riderGone ?? false), braking && !wrecked);
    if (dmg?.burnt) {
      ctx.globalAlpha = Math.min(0.8, dmg.burnt);
      rrect(ctx, -hl, -hw, L, W, 1, '#1c1a19');
      ctx.globalAlpha = 1;
    }
    ctx.restore();
    return;
  }

  // Tyres peek out from under the body.
  const wheels = dmg?.wheels ?? [true, true, true, true];
  ctx.fillStyle = '#141416';
  const wx = [0.3 * L, 0.3 * L, -0.3 * L, -0.3 * L];
  const wy = [-hw + 0.1, hw - 1.5, -hw + 0.1, hw - 1.5];
  for (let i = 0; i < 4; i++) {
    if (wheels[i]) ctx.fillRect(wx[i] - 1.8, wy[i] - 0.3, 3.6, 1.7);
  }

  // Mirrors.
  ctx.fillStyle = COLORS.ink;
  ctx.fillRect(0.12 * L, -hw - 0.7, 1.1, 0.8);
  ctx.fillRect(0.12 * L, hw - 0.1, 1.1, 0.8);

  rrect(ctx, -hl, -hw, L, W, radius, color);
  bodyGloss(ctx, -hl, -hw, L, W, radius);
  ctx.strokeStyle = 'rgba(0, 0, 0, 0.45)';
  ctx.lineWidth = 0.5;
  ctx.beginPath();
  ctx.roundRect(-hl, -hw, L, W, radius);
  ctx.stroke();

  if (look.model === 'beater') {
    // Mismatched primer door and rust.
    rrect(ctx, -0.12 * L, -hw + 0.2, 0.26 * L, 2.4, 0.6, look.accent);
    ctx.fillStyle = 'rgba(120, 62, 30, 0.75)';
    ctx.beginPath();
    ctx.arc(-hl + 2.5 + look.seed * 3, hw - 1.8, 1.1, 0, Math.PI * 2);
    ctx.arc(hl - 3 - look.seed * 2, -hw + 1.5, 0.8, 0, Math.PI * 2);
    ctx.fill();
  }

  paintTop(ctx, look, color, dmg?.hazard ?? false);
  // Windscreen glint.
  ctx.strokeStyle = COLORS.glint;
  ctx.lineWidth = 0.45;
  ctx.beginPath();
  ctx.moveTo(0.2 * L, -hw + 2.2);
  ctx.lineTo(0.24 * L, -hw + 3.6);
  ctx.stroke();

  if (look.rude) {
    ctx.fillStyle = '#ffffff';
    ctx.fillRect(-hl + 0.6, -1.15, L - 1.2, 0.7);
    ctx.fillRect(-hl + 0.6, 0.45, L - 1.2, 0.7);
    rrect(ctx, -hl - 0.8, -hw + 0.3, 1.6, W - 0.6, 0.5, COLORS.ink);
  }

  // Lights.
  ctx.fillStyle = COLORS.headlight;
  ctx.fillRect(hl - 1.1, -hw + 0.9, 1, 1.6);
  ctx.fillStyle = look.model === 'beater' && look.seed > 0.5 ? '#3a3a3a' : COLORS.headlight;
  ctx.fillRect(hl - 1.1, hw - 2.5, 1, 1.6);
  ctx.fillStyle = COLORS.taillight;
  ctx.fillRect(-hl + 0.1, -hw + 0.9, 0.9, 1.6);
  ctx.fillRect(-hl + 0.1, hw - 2.5, 0.9, 1.6);
  if (braking && !wrecked) {
    ctx.fillStyle = 'rgba(255, 40, 40, 0.4)';
    ctx.beginPath();
    ctx.arc(-hl, -hw + 1.7, 2.3, 0, Math.PI * 2);
    ctx.arc(-hl, hw - 1.7, 2.3, 0, Math.PI * 2);
    ctx.fill();
  }

  if (dmg) {
    drawDamage(ctx, look, dmg, radius);
  } else if (wrecked) {
    // Parked junk: crumpled hood and cracked glass.
    poly(ctx, [hl, -hw, hl - 3.5, -hw + 1.5, hl - 2, -1, hl - 4, 1, hl - 2.5, hw - 1.4, hl, hw], shade(look.color, -0.65));
    ctx.fillStyle = 'rgba(20, 16, 14, 0.35)';
    ctx.beginPath();
    ctx.ellipse(-1, 0.5, 3.5, 2.4, 0.4, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.restore();
}

/** Roof, glass and model-specific details, in the car's local frame. */
function paintTop(ctx: CanvasRenderingContext2D, look: CarLook, color: string, flash: boolean): void {
  const L = look.length;
  const W = look.width;
  const hl = L / 2;
  const hw = W / 2;
  const glassFront = (a: number, b: number, inset = 1.2) => poly(ctx, [a, -hw + inset, b, -hw + inset + 0.7, b, hw - inset - 0.7, a, hw - inset], COLORS.glass);
  const glassBack = (a: number, b: number) => poly(ctx, [a, -hw + 1.2, b, -hw + 1.8, b, hw - 1.8, a, hw - 1.2], COLORS.glass);
  const roof = (a: number, b: number) => rrect(ctx, a, -hw + 1.2, b - a, W - 2.4, 1.2, shade(color, 0.14));
  switch (look.model) {
    case 'sports':
      poly(ctx, [-0.14 * L, -hw + 1.4, 0.1 * L, -hw + 1.6, 0.1 * L, hw - 1.6, -0.14 * L, hw - 1.4], shade(color, -0.25));
      poly(ctx, [0.1 * L, -hw + 1.4, 0.3 * L, -hw + 2, 0.3 * L, hw - 2, 0.1 * L, hw - 1.4], COLORS.glass);
      poly(ctx, [-0.14 * L, -hw + 1.5, -0.24 * L, -hw + 2.1, -0.24 * L, hw - 2.1, -0.14 * L, hw - 1.5], COLORS.glass);
      ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
      ctx.fillRect(0.34 * L, -1.4, 1.8, 0.5);
      ctx.fillRect(0.34 * L, 0.9, 1.8, 0.5);
      return;
    case 'compact':
      glassFront(0.16 * L, 0.3 * L);
      glassBack(-0.3 * L, -0.38 * L);
      roof(-0.3 * L, 0.16 * L);
      return;
    case 'pickup': {
      glassFront(0.12 * L, 0.24 * L);
      roof(-0.08 * L, 0.12 * L);
      ctx.fillStyle = COLORS.glass;
      ctx.fillRect(-0.1 * L, -hw + 1.6, 0.6, W - 3.2);
      rrect(ctx, -hl + 0.8, -hw + 0.9, 0.38 * L, W - 1.8, 0.6, shade(color, -0.4));
      ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
      ctx.lineWidth = 0.3;
      ctx.beginPath();
      for (let y = -hw + 2; y < hw - 1; y += 1.4) {
        ctx.moveTo(-hl + 1, y);
        ctx.lineTo(-hl + 0.38 * L, y);
      }
      ctx.stroke();
      if (look.seed < 0.4) rrect(ctx, -hl + 1.6, -2.4, 4.2, 4.8, 0.6, '#e2c25a');
      else if (look.seed < 0.75) {
        rrect(ctx, -hl + 1.4, -2.6, 2.8, 2.6, 0.3, '#9a6b3c');
        rrect(ctx, -hl + 1.6, 0.4, 2.8, 2.4, 0.3, '#8a5b2c');
      }
      return;
    }
    case 'van':
      glassFront(0.28 * L, 0.38 * L);
      rrect(ctx, -hl + 0.8, -hw + 1, 0.76 * L, W - 2, 1, shade(color, 0.18));
      ctx.fillStyle = look.accent;
      ctx.fillRect(-hl + 2, -0.6, 0.6 * L, 1.2);
      return;
    case 'bus': {
      rrect(ctx, -hl + 0.8, -hw + 1, L - 2.6, W - 2, 1.2, shade(color, 0.12));
      ctx.fillStyle = COLORS.glass;
      ctx.fillRect(-hl + 1.5, -hw + 0.4, L - 4, 0.9);
      ctx.fillRect(-hl + 1.5, hw - 1.3, L - 4, 0.9);
      glassFront(hl - 2.4, hl - 0.6, 0.8);
      for (const x of [-0.25 * L, 0.05 * L]) rrect(ctx, x, -2, 5, 4, 0.5, '#c9ccd1');
      ctx.fillStyle = '#ffb000';
      ctx.fillRect(hl - 1.2, -1.6, 0.8, 3.2);
      return;
    }
    case 'limo': {
      glassFront(0.3 * L, 0.38 * L);
      glassBack(-0.3 * L, -0.36 * L);
      roof(-0.3 * L, 0.3 * L);
      ctx.fillStyle = COLORS.glass;
      for (let x = -0.24 * L; x < 0.26 * L; x += 4) {
        ctx.fillRect(x, -hw + 1.2, 3, 0.7);
        ctx.fillRect(x, hw - 1.9, 3, 0.7);
      }
      rrect(ctx, -2, -1.3, 4, 2.6, 0.6, COLORS.glass);
      return;
    }
    case 'icecream': {
      glassFront(0.3 * L, 0.4 * L);
      rrect(ctx, -hl + 0.8, -hw + 1, 0.8 * L, W - 2, 1, '#fff6f2');
      ctx.fillStyle = '#ff8fc7';
      for (let x = -hl + 1.5; x < 0.28 * L; x += 3) ctx.fillRect(x, -hw + 1, 1.4, W - 2);
      // The giant cone on the roof.
      poly(ctx, [-3.5, 0, 1.2, -1.9, 1.2, 1.9], '#d9a75b');
      ctx.fillStyle = '#ffd1e6';
      ctx.beginPath();
      ctx.arc(1.8, 0, 2.2, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = '#e8434f';
      ctx.beginPath();
      ctx.arc(2.6, -0.6, 0.6, 0, Math.PI * 2);
      ctx.fill();
      return;
    }
    case 'tow': {
      glassFront(0.22 * L, 0.32 * L);
      roof(0.02 * L, 0.22 * L);
      rrect(ctx, -hl + 0.8, -hw + 1, 0.5 * L, W - 2, 0.6, '#3a3a40');
      ctx.strokeStyle = '#d4d8dd';
      ctx.lineWidth = 0.8;
      ctx.beginPath();
      ctx.moveTo(-0.05 * L, 0);
      ctx.lineTo(-hl - 1.5, 0);
      ctx.stroke();
      ctx.fillStyle = '#9a9ca2';
      ctx.fillRect(-hl - 2.2, -0.8, 1.2, 1.6);
      rrect(ctx, 0.05 * L, -hw + 1.4, 1.4, W - 2.8, 0.4, flash ? '#ffb000' : '#8a6a1a');
      if (flash) {
        ctx.fillStyle = 'rgba(255, 176, 0, 0.35)';
        ctx.beginPath();
        ctx.arc(0.05 * L, 0, 6, 0, Math.PI * 2);
        ctx.fill();
      }
      return;
    }
    case 'ambulance': {
      glassFront(0.32 * L, 0.42 * L);
      rrect(ctx, -hl + 0.8, -hw + 1, 0.74 * L, W - 2, 1, '#fdfdfd');
      ctx.fillStyle = '#d93a3a';
      ctx.fillRect(-hl + 0.8, -0.6, 0.74 * L, 1.2);
      ctx.fillRect(-4, -2.8, 3, 5.6);
      ctx.fillRect(-5.3, -1.5, 5.6, 3);
      rrect(ctx, 0.22 * L, -hw + 1.4, 1.4, W - 2.8, 0.4, flash ? '#ff2d2d' : '#7a1515');
      if (flash) {
        ctx.fillStyle = 'rgba(255, 60, 60, 0.35)';
        ctx.beginPath();
        ctx.arc(0.22 * L, 0, 7, 0, Math.PI * 2);
        ctx.fill();
      }
      return;
    }
    case 'firetruck': {
      glassFront(0.36 * L, 0.44 * L);
      rrect(ctx, -hl + 1, -hw + 1.2, 0.8 * L, W - 2.4, 1, shade(color, -0.1));
      // The ladder down the middle.
      ctx.fillStyle = '#d4d8dd';
      ctx.fillRect(-hl + 2, -1.6, 0.68 * L, 0.5);
      ctx.fillRect(-hl + 2, 1.1, 0.68 * L, 0.5);
      for (let x = -hl + 3; x < -hl + 2 + 0.68 * L; x += 2) ctx.fillRect(x, -1.6, 0.4, 3.2);
      ctx.fillStyle = '#f4f1ea';
      ctx.fillRect(-hl + 1, -hw + 1.2, 0.8 * L, 0.6);
      ctx.fillRect(-hl + 1, hw - 1.8, 0.8 * L, 0.6);
      rrect(ctx, 0.3 * L, -hw + 1.4, 1.4, W - 2.8, 0.4, flash ? '#ff2d2d' : '#7a1515');
      if (flash) {
        ctx.fillStyle = 'rgba(255, 60, 60, 0.35)';
        ctx.beginPath();
        ctx.arc(0.3 * L, 0, 8, 0, Math.PI * 2);
        ctx.fill();
      }
      return;
    }
    case 'garbage': {
      glassFront(0.34 * L, 0.42 * L);
      rrect(ctx, 0.22 * L, -hw + 1, 0.12 * L, W - 2, 0.6, shade(color, 0.12));
      // The compactor body: ribbed, with the hopper at the back.
      rrect(ctx, -hl + 0.6, -hw + 0.8, 0.7 * L, W - 1.6, 1, shade(color, -0.12));
      ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
      ctx.lineWidth = 0.4;
      ctx.beginPath();
      for (let x = -hl + 3; x < 0.18 * L; x += 2.4) {
        ctx.moveTo(x, -hw + 1);
        ctx.lineTo(x, hw - 1);
      }
      ctx.stroke();
      rrect(ctx, -hl - 0.4, -hw + 1.4, 2.6, W - 2.8, 0.5, '#3a3d42');
      ctx.fillStyle = '#f4f1ea';
      ctx.fillRect(-hl + 4, -0.5, 0.4 * L, 1);
      rrect(ctx, 0.2 * L, -hw + 1.6, 1.2, W - 3.2, 0.4, flash ? '#ffb000' : '#8a6a1a');
      return;
    }
    case 'sweeper': {
      glassFront(0.18 * L, 0.36 * L, 1.6);
      roof(-0.04 * L, 0.18 * L);
      rrect(ctx, -hl + 0.8, -hw + 1.2, 0.38 * L, W - 2.4, 0.8, shade(color, -0.15));
      // Rotating brushes at the front corners.
      ctx.fillStyle = '#c9a24a';
      for (const sy of [-1, 1]) {
        ctx.beginPath();
        ctx.arc(hl - 1.5, sy * (hw - 0.6), 2, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.strokeStyle = '#7a5a1a';
      ctx.lineWidth = 0.3;
      ctx.beginPath();
      for (const sy of [-1, 1]) {
        for (let a = 0; a < Math.PI * 2; a += Math.PI / 4) {
          ctx.moveTo(hl - 1.5, sy * (hw - 0.6));
          ctx.lineTo(hl - 1.5 + Math.cos(a + look.seed * 6) * 2, sy * (hw - 0.6) + Math.sin(a + look.seed * 6) * 2);
        }
      }
      ctx.stroke();
      rrect(ctx, -0.02 * L, -hw + 1.6, 1.1, W - 3.2, 0.4, flash ? '#ffb000' : '#8a6a1a');
      return;
    }
    case 'works':
    case 'roadside': {
      glassFront(0.16 * L, 0.28 * L);
      roof(-0.06 * L, 0.16 * L);
      if (look.model === 'works') {
        // Utility bed with tool boxes and a ladder rack.
        rrect(ctx, -hl + 0.8, -hw + 0.9, 0.42 * L, W - 1.8, 0.6, '#d4d8dd');
        rrect(ctx, -hl + 1.4, -hw + 1.2, 0.34 * L, 1.6, 0.3, '#8d9096');
        rrect(ctx, -hl + 1.4, hw - 2.8, 0.34 * L, 1.6, 0.3, '#8d9096');
        ctx.fillStyle = '#c9a24a';
        ctx.fillRect(-hl + 1, -0.4, 0.44 * L, 0.8);
      } else {
        // Roadside assistance van: high roof with a stripe.
        rrect(ctx, -hl + 0.8, -hw + 1, 0.56 * L, W - 2, 0.8, shade(color, 0.16));
        ctx.fillStyle = look.accent;
        ctx.fillRect(-hl + 1.2, -hw + 1, 0.56 * L, 1.1);
        ctx.fillRect(-hl + 1.2, hw - 2.1, 0.56 * L, 1.1);
      }
      rrect(ctx, -0.08 * L, -hw + 1.5, 1.2, W - 3, 0.4, flash ? '#ffb000' : '#8a6a1a');
      if (flash) {
        ctx.fillStyle = 'rgba(255, 176, 0, 0.35)';
        ctx.beginPath();
        ctx.arc(-0.08 * L, 0, 5, 0, Math.PI * 2);
        ctx.fill();
      }
      return;
    }
    case 'dump': {
      glassFront(0.3 * L, 0.4 * L);
      rrect(ctx, 0.2 * L, -hw + 1, 0.12 * L, W - 2, 0.6, shade(color, 0.12));
      // Tipper bed full of asphalt.
      rrect(ctx, -hl + 0.5, -hw + 0.6, 0.66 * L, W - 1.2, 0.6, shade(color, -0.25));
      rrect(ctx, -hl + 1.5, -hw + 1.6, 0.6 * L, W - 3.2, 0.5, '#2b2b2e');
      ctx.fillStyle = 'rgba(255, 255, 255, 0.08)';
      for (let i = 0; i < 6; i++) ctx.fillRect(-hl + 2 + i * 2.2, -hw + 2 + (i % 3) * 1.8, 1.4, 1);
      rrect(ctx, 0.18 * L, -hw + 1.6, 1.2, W - 3.2, 0.4, flash ? '#ffb000' : '#8a6a1a');
      return;
    }
    case 'police': {
      ctx.fillStyle = '#f4f4f4';
      ctx.fillRect(-0.18 * L, -hw + 0.1, 0.36 * L, 1.4);
      ctx.fillRect(-0.18 * L, hw - 1.5, 0.36 * L, 1.4);
      glassFront(0.14 * L, 0.28 * L);
      glassBack(-0.22 * L, -0.3 * L);
      roof(-0.22 * L, 0.14 * L);
      const on = flash;
      rrect(ctx, -1, -hw + 1.4, 2, (W - 2.8) / 2, 0.4, on ? '#ff2d2d' : '#7a1515');
      rrect(ctx, -1, 0, 2, (W - 2.8) / 2, 0.4, on ? '#1e4bff' : '#14256b');
      if (on) {
        ctx.fillStyle = 'rgba(255, 60, 60, 0.35)';
        ctx.beginPath();
        ctx.arc(0, -2, 5, 0, Math.PI * 2);
        ctx.fill();
        ctx.fillStyle = 'rgba(60, 90, 255, 0.35)';
        ctx.beginPath();
        ctx.arc(0, 2, 5, 0, Math.PI * 2);
        ctx.fill();
      }
      return;
    }
    default: {
      const roofStart = look.model === 'suv' ? -0.36 * L : -0.22 * L;
      const roofEnd = 0.14 * L;
      glassFront(roofEnd, 0.28 * L);
      glassBack(roofStart, roofStart - 0.08 * L);
      roof(roofStart, roofEnd);
      if (look.model === 'suv') {
        ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
        ctx.lineWidth = 0.4;
        ctx.beginPath();
        ctx.moveTo(roofStart + 1, -1.6);
        ctx.lineTo(roofEnd - 1, -1.6);
        ctx.moveTo(roofStart + 1, 1.6);
        ctx.lineTo(roofEnd - 1, 1.6);
        ctx.stroke();
      }
      if (look.model === 'taxi') {
        rrect(ctx, -2, -1.6, 3.4, 3.2, 0.6, '#fff7c2');
        ctx.fillStyle = COLORS.ink;
        for (let i = 0; i < 6; i += 2) {
          ctx.fillRect(-hl + 2 + i * 1.6, -hw + 0.1, 1.6, 0.7);
          ctx.fillRect(-hl + 2 + i * 1.6, hw - 0.8, 1.6, 0.7);
        }
      }
    }
  }
}

/** A motorbike and (unless thrown) its rider. */
function drawBike(ctx: CanvasRenderingContext2D, look: CarLook, color: string, rider: boolean, braking: boolean): void {
  rrect(ctx, 1.6, -0.7, 2.6, 1.4, 0.7, '#141416');
  rrect(ctx, -3.9, -0.8, 2.6, 1.6, 0.8, '#141416');
  rrect(ctx, -2.4, -1, 4.6, 2, 1, color);
  ctx.strokeStyle = '#9a9ca2';
  ctx.lineWidth = 0.4;
  ctx.beginPath();
  ctx.moveTo(1.8, -1.8);
  ctx.lineTo(1.8, 1.8);
  ctx.stroke();
  ctx.fillStyle = braking ? '#ff2a2a' : COLORS.taillight;
  ctx.fillRect(-3.9, -0.3, 0.5, 0.6);
  if (!rider) return;
  ctx.fillStyle = look.accent;
  ctx.beginPath();
  ctx.ellipse(-0.6, 0, 1.4, 1.7, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = shade(look.color, 0.3);
  ctx.beginPath();
  ctx.arc(0.3, 0, 1, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = COLORS.glass;
  ctx.fillRect(0.8, -0.5, 0.5, 1);
}

export type AnimalKind = 'deer' | 'moose' | 'bear' | 'fox' | 'raccoon' | 'rabbit' | 'dog' | 'cow';

/** Wild animals seen from above. `pose`: walking, tumbling through the air, or lying dead. */
export function drawAnimal(ctx: CanvasRenderingContext2D, kind: AnimalKind, x: number, y: number, angle: number, stride: number, pose: 'walk' | 'fly' | 'dead', z = 0, spin = 0): void {
  const size: Record<AnimalKind, [number, number, string, string]> = {
    deer: [6.5, 2.6, '#9b6a3e', '#d8b88a'],
    moose: [9.5, 3.8, '#4a3423', '#6d5136'],
    bear: [8, 4.6, '#1d1a18', '#3a332e'],
    fox: [4.6, 1.6, '#d8682a', '#f2f0ea'],
    raccoon: [4, 2.2, '#7d7b78', '#2a2a2a'],
    rabbit: [2.6, 1.6, '#b39a7a', '#f4f0e8'],
    dog: [3.6, 1.7, '#c8a06a', '#8a5a3c'],
    cow: [8.5, 3.8, '#f4f1ea', '#1b1b1f'],
  };
  const [len, wid, body, accent] = size[kind];
  const lift = 1 + z / 30;
  ctx.fillStyle = `rgba(0, 0, 0, ${0.25 / (1 + z / 20)})`;
  ctx.beginPath();
  ctx.ellipse(x + 0.8 + z * 0.35, y + 1 + z * 0.5, len / 2, wid / 2 + 0.3, angle, 0, Math.PI * 2);
  ctx.fill();
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(angle + spin);
  ctx.scale(lift, lift);
  const hl = len / 2;
  const legs = (spread: number) => {
    ctx.strokeStyle = shade(body, -0.3);
    ctx.lineWidth = Math.max(0.5, wid * 0.22);
    ctx.lineCap = 'round';
    ctx.beginPath();
    for (const [lx, side] of [
      [hl * 0.55, -1],
      [hl * 0.55, 1],
      [-hl * 0.55, -1],
      [-hl * 0.55, 1],
    ]) {
      const swing = pose === 'walk' ? Math.sin(stride + lx) * 0.8 : 0;
      ctx.moveTo(lx, (side * wid) / 3);
      ctx.lineTo(lx + swing, side * (wid / 2 + spread));
    }
    ctx.stroke();
    ctx.lineCap = 'butt';
  };
  if (pose === 'dead') {
    legs(wid * 0.9);
  } else {
    legs(0.6);
  }
  ctx.fillStyle = body;
  ctx.beginPath();
  ctx.ellipse(0, 0, hl, wid / 2, 0, 0, Math.PI * 2);
  ctx.fill();
  // Head.
  ctx.beginPath();
  ctx.ellipse(hl + wid * 0.25, 0, wid * 0.45, wid * 0.36, 0, 0, Math.PI * 2);
  ctx.fill();
  if (kind === 'deer' || kind === 'moose') {
    ctx.strokeStyle = kind === 'moose' ? '#cdb58c' : '#e2cfa8';
    ctx.lineWidth = kind === 'moose' ? 0.9 : 0.45;
    ctx.beginPath();
    ctx.moveTo(hl, -wid * 0.2);
    ctx.lineTo(hl - 0.6, -wid * (kind === 'moose' ? 1.1 : 0.8));
    ctx.moveTo(hl, wid * 0.2);
    ctx.lineTo(hl - 0.6, wid * (kind === 'moose' ? 1.1 : 0.8));
    ctx.stroke();
    ctx.fillStyle = accent;
    ctx.beginPath();
    ctx.arc(-hl, 0, wid * 0.25, 0, Math.PI * 2);
    ctx.fill();
  } else if (kind === 'fox') {
    ctx.fillStyle = body;
    ctx.beginPath();
    ctx.ellipse(-hl - 1.4, 0, 1.8, 0.7, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = accent;
    ctx.beginPath();
    ctx.arc(-hl - 2.8, 0, 0.5, 0, Math.PI * 2);
    ctx.fill();
  } else if (kind === 'raccoon') {
    ctx.fillStyle = accent;
    ctx.fillRect(hl, -wid * 0.3, 0.6, wid * 0.6);
    for (let i = 0; i < 3; i++) {
      ctx.fillStyle = i % 2 ? body : accent;
      ctx.fillRect(-hl - 0.8 - i * 0.7, -0.45, 0.7, 0.9);
    }
  } else if (kind === 'cow') {
    ctx.fillStyle = accent;
    for (const [px, py, r] of [
      [-1.5, -0.6, 1.2],
      [1.8, 0.8, 0.9],
      [-3, 0.9, 0.7],
    ]) {
      ctx.beginPath();
      ctx.arc(px, py, r, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.fillStyle = '#f2a6b4';
    ctx.beginPath();
    ctx.arc(hl + wid * 0.55, 0, wid * 0.22, 0, Math.PI * 2);
    ctx.fill();
  } else if (kind === 'dog') {
    ctx.strokeStyle = body;
    ctx.lineWidth = 0.6;
    ctx.beginPath();
    ctx.moveTo(-hl, 0);
    ctx.lineTo(-hl - 1.4, Math.sin(stride * 3) * 0.8);
    ctx.stroke();
  } else if (kind === 'rabbit') {
    ctx.fillStyle = accent;
    ctx.beginPath();
    ctx.arc(-hl, 0, 0.5, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = body;
    ctx.fillRect(hl, -0.6, 1.2, 0.35);
    ctx.fillRect(hl, 0.25, 1.2, 0.35);
  }
  ctx.restore();
}

/** Crush zones, shattered glass, soot and blinking hazards over a car body. */
function drawDamage(ctx: CanvasRenderingContext2D, look: CarLook, dmg: CarDamage, radius: number): void {
  const L = look.length;
  const W = look.width;
  const hl = L / 2;
  const hw = W / 2;
  const [front, rear, left, right] = dmg.crumple;
  const dark = shade(look.color, -0.6);
  const jag = (n: number, seed: number) => ((Math.sin(n * 12.9898 + seed * 78.233) * 43758.5453) % 1 + 1) % 1;
  // Crushed nose and tail: cut the panel back with a jagged, dark edge.
  if (front > 0.3) {
    const pts = [hl + 0.6, -hw - 0.6];
    for (let i = 0; i <= 6; i++) pts.push(hl - front * (0.6 + jag(i, look.seed) * 0.6), -hw + (W * i) / 6);
    pts.push(hl + 0.6, hw + 0.6);
    poly(ctx, pts, dark);
    ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
    ctx.fillRect(hl - front * 1.4, -hw + 0.5, front * 0.6, W - 1);
  }
  if (rear > 0.3) {
    const pts = [-hl - 0.6, -hw - 0.6];
    for (let i = 0; i <= 6; i++) pts.push(-hl + rear * (0.6 + jag(i + 9, look.seed) * 0.6), -hw + (W * i) / 6);
    pts.push(-hl - 0.6, hw + 0.6);
    poly(ctx, pts, dark);
  }
  for (const [depth, sign] of [
    [left, -1],
    [right, 1],
  ] as const) {
    if (depth < 0.3) continue;
    const c = (jag(sign + 3, look.seed) - 0.5) * L * 0.5;
    const span = L * 0.22 + depth;
    poly(ctx, [c - span, sign * (hw + 0.5), c - span * 0.3, sign * (hw - depth), c + span * 0.4, sign * (hw - depth * 0.8), c + span, sign * (hw + 0.5)], dark);
  }
  if (dmg.glass) {
    ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
    ctx.lineWidth = 0.3;
    ctx.beginPath();
    ctx.moveTo(0.22 * L, -1.5);
    ctx.lineTo(0.17 * L, 0.3);
    ctx.lineTo(0.25 * L, 1.8);
    ctx.moveTo(0.17 * L, 0.3);
    ctx.lineTo(0.27 * L, -0.4);
    ctx.moveTo(-0.26 * L, -1.4);
    ctx.lineTo(-0.22 * L, 1.2);
    ctx.stroke();
  }
  if (dmg.burnt > 0) {
    ctx.globalAlpha = Math.min(0.85, dmg.burnt);
    rrect(ctx, -hl, -hw, L, W, radius, '#1c1a19');
    ctx.globalAlpha = 1;
  }
  if (dmg.hazard) {
    ctx.fillStyle = 'rgba(255, 170, 30, 0.95)';
    for (const [x, y] of [
      [hl - 1, -hw + 0.6],
      [hl - 1, hw - 0.6],
      [-hl + 1, -hw + 0.6],
      [-hl + 1, hw - 0.6],
    ]) {
      ctx.beginPath();
      ctx.arc(x, y, 1.6, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.fillStyle = 'rgba(255, 170, 30, 0.25)';
    ctx.beginPath();
    ctx.arc(0, 0, hl + 2, 0, Math.PI * 2);
    ctx.fill();
  }
}

/** A car lying on its side: we see one side panel, its windows and the wheels sticking out. */
function drawSide(ctx: CanvasRenderingContext2D, look: CarLook, dmg: CarDamage, side: number): void {
  const L = look.length;
  const hl = L / 2;
  const H = look.model === 'suv' ? 7.4 : look.model === 'sports' ? 5.2 : 6.2;
  const hh = H / 2;
  rrect(ctx, -hl, -hh, L, H, 1.6, shade(look.color, -0.1));
  bodyGloss(ctx, -hl, -hh, L, H, 1.6);
  // Windows along the cabin.
  ctx.fillStyle = COLORS.glass;
  ctx.fillRect(-0.25 * L, side > 0 ? -hh + 0.6 : hh - 2.4, 0.22 * L, 1.8);
  ctx.fillRect(0.02 * L, side > 0 ? -hh + 0.6 : hh - 2.4, 0.2 * L, 1.8);
  ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
  ctx.lineWidth = 0.4;
  ctx.beginPath();
  ctx.moveTo(0, -hh);
  ctx.lineTo(0, hh);
  ctx.stroke();
  // Wheels stick out of the bottom edge.
  const wy = hh * side * -1;
  for (const [i, wx] of [
    [0, 0.3 * L],
    [2, -0.3 * L],
  ] as const) {
    if (!dmg.wheels[i]) continue;
    rrect(ctx, wx - 2.2, wy - 1.3, 4.4, 2.6, 1, '#121214');
  }
  if (dmg.burnt > 0) {
    ctx.globalAlpha = Math.min(0.8, dmg.burnt);
    rrect(ctx, -hl, -hh, L, H, 1.6, '#1c1a19');
    ctx.globalAlpha = 1;
  }
}

/** A car on its roof: chassis rails, axles, exhaust and whatever wheels are left. */
function drawUnderside(ctx: CanvasRenderingContext2D, look: CarLook, dmg: CarDamage): void {
  const L = look.length;
  const W = look.width;
  const hl = L / 2;
  const hw = W / 2;
  rrect(ctx, -hl, -hw, L, W, 2.4, shade(look.color, -0.45));
  rrect(ctx, -hl + 1, -hw + 1, L - 2, W - 2, 1.6, '#2b2b2f');
  ctx.strokeStyle = '#4a4a50';
  ctx.lineWidth = 0.7;
  ctx.beginPath();
  ctx.moveTo(-hl + 2, -1.6);
  ctx.lineTo(hl - 2, -1.6);
  ctx.moveTo(-hl + 2, 1.6);
  ctx.lineTo(hl - 2, 1.6);
  ctx.moveTo(0.3 * L, -hw + 1);
  ctx.lineTo(0.3 * L, hw - 1);
  ctx.moveTo(-0.3 * L, -hw + 1);
  ctx.lineTo(-0.3 * L, hw - 1);
  ctx.stroke();
  ctx.strokeStyle = '#8a8a90';
  ctx.lineWidth = 0.6;
  ctx.beginPath();
  ctx.moveTo(-hl + 1, 2.4);
  ctx.lineTo(0.1 * L, 2.4);
  ctx.stroke();
  const wx = [0.3 * L, 0.3 * L, -0.3 * L, -0.3 * L];
  const wy = [-hw + 1.4, hw - 1.4, -hw + 1.4, hw - 1.4];
  for (let i = 0; i < 4; i++) {
    if (!dmg.wheels[i]) continue;
    rrect(ctx, wx[i] - 2, wy[i] - 1.1, 4, 2.2, 0.8, '#121214');
    ctx.fillStyle = '#9a9ca2';
    ctx.beginPath();
    ctx.arc(wx[i], wy[i], 0.6, 0, Math.PI * 2);
    ctx.fill();
  }
  if (dmg.burnt > 0) {
    ctx.globalAlpha = Math.min(0.8, dmg.burnt);
    rrect(ctx, -hl, -hw, L, W, 2.4, '#1c1a19');
    ctx.globalAlpha = 1;
  }
}

export interface PedLook {
  shirt: string;
  pants: string;
  skin: string;
  hair: string;
  bag?: string;
  hat?: string;
}

/** A pedestrian seen from above. `pose`: walking, lying knocked out, or tumbling through the air. */
export function drawPed(
  ctx: CanvasRenderingContext2D,
  look: PedLook,
  x: number,
  y: number,
  angle: number,
  stride: number,
  pose: 'walk' | 'down' | 'fly',
  z = 0,
  spin = 0,
  time = 0,
): void {
  const lift = 1 + z / 30;
  ctx.fillStyle = `rgba(0, 0, 0, ${0.28 / (1 + z / 20)})`;
  ctx.beginPath();
  ctx.ellipse(x + 0.6 + z * 0.35, y + 0.9 + z * 0.5, pose === 'down' ? 4.2 : 2.4, pose === 'down' ? 1.8 : 1.8, angle, 0, Math.PI * 2);
  ctx.fill();
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(angle + spin);
  ctx.scale(lift, lift);
  if (pose === 'walk') {
    const s = Math.sin(stride) * 1.4;
    ctx.fillStyle = look.pants;
    ctx.beginPath();
    ctx.ellipse(s, -0.8, 1.1, 0.6, 0, 0, Math.PI * 2);
    ctx.ellipse(-s, 0.8, 1.1, 0.6, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = look.skin;
    ctx.beginPath();
    ctx.arc(-s * 0.8, -2.3, 0.55, 0, Math.PI * 2);
    ctx.arc(s * 0.8, 2.3, 0.55, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = look.shirt;
    ctx.beginPath();
    ctx.ellipse(0, 0, 1.3, 2.2, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = look.hair;
    ctx.beginPath();
    ctx.arc(0.2, 0, 1.15, 0, Math.PI * 2);
    ctx.fill();
    if (look.bag) {
      rrect(ctx, -0.6 + s * 0.4, 2.2, 1.6, 1.3, 0.3, look.bag);
    }
    if (look.hat) {
      poly(ctx, [1.4, 0, -0.6, -0.8, -0.6, 0.8], look.hat);
    }
  } else {
    // Sprawled out: limbs flung wide.
    ctx.strokeStyle = look.pants;
    ctx.lineWidth = 1.1;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo(-1, -0.4);
    ctx.lineTo(-3.8, -1.6);
    ctx.moveTo(-1, 0.4);
    ctx.lineTo(-3.6, 1.8);
    ctx.stroke();
    ctx.strokeStyle = look.shirt;
    ctx.beginPath();
    ctx.moveTo(0.8, -0.6);
    ctx.lineTo(2.2, -2.8);
    ctx.moveTo(0.8, 0.6);
    ctx.lineTo(2.6, 2.4);
    ctx.stroke();
    ctx.lineCap = 'butt';
    ctx.fillStyle = look.shirt;
    ctx.beginPath();
    ctx.ellipse(0, 0, 1.9, 1.3, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = look.skin;
    ctx.beginPath();
    ctx.arc(2.6, 0, 1.05, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = look.hair;
    ctx.beginPath();
    ctx.arc(2.9, 0, 0.8, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.restore();
  if (pose === 'down') {
    // Cartoon stars circling a dazed head.
    ctx.fillStyle = '#ffd84a';
    for (let i = 0; i < 3; i++) {
      const a = time * 4 + (i * Math.PI * 2) / 3;
      const sx = x + Math.cos(angle) * 2.6 + Math.cos(a) * 3;
      const sy = y + Math.sin(angle) * 2.6 + Math.sin(a) * 1.6 - 2;
      ctx.beginPath();
      for (let k = 0; k < 10; k++) {
        const r = k % 2 === 0 ? 1.1 : 0.45;
        const t = (k * Math.PI) / 5;
        ctx.lineTo(sx + Math.cos(t) * r, sy + Math.sin(t) * r);
      }
      ctx.closePath();
      ctx.fill();
    }
  }
}

export function drawTruck(ctx: CanvasRenderingContext2D, t: TruckPose): void {
  const roll = t.roll ?? 0;
  if (Math.abs(roll) > 1.2) {
    drawTruckOnSide(ctx, t, Math.sign(roll));
    return;
  }
  if (Math.abs(roll) > 0.02) {
    // Leaning: the body slides toward the outside of the turn and the shadow stretches.
    const lean = Math.sin(roll) * 5;
    const off = (a: number) => ({ x: -Math.sin(a) * lean, y: Math.cos(a) * lean });
    const o1 = off(t.trailerAngle);
    const o2 = off(t.angle);
    t = { ...t, x: t.x + o2.x, y: t.y + o2.y, trailer: { x: t.trailer.x + o1.x, y: t.trailer.y + o1.y } };
  }
  // Shadows first so the cab's shadow doesn't land on the trailer.
  for (const [px, py, a, l, w] of [
    [t.trailer.x, t.trailer.y, t.trailerAngle, 38, 12],
    [t.x, t.y, t.angle, 18, 12],
  ] as const) {
    ctx.save();
    ctx.translate(px + 1.8, py + 2.6);
    ctx.rotate(a);
    rrect(ctx, -l / 2, -w / 2, l, w, 2, 'rgba(0, 0, 0, 0.32)');
    ctx.restore();
  }

  // Trailer.
  ctx.save();
  ctx.translate(t.trailer.x, t.trailer.y);
  ctx.rotate(t.trailerAngle);
  ctx.fillStyle = '#141416';
  for (const tx of [-15.5, -11]) {
    ctx.fillRect(tx - 1.9, -6.9, 3.8, 1.8);
    ctx.fillRect(tx - 1.9, 5.1, 3.8, 1.8);
  }
  rrect(ctx, -19, -6, 38, 12, 1.4, '#55524a');
  rrect(ctx, -18.6, -5.6, 37.2, 11.2, 1.2, COLORS.trailer);
  bodyGloss(ctx, -18.6, -5.6, 37.2, 11.2, 1.2);
  ctx.strokeStyle = 'rgba(0, 0, 0, 0.09)';
  ctx.lineWidth = 0.5;
  ctx.beginPath();
  for (let x = -15; x < 18; x += 3.2) {
    ctx.moveTo(x, -5.4);
    ctx.lineTo(x, 5.4);
  }
  ctx.stroke();
  ctx.fillStyle = COLORS.cab;
  ctx.fillRect(-18.6, -1.3, 37.2, 2.6);
  ctx.fillStyle = '#fff';
  ctx.font = '700 2.1px system-ui, sans-serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText('BIG RIG RUSH', 0, 0.1);
  ctx.fillStyle = '#8a8478';
  ctx.fillRect(-18.6, -5.6, 0.8, 11.2);
  ctx.fillStyle = COLORS.taillight;
  ctx.fillRect(-19.2, -5.4, 0.9, 2);
  ctx.fillRect(-19.2, 3.4, 0.9, 2);
  if (t.braking) {
    ctx.fillStyle = 'rgba(255, 40, 40, 0.45)';
    ctx.beginPath();
    ctx.arc(-19, -4.4, 2.8, 0, Math.PI * 2);
    ctx.arc(-19, 4.4, 2.8, 0, Math.PI * 2);
    ctx.fill();
  }
  drawDents(ctx, t.trailerDents);
  ctx.restore();

  // Cab.
  ctx.save();
  ctx.translate(t.x, t.y);
  ctx.rotate(t.angle);
  ctx.fillStyle = '#141416';
  ctx.fillRect(-6, -7, 4, 1.9);
  ctx.fillRect(-6, 5.1, 4, 1.9);
  for (const side of [-1, 1]) {
    ctx.save();
    ctx.translate(5, side * 6);
    ctx.rotate((t.steer ?? 0) * 0.55);
    ctx.fillRect(-2, -0.95, 4, 1.9);
    ctx.restore();
  }
  rrect(ctx, -9.5, -3.2, 5, 6.4, 1, '#2a2a30');
  ctx.fillStyle = COLORS.ink;
  ctx.fillRect(2.4, -7, 1.2, 1.2);
  ctx.fillRect(2.4, 5.8, 1.2, 1.2);
  rrect(ctx, -6.8, -6, 16, 12, 2.4, COLORS.cab);
  bodyGloss(ctx, -6.8, -6, 16, 12, 2.4);
  rrect(ctx, -6, -4.9, 7.6, 9.8, 1.8, shade(COLORS.cab, 0.16));
  rrect(ctx, -4.6, -3.2, 4.6, 6.4, 1.2, shade(COLORS.cab, -0.12));
  poly(ctx, [1.7, -5, 4.2, -5.4, 4.2, 5.4, 1.7, 5], COLORS.glass);
  ctx.strokeStyle = COLORS.glint;
  ctx.lineWidth = 0.5;
  ctx.beginPath();
  ctx.moveTo(2.4, -4);
  ctx.lineTo(3.6, -1.5);
  ctx.stroke();
  ctx.fillStyle = shade(COLORS.cab, -0.4);
  ctx.fillRect(8.4, -4.6, 0.8, 9.2);
  ctx.fillStyle = COLORS.chrome;
  ctx.fillRect(8.9, -4, 0.5, 8);
  ctx.fillStyle = COLORS.headlight;
  ctx.fillRect(8.6, -5.4, 0.9, 1.8);
  ctx.fillRect(8.6, 3.6, 0.9, 1.8);
  ctx.fillStyle = COLORS.chrome;
  ctx.beginPath();
  ctx.arc(-6.2, -5.4, 0.9, 0, Math.PI * 2);
  ctx.arc(-6.2, 5.4, 0.9, 0, Math.PI * 2);
  ctx.fill();
  drawDents(ctx, t.cabDents);
  ctx.restore();
}

/** The rig lying on its side: the trailer's side wall and the cab's door face the sky. */
function drawTruckOnSide(ctx: CanvasRenderingContext2D, t: TruckPose, side: number): void {
  for (const [px, py, a, l] of [
    [t.trailer.x, t.trailer.y, t.trailerAngle, 38],
    [t.x, t.y, t.angle, 18],
  ] as const) {
    ctx.save();
    ctx.translate(px + 2, py + 3);
    ctx.rotate(a);
    rrect(ctx, -l / 2, -8, l, 16, 2, 'rgba(0, 0, 0, 0.35)');
    ctx.restore();
  }
  ctx.save();
  ctx.translate(t.trailer.x, t.trailer.y);
  ctx.rotate(t.trailerAngle);
  rrect(ctx, -19, -7.5, 38, 15, 1.2, '#55524a');
  rrect(ctx, -18.6, -7.1, 37.2, 14.2, 1, COLORS.trailer);
  bodyGloss(ctx, -18.6, -7.1, 37.2, 14.2, 1);
  ctx.fillStyle = COLORS.cab;
  ctx.fillRect(-18.6, -1.6, 37.2, 3.2);
  ctx.fillStyle = '#fff';
  ctx.font = '800 3px system-ui, sans-serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText('BIG RIG RUSH', 0, 0.2);
  ctx.fillStyle = '#141416';
  for (const x of [-15.5, -11]) rrect(ctx, x - 2.2, side > 0 ? -9.5 : 6.5, 4.4, 3, 1, '#141416');
  drawDents(ctx, t.trailerDents);
  ctx.restore();
  ctx.save();
  ctx.translate(t.x, t.y);
  ctx.rotate(t.angle);
  rrect(ctx, -7.5, -7, 17, 14, 2.4, COLORS.cab);
  bodyGloss(ctx, -7.5, -7, 17, 14, 2.4);
  ctx.fillStyle = COLORS.glass;
  ctx.fillRect(1.5, -4.5, 4, 5);
  ctx.fillStyle = shade(COLORS.cab, -0.3);
  ctx.fillRect(-6.5, -6, 7, 12);
  for (const x of [-4, 5]) rrect(ctx, x - 2.2, side > 0 ? -9 : 6, 4.4, 3, 1, '#141416');
  drawDents(ctx, t.cabDents);
  ctx.restore();
}

/** A helicopter seen from above: shadow on the ground, body lifted by its altitude, spinning rotor. */
export function drawHelicopter(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, z: number, rotor: number, color: string, accent: string, flash: boolean): void {
  const lift = 1 + z / 60;
  ctx.save();
  ctx.translate(x + 2 + z * 0.35, y + 3 + z * 0.5);
  ctx.rotate(angle);
  ctx.fillStyle = `rgba(0, 0, 0, ${0.28 / (1 + z / 60)})`;
  ctx.beginPath();
  ctx.ellipse(1, 0, 6, 3.6, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillRect(-12, -0.7, 8, 1.4);
  ctx.beginPath();
  ctx.arc(1, 0, 10, 0, Math.PI * 2);
  ctx.fillStyle = `rgba(0, 0, 0, ${0.08 / (1 + z / 60)})`;
  ctx.fill();
  ctx.restore();

  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(angle);
  ctx.scale(lift, lift);
  // Skids, tail boom and tail rotor.
  ctx.fillStyle = '#2b2b2e';
  ctx.fillRect(-4, -4.2, 9, 0.7);
  ctx.fillRect(-4, 3.5, 9, 0.7);
  rrect(ctx, -13, -0.8, 9, 1.6, 0.6, shade(color, -0.15));
  ctx.fillStyle = accent;
  ctx.fillRect(-13.5, -2.2, 1.2, 4.4);
  // Cabin with a bubble canopy.
  rrect(ctx, -5, -3.4, 12, 6.8, 3.2, color);
  bodyGloss(ctx, -5, -3.4, 12, 6.8, 3.2);
  ctx.fillStyle = COLORS.glass;
  ctx.beginPath();
  ctx.ellipse(4, 0, 3, 2.8, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = accent;
  ctx.fillRect(-4, -0.6, 6, 1.2);
  if (flash) {
    ctx.fillStyle = 'rgba(255, 60, 60, 0.6)';
    ctx.beginPath();
    ctx.arc(-1, 0, 1.2, 0, Math.PI * 2);
    ctx.fill();
  }
  // Rotor disc and blades.
  ctx.fillStyle = 'rgba(30, 30, 34, 0.12)';
  ctx.beginPath();
  ctx.arc(0.5, 0, 10, 0, Math.PI * 2);
  ctx.fill();
  ctx.strokeStyle = 'rgba(25, 25, 28, 0.75)';
  ctx.lineWidth = 0.8;
  ctx.beginPath();
  for (let i = 0; i < 4; i++) {
    const a = rotor + (i * Math.PI) / 2;
    ctx.moveTo(0.5, 0);
    ctx.lineTo(0.5 + Math.cos(a) * 10, Math.sin(a) * 10);
  }
  ctx.stroke();
  ctx.fillStyle = '#1b1b1f';
  ctx.beginPath();
  ctx.arc(0.5, 0, 0.9, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

/**
 * A bulldozer from above: two tracks (treads crawl with `odometer`), a yellow body with
 * an engine deck and exhaust stack, a caged cab, and the wide blade on push arms.
 */
export function drawDozer(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, odometer: number, dents: Dent[], braking: boolean): void {
  const yellow = '#f2b705';
  ctx.save();
  ctx.translate(x + 1.6, y + 2.2);
  ctx.rotate(angle);
  rrect(ctx, -9, -7.4, 21, 14.8, 1.5, 'rgba(0, 0, 0, 0.3)');
  ctx.restore();

  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(angle);
  // Tracks, with treads that crawl as it moves.
  for (const side of [-1, 1]) {
    const ty = side * 5.2;
    rrect(ctx, -8.5, ty - 1.9, 16, 3.8, 1.6, '#1d1d20');
    ctx.fillStyle = '#4a4a50';
    const shift = ((odometer % 2) + 2) % 2;
    for (let tx = -8 + shift; tx < 7.5; tx += 2) ctx.fillRect(tx, ty - 1.9, 0.7, 3.8);
    ctx.fillStyle = '#2b2b2e';
    for (const wx of [-6, -2, 2, 6]) {
      ctx.beginPath();
      ctx.arc(wx, ty, 0.7, 0, Math.PI * 2);
      ctx.fill();
    }
  }
  // Push arms and the blade.
  ctx.fillStyle = shade(yellow, -0.25);
  ctx.fillRect(4, -5.8, 6.5, 1.2);
  ctx.fillRect(4, 4.6, 6.5, 1.2);
  rrect(ctx, 9.8, -8.6, 2.6, 17.2, 0.6, shade(yellow, -0.1));
  ctx.fillStyle = '#8d9096';
  ctx.fillRect(12.2, -8.6, 0.7, 17.2);
  ctx.fillStyle = 'rgba(0, 0, 0, 0.25)';
  ctx.fillRect(9.8, -8.6, 0.6, 17.2);
  // Body: engine deck in front, cab behind, ripper at the back.
  rrect(ctx, -7, -3.4, 13, 6.8, 1, yellow);
  bodyGloss(ctx, -7, -3.4, 13, 6.8, 1);
  ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
  for (let gx = 1.5; gx < 5.5; gx += 1.1) ctx.fillRect(gx, -2.4, 0.5, 4.8);
  ctx.fillStyle = '#2b2b2e';
  ctx.beginPath();
  ctx.arc(1, -2.2, 0.8, 0, Math.PI * 2);
  ctx.fill();
  rrect(ctx, -6.5, -3, 5.5, 6, 0.8, shade(yellow, 0.12));
  rrect(ctx, -5.8, -2.3, 4.1, 4.6, 0.5, COLORS.glass);
  ctx.strokeStyle = '#2b2b2e';
  ctx.lineWidth = 0.5;
  ctx.strokeRect(-6.5, -3, 5.5, 6);
  ctx.fillStyle = '#5c5f66';
  ctx.fillRect(-9.6, -1.6, 2.6, 0.8);
  ctx.fillRect(-9.6, 0.8, 2.6, 0.8);
  // Lights.
  ctx.fillStyle = COLORS.headlight;
  ctx.fillRect(5.6, -3.2, 0.8, 1);
  ctx.fillRect(5.6, 2.2, 0.8, 1);
  ctx.fillStyle = braking ? '#ff6b6b' : COLORS.taillight;
  ctx.fillRect(-7.1, -3, 0.6, 1);
  ctx.fillRect(-7.1, 2, 0.6, 1);
  drawDents(ctx, dents);
  ctx.restore();
}

/**
 * An A-10 Warthog from above: long straight wings with stores, the twin engine pods
 * on the rear fuselage, the H-tail, and the gun in the nose. `bank` tilts the wings
 * (one foreshortened), `altitude` lifts it off its shadow, `throttle` lights the engines.
 */