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/peds.ts
import type { City, Venue, Walk } from './city.js';
import type { Effects } from './effects.js';
import { clamp, pointAt, type Vec } from './geometry.js';
import { velocityAt, type Body } from './physics.js';
import { inView, type View } from './traffic.js';
import type { Truck } from './truck.js';
import { drawAnimal, drawPed, type PedLook } from './vehicles.js';

type PedState = 'walk' | 'cross' | 'flee' | 'fly' | 'slide' | 'down' | 'mill' | 'follow' | 'hail';
export type GroupKind = 'joggers' | 'dogwalk' | 'tourists' | 'protest' | 'busker' | 'picnic' | 'yoga' | 'barcrawl';
/** What hit someone: a low car nose scoops people up, a flat truck or bus front shoves them down. */
export type Profile = 'low' | 'flat';

const SCREAMS = ['AAH!', 'WATCH IT!', 'MY LIFE FLASHED!', 'EEK!', 'NOT TODAY!', 'RUN!!'];
const HIT_LINES = ['OOF!', 'MY SPINE!', 'OW OW OW', 'I SAW MY ANCESTORS', 'WHO TURNED OFF THE SKY?'];
const ANGRY = ["I'M SUING!", 'WATCH IT, TRUCKER!', 'I HAD THE WALK SIGN!', 'MY LAWYER HAS A BILLBOARD', 'I GOT YOUR PLATE!'];
const MARKET_LINES = ['FRESH CORN!', 'ORGANIC KALE, $9', 'LOCAL HONEY!', 'TRY A SAMPLE!', 'IS THIS GLUTEN FREE?', 'TWO FOR FIVE!'];
const PARTY_LINES = ['WOOOO!', 'PARTY!!', 'TURN IT UP!', 'WHO INVITED THE TRUCK?!', 'CANNONBALL!', 'BEST NIGHT EVER!', 'SHOTS! SHOTS!'];
const RAVE_LINES = ['DROP THE BASS!', 'UNTZ UNTZ UNTZ', 'I CAN SEE SOUND', 'PLUR!!', 'ONE MORE SONG!', 'WHERE ARE MY SHOES?'];
const ERRAND_LINES = ['MILK, EGGS, BREAD...', 'WHERE DID I PARK?', 'LATE FOR YOGA!', 'GOTTA RETURN THIS'];
const GROUP_LINES: Record<GroupKind, string[]> = {
  joggers: ['ON YOUR LEFT!', 'MILE SIX!', 'FEEL THE BURN!', 'PACE! PACE!'],
  dogwalk: ['GOOD BOY!', 'DROP IT!', 'WHO WANTS A WALK?'],
  tourists: ['OOH, A TRUCK!', 'TAKE MY PICTURE!', 'WHERE IS THE MUSEUM?', 'SO MANY POTHOLES!'],
  protest: ['NO MORE TRUCKS!', 'HONK IF YOU AGREE!', 'WHAT DO WE WANT?!', 'BIKE LANES NOW!'],
  busker: ['WONDERWALL!!', 'TIPS APPRECIATED!', 'ANY REQUESTS?', 'ENCORE!'],
  picnic: ['PASS THE CHIPS', 'ANTS! ANTS!', 'PERFECT WEATHER'],
  yoga: ['AND BREATHE...', 'DOWNWARD DOG', 'NAMASTE', 'FIND YOUR CENTRE'],
  barcrawl: ['ONE MORE BAR!', 'I LOVE YOU MAN!', '*HIC*', 'WHICH WAY IS HOME?', 'KARAOKE!!'],
};
const BAGS = ['#d8c3a0', '#f2f2f2', '#c0392b', '#2471a3', '#239b56'];
const HATS = ['#ff4fa3', '#ffd400', '#3fd0ff', '#7dff6a'];
const SHIRTS = ['#d9534f', '#3d7dd8', '#f0ad4e', '#5cb85c', '#8e44ad', '#ecf0f1', '#2c3e50', '#16a085', '#e67e22', '#c0392b'];
const PANTS = ['#2c3e50', '#34495e', '#5d4037', '#1f2a44', '#6d6d6d', '#3e2723'];
const SKIN = ['#f1c27d', '#e0ac69', '#c68642', '#8d5524', '#ffdbac', '#a0673d'];
const HAIR = ['#2b1d14', '#4a3222', '#0f0f0f', '#a0703c', '#d6b370', '#7a7a7a', '#b5482f'];
const PED_RADIUS = 1.6;
const HAIL_LINES = ['TAXI!', 'TAXI! OVER HERE!', 'HEY! CAB!'];
/** Districts where people hail cabs. */
const CAB_TOWNS = new Set(['downtown', 'uptown', 'hood', 'village', 'port']);

/** Which group activities happen at which hours. */
const SCHEDULE: Record<GroupKind, [number, number, number][]> = {
  joggers: [[6, 10, 1], [16, 20, 1]],
  dogwalk: [[6, 21, 1]],
  tourists: [[9, 18, 1]],
  protest: [[10, 17, 0.5]],
  busker: [[10, 22, 0.8]],
  picnic: [[11, 17, 0.8]],
  yoga: [[7, 11, 0.7]],
  barcrawl: [[20, 27, 1.2]],
};

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

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

