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/animals.ts
import type { City } from './city.js';
import type { Effects } from './effects.js';
import type { Vec } from './geometry.js';
import type { Body } from './physics.js';
import { inView, type View } from './traffic.js';
import type { Truck } from './truck.js';
import { drawAnimal, type AnimalKind } from './vehicles.js';

interface Species {
  weight: number;
  mass: number;
  walk: number;
  run: number;
  size: number;
}

const SPECIES: Record<AnimalKind, Species> = {
  deer: { weight: 5, mass: 1.6, walk: 10, run: 42, size: 1.2 },
  moose: { weight: 1, mass: 4.5, walk: 8, run: 24, size: 2 },
  bear: { weight: 0.8, mass: 3.6, walk: 8, run: 30, size: 1.8 },
  fox: { weight: 2, mass: 0.4, walk: 12, run: 38, size: 0.6 },
  raccoon: { weight: 2, mass: 0.3, walk: 7, run: 16, size: 0.5 },
  rabbit: { weight: 3, mass: 0.15, walk: 9, run: 34, size: 0.35 },
  dog: { weight: 0, mass: 0.3, walk: 12, run: 36, size: 0.5 },
  cow: { weight: 0, mass: 5, walk: 4, run: 14, size: 2 },
};
const KINDS = (Object.keys(SPECIES) as AnimalKind[]).filter((k) => SPECIES[k].weight > 0);
const MAX_CARCASSES = 40;

const range = (a: number, b: number) => a + Math.random() * (b - a);

function pickKind(): AnimalKind {
  let roll = Math.random() * KINDS.reduce((s, k) => s + SPECIES[k].weight, 0);
  for (const k of KINDS) {
    roll -= SPECIES[k].weight;
    if (roll <= 0) return k;
  }
  return 'deer';
}

export class Animal {
  kind: AnimalKind = 'deer';
  x = 0;
  y = 0;
  angle = 0;
  state: 'graze' | 'cross' | 'freeze' | 'fly' | 'dead' = 'graze';
  /** Cows keep to their pasture. */
  pasture: { x: number; y: number; w: number; h: number } | null = null;
  homeX = 0;
  homeY = 0;
  tx = 0;
  ty = 0;
  speed = 0;
  stride = 0;
  timer = 0;
  z = 0;
  vz = 0;
  vx = 0;
  vy = 0;
  spin = 0;
  spinRate = 0;
  diedAt = 0;
}

/** Deer, moose, bears and small critters living by the country roads. */
export class Wildlife {
  readonly animals: Animal[] = [];
  readonly carcasses: Animal[] = [];
  /** An animal was struck; mass decides how much it hurts the vehicle. */
  onHit: ((animal: Animal, speed: number, mass: number) => void) | null = null;
  private time = 0;

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

  readonly herd: Animal[] = [];

  clear(): void {
    this.animals.length = 0;
    this.carcasses.length = 0;
    this.herd.length = 0;
  }

  /** Put a few cows in every pasture. */
  setupHerds(): void {
    this.herd.length = 0;
    for (const p of this.city.pastures) {
      const n = 3 + Math.floor(Math.random() * 5);
      for (let i = 0; i < n; i++) {
        const a = new Animal();
        a.kind = 'cow';
        a.pasture = p;
        a.x = a.homeX = p.x + range(12, p.w - 12);
        a.y = a.homeY = p.y + range(12, p.h - 12);
        a.angle = Math.random() * Math.PI * 2;
        a.timer = 1e9;
        this.herd.push(a);
      }
    }
  }

  setCount(count: number, truck: Truck, view: View): void {
    while (this.animals.length < count) {
      const a = new Animal();
      this.animals.push(a);
      this.place(a, truck, view, true);
    }
  }

  /** Animals standing in the road, for drivers to stop for. */
  roadObstacles(): Vec[] {
    return this.animals.filter((a) => (a.state === 'cross' || a.state === 'freeze') && this.city.surfaceAt(a.x, a.y) === 1).map((a) => ({ x: a.x, y: a.y }));
  }

  private place(a: Animal, truck: Truck, view: View, initial: boolean): void {
    const curves = this.city.countryCurves;
    if (!curves.length) return;
    for (let attempt = 0; attempt < 60; attempt++) {
      const curve = curves[Math.floor(Math.random() * curves.length)];
      const i = 2 + Math.floor(Math.random() * (curve.length - 4));
      const p = curve[i];
      const q = curve[i + 1];
      const l = Math.hypot(q.x - p.x, q.y - p.y) || 1;
      const side = Math.random() < 0.5 ? -1 : 1;
      const off = range(30, 70);
      const x = p.x - ((q.y - p.y) / l) * off * side;
      const y = p.y + ((q.x - p.x) / l) * off * side;
      const d = Math.hypot(x - truck.x, y - truck.y);
      if (initial ? d < 150 : d < 220 || d > 900 || inView(x, y, view, 20)) continue;
      if (this.city.surfaceAt(x, y) === 4) continue;
      Object.assign(a, new Animal());
      a.kind = pickKind();
      a.x = a.homeX = x;
      a.y = a.homeY = y;
      // The far side of the road, for crossing.
      a.tx = p.x + ((q.y - p.y) / l) * off * side;
      a.ty = p.y - ((q.x - p.x) / l) * off * side;
      a.timer = range(2, 12);
      a.angle = Math.random() * Math.PI * 2;
      return;
    }
  }

