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/workers.ts
import type { City } from './city.js';
import type { Effects } from './effects.js';
import type { Vec } from './geometry.js';
import type { Pedestrians } from './peds.js';
import { boxContact, boxInertia, resolveContact, type Body } from './physics.js';
import { inView, type View } from './traffic.js';
import type { Truck } from './truck.js';

const FARM_LINES = ['MY CROPS!', 'DARN CROWS', 'GIDDY UP!', 'RAIN TOMORROW?', 'YOU SPOOKED THE COWS!', 'HARVEST TIME!'];
const SITE_LINES = ['HARD HAT AREA!', 'WHO ORDERED THE CONCRETE?', 'BREAK TIME?', 'MEASURE TWICE!', 'LEFT A BIT... LEFT!'];
const PLANT_LINES = ["SHIFT'S ALMOST OVER", 'WATCH THE FORKLIFT!', 'SAFETY FIRST', 'WHO TOOK MY PALLET?', 'OVERTIME AGAIN?'];

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)];

interface Machine {
  kind: 'tractor' | 'forklift';
  body: Body;
  color: string;
  /** Tractors work rows across a field; forklifts shuttle between two points. */
  area: { x: number; y: number; w: number; h: number } | null;
  a: Vec;
  b: Vec;
  dir: number;
  row: number;
  wrecked: boolean;
  smoke: number;
  hours: [number, number];
}

interface Crane {
  x: number;
  y: number;
  jib: number;
  seed: number;
}

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

function machineBody(x: number, y: number, halfL: number, halfW: number, mass: number): Body {
  return { x, y, vx: 0, vy: 0, angle: 0, av: 0, mass, inertia: boxInertia(mass, halfL, halfW), halfL, halfW };
}

/** Farms, building sites and the industrial park at work. */
export class Workers {
  readonly machines: Machine[] = [];
  private readonly cranes: Crane[] = [];
  private time = 0;
  private stackTimer = 0;
  /** A machine was rammed. */
  onCrash: ((x: number, y: number, closing: number, nx: number, ny: number) => void) | null = null;

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

  setup(): void {
    this.machines.length = 0;
    this.cranes.length = 0;
    const farmer = () => ({ shirt: pick(['#b03a2e', '#5b7a3a', '#c97b3a', '#3a5b8a']), pants: '#2f5d8c', hat: '#e0c070' });
    for (const f of this.city.fields) {
      const cx = f.x + f.w / 2;
      const cy = f.y + f.h / 2;
      if (f.kind === 'crop' && Math.random() < 0.4) {
        const m: Machine = {
          kind: 'tractor',
          body: machineBody(f.x + 12, f.y + 12, 6, 4, 5),
          color: pick(['#2e8b3a', '#c0392b', '#2a6fb0']),
          area: f,
          a: { x: 0, y: 0 },
          b: { x: 0, y: 0 },
          dir: 1,
          row: 0,
          wrecked: false,
          smoke: 0,
          hours: [6, 20],
        };
        this.machines.push(m);
      }
      const count = f.kind === 'orchard' ? 3 : Math.random() < 0.5 ? 1 : 2;
      for (let i = 0; i < count; i++) {
        this.peds.addWorker(cx + range(-f.w, f.w) * 0.3, cy + range(-f.h, f.h) * 0.3, Math.min(f.w, f.h) * 0.35, { ...farmer(), bag: f.kind === 'orchard' ? '#8a6a44' : undefined }, FARM_LINES, [6, 20]);
      }
    }
    for (const p of this.city.pastures) {
      if (Math.random() < 0.4) this.peds.addWorker(p.x + p.w / 2, p.y + p.h / 2, Math.min(p.w, p.h) * 0.3, farmer(), FARM_LINES, [6, 19]);
    }
    for (const s of this.city.sites) {
      const c = Math.cos(s.angle);
      const sn = Math.sin(s.angle);
      const corner = { x: s.x + c * (-s.w / 2 + 8) - sn * (-s.d / 2 + 8), y: s.y + sn * (-s.w / 2 + 8) + c * (-s.d / 2 + 8) };
      this.cranes.push({ x: corner.x, y: corner.y, jib: Math.min(60, Math.max(s.w, s.d) * 0.9), seed: Math.random() * 10 });
      for (let i = 0; i < 4; i++) {
        this.peds.addWorker(s.x, s.y, Math.min(s.w, s.d) * 0.3, { shirt: '#ff8a00', pants: '#34495e', hat: '#ffcc00' }, SITE_LINES, [7, 18]);
      }
    }
    for (const y of this.city.yards) {
      const c = Math.cos(y.angle);
      const sn = Math.sin(y.angle);
      const at = (u: number, v: number) => ({ x: y.x + c * u - sn * v, y: y.y + sn * u + c * v });
      this.machines.push({
        kind: 'forklift',
        body: machineBody(y.x, y.y, 3.5, 2.2, 2),
        color: '#f2b705',
        area: null,
        a: at(-y.w * 0.35, range(-y.d, y.d) * 0.3),
        b: at(y.w * 0.35, range(-y.d, y.d) * 0.3),
        dir: 1,
        row: 0,
        wrecked: false,
        smoke: 0,
        hours: [0, 24],
      });
      for (let i = 0; i < 3; i++) {
        this.peds.addWorker(y.x, y.y, Math.min(y.w, y.d) * 0.35, { shirt: '#2471a3', pants: '#2c3e50', hat: '#f4f4f4' }, PLANT_LINES, [0, 24]);
      }
    }
  }

