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 { laneLayer, SURF_DIRT, SURF_GRASS, SURF_ROAD, SURF_WATER, type City, type Lane, type Station, type StationKind } from './city.js';
import type { Effects } from './effects.js';
import { angleDiff, clamp, poseAt, type Vec } from './geometry.js';
import { axleGrip, boxContact, boxInertia, forwardSpeed, integrate, resolveContact, resolveStatic, type Body } from './physics.js';
import { inView, type Traffic, type View } from './traffic.js';
import { GRAVITY, type Truck } from './truck.js';
import { drawCar, drawPed, makeLook, type CarDamage, type CarLook, type CarModel, type PedLook } from './vehicles.js';
export type ServiceKind = 'tow' | 'roadside' | 'ambulance' | 'fire' | 'police' | 'garbage' | 'sweeper' | 'works' | 'roadcrew' | 'rescue' | 'mytow' | 'mob';
/** What a crew does on arrival: walk (or drive) to each work point, work there, then head home. */
export interface ServiceTask {
/** Where the crew should go once parked (re-evaluated on arrival). */
points: () => Vec[];
/** Cancel an assignment if its scene becomes unsafe again, even en route. */
valid?: () => boolean;
/** Seconds of work at each point. */
work: number;
/** 'crew' walks to each point; 'drive' drives the vehicle over them (street sweepers). */
mode?: 'crew' | 'drive';
/** Leave the road for the last stretch to reach a scene off the road (default true). */
offroad?: boolean;
/** Keep standing by until this is true (police waiting for the tow trucks). */
until?: () => boolean;
/** Longest a unit stays on scene, in seconds. */
maxTime?: number;
onWorking?: (crew: Vec, point: Vec, dt: number) => void;
onPointDone?: (point: Vec, vehicle: ServiceVehicle) => void;
/** Called once the whole job is done. */
onDone?: () => void;
/** The job fell through (unit wrecked or hopelessly stuck): release any claims. */
onAbort?: () => void;
}
interface Crew {
x: number;
y: number;
angle: number;
stride: number;
look: PedLook;
queue: Vec[];
timer: number;
}
interface RoutePt {
x: number;
y: number;
layer: number;
/** Junction turn: take it slowly. */
slow: boolean;
}
interface Spec {
model: CarModel;
color: string;
accent: string;
crew: PedLook;
crewSize: number;
/** Cruise speed with lights and siren (or on a normal run). */
speed: number;
mass: number;
siren: boolean;
/** Amber beacon while working. */
beacon: boolean;
}
const SPECS: Record<ServiceKind, Spec> = {
tow: { model: 'tow', color: '#f2a72e', accent: '#ffffff', crew: { shirt: '#ff8a00', pants: '#2c3e50', skin: '#e0ac69', hair: '#2b1d14', hat: '#ffcc00' }, crewSize: 1, speed: 95, mass: 4, siren: false, beacon: true },
roadside: { model: 'roadside', color: '#f7d038', accent: '#1f3b6e', crew: { shirt: '#1f3b6e', pants: '#2c3e50', skin: '#c68642', hair: '#0f0f0f', hat: '#f7d038' }, crewSize: 1, speed: 82, mass: 2.4, siren: false, beacon: true },
ambulance: { model: 'ambulance', color: '#fdfdfd', accent: '#ffffff', crew: { shirt: '#f4f4f4', pants: '#1f3b6e', skin: '#c68642', hair: '#0f0f0f' }, crewSize: 2, speed: 108, mass: 3.2, siren: true, beacon: false },
fire: { model: 'firetruck', color: '#c0392b', accent: '#ffffff', crew: { shirt: '#3a2a1a', pants: '#3a2a1a', skin: '#f1c27d', hair: '#4a3222', hat: '#ffcc00' }, crewSize: 2, speed: 96, mass: 8, siren: true, beacon: false },
police: { model: 'police', color: '#15161c', accent: '#ffffff', crew: { shirt: '#1f3b6e', pants: '#15161c', skin: '#ffdbac', hair: '#15161c', hat: '#15161c' }, crewSize: 1, speed: 118, mass: 1.7, siren: true, beacon: false },
garbage: { model: 'garbage', color: '#2e7d4f', accent: '#ffffff', crew: { shirt: '#7dff6a', pants: '#2c3e50', skin: '#8d5524', hair: '#2b1d14' }, crewSize: 2, speed: 58, mass: 7, siren: false, beacon: true },
sweeper: { model: 'sweeper', color: '#f4f1ea', accent: '#ff8a00', crew: { shirt: '#ff8a00', pants: '#2c3e50', skin: '#ffdbac', hair: '#7a7a7a' }, crewSize: 1, speed: 55, mass: 3.5, siren: false, beacon: true },
works: { model: 'works', color: '#e67e22', accent: '#ffffff', crew: { shirt: '#ffcc00', pants: '#5d4037', skin: '#e0ac69', hair: '#4a3222', hat: '#ffcc00' }, crewSize: 2, speed: 70, mass: 2.6, siren: false, beacon: true },
// The player's own tow truck: rig colours, fast, and nothing gets in its way.
mytow: { model: 'tow', color: '#d8402f', accent: '#ffffff', crew: { shirt: '#d8402f', pants: '#15141b', skin: '#f1c27d', hair: '#2b1d14', hat: '#efeae0' }, crewSize: 1, speed: 150, mass: 4, siren: false, beacon: true },
// The family's black van.
mob: { model: 'van', color: '#15161c', accent: '#3a3d42', crew: { shirt: '#15161c', pants: '#15161c', skin: '#e0ac69', hair: '#0f0f0f', hat: '#2b2b2e' }, crewSize: 2, speed: 115, mass: 2.4, siren: false, beacon: false },
// Heavy rescue: the fire service's search-and-rescue truck, for people under the rubble.
rescue: { model: 'firetruck', color: '#f2b705', accent: '#c0392b', crew: { shirt: '#f2b705', pants: '#3a2a1a', skin: '#c68642', hair: '#2b1d14', hat: '#f4f4f4' }, crewSize: 2, speed: 92, mass: 7, siren: true, beacon: false },
roadcrew: { model: 'dump', color: '#e67e22', accent: '#ffffff', crew: { shirt: '#ff8a00', pants: '#2c3e50', skin: '#a0673d', hair: '#0f0f0f', hat: '#ffcc00' }, crewSize: 2, speed: 62, mass: 7.5, siren: false, beacon: true },
};
/** Which units each kind of station keeps. */
const FLEET: Record<StationKind, [ServiceKind, number][]> = {
hospital: [['ambulance', 3]],
police: [['police', 3]],
fire: [['fire', 2], ['rescue', 1]],
tow: [['tow', 5], ['roadside', 2]],
works: [['garbage', 2], ['sweeper', 2], ['works', 2], ['roadcrew', 2]],
};
/** A unit written off in a crash is replaced by the city after this long. */
const REPLACE_SECONDS = 150;
export type DispatchResult = 'ok' | 'busy' | 'unreachable';
interface Depot {
station: Station;
kind: ServiceKind;
units: number;
busy: number;
lane: number;
s: number;
/** Seconds until each written-off unit is replaced. */
lost: number[];
}
function surfaceGrip(surface: number): number {
if (surface === SURF_GRASS) return 0.7;
if (surface === SURF_DIRT) return 0.75;
if (surface === SURF_WATER) return 0.35;
return 1;
}
export class ServiceVehicle {
readonly look: CarLook;
readonly body: Body;
readonly spec: Spec;
state: 'drive' | 'work' | 'return' = 'drive';
depot: Depot | null = null;
/** Sent for the player's own rig (as opposed to a city job). */
forPlayer = false;
/** A wreck hooked up behind a tow truck. */
towing: { look: CarLook; dmg: CarDamage } | null = null;
/** Heading for the junkyard with a wreck. */
toJunk = false;
/** Patients aboard an ambulance, for the hospital. */
patients = 0;
route: RoutePt[] = [];
/** Distance along the route to each point. */
cum: number[] = [];
idx = 0;
layer = 0;
crew: Crew[] = [];
cones: Vec[] = [];
age = 0;
/** Time on scene. */
onScene = 0;
steer = 0;
health = 100;
/** Sideways offset from the route while getting round something. */
avoid = 0;
avoidTarget = 0;
avoidTimer = 0;
stuck = 0;
stuckCount = 0;
reverse = 0;
/** Seconds spent held up behind something that won't move. */
wait = 0;
/** Squeezing through a jam: ignore traffic for a moment. */
ghost = 0;
/** What last damaged it. */
lastHit = '';
/** Near enough to the player to matter: off-screen units don't trade paint with traffic. */
seen = false;
braking = false;
/** Work points for drive-mode jobs. */
sweep: Vec[] = [];
sweepTimer = 0;
/** Driving over a work point (slowly, without arriving anywhere). */
sweeping = false;
/** The player's tow: drives through traffic as if it weren't there. */
express = false;
/** Seconds out of sight while heading home. */
unseenTime = 0;
/** Police pull across the lane to close it. */
parkAngle: number | null = null;
readonly 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(
readonly kind: ServiceKind,
public target: Vec,
public task: ServiceTask,
) {
this.spec = SPECS[kind];
this.look = makeLook(this.spec.model, this.spec.color, this.spec.accent, false);
const m = this.spec.mass;
this.body = { x: 0, y: 0, vx: 0, vy: 0, angle: 0, av: 0, mass: m, inertia: boxInertia(m, this.look.length / 2, this.look.width / 2), halfL: this.look.length / 2, halfW: this.look.width / 2 };
}
get x(): number {
return this.body.x;
}
get y(): number {
return this.body.y;
}
get speed(): number {
return forwardSpeed(this.body);
}
get siren(): boolean {
return this.spec.siren && this.state === 'drive';
}
/** Still out on (or heading to) its job. */
get busy(): boolean {
return this.state !== 'return';
}
}
/** What the fleet needs to see of the rest of the world while driving. */
export interface ServiceWorld {
traffic: Traffic;
truck: Truck;
/** People and animals in the road. */
obstacles: Vec[];
view: View;
}
/**
* Every city service fleet: units wait at their stations, drive out over the lane
* graph with real physics, leave the road when they must, work the scene and drive
* back. Units can be rammed and written off; the city replaces them after a while.
*/
export class Services {
readonly vehicles: ServiceVehicle[] = [];
readonly depots: Depot[] = [];
/** Roads the police have closed round a scene: traffic is held back or waved past slowly. */
readonly closures: { x: number; y: number; r: number; layer: number }[] = [];
private time = 0;
private view: View = { x: 0, y: 0, w: 0, h: 0 };
/** Lanes leading into each lane (for spawning the player's tow behind them). */
private readonly prev = new Map<number, number[]>();
/** A unit just finished: give it another job nearby instead of sending it home. */
onIdle: ((v: ServiceVehicle) => boolean) | null = null;
/** A unit was rammed hard enough to write it off. */
onWrecked: ((v: ServiceVehicle, closing: number, px: number, py: number, nx: number, ny: number) => void) | null = null;
/** The player's rig hit a unit. */
onTruckHit: ((v: ServiceVehicle, closing: number, px: number, py: number) => void) | null = null;
constructor(
private readonly city: City,
private readonly effects: Effects,
) {
for (const st of city.stations) {
const near = this.nearestLane(st.x, st.y, 0);
if (!near) continue;
for (const [kind, units] of FLEET[st.kind]) this.depots.push({ station: st, kind, units, busy: 0, lane: near.lane.id, s: near.s, lost: [] });
}
for (const lane of city.lanes) {
for (const n of lane.next) {
const list = this.prev.get(n);
if (list) list.push(lane.id);
else this.prev.set(n, [lane.id]);
}
}
}
/** A private fleet based somewhere that isn't a city station (the family's vans). */
addDepot(station: Station, kind: ServiceKind, units: number): void {
const near = this.nearestLane(station.x, station.y, 0);
if (near) this.depots.push({ station, kind, units, busy: 0, lane: near.lane.id, s: near.s, lost: [] });
}
clear(): void {
this.vehicles.length = 0;
for (const d of this.depots) {
d.busy = 0;
d.lost = [];
}
}
/** The player's own tow truck (one at a time). */
active(kind: ServiceKind): ServiceVehicle | undefined {
return this.vehicles.find((v) => v.kind === kind && v.busy && v.forPlayer);
}
/** Free units of a kind across every station. */
available(kind: ServiceKind): number {
return this.depots.filter((d) => d.kind === kind && !d.station.structure?.destroyed).reduce((n, d) => n + Math.max(0, d.units - d.busy), 0);
}
total(kind: ServiceKind): number {
return this.depots.filter((d) => d.kind === kind).reduce((n, d) => n + d.units, 0);
}
/** Is a unit of this kind already working (or heading to) somewhere within r? */
covering(kind: ServiceKind, x: number, y: number, r: number): boolean {
return this.vehicles.some((v) => v.kind === kind && v.busy && Math.hypot(v.target.x - x, v.target.y - y) < r);
}
/** The closest lane on a layer to a point (excluding junction connectors), preferring one heading our way. */
private nearestLane(x: number, y: number, layer: number, heading?: number): { lane: Lane; s: number; d: number } | null {
let best: { lane: Lane; s: number; d: number } | null = null;
let bestScore = Infinity;
for (const lane of this.city.lanes) {
if (lane.kind === 'connector' || (lane.layer0 !== layer && lane.layer1 !== layer)) continue;
if (x < lane.x0 - 80 || x > lane.x1 + 80 || y < lane.y0 - 80 || y > lane.y1 + 80) continue;
for (let i = 0; i < lane.pts.length - 1; i++) {
const a = lane.pts[i];
const b = lane.pts[i + 1];
const vx = b.x - a.x;
const vy = b.y - a.y;
const l2 = vx * vx + vy * vy || 1;
const t = Math.max(0, Math.min(1, ((x - a.x) * vx + (y - a.y) * vy) / l2));
const d = Math.hypot(x - (a.x + vx * t), y - (a.y + vy * t));
const s = lane.cum[i] + Math.sqrt(l2) * t;
let score = d;
if (heading !== undefined && Math.cos(Math.atan2(vy, vx) - heading) < 0) score += 18;
if (score >= bestScore || laneLayer(lane, s) !== layer) continue;
bestScore = score;
best = { lane, s, d };
}
}
return best;
}
/**
* Shortest route by distance along lane links (Dijkstra). A lane can route back
* round to itself when the goal is behind us on the same lane.
*/
private laneRoute(from: number, to: number, sameLane: boolean): number[] | null {
if (from === to && sameLane) return [from];
const lanes = this.city.lanes;
const dist = new Map<number, number>();
const parent = new Map<number, number>();
// Binary heap of [cost, lane].
const heap: [number, number][] = [];
const push = (cost: number, id: number) => {
heap.push([cost, id]);
let i = heap.length - 1;
while (i > 0) {
const p = (i - 1) >> 1;
if (heap[p][0] <= heap[i][0]) break;
[heap[p], heap[i]] = [heap[i], heap[p]];
i = p;
}
};
const pop = (): [number, number] => {
const top = heap[0];
const last = heap.pop()!;
if (heap.length) {
heap[0] = last;
let i = 0;
for (;;) {
const l = i * 2 + 1;
const r = l + 1;
let m = i;
if (l < heap.length && heap[l][0] < heap[m][0]) m = l;
if (r < heap.length && heap[r][0] < heap[m][0]) m = r;
if (m === i) break;
[heap[m], heap[i]] = [heap[i], heap[m]];
i = m;
}
}
return top;
};
for (const n of lanes[from].next) {
if (n === from && from !== to) continue;
if ((dist.get(n) ?? Infinity) <= lanes[from].len) continue;
dist.set(n, lanes[from].len);
parent.set(n, from);
push(lanes[from].len, n);
}
let visited = 0;
while (heap.length && visited++ < 20000) {
const [cost, id] = pop();
if (cost > (dist.get(id) ?? Infinity)) continue;
if (id === to) {
const path = [to];
for (let cur = parent.get(to)!; cur !== from; cur = parent.get(cur)!) path.push(cur);
path.push(from);
return path.reverse();
}
const next = cost + lanes[id].len;
for (const n of lanes[id].next) {
if (n === from && from !== to) continue;
if (next >= (dist.get(n) ?? Infinity)) continue;
dist.set(n, next);
parent.set(n, id);
push(next, n);
}
}
return null;
}
/** Points along a lane path, from s0 on the first lane to s1 on the last. */
private lanePoints(path: number[], s0: number, s1: number, out: RoutePt[]): void {
path.forEach((id, i) => {
const lane = this.city.lanes[id];
const a = i === 0 ? s0 : 0;
const b = i === path.length - 1 ? s1 : lane.len;
const slow = lane.turn || lane.kind === 'connector';
for (let s = a; s < b; s += 7) {
const p = poseAt(lane, s);
out.push({ x: p.x, y: p.y, layer: laneLayer(lane, s), slow });
}
const p = poseAt(lane, b);
out.push({ x: p.x, y: p.y, layer: laneLayer(lane, b), slow });
});
}
private setRoute(v: ServiceVehicle, pts: RoutePt[]): void {
v.route = pts;
v.cum = [0];
for (let i = 1; i < pts.length; i++) v.cum.push(v.cum[i - 1] + Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y));
v.idx = 0;
v.stuck = 0;
v.stuckCount = 0;
}
/**
* A route from a lane position to a scene: along the lanes to the nearest point,
* pulled over onto the shoulder, and (if allowed) off the road to the scene itself.
*/
private planTo(v: ServiceVehicle, fromLane: number, fromS: number, x: number, y: number, layer: number, lead: RoutePt[] = []): boolean {
const goal = this.nearestLane(x, y, layer);
if (!goal) return false;
const offroad = (v.task.offroad ?? true) && layer === 0 && goal.d > 16;
const stopS = offroad ? goal.s : Math.max(0, goal.s - (v.kind === 'sweeper' ? 4 : 18));
const path = this.laneRoute(fromLane, goal.lane.id, fromS <= stopS);
if (!path) return false;
const pts = [...lead];
this.lanePoints(path, fromS, stopS, pts);
if (offroad) {
// Straight across the verge to just short of the scene.
const end = pts[pts.length - 1];
const d = Math.hypot(x - end.x, y - end.y);
const stop = Math.max(0, d - (v.kind === 'fire' ? 16 : 12));
for (let t = 8; t < stop; t += 8) pts.push({ x: end.x + ((x - end.x) / d) * t, y: end.y + ((y - end.y) / d) * t, layer: 0, slow: true });
if (stop > 0) pts.push({ x: end.x + ((x - end.x) / d) * stop, y: end.y + ((y - end.y) / d) * stop, layer: 0, slow: true });
} else if (pts.length > 2) {
// Pull over to the right for the last few metres.
const n = pts.length;
for (let i = Math.max(1, n - 4); i < n; i++) {
const a = pts[i - 1];
const b = pts[i];
const l = Math.hypot(b.x - a.x, b.y - a.y) || 1;
const k = (i - (n - 5)) / 4;
pts[i] = { ...b, x: b.x - ((b.y - a.y) / l) * 5 * k, y: b.y + ((b.x - a.x) / l) * 5 * k };
}
}
this.setRoute(v, pts);
return true;
}
/** Point on the road nearest a spot (for putting the rig back). */
roadPointNear(x: number, y: number): { x: number; y: number; angle: number; layer: number } | null {
const near = this.nearestLane(x, y, 0);
if (!near) return null;
const p = poseAt(near.lane, near.s);
return { x: p.x, y: p.y, angle: p.angle, layer: laneLayer(near.lane, near.s) };
}
/** Send the nearest free unit of a kind from its station. */
dispatch(kind: ServiceKind, x: number, y: number, task: ServiceTask, forPlayer = false, layer = 0): DispatchResult {
// A station that's been destroyed sends nobody until it's rebuilt.
const depots = this.depots.filter((d) => d.kind === kind && d.busy < d.units && !d.station.structure?.destroyed).sort((a, b) => Math.hypot(a.station.x - x, a.station.y - y) - Math.hypot(b.station.x - x, b.station.y - y));
if (!depots.length) return 'busy';
const v = new ServiceVehicle(kind, { x, y }, task);
v.forPlayer = forPlayer;
// Of the three nearest stations, the one with the shortest drive.
let best: { d: Depot; route: ServiceVehicle['route']; cum: number[] } | null = null;
let bestLen = Infinity;
for (const d of depots.slice(0, 3)) {
if (!this.planTo(v, d.lane, d.s, x, y, layer)) continue;
const len = v.cum[v.cum.length - 1];
if (len < bestLen) {
bestLen = len;
best = { d, route: v.route, cum: v.cum };
}
}
if (best) {
const d = best.d;
let route = best.route;
// A long run: the unit was already out on patrol, so it starts its last stretch just out of sight.
const total = bestLen;
// The family's van is always lurking nearby.
const lead = kind === 'mob' ? 380 : 750;
if (total > lead + 250) {
const cum = best.cum;
let i = cum.findIndex((c) => total - c <= lead);
while (i > 0 && inView(route[i].x, route[i].y, this.view, 90)) i--;
if (i > 0) route = route.slice(i);
}
this.setRoute(v, route);
d.busy++;
v.depot = d;
const p = route[0];
const q = route[Math.min(route.length - 1, 1)];
v.body.x = p.x;
v.body.y = p.y;
v.body.angle = q === p ? 0 : Math.atan2(q.y - p.y, q.x - p.x);
v.layer = p.layer;
if (total > lead + 250) {
v.body.vx = Math.cos(v.body.angle) * v.spec.speed * 0.6;
v.body.vy = Math.sin(v.body.angle) * v.spec.speed * 0.6;
}
this.vehicles.push(v);
return 'ok';
}
return 'unreachable';
}
/**
* The player's own tow truck: it comes from just behind the rig, out of sight,
* fast, straight through the traffic, and fixes things in no time.
*/
dispatchExpress(x: number, y: number, task: ServiceTask): boolean {
const goal = this.nearestLane(x, y, 0);
if (!goal) return false;
const v = new ServiceVehicle('mytow', { x, y }, task);
v.forPlayer = true;
v.express = true;
for (const back of [240, 380, 600]) {
let lane = goal.lane.id;
let s = goal.s;
let left = back;
while (left > s) {
left -= s;
const prevs = this.prev.get(lane)?.filter((l) => this.city.lanes[l].layer0 === 0 && this.city.lanes[l].layer1 === 0);
if (!prevs?.length) {
left = s;
break;
}
lane = prevs[Math.floor(Math.random() * prevs.length)];
s = this.city.lanes[lane].len;
}
const startS = Math.max(0, s - left);
const p = poseAt(this.city.lanes[lane], startS);
if (back < 600 && inView(p.x, p.y, this.view, 20)) continue;
if (!this.planTo(v, lane, startS, x, y, 0)) continue;
v.body.x = p.x;
v.body.y = p.y;
v.body.angle = p.angle;
v.body.vx = Math.cos(p.angle) * 90;
v.body.vy = Math.sin(p.angle) * 90;
this.vehicles.push(v);
return true;
}
return false;
}
/** Give a unit that's out and about a new job (instead of going back to base). */
reassign(v: ServiceVehicle, x: number, y: number, layer: number, task: ServiceTask): boolean {
const start = this.nearestLane(v.x, v.y, v.layer, v.body.angle);
if (!start) return false;
const old = { target: v.target, task: v.task };
v.target = { x, y };
v.task = task;
const lead: RoutePt[] = start.d > 10 ? [{ x: v.x, y: v.y, layer: v.layer, slow: true }] : [];
if (!this.planTo(v, start.lane.id, start.s, x, y, layer, lead)) {
v.target = old.target;
v.task = old.task;
return false;
}
v.state = 'drive';
v.age = 0;
v.onScene = 0;
v.unseenTime = 0;
v.parkAngle = null;
v.crew = [];
v.cones = [];
v.sweep = [];
return true;
}
/** Job done: take the next one nearby if there is one, otherwise head home. */
private finish(v: ServiceVehicle): void {
v.crew = [];
v.cones = [];
v.sweep = [];
v.state = 'return';
// Carrying a wreck or patients: deliver them first.
if (!v.towing && !v.patients && !v.forPlayer && this.onIdle?.(v)) return;
this.goHome(v);
}
/** Head back to the station once the job is done (or just drive off, for units with no station). */
private goHome(v: ServiceVehicle): void {
v.state = 'return';
v.age = 0;
v.unseenTime = 0;
v.parkAngle = null;
v.avoid = v.avoidTarget = 0;
const home = v.depot;
const start = this.nearestLane(v.x, v.y, v.layer, v.body.angle);
const lead: RoutePt[] = [];
if (start && start.d > 10) lead.push({ x: v.x, y: v.y, layer: v.layer, slow: true });
// A tow truck with a wreck on the hook takes it to the junkyard.
const junk = this.city.junkyard;
if (v.towing && junk && start) {
const goal = this.nearestLane(junk.x, junk.y, 0);
const path = goal ? this.laneRoute(start.lane.id, goal.lane.id, start.s <= goal.s) : null;
if (goal && path) {
this.lanePoints(path, start.s, goal.s, lead);
this.setRoute(v, lead);
v.toJunk = true;
return;
}
}
if (!home && start) {
// Drive off down the road and out of sight.
const path = [start.lane.id];
let len = this.city.lanes[start.lane.id].len - start.s;
while (len < 500) {
const next = this.city.lanes[path[path.length - 1]].next;
if (!next.length) break;
const n = next[Math.floor(Math.random() * next.length)];
path.push(n);
len += this.city.lanes[n].len;
}
this.lanePoints(path, start.s, this.city.lanes[path[path.length - 1]].len, lead);
this.setRoute(v, lead);
return;
}
if (!home || !start) {
this.setRoute(v, []);
return;
}
const path = this.laneRoute(start.lane.id, home.lane, start.s <= home.s);
if (!path) {
this.setRoute(v, []);
return;
}
this.lanePoints(path, start.s, home.s, lead);
this.setRoute(v, lead);
}
private abort(v: ServiceVehicle): void {
if (v.state === 'return') return;
v.task.onAbort?.();
v.crew = [];
v.cones = [];
v.sweep = [];
this.goHome(v);
}
update(dt: number, world: ServiceWorld): void {
this.time += dt;
this.view = world.view;
this.closures.length = 0;
for (const v of this.vehicles) if (v.kind === 'police' && v.state === 'work') this.closures.push({ x: v.target.x, y: v.target.y, r: 55, layer: v.layer });
for (const d of this.depots) {
for (let i = d.lost.length - 1; i >= 0; i--) {
d.lost[i] -= dt;
if (d.lost[i] <= 0) {
d.lost.splice(i, 1);
d.busy = Math.max(0, d.busy - 1);
}
}
}
for (const v of this.vehicles) {
if (v.state !== 'return' && v.task.valid && !v.task.valid()) this.abort(v);
v.age += dt;
v.seen = inView(v.x, v.y, world.view, 160);
const flashing = v.siren || (v.state === 'work' && (v.spec.siren || v.spec.beacon)) || (v.spec.beacon && v.state === 'drive') || !!v.towing;
v.dmg.hazard = flashing && Math.floor(this.time * 4) % 2 === 0;
if (v.state === 'work') this.work(v, dt, world);
else this.drive(v, dt, world);
if (v.siren) this.clearTheWay(v, world.traffic);
}
this.collide(world);
for (let i = this.vehicles.length - 1; i >= 0; i--) {
const v = this.vehicles[i];
// Out of sight on the way home: it's as good as back (wrecks and patients delivered).
if (v.state === 'return') {
v.unseenTime = inView(v.x, v.y, world.view, 260) ? 0 : v.unseenTime + dt;
if (v.unseenTime > 2.5) {
v.towing = null;
v.toJunk = false;
v.patients = 0;
v.unseenTime = 0;
if (!v.forPlayer && this.onIdle?.(v)) continue;
if (v.depot) v.depot.busy = Math.max(0, v.depot.busy - 1);
this.vehicles.splice(i, 1);
continue;
}
}
const last = v.route[v.route.length - 1];
const home = v.state === 'return' && (!last || (v.idx >= v.route.length - 2 && Math.hypot(last.x - v.x, last.y - v.y) < 10));
if (home && v.toJunk) {
// Dropped at the junkyard: on to the next job, or back to the depot.
v.towing = null;
v.toJunk = false;
if (!this.onIdle?.(v)) this.goHome(v);
continue;
}
const lost = v.state === 'return' && v.age > 240 && !inView(v.x, v.y, world.view, 60);
if (home || lost) {
if (v.depot) v.depot.busy = Math.max(0, v.depot.busy - 1);
this.vehicles.splice(i, 1);
}
}
}
/** Everyone ahead of a siren (same way, oncoming or at the next junction) pulls over to the right and waits. */
private clearTheWay(v: ServiceVehicle, traffic: Traffic): void {
const c = Math.cos(v.body.angle);
const s = Math.sin(v.body.angle);
for (const car of traffic.cars) {
if (car.layer !== v.layer || Math.abs(car.x - v.x) > 170 || Math.abs(car.y - v.y) > 170) continue;
const dx = car.x - v.x;
const dy = car.y - v.y;
const along = dx * c + dy * s;
if (along < -8 || along > 170 || Math.abs(-dx * s + dy * c) > 45) continue;
car.yieldTime = 1.6;
}
}
/** Steer along the route with real tyre physics, pass anything in the way, and get unstuck. */
private drive(v: ServiceVehicle, dt: number, world: ServiceWorld): void {
const b = v.body;
const pts = v.route;
const n = pts.length;
if (!n) {
v.age = 999;
return;
}
const fwd = forwardSpeed(b);
while (v.idx < n - 1) {
const p = pts[v.idx];
const q = pts[v.idx + 1];
const d = Math.hypot(p.x - b.x, p.y - b.y);
if (d < 5 + Math.abs(fwd) * 0.12 || Math.hypot(q.x - b.x, q.y - b.y) < d) v.idx++;
else break;
}
v.layer = pts[v.idx].layer;
const lastPt = pts[n - 1];
const remaining = v.cum[n - 1] - v.cum[v.idx] + Math.hypot(pts[v.idx].x - b.x, pts[v.idx].y - b.y);
if (v.state === 'drive' && !v.sweeping && (remaining < 3 || (v.idx >= n - 2 && Math.hypot(lastPt.x - b.x, lastPt.y - b.y) < 4 && Math.abs(fwd) < 6))) {
this.arrive(v);
return;
}
// Look ahead along the route, shifted sideways if we're passing something.
const lookDist = 9 + Math.abs(fwd) * 0.3;
let j = v.idx;
while (j < n - 1 && Math.hypot(pts[j].x - b.x, pts[j].y - b.y) < lookDist) j++;
const tp = pts[j];
const prev = pts[Math.max(0, j - 1)];
const tl = Math.hypot(tp.x - prev.x, tp.y - prev.y) || 1;
v.avoidTimer -= dt;
if (v.avoidTimer <= 0) v.avoidTarget = 0;
v.avoid += clamp(v.avoidTarget - v.avoid, -18 * dt, 18 * dt);
const tx = tp.x - ((tp.y - prev.y) / tl) * v.avoid;
const ty = tp.y + ((tp.x - prev.x) / tl) * v.avoid;
// How fast: cruise, slow for junctions, verges and the stop, and leave room ahead.
const surface = this.city.surfaceAt(b.x, b.y);
// Out of sight the city's drivers make good time.
const unseen = !inView(b.x, b.y, world.view, 400);
let want = v.sweeping ? 16 : v.spec.speed * (v.state === 'return' ? 0.7 : v.siren ? 1 : 0.85) * (unseen ? 1.25 : 1);
for (let k = v.idx; k < Math.min(n, v.idx + 6); k++) if (pts[k].slow) want = Math.min(want, v.siren ? 42 : 30);
if (surface !== SURF_ROAD) want = Math.min(want, 36);
want = Math.min(want, Math.sqrt(2 * 80 * Math.max(0, remaining - 2)) + 3);
v.ghost -= dt;
const ahead = this.scanAhead(v, world);
// Squeezing through a jam: slowly.
if (v.ghost > 0) want = Math.min(want, v.siren ? 34 : 22);
if (ahead.gap < Infinity) {
// Never faster than we can stop in the space left.
want = Math.min(want, Math.sqrt(2 * 110 * Math.max(0, ahead.gap - 4)));
if (ahead.blocking && ahead.gap < 34 && v.avoidTimer <= 0) this.findWayRound(v, ahead.lat, ahead.halfW, ahead.gap, world, v.wait > 4);
// Held up by something that won't budge: go round it wide, then squeeze through.
if (want < 3 && Math.abs(fwd) < 2) v.wait += dt;
// Held up just short of the scene: park here and walk the rest.
if (v.state === 'drive' && !v.sweeping && v.wait > 2 && (remaining < 45 || Math.hypot(v.target.x - b.x, v.target.y - b.y) < 70)) {
this.arrive(v);
return;
}
// Leaving a busy scene squeezes out quickly; arriving waits a little longer first.
if (v.wait > (v.state === 'return' ? 1.5 : v.siren ? 2.5 : 5)) {
v.wait = 0;
if (unseen) this.hop(v);
else v.ghost = 3;
}
} else {
v.wait = Math.max(0, v.wait - dt * 2);
}
// Stuck against something: back up, turn the other way and try again.
if (v.reverse > 0) {
v.reverse -= dt;
want = -16;
} else if (want > 8 && Math.abs(fwd) < 2.5) {
v.stuck += dt;
if (v.stuck > 2.2) {
v.stuck = 0;
v.stuckCount++;
v.reverse = 1.3;
v.avoidTarget = (Math.random() < 0.5 ? -1 : 1) * (8 + Math.random() * 8);
v.avoidTimer = 3;
if (v.stuckCount >= 4 && !inView(b.x, b.y, world.view, 40)) this.hop(v);
if (v.stuckCount >= 9) this.abort(v);
}
} else if (Math.abs(fwd) > 6) {
v.stuck = Math.max(0, v.stuck - dt);
}
// Pure-pursuit steering.
const alpha = angleDiff(Math.atan2(ty - b.y, tx - b.x), b.angle);
const wheelbase = b.halfL * 1.2;
let steerWant = Math.abs(alpha) > Math.PI / 2 ? Math.sign(alpha) * 0.65 : Math.atan2(2 * wheelbase * Math.sin(alpha), Math.max(lookDist, 6));
if (v.reverse > 0) steerWant = -steerWant;
steerWant = clamp(steerWant, -0.65, 0.65);
v.steer += clamp(steerWant - v.steer, -3.5 * dt, 3.5 * dt);
v.braking = want < fwd - 8;
const steps = 2;
const h = dt / steps;
const mu = surfaceGrip(surface) * 1.35;
const accel = v.spec.mass > 5 ? 42 : 62;
for (let i = 0; i < steps; i++) {
const c = Math.cos(b.angle);
const s = Math.sin(b.angle);
const f = b.vx * c + b.vy * s;
const acc = clamp((want - f) * 3, -150, accel);
b.vx += c * acc * h;
b.vy += s * acc * h;
const nrm = (b.mass * GRAVITY) / 2;
const ax = b.halfL * 0.6;
axleGrip(b, b.x + c * ax, b.y + s * ax, b.angle + v.steer, mu * nrm * h);
axleGrip(b, b.x - c * ax, b.y - s * ax, b.angle, mu * nrm * h);
b.av *= Math.exp(-1.5 * h);
integrate(b, h);
this.collideWorld(v);
}
if (surface !== SURF_ROAD && Math.abs(fwd) > 15 && Math.random() < dt * 8) this.effects.dust(b.x - Math.cos(b.angle) * b.halfL, b.y - Math.sin(b.angle) * b.halfL, 0.6);
}
/** The nearest thing in our path: how far, where, and whether we should go round it. */
private scanAhead(v: ServiceVehicle, world: ServiceWorld): { gap: number; lat: number; halfW: number; blocking: boolean } {
const b = v.body;
const c = Math.cos(b.angle);
const s = Math.sin(b.angle);
const fwd = Math.max(0, forwardSpeed(b));
const reach = b.halfL + 14 + fwd * 0.9;
let best = { gap: Infinity, lat: 0, halfW: 0, blocking: false };
const consider = (x: number, y: number, angle: number, halfL: number, halfW: number, speed: number, parked: boolean) => {
const dx = x - b.x;
const dy = y - b.y;
const along = dx * c + dy * s;
// Its footprint in our frame (a wreck lying sideways is wide).
const rel = angle - b.angle;
const extAlong = Math.abs(halfL * Math.cos(rel)) + Math.abs(halfW * Math.sin(rel));
const extSide = Math.abs(halfL * Math.sin(rel)) + Math.abs(halfW * Math.cos(rel));
if (along + extAlong <= 0 || along > reach + extAlong) return;
const lat = -dx * s + dy * c;
if (Math.abs(lat) > b.halfW + extSide + 1.5) return;
const gap = along - b.halfL - extAlong;
halfW = extSide;
if (gap >= best.gap) return;
// Sirens go round anything slow; everyone else only round wrecks and parked units.
const blocking = parked || (v.siren && speed < Math.max(12, fwd * 0.5));
best = { gap, lat, halfW, blocking };
};
for (const car of v.ghost > 0 || v.express ? [] : world.traffic.all()) {
if (car.layer !== v.layer || Math.abs(car.x - b.x) > reach + 20 || Math.abs(car.y - b.y) > reach + 20) continue;
consider(car.x, car.y, car.angle, car.body.halfL, car.body.halfW, Math.abs(car.speed), car.wrecked || car.broken);
}
for (const o of v.ghost > 0 || v.express ? [] : this.vehicles) {
if (o === v || o.layer !== v.layer || Math.abs(o.x - b.x) > reach + 30 || Math.abs(o.y - b.y) > reach + 30) continue;
// Units already overlapping (just leaving the same station) sort themselves out.
if (boxContact(b, o.body)) continue;
consider(o.x, o.y, o.body.angle, o.body.halfL, o.body.halfW, Math.abs(o.speed), o.state === 'work');
}
const t = world.truck;
if (t.layer === v.layer && v.ghost <= 0) {
for (const body of [t.cab, t.trailer]) {
if (Math.abs(body.x - b.x) > reach + 30 || Math.abs(body.y - b.y) > reach + 30) continue;
consider(body.x, body.y, body.angle, body.halfL, body.halfW, Math.abs(t.speed), Math.abs(t.speed) < 3);
}
}
if (v.layer === 0 && v.ghost <= 0) for (const p of world.obstacles) consider(p.x, p.y, 0, 1.5, 2, 0, false);
return best;
}
/** Pick a side to pass on, even across the verge, if there's room. */
private findWayRound(v: ServiceVehicle, lat: number, halfW: number, gap: number, world: ServiceWorld, wide = false): void {
const b = v.body;
const c = Math.cos(b.angle);
const s = Math.sin(b.angle);
const clear = halfW + b.halfW + 2.5;
const options = [lat - clear, lat + clear, lat - clear - 6, lat + clear + 6, ...(wide ? [lat - clear - 12, lat + clear + 12] : [])].sort((p, q) => Math.abs(p) - Math.abs(q));
for (const off of options) {
const total = v.avoid + off;
if (Math.abs(total) > (wide ? 30 : 24)) continue;
let ok = true;
for (const along of [gap * 0.5 + b.halfL, gap + b.halfL + 6, gap + b.halfL + 18]) {
const px = b.x + c * along - s * off;
const py = b.y + s * along + c * off;
const push = this.city.resolveCircle(px, py, b.halfW, v.layer);
if (push.x || push.y) ok = false;
for (const car of world.traffic.all()) {
if (wide && car.mode === 'drive') continue;
if (car.layer === v.layer && Math.abs(car.x - px) < car.body.halfL + b.halfW && Math.abs(car.y - py) < car.body.halfL + b.halfW && Math.hypot(car.x - px, car.y - py) < car.body.halfW + b.halfW + 1) ok = false;
}
for (const o of this.vehicles) {
if (o !== v && o.layer === v.layer && Math.hypot(o.x - px, o.y - py) < o.body.halfL + b.halfW + 2) ok = false;
}
if (!ok) break;
}
// Don't swing out into oncoming traffic.
for (const car of world.traffic.cars) {
if (!ok || car.layer !== v.layer || car.speed < 5) continue;
const dx = car.x - b.x;
const dy = car.y - b.y;
const along = dx * c + dy * s;
const side = -dx * s + dy * c;
if (along > 0 && along < 140 && Math.abs(side - off) < b.halfW + car.body.halfW + 3 && Math.cos(car.angle - b.angle) < -0.3) ok = false;
}
if (!ok) continue;
v.avoidTarget = total;
v.avoidTimer = 2 + gap / 25;
return;
}
}
/** Hopelessly stuck out of sight: skip ahead along the route. */
private hop(v: ServiceVehicle): void {
const k = Math.min(v.route.length - 1, v.idx + 6);
const p = v.route[k];
const q = v.route[Math.min(v.route.length - 1, k + 1)];
v.body.x = p.x;
v.body.y = p.y;
if (q !== p) v.body.angle = Math.atan2(q.y - p.y, q.x - p.x);
v.body.vx = v.body.vy = v.body.av = 0;
v.idx = k;
v.layer = p.layer;
v.avoid = v.avoidTarget = 0;
}
private collideWorld(v: ServiceVehicle): void {
const b = v.body;
for (const d of [b.halfL - b.halfW * 0.55, 0, -(b.halfL - b.halfW * 0.55)]) {
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, v.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, 0.2, 0.4);
if (impact > 22) {
this.effects.crash(cx, cy, impact / 140, [v.look.color, '#1b1b1f'], { x: push.x / l, y: push.y / l }, { material, glass: impact > 60 });
if ((material === 'concrete' || material === 'wood') && impact > 30) this.effects.structureHit(cx, cy, impact, push.x / l, push.y / l, material);
this.damage(v, impact * 0.7, cx, cy, -push.x / l, -push.y / l, material);
}
}
}
/** Units bump into traffic, wrecks, each other and the player's rig. */
private collide(world: ServiceWorld): void {
const { traffic, truck } = world;
for (let i = 0; i < this.vehicles.length; i++) {
const v = this.vehicles[i];
if (!v.seen || v.express) continue;
for (const car of v.ghost > 0 ? [] : traffic.all()) {
if (car.ghost > 0 || car.layer !== v.layer || car.dmg.z > 8 || Math.abs(car.x - v.x) > 30 || Math.abs(car.y - v.y) > 30) continue;
const c = boxContact(v.body, car.body);
if (!c) continue;
if (car.mode === 'drive') {
// Everyday nudges between a unit and a driver: they stop, nobody's wrecked.
// Units and drivers only ever nudge each other: they stop, nobody's wrecked.
traffic.bump(car, c.depth);
const push = Math.max(0, c.depth - 0.05);
v.body.x -= c.nx * push;
v.body.y -= c.ny * push;
const vn = v.body.vx * c.nx + v.body.vy * c.ny;
if (vn > 0) {
v.body.vx -= c.nx * vn;
v.body.vy -= c.ny * vn;
}
continue;
}
const hit = resolveContact(v.body, car.body, c, 0.15, 0.4);
if (hit.closing < 8) continue;
traffic.applyHit(car, hit.closing, c.px, c.py, -c.nx, -c.ny, { material: 'car', color: v.look.color }, 'pileup');
this.damage(v, hit.closing * Math.min(1.5, car.body.mass / v.body.mass), c.px, c.py, c.nx, c.ny, car.wrecked ? 'wreck' : 'car');
}
for (let k = i + 1; k < this.vehicles.length; k++) {
const o = this.vehicles[k];
if (!o.seen || o.express || v.ghost > 0 || o.ghost > 0 || o.layer !== v.layer || Math.abs(o.x - v.x) > 34 || Math.abs(o.y - v.y) > 34) continue;
const c = boxContact(v.body, o.body);
if (!c) continue;
const hit = resolveContact(v.body, o.body, c, 0.15, 0.4);
// Fleet drivers only trade paint with each other in a real smash.
if (hit.closing < 45) continue;
this.damage(v, hit.closing, c.px, c.py, c.nx, c.ny, 'unit');
this.damage(o, hit.closing, c.px, c.py, -c.nx, -c.ny, 'unit');
}
if (v.ghost > 0 || truck.layer !== v.layer || Math.abs(truck.x - v.x) > 70 || Math.abs(truck.y - v.y) > 70) continue;
for (const body of [truck.cab, truck.trailer]) {
const c = boxContact(body, v.body);
if (!c) continue;
const hit = resolveContact(body, v.body, c, 0.12, 0.45);
if (hit.closing < 6) continue;
this.onTruckHit?.(v, hit.closing, c.px, c.py);
this.damage(v, hit.closing * 1.2, c.px, c.py, -c.nx, -c.ny, 'truck');
}
}
for (let i = this.vehicles.length - 1; i >= 0; i--) {
const v = this.vehicles[i];
if (v.health > 0) continue;
// Written off: the wreck joins the others on the road and the unit is lost for a while.
this.abort(v);
this.vehicles.splice(i, 1);
if (v.depot) v.depot.lost.push(REPLACE_SECONDS);
this.onWrecked?.(v, 0, v.x, v.y, 0, 0);
}
}
/** An explosion: units nearby are thrown and battered (a direct hit writes one off). */
blast(x: number, y: number, r: number, layer: number): void {
for (const v of this.vehicles) {
if (v.layer !== layer) continue;
const dx = v.x - x;
const dy = v.y - y;
const d = Math.hypot(dx, dy);
if (d > r) continue;
const nx = d > 0.1 ? dx / d : 1;
const ny = d > 0.1 ? dy / d : 0;
const power = (1 - d / r) * 160 + 20;
v.body.vx += nx * power * (3 / v.body.mass);
v.body.vy += ny * power * (3 / v.body.mass);
this.damage(v, power, v.x - nx * v.body.halfW, v.y - ny * v.body.halfW, -nx, -ny, 'blast');
}
}
/** (nx, ny) points from the unit toward whatever hit it. */
private damage(v: ServiceVehicle, closing: number, px: number, py: number, nx: number, ny: number, what: string): void {
if (closing < 18) return;
v.lastHit = `${what}:${Math.round(closing)}`;
const b = v.body;
const lx = (px - b.x) * Math.cos(b.angle) + (py - b.y) * Math.sin(b.angle);
const ly = -(px - b.x) * Math.sin(b.angle) + (py - b.y) * Math.cos(b.angle);
const zone = Math.abs(lx) / b.halfL > Math.abs(ly) / b.halfW ? (lx > 0 ? 0 : 1) : ly < 0 ? 2 : 3;
v.dmg.crumple[zone] = Math.min(zone < 2 ? 5 : 3, v.dmg.crumple[zone] + closing * 0.03);
if (closing > 45) v.dmg.glass = true;
v.health -= (closing - 14) * 0.7 * (4 / (v.spec.mass + 2));
this.effects.crash(px, py, closing / 120, [v.look.color, '#1b1b1f', '#c9ccd1'], { x: -nx, y: -ny }, { material: 'car', glass: closing > 45, flavor: v.look.model });
// A really hard hit writes it off straight away.
if (closing > 85 + v.spec.mass * 6) v.health = 0;
}
/** Parked at the scene: send the crew out (or start sweeping). */
private arrive(v: ServiceVehicle): void {
v.state = 'work';
v.onScene = 0;
v.body.vx *= 0.3;
v.body.vy *= 0.3;
const points = v.task.points();
if (v.task.mode === 'drive') {
v.sweep = points;
return;
}
const side = { x: -Math.sin(v.body.angle), y: Math.cos(v.body.angle) };
for (let i = 0; i < v.spec.crewSize; i++) {
v.crew.push({
x: v.x + side.x * (v.body.halfW + 2) - Math.cos(v.body.angle) * i * 4,
y: v.y + side.y * (v.body.halfW + 2) - Math.sin(v.body.angle) * i * 4,
angle: v.body.angle,
stride: 0,
look: { ...v.spec.crew },
queue: points.filter((_, k) => k % v.spec.crewSize === i),
timer: 0,
});
}
// Police swing across the lane to close the road behind them.
if (v.kind === 'police' && this.city.surfaceAt(v.x, v.y) === SURF_ROAD) v.parkAngle = v.body.angle + 0.9;
if (v.kind === 'police' || v.kind === 'roadcrew' || v.kind === 'works') {
// Cones round the scene (and round each hole for road crews).
const centres = v.kind === 'police' ? [v.target] : points.slice(0, 4);
const r = v.kind === 'police' ? 24 : 8;
for (const c of centres) for (let a = 0; a < Math.PI * 2; a += Math.PI / 3) v.cones.push({ x: c.x + Math.cos(a) * r, y: c.y + Math.sin(a) * r });
}
}
private work(v: ServiceVehicle, dt: number, world: ServiceWorld): void {
v.onScene += dt;
const b = v.body;
if (v.task.mode === 'drive') {
this.sweepDrive(v, dt, world);
return;
}
if (v.parkAngle !== null) b.angle += clamp(angleDiff(v.parkAngle, b.angle), -1.6 * dt, 1.6 * dt);
// Parked: the handbrake is on.
const stop = Math.exp(-6 * dt);
b.vx *= stop;
b.vy *= stop;
b.av *= stop;
integrate(b, dt);
this.collideWorld(v);
let allHome = true;
for (const c of v.crew) {
const goal = c.queue[0] ?? { x: b.x - Math.sin(b.angle) * (b.halfW + 2), y: b.y + Math.cos(b.angle) * (b.halfW + 2) };
const dx = goal.x - c.x;
const dy = goal.y - c.y;
const d = Math.hypot(dx, dy);
const reach = v.kind === 'fire' ? 14 : 3;
if (d > reach) {
const step = Math.min(d - reach + 0.1, 22 * dt);
c.x += (dx / d) * step;
c.y += (dy / d) * step;
c.angle = Math.atan2(dy, dx);
c.stride += step;
allHome = false;
continue;
}
if (!c.queue.length) continue;
allHome = false;
c.angle = Math.atan2(dy, dx);
c.timer += dt;
v.task.onWorking?.(c, goal, dt);
if (c.timer >= v.task.work) {
c.timer = 0;
v.task.onPointDone?.(goal, v);
c.queue.shift();
}
}
const standBy = v.task.until && !v.task.until();
if ((allHome && !standBy) || v.onScene > (v.task.maxTime ?? 100)) {
v.task.onDone?.();
this.finish(v);
}
}
/** Street sweepers drive slowly over each dirty spot. */
private sweepDrive(v: ServiceVehicle, dt: number, world: ServiceWorld): void {
const goal = v.sweep[0];
if (!goal || v.onScene > (v.task.maxTime ?? 100)) {
v.task.onDone?.();
this.finish(v);
return;
}
if (!v.route.length || v.route[v.route.length - 1].x !== goal.x || v.route[v.route.length - 1].y !== goal.y) {
this.setRoute(v, [{ x: v.x, y: v.y, layer: v.layer, slow: true }, { x: goal.x, y: goal.y, layer: v.layer, slow: true }]);
}
v.state = 'drive';
v.sweeping = true;
this.drive(v, dt, world);
v.sweeping = false;
if (v.state !== 'drive') return;
v.state = 'work';
v.sweepTimer -= dt;
if (v.sweepTimer <= 0) {
v.sweepTimer = 0.25;
v.task.onWorking?.(v, goal, dt);
}
if (Math.hypot(goal.x - v.x, goal.y - v.y) < 7 || v.stuckCount > 2) {
v.task.onPointDone?.(goal, v);
v.sweep.shift();
v.stuckCount = 0;
}
}
draw(ctx: CanvasRenderingContext2D, view: View, time: number, layer: number): void {
for (const v of this.vehicles) {
if (v.layer !== layer || !inView(v.x, v.y, view, 40)) continue;
for (const cone of v.cones) {
ctx.fillStyle = '#ff7a1a';
ctx.beginPath();
ctx.moveTo(cone.x, cone.y - 1.6);
ctx.lineTo(cone.x + 1.3, cone.y + 1);
ctx.lineTo(cone.x - 1.3, cone.y + 1);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.fillRect(cone.x - 0.7, cone.y - 0.3, 1.4, 0.5);
}
if (v.towing) {
// The wreck rides behind on the hook, nose up.
const back = v.body.halfL + v.towing.look.length / 2 - 2;
drawCar(ctx, v.towing.look, v.x - Math.cos(v.body.angle) * back, v.y - Math.sin(v.body.angle) * back, v.body.angle, true, false, v.towing.dmg);
}
if (v.kind === 'sweeper' && v.state === 'work') v.look.seed = (time * 2) % 1;
drawCar(ctx, v.look, v.x, v.y, v.body.angle, false, v.braking, v.dmg);
for (const c of v.crew) drawPed(ctx, c.look, c.x, c.y, c.angle, c.stride, 'walk', 0, 0, time);
}
}
/** Units parked at their stations. */
drawDepots(ctx: CanvasRenderingContext2D, view: View): void {
const rows = new Map<Station, number>();
for (const d of this.depots) {
const st = d.station;
if (d.kind === 'mob' || st.structure?.destroyed || !inView(st.x, st.y, view, 60)) continue;
const free = d.units - d.busy;
const look = makeLook(SPECS[d.kind].model, SPECS[d.kind].color, SPECS[d.kind].accent, false);
const c = Math.cos(st.angle);
const sn = Math.sin(st.angle);
let slot = rows.get(st) ?? 0;
for (let i = 0; i < free; i++, slot++) {
const u = -st.w / 2 + 6 + look.width / 2 + slot * 12;
if (u > st.w / 2 - 4) break;
const w = -st.d / 2 + look.length / 2 + 2;
drawCar(ctx, look, st.x + c * u - sn * w, st.y + sn * u + c * w, st.angle - Math.PI / 2);
}
rows.set(st, slot);
}
}
/** Lightbars and beacons for the night overlay. */
lights(view: View): { x: number; y: number; color: string }[] {
const out: { x: number; y: number; color: string }[] = [];
for (const v of this.vehicles) {
if (!v.dmg.hazard || !inView(v.x, v.y, view, 40)) continue;
const color = !v.spec.siren ? 'rgba(255, 176, 0, 0.7)' : Math.floor(this.time * 8) % 2 ? 'rgba(255, 40, 40, 0.7)' : 'rgba(60, 90, 255, 0.7)';
out.push({ x: v.x, y: v.y, color });
}
return out;
}
}
