California gets roads, traffic, and a car to follow
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* The first California transport corridor: two coarse LA-to-Bay itineraries.
|
||||
*
|
||||
* This is simulation geometry, not a navigation dataset. Coordinates were
|
||||
* placed by hand at recognisable cities and junctions, with long road sections
|
||||
* represented by one straight edge. In particular, I-5 does not enter San
|
||||
* Francisco: that itinerary names its real Bay approach over I-580 and I-80.
|
||||
*/
|
||||
|
||||
import type {
|
||||
TransportAnchor,
|
||||
TransportNode,
|
||||
TransportPack,
|
||||
TransportRoute,
|
||||
TransportSegment,
|
||||
} from "./types.ts";
|
||||
|
||||
const AUTHORED_NODE =
|
||||
"Original coarse waypoint authored by the Tera project from general California geography; approximate and not for navigation.";
|
||||
const AUTHORED_ROAD =
|
||||
"Original coarse simulation segment authored by the Tera project; road identity and representative speed/lanes are approximate, not live navigation data.";
|
||||
const INTERNAL_OFFICE =
|
||||
"Transition coordinate mirrors Tera's authored office site catalogue; it is project data, not copied map geometry.";
|
||||
|
||||
export const CALIFORNIA_TRANSPORT_NODES: readonly TransportNode[] = [
|
||||
{
|
||||
id: "los-angeles",
|
||||
label: "Los Angeles",
|
||||
kind: "terminus",
|
||||
position: { lat: 34.0522, lng: -118.2437 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
|
||||
// US-101: the coast and Salinas Valley approach.
|
||||
{
|
||||
id: "ventura",
|
||||
label: "Ventura",
|
||||
kind: "waypoint",
|
||||
position: { lat: 34.2805, lng: -119.2945 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "santa-barbara",
|
||||
label: "Santa Barbara",
|
||||
kind: "waypoint",
|
||||
position: { lat: 34.4208, lng: -119.6982 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "santa-maria",
|
||||
label: "Santa Maria",
|
||||
kind: "waypoint",
|
||||
position: { lat: 34.953, lng: -120.4357 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "san-luis-obispo",
|
||||
label: "San Luis Obispo",
|
||||
kind: "waypoint",
|
||||
position: { lat: 35.2828, lng: -120.6596 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "paso-robles",
|
||||
label: "Paso Robles",
|
||||
kind: "waypoint",
|
||||
position: { lat: 35.626, lng: -120.691 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "king-city",
|
||||
label: "King City",
|
||||
kind: "waypoint",
|
||||
position: { lat: 36.2127, lng: -121.126 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "salinas",
|
||||
label: "Salinas",
|
||||
kind: "waypoint",
|
||||
position: { lat: 36.6777, lng: -121.6555 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "gilroy",
|
||||
label: "Gilroy",
|
||||
kind: "waypoint",
|
||||
position: { lat: 37.0058, lng: -121.5683 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "san-jose",
|
||||
label: "San Jose",
|
||||
kind: "junction",
|
||||
position: { lat: 37.3382, lng: -121.8863 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "redwood-city",
|
||||
label: "Redwood City",
|
||||
kind: "waypoint",
|
||||
position: { lat: 37.4852, lng: -122.2364 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
|
||||
// I-5: Central Valley, followed by an explicitly named Bay connector.
|
||||
{
|
||||
id: "santa-clarita",
|
||||
label: "Santa Clarita",
|
||||
kind: "waypoint",
|
||||
position: { lat: 34.3917, lng: -118.5426 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "grapevine",
|
||||
label: "Grapevine",
|
||||
kind: "waypoint",
|
||||
position: { lat: 34.9416, lng: -118.929 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "lost-hills",
|
||||
label: "Lost Hills",
|
||||
kind: "waypoint",
|
||||
position: { lat: 35.6166, lng: -119.6943 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "coalinga-interchange",
|
||||
label: "Coalinga / SR-198",
|
||||
kind: "junction",
|
||||
position: { lat: 36.253, lng: -120.237 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "santa-nella",
|
||||
label: "Santa Nella",
|
||||
kind: "junction",
|
||||
position: { lat: 37.102, lng: -121.016 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "tracy",
|
||||
label: "Tracy",
|
||||
kind: "junction",
|
||||
position: { lat: 37.7397, lng: -121.4252 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "altamont-pass",
|
||||
label: "Altamont Pass",
|
||||
kind: "waypoint",
|
||||
position: { lat: 37.696, lng: -121.686 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "dublin",
|
||||
label: "Dublin",
|
||||
kind: "waypoint",
|
||||
position: { lat: 37.7022, lng: -121.9358 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "oakland",
|
||||
label: "Oakland",
|
||||
kind: "junction",
|
||||
position: { lat: 37.8044, lng: -122.2712 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "bay-bridge",
|
||||
label: "San Francisco-Oakland Bay Bridge",
|
||||
kind: "junction",
|
||||
position: { lat: 37.7983, lng: -122.3778 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "san-francisco",
|
||||
label: "San Francisco",
|
||||
kind: "terminus",
|
||||
position: { lat: 37.7749, lng: -122.4194 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
];
|
||||
|
||||
export const CALIFORNIA_TRANSPORT_SEGMENTS: readonly TransportSegment[] = [
|
||||
// US-101 route.
|
||||
["us101-la-ventura", "los-angeles", "ventura", 65, 3],
|
||||
["us101-ventura-santa-barbara", "ventura", "santa-barbara", 65, 2],
|
||||
["us101-santa-barbara-santa-maria", "santa-barbara", "santa-maria", 65, 2],
|
||||
["us101-santa-maria-san-luis-obispo", "santa-maria", "san-luis-obispo", 65, 2],
|
||||
["us101-san-luis-obispo-paso-robles", "san-luis-obispo", "paso-robles", 65, 2],
|
||||
["us101-paso-robles-king-city", "paso-robles", "king-city", 65, 2],
|
||||
["us101-king-city-salinas", "king-city", "salinas", 65, 2],
|
||||
["us101-salinas-gilroy", "salinas", "gilroy", 65, 2],
|
||||
["us101-gilroy-san-jose", "gilroy", "san-jose", 65, 3],
|
||||
["us101-san-jose-redwood-city", "san-jose", "redwood-city", 65, 4],
|
||||
["us101-redwood-city-san-francisco", "redwood-city", "san-francisco", 65, 4],
|
||||
].map(([id, fromNodeId, toNodeId, speedLimitMph, lanesPerDirection]) => ({
|
||||
id: id as string,
|
||||
fromNodeId: fromNodeId as string,
|
||||
toNodeId: toNodeId as string,
|
||||
roadName: "US-101",
|
||||
kind: "us-highway" as const,
|
||||
speedLimitMph: speedLimitMph as number,
|
||||
lanesPerDirection: lanesPerDirection as number,
|
||||
provenance: AUTHORED_ROAD,
|
||||
}));
|
||||
|
||||
const INTERSTATE_SEGMENTS: readonly TransportSegment[] = [
|
||||
["i5-la-santa-clarita", "los-angeles", "santa-clarita", 65, 4],
|
||||
["i5-santa-clarita-grapevine", "santa-clarita", "grapevine", 65, 3],
|
||||
["i5-grapevine-lost-hills", "grapevine", "lost-hills", 70, 2],
|
||||
["i5-lost-hills-coalinga", "lost-hills", "coalinga-interchange", 70, 2],
|
||||
["i5-coalinga-santa-nella", "coalinga-interchange", "santa-nella", 70, 2],
|
||||
["i5-santa-nella-tracy", "santa-nella", "tracy", 70, 3],
|
||||
].map(([id, fromNodeId, toNodeId, speedLimitMph, lanesPerDirection]) => ({
|
||||
id: id as string,
|
||||
fromNodeId: fromNodeId as string,
|
||||
toNodeId: toNodeId as string,
|
||||
roadName: "I-5",
|
||||
kind: "interstate" as const,
|
||||
speedLimitMph: speedLimitMph as number,
|
||||
lanesPerDirection: lanesPerDirection as number,
|
||||
provenance: AUTHORED_ROAD,
|
||||
}));
|
||||
|
||||
const BAY_CONNECTOR_SEGMENTS: readonly TransportSegment[] = [
|
||||
{
|
||||
id: "i580-tracy-altamont",
|
||||
fromNodeId: "tracy",
|
||||
toNodeId: "altamont-pass",
|
||||
roadName: "I-580",
|
||||
kind: "connector",
|
||||
speedLimitMph: 65,
|
||||
lanesPerDirection: 3,
|
||||
provenance: AUTHORED_ROAD,
|
||||
},
|
||||
{
|
||||
id: "i580-altamont-dublin",
|
||||
fromNodeId: "altamont-pass",
|
||||
toNodeId: "dublin",
|
||||
roadName: "I-580",
|
||||
kind: "connector",
|
||||
speedLimitMph: 65,
|
||||
lanesPerDirection: 4,
|
||||
provenance: AUTHORED_ROAD,
|
||||
},
|
||||
{
|
||||
id: "i580-dublin-oakland",
|
||||
fromNodeId: "dublin",
|
||||
toNodeId: "oakland",
|
||||
roadName: "I-580",
|
||||
kind: "connector",
|
||||
speedLimitMph: 65,
|
||||
lanesPerDirection: 4,
|
||||
provenance: AUTHORED_ROAD,
|
||||
},
|
||||
{
|
||||
id: "i80-oakland-bay-bridge",
|
||||
fromNodeId: "oakland",
|
||||
toNodeId: "bay-bridge",
|
||||
roadName: "I-80 / Bay Bridge",
|
||||
kind: "connector",
|
||||
speedLimitMph: 50,
|
||||
lanesPerDirection: 5,
|
||||
provenance: AUTHORED_ROAD,
|
||||
},
|
||||
{
|
||||
id: "i80-bay-bridge-san-francisco",
|
||||
fromNodeId: "bay-bridge",
|
||||
toNodeId: "san-francisco",
|
||||
roadName: "I-80",
|
||||
kind: "connector",
|
||||
speedLimitMph: 50,
|
||||
lanesPerDirection: 5,
|
||||
provenance: AUTHORED_ROAD,
|
||||
},
|
||||
];
|
||||
|
||||
export const CALIFORNIA_I5_SEGMENTS: readonly TransportSegment[] = [
|
||||
...INTERSTATE_SEGMENTS,
|
||||
...BAY_CONNECTOR_SEGMENTS,
|
||||
];
|
||||
|
||||
const US_101_SEGMENT_IDS = CALIFORNIA_TRANSPORT_SEGMENTS.map((segment) => segment.id);
|
||||
const I_5_SEGMENT_IDS = CALIFORNIA_I5_SEGMENTS.map((segment) => segment.id);
|
||||
|
||||
export const CALIFORNIA_TRANSPORT_ROUTES: readonly TransportRoute[] = [
|
||||
{
|
||||
id: "la-sf-us-101",
|
||||
label: "Los Angeles to San Francisco via US-101",
|
||||
description: "The coastal and Salinas Valley route through Santa Barbara and San Jose.",
|
||||
fromNodeId: "los-angeles",
|
||||
toNodeId: "san-francisco",
|
||||
segmentIds: US_101_SEGMENT_IDS,
|
||||
provenance: AUTHORED_ROAD,
|
||||
},
|
||||
{
|
||||
id: "la-sf-i-5",
|
||||
label: "Los Angeles to San Francisco via I-5, I-580, and I-80",
|
||||
description:
|
||||
"The Central Valley route, leaving I-5 at Tracy for I-580 and I-80 across the Bay Bridge.",
|
||||
fromNodeId: "los-angeles",
|
||||
toNodeId: "san-francisco",
|
||||
segmentIds: I_5_SEGMENT_IDS,
|
||||
provenance: AUTHORED_ROAD,
|
||||
},
|
||||
];
|
||||
|
||||
export const CALIFORNIA_TRANSPORT_ANCHORS: readonly TransportAnchor[] = [
|
||||
{
|
||||
id: "city-socal",
|
||||
kind: "city",
|
||||
cityId: "socal",
|
||||
label: "Southern California",
|
||||
nodeId: "los-angeles",
|
||||
position: { lat: 34.0522, lng: -118.2437 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "city-sf",
|
||||
kind: "city",
|
||||
cityId: "sf",
|
||||
label: "San Francisco",
|
||||
nodeId: "san-francisco",
|
||||
position: { lat: 37.7749, lng: -122.4194 },
|
||||
provenance: AUTHORED_NODE,
|
||||
},
|
||||
{
|
||||
id: "office-mateo-court",
|
||||
kind: "office",
|
||||
cityId: "socal",
|
||||
officeId: "mateo-court",
|
||||
label: "Mateo Court",
|
||||
nodeId: "los-angeles",
|
||||
position: { lat: 34.0395, lng: -118.2288 },
|
||||
provenance: INTERNAL_OFFICE,
|
||||
},
|
||||
{
|
||||
id: "office-lumbridge-hq",
|
||||
kind: "office",
|
||||
cityId: "sf",
|
||||
officeId: "lumbridge-hq",
|
||||
label: "Lumbridge HQ",
|
||||
nodeId: "san-francisco",
|
||||
position: { lat: 37.7897, lng: -122.3972 },
|
||||
provenance: INTERNAL_OFFICE,
|
||||
},
|
||||
{
|
||||
id: "office-frontier-valley",
|
||||
kind: "office",
|
||||
cityId: "sf",
|
||||
officeId: "frontier-valley",
|
||||
label: "Frontier Valley",
|
||||
nodeId: "oakland",
|
||||
position: { lat: 37.7756, lng: -122.3186 },
|
||||
provenance: INTERNAL_OFFICE,
|
||||
},
|
||||
];
|
||||
|
||||
/** All corridor data in one JSON-safe object. */
|
||||
export const CALIFORNIA_TRANSPORT: TransportPack = {
|
||||
id: "california-la-bay",
|
||||
name: "California: Los Angeles to the Bay",
|
||||
schemaVersion: 1,
|
||||
description:
|
||||
"A coarse statewide simulation corridor connecting Tera's Southern California and San Francisco scenes.",
|
||||
nodes: CALIFORNIA_TRANSPORT_NODES,
|
||||
segments: [...CALIFORNIA_TRANSPORT_SEGMENTS, ...CALIFORNIA_I5_SEGMENTS],
|
||||
routes: CALIFORNIA_TRANSPORT_ROUTES,
|
||||
anchors: CALIFORNIA_TRANSPORT_ANCHORS,
|
||||
provenance: [
|
||||
"Original manually authored Tera project data; no third-party map geometry is embedded.",
|
||||
"Coordinates, lane counts, and speed envelopes are coarse simulation inputs and must not be used for navigation.",
|
||||
"Office transition coordinates mirror the repository's own src/offices/sites.ts catalogue.",
|
||||
],
|
||||
};
|
||||
|
||||
export default CALIFORNIA_TRANSPORT;
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Serializable contracts for transport packs.
|
||||
*
|
||||
* These types deliberately contain data only: no classes, dates, maps, or
|
||||
* three.js values. A pack can therefore cross an HTTP boundary, live in a
|
||||
* Worker, or be recorded for a deterministic replay without translation.
|
||||
*/
|
||||
|
||||
/** A WGS84-like geographic position in decimal degrees. */
|
||||
export interface GeographicPoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
/** The road system responsible for a segment. */
|
||||
export type RoadKind = "interstate" | "us-highway" | "connector";
|
||||
|
||||
/** Why a node exists in the deliberately sparse corridor graph. */
|
||||
export type TransportNodeKind = "terminus" | "waypoint" | "junction";
|
||||
|
||||
export interface TransportNode {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: TransportNodeKind;
|
||||
position: GeographicPoint;
|
||||
/** Human-readable origin and accuracy note for this authored coordinate. */
|
||||
provenance: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A directed driveable edge. Reverse itineraries traverse the same edge in
|
||||
* reverse; geometry is intentionally not duplicated for each direction.
|
||||
*/
|
||||
export interface TransportSegment {
|
||||
id: string;
|
||||
fromNodeId: string;
|
||||
toNodeId: string;
|
||||
/** The name a route badge or itinerary should show. */
|
||||
roadName: string;
|
||||
kind: RoadKind;
|
||||
/** Coarse simulation envelope, not live traffic or navigation advice. */
|
||||
speedLimitMph: number;
|
||||
/** Number of through lanes in one direction at the representative section. */
|
||||
lanesPerDirection: number;
|
||||
provenance: string;
|
||||
}
|
||||
|
||||
/** A named, ordered itinerary through the segment graph. */
|
||||
export interface TransportRoute {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
fromNodeId: string;
|
||||
toNodeId: string;
|
||||
segmentIds: readonly string[];
|
||||
provenance: string;
|
||||
}
|
||||
|
||||
interface AnchorBase {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Nearest corridor node used to enter or leave the large-scale simulation. */
|
||||
nodeId: string;
|
||||
/** Exact transition marker; it need not lie on the corridor centreline. */
|
||||
position: GeographicPoint;
|
||||
provenance: string;
|
||||
}
|
||||
|
||||
export interface CityTransportAnchor extends AnchorBase {
|
||||
kind: "city";
|
||||
cityId: string;
|
||||
}
|
||||
|
||||
export interface OfficeTransportAnchor extends AnchorBase {
|
||||
kind: "office";
|
||||
cityId: string;
|
||||
officeId: string;
|
||||
}
|
||||
|
||||
export type TransportAnchor = CityTransportAnchor | OfficeTransportAnchor;
|
||||
|
||||
/** A complete coarse world graph and its links to finer Tera scenes. */
|
||||
export interface TransportPack {
|
||||
id: string;
|
||||
name: string;
|
||||
schemaVersion: 1;
|
||||
description: string;
|
||||
nodes: readonly TransportNode[];
|
||||
segments: readonly TransportSegment[];
|
||||
routes: readonly TransportRoute[];
|
||||
anchors: readonly TransportAnchor[];
|
||||
/** Pack-wide authorship and fitness-for-purpose notices. */
|
||||
provenance: readonly string[];
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* Renderer-independent solo vehicle controls for a route-relative simulation.
|
||||
*
|
||||
* Inputs are normalized action snapshots rather than DOM events, so keyboards,
|
||||
* gamepads, touch controls, remote clients, and recorded replays all drive the
|
||||
* same deterministic fixed-step state machine.
|
||||
*/
|
||||
|
||||
import type { GeographicPoint, TransportPack } from "./types.ts";
|
||||
import {
|
||||
buildRoutePath,
|
||||
sampleRoute,
|
||||
type RoutePath,
|
||||
type RouteSample,
|
||||
} from "./vehicleSim.ts";
|
||||
|
||||
const MPH_TO_MPS = 0.44704;
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
const TWO_PI = Math.PI * 2;
|
||||
|
||||
export type VehicleControlMode = "assisted" | "manual";
|
||||
export type VehicleModeRequest = "none" | VehicleControlMode;
|
||||
|
||||
/** Device-neutral actions sampled for one rendered or fixed simulation frame. */
|
||||
export interface VehicleActionSnapshot {
|
||||
/** Accelerator position in the inclusive range [0, 1]. */
|
||||
throttle: number;
|
||||
/** Service brake position in the inclusive range [0, 1]. */
|
||||
brake: number;
|
||||
/** Steering input where -1 is full left and 1 is full right. */
|
||||
steering: number;
|
||||
handbrake: boolean;
|
||||
/** One-shot mode request. Meaningful even when all analogue axes are neutral. */
|
||||
modeRequest: VehicleModeRequest;
|
||||
/** One-shot request to restore the configured initial state. */
|
||||
reset: boolean;
|
||||
}
|
||||
|
||||
export const NEUTRAL_VEHICLE_ACTIONS: Readonly<VehicleActionSnapshot> = Object.freeze({
|
||||
throttle: 0,
|
||||
brake: 0,
|
||||
steering: 0,
|
||||
handbrake: false,
|
||||
modeRequest: "none",
|
||||
reset: false,
|
||||
});
|
||||
|
||||
export interface VehicleControllerOptions {
|
||||
routeId: string;
|
||||
mode?: VehicleControlMode;
|
||||
direction?: 1 | -1;
|
||||
initialDistanceM?: number;
|
||||
initialLateralOffsetM?: number;
|
||||
initialSpeedMps?: number;
|
||||
/** Defaults to 60 Hz and is clamped to a safe simulation range. */
|
||||
fixedStepSeconds?: number;
|
||||
/** Caps catch-up after a sleeping tab. Defaults to 0.25 seconds. */
|
||||
maxFrameDeltaSeconds?: number;
|
||||
maximumSpeedMps?: number;
|
||||
assistedCruiseRatio?: number;
|
||||
guardrailOffsetM?: number;
|
||||
wheelRadiusM?: number;
|
||||
/**
|
||||
* Multiplies longitudinal route progress without changing acceleration or
|
||||
* steering response. State-scale boards use compression; metre-scale roads
|
||||
* leave this at 1. Defaults to 1.
|
||||
*/
|
||||
travelScale?: number;
|
||||
}
|
||||
|
||||
export interface VehicleControllerState extends GeographicPoint {
|
||||
routeId: string;
|
||||
mode: VehicleControlMode;
|
||||
direction: 1 | -1;
|
||||
/** Distance from the route's declared start, wrapped to its total length. */
|
||||
distanceM: number;
|
||||
progress: number;
|
||||
/** Signed offset from route centre; positive is to the driver's right. */
|
||||
lateralOffsetM: number;
|
||||
speedMps: number;
|
||||
/** Smoothed normalized steering position, independent of input device. */
|
||||
steering: number;
|
||||
routeHeadingDeg: number;
|
||||
headingDeg: number;
|
||||
segmentId: string;
|
||||
roadName: string;
|
||||
speedLimitMph: number;
|
||||
wheelRadians: number;
|
||||
guardrailContact: boolean;
|
||||
elapsedSteps: number;
|
||||
}
|
||||
|
||||
export interface VehicleControllerSnapshot extends VehicleControllerState {}
|
||||
|
||||
/** A held input snapshot and its exact duration in fixed simulation steps. */
|
||||
export interface TimedVehicleInputFrame {
|
||||
steps: number;
|
||||
actions?: Partial<VehicleActionSnapshot>;
|
||||
}
|
||||
|
||||
export interface VehicleReplayResult {
|
||||
/** Initial state followed by one snapshot after every simulated step. */
|
||||
trajectory: readonly VehicleControllerSnapshot[];
|
||||
final: VehicleControllerSnapshot;
|
||||
}
|
||||
|
||||
interface ResolvedOptions {
|
||||
routeId: string;
|
||||
mode: VehicleControlMode;
|
||||
direction: 1 | -1;
|
||||
initialDistanceM: number;
|
||||
initialLateralOffsetM: number;
|
||||
initialSpeedMps: number;
|
||||
fixedStepSeconds: number;
|
||||
maxFrameDeltaSeconds: number;
|
||||
maximumSpeedMps: number;
|
||||
assistedCruiseRatio: number;
|
||||
guardrailOffsetM: number;
|
||||
wheelRadiusM: number;
|
||||
travelScale: number;
|
||||
}
|
||||
|
||||
function finiteOr(value: number | undefined, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function wrap(value: number, modulus: number): number {
|
||||
return ((value % modulus) + modulus) % modulus;
|
||||
}
|
||||
|
||||
function moveToward(value: number, target: number, maximumDelta: number): number {
|
||||
if (value < target) return Math.min(value + maximumDelta, target);
|
||||
if (value > target) return Math.max(value - maximumDelta, target);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Clamp and sanitize input from any adapter before it reaches simulation. */
|
||||
export function normalizeVehicleActions(
|
||||
actions: Partial<VehicleActionSnapshot> | undefined,
|
||||
): VehicleActionSnapshot {
|
||||
const modeRequest = actions?.modeRequest;
|
||||
return {
|
||||
throttle: clamp(finiteOr(actions?.throttle, 0), 0, 1),
|
||||
brake: clamp(finiteOr(actions?.brake, 0), 0, 1),
|
||||
steering: clamp(finiteOr(actions?.steering, 0), -1, 1),
|
||||
handbrake: actions?.handbrake === true,
|
||||
modeRequest: modeRequest === "manual" || modeRequest === "assisted" ? modeRequest : "none",
|
||||
reset: actions?.reset === true,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOptions(options: VehicleControllerOptions): ResolvedOptions {
|
||||
return {
|
||||
routeId: options.routeId,
|
||||
mode: options.mode === "manual" ? "manual" : "assisted",
|
||||
direction: options.direction === -1 ? -1 : 1,
|
||||
initialDistanceM: finiteOr(options.initialDistanceM, 0),
|
||||
initialLateralOffsetM: finiteOr(options.initialLateralOffsetM, 0),
|
||||
initialSpeedMps: Math.max(0, finiteOr(options.initialSpeedMps, 0)),
|
||||
fixedStepSeconds: clamp(finiteOr(options.fixedStepSeconds, 1 / 60), 1 / 240, 0.1),
|
||||
maxFrameDeltaSeconds: clamp(finiteOr(options.maxFrameDeltaSeconds, 0.25), 0.05, 1),
|
||||
maximumSpeedMps: clamp(finiteOr(options.maximumSpeedMps, 58), 5, 100),
|
||||
assistedCruiseRatio: clamp(finiteOr(options.assistedCruiseRatio, 0.92), 0.25, 1.1),
|
||||
guardrailOffsetM: clamp(finiteOr(options.guardrailOffsetM, 5.4), 1, 20),
|
||||
wheelRadiusM: clamp(finiteOr(options.wheelRadiusM, 0.36), 0.1, 1),
|
||||
travelScale: clamp(finiteOr(options.travelScale, 1), 1, 10_000),
|
||||
};
|
||||
}
|
||||
|
||||
function hasManualIntent(actions: VehicleActionSnapshot): boolean {
|
||||
return (
|
||||
actions.modeRequest === "manual" ||
|
||||
actions.handbrake ||
|
||||
actions.throttle > 0.02 ||
|
||||
actions.brake > 0.02 ||
|
||||
Math.abs(actions.steering) > 0.08
|
||||
);
|
||||
}
|
||||
|
||||
function offsetPoint(sample: RouteSample, lateralOffsetM: number): GeographicPoint {
|
||||
const heading = (sample.headingDeg * Math.PI) / 180;
|
||||
// Right-hand normal to a compass bearing: south for eastbound, east for northbound.
|
||||
const northM = -Math.sin(heading) * lateralOffsetM;
|
||||
const eastM = Math.cos(heading) * lateralOffsetM;
|
||||
const latitudeRadians = (sample.lat * Math.PI) / 180;
|
||||
return {
|
||||
lat: sample.lat + (northM / EARTH_RADIUS_M) * (180 / Math.PI),
|
||||
lng:
|
||||
sample.lng +
|
||||
(eastM / (EARTH_RADIUS_M * Math.max(0.01, Math.cos(latitudeRadians)))) * (180 / Math.PI),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic route-relative driving state machine.
|
||||
*
|
||||
* `tick` adapts render time to a fixed clock. `stepFixed` is the authoritative
|
||||
* primitive for tests, networking, and replay and always advances exactly once.
|
||||
*/
|
||||
export class VehicleController {
|
||||
private readonly pack: TransportPack;
|
||||
private readonly options: ResolvedOptions;
|
||||
private path: RoutePath;
|
||||
private accumulator = 0;
|
||||
private readonly current: VehicleControllerState;
|
||||
|
||||
constructor(pack: TransportPack, options: VehicleControllerOptions) {
|
||||
this.pack = pack;
|
||||
this.options = resolveOptions(options);
|
||||
this.path = buildRoutePath(pack, this.options.routeId);
|
||||
const sample = sampleRoute(this.path, this.options.initialDistanceM, this.options.direction);
|
||||
const point = offsetPoint(sample, 0);
|
||||
this.current = {
|
||||
...point,
|
||||
routeId: this.path.route.id,
|
||||
mode: this.options.mode,
|
||||
direction: this.options.direction,
|
||||
distanceM: 0,
|
||||
progress: 0,
|
||||
lateralOffsetM: 0,
|
||||
speedMps: 0,
|
||||
steering: 0,
|
||||
routeHeadingDeg: sample.headingDeg,
|
||||
headingDeg: sample.headingDeg,
|
||||
segmentId: sample.segmentId,
|
||||
roadName: sample.roadName,
|
||||
speedLimitMph: sample.speedLimitMph,
|
||||
wheelRadians: 0,
|
||||
guardrailContact: false,
|
||||
elapsedSteps: 0,
|
||||
};
|
||||
this.reset();
|
||||
}
|
||||
|
||||
fixedStepSeconds(): number {
|
||||
return this.options.fixedStepSeconds;
|
||||
}
|
||||
|
||||
routeId(): string {
|
||||
return this.path.route.id;
|
||||
}
|
||||
|
||||
/** Stable state object for allocation-free polling. Treat it as read-only. */
|
||||
state(): Readonly<VehicleControllerState> {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** Detached state suitable for logs, network frames, and equality assertions. */
|
||||
snapshot(): VehicleControllerSnapshot {
|
||||
return { ...this.current };
|
||||
}
|
||||
|
||||
/** Restore the configured spawn state and clear pending fractional time. */
|
||||
reset(): void {
|
||||
this.accumulator = 0;
|
||||
const distanceM = wrap(this.options.initialDistanceM, this.path.lengthM);
|
||||
const lateralOffsetM = clamp(
|
||||
this.options.initialLateralOffsetM,
|
||||
-this.options.guardrailOffsetM,
|
||||
this.options.guardrailOffsetM,
|
||||
);
|
||||
const speedMps = clamp(this.options.initialSpeedMps, 0, this.options.maximumSpeedMps);
|
||||
const sample = sampleRoute(this.path, distanceM, this.options.direction);
|
||||
const point = offsetPoint(sample, lateralOffsetM);
|
||||
Object.assign(this.current, point, {
|
||||
routeId: this.path.route.id,
|
||||
mode: this.options.mode,
|
||||
direction: this.options.direction,
|
||||
distanceM,
|
||||
progress: distanceM / this.path.lengthM,
|
||||
lateralOffsetM,
|
||||
speedMps,
|
||||
steering: 0,
|
||||
routeHeadingDeg: sample.headingDeg,
|
||||
headingDeg: sample.headingDeg,
|
||||
segmentId: sample.segmentId,
|
||||
roadName: sample.roadName,
|
||||
speedLimitMph: sample.speedLimitMph,
|
||||
wheelRadians: 0,
|
||||
guardrailContact: false,
|
||||
elapsedSteps: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change corridor as an explicit reset. Progress may optionally be preserved,
|
||||
* which is useful for switching route variants without retaining stale metres.
|
||||
*/
|
||||
setRoute(routeId: string, preserveProgress = false): void {
|
||||
if (routeId === this.path.route.id) return;
|
||||
const previousProgress = this.current.progress;
|
||||
this.path = buildRoutePath(this.pack, routeId);
|
||||
this.options.routeId = routeId;
|
||||
this.options.initialDistanceM = preserveProgress ? previousProgress * this.path.lengthM : 0;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/** Advance rendered seconds and return the number of fixed steps executed. */
|
||||
tick(
|
||||
deltaSeconds: number,
|
||||
actions: Partial<VehicleActionSnapshot> = NEUTRAL_VEHICLE_ACTIONS,
|
||||
): number {
|
||||
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
|
||||
const normalized = normalizeVehicleActions(actions);
|
||||
if (normalized.reset) {
|
||||
this.reset();
|
||||
return 0;
|
||||
}
|
||||
this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds);
|
||||
let steps = 0;
|
||||
while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) {
|
||||
this.stepNormalized(normalized);
|
||||
this.accumulator -= this.options.fixedStepSeconds;
|
||||
steps += 1;
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** Advance exactly one authoritative simulation step. */
|
||||
stepFixed(actions: Partial<VehicleActionSnapshot> = NEUTRAL_VEHICLE_ACTIONS): void {
|
||||
const normalized = normalizeVehicleActions(actions);
|
||||
if (normalized.reset) {
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
this.stepNormalized(normalized);
|
||||
}
|
||||
|
||||
private stepNormalized(actions: VehicleActionSnapshot): void {
|
||||
const dt = this.options.fixedStepSeconds;
|
||||
const manualIntent = hasManualIntent(actions);
|
||||
// Direct human input always wins, including over a simultaneous request to
|
||||
// resume assistance. A neutral assisted request can re-engage on the next step.
|
||||
if (manualIntent) this.current.mode = "manual";
|
||||
else if (actions.modeRequest === "assisted") this.current.mode = "assisted";
|
||||
|
||||
let throttle = actions.throttle;
|
||||
let brake = actions.brake;
|
||||
let steeringTarget = actions.steering;
|
||||
|
||||
if (this.current.mode === "assisted") {
|
||||
const roadTarget = this.current.speedLimitMph * MPH_TO_MPS * this.options.assistedCruiseRatio;
|
||||
const targetSpeed = Math.min(roadTarget, this.options.maximumSpeedMps);
|
||||
const speedError = targetSpeed - this.current.speedMps;
|
||||
throttle = clamp(speedError / 5, 0, 1);
|
||||
brake = clamp(-speedError / 7, 0, 1);
|
||||
steeringTarget = clamp(-this.current.lateralOffsetM / 2.4, -1, 1);
|
||||
}
|
||||
|
||||
this.current.steering = moveToward(this.current.steering, steeringTarget, 3.8 * dt);
|
||||
|
||||
const aeroDrag = this.current.speedMps * this.current.speedMps * 0.0018;
|
||||
const rollingDrag = this.current.speedMps > 0 ? 0.12 : 0;
|
||||
const engineFade = 1 - 0.55 * (this.current.speedMps / this.options.maximumSpeedMps);
|
||||
const acceleration =
|
||||
throttle * 5.4 * Math.max(0.2, engineFade) -
|
||||
brake * 9.5 -
|
||||
(actions.handbrake ? 13 : 0) -
|
||||
aeroDrag -
|
||||
rollingDrag;
|
||||
this.current.speedMps = clamp(
|
||||
this.current.speedMps + acceleration * dt,
|
||||
0,
|
||||
this.options.maximumSpeedMps,
|
||||
);
|
||||
|
||||
const previousDistance = this.current.distanceM;
|
||||
const physicalTravelled = this.current.speedMps * dt;
|
||||
const routeTravelled = physicalTravelled * this.options.travelScale;
|
||||
this.current.distanceM = wrap(
|
||||
previousDistance + routeTravelled * this.current.direction,
|
||||
this.path.lengthM,
|
||||
);
|
||||
|
||||
let proposedLateral =
|
||||
this.current.lateralOffsetM + this.current.steering * this.current.speedMps * 0.2 * dt;
|
||||
if (this.current.mode === "assisted") {
|
||||
// Assistance damps the final few centimetres without an abrupt lane snap.
|
||||
proposedLateral *= Math.exp(-0.35 * dt);
|
||||
}
|
||||
this.current.guardrailContact = Math.abs(proposedLateral) > this.options.guardrailOffsetM;
|
||||
if (this.current.guardrailContact) {
|
||||
proposedLateral = clamp(
|
||||
proposedLateral,
|
||||
-this.options.guardrailOffsetM,
|
||||
this.options.guardrailOffsetM,
|
||||
);
|
||||
this.current.speedMps = Math.min(this.current.speedMps * 0.78, 12);
|
||||
this.current.steering *= 0.35;
|
||||
}
|
||||
this.current.lateralOffsetM = proposedLateral;
|
||||
|
||||
const sample = sampleRoute(this.path, this.current.distanceM, this.current.direction);
|
||||
const point = offsetPoint(sample, this.current.lateralOffsetM);
|
||||
const wheelDelta = physicalTravelled / this.options.wheelRadiusM;
|
||||
Object.assign(this.current, point, {
|
||||
progress: this.current.distanceM / this.path.lengthM,
|
||||
routeHeadingDeg: sample.headingDeg,
|
||||
headingDeg: sample.headingDeg + this.current.steering * 9,
|
||||
segmentId: sample.segmentId,
|
||||
roadName: sample.roadName,
|
||||
speedLimitMph: sample.speedLimitMph,
|
||||
wheelRadians: wrap(this.current.wheelRadians + wheelDelta, TWO_PI),
|
||||
elapsedSteps: this.current.elapsedSteps + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute an exact, renderer-independent input recording. */
|
||||
export function replayVehicleInputs(
|
||||
pack: TransportPack,
|
||||
options: VehicleControllerOptions,
|
||||
frames: readonly TimedVehicleInputFrame[],
|
||||
): VehicleReplayResult {
|
||||
const controller = new VehicleController(pack, options);
|
||||
const trajectory: VehicleControllerSnapshot[] = [controller.snapshot()];
|
||||
for (const frame of frames) {
|
||||
const steps = Math.max(0, Math.floor(finiteOr(frame.steps, 0)));
|
||||
const actions = normalizeVehicleActions(frame.actions);
|
||||
for (let index = 0; index < steps; index += 1) {
|
||||
// Reset and mode requests are edge-triggered at the start of a timed frame.
|
||||
controller.stepFixed(
|
||||
index === 0
|
||||
? actions
|
||||
: { ...actions, modeRequest: "none", reset: false },
|
||||
);
|
||||
trajectory.push(controller.snapshot());
|
||||
}
|
||||
}
|
||||
const final = trajectory.at(-1) ?? controller.snapshot();
|
||||
return { trajectory, final };
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Deterministic road traffic over a serializable `TransportPack`.
|
||||
*
|
||||
* The simulation owns route progress, never render objects. It advances on a
|
||||
* fixed clock and exposes plain geographic poses, so a city scene can project
|
||||
* them through `World` while a server or replay runner can use the same code
|
||||
* without three.js. Long background-tab deltas are capped rather than replayed
|
||||
* as a burst; traffic resumes smoothly instead of teleporting through a route.
|
||||
*/
|
||||
|
||||
import type {
|
||||
GeographicPoint,
|
||||
TransportPack,
|
||||
TransportRoute,
|
||||
TransportSegment,
|
||||
} from "./types.ts";
|
||||
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
const MPH_TO_MPS = 0.44704;
|
||||
const FIXED_STEP = 1 / 20;
|
||||
const MAX_FRAME_DELTA = 0.25;
|
||||
|
||||
export interface RouteLeg {
|
||||
segment: TransportSegment;
|
||||
from: GeographicPoint;
|
||||
to: GeographicPoint;
|
||||
lengthM: number;
|
||||
startM: number;
|
||||
endM: number;
|
||||
}
|
||||
|
||||
export interface RoutePath {
|
||||
route: TransportRoute;
|
||||
legs: readonly RouteLeg[];
|
||||
lengthM: number;
|
||||
}
|
||||
|
||||
export interface RouteSample extends GeographicPoint {
|
||||
/** Compass bearing in degrees clockwise from true north. */
|
||||
headingDeg: number;
|
||||
segmentId: string;
|
||||
roadName: string;
|
||||
speedLimitMph: number;
|
||||
}
|
||||
|
||||
export interface VehiclePose extends RouteSample {
|
||||
id: string;
|
||||
routeId: string;
|
||||
/** 0 is the median-side lane; positive values move toward the shoulder. */
|
||||
lane: number;
|
||||
direction: 1 | -1;
|
||||
speedMps: number;
|
||||
distanceM: number;
|
||||
progress: number;
|
||||
wheelRadians: number;
|
||||
}
|
||||
|
||||
export interface VehicleSimulationOptions {
|
||||
routeId: string;
|
||||
count?: number;
|
||||
seed?: number;
|
||||
/** Time compression for the statewide board. Defaults to 900x. */
|
||||
timeScale?: number;
|
||||
}
|
||||
|
||||
interface VehicleState {
|
||||
pose: VehiclePose;
|
||||
cruise: number;
|
||||
}
|
||||
|
||||
function radians(degrees: number): number {
|
||||
return (degrees * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/** Equirectangular distance; sub-metre agreement is unnecessary at this scale. */
|
||||
export function distanceMetres(a: GeographicPoint, b: GeographicPoint): number {
|
||||
const meanLat = radians((a.lat + b.lat) / 2);
|
||||
const dy = radians(b.lat - a.lat);
|
||||
const dx = radians(b.lng - a.lng) * Math.cos(meanLat);
|
||||
return Math.hypot(dx, dy) * EARTH_RADIUS_M;
|
||||
}
|
||||
|
||||
function bearing(a: GeographicPoint, b: GeographicPoint): number {
|
||||
const meanLat = radians((a.lat + b.lat) / 2);
|
||||
const north = b.lat - a.lat;
|
||||
const east = (b.lng - a.lng) * Math.cos(meanLat);
|
||||
return (Math.atan2(east, north) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
export function buildRoutePath(pack: TransportPack, routeId: string): RoutePath {
|
||||
const route = pack.routes.find((candidate) => candidate.id === routeId);
|
||||
if (!route) throw new Error(`transport: unknown route "${routeId}"`);
|
||||
const nodes = new Map(pack.nodes.map((node) => [node.id, node]));
|
||||
const segments = new Map(pack.segments.map((segment) => [segment.id, segment]));
|
||||
const legs: RouteLeg[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (const id of route.segmentIds) {
|
||||
const segment = segments.get(id);
|
||||
if (!segment) throw new Error(`transport: route "${routeId}" references missing segment "${id}"`);
|
||||
const from = nodes.get(segment.fromNodeId)?.position;
|
||||
const to = nodes.get(segment.toNodeId)?.position;
|
||||
if (!from || !to) throw new Error(`transport: segment "${id}" references a missing node`);
|
||||
const lengthM = distanceMetres(from, to);
|
||||
if (!(lengthM > 0)) throw new Error(`transport: segment "${id}" has no length`);
|
||||
legs.push({ segment, from, to, lengthM, startM: cursor, endM: cursor + lengthM });
|
||||
cursor += lengthM;
|
||||
}
|
||||
|
||||
if (legs.length === 0) throw new Error(`transport: route "${routeId}" is empty`);
|
||||
return { route, legs, lengthM: cursor };
|
||||
}
|
||||
|
||||
function wrap(value: number, modulus: number): number {
|
||||
return ((value % modulus) + modulus) % modulus;
|
||||
}
|
||||
|
||||
export function sampleRoute(path: RoutePath, distanceM: number, direction: 1 | -1 = 1): RouteSample {
|
||||
const travelled = wrap(distanceM, path.lengthM);
|
||||
const leg = path.legs.find((candidate) => travelled <= candidate.endM) ?? path.legs.at(-1);
|
||||
if (!leg) throw new Error(`transport: route "${path.route.id}" has no legs`);
|
||||
const t = Math.max(0, Math.min(1, (travelled - leg.startM) / leg.lengthM));
|
||||
const from = direction === 1 ? leg.from : leg.to;
|
||||
const to = direction === 1 ? leg.to : leg.from;
|
||||
// `distanceM` is always measured from the route's declared start. Reverse
|
||||
// traffic advances that scalar downward, so its geographic interpolation is
|
||||
// still `t`; only its bearing is reversed. Mirroring `t` here makes a
|
||||
// southbound car move north while visually facing south.
|
||||
const u = t;
|
||||
return {
|
||||
lat: leg.from.lat + (leg.to.lat - leg.from.lat) * u,
|
||||
lng: leg.from.lng + (leg.to.lng - leg.from.lng) * u,
|
||||
headingDeg: bearing(from, to),
|
||||
segmentId: leg.segment.id,
|
||||
roadName: leg.segment.roadName,
|
||||
speedLimitMph: leg.segment.speedLimitMph,
|
||||
};
|
||||
}
|
||||
|
||||
/** Small reproducible generator; simulation results never depend on `Math.random()`. */
|
||||
function seeded(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state += 0x6d2b79f5;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
|
||||
};
|
||||
}
|
||||
|
||||
export class VehicleSimulation {
|
||||
private readonly pack: TransportPack;
|
||||
private path: RoutePath;
|
||||
private readonly count: number;
|
||||
private readonly seed: number;
|
||||
private readonly timeScale: number;
|
||||
private accumulator = 0;
|
||||
private states: VehicleState[] = [];
|
||||
private poseView: VehiclePose[] = [];
|
||||
|
||||
constructor(pack: TransportPack, options: VehicleSimulationOptions) {
|
||||
this.pack = pack;
|
||||
this.path = buildRoutePath(pack, options.routeId);
|
||||
this.count = Math.max(1, Math.min(64, Math.floor(options.count ?? 12)));
|
||||
this.seed = options.seed ?? 115;
|
||||
this.timeScale = Math.max(1, options.timeScale ?? 900);
|
||||
this.reset();
|
||||
}
|
||||
|
||||
routeId(): string {
|
||||
return this.path.route.id;
|
||||
}
|
||||
|
||||
setRoute(routeId: string): void {
|
||||
if (routeId === this.path.route.id) return;
|
||||
this.path = buildRoutePath(this.pack, routeId);
|
||||
this.accumulator = 0;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
const rand = seeded(this.seed ^ hash(this.path.route.id));
|
||||
this.states = Array.from({ length: this.count }, (_, index) => {
|
||||
// The first vehicle is the northbound follow-camera hero. Every third
|
||||
// background vehicle after it runs south so both carriageways stay alive.
|
||||
const direction: 1 | -1 = index > 0 && index % 3 === 0 ? -1 : 1;
|
||||
const distanceM = ((index + rand() * 0.6) / this.count) * this.path.lengthM;
|
||||
const sample = sampleRoute(this.path, distanceM, direction);
|
||||
const cruise = 0.86 + rand() * 0.12;
|
||||
const speedMps = sample.speedLimitMph * MPH_TO_MPS * cruise;
|
||||
return {
|
||||
cruise,
|
||||
pose: {
|
||||
...sample,
|
||||
id: index === 0 ? "model-x-hero" : `model-x-${String(index + 1).padStart(2, "0")}`,
|
||||
routeId: this.path.route.id,
|
||||
lane: index % 2,
|
||||
direction,
|
||||
speedMps,
|
||||
distanceM,
|
||||
progress: distanceM / this.path.lengthM,
|
||||
wheelRadians: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
// Keep one stable array for render consumers. The pose objects within it
|
||||
// are already mutated in place by `step`, so a 60 fps scene should not pay
|
||||
// for a fresh wrapper array on every frame.
|
||||
this.poseView = this.states.map((state) => state.pose);
|
||||
}
|
||||
|
||||
/** Advance by rendered seconds; internally every state change is a 20 Hz step. */
|
||||
tick(dt: number): void {
|
||||
if (!Number.isFinite(dt) || dt <= 0) return;
|
||||
this.accumulator += Math.min(dt, MAX_FRAME_DELTA);
|
||||
while (this.accumulator >= FIXED_STEP) {
|
||||
this.step(FIXED_STEP);
|
||||
this.accumulator -= FIXED_STEP;
|
||||
}
|
||||
}
|
||||
|
||||
private step(dt: number): void {
|
||||
for (const state of this.states) {
|
||||
const previous = state.pose;
|
||||
const signed = previous.speedMps * this.timeScale * dt * previous.direction;
|
||||
const distanceM = wrap(previous.distanceM + signed, this.path.lengthM);
|
||||
const sample = sampleRoute(this.path, distanceM, previous.direction);
|
||||
const speedMps = sample.speedLimitMph * MPH_TO_MPS * state.cruise;
|
||||
Object.assign(previous, sample, {
|
||||
speedMps,
|
||||
distanceM,
|
||||
progress: distanceM / this.path.lengthM,
|
||||
// 0.36 m is a representative Model X tyre radius.
|
||||
wheelRadians: wrap(previous.wheelRadians + (Math.abs(signed) / 0.36), Math.PI * 2),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable objects, mutated in place; render layers may retain references. */
|
||||
poses(): readonly VehiclePose[] {
|
||||
return this.poseView;
|
||||
}
|
||||
}
|
||||
|
||||
function hash(value: string): number {
|
||||
let out = 2_166_136_261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
out ^= value.charCodeAt(index);
|
||||
out = Math.imul(out, 16_777_619);
|
||||
}
|
||||
return out >>> 0;
|
||||
}
|
||||
Reference in New Issue
Block a user