  update(dt: number, truck: Truck, view: View, hour: number): void {
    this.time += dt;
    for (const m of this.machines) {
      const b = m.body;
      if (Math.abs(b.x - truck.x) > 1200 || Math.abs(b.y - truck.y) > 1200) continue;
      if (m.wrecked) {
        if (m.smoke > 0 && Math.random() < dt * 6 * m.smoke) this.effects.puff(b.x, b.y, true, false, 1.2);
        m.smoke = Math.max(0, m.smoke - dt * 0.02);
      } else if (inHours(hour, m.hours[0], m.hours[1])) {
        this.drive(m, dt);
      } else {
        b.vx = b.vy = 0;
      }
      if (truck.layer === 0 && Math.abs(b.x - truck.x) < 60 && Math.abs(b.y - truck.y) < 60) this.collide(m, truck);
    }
    // Smokestacks and cooling towers.
    this.stackTimer -= dt;
    if (this.stackTimer <= 0) {
      this.stackTimer = 0.18;
      for (const s of this.city.stacks) {
        if (!inView(s.x, s.y, view, 140)) continue;
        if (s.steam) this.effects.steam(s.x + range(-10, 10), s.y + range(-10, 10), 4);
        else this.effects.puff(s.x, s.y, Math.random() < 0.6, false, 1.6);
      }
    }
  }

  private drive(m: Machine, dt: number): void {
    const b = m.body;
    const speed = m.kind === 'tractor' ? 13 : 16;
    let tx: number;
    let ty: number;
    if (m.kind === 'tractor' && m.area) {
      // Serpentine rows across the field.
      const f = m.area;
      const rows = Math.max(1, Math.floor((f.h - 20) / 12));
      ty = f.y + 10 + (m.row % rows) * 12;
      tx = m.dir > 0 ? f.x + f.w - 10 : f.x + 10;
      if (Math.abs(b.x - tx) < 1 && Math.abs(b.y - ty) < 1) {
        m.dir = -m.dir;
        m.row++;
      }
      if (Math.random() < dt * 6) this.effects.dust(b.x - Math.cos(b.angle) * 7, b.y - Math.sin(b.angle) * 7, 0.8);
    } else {
      const goal = m.dir > 0 ? m.b : m.a;
      tx = goal.x;
      ty = goal.y;
      if (Math.hypot(b.x - tx, b.y - ty) < 1) m.dir = -m.dir;
    }
    const dx = tx - b.x;
    const dy = ty - b.y;
    const d = Math.hypot(dx, dy);
    if (d < 0.01) return;
    const target = Math.atan2(dy, dx);
    let diff = target - b.angle;
    while (diff > Math.PI) diff -= Math.PI * 2;
    while (diff < -Math.PI) diff += Math.PI * 2;
    b.angle += Math.max(-2 * dt, Math.min(2 * dt, diff));
    const step = Math.min(d, speed * dt * (Math.abs(diff) > 0.6 ? 0.4 : 1));
    b.vx = (dx / d) * speed;
    b.vy = (dy / d) * speed;
    b.x += (dx / d) * step;
    b.y += (dy / d) * step;
  }

  private collide(m: Machine, truck: Truck): void {
    if (truck.layer !== 0) return;
    for (const tb of [truck.cab, truck.trailer]) {
      const c = boxContact(tb, m.body);
      if (!c) continue;
      const vx = m.body.vx;
      const vy = m.body.vy;
      const hit = resolveContact(tb, m.body, c, 0.15, 0.4);
      // Machines are heavy and planted: undo their bounce unless the hit wrecks them.
      if (hit.closing > 18) {
        m.wrecked = true;
        m.smoke = Math.min(1, hit.closing / 80);
        m.body.angle += (Math.random() - 0.5) * 0.8;
        this.effects.crash(c.px, c.py, hit.closing / 100, [m.color, '#1b1b1f', '#e2c25a'], { x: c.nx, y: c.ny }, { material: 'car', glass: hit.closing > 40, wheels: hit.closing > 70 ? 1 : 0 });
        this.onCrash?.(c.px, c.py, hit.closing, c.nx, c.ny);
      }
      m.body.vx = vx;
      m.body.vy = vy;
    }
  }