  strike(body: Body, layer: number, careful = false): void {
    if (layer !== 0) return;
    const speed = Math.hypot(body.vx, body.vy);
    if (speed < 6) return;
    const c = Math.cos(body.angle);
    const s = Math.sin(body.angle);
    for (const a of [...this.animals, ...this.herd]) {
      if (a.state === 'fly' || a.state === 'dead') continue;
      const dx = a.x - body.x;
      const dy = a.y - body.y;
      if (Math.abs(dx) > body.halfL + 8 || Math.abs(dy) > body.halfL + 8) continue;
      const sp = SPECIES[a.kind];
      const lx = dx * c + dy * s;
      const ly = -dx * s + dy * c;
      if (Math.abs(lx) > body.halfL + sp.size * 2 || Math.abs(ly) > body.halfW + sp.size * 1.5) continue;
      const side = ly >= 0 ? 1 : -1;
      if (careful) {
        // The driver slows and the animal bolts out of the way.
        a.x += -s * side * 2;
        a.y += c * side * 2;
        continue;
      }
      const share = body.mass / (body.mass + sp.mass);
      a.state = 'fly';
      a.vx = body.vx * share * 1.1 - s * side * speed * 0.3;
      a.vy = body.vy * share * 1.1 + c * side * speed * 0.3;
      a.vz = 30 + speed * 0.5 * share;
      a.z = 1;
      a.spinRate = range(6, 14) * (Math.random() < 0.5 ? -1 : 1);
      // Heavy animals take a real bite out of the vehicle's speed.
      const slow = (sp.mass / (body.mass + sp.mass)) * 0.8;
      body.vx *= 1 - slow;
      body.vy *= 1 - slow;
      this.effects.splat(a.x, a.y, Math.min(3, 0.8 + sp.size + speed / 80), body.vx, body.vy);
      this.effects.crash(a.x, a.y, Math.min(1.2, (speed / 160) * sp.size), ['#7a5532', '#b5121b'], { x: c, y: s }, { material: 'ground', glass: sp.mass > 3 && speed > 50 });
      this.onHit?.(a, speed, sp.mass);
    }
  }

  blast(x: number, y: number, radius: number): void {
    for (const a of [...this.animals, ...this.herd]) {
      if (a.state === 'dead') continue;
      const dx = a.x - x;
      const dy = a.y - y;
      const d = Math.hypot(dx, dy);
      if (d > radius || d < 0.1) continue;
      const f = (1 - d / radius) * 120;
      a.state = 'fly';
      a.vx = (dx / d) * f;
      a.vy = (dy / d) * f;
      a.vz = 40 + f * 0.4;
      a.z = 1;
      a.spinRate = range(6, 14);
    }
  }

