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/mob.ts
import type { City, Spot } from './city.js';
import type { Effects } from './effects.js';
import { angleDiff, boxCorners, clamp, poseAt, type Vec } from './geometry.js';
import type { Pedestrians } from './peds.js';
import { axleGrip, boxContact, boxInertia, forwardSpeed, integrate, resolveStatic, type Body } from './physics.js';
import type { Services } from './services.js';
import { inView, type Car, type Traffic, type View } from './traffic.js';
import { GRAVITY } from './truck.js';
import { drawCar, makeLook, type CarLook } from './vehicles.js';

export type Building = City['buildings'][number];

const GOON_LINES = ['THE FAMILY SAYS HI', 'NICE TRUCK. SHAME IF...', 'FUGGEDABOUTIT', 'YOU DIDN’T SEE NOTHIN’', 'BRING THE GOODS, WE TALK', 'THIS IS OUR STREET'];
const GOON_LOOK = { shirt: '#15161c', pants: '#15161c', hat: '#2b2b2e' };

/** A black sedan sent after a marked car. */
interface CarHit {
  target: Car;
  body: Body;
  look: CarLook;
  steer: number;
  timer: number;
  /** chase → done (it got them) → away (driving off). */
  state: 'chase' | 'away';
  flash: number;
}

/** A bomb planted on a building, ticking. */
interface Bomb {
  b: Building;
  timer: number;
}

/**
 * The family: they run the hood (the police stay out), take deliveries at their
 * social club, and repay each one with a favor. A favor buys a hit: a black sedan
 * that rams a marked car until it blows, or a van crew that bombs a building.
 */
export class Mob {
  readonly hq: Spot;
  favors = 0;
  private hits: CarHit[] = [];
  private bombs: Bomb[] = [];
  /** Buildings the van is on its way to (goons walk it over if the van's held up). */
  private pending: { b: Building; t: number }[] = [];
  private time = 0;
  /** Something went up: the game turns it into a disaster (drama, shockwave, chain blasts). */
  onMayhem: ((x: number, y: number, closing: number) => void) | null = null;
  /** Staggered secondary blasts. */
  onBlast: ((x: number, y: number, size: number, delay: number) => void) | null = null;
  onToast: ((text: string) => void) | null = null;
  /** The building comes down (the game shows the collapse and the crews rebuild it). */
  onDemolish: ((b: Building) => void) | null = null;

  constructor(
    private readonly city: City,
    private readonly effects: Effects,
    private readonly traffic: Traffic,
    private readonly services: Services,
  ) {
    // The social club: the hood's drop-off nearest the middle of their turf.
    const spots = city.dropSpots.filter((s) => s.district === 'hood');
    const mid = { x: 830, y: 3200 };
    this.hq = spots.sort((a, b) => Math.hypot(a.x - mid.x, a.y - mid.y) - Math.hypot(b.x - mid.x, b.y - mid.y))[0] ?? city.dropSpots[0];
    services.addDepot({ kind: 'tow', x: this.hq.x, y: this.hq.y, angle: this.hq.angle, w: 30, d: 20, units: 2, district: 'hood' }, 'mob', 2);
  }

  /** The family's turf: all of the hood. */
  inTurf(x: number, y: number): boolean {
    return this.city.districtAt(x, y) === 'hood';
  }

  clear(): void {
    this.favors = 0;
    this.hits = [];
    this.bombs = [];
    this.pending = [];
  }

  /** Goons hanging round the club and on corners of the turf. */
  setupGoons(peds: Pedestrians): void {
    const nx = -Math.sin(this.hq.angle);
    const ny = Math.cos(this.hq.angle);
    for (let i = 0; i < 4; i++) {
      peds.addWorker(this.hq.x + nx * 12 + Math.cos(this.hq.angle) * (i - 1.5) * 6, this.hq.y + ny * 12 + Math.sin(this.hq.angle) * (i - 1.5) * 6, 5, GOON_LOOK, GOON_LINES, [0, 24]);
    }
    for (const s of this.city.dropSpots.filter((d) => d.district === 'hood').slice(0, 6)) {
      peds.addWorker(s.x - Math.sin(s.angle) * 12, s.y + Math.cos(s.angle) * 12, 4, GOON_LOOK, GOON_LINES, [0, 24]);
    }
  }

  isGoon(lines: string[] | null): boolean {
    return lines === GOON_LINES;
  }

