1149 lines
45 KiB
TypeScript
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,
|
|
};
|
|
}
|