export class Ped {
  x = 0;
  y = 0;
  angle = 0;
  walk = 0;
  s = 0;
  dir = 1;
  offset = 0;
  speed = 12;
  stride = 0;
  state: PedState = 'walk';
  tx = 0;
  ty = 0;
  onRoad = false;
  timer = 0;
  z = 0;
  vz = 0;
  vx = 0;
  vy = 0;
  spin = 0;
  spinRate = 0;
  limp = 0;
  bubble = '';
  bubbleTime = 0;
  look: PedLook = { shirt: pick(SHIRTS), pants: pick(PANTS), skin: pick(SKIN), hair: pick(HAIR) };
  /** Index of the market, party or rave this person belongs to, or -1. */
  venue = -1;
  /** Index into the active groups, or -1. */
  group = -1;
  /** Home spot for milling about (venues and stationary groups). */
  hx = 0;
  hy = 0;
  hr = 0;
  /** Formation offset behind the group leader (forward, sideways). */
  ox = 0;
  oy = 0;
  chatter = 0;
  crushed = false;
  /** How much more blood this person can lose. Once it's gone they're just lying there: nothing more to run over. */
  blood = 3 + Math.random() * 2.5;
  sign = '';
  dog: { x: number; y: number; angle: number; stride: number; color: string } | null = null;
  /** Workers: their own chatter and working hours. */
  lines: string[] | null = null;
  hours: [number, number] | null = null;
}

interface Group {
  kind: GroupKind;
  leader: Ped;
  members: Ped[];
  x: number;
  y: number;
  angle: number;
  stationary: boolean;
}

export class Pedestrians {
  readonly peds: Ped[] = [];
  /** Market shoppers, partygoers and ravers; they stay at their venue. */
  readonly crowd: Ped[] = [];
  /** People doing things together: joggers, protests, picnics… */
  readonly groupPeds: Ped[] = [];
  /** Farmers, builders and factory hands, tied to their workplace. */
  readonly workers: Ped[] = [];
  private activeWorkers: Ped[] = [];
  private groups: Group[] = [];
  private venues: Venue[] = [];
  private open: boolean[] = [];
  private time = 0;
  hour = 12;
  /** Called when a pedestrian is struck, with the impact speed. */
  onHit: ((ped: Ped, speed: number) => void) | null = null;

  constructor(
    private readonly city: City,
    private readonly effects: Effects,
  ) {}

  clear(): void {
    this.peds.length = 0;
    this.crowd.length = 0;
    this.groupPeds.length = 0;
    this.groups = [];
    this.workers.length = 0;
    this.activeWorkers = [];
  }

  /** Add a worker who potters about within `hr` of (x, y) during their hours. */
  addWorker(x: number, y: number, hr: number, look: Partial<PedLook>, lines: string[], hours: [number, number]): Ped {
    const p = new Ped();
    p.hx = x;
    p.hy = y;
    p.hr = Math.max(4, hr);
    p.look = { ...p.look, ...look };
    p.lines = lines;
    p.hours = hours;
    p.speed = range(5, 9);
    p.chatter = range(2, 20);
    this.resetMill(p);
    this.workers.push(p);
    return p;
  }

  /** Blood, but only so much of it per person. */
  private bleed(p: Ped, x: number, y: number, size: number, vx = 0, vy = 0): void {
    if (p.blood <= 0) return;
    const s = Math.min(size, p.blood);
    p.blood -= s;
    this.effects.splat(x, y, s, vx, vy);
  }

  /** Paramedics patch someone up. */
  revive(p: Ped): void {
    if (p.state !== 'down' && p.state !== 'slide') return;
    p.state = 'down';
    p.timer = 0.01;
    p.crushed = false;
    p.blood = 3 + Math.random() * 2.5;
    p.bubble = pick(['THANK YOU, DOC!', 'I CAN FEEL MY LEGS!', 'BEST. AMBULANCE. EVER.']);
    p.bubbleTime = 2.5;
  }

  /**
   * Paramedics load someone into the ambulance: passers-by are taken off to hospital
   * (gone from the street); venue-goers, groups and workers are treated on the spot.
   */
  evacuate(p: Ped): boolean {
    const i = this.peds.indexOf(p);
    if (i < 0) {
      this.revive(p);
      return false;
    }
    this.peds.splice(i, 1);
    return true;
  }

  /** Injured people near a spot (for the ambulance). */
  injuredNear(x: number, y: number, r: number): Ped[] {
    return this.all().filter((p) => (p.state === 'down' || p.state === 'slide') && Math.hypot(p.x - x, p.y - y) < r);
  }

  /** Everyone lying hurt anywhere in the city (for the ambulance dispatcher). */
  injured(): Ped[] {
    return this.all().filter((p) => p.state === 'down' || p.state === 'slide');
  }

  /** People at the curb waving down a cab. */
  hailing(): Ped[] {
    return this.peds.filter((p) => p.state === 'hail');
  }

  /** They got into a taxi: gone from the street (someone new turns up elsewhere). */
  board(p: Ped): void {
    const i = this.peds.indexOf(p);
    if (i >= 0) this.peds.splice(i, 1);
  }

  /** A taxi lets its fare out at the curb, on the nearest sidewalk. */
  dropOff(x: number, y: number): void {
    let best: { walk: number; s: number; d: number } | null = null;
    for (const w of this.city.walks) {
      for (let i = 0; i < w.pts.length; i++) {
        const q = w.pts[i];
        if (Math.abs(q.x - x) > 40 || Math.abs(q.y - y) > 40) continue;
        const d = Math.hypot(q.x - x, q.y - y);
        if (!best || d < best.d) best = { walk: w.id, s: w.cum[i], d };
      }
    }
    if (!best) return;
    const p = new Ped();
    p.walk = best.walk;
    p.s = best.s;
    p.dir = Math.random() < 0.5 ? 1 : -1;
    p.offset = range(-2, 2);
    p.speed = range(9, 16);
    p.x = x;
    p.y = y;
    p.state = 'cross';
    const pt = pointAt(this.city.walks[p.walk], p.s);
    p.tx = pt.x;
    p.ty = pt.y;
    if (Math.random() < 0.5) p.look.bag = pick(BAGS);
    this.say(p, ['THANKS, DRIVER!', 'WHAT A RIDE', 'HOME SWEET HOME', 'JUST IN TIME!']);
    this.peds.push(p);
  }