  /** A hit on a car: a black sedan comes round the corner after it. */
  hitCar(target: Car, view: View): void {
    const lane = this.city.lanes[target.lane];
    const back = Math.max(0, target.s - 170);
    const p = poseAt(lane, back);
    const look = makeLook('sedan', '#111318', '#3a3d42', false);
    const m = 2.2;
    const body: Body = { x: p.x, y: p.y, vx: Math.cos(p.angle) * 110, vy: Math.sin(p.angle) * 110, angle: p.angle, av: 0, mass: m, inertia: boxInertia(m, look.length / 2, look.width / 2), halfL: look.length / 2, halfW: look.width / 2 };
    if (inView(p.x, p.y, view, 0)) {
      // Too close to sneak up: come from further back along the target's heading.
      body.x = target.x - Math.cos(target.angle) * 200;
      body.y = target.y - Math.sin(target.angle) * 200;
      body.angle = target.angle;
    }
    target.marked = true;
    target.temper = 1.45;
    target.bubble = 'WHO ARE THOSE GUYS?!';
    target.bubbleTime = 2.5;
    this.hits.push({ target, body, look, steer: 0, timer: 0, state: 'chase', flash: 0 });
  }

  /** A hit on a building: the family's van crew plants a bomb. */
  hitBuilding(b: Building): void {
    const task = {
      points: () => [{ x: b.x, y: b.y }],
      work: 2.5,
      offroad: false,
      onPointDone: () => this.plant(b),
      onAbort: () => this.plant(b),
    };
    if (this.services.dispatch('mob', b.x, b.y, task) !== 'ok') this.plant(b);
    else this.pending.push({ b, t: 15 });
  }

  private plant(b: Building): void {
    this.pending = this.pending.filter((p) => p.b !== b);
    if (this.bombs.some((k) => k.b === b) || b.destroyed) return;
    this.bombs.push({ b, timer: 6 });
  }

  update(dt: number, view: View): void {
    this.time += dt;
    for (const h of this.hits) this.drive(h, dt, view);
    this.hits = this.hits.filter((h) => !(h.state === 'away' && h.timer > 3 && !inView(h.body.x, h.body.y, view, 80)) && h.timer < 60);
    for (const p of [...this.pending]) {
      p.t -= dt;
      if (p.t <= 0) this.plant(p.b);
    }
    for (const k of this.bombs) {
      k.timer -= dt;
      if (k.timer <= 0) this.detonate(k.b);
    }
    this.bombs = this.bombs.filter((k) => k.timer > 0);
  }

  /** Chase the target down, ram it, and make sure it goes up. */
  private drive(h: CarHit, dt: number, view: View): void {
    h.timer += dt;
    const b = h.body;
    const t = h.target;
    let tx: number;
    let ty: number;
    let want: number;
    if (h.state === 'chase') {
      // The target got cleared away or recycled: call it a day.
      if (!this.traffic.all().includes(t)) {
        h.state = 'away';
        h.timer = 0;
        return;
      }
      const lead = Math.min(0.6, Math.hypot(t.x - b.x, t.y - b.y) / 200);
      tx = t.x + Math.cos(t.angle) * t.speed * lead;
      ty = t.y + Math.sin(t.angle) * t.speed * lead;
      want = 175;
      h.flash -= dt;
      const d = Math.hypot(t.x - b.x, t.y - b.y);
      if (d < 60 && h.flash <= 0 && inView(b.x, b.y, view, 40)) {
        // Shots from the window.
        h.flash = 0.25;
        this.effects.scrape(t.x, t.y, b.x - t.x, b.y - t.y);
        t.dmg.glass = true;
      }
      const c = boxContact(b, t.body);
      if (c || h.timer > 30) this.strike(h);
    } else {
      // Off into the night.
      tx = b.x + Math.cos(b.angle) * 100;
      ty = b.y + Math.sin(b.angle) * 100;
      want = 140;
    }
    const alpha = angleDiff(Math.atan2(ty - b.y, tx - b.x), b.angle);
    h.steer += clamp(clamp(alpha * 1.4, -0.6, 0.6) - h.steer, -4 * dt, 4 * dt);
    const steps = 2;
    const st = dt / steps;
    for (let i = 0; i < steps; i++) {
      const c = Math.cos(b.angle);
      const s = Math.sin(b.angle);
      const f = forwardSpeed(b);
      const acc = clamp((want - f) * 3, -150, 90);
      b.vx += c * acc * st;
      b.vy += s * acc * st;
      const n = (b.mass * GRAVITY) / 2;
      const ax = b.halfL * 0.6;
      axleGrip(b, b.x + c * ax, b.y + s * ax, b.angle + h.steer, 1.5 * n * st);
      axleGrip(b, b.x - c * ax, b.y - s * ax, b.angle, 1.5 * n * st);
      b.av *= Math.exp(-1.5 * st);
      integrate(b, st);
      for (const d of [b.halfL - 3, -(b.halfL - 3)]) {
        const px = b.x + c * d;
        const py = b.y + s * d;
        const push = this.city.resolveCircle(px, py, b.halfW, 0);
        if (push.x || push.y) resolveStatic(b, push, px, py, 0.2, 0.3);
      }
    }
  }

