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/street.ts
import type { City } from './city.js';
import type { Effects } from './effects.js';
import { pointAt, type Vec } from './geometry.js';
import type { Body } from './physics.js';
import { inView, type View } from './traffic.js';

export type FixtureKind = 'lamp' | 'bin' | 'hydrant';

/** A street lamp, trash can or fire hydrant that vehicles can knock over. */
export interface Fixture {
  kind: FixtureKind;
  x: number;
  y: number;
  /** Knocked over (lamps, bins) or sheared off (hydrants: a geyser until capped). */
  down: boolean;
  /** Which way it fell. */
  fall: number;
  /** Trash bins: 0 empty … 1 full and waiting for the garbage truck. */
  fill: number;
}

/** Towns get trash cans and hydrants along the sidewalks. */
const TOWNS = new Set(['downtown', 'uptown', 'hood', 'village', 'port', 'industrial']);
const CELL = 64;

/** Street furniture: lamps, trash cans and hydrants that get knocked flying, and get put back. */
export class Street {
  readonly items: Fixture[] = [];
  private readonly grid = new Map<number, Fixture[]>();
  /** Something got knocked over (for sounds). */
  onKnock: ((f: Fixture, speed: number) => void) | null = null;

  constructor(
    city: City,
    private readonly effects: Effects,
  ) {
    for (const p of city.lights) this.add('lamp', p.x, p.y);
    for (const w of city.walks) {
      if (!TOWNS.has(w.district)) continue;
      for (let s = 20 + Math.random() * 40; s < w.len - 10; s += 90 + Math.random() * 80) {
        const p = pointAt(w, s);
        this.add(Math.random() < 0.6 ? 'bin' : 'hydrant', p.x, p.y);
      }
    }
  }

  private add(kind: FixtureKind, x: number, y: number): void {
    const f: Fixture = { kind, x, y, down: false, fall: 0, fill: Math.random() ** 2 * 1.1 };
    this.items.push(f);
    const key = this.key(x, y);
    const cell = this.grid.get(key);
    if (cell) cell.push(f);
    else this.grid.set(key, [f]);
  }

  private key(x: number, y: number): number {
    return Math.floor(y / CELL) * 1000 + Math.floor(x / CELL);
  }

