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 { TrafficRouting } from './traffic-routing.js';
import { laneLayer, type City, type District, type Lane, type Signal } from './city.js';
import type { Effects, HitMaterial } from './effects.js';
import { poseAt, type Vec } from './geometry.js';
import { pickLine, type LineKind } from './lines.js';
import { axleGrip, boxContact, boxInertia, forwardSpeed, integrate, resolveContact, resolveStatic, type Body } from './physics.js';
import { GRAVITY, type Truck } from './truck.js';
import { makeLook, type CarDamage, type CarLook, type CarModel } from './vehicles.js';
export interface View {
x: number;
y: number;
w: number;
h: number;
}
export type CrashKind = 'wall' | 'car' | 'pileup';
/** How each model behaves in a crash. */
interface CrashSpec {
mass: number;
/** Multiplies crush and damage: flimsier cars fold up sooner. */
fragility: number;
/** Static stability: lower tips over sooner (tall SUVs), higher hugs the road. */
stability: number;
fire: number;
explode: number;
}
const SPECS: Record<CarModel, CrashSpec> = {
sedan: { mass: 1.5, fragility: 1, stability: 1.2, fire: 0.3, explode: 0.15 },
taxi: { mass: 1.6, fragility: 0.95, stability: 1.2, fire: 0.3, explode: 0.15 },
sports: { mass: 1.3, fragility: 1.2, stability: 1.6, fire: 0.45, explode: 0.3 },
suv: { mass: 2.1, fragility: 0.8, stability: 0.85, fire: 0.25, explode: 0.12 },
beater: { mass: 1.4, fragility: 1.4, stability: 1.1, fire: 0.6, explode: 0.35 },
compact: { mass: 1.1, fragility: 1.3, stability: 1.25, fire: 0.3, explode: 0.15 },
pickup: { mass: 2.2, fragility: 0.85, stability: 1, fire: 0.3, explode: 0.2 },
van: { mass: 2.4, fragility: 0.95, stability: 0.85, fire: 0.3, explode: 0.15 },
bus: { mass: 7, fragility: 0.7, stability: 0.9, fire: 0.2, explode: 0.1 },
motorcycle: { mass: 0.6, fragility: 1.6, stability: 0.6, fire: 0.5, explode: 0.3 },
police: { mass: 1.7, fragility: 0.85, stability: 1.3, fire: 0.3, explode: 0.2 },
limo: { mass: 2.6, fragility: 0.9, stability: 1.2, fire: 0.3, explode: 0.2 },
icecream: { mass: 2.3, fragility: 1, stability: 0.8, fire: 0.35, explode: 0.25 },
tow: { mass: 4, fragility: 0.6, stability: 1, fire: 0.2, explode: 0.1 },
ambulance: { mass: 3.2, fragility: 0.8, stability: 0.85, fire: 0.25, explode: 0.1 },
firetruck: { mass: 8, fragility: 0.6, stability: 0.9, fire: 0.1, explode: 0.05 },
garbage: { mass: 7, fragility: 0.65, stability: 0.85, fire: 0.2, explode: 0.08 },
sweeper: { mass: 3.5, fragility: 0.8, stability: 0.9, fire: 0.2, explode: 0.1 },
works: { mass: 2.6, fragility: 0.85, stability: 1, fire: 0.3, explode: 0.15 },
dump: { mass: 7.5, fragility: 0.6, stability: 0.9, fire: 0.2, explode: 0.08 },
roadside: { mass: 2.4, fragility: 0.9, stability: 0.85, fire: 0.3, explode: 0.15 },
};
/** What a car collided with. */
export interface HitSource {
material: HitMaterial;
color: string;
}
/** Someone at the curb waving down a taxi. */
export interface Hailer {
x: number;
y: number;
}
/** Something that isn't traffic but that drivers must stop for (a service unit). */
export interface Blocker {
body: Body;
layer: number;
parked: boolean;
}
export class Car {
readonly body: Body;
readonly spec: CrashSpec;
/** Per-car build quality: no two cars crumple exactly alike. */
readonly quality = 0.8 + Math.random() * 0.45;
lane = 0;
s = 0;
next = -1;
lat = 0;
speed = 0;
mode: 'drive' | 'wreck' = 'drive';
layer = 0;
temper = 1;
rude = false;
drifter = false;
look: CarLook;
brakeTimer = 0;
brakeDelay = 0;
cooldown = 0;
laneCooldown = 0;
waitTime = 0;
ghost = 0;
alert = 0;
bubble = '';
bubbleTime = 0;
braking = false;
skidding = false;
wreckTime = 0;
alarm = 0;
fire = 0;
/** Seconds until the fuel tank goes up (0 = no fuse lit). */
fuse = 0;
exploded = false;
engine = 0;
smokeKind: 'none' | 'steam' | 'black' = 'none';
smokeLevel = 0;
vz = 0;
rollVel = 0;
private quarter = 0;
drift = 0;
pathAngle = 0;
health = 100;
/** Pull over and slow down: a siren is coming up behind. */
yieldTime = 0;
/** Broken down at the roadside, hazards on, waiting for roadside assistance. */
broken = false;
brokenTime = 0;
/** Taxis: seconds left on the current fare (0 = for hire), and the person being picked up. */
fare = 0;
hailer: Hailer | null = null;
stopTimer = 0;
/** A tow truck is on its way for this wreck: don't clear it away. */
towClaim = false;
/** A family car (in their turf): wreck it and they take it personally. */
mob = false;
/** The family put a hit on this car. */
marked = false;
/** Stopped (or stopping) at a red light, and waiting in the queue behind one. */
atLight = false;
queued = false;
/** Buses: seconds of driving until the next stop. */
busTimer = 20 + Math.random() * 20;
/** Waved through a police closure: creep past the scene. */
waved = 0;
closedWait = 0;
routeDelay = 5 + Math.random() * 10;
routeTimer = Math.random() * 2;
dmg: CarDamage = { crumple: [0, 0, 0, 0], wheels: [true, true, true, true], flipped: false, z: 0, roll: 0, hazard: false, burnt: 0, glass: false };
constructor(look: CarLook) {
this.look = look;
this.spec = SPECS[look.model];
const m = this.spec.mass * (0.92 + Math.random() * 0.16);
this.body = { x: 0, y: 0, vx: 0, vy: 0, angle: 0, av: 0, mass: m, inertia: boxInertia(m, look.length / 2, look.width / 2), halfL: look.length / 2, halfW: look.width / 2 };
}
get x(): number {
return this.body.x;
}
get y(): number {
return this.body.y;
}
get angle(): number {
return this.body.angle;
}
get wrecked(): boolean {
return this.mode === 'wreck';
}
/** Which quarter-turn of a roll the car is in; changes each time a side or roof hits the road. */
rollQuarter(): number {
return Math.round(this.dmg.roll / (Math.PI / 2));
}
takeQuarter(): boolean {
const q = this.rollQuarter();
if (q === this.quarter) return false;
this.quarter = q;
return true;
}
}
const PALETTES: Record<District, string[]> = {
downtown: ['#3d7dd8', '#e6e6e6', '#2e2f38', '#9aa3ad', '#b33a3a', '#3a6e5a', '#5c4a8a'],
uptown: ['#111318', '#f2f2f2', '#c0c4ca', '#7a1f2b', '#1f3b6e', '#d9b25a'],
hood: ['#7a6a55', '#5e7b8a', '#8a4a3a', '#6b6f3a', '#a08a5a', '#4a4f5a'],
port: ['#9aa3ad', '#e6e6e6', '#5e7b8a', '#b33a3a', '#2e2f38'],
country: ['#8a3b2e', '#3f5e3a', '#c9b28a', '#5b6a7a', '#e6e6e6', '#7a5a3a'],
village: ['#c0392b', '#2e86c1', '#e6e6e6', '#5d6d7e', '#d4ac0d'],
industrial: ['#9aa3ad', '#e6e6e6', '#5e7b8a', '#ca6f1e', '#2e2f38', '#d4ac0d'],
highway: ['#3d7dd8', '#e6e6e6', '#2e2f38', '#b33a3a', '#c0c4ca', '#e07a2f', '#2fb5a8'],
docks: ['#9aa3ad', '#e6e6e6', '#5e7b8a', '#b33a3a', '#2e2f38', '#d4ac0d'],
factory: ['#9aa3ad', '#e6e6e6', '#5e7b8a', '#ca6f1e', '#2e2f38'],
rail: ['#9aa3ad', '#e6e6e6', '#5e7b8a', '#ca6f1e', '#2e2f38'],
suburb: ['#3d7dd8', '#e6e6e6', '#c0c4ca', '#7a1f2b', '#1f3b6e', '#3a6e5a', '#d9b25a'],
commercial: ['#3d7dd8', '#e6e6e6', '#2e2f38', '#9aa3ad', '#b33a3a', '#e07a2f'],
military: ['#4b5320', '#5a5f45', '#3f4a2e', '#6b6f3a', '#2e2f38'],
rural: ['#8a3b2e', '#3f5e3a', '#c9b28a', '#5b6a7a', '#e6e6e6', '#7a5a3a'],
town: ['#c0392b', '#2e86c1', '#e6e6e6', '#5d6d7e', '#d4ac0d'],
};
const MODELS: Record<District, [CarModel, number][]> = {
downtown: [['taxi', 4.5], ['sedan', 4], ['suv', 1.5], ['compact', 2], ['van', 1.5], ['bus', 1], ['motorcycle', 1], ['police', 0.6], ['limo', 0.4]],
uptown: [['taxi', 1.5], ['sports', 4], ['suv', 3], ['sedan', 1.5], ['limo', 1.2], ['police', 0.4], ['icecream', 0.6]],
hood: [['taxi', 1], ['beater', 5], ['sedan', 2], ['compact', 2], ['icecream', 0.8], ['motorcycle', 1], ['police', 0.6], ['pickup', 0.6]],
port: [['van', 3], ['pickup', 2], ['suv', 2], ['bus', 0.5], ['beater', 1]],
highway: [['sedan', 4], ['suv', 3], ['sports', 2], ['taxi', 1], ['van', 1.5], ['pickup', 1.5], ['motorcycle', 1.5], ['bus', 0.6], ['police', 0.5], ['limo', 0.3]],
country: [['pickup', 5], ['beater', 2], ['suv', 2], ['sedan', 1.5], ['motorcycle', 1], ['van', 0.6], ['icecream', 0.3]],
village: [['taxi', 0.6], ['compact', 3], ['sedan', 3], ['pickup', 2], ['van', 1], ['icecream', 0.6], ['police', 0.4]],
industrial: [['van', 3], ['pickup', 3], ['suv', 1], ['beater', 1.5], ['bus', 0.4]],
docks: [['van', 3], ['pickup', 3], ['suv', 1], ['beater', 1], ['bus', 0.3]],
factory: [['van', 3], ['pickup', 3], ['suv', 1], ['beater', 1.5], ['bus', 0.5]],
rail: [['van', 2], ['pickup', 3], ['suv', 1], ['beater', 1]],
suburb: [['suv', 4], ['sedan', 3], ['compact', 2], ['sports', 1], ['van', 1], ['icecream', 0.5], ['police', 0.4], ['bus', 0.3]],
commercial: [['sedan', 4], ['suv', 3], ['compact', 2], ['van', 1.5], ['taxi', 1], ['bus', 0.8], ['police', 0.5], ['motorcycle', 1]],
military: [['suv', 3], ['pickup', 4], ['van', 2], ['bus', 0.5]],
rural: [['pickup', 5], ['beater', 2], ['suv', 2], ['sedan', 1], ['motorcycle', 1], ['van', 0.5]],
town: [['compact', 3], ['sedan', 3], ['pickup', 2], ['van', 1], ['icecream', 0.6], ['police', 0.4], ['taxi', 0.4]],
};
const FIXED_COLORS: Partial<Record<CarModel, string[]>> = {
taxi: ['#f2c230'],
bus: ['#f2b705', '#e6e6e6', '#2f6fc0'],
police: ['#15161c'],
icecream: ['#fdfdfd'],
limo: ['#111318', '#f2f2f2'],
};
const RUDE_COLORS = ['#ff2e88', '#a4e021', '#ff6a2a', '#16c8f0', '#ffd400'];
const MAX_WRECKS = 60;
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)];
const dot = (a: Vec, b: Vec) => a.x * b.x + a.y * b.y;
function angleDelta(a: number, b: number): number {
let d = a - b;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
return d;
}
function weighted<T>(items: [T, number][]): T {
let roll = Math.random() * items.reduce((sum, [, w]) => sum + w, 0);
for (const [item, w] of items) {
roll -= w;
if (roll <= 0) return item;
}
return items[0][0];
}
export function inView(x: number, y: number, view: View, margin: number): boolean {
return x > view.x - margin && x < view.x + view.w + margin && y > view.y - margin && y < view.y + view.h + margin;
}
export class Traffic {
readonly cars: Car[] = [];
readonly wrecks: Car[] = [];
onCrash: ((x: number, y: number, intensity: number, kind: CrashKind, car: Car) => void) | null = null;
/** A car's fuel tank exploded. */
onExplode: ((x: number, y: number, size: number) => void) | null = null;
/** A car was just wrecked (for the A-10's destruction tally). */
onWrecked: ((car: Car) => void) | null = null;
/** A motorcyclist is thrown from the bike. */
onEject: ((car: Car, vx: number, vy: number) => void) | null = null;
/** A rolling car's roof or side slammed into the road. */
onRollHit: ((x: number, y: number, energy: number) => void) | null = null;
onSay: ((car: Car, kind: LineKind) => void) | null = null;
/** Points other drivers should stop for (pedestrians in the road). */
obstacles: Vec[] = [];
/** City service units on the road. */
blockers: Blocker[] = [];
/** People hailing a cab. */
hailers: Hailer[] = [];
/** Roads closed by the police round a scene. */
closures: { x: number; y: number; r: number; layer?: number }[] = [];
private closed = new Set<number>();
private readonly routing: TrafficRouting;
private hazardTimer = 0;
private spawnRetryAt = 0;
/** A taxi stopped for someone: they get in. */
onBoard: ((car: Car, who: Hailer) => void) | null = null;
/** A taxi dropping its fare off at the curb. */
onDropOff: ((car: Car, x: number, y: number) => void) | null = null;
private time = 0;
constructor(
private readonly city: City,
private readonly effects: Effects,
) { this.routing = new TrafficRouting(city.lanes); }
all(): Car[] {
return [...this.cars, ...this.wrecks];
}
setCount(count: number, truck: Truck, view: View, rudeness: number): void {
while (this.cars.length < count && this.time >= this.spawnRetryAt) {
const car = new Car(makeLook('sedan', '#888888', '#888888', false));
if (!this.place(car, truck, view, rudeness, this.cars.length < count / 2)) { this.spawnRetryAt = this.time + 0.5; break; }
this.cars.push(car);
}
for (let i = this.cars.length - 1; i >= 0 && this.cars.length > count; i--) {
if (!inView(this.cars[i].x, this.cars[i].y, view, 30)) this.cars.splice(i, 1);
}
}
clear(): void {
this.cars.length = 0;
this.wrecks.length = 0;
this.routing.clear();
this.closed.clear();
this.hazardTimer = 0;
this.time = 0;
this.spawnRetryAt = 0;
}
update(dt: number, truck: Truck, view: View, rudeness: number): void {
this.time += dt;
this.hazardTimer -= dt;
if (this.hazardTimer <= 0) {
this.hazardTimer = 0.5;
this.routing.refresh([
...this.wrecks.map((w) => ({ x: w.x, y: w.y, r: Math.hypot(w.body.halfL, w.body.halfW) + 4, layer: w.layer, hard: true })),
...this.cars.filter((c) => c.fire > 0 || c.fuse > 0).map((c) => ({ x: c.x, y: c.y, r: 18, layer: c.layer, hard: true })),
...this.effects.roadFires().map((f) => ({ ...f, layer: 0, hard: true })),
...this.closures.map((c) => ({ ...c, layer: c.layer ?? 0, hard: false })),
], this.time);
this.refreshClosures();
}
for (const car of this.cars) {
car.bubbleTime -= dt;
car.alert -= dt;
car.yieldTime -= dt;
const far = Math.hypot(car.x - truck.x, car.y - truck.y);
if (far > (car.broken ? 1700 : 1100) && !inView(car.x, car.y, view, 40)) {
this.place(car, truck, view, rudeness, false);
continue;
}
this.maybeBreakDown(car, dt);
this.think(car, dt, truck);
this.advance(car, dt, truck, view, rudeness);
}
for (const car of this.wrecks) {
car.bubbleTime -= dt;
this.updateWreck(car, dt);
}
this.collidePairs();
// Wrecked cars leave the traffic pool and stay where they died; a fresh car replaces them.
for (let i = this.cars.length - 1; i >= 0; i--) {
const car = this.cars[i];
if (car.mode !== 'wreck') continue;
this.cars.splice(i, 1);
this.wrecks.push(car);
const fresh = new Car(makeLook('sedan', '#888888', '#888888', false));
if (this.place(fresh, truck, view, rudeness, false)) this.cars.push(fresh);
}
while (this.wrecks.length > MAX_WRECKS) {
const idx = this.wrecks.findIndex((w) => !w.towClaim && !inView(w.x, w.y, view, 60));
this.wrecks.splice(idx < 0 ? 0 : idx, 1);
}
}
// --- Spawning.
private place(car: Car, truck: Truck, view: View, rudeness: number, initial: boolean): boolean {
let spawn: { lane: number; s: number } | null = null;
for (let attempt = 0; attempt < 90; attempt++) {
const candidate = this.city.randomSpawn();
const p = poseAt(this.city.lanes[candidate.lane], candidate.s);
const d = Math.hypot(p.x - truck.x, p.y - truck.y);
const ok = initial ? d > 120 && d < 1200 : d > 220 && d < 950 && !inView(p.x, p.y, view, 30);
if (!ok || !this.routing.safeSpawn(candidate.lane, candidate.s, this.time)) continue;
if (this.all().some((o) => o !== car && Math.abs(o.x - p.x) < 30 && Math.abs(o.y - p.y) < 30)) continue;
spawn = candidate;
break;
}
if (!spawn) return false;
const lane = this.city.lanes[spawn.lane];
const pose = poseAt(lane, spawn.s);
const district = this.city.districtAt(pose.x, pose.y);
// The family's black cars cruise their turf.
const mob = district === 'hood' && Math.random() < 0.18;
// Rude drivers are the exception: the setting scales how many, and even they're toned down.
const rude = !mob && Math.random() * 100 < rudeness * 0.35;
const model = mob ? pick<CarModel>(['sedan', 'limo', 'suv']) : weighted(MODELS[district]);
const fixed = FIXED_COLORS[model];
const color = mob ? '#111318' : rude && !fixed ? pick(RUDE_COLORS) : fixed ? pick(fixed) : pick(PALETTES[district]);
const fresh = new Car(makeLook(model, color, pick(['#9a9a9a', '#6f7a83', '#b5a58a', '#2f6fc0', '#c0392b']), rude && !fixed));
fresh.mob = mob;
Object.assign(car, fresh);
(car as { body: Body }).body = fresh.body;
car.lane = spawn.lane;
car.s = spawn.s;
car.body.x = pose.x;
car.body.y = pose.y;
car.body.angle = pose.angle;
car.rude = rude;
car.mob = mob;
car.drifter = (rude && model !== 'bus' && Math.random() < 0.3) || (model === 'sports' && Math.random() < 0.15);
car.temper = rude ? range(0.98, 1.1) : range(0.78, 0.95);
car.cooldown = range(2, 5);
car.next = this.chooseNext(lane, car);
car.speed = lane.speed * car.temper * 0.8;
car.layer = laneLayer(lane, spawn.s);
return true;
}
/** A signal's light for one phase: 9 s green, 2.5 s amber, 1.5 s all-red, then the cross street's turn. */
/** Is the junction's power on? (A blackout leaves the lights dark.) */
powered: (x: number, y: number) => boolean = () => true;
light(signal: Signal, phase: number): 'green' | 'amber' | 'red' | 'dark' {
if (!this.powered(signal.x, signal.y)) return 'dark';
const t = (this.time + signal.offset) % 26;
const local = phase === 0 ? t : (t + 13) % 26;
return local < 9 ? 'green' : local < 11.5 ? 'amber' : 'red';
}
/** Lanes carrying known scene risk, for local police handling. */
private refreshClosures(): void {
this.closed.clear();
// Route decisions use the layer-aware hazard model; closure stop/wave behavior
// below remains local, and must never override a physical disaster barrier.
for (const lane of this.city.lanes) {
if (this.routing.cost(lane.id, this.time, 0, 0, 0) > 0) this.closed.add(lane.id);
}
}
private chooseNext(lane: Lane, car: Car): number {
return this.routing.choose(lane, this.time, car.routeDelay + (car.rude ? 5 : 0), car.x, car.y, -1);
}
// --- Damage.
/**
* Apply a hit to a car. `closing` is the impact speed (px/s); (nx, ny) points from
* the car toward whatever hit it. The outcome depends on the car's model, its
* build quality, what it hit, where it was hit and a roll of the dice.
*/
applyHit(car: Car, closing: number, px: number, py: number, nx: number, ny: number, src: HitSource, kind: CrashKind): number {
if (closing < 6) return 0;
const b = car.body;
const matScale = src.material === 'truck' ? 1.15 : src.material === 'concrete' ? 1.1 : src.material === 'barrier' ? 0.8 : src.material === 'shore' ? 0.9 : 1;
const eff = closing * car.spec.fragility * car.quality * matScale * range(0.85, 1.15);
if (car.mode === 'drive') {
if (eff < 14 && car.dmg.roll === 0) {
car.lat -= (nx * -Math.sin(b.angle) + ny * Math.cos(b.angle)) * Math.min(4, closing * 0.3);
if (car.cooldown <= 0) this.say(car, car.rude ? 'taunt' : 'polite', 3);
this.effects.crash(px, py, 0.1, [car.look.color], { x: -nx, y: -ny }, { material: src.material, glass: false });
return 0.1;
}
this.toWreck(car);
if (car.look.model === 'motorcycle' && !car.dmg.riderGone) {
car.dmg.riderGone = true;
this.onEject?.(car, b.vx - nx * closing * 0.8, b.vy - ny * closing * 0.8);
}
}
const c = Math.cos(b.angle);
const s = Math.sin(b.angle);
const lx = (px - b.x) * c + (py - b.y) * s;
const ly = -(px - b.x) * s + (py - b.y) * c;
const zone = Math.abs(lx) / b.halfL > Math.abs(ly) / b.halfW ? (lx > 0 ? 0 : 1) : ly < 0 ? 2 : 3;
const crumple = car.dmg.crumple;
crumple[zone] = Math.min(zone < 2 ? 7 : 4, crumple[zone] + eff * range(0.025, 0.045));
car.health -= eff * 0.6;
const glassBroke = !car.dmg.glass && eff > range(18, 34);
if (glassBroke) car.dmg.glass = true;
// Engine damage decides the smoke: none, radiator steam, or an oily black plume.
car.engine += eff * (zone === 0 ? range(0.7, 1.1) : range(0.1, 0.35));
const smokeBefore = car.smokeKind;
if (car.engine > 75 || car.fire > 0) car.smokeKind = 'black';
else if (car.engine > 35 && car.smokeKind === 'none') car.smokeKind = Math.random() < 0.7 ? 'steam' : 'black';
if (car.smokeKind !== 'none') car.smokeLevel = Math.min(1, car.smokeLevel + eff / 140);
// Wheels torn off near the impact.
let wheels = 0;
if (eff > range(40, 65)) {
const order = [0, 1, 2, 3].sort((i, j) => {
const wx = (k: number) => (k < 2 ? 1 : -1) * b.halfL * 0.6;
const wy = (k: number) => (k % 2 === 0 ? -1 : 1) * b.halfW;
return Math.hypot(wx(i) - lx, wy(i) - ly) - Math.hypot(wx(j) - lx, wy(j) - ly);
});
const count = Math.min(4, Math.floor((eff - 30) / range(30, 55)));
for (const w of order) {
if (wheels >= count) break;
if (!car.dmg.wheels[w]) continue;
car.dmg.wheels[w] = false;
wheels++;
}
}
// Sideways shove starts a roll; the harder and higher the hit, the more it spins.
const side = { x: -s, y: c };
const lateral = -(nx * side.x + ny * side.y);
const rollKick = (lateral * eff * range(0.012, 0.03)) / car.spec.stability;
car.rollVel += rollKick;
if (src.material === 'truck' && eff > range(90, 140) && car.dmg.z <= 0) {
// Scooped up by the truck's nose: launched into the air.
car.vz = 30 + eff * range(0.35, 0.65);
car.dmg.z = 0.5;
car.rollVel += (Math.random() < 0.5 ? -1 : 1) * range(4, 12) / car.spec.stability;
}
// Fire and fuel-tank explosions get likelier with speed (and with flimsy cars).
if (eff > 55 && car.fire <= 0 && (eff > 120 || Math.random() < car.spec.fire * Math.min(1, (eff - 55) / 70))) car.fire = range(10, 25);
if (eff > 150 && !car.exploded) car.fuse = Math.min(car.fuse > 0 ? car.fuse : 9, range(0.05, 0.6));
if (!car.exploded && car.fuse <= 0 && eff > 90 && Math.random() < car.spec.explode * Math.min(1.6, (eff - 90) / 60)) {
car.fuse = eff > 160 ? 0.05 : range(1, 4);
car.fire = Math.max(car.fire, 8);
}
if (eff > 18 && car.alarm <= 0 && Math.random() < 0.7) car.alarm = range(18, 35);
if (eff > 15 && car.bubbleTime < 0.5) this.say(car, 'wreck', 0);
const k = closing / 100;
const colors = [car.look.color, car.look.color, '#1b1b1f', '#c9ccd1', src.color];
this.effects.crash(px, py, (eff / 100) * range(0.85, 1.2), colors, { x: -nx, y: -ny }, {
wheels,
panels: eff > range(55, 90) ? 1 + Math.floor(Math.random() * 2) : 0,
material: src.material,
glass: glassBroke,
smoke: car.smokeKind !== smokeBefore ? car.smokeKind : 'none',
flavor: car.look.model,
});
this.onCrash?.(px, py, k, kind, car);
return k;
}
private toWreck(car: Car): void {
car.mode = 'wreck';
this.onWrecked?.(car);
car.wreckTime = 0;
car.dmg.hazard = false;
car.bubbleTime = 0;
}
/** The fuel tank goes up: fireball, shockwave and a burnt-out shell. */
private explode(car: Car): void {
const b = car.body;
car.exploded = true;
car.fuse = 0;
car.fire = Math.max(car.fire, 25);
car.smokeKind = 'black';
car.smokeLevel = 1;
car.dmg.burnt = Math.max(car.dmg.burnt, 0.6);
car.dmg.glass = true;
const size = range(0.8, 1.3);
this.effects.fireball(b.x, b.y, size);
this.effects.crash(b.x, b.y, 1.2, [car.look.color, '#1b1b1f'], { x: 0, y: 0 }, { panels: 2, wheels: car.dmg.wheels.filter(Boolean).length > 1 ? 1 : 0, glass: true, material: 'car' });
// The blast throws the car and anything near it.
car.vz = range(40, 90);
car.dmg.z = Math.max(car.dmg.z, 0.5);
car.rollVel += range(-8, 8);
b.av += range(-4, 4);
for (const o of this.all()) {
if (o === car || o.layer !== car.layer) continue;
const dx = o.x - b.x;
const dy = o.y - b.y;
const d = Math.hypot(dx, dy);
if (d > 50 || d < 0.1) continue;
const push = (1 - d / 50) * 120 * size;
if (o.mode === 'drive') this.syncBody(o);
o.body.vx += (dx / d) * push;
o.body.vy += (dy / d) * push;
this.applyHit(o, push, o.x - (dx / d) * o.body.halfW, o.y - (dy / d) * o.body.halfW, -dx / d, -dy / d, { material: 'car', color: '#1b1b1f' }, 'pileup');
}
this.onExplode?.(b.x, b.y, size);
}
// --- Wreck physics: drifting, rolling over and burning.
private updateWreck(car: Car, dt: number): void {
const b = car.body;
car.wreckTime += dt;
car.alarm = Math.max(0, car.alarm - dt);
car.dmg.hazard = car.alarm > 0 && Math.floor(car.wreckTime * 2.5) % 2 === 0;
if (car.fuse > 0) {
car.fuse -= dt;
if (car.fuse <= 0) this.explode(car);
}
if (car.fire > 0) {
car.fire -= dt;
car.dmg.burnt = Math.min(1, car.dmg.burnt + dt * 0.06);
if (Math.random() < dt * 24) this.effects.puff(b.x + range(-3, 3), b.y + range(-3, 3), false, true, range(1, 1.9));
if (Math.random() < dt * 14) this.effects.puff(b.x, b.y, true, false, range(1.6, 2.8));
} else if (car.smokeKind !== 'none' && car.smokeLevel > 0.02) {
if (Math.random() < dt * 9 * car.smokeLevel) {
if (car.smokeKind === 'steam') this.effects.steam(b.x + Math.cos(b.angle) * b.halfL * 0.6, b.y + Math.sin(b.angle) * b.halfL * 0.6, 0.8 + car.smokeLevel);
else this.effects.puff(b.x + range(-2, 2), b.y + range(-2, 2), true, false, 1 + car.smokeLevel);
}
// Radiator steam runs out quickly; oil smoke lingers.
car.smokeLevel = Math.max(0, car.smokeLevel - dt * (car.smokeKind === 'steam' ? 0.05 : 0.008));
}
const steps = 3;
const h = dt / steps;
const G = GRAVITY;
for (let i = 0; i < steps; i++) {
const airborne = car.dmg.z > 0 || car.vz > 0;
const r = car.dmg.roll;
if (airborne) {
car.vz -= 340 * h;
car.dmg.z += car.vz * h;
if (car.dmg.z <= 0) {
const landing = Math.abs(car.vz);
car.dmg.z = 0;
car.vz = 0;
if (landing > 30) this.slam(car, landing);
}
} else {
const upright = Math.cos(r) > 0.7;
const sideX = -Math.sin(b.angle);
const sideY = Math.cos(b.angle);
const vl = b.vx * sideX + b.vy * sideY;
if (upright) {
// Locked or missing wheels: low sideways grip, so the car drifts and spins.
const missing = car.dmg.wheels.filter((w) => !w).length;
const mu = 0.55 + missing * 0.12;
const n = (b.mass * G) / 2;
for (const ax of [b.halfL * 0.6, -b.halfL * 0.6]) {
const p = { x: b.x + Math.cos(b.angle) * ax, y: b.y + Math.sin(b.angle) * ax };
axleGrip(b, p.x, p.y, b.angle, mu * n * h);
}
// Tripping: sliding sideways faster than the car's stability can take tips it over.
const tip = 22 * car.spec.stability;
if (Math.abs(vl) > tip && Math.abs(r) < 0.6) car.rollVel += Math.sign(vl) * (Math.abs(vl) - tip) * 0.018 / car.spec.stability;
const v = Math.hypot(b.vx, b.vy);
if (v > 0) {
const dv = Math.min(v, mu * 0.6 * G * h);
b.vx -= (b.vx / v) * dv;
b.vy -= (b.vy / v) * dv;
}
b.av *= Math.exp(-1.1 * h);
car.skidding = v > 12;
if (missing && v > 25 && Math.random() < 0.5) this.effects.scrape(b.x, b.y, b.vx, b.vy);
} else {
// On its side or roof: metal on asphalt, lots of friction and sparks.
const v = Math.hypot(b.vx, b.vy);
if (v > 0) {
const dv = Math.min(v, 0.85 * G * h);
b.vx -= (b.vx / v) * dv;
b.vy -= (b.vy / v) * dv;
}
b.av *= Math.exp(-2.5 * h);
car.skidding = false;
if (v > 18 && Math.random() < 0.7) this.effects.scrape(b.x, b.y, b.vx, b.vy);
// A rolling car keeps tumbling while it's still moving sideways.
if (Math.abs(car.rollVel) > 1.5) car.rollVel += Math.sign(car.rollVel) * Math.min(Math.abs(vl), 60) * 0.004;
}
// Gravity settles the body on its wheels, a side or its roof.
car.rollVel += -Math.sin(4 * r) * 18 * h;
car.rollVel *= Math.exp(-1.6 * h);
if (Math.abs(car.rollVel) < 0.25 && Math.abs(Math.sin(2 * r)) < 0.05) {
car.rollVel = 0;
car.dmg.roll = Math.round(r / (Math.PI / 2)) * (Math.PI / 2);
}
}
car.dmg.roll += car.rollVel * h;
if (car.takeQuarter() && !airborne) this.slam(car, Math.abs(car.rollVel) * 9 + Math.hypot(b.vx, b.vy) * 0.3);
integrate(b, h);
this.collideWorld(car);
}
car.dmg.flipped = Math.cos(car.dmg.roll) < 0;
car.speed = forwardSpeed(b);
}
/** A side, roof or landing hits the road during a roll. */
private slam(car: Car, energy: number): void {
if (energy < 12) return;
const b = car.body;
car.health -= energy * 0.3;
const zone = 2 + Math.floor(Math.random() * 2);
car.dmg.crumple[zone] = Math.min(4, car.dmg.crumple[zone] + energy * 0.02);
if (energy > 30) car.dmg.glass = true;
let wheels = 0;
if (energy > 45 && Math.random() < 0.5) {
const i = car.dmg.wheels.findIndex(Boolean);
if (i >= 0) {
car.dmg.wheels[i] = false;
wheels = 1;
}
}
this.effects.crash(b.x, b.y, energy / 160, [car.look.color, '#1b1b1f'], { x: b.vx / 200, y: b.vy / 200 }, { wheels, glass: energy > 30, material: 'ground' });
this.onRollHit?.(b.x, b.y, energy);
}
private collideWorld(car: Car): void {
const b = car.body;
for (const d of [b.halfL - 4.5, -(b.halfL - 4.5)]) {
const px = b.x + Math.cos(b.angle) * d;
const py = b.y + Math.sin(b.angle) * d;
const push = this.city.resolveCircle(px, py, b.halfW, car.layer);
if (!push.x && !push.y) continue;
const material = this.city.lastMaterial;
const l = Math.hypot(push.x, push.y);
const cx = px - (push.x / l) * b.halfW;
const cy = py - (push.y / l) * b.halfW;
const impact = resolveStatic(b, push, cx, cy, material === 'barrier' ? 0.15 : 0.3, material === 'barrier' ? 0.2 : 0.5);
if (impact > 20 && car.mode === 'wreck') this.applyHit(car, impact * 0.85, cx, cy, -push.x / l, -push.y / l, { material, color: material === 'barrier' ? '#d4d0c6' : '#8d9096' }, 'wall');
}
}
/** Car-on-car contacts: wrecks slam into traffic and each other. */
private collidePairs(): void {
const list = this.all();
for (let i = 0; i < list.length; i++) {
const a = list[i];
for (let j = i + 1; j < list.length; j++) {
const b = list[j];
if (a.mode === 'drive' && b.mode === 'drive') continue;
if (a.layer !== b.layer || a.ghost > 0 || b.ghost > 0) continue;
if (Math.abs(a.x - b.x) > 22 || Math.abs(a.y - b.y) > 22) continue;
if (Math.abs(a.dmg.z - b.dmg.z) > 6) continue;
const c = boxContact(a.body, b.body);
if (!c) continue;
// Careful drivers creep into a wreck that's lying still and stop: a bump, not a pile-up.
const driver = a.mode === 'drive' ? a : b.mode === 'drive' ? b : null;
const other = driver === a ? b : a;
if (driver && Math.hypot(other.body.vx, other.body.vy) < 15) {
this.bump(driver, c.depth);
continue;
}
if (a.mode === 'drive') this.syncBody(a);
if (b.mode === 'drive') this.syncBody(b);
const hit = resolveContact(a.body, b.body, c, 0.15, 0.4);
if (hit.closing > 8) {
this.applyHit(a, hit.closing, c.px, c.py, c.nx, c.ny, { material: 'car', color: b.look.color }, 'pileup');
this.applyHit(b, hit.closing, c.px, c.py, -c.nx, -c.ny, { material: 'car', color: a.look.color }, 'pileup');
}
}
}
}
/** A driving car nudged something at low speed: back off a touch and wait. */
bump(car: Car, depth: number): void {
car.s = Math.max(0, car.s - depth - 0.5);
car.speed = 0;
car.brakeTimer = Math.max(car.brakeTimer, 1.2);
}
/** Give a lane-following car a real velocity before it takes part in a collision. */
syncBody(car: Car): void {
const b = car.body;
b.vx = Math.cos(b.angle) * car.speed;
b.vy = Math.sin(b.angle) * car.speed;
}
// --- Driving.
private think(car: Car, dt: number, truck: Truck): void {
const lane = this.city.lanes[car.lane];
car.cooldown -= dt;
car.laneCooldown -= dt;
car.brakeTimer -= dt;
car.ghost -= dt;
if (car.brakeDelay > 0) {
car.brakeDelay -= dt;
if (car.brakeDelay <= 0) car.brakeTimer = 0.8;
}
car.routeTimer -= dt;
if (car.routeTimer <= 0 && lane.kind !== 'connector') {
car.routeTimer = 1.5 + Math.random();
car.next = this.routing.choose(lane, this.time, car.routeDelay + (car.rude ? 5 : 0), car.x, car.y, car.next);
}
const disasterStop = this.routing.stopAt(car.lane, car.s, this.time);
if (disasterStop < Infinity) { car.ghost = 0; car.waved = 0; }
const f = { x: Math.cos(car.angle), y: Math.sin(car.angle) };
const side = { x: -f.y, y: f.x };
let gap = disasterStop - car.s - car.body.halfL;
let carGap = Infinity;
let aheadCar: Car | null = null;
let blockedByTruck = false;
let hazard = disasterStop < Infinity;
// Careful drivers look further ahead and keep a wider berth.
const look = lane.kind === 'highway' ? 150 : car.rude ? 75 : 100;
if (truck.layer === car.layer) {
for (const c of truck.circles()) {
const rel = { x: c.x - car.x, y: c.y - car.y };
const along = dot(rel, f);
if (along > 0 && along < look && Math.abs(dot(rel, side)) < c.r + 7 && along - c.r < gap) {
gap = along - c.r;
blockedByTruck = true;
}
}
}
if (car.ghost <= 0) {
for (const o of [...this.cars, ...this.wrecks]) {
if (o === car || o.layer !== car.layer || Math.abs(o.x - car.x) > look || Math.abs(o.y - car.y) > look) continue;
const rel = { x: o.x - car.x, y: o.y - car.y };
const along = dot(rel, f);
// A wreck can lie at any angle: use its real footprint across our path.
const ra = o.angle - car.angle;
const wide = o.mode === 'wreck' ? Math.abs(o.body.halfL * Math.sin(ra)) + Math.abs(o.body.halfW * Math.cos(ra)) + car.body.halfW + 1.5 : 9;
const deep = o.mode === 'wreck' ? Math.abs(o.body.halfL * Math.cos(ra)) + Math.abs(o.body.halfW * Math.sin(ra)) : o.body.halfL;
if (along > 0 && along < look + deep && Math.abs(dot(rel, side)) < wide) {
const g = along - (deep + car.body.halfL + 2);
carGap = Math.min(carGap, g);
if (o.mode === 'wreck' || o.broken || o.stopTimer > 0) hazard = true;
if (g < gap) {
gap = g;
blockedByTruck = false;
aheadCar = o;
}
}
}
// Service units: pass parked ones if there's room, otherwise wait (then squeeze by like any jam).
for (const o of this.blockers) {
if (o.layer !== car.layer || Math.abs(o.body.x - car.x) > look || Math.abs(o.body.y - car.y) > look) continue;
const rel = { x: o.body.x - car.x, y: o.body.y - car.y };
const along = dot(rel, f);
if (along > 0 && along < look + o.body.halfL && Math.abs(dot(rel, side)) < o.body.halfW + 5) {
const g = along - (o.body.halfL + car.body.halfL + 2);
if (g < gap) {
gap = g;
blockedByTruck = false;
if (o.parked) hazard = true;
}
}
}
}
// People and animals: never ghost through them. Spot them early and leave room.
if (car.layer === 0) {
for (const p of this.obstacles) {
const rel = { x: p.x - car.x, y: p.y - car.y };
const along = dot(rel, f);
if (along > 0 && along < 70 && Math.abs(dot(rel, side)) < 10 && along - 10 < gap) {
gap = along - 10;
hazard = true;
blockedByTruck = true;
}
}
}
// Police closures: detour, switch lanes, or wait at the line until an officer waves us past.
if (this.closed.size && car.waved <= 0) {
for (const c of this.closures) {
if ((c.layer ?? 0) !== car.layer) continue;
const rel = { x: c.x - car.x, y: c.y - car.y };
const d = Math.hypot(rel.x, rel.y);
if (d > 160) continue;
if (d < c.r) {
car.waved = 6;
car.ghost = 6;
break;
}
const heading = this.closed.has(car.lane) || (car.next >= 0 && this.closed.has(car.next) && lane.len - car.s < 70);
const along = dot(rel, f);
if (!heading || along <= 0) continue;
if (car.laneCooldown <= 0) {
car.laneCooldown = 1.5;
const nb = [lane.left, lane.right].find((n) => n >= 0 && !this.closed.has(n));
if (nb !== undefined && this.laneClear(nb, (car.s / lane.len) * this.city.lanes[nb].len, car, truck)) this.changeLane(car, nb);
}
const g = along - c.r - car.body.halfL;
if (g < gap) {
gap = g;
blockedByTruck = true;
hazard = true;
}
if (car.speed < 2 && g < 25) {
car.closedWait += dt;
if (car.closedWait > 4.5) {
car.closedWait = 0;
car.waved = 8;
car.ghost = 8;
}
}
}
}
if (disasterStop < Infinity) { car.ghost = 0; car.waved = 0; }
const turnSpeed = car.drifter ? 36 : 26;
let target = lane.speed * car.temper;
if (lane.turn) target = Math.min(target, turnSpeed);
const upcoming = car.next >= 0 ? this.city.lanes[car.next] : null;
if (upcoming) {
const nextSpeed = upcoming.turn ? turnSpeed : upcoming.speed * car.temper;
target = Math.min(target, nextSpeed + (lane.len - car.s) * 1.2);
}
if (car.brakeTimer > 0) target = 0;
// A siren is coming: pull right over and (nearly) stop until it's by.
if (car.yieldTime > 0) target = Math.min(target, 4);
if (car.waved > 0) {
car.waved -= dt;
target = Math.min(target, 14);
}
if (car.broken) {
target = 0;
car.brokenTime += dt;
blockedByTruck = true;
}
if (car.look.model === 'taxi') target = Math.min(target, this.taxi(car, lane, dt, f, side));
if (car.look.model === 'bus') target = Math.min(target, this.busStop(car, lane, dt));
if (gap < Infinity) target = Math.min(target, Math.max(0, (gap - 15) * (lane.kind === 'highway' ? 1.4 : 1.7)));
// Traffic lights: stop at the line on red, and on amber if there's room to stop.
car.atLight = false;
const sig = this.city.laneSignal.get(car.lane);
if (sig && car.waved <= 0) {
const toLine = lane.len - car.s - 3;
const light = this.light(sig.signal, sig.phase);
const canStop = toLine > (car.speed * car.speed) / (2 * 160);
if (toLine > -2 && (light === 'red' || (light === 'amber' && canStop))) {
car.atLight = true;
target = Math.min(target, Math.max(0, toLine * 1.6));
} else if (light === 'dark' && toLine > -2 && toLine < 45) {
// A dead signal: treat the junction as an all-way stop and creep across.
target = Math.min(target, Math.max(10, toLine * 0.9));
}
}
// Yield at junctions: wait if someone is already crossing where we're headed.
// Don't block the box: only go into a junction if there's room on the far side.
let boxFull = false;
if (upcoming?.kind === 'connector' && lane.kind !== 'highway' && car.waved <= 0) {
const toEnd = lane.len - car.s;
if (toEnd < 30) {
const exit = upcoming.next[0];
boxFull = this.cars.some((o) => o !== car && ((o.lane === exit && o.s < o.body.halfL * 2 + 10 && o.speed < 4) || (o.lane === upcoming.id && o.speed < 3)));
if (boxFull) target = Math.min(target, Math.max(0, (toEnd - 3) * 1.6));
}
}
if (!sig && upcoming?.kind === 'connector' && lane.len - car.s < 22 && lane.kind !== 'highway') {
const mid = upcoming.pts[Math.floor(upcoming.pts.length / 2)];
const crossing = this.cars.some((o) => {
if (o === car || o.mode !== 'drive' || this.city.lanes[o.lane].kind !== 'connector') return false;
if (Math.abs(o.x - mid.x) > 26 || Math.abs(o.y - mid.y) > 26) return false;
let da = Math.abs(o.angle - car.angle) % (Math.PI * 2);
if (da > Math.PI) da = Math.PI * 2 - da;
return da > 0.5;
});
if (crossing) target = Math.min(target, Math.max(0, (lane.len - car.s - 6) * 2));
}
// Waiting in line at a light (or behind someone who is) is normal: no squeezing through.
car.queued = car.atLight || boxFull || (!!aheadCar && aheadCar.queued && !aheadCar.broken && aheadCar.speed < 4);
if (car.speed < 4 && target < 4) {
car.waitTime += dt;
// Squeeze past a jam: soon if it isn't a proper queue, eventually even if it is (never through our own red).
if (disasterStop === Infinity && ((car.waitTime > 8 && !blockedByTruck && !car.queued) || (car.waitTime > 25 && !car.atLight))) {
car.ghost = 1.5;
car.waitTime = 0;
}
if (blockedByTruck && !car.queued && car.waitTime > 2.5 && car.cooldown <= 0) this.say(car, car.rude ? 'taunt' : 'waiting', car.rude ? 3 : 8);
} else {
car.waitTime = 0;
}
const before = car.speed;
car.speed += Math.max(-(hazard ? 330 : 260) * dt, Math.min(52 * dt, target - car.speed));
const decel = (before - car.speed) / Math.max(dt, 1e-3);
car.braking = decel > 40;
car.skidding = (decel > 170 && car.speed > 18) || (lane.turn && car.drifter && car.speed > 40);
if (lane.kind === 'highway' && carGap < 80 && car.laneCooldown <= 0 && car.speed < lane.speed * car.temper - 10) this.tryOvertake(car, lane, truck);
// Steer around a wreck, a person or an animal if the next lane is free.
else if (hazard && gap < 45 && car.laneCooldown <= 0 && lane.kind !== 'connector' && (lane.left >= 0 || lane.right >= 0)) this.tryOvertake(car, lane, truck);
if (car.rude && (lane.kind === 'street' || lane.kind === 'highway')) this.beRude(car, lane, truck, f, side, dt);
}
private laneClear(laneId: number, s: number, self: Car, truck: Truck): boolean {
const p = poseAt(this.city.lanes[laneId], s);
for (const o of this.all()) if (o !== self && Math.abs(o.x - p.x) < 32 && Math.abs(o.y - p.y) < 32) return false;
return truck.circles().every((c) => Math.hypot(c.x - p.x, c.y - p.y) > c.r + 14);
}
private tryOvertake(car: Car, lane: Lane, truck: Truck): void {
for (const nb of [lane.left, lane.right]) {
if (nb < 0) continue;
const s = (car.s / lane.len) * this.city.lanes[nb].len;
if (this.routing.safeSpawn(nb, s, this.time) && this.laneClear(nb, s, car, truck)) {
this.changeLane(car, nb);
car.laneCooldown = 3;
return;
}
}
car.laneCooldown = 1;
}
private changeLane(car: Car, target: number): void {
const from = this.city.lanes[car.lane];
const to = this.city.lanes[target];
const s = (car.s / from.len) * to.len;
const p = poseAt(to, s);
const side = { x: -Math.sin(p.angle), y: Math.cos(p.angle) };
car.lat = (car.x - p.x) * side.x + (car.y - p.y) * side.y;
car.lane = target;
car.s = s;
car.next = this.chooseNext(to, car);
}
private beRude(car: Car, lane: Lane, truck: Truck, f: Vec, side: Vec, dt: number): void {
if (truck.layer !== car.layer) return;
const td = { x: Math.cos(truck.angle), y: Math.sin(truck.angle) };
const aligned = dot(td, f) > 0.85;
const rel = { x: truck.x - car.x, y: truck.y - car.y };
const along = dot(rel, f);
const lat = dot(rel, side);
if (car.cooldown <= 0 && aligned && truck.speed > 30 && Math.abs(car.lat) < 1) {
for (const nb of [lane.left, lane.right]) {
if (nb < 0) continue;
const q = poseAt(this.city.lanes[nb], (car.s / lane.len) * this.city.lanes[nb].len);
const truckToLane = (truck.x - q.x) * side.x + (truck.y - q.y) * side.y;
// Only cut in when there's actually room ahead of the truck.
if (Math.abs(truckToLane) < 8 && along > -36 && along < -8 && lane.len - car.s > 70 && Math.random() < 0.15) {
this.changeLane(car, nb);
car.brakeDelay = 0.9;
this.say(car, 'cutoff', 14);
return;
}
}
if (Math.abs(lat) < 8 && along < -30 && along > -75 && truck.speed > 40 && Math.random() < 0.06) {
car.brakeTimer = 0.7;
this.say(car, 'brakecheck', 18);
return;
}
car.cooldown = 9;
}
if (car.cooldown <= 0 && Math.hypot(rel.x, rel.y) < 60 && Math.random() < dt * 0.5) this.say(car, 'taunt', 4);
}
say(car: Car, kind: LineKind, cooldown: number): void {
car.bubble = pickLine(kind, this.city.lanes[car.lane]?.district ?? 'highway');
car.bubbleTime = 2.2;
car.cooldown = Math.max(car.cooldown, cooldown);
car.alert = 0.8;
this.onSay?.(car, kind);
}
/** Now and then a car breaks down (beaters most of all) and pulls over with its hazards on. */
private maybeBreakDown(car: Car, dt: number): void {
if (car.broken) {
car.dmg.hazard = Math.floor(this.time * 2.5) % 2 === 0;
if (Math.random() < dt * 1.5) this.effects.steam(car.x + Math.cos(car.angle) * car.body.halfL * 0.6, car.y + Math.sin(car.angle) * car.body.halfL * 0.6, 0.6);
return;
}
const lane = this.city.lanes[car.lane];
if (lane.kind === 'connector' || lane.turn || car.look.model === 'motorcycle' || car.fare > 0 || lane.len - car.s < 45 || car.s < 15) return;
const rate = car.look.model === 'beater' ? 1 / 900 : 1 / 5000;
if (Math.random() >= dt * rate) return;
car.broken = true;
car.brokenTime = 0;
car.bubble = pick(['UGH, NOT AGAIN', 'IS THAT SMOKE?', 'COME ON, BABY...', 'CALLING ROADSIDE']);
car.bubbleTime = 2.4;
}
/** Broken-down cars waiting for help. */
brokenDown(): Car[] {
return this.cars.filter((c) => c.broken && c.mode === 'drive');
}
/** Roadside assistance got it going again. */
repair(car: Car): void {
if (!car.broken) return;
car.broken = false;
car.dmg.hazard = false;
car.bubble = pick(['YOU ARE A LIFESAVER!', 'PURRS LIKE A KITTEN', 'THANKS, MECHANIC!']);
car.bubbleTime = 2.4;
}
/**
* Cabs for hire pull over for anyone hailing one ahead on their side of the road,
* carry the fare a while, then drop them off at a curb. Returns the cab's speed cap.
*/
private taxi(car: Car, lane: Lane, dt: number, f: Vec, side: Vec): number {
if (car.mode !== 'drive' || car.broken || car.layer !== 0) return Infinity;
if (car.stopTimer > 0) {
car.stopTimer -= dt;
if (car.stopTimer <= 0) {
if (car.hailer) {
this.onBoard?.(car, car.hailer);
car.hailer = null;
car.fare = range(25, 70);
car.bubble = pick(['WHERE TO?', 'HOP IN!', 'METER IS RUNNING']);
car.bubbleTime = 2;
} else {
this.onDropOff?.(car, car.x - Math.sin(car.angle) * (car.body.halfW + 5), car.y + Math.cos(car.angle) * (car.body.halfW + 5));
car.fare = -range(10, 25);
car.bubble = pick(['HAVE A NICE DAY!', 'KEEP THE CHANGE?', 'FIVE STARS PLEASE']);
car.bubbleTime = 2;
}
}
return 0;
}
if (car.fare > 0) {
car.fare -= dt;
if (car.fare <= 0 && lane.kind === 'street' && !lane.turn && lane.len - car.s > 30) {
car.stopTimer = 1.6;
return 0;
}
if (car.fare <= 0) car.fare = 2;
return Infinity;
}
if (car.fare < 0) {
car.fare = Math.min(0, car.fare + dt);
return Infinity;
}
if (lane.kind !== 'street') return Infinity;
if (car.hailer && !this.hailers.includes(car.hailer)) car.hailer = null;
if (!car.hailer) {
for (const h of this.hailers) {
const rel = { x: h.x - car.x, y: h.y - car.y };
const along = dot(rel, f);
const lat = dot(rel, side);
if (along > 12 && along < 70 && lat > 3 && lat < 18 && !this.cars.some((o) => o !== car && o.hailer === h)) {
car.hailer = h;
break;
}
}
}
if (!car.hailer) return Infinity;
const along = dot({ x: car.hailer.x - car.x, y: car.hailer.y - car.y }, f);
if (along < 4 && car.speed < 3) {
car.stopTimer = 1.4;
return 0;
}
if (along < -10) {
car.hailer = null;
return Infinity;
}
car.stopTimer = 0;
return Math.max(0, along * 1.6);
}
/** City buses pull in at stops along the streets to let people off and take on anyone waiting. */
private busStop(car: Car, lane: Lane, dt: number): number {
if (car.mode !== 'drive' || car.broken || car.layer !== 0) return Infinity;
if (car.stopTimer > 0) {
car.stopTimer -= dt;
if (car.stopTimer <= 0) {
const off = 1 + Math.floor(Math.random() * 3);
for (let i = 0; i < off; i++) this.onDropOff?.(car, car.x - Math.sin(car.angle) * (car.body.halfW + 5) + Math.cos(car.angle) * (i * 4 - 4), car.y + Math.cos(car.angle) * (car.body.halfW + 5) + Math.sin(car.angle) * (i * 4 - 4));
for (const h of this.hailers) if (Math.hypot(h.x - car.x, h.y - car.y) < 30) this.onBoard?.(car, h);
car.busTimer = range(25, 45);
}
return 0;
}
if (lane.kind !== 'street' || lane.turn) return Infinity;
car.busTimer -= dt;
if (car.busTimer <= 0 && lane.len - car.s > 40 && car.s > 20) {
car.stopTimer = range(2.5, 4);
car.bubble = pick(['NEXT STOP!', 'MIND THE DOORS', 'EXACT CHANGE ONLY']);
car.bubbleTime = 1.8;
return 0;
}
return Infinity;
}
/** A city service unit written off in a crash joins the other wrecks. */
adoptWreck(look: CarLook, body: Body, layer: number, dmg: CarDamage): Car {
const car = new Car(look);
Object.assign(car.body, { x: body.x, y: body.y, vx: body.vx, vy: body.vy, angle: body.angle, av: body.av });
car.layer = layer;
car.mode = 'wreck';
car.dmg = { ...dmg, crumple: [...dmg.crumple], wheels: [...dmg.wheels], hazard: false };
car.health = 0;
this.wrecks.push(car);
return car;
}
/** A tow truck hauls a wreck away. */
removeWreck(car: Car): void {
const i = this.wrecks.indexOf(car);
if (i >= 0) this.wrecks.splice(i, 1);
}
/** Burning wrecks near a point (for the fire brigade). */
burningNear(x: number, y: number, r: number): Vec[] {
return this.wrecks.filter((w) => (w.fire > 0 || w.fuse > 0) && Math.hypot(w.x - x, w.y - y) < r).map((w) => ({ x: w.x, y: w.y }));
}
/** Hose down burning wrecks near a point. */
extinguish(x: number, y: number, r: number): void {
for (const w of this.wrecks) {
if (Math.hypot(w.x - x, w.y - y) > r) continue;
w.fire = 0;
w.fuse = 0;
if (w.smokeKind === 'black') w.smokeKind = 'steam';
w.smokeLevel = Math.min(w.smokeLevel, 0.5);
}
}
/** Nearby drivers react to the truck's air horn. */
hearHorn(x: number, y: number): void {
for (const car of this.cars) {
if (Math.hypot(car.x - x, car.y - y) > 110 || car.cooldown > 1) continue;
if (Math.random() < 0.6) this.say(car, car.rude ? 'taunt' : 'polite', 4);
}
}
private advance(car: Car, dt: number, truck: Truck, view: View, rudeness: number): void {
let lane = this.city.lanes[car.lane];
const b = car.body;
const prevAngle = b.angle;
car.s += car.speed * dt;
while (car.s >= lane.len) {
const next = car.next >= 0 ? car.next : this.chooseNext(lane, car);
if (next < 0) {
if (!this.place(car, truck, view, rudeness, false)) { car.s = lane.len; car.speed = 0; }
return;
}
car.s -= lane.len;
car.lane = next;
lane = this.city.lanes[next];
car.next = this.chooseNext(lane, car);
}
const p = poseAt(lane, car.s);
const prevLat = car.lat;
// Pulled over to the right (broken down, letting a siren by, or a taxi at the curb).
const pull = (car.broken || car.yieldTime > 0 || car.stopTimer > 0) && lane.kind !== 'connector' ? (lane.kind === 'highway' ? 5 : 4) : 0;
if (pull) car.lat += (pull - car.lat) * Math.min(1, dt * 2.5);
else car.lat *= Math.exp(-4.5 * dt);
if (Math.abs(car.lat) < 0.05) car.lat = 0;
const sx = -Math.sin(p.angle);
const sy = Math.cos(p.angle);
b.x = p.x + sx * car.lat;
b.y = p.y + sy * car.lat;
const latSpeed = (car.lat - prevLat) / Math.max(dt, 1e-3);
// Drifters hang the tail out through corners.
const turnRate = lane.turn ? angleDelta(p.angle, car.pathAngle) / Math.max(dt, 1e-3) : 0;
car.drift += ((car.drifter && lane.turn && car.speed > 38 ? Math.max(-0.7, Math.min(0.7, turnRate * 0.35)) : 0) - car.drift) * Math.min(1, dt * 6);
car.pathAngle = p.angle;
b.angle = p.angle + Math.atan2(latSpeed, Math.max(car.speed, 15)) + car.drift;
b.vx = Math.cos(b.angle) * car.speed;
b.vy = Math.sin(b.angle) * car.speed;
b.av = (b.angle - prevAngle) / Math.max(dt, 1e-3);
car.layer = laneLayer(lane, car.s);
}
}
