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 { 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();
}
}
}
