SOURCE / PINNED RELEASE
Made of little things.
Big Rig Rush
- Release
- ec9b6549f752…
- Author-recorded commit
- 74b729258b1a…
- License
- LICENSE
- Author’s source reference
- nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-0a99f0cae40
Archive hash verified: 900e22810340ccb1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import type { City, Venue, Walk } from './city.js';
import type { Effects } from './effects.js';
import { clamp, pointAt, type Vec } from './geometry.js';
import { velocityAt, type Body } from './physics.js';
import { inView, type View } from './traffic.js';
import type { Truck } from './truck.js';
import { drawAnimal, drawPed, type PedLook } from './vehicles.js';
type PedState = 'walk' | 'cross' | 'flee' | 'fly' | 'slide' | 'down' | 'mill' | 'follow' | 'hail';
export type GroupKind = 'joggers' | 'dogwalk' | 'tourists' | 'protest' | 'busker' | 'picnic' | 'yoga' | 'barcrawl';
/** What hit someone: a low car nose scoops people up, a flat truck or bus front shoves them down. */
export type Profile = 'low' | 'flat';
const SCREAMS = ['AAH!', 'WATCH IT!', 'MY LIFE FLASHED!', 'EEK!', 'NOT TODAY!', 'RUN!!'];
const HIT_LINES = ['OOF!', 'MY SPINE!', 'OW OW OW', 'I SAW MY ANCESTORS', 'WHO TURNED OFF THE SKY?'];
const ANGRY = ["I'M SUING!", 'WATCH IT, TRUCKER!', 'I HAD THE WALK SIGN!', 'MY LAWYER HAS A BILLBOARD', 'I GOT YOUR PLATE!'];
const MARKET_LINES = ['FRESH CORN!', 'ORGANIC KALE, $9', 'LOCAL HONEY!', 'TRY A SAMPLE!', 'IS THIS GLUTEN FREE?', 'TWO FOR FIVE!'];
const PARTY_LINES = ['WOOOO!', 'PARTY!!', 'TURN IT UP!', 'WHO INVITED THE TRUCK?!', 'CANNONBALL!', 'BEST NIGHT EVER!', 'SHOTS! SHOTS!'];
const RAVE_LINES = ['DROP THE BASS!', 'UNTZ UNTZ UNTZ', 'I CAN SEE SOUND', 'PLUR!!', 'ONE MORE SONG!', 'WHERE ARE MY SHOES?'];
const ERRAND_LINES = ['MILK, EGGS, BREAD...', 'WHERE DID I PARK?', 'LATE FOR YOGA!', 'GOTTA RETURN THIS'];
const GROUP_LINES: Record<GroupKind, string[]> = {
joggers: ['ON YOUR LEFT!', 'MILE SIX!', 'FEEL THE BURN!', 'PACE! PACE!'],
dogwalk: ['GOOD BOY!', 'DROP IT!', 'WHO WANTS A WALK?'],
tourists: ['OOH, A TRUCK!', 'TAKE MY PICTURE!', 'WHERE IS THE MUSEUM?', 'SO MANY POTHOLES!'],
protest: ['NO MORE TRUCKS!', 'HONK IF YOU AGREE!', 'WHAT DO WE WANT?!', 'BIKE LANES NOW!'],
busker: ['WONDERWALL!!', 'TIPS APPRECIATED!', 'ANY REQUESTS?', 'ENCORE!'],
picnic: ['PASS THE CHIPS', 'ANTS! ANTS!', 'PERFECT WEATHER'],
yoga: ['AND BREATHE...', 'DOWNWARD DOG', 'NAMASTE', 'FIND YOUR CENTRE'],
barcrawl: ['ONE MORE BAR!', 'I LOVE YOU MAN!', '*HIC*', 'WHICH WAY IS HOME?', 'KARAOKE!!'],
};
const BAGS = ['#d8c3a0', '#f2f2f2', '#c0392b', '#2471a3', '#239b56'];
const HATS = ['#ff4fa3', '#ffd400', '#3fd0ff', '#7dff6a'];
const SHIRTS = ['#d9534f', '#3d7dd8', '#f0ad4e', '#5cb85c', '#8e44ad', '#ecf0f1', '#2c3e50', '#16a085', '#e67e22', '#c0392b'];
const PANTS = ['#2c3e50', '#34495e', '#5d4037', '#1f2a44', '#6d6d6d', '#3e2723'];
const SKIN = ['#f1c27d', '#e0ac69', '#c68642', '#8d5524', '#ffdbac', '#a0673d'];
const HAIR = ['#2b1d14', '#4a3222', '#0f0f0f', '#a0703c', '#d6b370', '#7a7a7a', '#b5482f'];
const PED_RADIUS = 1.6;
const HAIL_LINES = ['TAXI!', 'TAXI! OVER HERE!', 'HEY! CAB!'];
/** Districts where people hail cabs. */
const CAB_TOWNS = new Set(['downtown', 'uptown', 'hood', 'village', 'port']);
/** Which group activities happen at which hours. */
const SCHEDULE: Record<GroupKind, [number, number, number][]> = {
joggers: [[6, 10, 1], [16, 20, 1]],
dogwalk: [[6, 21, 1]],
tourists: [[9, 18, 1]],
protest: [[10, 17, 0.5]],
busker: [[10, 22, 0.8]],
picnic: [[11, 17, 0.8]],
yoga: [[7, 11, 0.7]],
barcrawl: [[20, 27, 1.2]],
};
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)];
function inHours(hour: number, from: number, to: number): boolean {
const h = to > 24 && hour < from ? hour + 24 : hour;
return h >= from && h < to;
}
export class Ped {
x = 0;
y = 0;
angle = 0;
walk = 0;
s = 0;
dir = 1;
offset = 0;
speed = 12;
stride = 0;
state: PedState = 'walk';
tx = 0;
ty = 0;
onRoad = false;
timer = 0;
z = 0;
vz = 0;
vx = 0;
vy = 0;
spin = 0;
spinRate = 0;
limp = 0;
bubble = '';
bubbleTime = 0;
look: PedLook = { shirt: pick(SHIRTS), pants: pick(PANTS), skin: pick(SKIN), hair: pick(HAIR) };
/** Index of the market, party or rave this person belongs to, or -1. */
venue = -1;
/** Index into the active groups, or -1. */
group = -1;
/** Home spot for milling about (venues and stationary groups). */
hx = 0;
hy = 0;
hr = 0;
/** Formation offset behind the group leader (forward, sideways). */
ox = 0;
oy = 0;
chatter = 0;
crushed = false;
/** How much more blood this person can lose. Once it's gone they're just lying there: nothing more to run over. */
blood = 3 + Math.random() * 2.5;
sign = '';
dog: { x: number; y: number; angle: number; stride: number; color: string } | null = null;
/** Workers: their own chatter and working hours. */
lines: string[] | null = null;
hours: [number, number] | null = null;
}
interface Group {
kind: GroupKind;
leader: Ped;
members: Ped[];
x: number;
y: number;
angle: number;
stationary: boolean;
}
export class Pedestrians {
readonly peds: Ped[] = [];
/** Market shoppers, partygoers and ravers; they stay at their venue. */
readonly crowd: Ped[] = [];
/** People doing things together: joggers, protests, picnics… */
readonly groupPeds: Ped[] = [];
/** Farmers, builders and factory hands, tied to their workplace. */
readonly workers: Ped[] = [];
private activeWorkers: Ped[] = [];
private groups: Group[] = [];
private venues: Venue[] = [];
private open: boolean[] = [];
private time = 0;
hour = 12;
/** Called when a pedestrian is struck, with the impact speed. */
onHit: ((ped: Ped, speed: number) => void) | null = null;
constructor(
private readonly city: City,
private readonly effects: Effects,
) {}
clear(): void {
this.peds.length = 0;
this.crowd.length = 0;
this.groupPeds.length = 0;
this.groups = [];
this.workers.length = 0;
this.activeWorkers = [];
}
/** Add a worker who potters about within `hr` of (x, y) during their hours. */
addWorker(x: number, y: number, hr: number, look: Partial<PedLook>, lines: string[], hours: [number, number]): Ped {
const p = new Ped();
p.hx = x;
p.hy = y;
p.hr = Math.max(4, hr);
p.look = { ...p.look, ...look };
p.lines = lines;
p.hours = hours;
p.speed = range(5, 9);
p.chatter = range(2, 20);
this.resetMill(p);
this.workers.push(p);
return p;
}
/** Blood, but only so much of it per person. */
private bleed(p: Ped, x: number, y: number, size: number, vx = 0, vy = 0): void {
if (p.blood <= 0) return;
const s = Math.min(size, p.blood);
p.blood -= s;
this.effects.splat(x, y, s, vx, vy);
}
/** Paramedics patch someone up. */
revive(p: Ped): void {
if (p.state !== 'down' && p.state !== 'slide') return;
p.state = 'down';
p.timer = 0.01;
p.crushed = false;
p.blood = 3 + Math.random() * 2.5;
p.bubble = pick(['THANK YOU, DOC!', 'I CAN FEEL MY LEGS!', 'BEST. AMBULANCE. EVER.']);
p.bubbleTime = 2.5;
}
/**
* Paramedics load someone into the ambulance: passers-by are taken off to hospital
* (gone from the street); venue-goers, groups and workers are treated on the spot.
*/
evacuate(p: Ped): boolean {
const i = this.peds.indexOf(p);
if (i < 0) {
this.revive(p);
return false;
}
this.peds.splice(i, 1);
return true;
}
/** Injured people near a spot (for the ambulance). */
injuredNear(x: number, y: number, r: number): Ped[] {
return this.all().filter((p) => (p.state === 'down' || p.state === 'slide') && Math.hypot(p.x - x, p.y - y) < r);
}
/** Everyone lying hurt anywhere in the city (for the ambulance dispatcher). */
injured(): Ped[] {
return this.all().filter((p) => p.state === 'down' || p.state === 'slide');
}
/** People at the curb waving down a cab. */
hailing(): Ped[] {
return this.peds.filter((p) => p.state === 'hail');
}
/** They got into a taxi: gone from the street (someone new turns up elsewhere). */
board(p: Ped): void {
const i = this.peds.indexOf(p);
if (i >= 0) this.peds.splice(i, 1);
}
/** A taxi lets its fare out at the curb, on the nearest sidewalk. */
dropOff(x: number, y: number): void {
let best: { walk: number; s: number; d: number } | null = null;
for (const w of this.city.walks) {
for (let i = 0; i < w.pts.length; i++) {
const q = w.pts[i];
if (Math.abs(q.x - x) > 40 || Math.abs(q.y - y) > 40) continue;
const d = Math.hypot(q.x - x, q.y - y);
if (!best || d < best.d) best = { walk: w.id, s: w.cum[i], d };
}
}
if (!best) return;
const p = new Ped();
p.walk = best.walk;
p.s = best.s;
p.dir = Math.random() < 0.5 ? 1 : -1;
p.offset = range(-2, 2);
p.speed = range(9, 16);
p.x = x;
p.y = y;
p.state = 'cross';
const pt = pointAt(this.city.walks[p.walk], p.s);
p.tx = pt.x;
p.ty = pt.y;
if (Math.random() < 0.5) p.look.bag = pick(BAGS);
this.say(p, ['THANKS, DRIVER!', 'WHAT A RIDE', 'HOME SWEET HOME', 'JUST IN TIME!']);
this.peds.push(p);
}
/** Everyone who is out and about right now. */
all(): Ped[] {
return [...this.peds, ...this.crowd.filter((p) => this.open[p.venue] || p.state === 'fly' || p.state === 'slide'), ...this.groupPeds, ...this.activeWorkers];
}
setupVenues(venues: Venue[]): void {
this.venues = venues;
this.crowd.length = 0;
this.open = venues.map(() => false);
venues.forEach((v, i) => {
const count = v.kind === 'market' ? 14 : v.kind === 'rave' ? 30 : 18;
for (let k = 0; k < count; k++) {
const p = new Ped();
p.venue = i;
p.hx = v.x;
p.hy = v.y;
p.hr = v.r * 0.85;
this.resetMill(p);
p.speed = range(6, 11);
p.chatter = range(1, 10);
if (v.kind === 'market' && Math.random() < 0.6) p.look.bag = pick(BAGS);
if (v.kind !== 'market' && Math.random() < 0.6) p.look.hat = pick(HATS);
this.crowd.push(p);
}
});
}
/** Open or close a venue (markets by day, parties and raves by night). */
setVenueOpen(index: number, open: boolean): void {
if (this.open[index] === open) return;
this.open[index] = open;
if (open) for (const p of this.crowd) if (p.venue === index && p.state !== 'fly') this.resetMill(p);
}
private resetMill(p: Ped): void {
const a = Math.random() * Math.PI * 2;
const d = Math.sqrt(Math.random()) * p.hr;
p.x = p.hx + Math.cos(a) * d;
p.y = p.hy + Math.sin(a) * d;
p.tx = p.x;
p.ty = p.y;
p.state = 'mill';
p.z = 0;
}
setCount(count: number, truck: Truck, view: View): void {
while (this.peds.length < count) {
const ped = new Ped();
this.peds.push(ped);
this.place(ped, truck, view, true);
}
for (let i = this.peds.length - 1; i >= 0 && this.peds.length > count; i--) {
const p = this.peds[i];
if (!inView(p.x, p.y, view, 30) && p.state !== 'fly' && p.state !== 'slide') this.peds.splice(i, 1);
}
}
roadObstacles(): Vec[] {
return this.all()
.filter((p) => (p.state === 'cross' && p.onRoad) || ((p.state === 'down' || p.state === 'slide' || p.state === 'follow' || p.state === 'flee' || p.state === 'mill') && this.city.surfaceAt(p.x, p.y) === 1))
.map((p) => ({ x: p.x, y: p.y }));
}
hearHorn(x: number, y: number): void {
for (const p of this.all()) {
if (p.state === 'fly' || p.state === 'down' || p.state === 'slide') continue;
const d = Math.hypot(p.x - x, p.y - y);
if (d > 120) continue;
this.flee(p, (p.x - x) / (d || 1), (p.y - y) / (d || 1));
}
}
private place(ped: Ped, truck: Truck, view: View, initial: boolean): void {
const walk = this.findWalk(truck, view, initial);
Object.assign(ped, new Ped());
ped.walk = walk.id;
ped.s = Math.random() * walk.len;
ped.dir = Math.random() < 0.5 ? 1 : -1;
ped.offset = range(-2.5, 2.5);
ped.speed = range(9, 16);
if (Math.random() < 0.4) ped.look.bag = pick(BAGS);
ped.chatter = range(5, 30);
this.position(ped);
}
private findWalk(truck: Truck, view: View, initial: boolean): Walk {
const walks = this.city.walks;
for (let attempt = 0; attempt < 80; attempt++) {
const w = pick(walks);
const p = pointAt(w, w.len / 2);
const d = Math.hypot(p.x - truck.x, p.y - truck.y);
if (initial ? d < 1100 : d > 200 && d < 850 && !inView(p.x, p.y, view, 20)) return w;
}
return pick(walks);
}
private position(ped: Ped): void {
const w = this.city.walks[ped.walk];
const p = pointAt(w, ped.s);
const q = pointAt(w, ped.s + ped.dir * 2);
const ang = Math.atan2(q.y - p.y, q.x - p.x);
ped.x = p.x - Math.sin(ang) * ped.offset;
ped.y = p.y + Math.cos(ang) * ped.offset;
ped.angle = ang;
}
private say(ped: Ped, lines: string[]): void {
ped.bubble = pick(lines);
ped.bubbleTime = 2;
}
private flee(ped: Ped, dx: number, dy: number): void {
if (ped.state === 'flee') return;
ped.state = 'flee';
ped.timer = range(0.5, 0.8);
ped.vx = dx * 75;
ped.vy = dy * 75;
if (ped.bubbleTime <= 0) this.say(ped, SCREAMS);
}
private returnToWalk(ped: Ped): void {
if (ped.venue >= 0 || ped.hr > 0 || ped.group >= 0) {
const g = ped.group >= 0 ? this.groups[ped.group] : null;
if (g && !g.stationary) {
ped.state = 'follow';
return;
}
ped.state = 'mill';
ped.tx = ped.hx + range(-ped.hr, ped.hr) * 0.6;
ped.ty = ped.hy + range(-ped.hr, ped.hr) * 0.6;
return;
}
const w = this.city.walks[ped.walk];
const p = pointAt(w, ped.s);
ped.state = 'cross';
ped.onRoad = this.city.surfaceAt(ped.x, ped.y) === 1;
ped.tx = p.x;
ped.ty = p.y;
}
/**
* Physical hit: find the contact point on the vehicle's box, take the vehicle's
* velocity at that point, and throw the person along the contact normal. A low
* car nose scoops them up over the hood; a flat truck front shoves them down and
* can run them over.
*/
strike(body: Body, layer: number, profile: Profile = 'low', careful = false): void {
if (layer !== 0) return;
const c = Math.cos(body.angle);
const s = Math.sin(body.angle);
for (const p of this.all()) {
if ((p.state === 'fly' && p.z > 3) || p.z > 6) continue;
// Bled out on the road: vehicles roll over them without another hit.
if (p.blood <= 0 && !careful) continue;
const dx = p.x - body.x;
const dy = p.y - body.y;
if (Math.abs(dx) > body.halfL + 6 || Math.abs(dy) > body.halfL + 6) continue;
const lx = dx * c + dy * s;
const ly = -dx * s + dy * c;
const qx = clamp(lx, -body.halfL, body.halfL);
const qy = clamp(ly, -body.halfW, body.halfW);
let nx = lx - qx;
let ny = ly - qy;
let d = Math.hypot(nx, ny);
const inside = d === 0;
if (inside) {
// Already under the vehicle: push out the nearest side.
const gaps = [body.halfL - lx, body.halfL + lx, body.halfW - ly, body.halfW + ly];
const m = Math.min(...gaps);
[nx, ny] = m === gaps[0] ? [1, 0] : m === gaps[1] ? [-1, 0] : m === gaps[2] ? [0, 1] : [0, -1];
d = 0;
} else {
nx /= d;
ny /= d;
}
if (d >= PED_RADIUS) continue;
// Back to world space.
const wnx = nx * c - ny * s;
const wny = nx * s + ny * c;
if (careful) {
// Careful drivers (traffic and city crews) never hurt anyone: the person jumps clear.
if (p.state === 'fly' || p.state === 'slide') continue;
p.x += wnx * (PED_RADIUS - d + 0.4);
p.y += wny * (PED_RADIUS - d + 0.4);
if (p.state !== 'down' && p.bubbleTime <= 0 && Math.hypot(body.vx, body.vy) > 8) this.say(p, SCREAMS);
continue;
}
const cpx = body.x + qx * c - qy * s;
const cpy = body.y + qx * s + qy * c;
const v = velocityAt(body, cpx, cpy);
const rvx = v.x - p.vx;
const rvy = v.y - p.vy;
const vn = rvx * wnx + rvy * wny;
if (vn < 4) {
if (p.state !== 'fly' && p.state !== 'slide') {
p.x += wnx * (PED_RADIUS - d);
p.y += wny * (PED_RADIUS - d);
}
continue;
}
const restitution = profile === 'flat' ? 0.3 : 0.15;
const tvx = rvx - wnx * vn;
const tvy = rvy - wny * vn;
const wasDown = p.state === 'down' || p.state === 'slide';
p.vx += wnx * vn * (1 + restitution) + tvx * 0.35;
p.vy += wny * vn * (1 + restitution) + tvy * 0.35;
p.vz = Math.max(p.vz, profile === 'low' ? vn * 0.55 : vn * 0.16) + 8;
p.z = Math.max(p.z, 0.8);
p.spinRate = (tvx * -wny + tvy * wnx) * 0.12 + range(-6, 6) + (profile === 'low' ? 8 : 0);
p.state = 'fly';
p.x += wnx * (PED_RADIUS - d);
p.y += wny * (PED_RADIUS - d);
if (profile === 'flat' && (inside || wasDown) && vn > 10) {
// Run over by the wheels.
p.crushed = true;
p.vz = 4;
this.bleed(p, p.x, p.y, Math.min(3, 1.4 + vn / 50), v.x, v.y);
}
this.say(p, HIT_LINES);
this.bleed(p, cpx, cpy, Math.min(2.4, 0.5 + vn / 60), v.x, v.y);
this.onHit?.(p, vn);
}
}
/** Throw a new person into the world (a motorcyclist off their bike). */
launch(x: number, y: number, vx: number, vy: number, shirt: string, helmet: string): void {
const p = new Ped();
p.x = x;
p.y = y;
p.state = 'fly';
p.vx = vx;
p.vy = vy;
p.vz = 40 + Math.hypot(vx, vy) * 0.5;
p.z = 1;
p.spinRate = range(10, 20) * (Math.random() < 0.5 ? -1 : 1);
p.look = { ...p.look, shirt, hair: helmet };
let best = 0;
let bestD = Infinity;
this.city.walks.forEach((w, i) => {
const d = Math.hypot(w.pts[0].x - x, w.pts[0].y - y);
if (d < bestD) {
bestD = d;
best = i;
}
});
p.walk = best;
this.say(p, HIT_LINES);
this.peds.push(p);
}
blast(x: number, y: number, radius: number): void {
for (const p of this.all()) {
const dx = p.x - x;
const dy = p.y - y;
const d = Math.hypot(dx, dy);
if (d > radius || d < 0.1) continue;
const f = (1 - d / radius) * 140;
p.state = 'fly';
p.vx = (dx / d) * f;
p.vy = (dy / d) * f;
p.vz = 40 + f * 0.5;
p.z = 1;
p.spinRate = range(8, 18) * (Math.random() < 0.5 ? -1 : 1);
this.say(p, HIT_LINES);
}
}
// --- Groups of people doing things.
private wantedGroups(): GroupKind[] {
const out: GroupKind[] = [];
for (const [kind, slots] of Object.entries(SCHEDULE) as [GroupKind, [number, number, number][]][]) {
for (const [from, to, w] of slots) if (inHours(this.hour, from, to)) for (let i = 0; i < Math.round(w * 2); i++) out.push(kind);
}
return out;
}
private spawnGroup(kind: GroupKind, truck: Truck, view: View): void {
const stationary = kind === 'busker' || kind === 'picnic' || kind === 'yoga';
const walk = this.findWalk(truck, view, false);
const mk = () => {
const p = new Ped();
p.group = this.groups.length;
p.chatter = range(3, 15);
return p;
};
const leader = mk();
leader.walk = walk.id;
leader.s = Math.random() * walk.len;
leader.dir = Math.random() < 0.5 ? 1 : -1;
this.position(leader);
const group: Group = { kind, leader, members: [leader], x: leader.x, y: leader.y, angle: leader.angle, stationary };
const size = { joggers: 4, dogwalk: 1, tourists: 7, protest: 9, busker: 6, picnic: 4, yoga: 6, barcrawl: 5 }[kind];
if (stationary) {
// Set up on the nearest patch of grass or plaza beside the sidewalk.
const side = Math.random() < 0.5 ? -1 : 1;
let cx = leader.x;
let cy = leader.y;
for (const off of [22, 30, 14]) {
const tx = leader.x - Math.sin(leader.angle) * off * side;
const ty = leader.y + Math.cos(leader.angle) * off * side;
const push = this.city.resolveCircle(tx, ty, 9, 0);
if (!push.x && !push.y && this.city.surfaceAt(tx, ty) !== 1) {
cx = tx;
cy = ty;
break;
}
}
group.x = cx;
group.y = cy;
group.angle = Math.random() * Math.PI * 2;
}
for (let i = 1; i < size; i++) group.members.push(mk());
group.members.forEach((p, i) => {
if (stationary) {
p.hx = group.x;
p.hy = group.y;
if (kind === 'yoga') {
const col = (i % 3) - 1;
const row = Math.floor(i / 3);
p.x = group.x + Math.cos(group.angle) * row * 7 - Math.sin(group.angle) * col * 6;
p.y = group.y + Math.sin(group.angle) * row * 7 + Math.cos(group.angle) * col * 6;
p.angle = group.angle + Math.PI;
p.look.hat = i === 0 ? '#ffffff' : undefined;
} else {
const a = (i / size) * Math.PI * 2;
const r = i === 0 ? 0 : kind === 'busker' ? 9 : 5;
p.x = group.x + Math.cos(a) * r;
p.y = group.y + Math.sin(a) * r;
p.angle = Math.atan2(group.y - p.y, group.x - p.x);
}
p.hr = 0;
p.state = 'mill';
p.tx = p.x;
p.ty = p.y;
} else if (i > 0) {
const cols = kind === 'protest' ? 3 : 2;
p.ox = -(Math.floor((i - 1) / cols) + 1) * (kind === 'joggers' ? 5 : 4.5);
p.oy = (((i - 1) % cols) - (cols - 1) / 2) * (kind === 'protest' ? 7 : 4.5);
p.x = leader.x + Math.cos(leader.angle) * p.ox - Math.sin(leader.angle) * p.oy;
p.y = leader.y + Math.sin(leader.angle) * p.ox + Math.cos(leader.angle) * p.oy;
p.state = 'follow';
}
p.speed = kind === 'joggers' ? range(26, 32) : kind === 'barcrawl' ? 7 : kind === 'protest' ? 7 : range(8, 11);
if (kind === 'protest') p.sign = pick(['NO TRUCKS', 'SLOW DOWN', 'BIKES!', 'HONK NOW', 'SAVE TREES']);
if (kind === 'joggers') p.look.hat = pick(['#ff4fa3', '#3fd0ff', '#ffd400']);
if (kind === 'tourists' && i === 0) p.sign = 'TOUR';
if (kind === 'barcrawl' && Math.random() < 0.5) p.look.hat = pick(HATS);
});
leader.speed = group.members[1]?.speed ?? leader.speed;
if (kind === 'dogwalk') leader.dog = { x: leader.x, y: leader.y, angle: leader.angle, stride: 0, color: pick(['#c8a06a', '#2b2b2b', '#f2efe6', '#8a5a3c']) };
this.groups.push(group);
this.groupPeds.push(...group.members);
}
private updateGroups(dt: number, truck: Truck, view: View): void {
const wanted = this.wantedGroups();
// Retire groups that are far away or out of season.
for (let gi = this.groups.length - 1; gi >= 0; gi--) {
const g = this.groups[gi];
const far = Math.hypot(g.leader.x - truck.x, g.leader.y - truck.y) > 1000 && !inView(g.leader.x, g.leader.y, view, 40);
const done = !wanted.includes(g.kind) && !inView(g.x, g.y, view, 40);
if (far || done) {
this.groups.splice(gi, 1);
const gone = new Set(g.members);
for (let i = this.groupPeds.length - 1; i >= 0; i--) if (gone.has(this.groupPeds[i])) this.groupPeds.splice(i, 1);
this.groups.forEach((other, k) => other.members.forEach((m) => (m.group = k)));
}
}
if (this.groups.length < 7 && wanted.length && Math.random() < dt * 2) this.spawnGroup(pick(wanted), truck, view);
for (const g of this.groups) {
if (g.stationary) continue;
g.x = g.leader.x;
g.y = g.leader.y;
g.angle = g.leader.angle;
const dog = g.leader.dog;
if (dog) {
const tx = g.leader.x - Math.sin(g.leader.angle) * 4 + Math.cos(g.leader.angle) * 5;
const ty = g.leader.y + Math.cos(g.leader.angle) * 4 + Math.sin(g.leader.angle) * 5;
const dx = tx - dog.x;
const dy = ty - dog.y;
const d = Math.hypot(dx, dy);
if (d > 0.5) {
const step = Math.min(d, (g.leader.state === 'down' ? 0 : 1) * 40 * dt + (d > 12 ? d * dt * 3 : 0));
dog.x += (dx / d) * step;
dog.y += (dy / d) * step;
dog.angle = Math.atan2(dy, dx);
dog.stride += step * 0.8;
}
}
}
}
// --- Simulation.
update(dt: number, truck: Truck, view: View): void {
this.time += dt;
this.updateGroups(dt, truck, view);
this.activeWorkers = this.workers.filter(
(p) => Math.abs(p.hx - truck.x) < 900 && Math.abs(p.hy - truck.y) < 900 && (p.state !== 'mill' || !p.hours || inHours(this.hour, p.hours[0], p.hours[1])),
);
const tv = { x: truck.cab.vx, y: truck.cab.vy };
const tSpeed = Math.hypot(tv.x, tv.y);
for (const p of this.all()) {
p.bubbleTime -= dt;
p.chatter -= dt;
if (p.chatter <= 0) {
p.chatter = range(6, 18);
const v = p.venue >= 0 ? this.venues[p.venue] : null;
const g = p.group >= 0 ? this.groups[p.group] : null;
if (p.bubbleTime <= 0 && (p.state === 'mill' || p.state === 'walk' || p.state === 'follow') && Math.random() < (v || g || p.lines ? 0.45 : 0.12)) {
this.say(p, p.lines ?? (v ? (v.kind === 'market' ? MARKET_LINES : v.kind === 'rave' ? RAVE_LINES : PARTY_LINES) : g ? GROUP_LINES[g.kind] : ERRAND_LINES));
}
}
if (p.venue < 0 && p.group < 0 && !p.lines && p.state !== 'fly' && p.state !== 'slide' && p.state !== 'down' && Math.hypot(p.x - truck.x, p.y - truck.y) > 950 && !inView(p.x, p.y, view, 30)) {
this.place(p, truck, view, false);
continue;
}
if (p.state === 'walk' && p.venue < 0 && p.group < 0 && !p.lines && Math.random() < dt / 220 && CAB_TOWNS.has(this.city.walks[p.walk].district)) {
p.state = 'hail';
p.timer = range(35, 55);
p.chatter = 0;
}
if ((p.state === 'walk' || p.state === 'cross' || p.state === 'mill' || p.state === 'follow' || p.state === 'hail') && tSpeed > 30 && truck.layer === 0) {
const rx = p.x - truck.x;
const ry = p.y - truck.y;
const ahead = (rx * tv.x + ry * tv.y) / tSpeed;
const lateral = (rx * tv.y - ry * tv.x) / tSpeed;
// Ravers and bar-crawlers are slow to notice.
const alert = p.venue >= 0 && this.venues[p.venue].kind === 'rave' ? 1.2 : p.group >= 0 && this.groups[p.group]?.kind === 'barcrawl' ? 1 : 4;
if (ahead > 0 && ahead < 26 + tSpeed * 0.35 && Math.abs(lateral) < 13 && Math.random() < dt * alert) {
const away = lateral >= 0 ? 1 : -1;
this.flee(p, (-tv.y / tSpeed) * -away, (tv.x / tSpeed) * -away);
}
}
this.step(p, dt);
}
}
private step(p: Ped, dt: number): void {
switch (p.state) {
case 'mill': {
const v = p.venue >= 0 ? this.venues[p.venue] : null;
const dance = v ? v.kind !== 'market' : false;
const dx = p.tx - p.x;
const dy = p.ty - p.y;
const d = Math.hypot(dx, dy);
if (d < 1) {
p.timer -= dt;
if (dance) {
p.stride += dt * (v!.kind === 'rave' ? 22 : 14);
p.angle += Math.sin(this.time * 6 + p.speed) * dt * 3;
} else if (p.lines) {
p.stride += dt * 6;
} else if (p.group >= 0) {
const g = this.groups[p.group];
if (g?.kind === 'yoga') p.stride = Math.sin(this.time * 0.8) * 2;
if (g?.kind === 'busker' && g.members[0] === p) p.stride += dt * 10;
}
if (p.timer <= 0 && p.hr > 0) {
p.timer = dance ? range(1, 4) : range(2, 6);
const a = Math.random() * Math.PI * 2;
const r = Math.sqrt(Math.random()) * p.hr;
p.tx = p.hx + Math.cos(a) * r;
p.ty = p.hy + Math.sin(a) * r;
}
} else {
const step = Math.min(d, p.speed * dt);
p.x += (dx / d) * step;
p.y += (dy / d) * step;
p.angle = Math.atan2(dy, dx);
p.stride += dt * p.speed * 0.9;
}
break;
}
case 'follow': {
const g = this.groups[p.group];
if (!g) {
p.state = 'walk';
break;
}
const l = g.leader;
const wobble = g.kind === 'barcrawl' ? Math.sin(this.time * 2 + p.ox) * 4 : 0;
const tx = l.x + Math.cos(l.angle) * p.ox - Math.sin(l.angle) * (p.oy + wobble);
const ty = l.y + Math.sin(l.angle) * p.ox + Math.cos(l.angle) * (p.oy + wobble);
const dx = tx - p.x;
const dy = ty - p.y;
const d = Math.hypot(dx, dy);
if (d > 0.4) {
const step = Math.min(d, (l.speed * 1.4 + d) * dt);
p.x += (dx / d) * step;
p.y += (dy / d) * step;
p.angle = d > 1.5 ? Math.atan2(dy, dx) : l.angle;
p.stride += step * 0.9;
}
break;
}
case 'walk': {
const w = this.city.walks[p.walk];
const crawl = p.group >= 0 && this.groups[p.group]?.kind === 'barcrawl';
p.s += p.dir * p.speed * (p.limp > 0 ? 0.45 : 1) * dt;
p.limp -= dt;
p.stride += dt * p.speed * 0.9;
if (p.s < 0 || p.s > w.len) this.atEnd(p, w);
else {
this.position(p);
if (crawl) p.offset = Math.sin(this.time * 1.7) * 4;
}
break;
}
case 'cross': {
const dx = p.tx - p.x;
const dy = p.ty - p.y;
const d = Math.hypot(dx, dy);
const step = (p.limp > 0 ? 9 : 18) * dt;
p.stride += dt * 16;
p.limp -= dt;
if (d <= step) {
p.state = 'walk';
p.onRoad = false;
this.position(p);
} else {
p.x += (dx / d) * step;
p.y += (dy / d) * step;
p.angle = Math.atan2(dy, dx);
}
break;
}
case 'flee': {
p.timer -= dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vx *= 0.94;
p.vy *= 0.94;
p.angle = Math.atan2(p.vy, p.vx);
p.stride += dt * 30;
if (p.timer <= 0) {
p.vx = p.vy = 0;
this.returnToWalk(p);
}
break;
}
case 'fly': {
p.vz -= 300 * dt;
p.z += p.vz * dt;
p.spin += p.spinRate * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vx *= 0.998;
p.vy *= 0.998;
const push = this.city.resolveCircle(p.x, p.y, 2, 0);
if (push.x || push.y) {
// Slam into a wall: bounce off and leave a mark.
const l = Math.hypot(push.x, push.y);
const into = (p.vx * push.x + p.vy * push.y) / l;
p.x += push.x;
p.y += push.y;
if (into < 0) {
p.vx -= (push.x / l) * into * 1.4;
p.vy -= (push.y / l) * into * 1.4;
if (-into > 25) this.bleed(p, p.x, p.y, Math.min(2, -into / 60));
}
}
if (p.z <= 0) {
p.z = 0;
const hv = Math.hypot(p.vx, p.vy);
if (Math.abs(p.vz) > 50 && !p.crushed) {
p.vz = -p.vz * 0.3;
p.vx *= 0.75;
p.vy *= 0.75;
this.bleed(p, p.x, p.y, 0.6);
} else if (hv > 12) {
p.state = 'slide';
p.vz = 0;
} else {
this.layDown(p);
}
}
break;
}
case 'slide': {
// Tumbling along the asphalt, smearing as they go.
const hv = Math.hypot(p.vx, p.vy);
const dv = Math.min(hv, 0.75 * 60 * dt);
const ox = p.x;
const oy = p.y;
if (hv > 0) {
p.vx -= (p.vx / hv) * dv;
p.vy -= (p.vy / hv) * dv;
}
p.x += p.vx * dt;
p.y += p.vy * dt;
p.spin += p.spinRate * dt * 0.5;
p.spinRate *= Math.exp(-3 * dt);
const push = this.city.resolveCircle(p.x, p.y, 2, 0);
if (push.x || push.y) {
p.x += push.x;
p.y += push.y;
p.vx *= -0.2;
p.vy *= -0.2;
}
if (p.blood > 0) {
p.blood -= Math.hypot(p.x - ox, p.y - oy) * 0.01;
this.effects.smear(ox, oy, p.x, p.y);
}
if (Math.hypot(p.vx, p.vy) < 4) this.layDown(p);
break;
}
case 'hail': {
p.timer -= dt;
p.stride = 0;
if (p.bubbleTime <= 0 && Math.random() < dt * 0.6) this.say(p, HAIL_LINES);
if (p.timer <= 0) {
this.say(p, ['FORGET IT, I WILL WALK', 'NO CABS EVER', 'UGH.']);
p.state = 'walk';
}
break;
}
case 'down': {
p.timer -= dt;
if (p.timer <= 0) {
p.limp = 14;
p.crushed = false;
this.say(p, ANGRY);
this.returnToWalk(p);
}
break;
}
}
}
private layDown(p: Ped): void {
this.bleed(p, p.x, p.y, p.crushed ? 1.8 : 0.9);
p.state = 'down';
p.vx = p.vy = 0;
// Badly hurt people wait for the paramedics; the rest get up after a while.
p.timer = p.crushed ? range(140, 180) : range(30, 55);
p.angle += p.spin;
p.spin = 0;
}
private atEnd(p: Ped, w: Walk): void {
const end = w.ends[p.dir > 0 ? 1 : 0];
const options: { walk: number; atStart: boolean; road: boolean }[] = end.links.map((l) => ({ ...l, road: false }));
if (end.cross && Math.random() < 0.45) options.push({ ...end.cross, road: true }, { ...end.cross, road: true });
if (!options.length) {
p.dir = -p.dir;
p.s = Math.max(0, Math.min(w.len, p.s));
return;
}
const choice = pick(options);
const next = this.city.walks[choice.walk];
p.walk = choice.walk;
p.dir = choice.atStart ? 1 : -1;
p.s = choice.atStart ? 0 : next.len;
const target = next.ends[choice.atStart ? 0 : 1];
p.state = 'cross';
p.onRoad = choice.road;
p.tx = target.x;
p.ty = target.y;
}
// --- Drawing.
draw(ctx: CanvasRenderingContext2D, view: View, time: number): void {
// Group props underneath: blankets, mats, guitar cases.
for (const g of this.groups) {
if (!g.stationary || !inView(g.x, g.y, view, 30)) continue;
if (g.kind === 'picnic') {
ctx.save();
ctx.translate(g.x, g.y);
ctx.rotate(g.angle);
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 4; j++) {
ctx.fillStyle = (i + j) % 2 ? '#d93a3a' : '#f4f1ea';
ctx.fillRect(-6 + i * 3, -6 + j * 3, 3, 3);
}
}
ctx.fillStyle = '#8a5a3c';
ctx.fillRect(-1.5, -1, 3, 2);
ctx.restore();
} else if (g.kind === 'yoga') {
for (const m of g.members) {
ctx.save();
ctx.translate(m.x, m.y);
ctx.rotate(m.angle);
ctx.fillStyle = m === g.leader ? '#3fd0ff' : '#a66bd3';
ctx.fillRect(-3.5, -1.4, 7, 2.8);
ctx.restore();
}
} else if (g.kind === 'busker') {
ctx.fillStyle = '#5a3a24';
ctx.fillRect(g.x + 3, g.y + 2, 5, 2.4);
ctx.fillStyle = '#d4af37';
ctx.beginPath();
ctx.arc(g.x + 5.5, g.y + 3.2, 0.6, 0, Math.PI * 2);
ctx.fill();
}
}
for (const p of this.all()) {
if (!inView(p.x, p.y, view, 20)) continue;
if (p.dog) {
ctx.strokeStyle = 'rgba(40, 30, 20, 0.6)';
ctx.lineWidth = 0.25;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.dog.x, p.dog.y);
ctx.stroke();
drawAnimal(ctx, 'dog', p.dog.x, p.dog.y, p.dog.angle, p.dog.stride, 'walk');
}
const pose = p.state === 'fly' ? 'fly' : p.state === 'down' || p.state === 'slide' ? 'down' : 'walk';
const bounce = p.venue >= 0 && this.venues[p.venue].kind === 'rave' && p.state === 'mill' ? Math.abs(Math.sin(time * 9 + p.speed)) * 3 : 0;
drawPed(ctx, p.look, p.x, p.y, p.angle, p.stride, pose, p.z + bounce, p.spin, time);
if (p.sign && pose === 'walk') {
ctx.fillStyle = '#7a5532';
ctx.fillRect(p.x - 0.2, p.y - 4, 0.4, 3);
ctx.fillStyle = p.sign === 'TOUR' ? '#ffcc33' : '#f4f1ea';
ctx.fillRect(p.x - 3.5, p.y - 6.6, 7, 2.8);
ctx.fillStyle = '#c0392b';
ctx.font = '700 1.8px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(p.sign, p.x, p.y - 5.1);
}
}
}
/** Glowsticks and party lights for the night overlay. */
lights(view: View): { x: number; y: number; color: string }[] {
const out: { x: number; y: number; color: string }[] = [];
for (const p of this.crowd) {
if (!this.open[p.venue] || !p.look.hat || !inView(p.x, p.y, view, 10)) continue;
out.push({ x: p.x, y: p.y, color: p.look.hat });
}
return out;
}
}
