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, Spot } from './city.js';
import type { Effects } from './effects.js';
import { angleDiff, boxCorners, clamp, poseAt, type Vec } from './geometry.js';
import type { Pedestrians } from './peds.js';
import { axleGrip, boxContact, boxInertia, forwardSpeed, integrate, resolveStatic, type Body } from './physics.js';
import type { Services } from './services.js';
import { inView, type Car, type Traffic, type View } from './traffic.js';
import { GRAVITY } from './truck.js';
import { drawCar, makeLook, type CarLook } from './vehicles.js';
export type Building = City['buildings'][number];
const GOON_LINES = ['THE FAMILY SAYS HI', 'NICE TRUCK. SHAME IF...', 'FUGGEDABOUTIT', 'YOU DIDN’T SEE NOTHIN’', 'BRING THE GOODS, WE TALK', 'THIS IS OUR STREET'];
const GOON_LOOK = { shirt: '#15161c', pants: '#15161c', hat: '#2b2b2e' };
/** A black sedan sent after a marked car. */
interface CarHit {
target: Car;
body: Body;
look: CarLook;
steer: number;
timer: number;
/** chase → done (it got them) → away (driving off). */
state: 'chase' | 'away';
flash: number;
}
/** A bomb planted on a building, ticking. */
interface Bomb {
b: Building;
timer: number;
}
/**
* The family: they run the hood (the police stay out), take deliveries at their
* social club, and repay each one with a favor. A favor buys a hit: a black sedan
* that rams a marked car until it blows, or a van crew that bombs a building.
*/
export class Mob {
readonly hq: Spot;
favors = 0;
private hits: CarHit[] = [];
private bombs: Bomb[] = [];
/** Buildings the van is on its way to (goons walk it over if the van's held up). */
private pending: { b: Building; t: number }[] = [];
private time = 0;
/** Something went up: the game turns it into a disaster (drama, shockwave, chain blasts). */
onMayhem: ((x: number, y: number, closing: number) => void) | null = null;
/** Staggered secondary blasts. */
onBlast: ((x: number, y: number, size: number, delay: number) => void) | null = null;
onToast: ((text: string) => void) | null = null;
/** The building comes down (the game shows the collapse and the crews rebuild it). */
onDemolish: ((b: Building) => void) | null = null;
constructor(
private readonly city: City,
private readonly effects: Effects,
private readonly traffic: Traffic,
private readonly services: Services,
) {
// The social club: the hood's drop-off nearest the middle of their turf.
const spots = city.dropSpots.filter((s) => s.district === 'hood');
const mid = { x: 830, y: 3200 };
this.hq = spots.sort((a, b) => Math.hypot(a.x - mid.x, a.y - mid.y) - Math.hypot(b.x - mid.x, b.y - mid.y))[0] ?? city.dropSpots[0];
services.addDepot({ kind: 'tow', x: this.hq.x, y: this.hq.y, angle: this.hq.angle, w: 30, d: 20, units: 2, district: 'hood' }, 'mob', 2);
}
/** The family's turf: all of the hood. */
inTurf(x: number, y: number): boolean {
return this.city.districtAt(x, y) === 'hood';
}
clear(): void {
this.favors = 0;
this.hits = [];
this.bombs = [];
this.pending = [];
}
/** Goons hanging round the club and on corners of the turf. */
setupGoons(peds: Pedestrians): void {
const nx = -Math.sin(this.hq.angle);
const ny = Math.cos(this.hq.angle);
for (let i = 0; i < 4; i++) {
peds.addWorker(this.hq.x + nx * 12 + Math.cos(this.hq.angle) * (i - 1.5) * 6, this.hq.y + ny * 12 + Math.sin(this.hq.angle) * (i - 1.5) * 6, 5, GOON_LOOK, GOON_LINES, [0, 24]);
}
for (const s of this.city.dropSpots.filter((d) => d.district === 'hood').slice(0, 6)) {
peds.addWorker(s.x - Math.sin(s.angle) * 12, s.y + Math.cos(s.angle) * 12, 4, GOON_LOOK, GOON_LINES, [0, 24]);
}
}
isGoon(lines: string[] | null): boolean {
return lines === GOON_LINES;
}
/** A hit on a car: a black sedan comes round the corner after it. */
hitCar(target: Car, view: View): void {
const lane = this.city.lanes[target.lane];
const back = Math.max(0, target.s - 170);
const p = poseAt(lane, back);
const look = makeLook('sedan', '#111318', '#3a3d42', false);
const m = 2.2;
const body: Body = { x: p.x, y: p.y, vx: Math.cos(p.angle) * 110, vy: Math.sin(p.angle) * 110, angle: p.angle, av: 0, mass: m, inertia: boxInertia(m, look.length / 2, look.width / 2), halfL: look.length / 2, halfW: look.width / 2 };
if (inView(p.x, p.y, view, 0)) {
// Too close to sneak up: come from further back along the target's heading.
body.x = target.x - Math.cos(target.angle) * 200;
body.y = target.y - Math.sin(target.angle) * 200;
body.angle = target.angle;
}
target.marked = true;
target.temper = 1.45;
target.bubble = 'WHO ARE THOSE GUYS?!';
target.bubbleTime = 2.5;
this.hits.push({ target, body, look, steer: 0, timer: 0, state: 'chase', flash: 0 });
}
/** A hit on a building: the family's van crew plants a bomb. */
hitBuilding(b: Building): void {
const task = {
points: () => [{ x: b.x, y: b.y }],
work: 2.5,
offroad: false,
onPointDone: () => this.plant(b),
onAbort: () => this.plant(b),
};
if (this.services.dispatch('mob', b.x, b.y, task) !== 'ok') this.plant(b);
else this.pending.push({ b, t: 15 });
}
private plant(b: Building): void {
this.pending = this.pending.filter((p) => p.b !== b);
if (this.bombs.some((k) => k.b === b) || b.destroyed) return;
this.bombs.push({ b, timer: 6 });
}
update(dt: number, view: View): void {
this.time += dt;
for (const h of this.hits) this.drive(h, dt, view);
this.hits = this.hits.filter((h) => !(h.state === 'away' && h.timer > 3 && !inView(h.body.x, h.body.y, view, 80)) && h.timer < 60);
for (const p of [...this.pending]) {
p.t -= dt;
if (p.t <= 0) this.plant(p.b);
}
for (const k of this.bombs) {
k.timer -= dt;
if (k.timer <= 0) this.detonate(k.b);
}
this.bombs = this.bombs.filter((k) => k.timer > 0);
}
/** Chase the target down, ram it, and make sure it goes up. */
private drive(h: CarHit, dt: number, view: View): void {
h.timer += dt;
const b = h.body;
const t = h.target;
let tx: number;
let ty: number;
let want: number;
if (h.state === 'chase') {
// The target got cleared away or recycled: call it a day.
if (!this.traffic.all().includes(t)) {
h.state = 'away';
h.timer = 0;
return;
}
const lead = Math.min(0.6, Math.hypot(t.x - b.x, t.y - b.y) / 200);
tx = t.x + Math.cos(t.angle) * t.speed * lead;
ty = t.y + Math.sin(t.angle) * t.speed * lead;
want = 175;
h.flash -= dt;
const d = Math.hypot(t.x - b.x, t.y - b.y);
if (d < 60 && h.flash <= 0 && inView(b.x, b.y, view, 40)) {
// Shots from the window.
h.flash = 0.25;
this.effects.scrape(t.x, t.y, b.x - t.x, b.y - t.y);
t.dmg.glass = true;
}
const c = boxContact(b, t.body);
if (c || h.timer > 30) this.strike(h);
} else {
// Off into the night.
tx = b.x + Math.cos(b.angle) * 100;
ty = b.y + Math.sin(b.angle) * 100;
want = 140;
}
const alpha = angleDiff(Math.atan2(ty - b.y, tx - b.x), b.angle);
h.steer += clamp(clamp(alpha * 1.4, -0.6, 0.6) - h.steer, -4 * dt, 4 * dt);
const steps = 2;
const st = dt / steps;
for (let i = 0; i < steps; i++) {
const c = Math.cos(b.angle);
const s = Math.sin(b.angle);
const f = forwardSpeed(b);
const acc = clamp((want - f) * 3, -150, 90);
b.vx += c * acc * st;
b.vy += s * acc * st;
const n = (b.mass * GRAVITY) / 2;
const ax = b.halfL * 0.6;
axleGrip(b, b.x + c * ax, b.y + s * ax, b.angle + h.steer, 1.5 * n * st);
axleGrip(b, b.x - c * ax, b.y - s * ax, b.angle, 1.5 * n * st);
b.av *= Math.exp(-1.5 * st);
integrate(b, st);
for (const d of [b.halfL - 3, -(b.halfL - 3)]) {
const px = b.x + c * d;
const py = b.y + s * d;
const push = this.city.resolveCircle(px, py, b.halfW, 0);
if (push.x || push.y) resolveStatic(b, push, px, py, 0.2, 0.3);
}
}
}
/** Contact (or the car bomb, if they never caught up): the marked car goes up. */
private strike(h: CarHit): void {
const t = h.target;
const b = h.body;
if (t.mode === 'drive') this.traffic.syncBody(t);
const dx = t.x - b.x;
const dy = t.y - b.y;
const d = Math.hypot(dx, dy) || 1;
this.traffic.applyHit(t, 140, t.x - (dx / d) * t.body.halfW, t.y - (dy / d) * t.body.halfW, -dx / d, -dy / d, { material: 'car', color: '#111318' }, 'car');
t.body.vx += (dx / d) * 60;
t.body.vy += (dy / d) * 60;
t.fire = Math.max(t.fire, 15);
t.fuse = 0.5;
t.marked = false;
this.onMayhem?.(t.x, t.y, 160);
for (let i = 0; i < 2; i++) this.onBlast?.(t.x + (Math.random() - 0.5) * 30, t.y + (Math.random() - 0.5) * 30, 1, 0.6 + i * 0.5);
h.state = 'away';
h.timer = 0;
// Bounce off the wreck and away.
b.vx -= (dx / d) * 40;
b.vy -= (dy / d) * 40;
}
/** The bomb goes off: the building is gutted and everything round it thrown. */
private detonate(b: Building): void {
const corners = boxCorners(b.x, b.y, b.angle, b.w / 2, b.d / 2);
this.effects.fireball(b.x, b.y, 1.8, b.mat === 'wood' ? 'wood' : 'stone');
corners.forEach((c, i) => this.onBlast?.(c.x, c.y, 1.1 + Math.random() * 0.4, 0.25 + i * 0.3));
// Blow out every wall.
for (let i = 0; i < 4; i++) {
const a = corners[i];
const c = corners[(i + 1) % 4];
const mx = (a.x + c.x) / 2;
const my = (a.y + c.y) / 2;
const nx = mx - b.x;
const ny = my - b.y;
const l = Math.hypot(nx, ny) || 1;
this.effects.structureHit(mx, my, 170, nx / l, ny / l, 'concrete');
}
this.onDemolish?.(b);
this.onMayhem?.(b.x, b.y, 170);
}
/** Marked cars, the hitmen's sedans and ticking bombs. */
draw(ctx: CanvasRenderingContext2D, view: View): void {
for (const h of this.hits) {
if (inView(h.body.x, h.body.y, view, 40)) drawCar(ctx, h.look, h.body.x, h.body.y, h.body.angle, false, false);
const t = h.target;
if (h.state !== 'chase' || !inView(t.x, t.y, view, 30)) continue;
// Crosshairs on the marked car.
const r = 9 + Math.sin(this.time * 8) * 1.5;
ctx.strokeStyle = 'rgba(255, 40, 60, 0.9)';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.arc(t.x, t.y, r, 0, Math.PI * 2);
for (let i = 0; i < 4; i++) {
const a = (i * Math.PI) / 2;
ctx.moveTo(t.x + Math.cos(a) * (r - 3), t.y + Math.sin(a) * (r - 3));
ctx.lineTo(t.x + Math.cos(a) * (r + 3), t.y + Math.sin(a) * (r + 3));
}
ctx.stroke();
}
for (const k of this.bombs) {
if (!inView(k.b.x, k.b.y, view, 30)) continue;
const on = Math.floor(this.time * (k.timer < 2 ? 8 : 3)) % 2 === 0;
ctx.fillStyle = on ? '#ff2d2d' : '#5a1010';
ctx.beginPath();
ctx.arc(k.b.x, k.b.y, 2, 0, Math.PI * 2);
ctx.fill();
}
}
/** Bomb countdowns, for screen-space labels. */
countdowns(): { x: number; y: number; text: string }[] {
return this.bombs.map((k) => ({ x: k.b.x, y: k.b.y, text: `${Math.ceil(k.timer)}` }));
}
/** Marked targets still being chased. */
marked(): Vec[] {
return this.hits.filter((h) => h.state === 'chase').map((h) => ({ x: h.target.x, y: h.target.y }));
}
/** Black sedans out on a job (for the minimap). */
hitmen(): Vec[] {
return this.hits.map((h) => ({ x: h.body.x, y: h.body.y }));
}
}
