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.
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);
}
}
}