  /** Everyone who is out and about right now. */
  all(): Ped[] {
    return [...this.peds, ...this.crowd.filter((p) => this.open[p.venue] || p.state === 'fly' || p.state === 'slide'), ...this.groupPeds, ...this.activeWorkers];
  }

  setupVenues(venues: Venue[]): void {
    this.venues = venues;
    this.crowd.length = 0;
    this.open = venues.map(() => false);
    venues.forEach((v, i) => {
      const count = v.kind === 'market' ? 14 : v.kind === 'rave' ? 30 : 18;
      for (let k = 0; k < count; k++) {
        const p = new Ped();
        p.venue = i;
        p.hx = v.x;
        p.hy = v.y;
        p.hr = v.r * 0.85;
        this.resetMill(p);
        p.speed = range(6, 11);
        p.chatter = range(1, 10);
        if (v.kind === 'market' && Math.random() < 0.6) p.look.bag = pick(BAGS);
        if (v.kind !== 'market' && Math.random() < 0.6) p.look.hat = pick(HATS);
        this.crowd.push(p);
      }
    });
  }

  /** Open or close a venue (markets by day, parties and raves by night). */
  setVenueOpen(index: number, open: boolean): void {
    if (this.open[index] === open) return;
    this.open[index] = open;
    if (open) for (const p of this.crowd) if (p.venue === index && p.state !== 'fly') this.resetMill(p);
  }

  private resetMill(p: Ped): void {
    const a = Math.random() * Math.PI * 2;
    const d = Math.sqrt(Math.random()) * p.hr;
    p.x = p.hx + Math.cos(a) * d;
    p.y = p.hy + Math.sin(a) * d;
    p.tx = p.x;
    p.ty = p.y;
    p.state = 'mill';
    p.z = 0;
  }

  setCount(count: number, truck: Truck, view: View): void {
    while (this.peds.length < count) {
      const ped = new Ped();
      this.peds.push(ped);
      this.place(ped, truck, view, true);
    }
    for (let i = this.peds.length - 1; i >= 0 && this.peds.length > count; i--) {
      const p = this.peds[i];
      if (!inView(p.x, p.y, view, 30) && p.state !== 'fly' && p.state !== 'slide') this.peds.splice(i, 1);
    }
  }

  roadObstacles(): Vec[] {
    return this.all()
      .filter((p) => (p.state === 'cross' && p.onRoad) || ((p.state === 'down' || p.state === 'slide' || p.state === 'follow' || p.state === 'flee' || p.state === 'mill') && this.city.surfaceAt(p.x, p.y) === 1))
      .map((p) => ({ x: p.x, y: p.y }));
  }

  hearHorn(x: number, y: number): void {
    for (const p of this.all()) {
      if (p.state === 'fly' || p.state === 'down' || p.state === 'slide') continue;
      const d = Math.hypot(p.x - x, p.y - y);
      if (d > 120) continue;
      this.flee(p, (p.x - x) / (d || 1), (p.y - y) / (d || 1));
    }
  }

  private place(ped: Ped, truck: Truck, view: View, initial: boolean): void {
    const walk = this.findWalk(truck, view, initial);
    Object.assign(ped, new Ped());
    ped.walk = walk.id;
    ped.s = Math.random() * walk.len;
    ped.dir = Math.random() < 0.5 ? 1 : -1;
    ped.offset = range(-2.5, 2.5);
    ped.speed = range(9, 16);
    if (Math.random() < 0.4) ped.look.bag = pick(BAGS);
    ped.chatter = range(5, 30);
    this.position(ped);
  }

  private findWalk(truck: Truck, view: View, initial: boolean): Walk {
    const walks = this.city.walks;
    for (let attempt = 0; attempt < 80; attempt++) {
      const w = pick(walks);
      const p = pointAt(w, w.len / 2);
      const d = Math.hypot(p.x - truck.x, p.y - truck.y);
      if (initial ? d < 1100 : d > 200 && d < 850 && !inView(p.x, p.y, view, 20)) return w;
    }
    return pick(walks);
  }

  private position(ped: Ped): void {
    const w = this.city.walks[ped.walk];
    const p = pointAt(w, ped.s);
    const q = pointAt(w, ped.s + ped.dir * 2);
    const ang = Math.atan2(q.y - p.y, q.x - p.x);
    ped.x = p.x - Math.sin(ang) * ped.offset;
    ped.y = p.y + Math.cos(ang) * ped.offset;
    ped.angle = ang;
  }

  private say(ped: Ped, lines: string[]): void {
    ped.bubble = pick(lines);
    ped.bubbleTime = 2;
  }

  private flee(ped: Ped, dx: number, dy: number): void {
    if (ped.state === 'flee') return;
    ped.state = 'flee';
    ped.timer = range(0.5, 0.8);
    ped.vx = dx * 75;
    ped.vy = dy * 75;
    if (ped.bubbleTime <= 0) this.say(ped, SCREAMS);
  }