  private near(x: number, y: number, r: number): Fixture[] {
    const out: Fixture[] = [];
    for (let cy = Math.floor((y - r) / CELL); cy <= Math.floor((y + r) / CELL); cy++) {
      for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++) {
        const cell = this.grid.get(cy * 1000 + cx);
        if (cell) out.push(...cell);
      }
    }
    return out;
  }

  reset(): void {
    for (const f of this.items) {
      f.down = false;
      f.fill = Math.random() ** 2 * 1.1;
    }
  }

  /** A moving body on the ground knocks over anything it touches. */
  strike(b: Body, layer: number): void {
    if (layer !== 0) return;
    const speed = Math.hypot(b.vx, b.vy);
    if (speed < 9) return;
    const c = Math.cos(b.angle);
    const s = Math.sin(b.angle);
    for (const f of this.near(b.x, b.y, b.halfL + 3)) {
      if (f.down) continue;
      const dx = f.x - b.x;
      const dy = f.y - b.y;
      if (Math.abs(dx * c + dy * s) > b.halfL + 1 || Math.abs(-dx * s + dy * c) > b.halfW + 1) continue;
      f.down = true;
      f.fall = Math.atan2(b.vy, b.vx) + (Math.random() - 0.5) * 0.8;
      const dir = { x: b.vx / speed, y: b.vy / speed };
      const k = Math.min(1, speed / 120);
      b.vx *= f.kind === 'lamp' ? 0.92 : 0.98;
      b.vy *= f.kind === 'lamp' ? 0.92 : 0.98;
      if (f.kind === 'lamp') this.effects.crash(f.x, f.y, 0.3 + k * 0.4, ['#5c5f66', '#8d9096', '#fff2b0'], dir, { material: 'barrier', glass: true });
      else if (f.kind === 'hydrant') this.effects.crash(f.x, f.y, 0.3 + k * 0.3, ['#c0392b', '#8d9096'], dir, { material: 'barrier', glass: false });
      else {
        // Trash everywhere.
        this.effects.crash(f.x, f.y, 0.25 + k * 0.5 + f.fill * 0.3, ['#2e7d4f', '#d8c3a0', '#f2f2f2', '#8a6a44', '#c0392b'], dir, { material: 'ground', glass: false });
        f.fill = 0;
      }
      this.onKnock?.(f, speed);
    }
  }

  /** A blast knocks over everything within r (thrown outward from the centre). */
  blast(x: number, y: number, r: number): void {
    for (const f of this.near(x, y, r)) {
      if (f.down || Math.hypot(f.x - x, f.y - y) > r) continue;
      f.down = true;
      f.fall = Math.atan2(f.y - y, f.x - x);
      if (f.kind === 'bin') f.fill = 0;
      this.effects.crash(f.x, f.y, 0.3, f.kind === 'hydrant' ? ['#c0392b', '#8d9096'] : f.kind === 'lamp' ? ['#5c5f66', '#fff2b0'] : ['#2e7d4f', '#d8c3a0'], { x: Math.cos(f.fall), y: Math.sin(f.fall) }, { material: 'barrier', glass: f.kind === 'lamp' });
      this.onKnock?.(f, 60);
    }
  }

  /** Bins fill up through the day; broken hydrants gush. */
  update(dt: number, fillRate: number, view: View): void {
    for (const f of this.items) {
      if (f.kind === 'bin' && !f.down) f.fill = Math.min(1.2, f.fill + dt * fillRate * (0.6 + ((f.x * 7 + f.y) % 1) * 0.8));
      if (f.kind === 'hydrant' && f.down && inView(f.x, f.y, view, 40) && Math.random() < dt * 30) {
        const a = Math.random() * Math.PI * 2;
        const r = 4 + Math.random() * 10;
        this.effects.water(f.x, f.y, f.x + Math.cos(a) * r, f.y + Math.sin(a) * r - 3);
      }
    }
  }

  /** Lamps, hydrants and bins that need putting right. */
  broken(): Fixture[] {
    return this.items.filter((f) => f.down);
  }

  fullBins(): Fixture[] {
    return this.items.filter((f) => f.kind === 'bin' && !f.down && f.fill >= 1);
  }

  /** Street lamps still standing (and so still lit at night). */
  lit(view: View): Vec[] {
    return this.items.filter((f) => f.kind === 'lamp' && !f.down && inView(f.x, f.y, view, 60));
  }

  /** A crew stands it back up (or caps the hydrant); bins get emptied too. */
  fix(f: Fixture): void {
    f.down = false;
    if (f.kind === 'bin') f.fill = 0;
  }

  draw(ctx: CanvasRenderingContext2D, view: View): void {
    for (const f of this.items) {
      if (!inView(f.x, f.y, view, 12)) continue;
      if (f.kind === 'lamp') {
        if (!f.down) continue;
        // The pole lies across the sidewalk, lamp head smashed.
        ctx.strokeStyle = '#5c5f66';
        ctx.lineWidth = 0.9;
        ctx.beginPath();
        ctx.moveTo(f.x, f.y);
        ctx.lineTo(f.x + Math.cos(f.fall) * 11, f.y + Math.sin(f.fall) * 11);
        ctx.stroke();
        ctx.fillStyle = '#3a3d42';
        ctx.fillRect(f.x + Math.cos(f.fall) * 11 - 1.2, f.y + Math.sin(f.fall) * 11 - 1.2, 2.4, 2.4);
      } else if (f.kind === 'hydrant') {
        ctx.fillStyle = f.down ? '#5c5f66' : '#c0392b';
        ctx.beginPath();
        ctx.arc(f.x, f.y, f.down ? 0.7 : 1.1, 0, Math.PI * 2);
        ctx.fill();
        if (!f.down) {
          ctx.fillStyle = '#e6e6e6';
          ctx.fillRect(f.x - 0.3, f.y - 0.3, 0.6, 0.6);
        } else {
          ctx.fillStyle = '#c0392b';
          ctx.fillRect(f.x + Math.cos(f.fall) * 4 - 0.9, f.y + Math.sin(f.fall) * 4 - 0.9, 1.8, 1.8);
        }
      } else if (f.down) {
        ctx.save();
        ctx.translate(f.x + Math.cos(f.fall) * 2, f.y + Math.sin(f.fall) * 2);
        ctx.rotate(f.fall);
        ctx.fillStyle = '#2e7d4f';
        ctx.fillRect(-1.6, -1.1, 3.2, 2.2);
        ctx.fillStyle = '#1b1b1f';
        ctx.fillRect(1.4, -1.1, 0.5, 2.2);
        ctx.restore();
      } else {
        ctx.fillStyle = 'rgba(0, 0, 0, 0.25)';
        ctx.beginPath();
        ctx.arc(f.x + 0.4, f.y + 0.6, 1.4, 0, Math.PI * 2);
        ctx.fill();
        ctx.fillStyle = '#2e7d4f';
        ctx.beginPath();
        ctx.arc(f.x, f.y, 1.4, 0, Math.PI * 2);
        ctx.fill();
        ctx.fillStyle = f.fill >= 1 ? '#8a6a44' : '#3f9a63';
        ctx.beginPath();
        ctx.arc(f.x, f.y, 0.9, 0, Math.PI * 2);
        ctx.fill();
      }
    }
  }
}