Files
Portfolio/src/scene/world/traffic-model.ts

1149 lines
45 KiB
TypeScript

import type { EnvironmentChapter } from '@/journey/types';
/**
* Night traffic as a small deterministic car-following simulation (no
* three.js here, so it is unit-testable and allocation free).
*
* A three-lane expressway: the reader drives the CENTRE lane, LEFT is the fast
* lane, RIGHT is freight and slow cars. Every vehicle runs IDM-lite against
* the nearest vehicle ahead whose lateral span (including the rest of a lane
* change) overlaps its own. The hero car is a virtual vehicle in CENTRE that
* nothing can touch: the only lane changes are right-lane cars borrowing the
* centre lane far ahead to pass a slower vehicle, and they leave it long
* before the reader reaches them (time to reach, a hard deadline, a flash).
*
* Coordinates are the hero frame: z grows toward the camera, a vehicle's
* origin is its rear face at road level, its body runs forward to z - length.
*/
export const TRAFFIC_LANE_LEFT = 0;
export const TRAFFIC_LANE_CENTRE = 1;
export const TRAFFIC_LANE_RIGHT = 2;
/** Lane centres (world x). The hero drives CENTRE. */
export const TRAFFIC_LANE_X: readonly number[] = Object.freeze([-5.4, 0, 5.4]);
/** Max lateral offset of a vehicle inside its lane (seeded per entry). */
export const TRAFFIC_LANE_JITTER = 0.1;
/** The asphalt's half width (road.ts). */
export const TRAFFIC_ASPHALT_HALF_WIDTH = 7.78;
export const TRAFFIC_CAR = 0;
export const TRAFFIC_TRUCK = 1;
export interface TrafficKindSize {
/** Render scale over the 1x body (traffic.ts). */
readonly scale: number;
readonly halfWidth: number;
readonly length: number;
readonly roof: number;
}
/** Cars 1.7x, trucks 1.4x the 1x bodies, so they read beside the 2.78x hero. */
export const TRAFFIC_KINDS: readonly TrafficKindSize[] = Object.freeze([
Object.freeze({ scale: 1.7, halfWidth: 1.87, length: 8.16, roof: 2.72 }),
Object.freeze({ scale: 1.4, halfWidth: 1.75, length: 21, roof: 5.67 }),
]);
/** The hero car's envelope in the model (x +-2.6, nose -6, tail 10). */
export const HERO_HALF_WIDTH = 2.6;
export const HERO_NOSE_Z = -6;
export const HERO_TAIL_Z = 10;
/** A vehicle's rear beyond this (pulling away) leaves into the haze. */
export const TRAFFIC_FAR_Z = -660;
/** Slower-than-hero vehicles enter here, inside the far fog. */
export const TRAFFIC_ENTRY_FAR_Z = -640;
/**
* When a lane's far stretch is empty (after a scroll has streamed everything
* past), a slower vehicle may instead screen-door in at the edge of the haze,
* so the road ahead fills again in seconds rather than the minute a vehicle
* at 8 u/s closing takes from -640. Entry z in [NEAR - SPAN, NEAR]; only when
* nothing of that lane is in the band around it.
*/
export const TRAFFIC_ENTRY_NEAR_Z = -420;
export const TRAFFIC_ENTRY_NEAR_SPAN = 90;
export const TRAFFIC_ENTRY_NEAR_BAND_FAR = -560;
export const TRAFFIC_ENTRY_NEAR_BAND_NEAR = -270;
export const FADE_IN_SECONDS = 0.6;
/**
* A right car entering from the haze may join a truck's queue instead: the
* pair then drifts into the overtake band together, so a pass is on screen
* even when the pool is small (lite) and cars would not catch a truck in
* time on their own. The truck's rear z must lie in this window (the car
* fades in behind it, still in the haze) with no follower within the gap.
*/
export const TRAFFIC_CONVOY_FAR_Z = -600;
export const TRAFFIC_CONVOY_NEAR_Z = -450;
const CONVOY_FOLLOWER_GAP = 150;
const CONVOY_GAP_MIN = 30;
const CONVOY_GAP_SPAN = 20;
/** The farthest a queued car's rear sits behind its truck's rear, past its own length (m). */
export const TRAFFIC_CONVOY_GAP_MAX = CONVOY_GAP_MIN + CONVOY_GAP_SPAN;
/**
* Lite (6-vehicle pool, phones): the convoy window is nearer, so a queued
* pair reaches the overtake band in ~10-17 s at cruise instead of ~25 s.
*/
export const TRAFFIC_LITE_CONVOY_FAR_Z = -420;
export const TRAFFIC_LITE_CONVOY_NEAR_Z = -330;
/**
* Lite: a pass is staged at each chapter entry (TrafficInput.cue) for up to
* TRAFFIC_LITE_STAGE_SECONDS, and whenever the road has gone this long with
* no lane change: a truck screen-doors in at the haze (if none is coming)
* and one right car more than the chapter wants may queue behind it. The
* first lane change ends the staging.
*/
export const TRAFFIC_LITE_DROUGHT_SECONDS = 10;
export const TRAFFIC_LITE_STAGE_SECONDS = 20;
/** Where a staged truck's rear enters: [Z, Z + SPAN], inside the lite convoy window. */
export const TRAFFIC_LITE_STAGE_Z = -410;
export const TRAFFIC_LITE_STAGE_SPAN = 40;
/** Lite staging: a right-lane truck further out than the window by this much does not count as on its way. */
export const TRAFFIC_LITE_STAGE_REACH = 140;
/** Lite staging: a truck on its way counts only with a passer that will be this close behind it... */
const TRAFFIC_LITE_STAGE_FOLLOW = 60;
/** ...by the time it is this far into the entry band. */
const TRAFFIC_LITE_STAGE_BAND = 30;
/** Lite staging ahead of a car already out there: no nearer than this (rear z; it screen-doors in). */
export const TRAFFIC_LITE_STAGE_NEAR_Z = -260;
/**
* A vehicle whose nose has passed this z has gone beneath the chase camera
* (z 23-26, y >= 5.6) and out of every chapter's frame; it is still simulated
* (state BEHIND) so a car just overtaken can come back past the reader.
*/
export const TRAFFIC_EXIT_NOSE_Z = 20;
/** Behind the camera this far (rear z), a vehicle is forgotten. */
export const TRAFFIC_BEHIND_Z = 150;
/** Near-fade limits: never (cars, most trucks) or a camera-side truck's band. */
export const TRAFFIC_NEAR_OPEN = 64;
export const TRAFFIC_NEAR_CAMERA_SIDE = -30;
/** A truck's near limit is decided once, as its rear crosses this z. */
export const TRAFFIC_LATCH_Z = -60;
/** IDM-lite (SI units: m, m/s). */
export const TRAFFIC_IDM = Object.freeze({
A: 2.2,
B: 3.0,
S0: 6,
T: 1.3,
vMin: 4,
minGap: 2,
});
/**
* Base desired speeds; each vehicle is +-8 % around its own. The reader cruises
* at 18 + 0.18 * 54 = 27.7 u/s: fast cars pass on the left at ~12 u/s, the
* right lane is overtaken at 8-15 u/s.
*/
export const TRAFFIC_SPEEDS = Object.freeze({
leftCar: 40,
rightCar: 20,
truck: 13,
});
/**
* A car borrowing the centre lane to pass drives this much over its own pace:
* about the reader's cruise, so it holds station ahead while the truck drops
* back, and the whole pass stays where it started.
*/
const OVERTAKE_BOOST = 8;
/** ...and this much when the reader closes in before it can pull back in. */
const SPRINT_BOOST = 12;
export const LANE_CHANGE_SECONDS = 1.4;
/**
* A yield the reader forces (closing fast, a flash, the deadline) is a swerve:
* the corridor (|x| >= 4.47 of the 5.4 m change, smoothstep 0.71) is clear
* 0.57 s in. With one 0.1 s frame of slack and the worst closing speed
* (120 - 4 u/s) that is 78 + 12 m, so a car that leaves at the deadline is
* clear of the corridor by -10, ahead of the nose at -6.
*/
export const FORCED_LANE_CHANGE_SECONDS = 0.8;
export const ENTRY_LEAD_SECONDS = 0.6;
export const YIELD_LEAD_SECONDS = 0.35;
/**
* A right-lane car enters the centre only this far ahead (rear z): 160 m
* from the nose, where a car is ~15 px tall at 1440x900. It drifts ~40 m
* toward the reader while it accelerates out of the queue, then holds
* station, so the pass and the return happen 110-150 m ahead.
*/
export const CENTRE_ENTRY_Z = -150;
/** ...and not so far that it is lost in the haze. */
const CENTRE_ENTRY_FAR_Z = -210;
/** Still in the corridor past this z: it must leave now, or dither out. */
export const CENTRE_DEADLINE_Z = -100;
/** A flash reaches a centre-lane vehicle this far ahead. */
export const FLASH_REACH_Z = -320;
/** A centre vehicle the reader would reach within this many seconds yields. */
export const TIME_TO_REACH_SECONDS = 3;
/** Urgent yields skip the indicator lead. */
const URGENT_SECONDS = 2;
/** A centre car starts looking for its way out this early (time to reach, rear z). */
const YIELD_SOON_SECONDS = 6;
export const YIELD_SOON_Z = -112;
/** Past this a yield skips its indicator lead and goes at once. */
const YIELD_NOW_Z = CENTRE_DEADLINE_Z - 6;
/** Headway fraction a forced yield accepts. */
const FORCED_HEADWAY = 0.35;
export const MANOEUVRE_GAP_SECONDS = 4;
/** Entry only when the reader is near cruise (closing speed below this). */
const ENTRY_MAX_CLOSING = 12;
/** The slow leader must be within this gap and this much slower than the car wants. */
const ENTRY_LEADER_GAP = 90;
const ENTRY_LEADER_DELTA = 4;
/** Indicators: 1.5 Hz, 40 % duty, 80 ms edges (photosensitivity: <= 3 Hz). */
export const INDICATOR_HZ = 1.5;
export const INDICATOR_DUTY = 0.4;
const INDICATOR_EDGE = 0.08;
/** Brake lamps: hysteresis on the IDM deceleration, minimum on time and spacing. */
export const BRAKE_ON_ACCEL = -1.2;
export const BRAKE_OFF_ACCEL = -0.4;
export const BRAKE_MIN_ON_SECONDS = 0.5;
export const BRAKE_MIN_SPACING_SECONDS = 0.7;
export const BRAKE_GLOBAL_PER_SECOND = 3;
const BRAKE_RISE_SECONDS = 0.15;
const BRAKE_FALL_SECONDS = 0.3;
export const DITHER_SECONDS = 0.25;
const SPAWN_GAP_SECONDS = 1.5;
/**
* The rising edge of a flash to pass, from the beam's intensity (0..1):
* fires once as it climbs past FLASH_ON after resting or falling below
* FLASH_OFF. The intro sweep climbs 0 -> 1 without ever falling, so it never
* arms the detector; the descent to the dipped level after it does.
*/
export const FLASH_ON = 0.95;
export const FLASH_OFF = 0.85;
export function createFlashDetector(): (intensity: number) => boolean {
let armed = false;
let previous = Number.NEGATIVE_INFINITY;
return (intensity: number): boolean => {
let fired = false;
if (intensity < FLASH_OFF) {
if (intensity <= previous) armed = true;
} else if (intensity > FLASH_ON && armed) {
armed = false;
fired = true;
}
previous = intensity;
return fired;
};
}
/** Frame states. */
export const TRAFFIC_PENDING = 0;
export const TRAFFIC_VISIBLE = 1;
export const TRAFFIC_DITHERING = 2;
export const TRAFFIC_BEHIND = 3;
export interface TrafficDensity {
readonly left: number;
readonly right: number;
/** Of the right lane, how many are trucks. */
readonly trucks: number;
}
/** Visible vehicles each chapter wants per lane. */
export const TRAFFIC_DENSITY: Readonly<Record<EnvironmentChapter, TrafficDensity>> = Object.freeze({
'deep-night': Object.freeze({ left: 2, right: 2, trucks: 1 }),
systems: Object.freeze({ left: 3, right: 3, trucks: 1 }),
infrastructure: Object.freeze({ left: 3, right: 5, trucks: 3 }),
builds: Object.freeze({ left: 3, right: 4, trucks: 2 }),
'pre-dawn': Object.freeze({ left: 2, right: 2, trucks: 0 }),
destination: Object.freeze({ left: 1, right: 1, trucks: 0 }),
});
/** Low tier: at most 2 / 4, one truck at a time (two in the pool). */
export const TRAFFIC_LITE_CAP: TrafficDensity = Object.freeze({
left: 2,
right: 4,
trucks: 1,
});
export interface TrafficConfig {
pool: 6 | 10 | 12;
seed: number;
lite: boolean;
}
export interface TrafficInput {
/** Seconds; clamped to [0, 0.1]. */
dt: number;
/** The reader's road speed, u/s. */
heroSpeed: number;
chapter: EnvironmentChapter;
/** A navigator jump or a fling: no manoeuvre starts. */
sweeping: boolean;
/** One frame: the reader flashed the high beam. */
flash: boolean;
/** The damped chapter camera x (camera-side trucks keep a near fade). */
cameraX: number;
/** 0..1, parked at the destination. */
engineOff: number;
/** One frame: the reader arrived in a new chapter (lite stages a pass). */
cue?: boolean;
}
export interface TrafficFrame {
readonly count: number;
/** 0 car, 1 truck (static). */
readonly kind: Uint8Array;
/** 0 pending, 1 visible, 2 dithering out, 3 behind the camera (not drawn). */
readonly active: Uint8Array;
readonly x: Float32Array;
readonly z: Float32Array;
readonly yaw: Float32Array;
/** Own road speed, u/s. */
readonly speed: Float32Array;
/** 0..1, ramped. */
readonly brake: Float32Array;
/** -1 left, 0 off, 1 right. */
readonly indicator: Int8Array;
/** 0..1 lamp level of the indicator this frame. */
readonly blink: Float32Array;
/** Near-fade limit (TRAFFIC_NEAR_OPEN or TRAFFIC_NEAR_CAMERA_SIDE). */
readonly nearLimit: Float32Array;
/** 0..1 screen-door fade of an emergency exit. */
readonly dither: Float32Array;
}
export interface TrafficEvents {
/** Vehicles crossing the hero's middle either way. */
passes: number;
emergencyExits: number;
brakeOnsets: number;
laneChanges: number;
/** Seconds of model time at the last emergency exit. */
lastEmergencyAt: number;
}
export interface TrafficModel {
step(input: TrafficInput): TrafficFrame;
readonly frame: TrafficFrame;
events(): TrafficEvents;
reset(): void;
}
/** How many trucks a pool carries. */
export function trafficTruckCount(pool: number): number {
return pool <= 6 ? 2 : 3;
}
const NO_PLAN = -1;
const HERO = -2;
const clamp = (value: number, min: number, max: number): number => (value < min ? min : value > max ? max : value);
const smooth = (from: number, to: number, value: number): number => {
const t = clamp((value - from) / (to - from), 0, 1);
return t * t * (3 - 2 * t);
};
/** Indicator lamp level `seconds` after it was switched on (on at once). */
export function indicatorBlink(seconds: number): number {
const period = 1 / INDICATOR_HZ;
const on = period * INDICATOR_DUTY;
const t = seconds - Math.floor(seconds / period) * period;
return smooth(0, INDICATOR_EDGE, t) * (1 - smooth(on, on + INDICATOR_EDGE, t));
}
export function createTrafficModel(config: TrafficConfig): TrafficModel {
const n = config.pool;
const trucks = trafficTruckCount(n);
const kind = new Uint8Array(n);
for (let i = 0; i < n; i += 1) kind[i] = i < trucks ? TRAFFIC_TRUCK : TRAFFIC_CAR;
const state = new Uint8Array(n);
const home = new Uint8Array(n);
const lane = new Uint8Array(n);
const plan = new Int8Array(n);
const indicator = new Int8Array(n);
const brakeOn = new Uint8Array(n);
const overtaking = new Int8Array(n);
const leader = new Int8Array(n);
const order = new Int8Array(n);
const x = new Float64Array(n);
const z = new Float64Array(n);
const prevZ = new Float64Array(n);
const v = new Float64Array(n);
const v0 = new Float64Array(n);
const accel = new Float64Array(n);
const fromX = new Float64Array(n);
const toX = new Float64Array(n);
const laneT = new Float64Array(n);
const laneSeconds = new Float64Array(n);
const fadeIn = new Float64Array(n);
const jitter = new Float64Array(n);
const planAt = new Float64Array(n);
const brakeSince = new Float64Array(n);
const lastOnset = new Float64Array(n);
const brakeLevel = new Float64Array(n);
const indicatorSince = new Float64Array(n);
const near = new Float64Array(n);
const dither = new Float64Array(n);
const onsets = new Float64Array(BRAKE_GLOBAL_PER_SECOND);
const lastSpawn = new Float64Array(3);
const frame: TrafficFrame = {
count: n,
kind,
active: new Uint8Array(n),
x: new Float32Array(n),
z: new Float32Array(n),
yaw: new Float32Array(n),
speed: new Float32Array(n),
brake: new Float32Array(n),
indicator: new Int8Array(n),
blink: new Float32Array(n),
nearLimit: new Float32Array(n),
dither: new Float32Array(n),
};
const counters: TrafficEvents = {
passes: 0,
emergencyExits: 0,
brakeOnsets: 0,
laneChanges: 0,
lastEmergencyAt: Number.NEGATIVE_INFINITY,
};
let seed = 1;
let time = 0;
let hero = 18;
let cameraX = 0;
let engineOff = 0;
let chapter: EnvironmentChapter = 'deep-night';
let primed = false;
let centre = -1;
let sprint = -1;
/** The reader flashed the centre car: every later gap check is the forced one. */
let hurried = false;
let lastManoeuvre = Number.NEGATIVE_INFINITY;
let onsetIndex = 0;
/** Lite pass staging: the last lane change, and the end of a chapter-entry cue. */
let lastLaneChange = 0;
let stageUntil = Number.NEGATIVE_INFINITY;
const random = (): number => {
seed = (seed * 16807) % 2147483647;
return (seed - 1) / 2147483646;
};
const halfWidth = (i: number): number => TRAFFIC_KINDS[kind[i]!]!.halfWidth;
const length = (i: number): number => TRAFFIC_KINDS[kind[i]!]!.length;
const present = (i: number): boolean => state[i] !== TRAFFIC_PENDING;
const laneOf = (value: number): number =>
value < -2.7 ? TRAFFIC_LANE_LEFT : value > 2.7 ? TRAFFIC_LANE_RIGHT : TRAFFIC_LANE_CENTRE;
const inCorridor = (i: number): boolean => Math.abs(x[i]!) < HERO_HALF_WIDTH + halfWidth(i);
const lateral = (i: number): boolean => laneT[i]! < 1;
/** Where a vehicle's lane change (if any) will take it. */
const targetLane = (i: number): number => (lateral(i) ? laneOf(toX[i]!) : plan[i]! !== NO_PLAN ? plan[i]! : lane[i]!);
function reset(): void {
seed = (Math.floor(Math.abs(config.seed)) % 2147483646) + 1;
time = 0;
hero = 18;
cameraX = 0;
engineOff = 0;
chapter = 'deep-night';
primed = false;
centre = -1;
sprint = -1;
hurried = false;
lastManoeuvre = Number.NEGATIVE_INFINITY;
onsetIndex = 0;
lastLaneChange = 0;
stageUntil = Number.NEGATIVE_INFINITY;
onsets.fill(Number.NEGATIVE_INFINITY);
lastSpawn.fill(Number.NEGATIVE_INFINITY);
state.fill(TRAFFIC_PENDING);
plan.fill(NO_PLAN);
indicator.fill(0);
brakeOn.fill(0);
brakeLevel.fill(0);
overtaking.fill(-1);
laneT.fill(1);
laneSeconds.fill(LANE_CHANGE_SECONDS);
fadeIn.fill(0);
dither.fill(0);
near.fill(TRAFFIC_NEAR_OPEN);
lastOnset.fill(Number.NEGATIVE_INFINITY);
for (let i = 0; i < n; i += 1) order[i] = i;
counters.passes = 0;
counters.emergencyExits = 0;
counters.brakeOnsets = 0;
counters.laneChanges = 0;
counters.lastEmergencyAt = Number.NEGATIVE_INFINITY;
writeFrame();
}
const cameraSide = (laneIndex: number): boolean =>
Math.abs(cameraX) > 1.5 && Math.sign(cameraX) === Math.sign(TRAFFIC_LANE_X[laneIndex]!);
const latch = (i: number): void => {
near[i] = kind[i] === TRAFFIC_TRUCK && cameraSide(laneOf(x[i]!)) ? TRAFFIC_NEAR_CAMERA_SIDE : TRAFFIC_NEAR_OPEN;
};
/** Swept lateral span of i: where it is and where its lane change ends. */
const spanLo = (i: number): number => Math.min(x[i]!, toX[i]!) - halfWidth(i);
const spanHi = (i: number): number => Math.max(x[i]!, toX[i]!) + halfWidth(i);
/** The nearest vehicle ahead whose swept span overlaps i's (or the hero). */
function findLeader(i: number): number {
const lo = spanLo(i);
const hi = spanHi(i);
const zi = z[i]!;
let best = -1;
let bestZ = Number.NEGATIVE_INFINITY;
for (let j = 0; j < n; j += 1) {
if (j === i || !present(j)) continue;
const zj = z[j]!;
if (zj >= zi || zj <= bestZ) continue;
if (spanHi(j) <= lo || spanLo(j) >= hi) continue;
best = j;
bestZ = zj;
}
if (HERO_TAIL_Z < zi && HERO_TAIL_Z > bestZ && hi > -HERO_HALF_WIDTH && lo < HERO_HALF_WIDTH) best = HERO;
return best;
}
/** Room for a body of `len` / `hw` at rear z `zr` and speed `speed`, laterally over [lo, hi]. */
function clearAt(self: number, lo: number, hi: number, zr: number, len: number, speed: number, headway = 1): boolean {
for (let j = 0; j < n; j += 1) {
if (j === self || !present(j)) continue;
if (spanHi(j) <= lo || spanLo(j) >= hi) continue;
const zj = z[j]!;
if (zj <= zr) {
// j ahead: our nose behind its rear by a safe gap.
if (zr - len - zj < (TRAFFIC_IDM.S0 + speed * TRAFFIC_IDM.T) * headway) return false;
} else if (zj - length(j) - zr < (TRAFFIC_IDM.S0 + v[j]! * TRAFFIC_IDM.T + Math.max(0, v[j]! - speed) * 1.5) * headway) {
// j behind: its nose behind our rear by its safe gap, more if it is closing.
return false;
}
}
return true;
}
/** Can vehicle i move into `laneIndex` from where it is now? */
/**
* Can vehicle i move into `laneIndex` from where it is now? A yield the
* reader forces accepts a tighter gap (a real driver ducking in, braking).
*/
function slotFree(i: number, laneIndex: number, forced = false): boolean {
const target = TRAFFIC_LANE_X[laneIndex]!;
const hw = halfWidth(i);
const lo = Math.min(x[i]!, target) - hw - TRAFFIC_LANE_JITTER;
const hi = Math.max(x[i]!, target) + hw + TRAFFIC_LANE_JITTER;
return clearAt(i, lo, hi, z[i]!, length(i), v[i]!, forced ? FORCED_HEADWAY : 1);
}
/** Is there room to pull back in ahead of the vehicle being passed? */
function returnRoom(i: number, l: number): boolean {
const front = z[l]! - length(l);
const need = length(i) + 2 * (TRAFFIC_IDM.S0 + v0[i]! * TRAFFIC_IDM.T);
for (let j = 0; j < n; j += 1) {
if (j === i || j === l || !present(j) || laneOf(x[j]!) !== TRAFFIC_LANE_RIGHT) continue;
if (z[j]! < z[l]! && front - z[j]! < need) return false;
}
return true;
}
function startLateral(i: number, laneIndex: number, seconds = LANE_CHANGE_SECONDS): void {
fromX[i] = x[i]!;
toX[i] = TRAFFIC_LANE_X[laneIndex]! + (laneIndex === TRAFFIC_LANE_CENTRE ? 0 : jitter[i]!);
laneT[i] = 0;
laneSeconds[i] = seconds;
plan[i] = NO_PLAN;
const direction = Math.sign(toX[i]! - fromX[i]!);
if (indicator[i] !== direction) {
indicator[i] = direction;
indicatorSince[i] = time;
}
counters.laneChanges += 1;
lastLaneChange = time;
stageUntil = Number.NEGATIVE_INFINITY;
}
/** A change already under way finishes at the swerve pace (progress is kept: no jump in x). */
function hurry(i: number): void {
if (lateral(i) && laneSeconds[i]! > FORCED_LANE_CHANGE_SECONDS) laneSeconds[i] = FORCED_LANE_CHANGE_SECONDS;
}
function signal(i: number, laneIndex: number, lead: number): void {
plan[i] = laneIndex;
planAt[i] = time + lead;
const direction = Math.sign(TRAFFIC_LANE_X[laneIndex]! - x[i]!);
if (indicator[i] !== direction) {
indicator[i] = direction;
indicatorSince[i] = time;
}
}
function cancel(i: number): void {
plan[i] = NO_PLAN;
indicator[i] = 0;
overtaking[i] = -1;
if (centre === i) {
centre = -1;
sprint = -1;
hurried = false;
}
}
function park(i: number): void {
state[i] = TRAFFIC_PENDING;
plan[i] = NO_PLAN;
indicator[i] = 0;
brakeOn[i] = 0;
brakeLevel[i] = 0;
laneT[i] = 1;
dither[i] = 0;
fadeIn[i] = 0;
overtaking[i] = -1;
if (centre === i) {
centre = -1;
sprint = -1;
hurried = false;
}
}
function place(i: number, laneIndex: number, rear: number, fading = false): void {
const k = kind[i]!;
const base =
k === TRAFFIC_TRUCK
? TRAFFIC_SPEEDS.truck
: laneIndex === TRAFFIC_LANE_LEFT
? TRAFFIC_SPEEDS.leftCar
: TRAFFIC_SPEEDS.rightCar;
v0[i] = base * (0.92 + random() * 0.16);
v[i] = v0[i]!;
accel[i] = 0;
jitter[i] = (random() * 2 - 1) * TRAFFIC_LANE_JITTER;
home[i] = laneIndex;
lane[i] = laneIndex;
x[i] = TRAFFIC_LANE_X[laneIndex]! + jitter[i]!;
fromX[i] = x[i]!;
toX[i] = x[i]!;
laneT[i] = 1;
z[i] = rear;
prevZ[i] = rear;
plan[i] = NO_PLAN;
indicator[i] = 0;
brakeOn[i] = 0;
brakeLevel[i] = 0;
lastOnset[i] = Number.NEGATIVE_INFINITY;
overtaking[i] = -1;
dither[i] = 0;
fadeIn[i] = fading ? 1 : 0;
laneSeconds[i] = LANE_CHANGE_SECONDS;
near[i] = TRAFFIC_NEAR_OPEN;
state[i] = TRAFFIC_VISIBLE;
}
/** Lite: a right-lane truck in the convoy window with no car queued behind it yet. */
function convoyOpen(): boolean {
for (let t = 0; t < n; t += 1) {
if (!present(t) || kind[t] !== TRAFFIC_TRUCK || lane[t] !== TRAFFIC_LANE_RIGHT || lateral(t)) continue;
if (z[t]! >= TRAFFIC_LITE_CONVOY_NEAR_Z || z[t]! <= TRAFFIC_LITE_CONVOY_FAR_Z) continue;
let followed = false;
for (let j = 0; j < n; j += 1) {
if (j === t || !present(j) || kind[j] !== TRAFFIC_CAR || laneOf(x[j]!) !== TRAFFIC_LANE_RIGHT) continue;
if (z[j]! > z[t]! && z[j]! - length(j) - z[t]! < CONVOY_FOLLOWER_GAP) followed = true;
}
if (!followed) return true;
}
return false;
}
/**
* Rear z (< 0) for a car queued behind an unfollowed right-lane truck in
* the convoy window; 1 while such a truck is still short of the window
* (worth waiting a few seconds for); 0 when there is none.
*/
function convoyRear(len: number): number {
const windowFar = config.lite ? TRAFFIC_LITE_CONVOY_FAR_Z : TRAFFIC_CONVOY_FAR_Z;
const windowNear = config.lite ? TRAFFIC_LITE_CONVOY_NEAR_Z : TRAFFIC_CONVOY_NEAR_Z;
let soon = 0;
for (let t = 0; t < n; t += 1) {
if (!present(t) || kind[t] !== TRAFFIC_TRUCK || lane[t] !== TRAFFIC_LANE_RIGHT || lateral(t)) continue;
const zt = z[t]!;
if (zt >= windowNear) continue;
if (zt <= windowFar) {
soon = 1;
continue;
}
let followed = false;
for (let j = 0; j < n; j += 1) {
if (j === t || !present(j) || laneOf(x[j]!) !== TRAFFIC_LANE_RIGHT) continue;
// (Lite: a truck behind is no passer; its own pass is already under way.)
if (config.lite && kind[j] === TRAFFIC_TRUCK) continue;
if (z[j]! > zt && z[j]! - length(j) - zt < CONVOY_FOLLOWER_GAP) {
followed = true;
break;
}
}
if (followed) continue;
return zt + len + CONVOY_GAP_MIN + random() * CONVOY_GAP_SPAN;
}
return soon;
}
/** Is any vehicle of this lane in the band around the near entry? */
function laneBandEmpty(laneIndex: number): boolean {
for (let j = 0; j < n; j += 1) {
if (!present(j) || laneOf(x[j]!) !== laneIndex) continue;
if (z[j]! > TRAFFIC_ENTRY_NEAR_BAND_FAR && z[j]! < TRAFFIC_ENTRY_NEAR_BAND_NEAR) return false;
}
return true;
}
function target(): TrafficDensity {
const wanted = TRAFFIC_DENSITY[chapter];
const parked = chapter === 'destination' && engineOff > 0.5;
const cap = config.lite ? TRAFFIC_LITE_CAP : null;
const left = parked ? 0 : cap ? Math.min(wanted.left, cap.left) : wanted.left;
const right = parked ? 0 : cap ? Math.min(wanted.right, cap.right) : wanted.right;
const truckCount = parked ? 0 : Math.min(right, cap ? Math.min(wanted.trucks, cap.trucks) : wanted.trucks);
scratchDensity.left = left;
scratchDensity.right = right;
scratchDensity.trucks = truckCount;
return scratchDensity;
}
const scratchDensity = { left: 0, right: 0, trucks: 0 };
function countVisible(laneIndex: number, k: number): number {
let count = 0;
for (let i = 0; i < n; i += 1) if (state[i] === TRAFFIC_VISIBLE && home[i] === laneIndex && kind[i] === k) count += 1;
return count;
}
function pendingOf(k: number): number {
for (let i = 0; i < n; i += 1) if (state[i] === TRAFFIC_PENDING && kind[i] === k) return i;
return -1;
}
/** One group (lane, kind) spawns at most one vehicle per call. */
/** `queueOnly`: the call carries a staging extra, which may only join a truck's queue. */
function spawn(group: number, laneIndex: number, k: number, want: number, initial: boolean, queueOnly = false): void {
if (countVisible(laneIndex, k) >= want) return;
if (!initial && time - lastSpawn[group]! < SPAWN_GAP_SECONDS) return;
const i = pendingOf(k);
if (i < 0) return;
const len = TRAFFIC_KINDS[k]!.length;
const hw = TRAFFIC_KINDS[k]!.halfWidth;
const base =
k === TRAFFIC_TRUCK
? TRAFFIC_SPEEDS.truck
: laneIndex === TRAFFIC_LANE_LEFT
? TRAFFIC_SPEEDS.leftCar
: TRAFFIC_SPEEDS.rightCar;
const lx = TRAFFIC_LANE_X[laneIndex]!;
const lo = lx - hw - TRAFFIC_LANE_JITTER;
const hi = lx + hw + TRAFFIC_LANE_JITTER;
let rear: number;
if (initial) {
// Spread the opening road through the visible span.
const seen = countVisible(laneIndex, k);
const slots = Math.max(1, want);
rear =
-90 -
((seen + 0.5) / slots) * 500 -
(laneIndex === TRAFFIC_LANE_LEFT ? 40 : 0) -
random() * 30 -
(k === TRAFFIC_TRUCK ? 15 : 0);
} else if (base * 1.08 > hero + 0.5) {
if (queueOnly && countVisible(laneIndex, k) >= want - 1) return;
rear = TRAFFIC_EXIT_NOSE_Z + len - 0.01;
} else if (k === TRAFFIC_CAR && laneIndex === TRAFFIC_LANE_RIGHT && (rear = convoyRear(len)) !== 0) {
// A truck is about to reach the window: wait for it (rear > 0)...
if (rear > 0) return;
// ...or join its queue in the haze: a pass waiting to happen.
if (!clearAt(i, lo, hi, rear, len, TRAFFIC_SPEEDS.truck)) return;
place(i, laneIndex, rear, true);
v[i] = TRAFFIC_SPEEDS.truck;
lastSpawn[group] = time;
return;
} else if (queueOnly && countVisible(laneIndex, k) >= want - 1) {
// The staging extra is for the queue only: no ordinary entry on its account.
return;
} else if (laneBandEmpty(laneIndex)) {
// The far stretch is empty: screen-door in at the edge of the haze.
rear = TRAFFIC_ENTRY_NEAR_Z - random() * TRAFFIC_ENTRY_NEAR_SPAN;
if (!clearAt(i, lo, hi, rear, len, base * 1.08)) return;
place(i, laneIndex, rear, true);
lastSpawn[group] = time;
return;
} else {
rear = TRAFFIC_ENTRY_FAR_Z;
}
if (!clearAt(i, lo, hi, rear, len, base * 1.08)) return;
place(i, laneIndex, rear);
if (!initial && rear > TRAFFIC_LATCH_Z) latch(i);
lastSpawn[group] = time;
}
/** Lite: a pass is wanted (chapter-entry cue armed, or a lane-change drought), and the road is running. */
function staging(): boolean {
if (!config.lite || engineOff > 0 || chapter === 'destination') return false;
return time < stageUntil || time - lastLaneChange > TRAFFIC_LITE_DROUGHT_SECONDS;
}
/** Vehicles present in (or bound for) the right lane, ahead of the camera. */
function rightCount(): number {
let count = 0;
for (let i = 0; i < n; i += 1) if (state[i] === TRAFFIC_VISIBLE && home[i] === TRAFFIC_LANE_RIGHT) count += 1;
return count;
}
/** Lite staging: a truck screen-doors in at the haze when none is on its way to the overtake band. */
function stageTruck(): void {
if (time - lastSpawn[1]! < SPAWN_GAP_SECONDS) return;
for (let t = 0; t < n; t += 1) {
if (!present(t) || kind[t] !== TRAFFIC_TRUCK || laneOf(x[t]!) !== TRAFFIC_LANE_RIGHT) continue;
// One already in (or just short of) the convoy window will do; one deep in the haze is too late.
if (z[t]! >= CENTRE_ENTRY_Z || z[t]! <= TRAFFIC_LITE_CONVOY_FAR_Z - TRAFFIC_LITE_STAGE_REACH) continue;
// ...and only if someone will pass it: a car closing on it, or a free car to queue in the window.
if (z[t]! < TRAFFIC_LITE_CONVOY_NEAR_Z && pendingOf(TRAFFIC_CAR) >= 0) return;
for (let c = 0; c < n; c += 1) {
if (!present(c) || kind[c] !== TRAFFIC_CAR || laneOf(x[c]!) !== TRAFFIC_LANE_RIGHT || lateral(c)) continue;
if (z[c]! <= z[t]! || v0[c]! - v[t]! < ENTRY_LEADER_DELTA) continue;
// Closing in time: within reach of it by the time it is at the entry band.
const seconds = Math.max(0, CENTRE_ENTRY_Z - TRAFFIC_LITE_STAGE_BAND - z[t]!) / Math.max(1, hero - v[t]!);
if (z[c]! - length(c) - z[t]! - (v0[c]! - v[t]!) * seconds < TRAFFIC_LITE_STAGE_FOLLOW) return;
}
}
const i = pendingOf(TRAFFIC_TRUCK);
if (i < 0) return;
const { length: len, halfWidth: hw } = TRAFFIC_KINDS[TRAFFIC_TRUCK]!;
const lx = TRAFFIC_LANE_X[TRAFFIC_LANE_RIGHT]!;
let rear = TRAFFIC_LITE_STAGE_Z + random() * TRAFFIC_LITE_STAGE_SPAN;
if (pendingOf(TRAFFIC_CAR) < 0) {
// No car left to queue behind it: slot the truck just ahead of a right-lane car already out there.
let lead = -1;
for (let c = 0; c < n; c += 1) {
if (!present(c) || kind[c] !== TRAFFIC_CAR || laneOf(x[c]!) !== TRAFFIC_LANE_RIGHT || lateral(c)) continue;
const vc = v[c]!;
const ahead = z[c]! - length(c) - TRAFFIC_IDM.S0 - vc * TRAFFIC_IDM.T - Math.max(0, vc - TRAFFIC_SPEEDS.truck * 1.08) * 1.5 - 4;
if (ahead > TRAFFIC_LITE_STAGE_NEAR_Z || ahead < TRAFFIC_LITE_STAGE_Z - TRAFFIC_LITE_STAGE_REACH) continue;
if (lead < 0 || ahead > rear) {
lead = c;
rear = ahead;
}
}
if (lead < 0) return;
rear -= random() * 10;
}
if (!clearAt(i, lx - hw - TRAFFIC_LANE_JITTER, lx + hw + TRAFFIC_LANE_JITTER, rear, len, TRAFFIC_SPEEDS.truck * 1.08)) return;
// Leave the passer room to cut back in ahead of it (as returnRoom will ask), or the pass never starts.
const room = TRAFFIC_KINDS[TRAFFIC_CAR]!.length + 2 * (TRAFFIC_IDM.S0 + TRAFFIC_SPEEDS.rightCar * 1.08 * TRAFFIC_IDM.T);
for (let j = 0; j < n; j += 1) {
if (j === i || !present(j) || laneOf(x[j]!) !== TRAFFIC_LANE_RIGHT) continue;
if (z[j]! < rear && rear - len - z[j]! < room) return;
}
place(i, TRAFFIC_LANE_RIGHT, rear, true);
lastSpawn[1] = time;
}
function spawnAll(initial: boolean): void {
const want = target();
// Lite staging may add one right-lane vehicle over the chapter's want (5 at most).
const stage = !initial && staging() && want.right > 0 && rightCount() <= TRAFFIC_LITE_CAP.right;
if (stage) stageTruck();
const extra = stage && rightCount() <= TRAFFIC_LITE_CAP.right ? 1 : 0;
// Groups: left cars, right trucks, right cars.
const rounds = initial ? 6 : 1;
for (let r = 0; r < rounds; r += 1) {
// While staging, trucks enter only through stageTruck, and the staged truck's follower has first call on a free car.
if (stage) spawn(2, TRAFFIC_LANE_RIGHT, TRAFFIC_CAR, want.right - want.trucks + extra, initial, true);
// ...and the left lane waits for it while one is still to be queued.
if (!stage || !convoyOpen()) spawn(0, TRAFFIC_LANE_LEFT, TRAFFIC_CAR, want.left, initial);
if (!stage) {
spawn(1, TRAFFIC_LANE_RIGHT, TRAFFIC_TRUCK, want.trucks, initial);
spawn(2, TRAFFIC_LANE_RIGHT, TRAFFIC_CAR, want.right - want.trucks, initial);
}
}
}
function sortOrder(): void {
// Front (most negative rear z) first; pending vehicles last. Insertion sort, n <= 12.
for (let a = 1; a < n; a += 1) {
const item = order[a]!;
const key = present(item) ? z[item]! : Number.POSITIVE_INFINITY;
let b = a - 1;
while (b >= 0) {
const other = order[b]!;
const otherKey = present(other) ? z[other]! : Number.POSITIVE_INFINITY;
if (otherKey <= key) break;
order[b + 1] = other;
b -= 1;
}
order[b + 1] = item;
}
}
function desired(i: number): number {
const centreBound = lane[i] === TRAFFIC_LANE_CENTRE || targetLane(i) === TRAFFIC_LANE_CENTRE;
if (!centreBound || overtaking[i]! < 0) return v0[i]!;
// Hurried out by the reader: finish the pass rather than dawdle beside the truck.
return v0[i]! + (sprint === i ? SPRINT_BOOST : OVERTAKE_BOOST);
}
function accelerations(): void {
const { A, B, S0, T } = TRAFFIC_IDM;
const root = 2 * Math.sqrt(A * B);
for (let i = 0; i < n; i += 1) {
if (!present(i)) continue;
const want = desired(i);
const speed = v[i]!;
// Above its own pace a vehicle coasts (no brake lamps), it does not stamp on the brake.
let a = speed <= want ? A * (1 - (speed / want) ** 4) : -0.8;
const l = findLeader(i);
leader[i] = l;
if (l !== -1) {
const leaderRear = l === HERO ? HERO_TAIL_Z : z[l]!;
const leaderSpeed = l === HERO ? hero : v[l]!;
const gap = Math.max(0.5, z[i]! - length(i) - leaderRear);
const sStar = S0 + Math.max(0, speed * T + (speed * (speed - leaderSpeed)) / root);
a -= A * (sStar / gap) ** 2;
}
accel[i] = clamp(a, -12, A);
}
}
function overtakeDone(i: number): boolean {
const l = overtaking[i]!;
if (l < 0 || !present(l)) return true;
return z[i]! < z[l]! - length(l);
}
function manoeuvres(sweeping: boolean, flash: boolean): void {
if (centre >= 0) {
const c = centre;
const closing = hero - v[c]!;
const gap = HERO_NOSE_Z - z[c]!;
const reach = closing > 0.1 ? gap / closing : Number.POSITIVE_INFINITY;
const urgent = reach < TIME_TO_REACH_SECONDS || z[c]! > CENTRE_DEADLINE_Z;
// The reader speeding up, closing in or the deadline near: leave at the first comfortable gap.
// (The reader's pace against the car's own, not its queued speed: a car
// just out from behind a truck is still accelerating.)
const soon = reach < YIELD_SOON_SECONDS || z[c]! > YIELD_SOON_Z || hero - desired(c) >= ENTRY_MAX_CLOSING;
if (flash && z[c]! > FLASH_REACH_Z) hurried = true;
const flashed = hurried;
const leaving = lateral(c) && laneOf(toX[c]!) !== TRAFFIC_LANE_CENTRE;
if (state[c] !== TRAFFIC_VISIBLE || leaving) {
// Dithering out, or already on its way back out: a reader now closing fast turns the rest into a swerve.
if (leaving && (urgent || flashed)) hurry(c);
} else if (plan[c] === TRAFFIC_LANE_CENTRE) {
// Signalling to pull out: a reader closing in (or a flash) calls it off.
if (urgent || flashed || engineOff > 0) {
cancel(c);
} else if (time >= planAt[c]!) {
if (slotFree(c, TRAFFIC_LANE_CENTRE)) {
startLateral(c, TRAFFIC_LANE_CENTRE);
lane[c] = TRAFFIC_LANE_CENTRE;
} else {
cancel(c);
}
}
} else if (plan[c] !== NO_PLAN) {
// Signalled a yield: go when the lead is up (at once if it got urgent).
if (time >= planAt[c]! || reach < URGENT_SECONDS || z[c]! > YIELD_NOW_Z) {
const out = plan[c]!;
const forced = urgent || flashed;
const pace = forced ? FORCED_LANE_CHANGE_SECONDS : LANE_CHANGE_SECONDS;
if (slotFree(c, out, forced)) startLateral(c, out, pace);
else if (out !== TRAFFIC_LANE_LEFT && forced && slotFree(c, TRAFFIC_LANE_LEFT, true)) {
home[c] = TRAFFIC_LANE_LEFT;
startLateral(c, TRAFFIC_LANE_LEFT, pace);
} else if (forced || soon) sprint = c;
}
} else if (urgent || flashed || soon || (!sweeping && overtakeDone(c))) {
// In the centre lane, or moving in (a reversal is allowed): leave.
const forced = urgent || flashed;
const out = slotFree(c, home[c]!, forced)
? home[c]!
: (forced || soon) && slotFree(c, TRAFFIC_LANE_LEFT, forced)
? TRAFFIC_LANE_LEFT
: -1;
sprint = out < 0 && (forced || soon) ? c : -1;
if (out >= 0) {
home[c] = out;
if (reach < URGENT_SECONDS || z[c]! > YIELD_NOW_Z || lateral(c)) {
startLateral(c, out, forced ? FORCED_LANE_CHANGE_SECONDS : LANE_CHANGE_SECONDS);
} else signal(c, out, YIELD_LEAD_SECONDS);
}
}
// The emergency exit: still in the corridor at the deadline and not on its way out.
if (
state[c] === TRAFFIC_VISIBLE &&
inCorridor(c) &&
z[c]! > CENTRE_DEADLINE_Z &&
!(lateral(c) && laneOf(toX[c]!) !== TRAFFIC_LANE_CENTRE)
) {
state[c] = TRAFFIC_DITHERING;
dither[c] = 0;
counters.emergencyExits += 1;
counters.lastEmergencyAt = time;
}
return;
}
if (sweeping || engineOff > 0 || time - lastManoeuvre < MANOEUVRE_GAP_SECONDS) return;
for (let k = 0; k < n; k += 1) {
const i = order[k]!;
if (state[i] !== TRAFFIC_VISIBLE || kind[i] !== TRAFFIC_CAR || home[i] !== TRAFFIC_LANE_RIGHT) continue;
if (lane[i] !== TRAFFIC_LANE_RIGHT || lateral(i) || plan[i] !== NO_PLAN) continue;
const zi = z[i]!;
if (zi >= CENTRE_ENTRY_Z || zi <= CENTRE_ENTRY_FAR_Z) continue;
const l = leader[i]!;
if (l < 0 || laneOf(x[l]!) !== TRAFFIC_LANE_RIGHT) continue;
if (zi - length(i) - z[l]! > ENTRY_LEADER_GAP || v0[i]! - v[l]! < ENTRY_LEADER_DELTA) continue;
// The reader near cruise (against the car's own pace; it is queued now)...
if (hero - v0[i]! >= ENTRY_MAX_CLOSING) continue;
// ...and not about to reach it once it is up to its passing pace.
const closing = hero - (v0[i]! + OVERTAKE_BOOST);
if (closing > 0.1 && (HERO_NOSE_Z - zi) / closing < 10) continue;
if (!slotFree(i, TRAFFIC_LANE_CENTRE)) continue;
if (!returnRoom(i, l)) continue;
centre = i;
overtaking[i] = l;
lastManoeuvre = time;
signal(i, TRAFFIC_LANE_CENTRE, ENTRY_LEAD_SECONDS);
return;
}
}
function integrate(dt: number): void {
const { vMin, minGap } = TRAFFIC_IDM;
for (let k = 0; k < n; k += 1) {
const i = order[k]!;
if (!present(i)) continue;
v[i] = Math.max(vMin, v[i]! + accel[i]! * dt);
z[i] = z[i]! + (hero - v[i]!) * dt;
if (lateral(i)) {
laneT[i] = Math.min(1, laneT[i]! + dt / laneSeconds[i]!);
x[i] = fromX[i]! + (toX[i]! - fromX[i]!) * smooth(0, 1, laneT[i]!);
if (laneT[i]! >= 1) {
x[i] = toX[i]!;
lane[i] = laneOf(x[i]!);
indicator[i] = 0;
if (lane[i] !== TRAFFIC_LANE_CENTRE) {
overtaking[i] = -1;
if (centre === i) {
centre = -1;
sprint = -1;
hurried = false;
}
}
}
}
// Never through the vehicle ahead (leaders are already integrated).
const l = findLeader(i);
if (l !== -1) {
const leaderRear = l === HERO ? HERO_TAIL_Z : z[l]!;
const minZ = leaderRear + length(i) + minGap;
if (z[i]! < minZ) {
z[i] = minZ;
v[i] = Math.min(v[i]!, l === HERO ? Math.max(vMin, hero) : v[l]!);
}
}
}
}
function brakeOnsetAllowed(): boolean {
return time - onsets[onsetIndex]! >= 1;
}
function lamps(dt: number): void {
for (let i = 0; i < n; i += 1) {
if (!present(i)) continue;
const a = accel[i]!;
if (brakeOn[i] === 0) {
if (a < BRAKE_ON_ACCEL && time - lastOnset[i]! >= BRAKE_MIN_SPACING_SECONDS && brakeOnsetAllowed()) {
brakeOn[i] = 1;
brakeSince[i] = time;
lastOnset[i] = time;
onsets[onsetIndex] = time;
onsetIndex = (onsetIndex + 1) % BRAKE_GLOBAL_PER_SECOND;
counters.brakeOnsets += 1;
}
} else if (a > BRAKE_OFF_ACCEL && time - brakeSince[i]! >= BRAKE_MIN_ON_SECONDS) {
brakeOn[i] = 0;
}
const goal = brakeOn[i]!;
const rate = dt / (goal > brakeLevel[i]! ? BRAKE_RISE_SECONDS : BRAKE_FALL_SECONDS);
brakeLevel[i] = goal > brakeLevel[i]! ? Math.min(goal, brakeLevel[i]! + rate) : Math.max(goal, brakeLevel[i]! - rate);
}
}
function wrap(dt: number): void {
for (let i = 0; i < n; i += 1) {
if (!present(i)) continue;
const zi = z[i]!;
const nose = zi - length(i);
const mid = zi - length(i) / 2;
const before = prevZ[i]!;
prevZ[i] = zi;
if (state[i] !== TRAFFIC_BEHIND && (before - length(i) / 2 - 2) * (mid - 2) < 0) counters.passes += 1;
// Rear crossed the latch line toward the camera this step.
if (before < TRAFFIC_LATCH_Z && zi >= TRAFFIC_LATCH_Z && state[i] === TRAFFIC_VISIBLE) latch(i);
// Last resort: nothing ever drives through the reader's car.
if (state[i] !== TRAFFIC_BEHIND && inCorridor(i) && nose < HERO_TAIL_Z + 1 && zi > HERO_NOSE_Z - 1) {
counters.emergencyExits += 1;
counters.lastEmergencyAt = time;
park(i);
continue;
}
if (state[i] === TRAFFIC_DITHERING) {
dither[i] = dither[i]! + dt / DITHER_SECONDS;
if (dither[i]! >= 1) park(i);
continue;
}
if (fadeIn[i]! > 0) fadeIn[i] = Math.max(0, fadeIn[i]! - dt / FADE_IN_SECONDS);
if (zi < TRAFFIC_FAR_Z || zi > TRAFFIC_BEHIND_Z) {
park(i);
} else if (nose > TRAFFIC_EXIT_NOSE_Z) {
state[i] = TRAFFIC_BEHIND;
} else if (state[i] === TRAFFIC_BEHIND) {
// Coming back past the reader from beneath the camera.
state[i] = TRAFFIC_VISIBLE;
latch(i);
}
}
}
function writeFrame(): void {
for (let i = 0; i < n; i += 1) {
frame.active[i] = state[i]!;
frame.x[i] = x[i]!;
frame.z[i] = z[i]!;
frame.speed[i] = v[i]!;
frame.brake[i] = state[i] === TRAFFIC_PENDING ? 0 : brakeLevel[i]!;
frame.indicator[i] = indicator[i]!;
frame.blink[i] = indicator[i] === 0 || state[i] === TRAFFIC_PENDING ? 0 : indicatorBlink(time - indicatorSince[i]!);
frame.nearLimit[i] = near[i]!;
frame.dither[i] =
state[i] === TRAFFIC_DITHERING ? Math.min(1, dither[i]!) : state[i] === TRAFFIC_VISIBLE ? fadeIn[i]! : 0;
frame.yaw[i] = lateral(i) ? -Math.sign(toX[i]! - fromX[i]!) * 0.05 * Math.sin(Math.PI * laneT[i]!) : 0;
}
}
reset();
return {
frame,
step(input: TrafficInput): TrafficFrame {
const dt = Number.isFinite(input.dt) ? clamp(input.dt, 0, 0.1) : 0;
if (Number.isFinite(input.heroSpeed)) hero = clamp(input.heroSpeed, 0, 240);
if (Number.isFinite(input.cameraX)) cameraX = input.cameraX;
if (Number.isFinite(input.engineOff)) engineOff = clamp(input.engineOff, 0, 1);
if (Object.prototype.hasOwnProperty.call(TRAFFIC_DENSITY, input.chapter)) chapter = input.chapter;
time += dt;
if (config.lite && input.cue === true) stageUntil = time + TRAFFIC_LITE_STAGE_SECONDS;
if (!primed) {
primed = true;
spawnAll(true);
}
sortOrder();
accelerations();
manoeuvres(input.sweeping === true, input.flash === true);
integrate(dt);
lamps(dt);
wrap(dt);
spawnAll(false);
writeFrame();
return frame;
},
events: () => counters,
reset,
};
}