  private returnToWalk(ped: Ped): void {
    if (ped.venue >= 0 || ped.hr > 0 || ped.group >= 0) {
      const g = ped.group >= 0 ? this.groups[ped.group] : null;
      if (g && !g.stationary) {
        ped.state = 'follow';
        return;
      }
      ped.state = 'mill';
      ped.tx = ped.hx + range(-ped.hr, ped.hr) * 0.6;
      ped.ty = ped.hy + range(-ped.hr, ped.hr) * 0.6;
      return;
    }
    const w = this.city.walks[ped.walk];
    const p = pointAt(w, ped.s);
    ped.state = 'cross';
    ped.onRoad = this.city.surfaceAt(ped.x, ped.y) === 1;
    ped.tx = p.x;
    ped.ty = p.y;
  }

  /**
   * Physical hit: find the contact point on the vehicle's box, take the vehicle's
   * velocity at that point, and throw the person along the contact normal. A low
   * car nose scoops them up over the hood; a flat truck front shoves them down and
   * can run them over.
   */
  strike(body: Body, layer: number, profile: Profile = 'low', careful = false): void {
    if (layer !== 0) return;
    const c = Math.cos(body.angle);
    const s = Math.sin(body.angle);
    for (const p of this.all()) {
      if ((p.state === 'fly' && p.z > 3) || p.z > 6) continue;
      // Bled out on the road: vehicles roll over them without another hit.
      if (p.blood <= 0 && !careful) continue;
      const dx = p.x - body.x;
      const dy = p.y - body.y;
      if (Math.abs(dx) > body.halfL + 6 || Math.abs(dy) > body.halfL + 6) continue;
      const lx = dx * c + dy * s;
      const ly = -dx * s + dy * c;
      const qx = clamp(lx, -body.halfL, body.halfL);
      const qy = clamp(ly, -body.halfW, body.halfW);
      let nx = lx - qx;
      let ny = ly - qy;
      let d = Math.hypot(nx, ny);
      const inside = d === 0;
      if (inside) {
        // Already under the vehicle: push out the nearest side.
        const gaps = [body.halfL - lx, body.halfL + lx, body.halfW - ly, body.halfW + ly];
        const m = Math.min(...gaps);
        [nx, ny] = m === gaps[0] ? [1, 0] : m === gaps[1] ? [-1, 0] : m === gaps[2] ? [0, 1] : [0, -1];
        d = 0;
      } else {
        nx /= d;
        ny /= d;
      }
      if (d >= PED_RADIUS) continue;
      // Back to world space.
      const wnx = nx * c - ny * s;
      const wny = nx * s + ny * c;
      if (careful) {
        // Careful drivers (traffic and city crews) never hurt anyone: the person jumps clear.
        if (p.state === 'fly' || p.state === 'slide') continue;
        p.x += wnx * (PED_RADIUS - d + 0.4);
        p.y += wny * (PED_RADIUS - d + 0.4);
        if (p.state !== 'down' && p.bubbleTime <= 0 && Math.hypot(body.vx, body.vy) > 8) this.say(p, SCREAMS);
        continue;
      }
      const cpx = body.x + qx * c - qy * s;
      const cpy = body.y + qx * s + qy * c;
      const v = velocityAt(body, cpx, cpy);
      const rvx = v.x - p.vx;
      const rvy = v.y - p.vy;
      const vn = rvx * wnx + rvy * wny;
      if (vn < 4) {
        if (p.state !== 'fly' && p.state !== 'slide') {
          p.x += wnx * (PED_RADIUS - d);
          p.y += wny * (PED_RADIUS - d);
        }
        continue;
      }
      const restitution = profile === 'flat' ? 0.3 : 0.15;
      const tvx = rvx - wnx * vn;
      const tvy = rvy - wny * vn;
      const wasDown = p.state === 'down' || p.state === 'slide';
      p.vx += wnx * vn * (1 + restitution) + tvx * 0.35;
      p.vy += wny * vn * (1 + restitution) + tvy * 0.35;
      p.vz = Math.max(p.vz, profile === 'low' ? vn * 0.55 : vn * 0.16) + 8;
      p.z = Math.max(p.z, 0.8);
      p.spinRate = (tvx * -wny + tvy * wnx) * 0.12 + range(-6, 6) + (profile === 'low' ? 8 : 0);
      p.state = 'fly';
      p.x += wnx * (PED_RADIUS - d);
      p.y += wny * (PED_RADIUS - d);
      if (profile === 'flat' && (inside || wasDown) && vn > 10) {
        // Run over by the wheels.
        p.crushed = true;
        p.vz = 4;
        this.bleed(p, p.x, p.y, Math.min(3, 1.4 + vn / 50), v.x, v.y);
      }
      this.say(p, HIT_LINES);
      this.bleed(p, cpx, cpy, Math.min(2.4, 0.5 + vn / 60), v.x, v.y);
      this.onHit?.(p, vn);
    }
  }

  /** Throw a new person into the world (a motorcyclist off their bike). */
  launch(x: number, y: number, vx: number, vy: number, shirt: string, helmet: string): void {
    const p = new Ped();
    p.x = x;
    p.y = y;
    p.state = 'fly';
    p.vx = vx;
    p.vy = vy;
    p.vz = 40 + Math.hypot(vx, vy) * 0.5;
    p.z = 1;
    p.spinRate = range(10, 20) * (Math.random() < 0.5 ? -1 : 1);
    p.look = { ...p.look, shirt, hair: helmet };
    let best = 0;
    let bestD = Infinity;
    this.city.walks.forEach((w, i) => {
      const d = Math.hypot(w.pts[0].x - x, w.pts[0].y - y);
      if (d < bestD) {
        bestD = d;
        best = i;
      }
    });
    p.walk = best;
    this.say(p, HIT_LINES);
    this.peds.push(p);
  }

