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