  draw(ctx: CanvasRenderingContext2D, view: View): void {
    for (const m of this.machines) {
      const b = m.body;
      if (!inView(b.x, b.y, view, 20)) continue;
      ctx.save();
      ctx.translate(b.x + 1, b.y + 1.5);
      ctx.rotate(b.angle);
      ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
      ctx.fillRect(-b.halfL, -b.halfW, b.halfL * 2, b.halfW * 2);
      ctx.restore();
      ctx.save();
      ctx.translate(b.x, b.y);
      ctx.rotate(b.angle);
      const color = m.wrecked ? '#4a4540' : m.color;
      if (m.kind === 'tractor') {
        // Big rear wheels, small fronts, hood, cab and a plough behind.
        ctx.fillStyle = '#141416';
        ctx.fillRect(-5, -5.2, 4.5, 2);
        ctx.fillRect(-5, 3.2, 4.5, 2);
        ctx.fillRect(2.5, -3.4, 2.4, 1.2);
        ctx.fillRect(2.5, 2.2, 2.4, 1.2);
        ctx.fillStyle = '#6b6f75';
        ctx.fillRect(-8.5, -3.5, 2, 7);
        ctx.fillStyle = color;
        ctx.fillRect(-4, -2.6, 10, 5.2);
        ctx.fillStyle = '#1d2a3c';
        ctx.fillRect(-4, -2.4, 3.6, 4.8);
        ctx.fillStyle = '#9a9ca2';
        ctx.fillRect(2, -0.4, 1.2, 0.8);
      } else {
        ctx.fillStyle = '#141416';
        ctx.fillRect(-3, -2.6, 2, 0.6);
        ctx.fillRect(-3, 2, 2, 0.6);
        ctx.fillStyle = color;
        ctx.fillRect(-3.5, -2.2, 5.5, 4.4);
        ctx.fillStyle = '#2b2b2f';
        ctx.fillRect(-2.5, -1.6, 2.4, 3.2);
        ctx.fillStyle = '#9a9ca2';
        ctx.fillRect(2, -1.6, 3, 0.5);
        ctx.fillRect(2, 1.1, 3, 0.5);
        if (m.dir > 0 && !m.wrecked) {
          ctx.fillStyle = '#c8a26a';
          ctx.fillRect(2.4, -1.8, 3, 3.6);
        }
      }
      ctx.restore();
    }
  }

  /** Tower cranes: tall, so drawn above traffic, with a swinging load. */
  drawAir(ctx: CanvasRenderingContext2D, view: View): void {
    for (const c of this.cranes) {
      if (!inView(c.x, c.y, view, c.jib + 40)) continue;
      const a = this.time * 0.12 + c.seed;
      const trolley = c.jib * (0.45 + 0.35 * Math.sin(this.time * 0.3 + c.seed));
      const tx = c.x + Math.cos(a) * trolley;
      const ty = c.y + Math.sin(a) * trolley;
      const lift = 14;
      // Shadow far from the base: the crane is tall.
      ctx.strokeStyle = 'rgba(0, 0, 0, 0.18)';
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(c.x + lift, c.y + lift * 1.2);
      ctx.lineTo(c.x + lift + Math.cos(a) * c.jib, c.y + lift * 1.2 + Math.sin(a) * c.jib);
      ctx.stroke();
      ctx.fillStyle = '#ffcc00';
      ctx.fillRect(c.x - 2.5, c.y - 2.5, 5, 5);
      ctx.strokeStyle = '#e0a800';
      ctx.lineWidth = 1.6;
      ctx.beginPath();
      ctx.moveTo(c.x - Math.cos(a) * c.jib * 0.25, c.y - Math.sin(a) * c.jib * 0.25);
      ctx.lineTo(c.x + Math.cos(a) * c.jib, c.y + Math.sin(a) * c.jib);
      ctx.stroke();
      ctx.fillStyle = '#6b6f75';
      ctx.fillRect(c.x - Math.cos(a) * c.jib * 0.25 - 2.5, c.y - Math.sin(a) * c.jib * 0.25 - 2.5, 5, 5);
      const sway = Math.sin(this.time * 1.4 + c.seed) * 1.5;
      ctx.strokeStyle = '#333';
      ctx.lineWidth = 0.3;
      ctx.beginPath();
      ctx.moveTo(tx, ty);
      ctx.lineTo(tx + sway, ty + sway);
      ctx.stroke();
      ctx.save();
      ctx.translate(tx + sway, ty + sway);
      ctx.rotate(a + 0.3);
      ctx.fillStyle = '#c0392b';
      ctx.fillRect(-6, -0.8, 12, 1.6);
      ctx.restore();
    }
  }
}