  blast(x: number, y: number, radius: number): void {
    for (const p of this.all()) {
      const dx = p.x - x;
      const dy = p.y - y;
      const d = Math.hypot(dx, dy);
      if (d > radius || d < 0.1) continue;
      const f = (1 - d / radius) * 140;
      p.state = 'fly';
      p.vx = (dx / d) * f;
      p.vy = (dy / d) * f;
      p.vz = 40 + f * 0.5;
      p.z = 1;
      p.spinRate = range(8, 18) * (Math.random() < 0.5 ? -1 : 1);
      this.say(p, HIT_LINES);
    }
  }

  // --- Groups of people doing things.

  private wantedGroups(): GroupKind[] {
    const out: GroupKind[] = [];
    for (const [kind, slots] of Object.entries(SCHEDULE) as [GroupKind, [number, number, number][]][]) {
      for (const [from, to, w] of slots) if (inHours(this.hour, from, to)) for (let i = 0; i < Math.round(w * 2); i++) out.push(kind);
    }
    return out;
  }

  private spawnGroup(kind: GroupKind, truck: Truck, view: View): void {
    const stationary = kind === 'busker' || kind === 'picnic' || kind === 'yoga';
    const walk = this.findWalk(truck, view, false);
    const mk = () => {
      const p = new Ped();
      p.group = this.groups.length;
      p.chatter = range(3, 15);
      return p;
    };
    const leader = mk();
    leader.walk = walk.id;
    leader.s = Math.random() * walk.len;
    leader.dir = Math.random() < 0.5 ? 1 : -1;
    this.position(leader);
    const group: Group = { kind, leader, members: [leader], x: leader.x, y: leader.y, angle: leader.angle, stationary };
    const size = { joggers: 4, dogwalk: 1, tourists: 7, protest: 9, busker: 6, picnic: 4, yoga: 6, barcrawl: 5 }[kind];
    if (stationary) {
      // Set up on the nearest patch of grass or plaza beside the sidewalk.
      const side = Math.random() < 0.5 ? -1 : 1;
      let cx = leader.x;
      let cy = leader.y;
      for (const off of [22, 30, 14]) {
        const tx = leader.x - Math.sin(leader.angle) * off * side;
        const ty = leader.y + Math.cos(leader.angle) * off * side;
        const push = this.city.resolveCircle(tx, ty, 9, 0);
        if (!push.x && !push.y && this.city.surfaceAt(tx, ty) !== 1) {
          cx = tx;
          cy = ty;
          break;
        }
      }
      group.x = cx;
      group.y = cy;
      group.angle = Math.random() * Math.PI * 2;
    }
    for (let i = 1; i < size; i++) group.members.push(mk());
    group.members.forEach((p, i) => {
      if (stationary) {
        p.hx = group.x;
        p.hy = group.y;
        if (kind === 'yoga') {
          const col = (i % 3) - 1;
          const row = Math.floor(i / 3);
          p.x = group.x + Math.cos(group.angle) * row * 7 - Math.sin(group.angle) * col * 6;
          p.y = group.y + Math.sin(group.angle) * row * 7 + Math.cos(group.angle) * col * 6;
          p.angle = group.angle + Math.PI;
          p.look.hat = i === 0 ? '#ffffff' : undefined;
        } else {
          const a = (i / size) * Math.PI * 2;
          const r = i === 0 ? 0 : kind === 'busker' ? 9 : 5;
          p.x = group.x + Math.cos(a) * r;
          p.y = group.y + Math.sin(a) * r;
          p.angle = Math.atan2(group.y - p.y, group.x - p.x);
        }
        p.hr = 0;
        p.state = 'mill';
        p.tx = p.x;
        p.ty = p.y;
      } else if (i > 0) {
        const cols = kind === 'protest' ? 3 : 2;
        p.ox = -(Math.floor((i - 1) / cols) + 1) * (kind === 'joggers' ? 5 : 4.5);
        p.oy = (((i - 1) % cols) - (cols - 1) / 2) * (kind === 'protest' ? 7 : 4.5);
        p.x = leader.x + Math.cos(leader.angle) * p.ox - Math.sin(leader.angle) * p.oy;
        p.y = leader.y + Math.sin(leader.angle) * p.ox + Math.cos(leader.angle) * p.oy;
        p.state = 'follow';
      }
      p.speed = kind === 'joggers' ? range(26, 32) : kind === 'barcrawl' ? 7 : kind === 'protest' ? 7 : range(8, 11);
      if (kind === 'protest') p.sign = pick(['NO TRUCKS', 'SLOW DOWN', 'BIKES!', 'HONK NOW', 'SAVE TREES']);
      if (kind === 'joggers') p.look.hat = pick(['#ff4fa3', '#3fd0ff', '#ffd400']);
      if (kind === 'tourists' && i === 0) p.sign = 'TOUR';
      if (kind === 'barcrawl' && Math.random() < 0.5) p.look.hat = pick(HATS);
    });
    leader.speed = group.members[1]?.speed ?? leader.speed;
    if (kind === 'dogwalk') leader.dog = { x: leader.x, y: leader.y, angle: leader.angle, stride: 0, color: pick(['#c8a06a', '#2b2b2b', '#f2efe6', '#8a5a3c']) };
    this.groups.push(group);
    this.groupPeds.push(...group.members);
  }