  /** Contact (or the car bomb, if they never caught up): the marked car goes up. */
  private strike(h: CarHit): void {
    const t = h.target;
    const b = h.body;
    if (t.mode === 'drive') this.traffic.syncBody(t);
    const dx = t.x - b.x;
    const dy = t.y - b.y;
    const d = Math.hypot(dx, dy) || 1;
    this.traffic.applyHit(t, 140, t.x - (dx / d) * t.body.halfW, t.y - (dy / d) * t.body.halfW, -dx / d, -dy / d, { material: 'car', color: '#111318' }, 'car');
    t.body.vx += (dx / d) * 60;
    t.body.vy += (dy / d) * 60;
    t.fire = Math.max(t.fire, 15);
    t.fuse = 0.5;
    t.marked = false;
    this.onMayhem?.(t.x, t.y, 160);
    for (let i = 0; i < 2; i++) this.onBlast?.(t.x + (Math.random() - 0.5) * 30, t.y + (Math.random() - 0.5) * 30, 1, 0.6 + i * 0.5);
    h.state = 'away';
    h.timer = 0;
    // Bounce off the wreck and away.
    b.vx -= (dx / d) * 40;
    b.vy -= (dy / d) * 40;
  }

  /** The bomb goes off: the building is gutted and everything round it thrown. */
  private detonate(b: Building): void {
    const corners = boxCorners(b.x, b.y, b.angle, b.w / 2, b.d / 2);
    this.effects.fireball(b.x, b.y, 1.8, b.mat === 'wood' ? 'wood' : 'stone');
    corners.forEach((c, i) => this.onBlast?.(c.x, c.y, 1.1 + Math.random() * 0.4, 0.25 + i * 0.3));
    // Blow out every wall.
    for (let i = 0; i < 4; i++) {
      const a = corners[i];
      const c = corners[(i + 1) % 4];
      const mx = (a.x + c.x) / 2;
      const my = (a.y + c.y) / 2;
      const nx = mx - b.x;
      const ny = my - b.y;
      const l = Math.hypot(nx, ny) || 1;
      this.effects.structureHit(mx, my, 170, nx / l, ny / l, 'concrete');
    }
    this.onDemolish?.(b);
    this.onMayhem?.(b.x, b.y, 170);
  }

  /** Marked cars, the hitmen's sedans and ticking bombs. */
  draw(ctx: CanvasRenderingContext2D, view: View): void {
    for (const h of this.hits) {
      if (inView(h.body.x, h.body.y, view, 40)) drawCar(ctx, h.look, h.body.x, h.body.y, h.body.angle, false, false);
      const t = h.target;
      if (h.state !== 'chase' || !inView(t.x, t.y, view, 30)) continue;
      // Crosshairs on the marked car.
      const r = 9 + Math.sin(this.time * 8) * 1.5;
      ctx.strokeStyle = 'rgba(255, 40, 60, 0.9)';
      ctx.lineWidth = 0.8;
      ctx.beginPath();
      ctx.arc(t.x, t.y, r, 0, Math.PI * 2);
      for (let i = 0; i < 4; i++) {
        const a = (i * Math.PI) / 2;
        ctx.moveTo(t.x + Math.cos(a) * (r - 3), t.y + Math.sin(a) * (r - 3));
        ctx.lineTo(t.x + Math.cos(a) * (r + 3), t.y + Math.sin(a) * (r + 3));
      }
      ctx.stroke();
    }
    for (const k of this.bombs) {
      if (!inView(k.b.x, k.b.y, view, 30)) continue;
      const on = Math.floor(this.time * (k.timer < 2 ? 8 : 3)) % 2 === 0;
      ctx.fillStyle = on ? '#ff2d2d' : '#5a1010';
      ctx.beginPath();
      ctx.arc(k.b.x, k.b.y, 2, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  /** Bomb countdowns, for screen-space labels. */
  countdowns(): { x: number; y: number; text: string }[] {
    return this.bombs.map((k) => ({ x: k.b.x, y: k.b.y, text: `${Math.ceil(k.timer)}` }));
  }

  /** Marked targets still being chased. */
  marked(): Vec[] {
    return this.hits.filter((h) => h.state === 'chase').map((h) => ({ x: h.target.x, y: h.target.y }));
  }

  /** Black sedans out on a job (for the minimap). */
  hitmen(): Vec[] {
    return this.hits.map((h) => ({ x: h.body.x, y: h.body.y }));
  }
}