SOURCE / PINNED RELEASE
Made of little things.
Pursuit PIT
- Release
- 565f4981bdc3…
- Author-recorded commit
- 197afdbed44b…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-951414e7fd7
Archive hash verified: 3b0485589942fd5c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Top-down 2D simulation of the highway. World axes: x is lateral (right is +),
// s is distance along the road (forward is +). yaw 0 faces +s; positive yaw turns
// toward +x. JS numbers are doubles, so s can grow forever without rebasing.
// Space stays road-relative through bends and hills (road.ts); cars only feel their forces.
import { ALL_PARTS, nearestPart } from './parts.js';
import { curvature, grade } from './road.js';
import { Birds } from './birds.js';
import { closedAhead, closedLanes, leftWall, trafficAt } from './zones.js';
import { randomSuspectColor, suspectHex, type SuspectColor } from './colors.js';
export const LANE_W = 3.7;
export const LANES = 6;
export const EDGE = (LANES * LANE_W) / 2; // painted edge line
export const SHOULDER = 1.3;
export const ROAD_HALF = EDGE + SHOULDER; // guardrail position
export const laneX = (lane: number): number => (lane - (LANES - 1) / 2) * LANE_W;
// On-ramps join from the right every RAMP_EVERY metres. Ramp traffic comes down a curved
// slip road, then uses an acceleration (merge) lane outside lane 5 before it tapers away.
// For everyone else the right guardrail just steps out around the merge lane, and an
// invisible barrier closes the gore where the slip road meets it, so the player can't leave.
export const RAMP_EVERY = 1100;
export const RAMP_OFFSET = 500;
export const RAMP_CURVE = 300; // length of the curved part of the slip road
export const RAMP_SWING = 45; // lateral distance the curve covers
export const RAMP_GORE = 70;
export const MERGE_LEN = 230;
export const MERGE_TAPER = 70;
export const MERGE_X = laneX(LANES); // the merge lane sits where a 7th lane would
const WALL_OUT = EDGE + LANE_W + SHOULDER;
export const MPS_TO_MPH = 2.23694;
/** Top speed of the patrol car: 160 mph. */
export const TOP_SPEED = 160 / MPS_TO_MPH;
/** The suspect's car tops out a little lower: 155 mph. */
export const SUSPECT_TOP_SPEED = 155 / MPS_TO_MPH;
/** Traffic cruises between 60 and 100 mph. */
export const TRAFFIC_MIN = 60 / MPS_TO_MPH;
export const TRAFFIC_MAX = 100 / MPS_TO_MPH;
const ESCAPE_GAP = 700;
const SPIN_OUT = 1.05; // rad away from the road direction counts as a wreck
const RIG_SPIN_OUT = 0.8; // a rig slewed this far has jackknifed: it's a wreck sooner
const RIG_SHAKEN = 4; // m/s impact that unsettles a rig: it loses grip and starts to swing
const EXPLODE_SPEED = 7; // m/s into the guardrail that makes a (non-player) car explode
const DAMAGE_SPEED = 0.8; // m/s closing speed at contact that knocks a panel off
// Traffic never blocks more than this many lanes in any ROUTE_BAND-long stretch,
// so there is always a way through for the player (and the suspect).
export const MAX_BLOCKED = LANES - 2;
export const ROUTE_BAND = 40;
/** Start of the ramp whose zone (slip road to end of taper) covers s. */
export function rampAt(s: number): number {
return Math.floor((s - RAMP_OFFSET + RAMP_CURVE + 260) / RAMP_EVERY) * RAMP_EVERY + RAMP_OFFSET;
}
/** Slip-road centreline x for a point d metres before the ramp joins (d >= 0). */
export function rampX(d: number): number {
if (d <= RAMP_CURVE) return MERGE_X + RAMP_SWING * (d / RAMP_CURVE) ** 2;
return MERGE_X + RAMP_SWING + ((2 * RAMP_SWING) / RAMP_CURVE) * (d - RAMP_CURVE);
}
/** dx/ds of the slip road (negative: it heads left toward the highway). */
function rampSlope(d: number): number {
return -((2 * RAMP_SWING) / RAMP_CURVE) * Math.min(1, Math.max(0, d) / RAMP_CURVE);
}
/** Right guardrail (or gore barrier) position at s. */
export function rightWall(s: number): number {
const d = s - rampAt(s);
if (d < 0 || d > MERGE_LEN + MERGE_TAPER) return ROAD_HALF;
if (d <= MERGE_LEN) return WALL_OUT;
return WALL_OUT + (ROAD_HALF - WALL_OUT) * ((d - MERGE_LEN) / MERGE_TAPER);
}
/** Right painted edge at s (the merge lane moves it out). */
export function rightEdge(s: number): number {
return rightWall(s) - SHOULDER;
}
export type Kind = 'player' | 'suspect' | 'traffic' | 'log' | 'police';
/** What a vehicle is: sets its size and weight here, and its model in the renderer. */
export type VehicleType = 'sedan' | 'sports' | 'pickup' | 'lifted' | 'box' | 'semi' | 'tanker' | 'logger' | 'log';
/** Size, weight and share of traffic for each vehicle type. Rigs are tractor + trailer. */
export const VEHICLES: Record<VehicleType, { len: number; wid: number; mass: number; share: number }> = {
sedan: { len: 4.7, wid: 1.9, mass: 1500, share: 40 },
sports: { len: 4.3, wid: 1.85, mass: 1250, share: 10 },
pickup: { len: 5.5, wid: 2.0, mass: 2300, share: 15 },
lifted: { len: 5.6, wid: 2.15, mass: 2800, share: 7 },
box: { len: 6.5, wid: 2.2, mass: 2600, share: 10 },
semi: { len: 17, wid: 2.5, mass: 16000, share: 11 }, // rigs need more room to spawn, so ask for more
tanker: { len: 17, wid: 2.5, mass: 18000, share: 7 },
logger: { len: 17, wid: 2.5, mass: 17000, share: 7 },
log: { len: 8.5, wid: 0.6, mass: 900, share: 0 }, // a log fallen off a logging truck
};
/** Tractor-trailer rigs: slow, kept right, and too big for the knock-off panels. */
export const RIGS: ReadonlySet<VehicleType> = new Set<VehicleType>(['semi', 'tanker', 'logger']);
const LOG_DROP_SPEED = 5; // m/s impact that shakes a logging truck's load loose
/**
* A rig in two parts: a 6.2 m tractor at the front and a 12.2 m trailer hinged on its fifth
* wheel, 5.2 m back from the front bumper. The trailer reaches 0.4 m ahead of the hinge, and
* its wheels are 9.4 m behind it.
*/
const RIG_TRACTOR = 6.2;
const RIG_HITCH = 5.2; // from the front
const RIG_TRAILER = 12.2;
const RIG_OVERHANG = 0.4; // trailer ahead of the hitch
const RIG_AXLE = 9.4; // hitch to trailer wheels
const RIG_JACKKNIFE = 1.3; // most the trailer can fold, rad
const TRAILER_KICK = 3; // m/s impact needed to swing a trailer about its hitch
/** The hitch point in world terms. */
function hitchOf(car: Car): [number, number] {
const back = car.len / 2 - RIG_HITCH;
return [car.x + Math.sin(car.yaw) * back, car.s + Math.cos(car.yaw) * back];
}
/** A vehicle's outline as boxes: one for most, tractor and trailer for a rig. */
export function shapesOf(car: Car): Box[] {
if (!RIGS.has(car.type)) return [car];
const fx = Math.sin(car.yaw), fs = Math.cos(car.yaw);
const ahead = car.len / 2 - RIG_TRACTOR / 2;
const [hx, hs] = hitchOf(car);
const back = RIG_TRAILER / 2 - RIG_OVERHANG;
return [
{ x: car.x + fx * ahead, s: car.s + fs * ahead, yaw: car.yaw, len: RIG_TRACTOR, wid: car.wid },
{ x: hx - Math.sin(car.trailerYaw) * back, s: hs - Math.cos(car.trailerYaw) * back, yaw: car.trailerYaw, len: RIG_TRAILER, wid: car.wid },
];
}
/** Lowest lane a rig will use: rigs stay out of the two left lanes. */
const RIG_LANE = 2;
// Road works close left lanes: no one moves into one that is shut just ahead (zones.ts).
const laneOk = (car: Car, lane: number) => lane >= (RIGS.has(car.type) ? RIG_LANE : 0) && lane < LANES
&& lane >= closedAhead(car.s - 20, 600);
/**
* At most this many open lanes may be taken in any stretch: two always stay free, or one
* where the road is down to three lanes (the mountains).
*/
export const maxBlocked = (s: number): number => {
const open = LANES - closedLanes(s);
return open <= 3 ? open - 1 : open - 2;
};
function randomType(): VehicleType {
const types = (Object.keys(VEHICLES) as VehicleType[]).filter((k) => VEHICLES[k].share > 0);
let r = Math.random() * types.reduce((n, k) => n + VEHICLES[k].share, 0);
for (const k of types) if ((r -= VEHICLES[k].share) < 0) return k;
return 'sedan';
}
export interface Car {
id: number;
kind: Kind;
x: number;
s: number;
yaw: number;
vx: number;
vs: number;
w: number;
len: number;
wid: number;
mass: number;
inertia: number;
color: number;
maxSpeed: number;
accel: number;
// AI state
lane: number; // LANES means the merge lane
targetX: number;
cruise: number;
laneTimer: number;
ramp: number | null; // ramp start while driving down the slip road
// driver commands, set each step by the player input or the AI
cmdAccel: number;
cmdYawRate: number;
// lost grip after a hit; wrecked cars never recover
slide: number;
// enforceRoute's request to stay below holdSpeed until holdUntil
holdSpeed: number;
holdUntil: number;
// How drivers react to the pursuit (see reactToChase).
notice: number; // how far ahead of the patrol car this driver hears the siren
yielding: boolean;
yieldSide: number;
hearAt: number; // when a driver who has heard the siren gets round to moving over (-1: not yet heard)
// Traffic lights: indicator (-1 left, 1 right, 0 off), and a signalled lane change waiting to
// be made (lane, and when the driver looks again and goes); brake lights.
signal: number;
pendingLane: number;
pendingAt: number;
braking: boolean;
routeMovedAt: number; // when enforceRoute last moved this car over (so it isn't bounced straight back)
panicUntil: number;
startleUntil: number;
startleDir: number;
type: VehicleType;
cargoLost: boolean; // a logging truck that has dropped its logs
// Damage: bit i set means panel i (parts.ts) has been knocked off.
lost: number;
lastDamage: number;
exploded: boolean;
// Rigs: the trailer's own heading, hinged at the fifth wheel (see trailerFollow).
trailerYaw: number;
wrecked: boolean;
}
export interface Input {
throttle: number; // -1 brake/reverse .. 1 gas
steer: number; // -1 left .. 1 right
}
// Getaway mode: 'gotaway' (police left GETAWAY_GAP behind), 'disabled' (every police car
// wrecked), 'busted' (spun out or boxed in with police around).
export type Outcome = 'running' | 'pit' | 'crash' | 'escaped' | 'gotaway' | 'disabled' | 'busted';
/** Seconds before a driver who hears the siren starts to move over (plus up to the spread). */
const SIREN_REACTION = 1.2, SIREN_REACTION_SPREAD = 2;
/** Seconds a driver indicates before changing lanes (plus up to the spread). */
const SIGNAL_FIRST = 1.1, SIGNAL_FIRST_SPREAD = 1;
/** The patrol car's paint; in a getaway the player drives a civilian colour instead. */
export const PATROL_COLOR = 0x1d2128;
/** Getaway: how far the nearest police car must fall behind for you to get away. */
export const GETAWAY_GAP = 600;
const POLICE_COUNT = 8;
/** Getaway police top speed: 165 mph, a little more than your 160. */
const POLICE_TOP_SPEED = 165 / MPS_TO_MPH;
export interface SimEvent {
// birdsWarn: a bird strike is coming; birds: the flock arrives; bird: one hit a car
type: 'pit' | 'bump' | 'wall' | 'crash' | 'damage' | 'explode' | 'logs' | 'birdsWarn' | 'birds' | 'bird';
strength: number;
x: number;
s: number;
y?: number; // bird: height of the hit
other?: Car; // the non-player car involved, if any
// damage / explode: which car, which panel (explode: bitmask of panels still on), its velocity
car?: number;
part?: number;
vx?: number;
vs?: number;
}
const TRAFFIC_COLORS = [0xd8d8d0, 0x3a5fa8, 0x2e2e33, 0x8a1f24, 0x6f7f86, 0x2f6b45, 0xc9b27c, 0x5a4a7a];
let nextId = 1;
function makeCar(kind: Kind, x: number, s: number, speed: number, type: VehicleType = 'sedan'): Car {
const { len, wid, mass } = VEHICLES[type];
const lane = nearestLane(x);
return {
id: nextId++, kind, x, s, yaw: 0, vx: 0, vs: speed, w: 0,
len, wid, mass, inertia: (mass * (len * len + wid * wid)) / 12,
color: kind === 'player' || kind === 'police' ? PATROL_COLOR
: TRAFFIC_COLORS[Math.floor(Math.random() * TRAFFIC_COLORS.length)],
maxSpeed: kind === 'traffic' ? TRAFFIC_MAX : kind === 'suspect' ? SUSPECT_TOP_SPEED : TOP_SPEED,
accel: kind === 'player' || kind === 'police' ? 9.5 : kind === 'suspect' ? 8 : RIGS.has(type) ? 2.2 : type === 'sports' ? 4.5 : 3,
lane, targetX: laneX(lane), cruise: speed, laneTimer: 4 + Math.random() * 14, ramp: null,
cmdAccel: 0, cmdYawRate: 0, slide: 0, holdSpeed: 0, holdUntil: -1, wrecked: false,
// A few drivers never notice the siren until it's right behind them.
notice: Math.random() < 0.15 ? 25 : 60 + Math.random() * 160,
yielding: false, yieldSide: 1, hearAt: -1, signal: 0, pendingLane: -1, pendingAt: 0, braking: false, routeMovedAt: -9, panicUntil: -1, startleUntil: -9, startleDir: 1,
// Pickups have an open bed where the trunk lid would be.
type, cargoLost: false, lost: type === 'pickup' || type === 'lifted' ? 1 << 3 : 0, lastDamage: -1, exploded: false, trailerYaw: 0,
};
}
export function nearestLane(x: number): number {
return Math.max(0, Math.min(LANES - 1, Math.round(x / LANE_W + (LANES - 1) / 2)));
}
export function wrapAngle(a: number): number {
return Math.atan2(Math.sin(a), Math.cos(a));
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
export function forwardSpeed(car: Car): number {
return car.vx * Math.sin(car.yaw) + car.vs * Math.cos(car.yaw);
}
/** Lateral speed relative to the car's heading: how much it is sliding sideways. */
export function slipSpeed(car: Car): number {
return car.vx * Math.cos(car.yaw) - car.vs * Math.sin(car.yaw);
}
export class Sim {
cars: Car[] = [];
player: Car;
suspect: Car;
time = 0;
outcome: Outcome = 'running';
density = 1;
input: Input = { throttle: 0, steer: 0 };
// Player contact with the guardrail (speed along it) and wheels on the edge-line rumble strip;
// the frame loop reads and clears these for the audio.
scrape = 0;
rumble = false;
/** Whether the patrol car's siren is on; traffic moves over for it. */
sirenOn = true;
lastRearHit = -99; // last time the player struck the back half of the suspect
private escapeTimer = 0;
// The suspect getting unstuck: 'reverse' out, then 'around' the obstacle toward x, until past s.
private escape: { mode: 'drive' | 'reverse' | 'around'; stuck: number; timer: number; x: number; from: number; past: number } =
{ mode: 'drive', stuck: 0, timer: 0, x: 0, from: 0, past: 0 };
private stepCount = 0;
private rampTimers = new Map<number, number>();
events: SimEvent[] = [];
/** False for free drive: no suspect on the road, nothing to win or lose. */
readonly pursuit: boolean;
/** Bird strikes (pursuit and free drive). */
birds: Birds | null = null;
/** The suspect's paint, as Dispatch calls it. */
readonly suspectColor: SuspectColor;
/** Getaway mode: the player is the one running, with POLICE_COUNT police cars after them. */
readonly getaway: boolean;
police: Car[] = [];
private bustTimer = 0;
constructor(density = 1, pursuit = true, suspectColor: SuspectColor = randomSuspectColor(), getaway = false) {
this.density = density;
this.pursuit = pursuit;
this.suspectColor = suspectColor;
this.player = makeCar('player', laneX(2), 0, 30);
this.suspect = makeCar('suspect', laneX(3), 130, 40);
this.suspect.color = suspectHex(suspectColor);
this.suspect.cruise = SUSPECT_TOP_SPEED;
this.cars.push(this.player);
if (pursuit) this.cars.push(this.suspect);
this.getaway = getaway && !pursuit;
if (this.getaway) {
// You're in the civilian car now, with three patrol cars close behind.
this.player.color = suspectHex(suspectColor);
for (let i = 0; i < POLICE_COUNT; i++) {
// A head start: they set off 180-240 m back, and pull away a little slower than you do.
const cop = makeCar('police', laneX([1, 3, 4, 2, 0, 5, 1, 4][i]), -180 - i * 30, 30);
cop.accel = 8.5;
cop.maxSpeed = POLICE_TOP_SPEED;
cop.laneTimer = 0;
this.police.push(cop);
this.cars.push(cop);
}
}
this.birds = new Birds(0); // bird strikes in both modes
this.fillTraffic(true, 0);
}
get gap(): number {
return this.pursuit ? this.suspect.s - this.player.s : 0;
}
/** Getaway: how far behind the nearest police car still in the chase is (Infinity if none). */
get policeGap(): number {
let gap = Infinity;
for (const c of this.police) if (!c.wrecked) gap = Math.min(gap, this.player.s - c.s);
return gap;
}
step(dt: number, input: Input): void {
this.time += dt;
this.input = input;
this.drivePlayer(input);
for (const car of this.cars) {
if (car.kind === 'traffic') this.driveTraffic(car, dt);
else if (car.kind === 'suspect') this.driveSuspect(car, dt);
else if (car.kind === 'police') this.drivePolice(car, dt);
}
for (const car of this.cars) if (car.kind === 'traffic') this.brakeLights(car);
for (const car of this.cars) integrate(car, dt);
for (const car of this.cars) if (RIGS.has(car.type)) trailerFollow(car, dt);
this.collide();
for (const car of this.cars) if (car.ramp === null) this.wall(car);
for (const car of this.cars) {
if (car.kind !== 'player' && car.kind !== 'log' && !car.wrecked && Math.abs(wrapAngle(car.yaw)) > (RIGS.has(car.type) ? RIG_SPIN_OUT : SPIN_OUT)) {
car.wrecked = true;
if (car.type === 'logger') this.dropLogs(car, car.x + 1);
const near = Math.hypot(car.x - this.player.x, car.s - this.player.s) < 120;
if (near) this.events.push({ type: 'crash', strength: Math.hypot(car.vx, car.vs), x: car.x, s: car.s, other: car });
}
}
const p = this.player;
for (const [x, s] of cornersOf(p)) {
if (x < Math.max(-EDGE, leftWall(s) + 0.2) || x > rightEdge(s)) this.rumble = true;
}
if (this.pursuit && this.outcome === 'running') {
if (this.suspect.wrecked) {
this.outcome = this.time - this.lastRearHit < 4 ? 'pit' : 'crash';
} else if (this.gap > ESCAPE_GAP) {
this.escapeTimer += dt;
if (this.escapeTimer > 3) this.outcome = 'escaped';
} else {
this.escapeTimer = 0;
}
}
if (this.getaway && this.outcome === 'running') {
const active = this.police.filter((c) => !c.wrecked);
if (!active.length) this.outcome = 'disabled';
else if (this.policeGap >= GETAWAY_GAP) {
this.escapeTimer += dt;
if (this.escapeTimer > 2) this.outcome = 'gotaway';
} else {
this.escapeTimer = 0;
}
// Busted: spun round, or stopped dead, with a police car right there.
const near = active.some((c) => Math.hypot(c.x - p.x, c.s - p.s) < 40);
const spun = Math.abs(wrapAngle(p.yaw)) > 1.3;
const stopped = Math.abs(forwardSpeed(p)) < 2;
this.bustTimer = near && (spun || stopped) ? this.bustTimer + dt : 0;
if (this.bustTimer > (spun ? 1.2 : 3)) this.outcome = 'busted';
}
this.fillTraffic(false, dt);
if (this.stepCount++ % 6 === 0) this.enforceRoute();
const seen = this.events.length;
this.birds?.update(dt, this.time, this.cars, this.player, this.events);
for (let i = seen; i < this.events.length; i++) if (this.events[i].type === 'bird') this.birdHit(this.events[i]);
}
/**
* Keep two lanes open everywhere: predict where traffic will be in 2 s, and if any
* stretch would have more than MAX_BLOCKED lanes taken, the rearmost moving car of that
* stretch is told to drop back below the speed of the others.
*/
private enforceRoute(): void {
const HORIZON = 2;
const pts = this.cars
.filter((c) => c.kind === 'traffic' && c.ramp === null)
.map((c) => {
const v = c.wrecked ? 0 : forwardSpeed(c);
const lane = c.wrecked ? nearestLane(c.x) : c.lane;
return { car: c, lane, v, at: c.s + Math.max(0, v) * HORIZON };
})
.filter((p) => p.lane < LANES)
.sort((a, b) => a.at - b.at);
const width = ROUTE_BAND + 15; // a margin past the rule itself
for (let i = 0; i < pts.length; i++) {
let j = i;
const lanes = new Set<number>();
while (j < pts.length && pts[j].at - pts[i].at <= width) lanes.add(pts[j++].lane);
if (lanes.size <= maxBlocked(pts[i].at)) continue;
const group = pts.slice(i, j);
// Wrecks are an emergency, not traffic flow: cars steer round them instead.
if (group.some((p) => p.car.wrecked)) continue;
// First choice: a car alone in an edge-of-group lane slides over into a lane the group
// already uses, which frees its lane without slowing anyone.
const count = new Map<number, number>();
for (const p of group) count.set(p.lane, (count.get(p.lane) ?? 0) + 1);
const mover = group.find((p) => count.get(p.lane) === 1 && p.car.slide <= 0 && this.time - p.car.routeMovedAt > 3 && [p.lane - 1, p.lane + 1]
.some((l) => count.has(l) && laneOk(p.car, l) && this.laneClear(p.car, l, 10, 14)));
if (mover) {
const l = [mover.lane - 1, mover.lane + 1].find((l) => count.has(l) && laneOk(mover.car, l) && this.laneClear(mover.car, l, 10, 14))!;
this.signalLane(mover.car, l, true);
mover.car.laneTimer = 8;
mover.car.routeMovedAt = this.time;
continue;
}
const rear = group.reduce((a, b) => (b.car.s < a.car.s ? b : a));
const slowest = Math.min(...group.filter((p) => p !== rear).map((p) => p.v));
// Never hold a car to a crawl, or held cars hold up the cars behind them and it all jams.
rear.car.holdSpeed = Math.max(TRAFFIC_MIN * 0.6, slowest - 4);
rear.car.holdUntil = this.time + 2;
}
}
private drivePlayer(input: Input): void {
const p = this.player;
const vf = forwardSpeed(p);
let a: number;
// Pull stays strong enough to beat drag right up to the limiter, so the car really hits 160.
if (input.throttle > 0) a = vf < p.maxSpeed ? p.accel * input.throttle * (1 - 0.55 * Math.max(0, vf) / p.maxSpeed) : 0;
else if (input.throttle < 0) a = vf > 0.5 ? 16 * input.throttle : vf > -9 ? 5 * input.throttle : 0;
else a = 0;
a -= Math.sign(vf) * (0.4 + 0.00035 * vf * vf);
p.cmdAccel = a;
const speedFactor = clamp(Math.abs(vf) / 7, 0, 1) / (1 + Math.abs(vf) / 45);
const steerRate = 1.5 * speedFactor * Math.sign(vf || 1);
// Steering assist: with no input, gently line the car back up with a straight road.
// It fades out as a bend starts: the car never turns into a bend by itself, so it
// runs wide unless the driver steers.
const roadYaw = wrapAngle(p.yaw);
const straight = clamp(1 - Math.abs(curvature(p.s)) * 5000, 0, 1);
p.cmdYawRate = input.steer !== 0 ? input.steer * steerRate
: Math.abs(roadYaw) < 1.3 ? -roadYaw * 1.6 * clamp(Math.abs(vf) / 8, 0, 1) * straight : 0;
}
private driveTraffic(car: Car, dt: number): void {
if (car.wrecked) {
car.signal = 0;
return;
}
if (car.ramp === null && car.lane !== LANES) this.updateSignal(car);
if (car.ramp !== null) {
this.driveRamp(car);
return;
}
if (car.lane === LANES) {
this.driveMerge(car);
return;
}
car.laneTimer -= dt;
const ahead = this.carAhead(car, 30 + Math.max(0, forwardSpeed(car)) * 1.3);
// Get around wrecks and stopped cars instead of queueing behind them forever.
const obstacle = ahead && (ahead.wrecked || forwardSpeed(ahead) < 6) && ahead.s - car.s < 45;
const closing = !laneOk(car, car.lane);
if (closing) {
// Step right, even into a lane that is closing too, on the way to an open one: in a
// proper gap while there's room, taking a tighter one only near the barrels.
const next = car.lane + 1;
const late = this.closureStart(car.s) - car.s < 150;
if (next < LANES && (late ? this.laneClear(car, next, 8, 10) : this.laneClear(car, next, 14, 18))) {
this.signalLane(car, next, late);
}
} else if (car.laneTimer <= 0 && (obstacle || (!car.yielding && car.pendingLane < 0))) {
car.laneTimer = obstacle ? 0.5 : 6 + Math.random() * 16;
const dir = Math.random() < 0.5 ? -1 : 1;
for (const next of [car.lane + dir, car.lane - dir]) {
if (laneOk(car, next) && this.laneClear(car, next, 14, 18) && (obstacle || this.routeOk(car, next))) {
this.signalLane(car, next, Boolean(obstacle)); // round a wreck there's no time to wait
break;
}
}
}
let target = car.cruise;
if (ahead) {
const gap = ahead.s - car.s - (ahead.len + car.len) / 2;
// Ease off early enough to settle 10 m behind it, braking at no more than ~4 m/s².
target = Math.min(target, Math.max(0, forwardSpeed(ahead) + Math.sign(gap - 10) * Math.sqrt(2 * 4 * Math.abs(gap - 10))));
}
// Told by enforceRoute to drop back so traffic doesn't wall off the road.
if (car.holdUntil > this.time) target = Math.min(target, car.holdSpeed);
// Still in a closing lane: stop short of the barrels rather than drive into them.
if (closing) target = Math.min(target, Math.sqrt(2 * 4 * Math.max(0, this.closureStart(car.s) - car.s - 12)));
target = this.reactToChase(car, target);
this.steerAI(car, target);
}
/**
* How real traffic behaves around a police pursuit:
* - Hearing the siren closing from behind, most drivers move over and slow down: left
* lanes go left and right lanes go right, opening the middle (the end lanes hug the shoulder).
* Some only notice late, and a few panic and stand on the brakes.
* - The fleeing car blasting past makes drivers flinch away and lift off.
* - Everyone slows to look at wrecks and fires.
*/
/**
* A bird smacks into a windscreen: the driver flinches. Most stand on the brakes for a moment,
* about half jerk the wheel to one side (reactToChase carries both out). The patrol car is
* yours to drive, so it only gets a jolt of camera shake.
*/
private birdHit(e: SimEvent): void {
const car = this.cars.find((c) => c.id === e.car);
if (!car || car.kind === 'player' || car.wrecked || car.ramp !== null) return;
if (Math.random() < 0.7) car.panicUntil = this.time + 0.8 + Math.random();
if (Math.random() < 0.5) {
car.startleUntil = this.time + 0.6 + Math.random() * 0.5;
car.startleDir = Math.random() < 0.5 ? -1 : 1;
}
}
/**
* Move a traffic car over a lane. Drivers indicate first and look again before they go; in a
* hurry (round a wreck, out of a closing lane at the barrels) they indicate as they move.
*/
private signalLane(car: Car, next: number, now = false): void {
car.signal = Math.sign(next - car.lane);
if (now) {
car.lane = next;
car.targetX = laneX(next);
car.pendingLane = -1;
} else if (car.pendingLane !== next) {
car.pendingLane = next;
car.pendingAt = this.time + SIGNAL_FIRST + Math.random() * SIGNAL_FIRST_SPREAD;
}
}
/** Make a signalled lane change once the driver has looked again, and cancel the indicator once across. */
private updateSignal(car: Car): void {
if (car.pendingLane >= 0) {
if (this.time < car.pendingAt) return;
// Still a gap (that leaves the way through open)? Go. Otherwise think better of it.
const forced = car.yielding || !laneOk(car, car.lane); // moving over for the siren, or out of a closing lane
if (laneOk(car, car.pendingLane) && this.laneClear(car, car.pendingLane, 10, 12)
&& (forced || this.routeOk(car, car.pendingLane))) {
car.lane = car.pendingLane;
car.targetX = laneX(car.lane);
} else {
car.signal = 0;
}
car.pendingLane = -1;
return;
}
if (car.signal !== 0 && Math.abs(car.x - car.targetX) < 0.35) car.signal = 0;
}
/** Brake lights: on while the driver is braking (with a little hysteresis) or held at a stop. */
private brakeLights(car: Car): void {
if (car.wrecked) {
car.braking = false;
return;
}
const stopped = forwardSpeed(car) < 0.5 && car.cmdAccel <= 0.05;
car.braking = stopped || car.cmdAccel < (car.braking ? -0.3 : -1);
}
private reactToChase(car: Car, target: number): number {
const p = this.player;
const behind = car.s - p.s;
const closing = forwardSpeed(p) - forwardSpeed(car);
const hears = this.sirenOn && behind > 0 && behind < car.notice && closing > 3;
if (!car.yielding) {
// It takes a moment to place the siren and decide which way to go before they move.
if (!hears) car.hearAt = -1;
else if (car.hearAt < 0) car.hearAt = this.time + SIREN_REACTION + Math.random() * SIREN_REACTION_SPREAD;
else if (this.time >= car.hearAt) {
car.yielding = true;
car.hearAt = -1;
// Left lanes move left, right lanes right; a rig always moves right (it's kept there).
car.yieldSide = car.lane <= (LANES - 1) / 2 && !RIGS.has(car.type) ? -1 : 1;
if (Math.random() < 0.1) car.panicUntil = this.time + 0.8 + Math.random();
}
} else if (behind < -12 || !this.sirenOn || behind > car.notice + 80) {
car.yielding = false;
car.targetX = laneX(car.lane);
}
if (car.yielding) {
const edge = car.yieldSide < 0 ? 0 : LANES - 1;
if (car.lane !== edge) {
const next = car.lane + car.yieldSide;
if (car.pendingLane < 0 && this.laneClear(car, next, 8, 10)) this.signalLane(car, next);
} else if (car.pendingLane < 0) {
car.targetX = laneX(edge) + car.yieldSide * 1.3; // hug the shoulder
if (Math.abs(car.x - car.targetX) > 0.5) car.signal = car.yieldSide;
}
target = Math.min(target, car.cruise * 0.72);
}
if (car.panicUntil > this.time) target = Math.min(target, car.cruise * 0.35);
const t = this.suspect;
const ahead = car.s - t.s;
if (this.pursuit && !t.wrecked && ahead > 0 && ahead < 25 && Math.abs(t.x - car.x) < 3.2
&& forwardSpeed(t) - forwardSpeed(car) > 12 && car.startleUntil < this.time - 3) {
car.startleUntil = this.time + 0.9;
car.startleDir = t.x < car.x ? 1 : -1;
}
if (car.startleUntil > this.time) {
car.targetX = laneX(car.lane) + car.startleDir * 1.1;
target = Math.min(target, car.cruise * 0.8);
} else if (!car.yielding) {
car.targetX = laneX(car.lane);
}
for (const o of this.cars) {
const d = o.s - car.s;
if (o !== car && o.wrecked && d > -10 && d < 90) {
target = Math.min(target, car.cruise * (o.exploded ? 0.5 : 0.65));
break;
}
}
return target;
}
private driveRamp(car: Car): void {
const r0 = car.ramp!;
const d = r0 - car.s;
if (d <= 0) {
car.ramp = null;
car.lane = LANES;
car.targetX = MERGE_X;
this.driveMerge(car);
return;
}
// Follow the slip road, queueing behind the ramp car ahead.
car.targetX = rampX(Math.max(0, d - 6));
car.signal = d < 150 ? -1 : 0; // indicating to join, coming down to the merge
let target = 27; // ~60 mph down the slip road
const ahead = this.cars
.filter((o) => o !== car && o.ramp === r0 && o.s > car.s)
.reduce<Car | undefined>((best, o) => (!best || o.s < best.s ? o : best), undefined);
if (ahead) target = Math.min(target, Math.max(0, forwardSpeed(ahead) + (ahead.s - car.s - 14) * 0.4));
this.steerAI(car, target, rampSlope(d));
}
private driveMerge(car: Car): void {
const left = LANES - 1;
car.signal = -1;
car.pendingLane = -1;
if (this.laneClear(car, left, 12, 16) && this.routeOk(car, left)) {
car.lane = left;
car.targetX = laneX(left);
this.steerAI(car, car.cruise);
return;
}
// Run out of merge lane: stop and wait for a gap.
const remaining = rampAt(car.s) + MERGE_LEN - car.s;
let target = Math.min(car.cruise, Math.max(0, (remaining - 8) * 0.6));
const ahead = this.carAhead(car, 35);
if (ahead) target = Math.min(target, Math.max(0, forwardSpeed(ahead) + (ahead.s - car.s - 12) * 0.35));
this.steerAI(car, target);
}
private driveSuspect(car: Car, dt: number): void {
if (car.wrecked) return;
const vf = forwardSpeed(car);
if (car.lane >= LANES) car.lane = LANES - 1;
// Stuck (boxed in, behind a wreck, pinned by the patrol car): back out, then floor it round.
if (this.escape.mode === 'drive') {
this.escape.stuck = vf < 2.5 && car.slide <= 0 ? this.escape.stuck + dt : 0;
if (this.escape.stuck > 0.5) this.startEscape(car);
}
if (this.escape.mode === 'reverse') {
this.escape.timer -= dt;
// Back up hard, swinging the nose toward the gap (reversing with the heading turned
// that way moves the front out toward it).
const wantYaw = clamp((this.escape.x - car.x) * 0.1, -0.32, 0.32);
car.cmdAccel = vf > -6 ? -9 : 0;
car.cmdYawRate = clamp(3 * (wantYaw - wrapAngle(car.yaw)), -1.4, 1.4) * Math.min(1, Math.abs(vf) / 3 + 0.25);
const blockedBehind = this.cars.some((o) => o !== car && o.ramp === null && Math.abs(o.x - car.x) < 2.2
&& car.s - o.s > 0 && car.s - o.s < (car.len + o.len) / 2 + 1.5);
if (this.escape.timer <= 0 || car.s < this.escape.from - 5 || blockedBehind) {
this.escape.mode = 'around';
this.escape.timer = 4;
}
return;
}
if (this.escape.mode === 'around') {
this.escape.timer -= dt;
car.targetX = this.escape.x;
car.lane = nearestLane(this.escape.x);
this.steerAI(car, car.cruise, 0, 2); // flat out, steering hard for the gap
if (this.escape.timer <= 0 || car.s > this.escape.past) {
this.escape.mode = 'drive';
this.escape.stuck = 0;
car.targetX = laneX(car.lane);
}
return;
}
car.laneTimer -= dt;
const gapToPlayer = car.s - this.player.s;
const pressured = gapToPlayer < 30 && gapToPlayer > -10;
const ahead = this.carAhead(car, 40 + Math.max(0, vf) * 1.6);
const gap = ahead ? ahead.s - car.s - (ahead.len + car.len) / 2 : Infinity;
// Something slower in its path: decide now, not when the lane timer next comes round.
const threat = ahead !== undefined && forwardSpeed(ahead) < vf - 2 && gap < vf * 1.4 + 20;
if (car.laneTimer <= 0 || threat) {
// Pick the lane with the longest open road ahead; a pursuer close behind makes it jumpier.
let best = car.lane;
const shut = closedAhead(car.s, 250);
let bestFree = car.lane < shut ? -100 - (shut - car.lane) * 10 : this.freeAhead(car, car.lane, 200) + (pressured || threat ? 0 : 15);
for (const next of [car.lane - 1, car.lane + 1]) {
// Out of a closing lane only rightward, toward the open ones.
if ((next < shut && !(car.lane < shut && next > car.lane)) || next >= LANES || !this.laneClear(car, next, 5, 7)) continue;
const free = this.freeAhead(car, next, 200) + (pressured ? Math.random() * 40 : 0);
if (free > bestFree + 5) {
best = next;
bestFree = free;
}
}
if (best !== car.lane) {
car.lane = best;
car.targetX = laneX(best);
car.laneTimer = pressured ? 0.6 + Math.random() * 0.6 : 0.6;
} else {
car.laneTimer = 0.15;
}
}
let target = car.cruise;
if (ahead) {
// Brake hard but late, like someone with nothing to lose: v = v_ahead + sqrt(2 * a * gap).
target = Math.min(target, Math.max(0, forwardSpeed(ahead) + Math.sqrt(2 * 10 * Math.max(0, gap - 5))));
}
this.steerAI(car, target, 0, 1.8);
}
/**
* Getaway police: close in flat out (160 mph), switching lanes the moment anything blocks
* them, then pull alongside a rear quarter of the player's car and turn in for the PIT.
* The first goes for the left side, the second the right, the third whichever is open.
*/
private drivePolice(car: Car, dt: number): void {
if (car.wrecked) return;
const p = this.player;
const vf = forwardSpeed(car), pv = forwardSpeed(p);
const role = this.police.indexOf(car);
const behind = p.s - car.s;
const shut = closedAhead(car.s, 300);
car.laneTimer -= dt;
// Anything other than the player in our path, or where we're steering.
let obstacle: Car | undefined, gap = Infinity;
const range = 40 + Math.max(0, vf) * 2.4;
for (const o of this.cars) {
if (o === car || o === p || o.ramp !== null) continue;
const d = o.s - car.s;
if (d <= 0 || d > range) continue;
const clear = (o.wid + car.wid) / 2 + 0.6;
if (Math.abs(o.x - car.x) > clear && Math.abs(o.x - car.targetX) > clear) continue;
const g = d - (o.len + car.len) / 2;
if (g < gap) {
gap = g;
obstacle = o;
}
}
let target: number;
let agile = 2.6;
if (behind > 35 || behind < -10) {
// Chasing: the open lane nearest the player's, picked again the moment something blocks it.
if (car.laneTimer <= 0 || (obstacle && gap < vf * 2 + 25)) {
const here = nearestLane(car.x);
let best = Math.max(here, shut), bestScore = -Infinity;
for (const l of [here - 1, here, here + 1]) {
if (l < shut || l < 0 || l >= LANES) continue;
if (l !== here && !this.laneClear(car, l, 3, 4)) continue;
const score = this.freeAhead(car, l, 160) - Math.abs(laneX(l) - p.x) * 2 - (l === here ? 0 : 3);
if (score > bestScore) {
bestScore = score;
best = l;
}
}
car.lane = best;
car.targetX = laneX(best);
car.laneTimer = 0.05;
}
// Flat out to catch up; if it has got ahead, it drops back to box the player in.
target = behind < -10 ? Math.max(0, pv - 5) : car.maxSpeed;
} else {
// Alongside: nose level with a rear quarter, then turn in hard.
const side = role === 0 ? -1 : role === 1 ? 1 : car.x < p.x ? -1 : 1;
const besideX = p.x + side * ((p.wid + car.wid) / 2 + 0.5);
const lineS = p.s - p.len * 0.32 - car.len * 0.5;
const lined = Math.abs(car.s - lineS) < 1.2 && Math.abs(car.x - besideX) < 0.9;
car.targetX = lined ? p.x + side * p.wid * 0.5 : besideX;
car.targetX = clamp(car.targetX, leftWall(car.s) + 1.2, rightWall(car.s) - 1.2);
car.lane = nearestLane(car.targetX);
target = lined ? pv + 2 : pv + clamp((lineS - car.s) * 1.5, -10, 12);
agile = lined ? 3 : 2.6;
}
// Brake hard and late for whatever's in the way.
if (obstacle) target = Math.min(target, Math.max(0, forwardSpeed(obstacle) + Math.sqrt(2 * 14 * Math.max(0, gap - 4))));
this.steerAI(car, target, 0, agile);
// Police brakes bite harder than everyone else's (steerAI stops at 11 m/s²).
if (target < vf) car.cmdAccel = Math.max(-14, Math.min(car.cmdAccel, (target - vf) * 1.6 * agile));
}
/** The suspect is stuck: pick the best way round (a lane either side, or the shoulder) and back out. */
private startEscape(car: Car): void {
const options: number[] = [car.lane - 1, car.lane + 1].filter((l) => l >= 0 && l < LANES).map(laneX);
// On an outside lane the shoulder is a way round too.
if (car.lane === 0) options.push(laneX(0) - 1.8);
if (car.lane === LANES - 1) options.push(laneX(LANES - 1) + 1.8);
const room = (x: number) => {
// Clear road ahead along that line, ignoring anything well behind.
let free = 80;
for (const o of this.cars) {
if (o === car || o.ramp !== null || Math.abs(o.x - x) > 2.4) continue;
const d = o.s - car.s;
if (d > -4 && d < free) free = Math.max(0, d);
}
return free;
};
const best = options.reduce((a, b) => (room(b) > room(a) ? b : a), options[0]);
const block = this.carAhead(car, 15);
this.escape = {
mode: 'reverse', stuck: 0, timer: 1.2, x: best, from: car.s,
past: (block ? block.s + block.len / 2 : car.s) + car.len + 6,
};
}
/** slope: the road's dx/ds where the car is (slip roads), so it steers ahead of the curve. */
/** agile > 1 steers harder and quicker (the suspect). */
private steerAI(car: Car, targetSpeed: number, slope = 0, agile = 1): void {
const vf = forwardSpeed(car);
const roadVel = slope * vf;
const wantLat = clamp(roadVel + 1.1 * agile * (car.targetX - car.x) - 0.35 * (car.vx - roadVel), -9 * agile, 9 * agile);
const wantYaw = Math.asin(clamp(wantLat / Math.max(vf, 6), -0.4 * Math.min(agile, 1.4), 0.4 * Math.min(agile, 1.4)));
car.cmdAccel = clamp((targetSpeed - vf) * 1.6 * agile, -11, car.accel);
// Plus the turn rate that holds the line through a bend.
car.cmdYawRate = clamp(3.2 * agile * (wantYaw - wrapAngle(car.yaw)) + curvature(car.s) * vf, -1.2 * agile, 1.2 * agile);
}
private bandCars(car: Car, x: number, halfWidth: number): Car[] {
return this.cars.filter((o) => o !== car && o.ramp === null && Math.abs(o.x - x) < halfWidth);
}
/** Nearest car ahead in our path or where we're steering (one pass, no lists: it runs for every car, every step). */
private carAhead(car: Car, range: number): Car | undefined {
let best: Car | undefined, bestD = range;
const steering = car.targetX !== car.x;
for (const o of this.cars) {
if (o === car || o.ramp !== null) continue;
const d = o.s - car.s;
if (d <= 0 || d >= bestD) continue;
if (Math.abs(o.x - car.x) < 2.6 || (steering && Math.abs(o.x - car.targetX) < 2.4)) {
best = o;
bestD = d;
}
}
return best;
}
private freeAhead(car: Car, lane: number, range: number): number {
let free = range;
for (const o of this.bandCars(car, laneX(lane), 2.5)) {
const d = o.s - car.s;
if (d > -2 && d < free) {
// A car moving at our speed is not much of an obstacle.
const closing = forwardSpeed(car) - forwardSpeed(o);
free = Math.min(free, closing < 2 ? d + 30 : d);
}
}
return free;
}
/** Where the next lane closure's barrels start (or far ahead if there is none). */
private closureStart(s: number): number {
for (let d = 0; d <= 400; d += 5) if (closedLanes(s + d)) return s + d;
return s + 1000;
}
/** No one in the lane near us, counting extra room for faster cars coming up from behind. */
private laneClear(car: Car, lane: number, behind: number, ahead: number): boolean {
const v = forwardSpeed(car);
// Includes cars already heading into that lane, so two cars don't merge into the same gap.
const x = laneX(lane);
const near = this.cars.filter((o) => o !== car && o.ramp === null && (Math.abs(o.x - x) < 2.8 || o.lane === lane));
return !near.some((o) => {
const d = o.s - car.s;
const extra = (o.len + car.len) / 2 - 4.7; // room for long vehicles beyond a car's length
return d > -(behind + extra + Math.max(0, forwardSpeed(o) - v) * 2.5) && d < ahead + extra + Math.max(0, v - forwardSpeed(o)) * 2.5;
});
}
/** Highway lanes taken by traffic (moving or wrecked) between s0 and s1. */
lanesUsed(s0: number, s1: number, skip?: Car): Set<number> {
const used = new Set<number>();
for (const o of this.cars) {
if (o === skip || o.kind !== 'traffic' || o.ramp !== null || o.s < s0 || o.s > s1) continue;
const lane = o.wrecked ? nearestLane(o.x) : o.lane;
if (lane < LANES) used.add(lane);
}
return used;
}
/** Would traffic still leave two lanes open in every stretch containing car at (lane, s)? */
private routeOk(car: Car, lane: number, s = car.s): boolean {
const pts: [number, number][] = [[s, lane]];
for (const o of this.cars) {
if (o === car || o.kind !== 'traffic' || o.ramp !== null || Math.abs(o.s - s) > ROUTE_BAND) continue;
const l = o.wrecked ? nearestLane(o.x) : o.lane;
if (l < LANES) pts.push([o.s, l]);
}
if (new Set(pts.map((p) => p[1])).size <= maxBlocked(s)) return true;
for (const [start] of pts) {
if (start > s || start < s - ROUTE_BAND) continue;
const used = new Set(pts.filter((p) => p[0] >= start && p[0] <= start + ROUTE_BAND).map((p) => p[1]));
if (used.size > maxBlocked(start)) return false;
}
return true;
}
private fillTraffic(initial: boolean, dt: number): void {
// Traffic lives around the chase: the player and (in a pursuit) the suspect.
const ends = this.pursuit ? [this.player.s, this.suspect.s] : [this.player.s];
// Getaway: the police within 600 m drive through traffic too.
for (const c of this.police) if (!c.wrecked && this.player.s - c.s < 600) ends.push(c.s);
const lo = Math.min(...ends) - 440;
const hi = Math.max(...ends) + 1600; // traffic out toward the end of the view
this.cars = this.cars.filter((c) => (c.kind !== 'traffic' && c.kind !== 'log') || (c.s > lo - (c.ramp !== null ? 400 : 0) && c.s < hi));
// Towed away: wrecks vanish once well out of the player's sight (behind, or past the fog).
this.cars = this.cars.filter((c) => !(c.kind === 'traffic' && c.wrecked && (c.s < this.player.s - 150 || c.s > this.player.s + 450)));
let area = 0;
for (let k = 0; k < 8; k++) area += trafficAt(lo + ((hi - lo) * (k + 0.5)) / 8) / 8;
const wanted = Math.round(((hi - lo) / 12) * this.density * area);
let traffic = this.cars.filter((c) => c.kind === 'traffic' && c.ramp === null && c.lane < LANES).length;
let tries = 0;
while (traffic < wanted && tries++ < 12) {
let s: number;
if (initial) s = lo + 40 + Math.random() * (hi - lo - 40);
else if (Math.random() < 0.82) s = hi - 200 + Math.random() * 190;
else s = lo + 10 + Math.random() * 80;
const type = randomType();
const rig = RIGS.has(type);
const lane = rig ? RIG_LANE + Math.floor(Math.random() * (LANES - RIG_LANE)) : Math.floor(Math.random() * LANES);
const x = laneX(lane);
const len = VEHICLES[type].len;
const blocked = this.cars.some((o) => Math.abs(o.x - x) < 3
&& Math.abs(o.s - s) < (o.len + len) / 2 + (o.kind === 'traffic' ? 17 : 40));
if (blocked) continue;
// Slower traffic keeps right, like a real highway.
// 60-100 mph: slower traffic keeps right, faster traffic uses the left lanes.
const speed = TRAFFIC_MIN + ((LANES - 1 - lane) / (LANES - 1)) * (TRAFFIC_MAX - TRAFFIC_MIN) * 0.75
+ Math.random() * (TRAFFIC_MAX - TRAFFIC_MIN) * 0.25;
// Rigs run at truck speeds (55-68 mph); sports cars a bit faster than the traffic around them.
const cruise = rig ? Math.min(speed, (55 + Math.random() * 13) / MPS_TO_MPH)
: type === 'sports' ? Math.min(TRAFFIC_MAX, speed + 8 / MPS_TO_MPH) : speed;
const car = makeCar('traffic', x, s, cruise, type);
if (rig) car.maxSpeed = 70 / MPS_TO_MPH;
if (!laneOk(car, lane) || !this.routeOk(car, lane)) continue;
this.cars.push(car);
traffic++;
}
// On-ramp traffic for ramps around the chase.
for (let r0 = rampAt(lo + 200); r0 < hi + 300; r0 += RAMP_EVERY) {
const spawnD = RAMP_CURVE + 120;
let timer = this.rampTimers.get(r0) ?? (initial ? 0 : Math.random() * 3);
timer -= dt;
if (timer <= 0) {
const s = r0 - spawnD;
const clear = !this.cars.some((o) => o.ramp === r0 && o.s - s < 30);
if (clear) {
// Anything but a rig comes down the ramp.
let type = randomType();
while (RIGS.has(type)) type = randomType();
const car = makeCar('traffic', rampX(spawnD), s, 22, type);
car.ramp = r0;
car.lane = LANES;
car.yaw = Math.atan(rampSlope(spawnD));
car.vx = Math.sin(car.yaw) * 22;
car.vs = Math.cos(car.yaw) * 22;
car.cruise = TRAFFIC_MIN + Math.random() * 7; // merges into the slow lane at 60-75 mph
car.targetX = rampX(spawnD);
this.cars.push(car);
timer = (5 + Math.random() * 7) / this.density;
} else {
timer = 1;
}
}
this.rampTimers.set(r0, timer);
}
for (const r0 of this.rampTimers.keys()) if (r0 < lo - 500) this.rampTimers.delete(r0);
}
private collide(): void {
const cars = this.cars;
for (let i = 0; i < cars.length; i++) {
for (let j = i + 1; j < cars.length; j++) {
const a = cars[i];
const b = cars[j];
const reach = (Math.max(a.len, a.wid) + Math.max(b.len, b.wid)) / 2 + 0.5;
if (Math.abs(a.s - b.s) > reach || Math.abs(a.x - b.x) > reach) continue;
for (const sa of shapesOf(a)) {
for (const sb of shapesOf(b)) {
const hit = obb(sa, sb);
if (hit) this.resolve(a, b, hit);
}
}
}
}
}
private resolve(a: Car, b: Car, hit: Hit): void {
const { nx, ns, px, ps, depth } = hit;
const wa = a.w, wb = b.w;
const rax = px - a.x, ras = ps - a.s;
const rbx = px - b.x, rbs = ps - b.s;
const va = pointVel(a, rax, ras);
const vb = pointVel(b, rbx, rbs);
const rvx = vb[0] - va[0], rvs = vb[1] - va[1];
const vn = rvx * nx + rvs * ns;
// Push apart, heavier car moves less.
const total = a.mass + b.mass;
a.x -= nx * depth * (b.mass / total);
a.s -= ns * depth * (b.mass / total);
b.x += nx * depth * (a.mass / total);
b.s += ns * depth * (a.mass / total);
if (vn >= 0) return;
const ca = perpDot(rax, ras, nx, ns);
const cb = perpDot(rbx, rbs, nx, ns);
const denom = 1 / a.mass + 1 / b.mass + (ca * ca) / a.inertia + (cb * cb) / b.inertia;
const jn = (-(1 + 0.25) * vn) / denom;
applyImpulse(a, -jn * nx, -jn * ns, rax, ras);
applyImpulse(b, jn * nx, jn * ns, rbx, rbs);
// Only a solid hit swings a trailer: brushes in traffic would set off a chain of
// swinging trailers clipping the next lane.
if (-vn > TRAILER_KICK) {
kickTrailer(a, px, ps, -jn * nx, -jn * ns);
kickTrailer(b, px, ps, jn * nx, jn * ns);
}
// Tangential friction scrapes cars along each other.
const tx = -ns, ts = nx;
const vt = rvx * tx + rvs * ts;
const cta = perpDot(rax, ras, tx, ts);
const ctb = perpDot(rbx, rbs, tx, ts);
const denomT = 1 / a.mass + 1 / b.mass + (cta * cta) / a.inertia + (ctb * ctb) / b.inertia;
const jt = clamp(-vt / denomT, -0.35 * jn, 0.35 * jn);
applyImpulse(a, -jt * tx, -jt * ts, rax, ras);
applyImpulse(b, jt * tx, jt * ts, rbx, rbs);
const strength = -vn;
for (const [car, w0] of [[a, wa], [b, wb]] as const) {
if (!RIGS.has(car.type) || car.wrecked || strength < RIG_SHAKEN) continue;
// A heavy rig hardly turns from the impulse itself, but its driver loses it: grip goes
// and the trailer swings the way the hit pushed it.
car.slide = Math.max(car.slide, 0.4 + strength * 0.1);
const dir = Math.sign(car.w - w0) || (Math.random() < 0.5 ? -1 : 1);
car.w += dir * Math.min(0.55, 0.12 + strength * 0.035);
}
this.damage(a, px, ps, strength);
this.damage(b, px, ps, strength);
for (const car of [a, b]) if (car.type === 'logger' && strength > LOG_DROP_SPEED) this.dropLogs(car, px);
const player = a.kind === 'player' ? a : b.kind === 'player' ? b : undefined;
const other = player === a ? b : a;
const suspect = a.kind === 'suspect' ? a : b.kind === 'suspect' ? b : undefined;
const police = a.kind === 'police' ? a : b.kind === 'police' ? b : undefined;
if (player && police) {
// Getaway: whoever was struck on a rear quarter by the other spins: the police PIT you,
// or you PIT them.
let pit = false;
for (const victim of [player, police]) {
if (victim.wrecked || pit) continue;
const r = victim === a ? [rax, ras] : [rbx, rbs];
const lon = r[0] * Math.sin(victim.yaw) + r[1] * Math.cos(victim.yaw);
const sideness = Math.abs(nx * Math.cos(victim.yaw) - ns * Math.sin(victim.yaw));
if (lon < -0.12 * victim.len && sideness > 0.4 && strength > 0.8) {
const cs = victim === a ? -ca : cb;
const attacker = victim === player ? police : player;
const dir = Math.sign(cs * jn) || Math.sign(victim.x - attacker.x) * -1;
victim.w += dir * Math.min(2.6, 0.9 + strength * 0.35);
victim.slide = 1.8;
this.events.push({ type: 'pit', strength, x: px, s: ps, other: police, car: victim.id });
pit = true;
}
}
if (!pit) this.events.push({ type: 'bump', strength, x: px, s: ps, other: police });
} else if (player && suspect && !suspect.wrecked) {
const r = suspect === a ? [rax, ras] : [rbx, rbs];
// Contact in the suspect's local frame: lon < 0 is behind its center.
const lon = r[0] * Math.sin(suspect.yaw) + r[1] * Math.cos(suspect.yaw);
const sideness = Math.abs(nx * Math.cos(suspect.yaw) - ns * Math.sin(suspect.yaw));
if (lon < 0) this.lastRearHit = this.time;
if (lon < -0.12 * suspect.len && sideness > 0.4 && strength > 0.8) {
// PIT: the rear steps out and the suspect loses grip long enough to spin.
const cs = suspect === a ? -ca : cb; // sign of the torque the impulse put on the suspect
const dir = Math.sign(cs * jn) || Math.sign(suspect.x - player.x) * -1;
suspect.w += dir * Math.min(2.6, 0.9 + strength * 0.35);
suspect.slide = 1.8;
this.events.push({ type: 'pit', strength, x: px, s: ps, other: suspect });
} else {
suspect.slide = Math.max(suspect.slide, 0.2);
this.events.push({ type: 'bump', strength, x: px, s: ps, other: suspect });
}
} else {
for (const car of [a, b]) {
if (car.kind === 'traffic' && strength > 4) car.slide = Math.max(car.slide, 0.3 + strength * 0.08);
}
const near = Math.hypot(px - this.player.x, ps - this.player.s) < 100;
if (player || (near && strength > 2)) this.events.push({ type: 'bump', strength, x: px, s: ps, other: player ? other : undefined });
}
}
private wall(car: Car): void {
for (let i = 0; i < 4; i++) {
// Deepest corner past either guardrail; recomputed after each push-back.
let worst: [number, number] | undefined;
let over = 0;
for (const [cx, cs] of shapesOf(car).flatMap(cornersOf)) {
const o = Math.max(leftWall(cs) - cx, cx - rightWall(cs));
if (o > over) {
over = o;
worst = [cx, cs];
}
}
if (!worst) return;
const [cx, cs] = worst;
const nx = cx < 0 ? 1 : -1; // pushes back onto the road
const rx = cx - car.x, rs = cs - car.s;
car.x += nx * over;
const [vx, vs] = pointVel(car, rx, rs);
const vn = vx * nx;
if (car.kind === 'player') this.scrape = Math.max(this.scrape, Math.abs(vs));
if (vn >= 0) continue;
const c = perpDot(rx, rs, nx, 0);
const jn = (-(1 + 0.3) * vn) / (1 / car.mass + (c * c) / car.inertia);
applyImpulse(car, jn * nx, 0, rx, rs);
const ct = perpDot(rx, rs, 0, 1);
const jt = clamp(-vs / (1 / car.mass + (ct * ct) / car.inertia), -0.3 * jn, 0.3 * jn);
applyImpulse(car, 0, jt, rx, rs);
if (car.kind === 'player' && -vn > 1.5) this.events.push({ type: 'wall', strength: -vn, x: cx, s: cs });
if (car.kind !== 'player' && -vn > (RIGS.has(car.type) ? RIG_SHAKEN : 6)) car.slide = Math.max(car.slide, RIGS.has(car.type) ? 0.4 - vn * 0.1 : 0.5);
if (car.type === 'logger' && -vn > LOG_DROP_SPEED) this.dropLogs(car, cx);
if (car.kind !== 'player' && car.kind !== 'log' && !car.exploded && -vn > EXPLODE_SPEED) this.explode(car, -vn);
else this.damage(car, cx, cs, -vn);
}
}
/**
* Knock the panel nearest the contact point off the car (two on a hard hit). The player's
* car keeps track too, but its losses only show in the replay (see Renderer).
*/
private damage(car: Car, px: number, ps: number, strength: number): void {
if (RIGS.has(car.type) || car.kind === 'log') return;
if (car.exploded || strength < DAMAGE_SPEED || this.time - car.lastDamage < 0.25) return;
car.lastDamage = this.time;
const rx = px - car.x, rs = ps - car.s;
const lat = rx * Math.cos(car.yaw) - rs * Math.sin(car.yaw);
const lon = rx * Math.sin(car.yaw) + rs * Math.cos(car.yaw);
for (let k = strength > 9 ? 2 : 1; k > 0; k--) {
const part = nearestPart(car.len, car.wid, car.lost, lat, lon);
if (part < 0) return;
car.lost |= 1 << part;
this.events.push({ type: 'damage', strength, x: px, s: ps, car: car.id, part, vx: car.vx, vs: car.vs });
}
}
/**
* A logging truck sheds its load: five logs roll off the trailer on the side away from the
* hit (at hitX), carried along at the truck's speed. They're heavy, slide to a stop, and
* knock panels off and spin anything they hit.
*/
private dropLogs(truck: Car, hitX: number): void {
if (truck.cargoLost) return;
truck.cargoLost = true;
const fx = Math.sin(truck.yaw), fs = Math.cos(truck.yaw), rx = Math.cos(truck.yaw), rs = -Math.sin(truck.yaw);
const hitLat = (hitX - truck.x) * rx;
const side = hitLat === 0 ? (Math.random() < 0.5 ? -1 : 1) : -Math.sign(hitLat);
const lon = -truck.len / 2 + 6.2; // middle of the trailer (the tractor is at the front)
for (let i = 0; i < 5; i++) {
const log = makeCar('log', 0, 0, 0, 'log');
const off = side * (truck.wid / 2 + 0.45 + i * 0.75);
const along = lon + (Math.random() - 0.5) * 1.5;
log.x = truck.x + rx * off + fx * along;
log.s = truck.s + rs * off + fs * along;
log.yaw = truck.yaw + (Math.random() - 0.5) * 0.3;
const out = side * (2 + Math.random() * 3);
log.vx = truck.vx * 0.85 + rx * out;
log.vs = truck.vs * 0.85 + rs * out;
log.w = (Math.random() - 0.5) * 0.6;
log.wrecked = true; // no driver: it just slides
log.color = 0x6b4a2b;
this.cars.push(log);
}
this.events.push({ type: 'logs', strength: 10, x: truck.x, s: truck.s, car: truck.id });
}
private explode(car: Car, strength: number): void {
const remaining = RIGS.has(car.type) ? 0 : ALL_PARTS & ~car.lost; // rigs have no panels to fling
car.exploded = true;
car.wrecked = true;
car.lost = ALL_PARTS;
car.w += (Math.random() - 0.5) * 3;
this.events.push({ type: 'explode', strength, x: car.x, s: car.s, car: car.id, part: remaining, vx: car.vx, vs: car.vs });
if (car.type === 'logger') this.dropLogs(car, car.x + 1);
// The blast shoves nearby cars away; a gas tanker's reaches much further and harder.
const reach = car.type === 'tanker' ? 26 : 9;
const force = car.type === 'tanker' ? 1.1 : 0.6;
for (const o of this.cars) {
const dx = o.x - car.x, ds = o.s - car.s;
const d = Math.hypot(dx, ds);
if (o === car || d > reach || d < 0.1) continue;
const push = (reach - d) * force;
o.vx += (dx / d) * push;
o.vs += (ds / d) * push;
if (o.kind !== 'player') o.slide = Math.max(o.slide, 0.6);
}
}
}
/**
* The trailer is towed by its hitch and rolls on its own wheels, so it turns toward wherever
* the hitch is going sideways (relative to the trailer), over its wheelbase: in a swerve the
* tractor turns first and the trailer swings after it. It can only fold so far.
*/
function trailerFollow(car: Car, dt: number): void {
const [hx, hs] = hitchOf(car);
const [vx, vs] = pointVel(car, hx - car.x, hs - car.s);
const sideways = vx * Math.cos(car.trailerYaw) - vs * Math.sin(car.trailerYaw);
car.trailerYaw += (sideways / RIG_AXLE) * dt;
const fold = wrapAngle(car.trailerYaw - car.yaw);
if (Math.abs(fold) > RIG_JACKKNIFE) car.trailerYaw = car.yaw + Math.sign(fold) * RIG_JACKKNIFE;
}
function integrate(car: Car, dt: number): void {
const fx = Math.sin(car.yaw), fs = Math.cos(car.yaw);
const rx = Math.cos(car.yaw), rs = -Math.sin(car.yaw);
let vf = car.vx * fx + car.vs * fs;
let vr = car.vx * rx + car.vs * rs;
if (car.wrecked) {
// Skidding to a stop: friction on the whole velocity, spin bleeds off.
const speed = Math.hypot(vf, vr);
const k = speed > 0 ? Math.max(0, speed - (car.kind === 'log' ? 3.5 : 7) * dt) / speed : 0;
vf *= k;
vr *= k;
car.w *= Math.exp(-1.2 * dt);
} else {
vf += car.cmdAccel * dt;
// Hills: slower up, faster down (not at a standstill, so a stopped car doesn't creep).
if (Math.abs(vf) > 1) vf -= 9.81 * grade(car.s) * dt;
vf = Math.min(vf, car.maxSpeed);
const sliding = car.slide > 0;
vr *= Math.exp(-(sliding ? 1.6 : 9) * dt);
if (sliding) {
car.slide -= dt;
car.w *= Math.exp(-0.4 * dt);
vf -= Math.sign(vf) * 2 * dt;
} else {
car.w += (car.cmdYawRate - car.w) * Math.min(1, 9 * dt);
}
}
car.vx = vf * fx + vr * rx;
car.vs = vf * fs + vr * rs;
car.x += car.vx * dt;
car.s += car.vs * dt;
car.yaw += car.w * dt;
// The road bends under the car: in road space its heading and velocity turn the other way.
const turn = -curvature(car.s) * car.vs * dt;
if (turn !== 0) {
const c = Math.cos(turn), sn = Math.sin(turn);
const vx = car.vx;
car.vx = vx * c + car.vs * sn;
car.vs = -vx * sn + car.vs * c;
car.yaw += turn;
car.trailerYaw += turn;
}
}
interface Hit {
nx: number;
ns: number;
px: number;
ps: number;
depth: number;
}
/** An oriented box: a whole vehicle, or one part of a rig. */
export interface Box {
x: number;
s: number;
yaw: number;
len: number;
wid: number;
}
function axesOf(car: Box): [number, number][] {
return [
[Math.sin(car.yaw), Math.cos(car.yaw)],
[Math.cos(car.yaw), -Math.sin(car.yaw)],
];
}
export function cornersOf(car: Box): [number, number][] {
const [f, r] = axesOf(car);
const hl = car.len / 2, hw = car.wid / 2;
return [
[1, 1], [1, -1], [-1, -1], [-1, 1],
].map(([a, b]) => [car.x + f[0] * hl * a + r[0] * hw * b, car.s + f[1] * hl * a + r[1] * hw * b]);
}
/** Separating-axis test for two oriented boxes. Normal points from a to b. */
function obb(a: Box, b: Box): Hit | undefined {
const ca = cornersOf(a), cb = cornersOf(b);
let best: { depth: number; nx: number; ns: number; owner: Box } | undefined;
for (const [owner, axes] of [[a, axesOf(a)], [b, axesOf(b)]] as const) {
for (const [ax, as] of axes) {
let minA = Infinity, maxA = -Infinity, minB = Infinity, maxB = -Infinity;
for (const [x, s] of ca) { const p = x * ax + s * as; minA = Math.min(minA, p); maxA = Math.max(maxA, p); }
for (const [x, s] of cb) { const p = x * ax + s * as; minB = Math.min(minB, p); maxB = Math.max(maxB, p); }
const depth = Math.min(maxA - minB, maxB - minA);
if (depth <= 0) return undefined;
if (!best || depth < best.depth) {
const dir = (b.x - a.x) * ax + (b.s - a.s) * as >= 0 ? 1 : -1;
best = { depth, nx: ax * dir, ns: as * dir, owner };
}
}
}
if (!best) return undefined;
const { nx, ns } = best;
// Contact: the deepest corner of the body that does not own the separating face.
let pts: [number, number][];
if (best.owner === a) {
const proj = cb.map(([x, s]) => x * nx + s * ns);
const m = Math.min(...proj);
pts = cb.filter((_, i) => proj[i] < m + 0.15);
} else {
const proj = ca.map(([x, s]) => x * nx + s * ns);
const m = Math.max(...proj);
pts = ca.filter((_, i) => proj[i] > m - 0.15);
}
const px = pts.reduce((t, p) => t + p[0], 0) / pts.length;
const ps = pts.reduce((t, p) => t + p[1], 0) / pts.length;
return { nx, ns, px, ps, depth: best.depth };
}
// d(position of a body point)/d(yaw) for offset (rx, rs) is (rs, -rx).
function pointVel(car: Car, rx: number, rs: number): [number, number] {
return [car.vx + car.w * rs, car.vs - car.w * rx];
}
function perpDot(rx: number, rs: number, jx: number, js: number): number {
return rs * jx - rx * js;
}
/** An impulse landing behind a rig's hitch also swings the trailer about the hitch. */
function kickTrailer(car: Car, px: number, ps: number, jx: number, js: number): void {
if (!RIGS.has(car.type)) return;
const [hx, hs] = hitchOf(car);
const behind = (px - hx) * Math.sin(car.trailerYaw) + (ps - hs) * Math.cos(car.trailerYaw);
if (behind > 0) return; // that's the tractor
// Trailer about its hitch: roughly 10 t over 12 m, I = m L^2 / 3.
const swing = perpDot(px - hx, ps - hs, jx, js) / 650000;
car.trailerYaw += Math.max(-0.3, Math.min(0.3, swing));
}
function applyImpulse(car: Car, jx: number, js: number, rx: number, rs: number): void {
car.vx += jx / car.mass;
car.vs += js / car.mass;
car.w += perpDot(rx, rs, jx, js) / car.inertia;
}