  private updateGroups(dt: number, truck: Truck, view: View): void {
    const wanted = this.wantedGroups();
    // Retire groups that are far away or out of season.
    for (let gi = this.groups.length - 1; gi >= 0; gi--) {
      const g = this.groups[gi];
      const far = Math.hypot(g.leader.x - truck.x, g.leader.y - truck.y) > 1000 && !inView(g.leader.x, g.leader.y, view, 40);
      const done = !wanted.includes(g.kind) && !inView(g.x, g.y, view, 40);
      if (far || done) {
        this.groups.splice(gi, 1);
        const gone = new Set(g.members);
        for (let i = this.groupPeds.length - 1; i >= 0; i--) if (gone.has(this.groupPeds[i])) this.groupPeds.splice(i, 1);
        this.groups.forEach((other, k) => other.members.forEach((m) => (m.group = k)));
      }
    }
    if (this.groups.length < 7 && wanted.length && Math.random() < dt * 2) this.spawnGroup(pick(wanted), truck, view);
    for (const g of this.groups) {
      if (g.stationary) continue;
      g.x = g.leader.x;
      g.y = g.leader.y;
      g.angle = g.leader.angle;
      const dog = g.leader.dog;
      if (dog) {
        const tx = g.leader.x - Math.sin(g.leader.angle) * 4 + Math.cos(g.leader.angle) * 5;
        const ty = g.leader.y + Math.cos(g.leader.angle) * 4 + Math.sin(g.leader.angle) * 5;
        const dx = tx - dog.x;
        const dy = ty - dog.y;
        const d = Math.hypot(dx, dy);
        if (d > 0.5) {
          const step = Math.min(d, (g.leader.state === 'down' ? 0 : 1) * 40 * dt + (d > 12 ? d * dt * 3 : 0));
          dog.x += (dx / d) * step;
          dog.y += (dy / d) * step;
          dog.angle = Math.atan2(dy, dx);
          dog.stride += step * 0.8;
        }
      }
    }
  }

  // --- Simulation.

  update(dt: number, truck: Truck, view: View): void {
    this.time += dt;
    this.updateGroups(dt, truck, view);
    this.activeWorkers = this.workers.filter(
      (p) => Math.abs(p.hx - truck.x) < 900 && Math.abs(p.hy - truck.y) < 900 && (p.state !== 'mill' || !p.hours || inHours(this.hour, p.hours[0], p.hours[1])),
    );
    const tv = { x: truck.cab.vx, y: truck.cab.vy };
    const tSpeed = Math.hypot(tv.x, tv.y);
    for (const p of this.all()) {
      p.bubbleTime -= dt;
      p.chatter -= dt;
      if (p.chatter <= 0) {
        p.chatter = range(6, 18);
        const v = p.venue >= 0 ? this.venues[p.venue] : null;
        const g = p.group >= 0 ? this.groups[p.group] : null;
        if (p.bubbleTime <= 0 && (p.state === 'mill' || p.state === 'walk' || p.state === 'follow') && Math.random() < (v || g || p.lines ? 0.45 : 0.12)) {
          this.say(p, p.lines ?? (v ? (v.kind === 'market' ? MARKET_LINES : v.kind === 'rave' ? RAVE_LINES : PARTY_LINES) : g ? GROUP_LINES[g.kind] : ERRAND_LINES));
        }
      }
      if (p.venue < 0 && p.group < 0 && !p.lines && p.state !== 'fly' && p.state !== 'slide' && p.state !== 'down' && Math.hypot(p.x - truck.x, p.y - truck.y) > 950 && !inView(p.x, p.y, view, 30)) {
        this.place(p, truck, view, false);
        continue;
      }
      if (p.state === 'walk' && p.venue < 0 && p.group < 0 && !p.lines && Math.random() < dt / 220 && CAB_TOWNS.has(this.city.walks[p.walk].district)) {
        p.state = 'hail';
        p.timer = range(35, 55);
        p.chatter = 0;
      }
      if ((p.state === 'walk' || p.state === 'cross' || p.state === 'mill' || p.state === 'follow' || p.state === 'hail') && tSpeed > 30 && truck.layer === 0) {
        const rx = p.x - truck.x;
        const ry = p.y - truck.y;
        const ahead = (rx * tv.x + ry * tv.y) / tSpeed;
        const lateral = (rx * tv.y - ry * tv.x) / tSpeed;
        // Ravers and bar-crawlers are slow to notice.
        const alert = p.venue >= 0 && this.venues[p.venue].kind === 'rave' ? 1.2 : p.group >= 0 && this.groups[p.group]?.kind === 'barcrawl' ? 1 : 4;
        if (ahead > 0 && ahead < 26 + tSpeed * 0.35 && Math.abs(lateral) < 13 && Math.random() < dt * alert) {
          const away = lateral >= 0 ? 1 : -1;
          this.flee(p, (-tv.y / tSpeed) * -away, (tv.x / tSpeed) * -away);
        }
      }
      this.step(p, dt);
    }
  }