  update(dt: number, truck: Truck, view: View): void {
    this.time += dt;
    const tSpeed = Math.hypot(truck.cab.vx, truck.cab.vy);
    for (const a of this.herd) {
      if (!inView(a.x, a.y, view, 200)) continue;
      if (a.state === 'graze') {
        if (Math.random() < dt * 0.3) a.angle += range(-1.2, 1.2);
        const move = Math.random() < 0.3 ? 3 : 0;
        const p = a.pasture!;
        const nx = a.x + Math.cos(a.angle) * move * dt;
        const ny = a.y + Math.sin(a.angle) * move * dt;
        if (nx > p.x + 6 && nx < p.x + p.w - 6 && ny > p.y + 6 && ny < p.y + p.h - 6) {
          a.x = nx;
          a.y = ny;
          a.stride += move * dt;
        } else a.angle += Math.PI * 0.6;
      } else if (a.state === 'fly') {
        this.flyStep(a, dt);
      }
    }
    for (const a of this.animals) {
      const sp = SPECIES[a.kind];
      if (a.state !== 'fly' && Math.hypot(a.x - truck.x, a.y - truck.y) > 1000 && !inView(a.x, a.y, view, 30)) {
        this.place(a, truck, view, false);
        continue;
      }
      switch (a.state) {
        case 'graze': {
          a.timer -= dt;
          // Ambling about near home.
          if (Math.random() < dt * 0.6) a.angle += range(-1, 1);
          const hx = a.homeX - a.x;
          const hy = a.homeY - a.y;
          if (Math.hypot(hx, hy) > 18) a.angle = Math.atan2(hy, hx);
          const move = Math.random() < 0.5 ? sp.walk * 0.3 : 0;
          a.x += Math.cos(a.angle) * move * dt;
          a.y += Math.sin(a.angle) * move * dt;
          this.keepOut(a);
          a.stride += move * dt;
          if (a.timer <= 0) {
            a.state = 'cross';
            a.speed = Math.random() < 0.4 ? sp.run : sp.walk * 1.6;
          }
          break;
        }
        case 'cross': {
          // Headlights: deer may freeze, everything else bolts.
          const d = Math.hypot(a.x - truck.x, a.y - truck.y);
          if (d < 70 && tSpeed > 40 && this.city.surfaceAt(a.x, a.y) === 1) {
            if (a.kind === 'deer' && Math.random() < dt * 2) {
              a.state = 'freeze';
              a.timer = range(0.8, 2);
              break;
            }
            a.speed = sp.run;
          }
          const dx = a.tx - a.x;
          const dy = a.ty - a.y;
          const dist = Math.hypot(dx, dy);
          const step = a.speed * dt;
          a.stride += step * 0.6;
          if (dist <= step) {
            a.state = 'graze';
            a.homeX = a.tx;
            a.homeY = a.ty;
            // Next time, cross back.
            [a.tx, a.ty] = [a.x, a.y];
            a.x += dx;
            a.y += dy;
            a.timer = range(6, 20);
          } else {
            a.x += (dx / dist) * step;
            a.y += (dy / dist) * step;
            a.angle = Math.atan2(dy, dx);
            this.keepOut(a);
          }
          break;
        }
        case 'freeze':
          a.timer -= dt;
          if (a.timer <= 0) {
            a.state = 'cross';
            a.speed = sp.run;
          }
          break;
        case 'fly': {
          this.flyStep(a, dt);
          break;
        }
        case 'dead':
          break;
      }
    }
    this.retireDead(truck, view);
  }

  private flyStep(a: Animal, dt: number): void {
    const sp = SPECIES[a.kind];
    a.vz -= 300 * dt;
    a.z += a.vz * dt;
    a.spin += a.spinRate * dt;
    a.x += a.vx * dt;
    a.y += a.vy * dt;
    const push = this.city.resolveCircle(a.x, a.y, 2, 0);
    if (push.x || push.y) {
      a.x += push.x;
      a.y += push.y;
      a.vx *= -0.3;
      a.vy *= -0.3;
      this.effects.splat(a.x, a.y, 0.8);
    }
    if (a.z > 0) return;
    a.z = 0;
    if (Math.abs(a.vz) > 60) {
      a.vz = -a.vz * 0.3;
      a.vx *= 0.6;
      a.vy *= 0.6;
      this.effects.splat(a.x, a.y, 0.6 + sp.size * 0.4);
    } else {
      a.state = 'dead';
      a.diedAt = this.time;
      a.angle += a.spin;
      a.spin = 0;
      this.effects.splat(a.x, a.y, 0.8 + sp.size * 0.6);
    }
  }

  private retireDead(truck: Truck, view: View): void {
    // The dead stay by the road; a fresh animal takes their place in the woods.
    for (let i = this.animals.length - 1; i >= 0; i--) {
      const a = this.animals[i];
      if (a.state !== 'dead') continue;
      this.animals.splice(i, 1);
      this.carcasses.push(a);
      const fresh = new Animal();
      this.animals.push(fresh);
      this.place(fresh, truck, view, false);
    }
    for (let i = this.herd.length - 1; i >= 0; i--) {
      if (this.herd[i].state !== 'dead') continue;
      this.carcasses.push(this.herd[i]);
      this.herd.splice(i, 1);
    }
    if (this.carcasses.length > MAX_CARCASSES) this.carcasses.splice(0, this.carcasses.length - MAX_CARCASSES);
  }

  /** Walk around buildings and trees instead of through them. */
  private keepOut(a: Animal): void {
    const push = this.city.resolveCircle(a.x, a.y, SPECIES[a.kind].size * 1.6 + 0.5, 0);
    if (!push.x && !push.y) return;
    a.x += push.x;
    a.y += push.y;
    a.angle += 0.3;
  }

  /** Animal control (the garbage crew) takes a carcass away. */
  removeCarcass(a: Animal): void {
    const i = this.carcasses.indexOf(a);
    if (i >= 0) this.carcasses.splice(i, 1);
  }

  draw(ctx: CanvasRenderingContext2D, view: View): void {
    for (const a of [...this.carcasses, ...this.herd, ...this.animals]) {
      if (!inView(a.x, a.y, view, 20)) continue;
      const pose = a.state === 'fly' ? 'fly' : a.state === 'dead' ? 'dead' : 'walk';
      drawAnimal(ctx, a.kind, a.x, a.y, a.angle, a.stride, pose, a.z, a.spin);
    }
  }
}