  private step(p: Ped, dt: number): void {
    switch (p.state) {
      case 'mill': {
        const v = p.venue >= 0 ? this.venues[p.venue] : null;
        const dance = v ? v.kind !== 'market' : false;
        const dx = p.tx - p.x;
        const dy = p.ty - p.y;
        const d = Math.hypot(dx, dy);
        if (d < 1) {
          p.timer -= dt;
          if (dance) {
            p.stride += dt * (v!.kind === 'rave' ? 22 : 14);
            p.angle += Math.sin(this.time * 6 + p.speed) * dt * 3;
          } else if (p.lines) {
            p.stride += dt * 6;
          } else if (p.group >= 0) {
            const g = this.groups[p.group];
            if (g?.kind === 'yoga') p.stride = Math.sin(this.time * 0.8) * 2;
            if (g?.kind === 'busker' && g.members[0] === p) p.stride += dt * 10;
          }
          if (p.timer <= 0 && p.hr > 0) {
            p.timer = dance ? range(1, 4) : range(2, 6);
            const a = Math.random() * Math.PI * 2;
            const r = Math.sqrt(Math.random()) * p.hr;
            p.tx = p.hx + Math.cos(a) * r;
            p.ty = p.hy + Math.sin(a) * r;
          }
        } else {
          const step = Math.min(d, p.speed * dt);
          p.x += (dx / d) * step;
          p.y += (dy / d) * step;
          p.angle = Math.atan2(dy, dx);
          p.stride += dt * p.speed * 0.9;
        }
        break;
      }
      case 'follow': {
        const g = this.groups[p.group];
        if (!g) {
          p.state = 'walk';
          break;
        }
        const l = g.leader;
        const wobble = g.kind === 'barcrawl' ? Math.sin(this.time * 2 + p.ox) * 4 : 0;
        const tx = l.x + Math.cos(l.angle) * p.ox - Math.sin(l.angle) * (p.oy + wobble);
        const ty = l.y + Math.sin(l.angle) * p.ox + Math.cos(l.angle) * (p.oy + wobble);
        const dx = tx - p.x;
        const dy = ty - p.y;
        const d = Math.hypot(dx, dy);
        if (d > 0.4) {
          const step = Math.min(d, (l.speed * 1.4 + d) * dt);
          p.x += (dx / d) * step;
          p.y += (dy / d) * step;
          p.angle = d > 1.5 ? Math.atan2(dy, dx) : l.angle;
          p.stride += step * 0.9;
        }
        break;
      }
      case 'walk': {
        const w = this.city.walks[p.walk];
        const crawl = p.group >= 0 && this.groups[p.group]?.kind === 'barcrawl';
        p.s += p.dir * p.speed * (p.limp > 0 ? 0.45 : 1) * dt;
        p.limp -= dt;
        p.stride += dt * p.speed * 0.9;
        if (p.s < 0 || p.s > w.len) this.atEnd(p, w);
        else {
          this.position(p);
          if (crawl) p.offset = Math.sin(this.time * 1.7) * 4;
        }
        break;
      }
      case 'cross': {
        const dx = p.tx - p.x;
        const dy = p.ty - p.y;
        const d = Math.hypot(dx, dy);
        const step = (p.limp > 0 ? 9 : 18) * dt;
        p.stride += dt * 16;
        p.limp -= dt;
        if (d <= step) {
          p.state = 'walk';
          p.onRoad = false;
          this.position(p);
        } else {
          p.x += (dx / d) * step;
          p.y += (dy / d) * step;
          p.angle = Math.atan2(dy, dx);
        }
        break;
      }
      case 'flee': {
        p.timer -= dt;
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        p.vx *= 0.94;
        p.vy *= 0.94;
        p.angle = Math.atan2(p.vy, p.vx);
        p.stride += dt * 30;
        if (p.timer <= 0) {
          p.vx = p.vy = 0;
          this.returnToWalk(p);
        }
        break;
      }
      case 'fly': {
        p.vz -= 300 * dt;
        p.z += p.vz * dt;
        p.spin += p.spinRate * dt;
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        p.vx *= 0.998;
        p.vy *= 0.998;
        const push = this.city.resolveCircle(p.x, p.y, 2, 0);
        if (push.x || push.y) {
          // Slam into a wall: bounce off and leave a mark.
          const l = Math.hypot(push.x, push.y);
          const into = (p.vx * push.x + p.vy * push.y) / l;
          p.x += push.x;
          p.y += push.y;
          if (into < 0) {
            p.vx -= (push.x / l) * into * 1.4;
            p.vy -= (push.y / l) * into * 1.4;
            if (-into > 25) this.bleed(p, p.x, p.y, Math.min(2, -into / 60));
          }
        }
        if (p.z <= 0) {
          p.z = 0;
          const hv = Math.hypot(p.vx, p.vy);
          if (Math.abs(p.vz) > 50 && !p.crushed) {
            p.vz = -p.vz * 0.3;
            p.vx *= 0.75;
            p.vy *= 0.75;
            this.bleed(p, p.x, p.y, 0.6);
          } else if (hv > 12) {
            p.state = 'slide';
            p.vz = 0;
          } else {
            this.layDown(p);
          }
        }
        break;
      }
      case 'slide': {
        // Tumbling along the asphalt, smearing as they go.
        const hv = Math.hypot(p.vx, p.vy);
        const dv = Math.min(hv, 0.75 * 60 * dt);
        const ox = p.x;
        const oy = p.y;
        if (hv > 0) {
          p.vx -= (p.vx / hv) * dv;
          p.vy -= (p.vy / hv) * dv;
        }
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        p.spin += p.spinRate * dt * 0.5;
        p.spinRate *= Math.exp(-3 * dt);
        const push = this.city.resolveCircle(p.x, p.y, 2, 0);
        if (push.x || push.y) {
          p.x += push.x;
          p.y += push.y;
          p.vx *= -0.2;
          p.vy *= -0.2;
        }
        if (p.blood > 0) {
          p.blood -= Math.hypot(p.x - ox, p.y - oy) * 0.01;
          this.effects.smear(ox, oy, p.x, p.y);
        }
        if (Math.hypot(p.vx, p.vy) < 4) this.layDown(p);
        break;
      }
      case 'hail': {
        p.timer -= dt;
        p.stride = 0;
        if (p.bubbleTime <= 0 && Math.random() < dt * 0.6) this.say(p, HAIL_LINES);
        if (p.timer <= 0) {
          this.say(p, ['FORGET IT, I WILL WALK', 'NO CABS EVER', 'UGH.']);
          p.state = 'walk';
        }
        break;
      }
      case 'down': {
        p.timer -= dt;
        if (p.timer <= 0) {
          p.limp = 14;
          p.crushed = false;
          this.say(p, ANGRY);
          this.returnToWalk(p);
        }
        break;
      }
    }
  }

  private layDown(p: Ped): void {
    this.bleed(p, p.x, p.y, p.crushed ? 1.8 : 0.9);
    p.state = 'down';
    p.vx = p.vy = 0;
    // Badly hurt people wait for the paramedics; the rest get up after a while.
    p.timer = p.crushed ? range(140, 180) : range(30, 55);
    p.angle += p.spin;
    p.spin = 0;
  }

  private atEnd(p: Ped, w: Walk): void {
    const end = w.ends[p.dir > 0 ? 1 : 0];
    const options: { walk: number; atStart: boolean; road: boolean }[] = end.links.map((l) => ({ ...l, road: false }));
    if (end.cross && Math.random() < 0.45) options.push({ ...end.cross, road: true }, { ...end.cross, road: true });
    if (!options.length) {
      p.dir = -p.dir;
      p.s = Math.max(0, Math.min(w.len, p.s));
      return;
    }
    const choice = pick(options);
    const next = this.city.walks[choice.walk];
    p.walk = choice.walk;
    p.dir = choice.atStart ? 1 : -1;
    p.s = choice.atStart ? 0 : next.len;
    const target = next.ends[choice.atStart ? 0 : 1];
    p.state = 'cross';
    p.onRoad = choice.road;
    p.tx = target.x;
    p.ty = target.y;
  }

  // --- Drawing.

  draw(ctx: CanvasRenderingContext2D, view: View, time: number): void {
    // Group props underneath: blankets, mats, guitar cases.
    for (const g of this.groups) {
      if (!g.stationary || !inView(g.x, g.y, view, 30)) continue;
      if (g.kind === 'picnic') {
        ctx.save();
        ctx.translate(g.x, g.y);
        ctx.rotate(g.angle);
        for (let i = 0; i < 4; i++) {
          for (let j = 0; j < 4; j++) {
            ctx.fillStyle = (i + j) % 2 ? '#d93a3a' : '#f4f1ea';
            ctx.fillRect(-6 + i * 3, -6 + j * 3, 3, 3);
          }
        }
        ctx.fillStyle = '#8a5a3c';
        ctx.fillRect(-1.5, -1, 3, 2);
        ctx.restore();
      } else if (g.kind === 'yoga') {
        for (const m of g.members) {
          ctx.save();
          ctx.translate(m.x, m.y);
          ctx.rotate(m.angle);
          ctx.fillStyle = m === g.leader ? '#3fd0ff' : '#a66bd3';
          ctx.fillRect(-3.5, -1.4, 7, 2.8);
          ctx.restore();
        }
      } else if (g.kind === 'busker') {
        ctx.fillStyle = '#5a3a24';
        ctx.fillRect(g.x + 3, g.y + 2, 5, 2.4);
        ctx.fillStyle = '#d4af37';
        ctx.beginPath();
        ctx.arc(g.x + 5.5, g.y + 3.2, 0.6, 0, Math.PI * 2);
        ctx.fill();
      }
    }
    for (const p of this.all()) {
      if (!inView(p.x, p.y, view, 20)) continue;
      if (p.dog) {
        ctx.strokeStyle = 'rgba(40, 30, 20, 0.6)';
        ctx.lineWidth = 0.25;
        ctx.beginPath();
        ctx.moveTo(p.x, p.y);
        ctx.lineTo(p.dog.x, p.dog.y);
        ctx.stroke();
        drawAnimal(ctx, 'dog', p.dog.x, p.dog.y, p.dog.angle, p.dog.stride, 'walk');
      }
      const pose = p.state === 'fly' ? 'fly' : p.state === 'down' || p.state === 'slide' ? 'down' : 'walk';
      const bounce = p.venue >= 0 && this.venues[p.venue].kind === 'rave' && p.state === 'mill' ? Math.abs(Math.sin(time * 9 + p.speed)) * 3 : 0;
      drawPed(ctx, p.look, p.x, p.y, p.angle, p.stride, pose, p.z + bounce, p.spin, time);
      if (p.sign && pose === 'walk') {
        ctx.fillStyle = '#7a5532';
        ctx.fillRect(p.x - 0.2, p.y - 4, 0.4, 3);
        ctx.fillStyle = p.sign === 'TOUR' ? '#ffcc33' : '#f4f1ea';
        ctx.fillRect(p.x - 3.5, p.y - 6.6, 7, 2.8);
        ctx.fillStyle = '#c0392b';
        ctx.font = '700 1.8px system-ui, sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(p.sign, p.x, p.y - 5.1);
      }
    }
  }

  /** Glowsticks and party lights for the night overlay. */
  lights(view: View): { x: number; y: number; color: string }[] {
    const out: { x: number; y: number; color: string }[] = [];
    for (const p of this.crowd) {
      if (!this.open[p.venue] || !p.look.hat || !inView(p.x, p.y, view, 10)) continue;
      out.push({ x: p.x, y: p.y, color: p.look.hat });
    }
    return out;
  }
}