@jgengine/core 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +174 -0
- package/dist/ai/policeResponse.d.ts +253 -0
- package/dist/ai/policeResponse.js +421 -0
- package/dist/anim/actionLayer.d.ts +47 -0
- package/dist/anim/actionLayer.js +63 -0
- package/dist/anim/ikSolver.d.ts +14 -0
- package/dist/anim/ikSolver.js +37 -0
- package/dist/anim/locomotionGraph.d.ts +58 -3
- package/dist/anim/locomotionGraph.js +142 -15
- package/dist/anim/ragdollRig.d.ts +53 -0
- package/dist/anim/ragdollRig.js +132 -0
- package/dist/anim/skeletonStandard.d.ts +140 -0
- package/dist/anim/skeletonStandard.js +326 -0
- package/dist/combat/damageRuntime.d.ts +14 -1
- package/dist/combat/damageRuntime.js +62 -4
- package/dist/combat/death.js +7 -3
- package/dist/combat/deathReason.d.ts +7 -1
- package/dist/combat/effects.d.ts +25 -0
- package/dist/combat/effects.js +16 -0
- package/dist/combat/statusRuntime.d.ts +2 -0
- package/dist/combat/statusRuntime.js +1 -0
- package/dist/economy/loanBook.d.ts +182 -0
- package/dist/economy/loanBook.js +303 -0
- package/dist/economy/market.d.ts +161 -0
- package/dist/economy/market.js +248 -0
- package/dist/economy/transactionLedger.d.ts +162 -0
- package/dist/economy/transactionLedger.js +301 -0
- package/dist/editor/creatorStorage.js +3 -1
- package/dist/editor/simulation.d.ts +5 -2
- package/dist/editor/simulation.js +29 -19
- package/dist/game/cameraConfig.d.ts +14 -0
- package/dist/game/clipRoles.d.ts +5 -4
- package/dist/game/clipRoles.js +34 -4
- package/dist/game/events.d.ts +27 -0
- package/dist/game/gameSave.d.ts +142 -0
- package/dist/game/gameSave.js +174 -0
- package/dist/game/playableGame.d.ts +74 -6
- package/dist/game/saveSlotIndex.d.ts +54 -0
- package/dist/game/saveSlotIndex.js +71 -0
- package/dist/game/saveSlots.d.ts +2 -0
- package/dist/game/saveSlots.js +1 -1
- package/dist/game/saveStore.d.ts +8 -0
- package/dist/game/saveStore.js +16 -0
- package/dist/gameplay.d.ts +17 -2
- package/dist/gameplay.js +15 -0
- package/dist/inventory/carryLoad.d.ts +48 -0
- package/dist/inventory/carryLoad.js +44 -0
- package/dist/inventory/equipment.d.ts +95 -0
- package/dist/inventory/equipment.js +169 -0
- package/dist/inventory/inventoryModel.d.ts +15 -6
- package/dist/inventory/inventoryModel.js +54 -5
- package/dist/inventory/shapedGrid.d.ts +26 -0
- package/dist/inventory/shapedGrid.js +47 -0
- package/dist/loot/lootGen.d.ts +186 -0
- package/dist/loot/lootGen.js +437 -0
- package/dist/loot/lootWeapon.d.ts +35 -0
- package/dist/loot/lootWeapon.js +70 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +156 -1
- package/dist/mission/compositions.d.ts +85 -0
- package/dist/mission/compositions.js +130 -0
- package/dist/mission/mission.d.ts +435 -0
- package/dist/mission/mission.js +770 -0
- package/dist/mission/triggers.d.ts +61 -0
- package/dist/mission/triggers.js +108 -0
- package/dist/nav/laneGraph.d.ts +178 -0
- package/dist/nav/laneGraph.js +671 -0
- package/dist/nav/traffic.d.ts +222 -0
- package/dist/nav/traffic.js +918 -0
- package/dist/physics/kinematicVehicle.d.ts +36 -0
- package/dist/physics/kinematicVehicle.js +15 -1
- package/dist/physics/vehicleDamage.d.ts +136 -0
- package/dist/physics/vehicleDamage.js +214 -0
- package/dist/physics/vehicleParts.d.ts +62 -0
- package/dist/physics/vehicleParts.js +65 -0
- package/dist/progression/gearScore.d.ts +56 -0
- package/dist/progression/gearScore.js +59 -0
- package/dist/progression/skillTree.d.ts +129 -0
- package/dist/progression/skillTree.js +265 -0
- package/dist/progression/xpCurve.d.ts +60 -0
- package/dist/progression/xpCurve.js +141 -0
- package/dist/runtime/context/combat.js +11 -0
- package/dist/scene/builtinSceneKinds.js +2 -0
- package/dist/settings/accessibilitySettings.d.ts +81 -0
- package/dist/settings/accessibilitySettings.js +152 -0
- package/dist/settings/graphicsProfile.d.ts +3 -0
- package/dist/settings/graphicsProfile.js +4 -0
- package/dist/settings/settingsModel.d.ts +1 -1
- package/dist/settings/settingsModel.js +1 -0
- package/dist/sim/nearAgents.d.ts +8 -1
- package/dist/sim/nearAgents.js +34 -17
- package/dist/sim/offScreen.d.ts +2 -1
- package/dist/sim/offScreen.js +14 -3
- package/dist/sim/population.d.ts +147 -0
- package/dist/sim/population.js +182 -0
- package/dist/sim/queue.d.ts +90 -0
- package/dist/sim/queue.js +171 -0
- package/dist/sim/rides.d.ts +287 -0
- package/dist/sim/rides.js +471 -0
- package/dist/sim/routine.js +19 -11
- package/dist/ui/loadingTips.d.ts +41 -0
- package/dist/ui/loadingTips.js +64 -0
- package/dist/ui/menuNav.d.ts +79 -0
- package/dist/ui/menuNav.js +147 -0
- package/dist/ui/menuTransition.d.ts +50 -0
- package/dist/ui/menuTransition.js +67 -0
- package/dist/ui.d.ts +2 -0
- package/dist/ui.js +2 -0
- package/dist/vfx/effectLibrary.d.ts +98 -0
- package/dist/vfx/effectLibrary.js +181 -0
- package/dist/vfx/particleBudget.d.ts +40 -0
- package/dist/vfx/particleBudget.js +39 -0
- package/dist/vfx/particleDirector.d.ts +16 -0
- package/dist/vfx/particleDirector.js +18 -0
- package/dist/world/authoredSimulation.js +7 -1
- package/dist/world/captureCamera.d.ts +30 -0
- package/dist/world/captureCamera.js +19 -0
- package/dist/world/decalKind.d.ts +48 -0
- package/dist/world/decalKind.js +86 -0
- package/dist/world/features.d.ts +18 -5
- package/dist/world/features.js +1 -0
- package/dist/world/grassKind.d.ts +5 -0
- package/dist/world/grassKind.js +3 -0
- package/dist/world/terraform.d.ts +8 -0
- package/dist/world/terrain.d.ts +1 -0
- package/dist/world/terrain.js +2 -0
- package/dist/world.d.ts +7 -2
- package/dist/world.js +5 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-combat/SKILL.md +9 -1
- package/skills/jgengine-combat/api.md +5 -4
- package/skills/jgengine-combat/reference.md +4 -0
- package/skills/jgengine-gameplay/SKILL.md +86 -0
- package/skills/jgengine-gameplay/api.md +485 -16
- package/skills/jgengine-gameplay/capabilities.md +131 -4
- package/skills/jgengine-gameplay/reference-systems.md +118 -0
- package/skills/jgengine-world/SKILL.md +8 -0
- package/skills/jgengine-world/api.md +243 -11
- package/skills/jgengine-world/capabilities.md +61 -1
- package/skills/jgengine-world/recipes/character-animation.md +25 -2
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
import { devtools } from "../devtools/devtools.js";
|
|
2
|
+
import { restoreRng, rngStateOf, seededRng } from "../random/rng.js";
|
|
3
|
+
import { lanePoseInto } from "./laneGraph.js";
|
|
4
|
+
const EPSILON = 1e-9;
|
|
5
|
+
const TWO_PI = Math.PI * 2;
|
|
6
|
+
const DEFAULT_TUNING = {
|
|
7
|
+
carLength: 4.4,
|
|
8
|
+
carWidth: 1.9,
|
|
9
|
+
maxAccel: 2.4,
|
|
10
|
+
comfortBrake: 3.2,
|
|
11
|
+
minGap: 1.6,
|
|
12
|
+
timeHeadway: 1.25,
|
|
13
|
+
speedScale: 1,
|
|
14
|
+
boxClearance: 6,
|
|
15
|
+
boxCooldownSeconds: 0.55,
|
|
16
|
+
laneChangeSeconds: 1.2,
|
|
17
|
+
laneChangeBlockedSeconds: 1.8,
|
|
18
|
+
laneChangeCooldown: 4,
|
|
19
|
+
laneChangeGapFactor: 1.6,
|
|
20
|
+
spawnGap: 14,
|
|
21
|
+
spawnSpeedFactor: 0.55,
|
|
22
|
+
fleetPerTick: 3,
|
|
23
|
+
watchSeconds: 60,
|
|
24
|
+
turnSpeedFactor: 0.55,
|
|
25
|
+
};
|
|
26
|
+
function resolveTuning(base, patch) {
|
|
27
|
+
if (patch === undefined)
|
|
28
|
+
return { ...base };
|
|
29
|
+
const next = { ...base };
|
|
30
|
+
for (const key of Object.keys(base)) {
|
|
31
|
+
const value = patch[key];
|
|
32
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
33
|
+
next[key] = Math.max(0, value);
|
|
34
|
+
}
|
|
35
|
+
next.timeHeadway = Math.max(0.05, next.timeHeadway);
|
|
36
|
+
next.maxAccel = Math.max(0.05, next.maxAccel);
|
|
37
|
+
next.fleetPerTick = Math.max(1, Math.floor(next.fleetPerTick));
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
/** Shortest signed difference between two headings, radians. */
|
|
41
|
+
function headingDelta(from, to) {
|
|
42
|
+
let delta = (to - from) % TWO_PI;
|
|
43
|
+
if (delta > Math.PI)
|
|
44
|
+
delta -= TWO_PI;
|
|
45
|
+
if (delta < -Math.PI)
|
|
46
|
+
delta += TWO_PI;
|
|
47
|
+
return delta;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Intelligent-driver-model acceleration for the tightest constraint ahead: `gap` metres to a leader
|
|
51
|
+
* moving at `leaderSpeed`. Free-flowing at zero gap-to-nothing, hard braking when the gap closes.
|
|
52
|
+
*/
|
|
53
|
+
function idmAccel(speed, desired, gap, leaderSpeed, tuning, minGap = tuning.minGap) {
|
|
54
|
+
const free = 1 - Math.pow(speed / Math.max(EPSILON, desired), 4);
|
|
55
|
+
if (!Number.isFinite(gap))
|
|
56
|
+
return tuning.maxAccel * free;
|
|
57
|
+
const closing = speed - leaderSpeed;
|
|
58
|
+
const desiredGap = minGap + speed * tuning.timeHeadway + (speed * closing) / (2 * Math.sqrt(tuning.maxAccel * tuning.comfortBrake));
|
|
59
|
+
const interaction = gap > EPSILON ? (desiredGap / gap) ** 2 : 4;
|
|
60
|
+
return tuning.maxAccel * (free - interaction);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Traffic for an authored lane graph (see {@link createLaneGraph}).
|
|
64
|
+
*
|
|
65
|
+
* @capability traffic lane-graph traffic with car following, signals, right-of-way, lane changes and parking
|
|
66
|
+
*/
|
|
67
|
+
export function createTraffic(options) {
|
|
68
|
+
const graph = options.graph;
|
|
69
|
+
const laneOf = new Map();
|
|
70
|
+
for (const laneId of graph.laneIds())
|
|
71
|
+
laneOf.set(laneId, graph.lane(laneId));
|
|
72
|
+
const spotOf = new Map(graph.spots().map((spot) => [spot.id, spot]));
|
|
73
|
+
const spotOwner = new Map();
|
|
74
|
+
let tuning = resolveTuning(DEFAULT_TUNING, options.tuning);
|
|
75
|
+
const rng = options.rng ?? seededRng(options.seed ?? "traffic");
|
|
76
|
+
const idPrefix = options.idPrefix ?? "traffic";
|
|
77
|
+
const route = options.route;
|
|
78
|
+
const cars = [];
|
|
79
|
+
const carOf = new Map();
|
|
80
|
+
const laneCars = new Map();
|
|
81
|
+
for (const laneId of laneOf.keys())
|
|
82
|
+
laneCars.set(laneId, []);
|
|
83
|
+
const laneFirst = new Map();
|
|
84
|
+
const intersections = new Map();
|
|
85
|
+
/**
|
|
86
|
+
* Default phase program: one green per line of travel, so opposite approaches share a window. The
|
|
87
|
+
* box stays exclusive, so a crossing movement inside a shared phase still has to wait its turn.
|
|
88
|
+
*/
|
|
89
|
+
function defaultPhases(inter) {
|
|
90
|
+
const buckets = new Map();
|
|
91
|
+
for (const laneId of inter.inLanes) {
|
|
92
|
+
const heading = graph.heading(laneId, 0);
|
|
93
|
+
const axis = ((heading % Math.PI) + Math.PI) % Math.PI;
|
|
94
|
+
const key = Math.round(axis / (Math.PI / 4));
|
|
95
|
+
const bucket = buckets.get(key);
|
|
96
|
+
if (bucket === undefined)
|
|
97
|
+
buckets.set(key, [laneId]);
|
|
98
|
+
else
|
|
99
|
+
bucket.push(laneId);
|
|
100
|
+
}
|
|
101
|
+
return [...buckets.entries()]
|
|
102
|
+
.sort((a, b) => a[0] - b[0])
|
|
103
|
+
.map(([, laneIds]) => laneIds.sort());
|
|
104
|
+
}
|
|
105
|
+
for (const lane of graph.intersections()) {
|
|
106
|
+
const phases = lane.signal?.phases ?? defaultPhases(lane);
|
|
107
|
+
const phaseOfLane = new Map();
|
|
108
|
+
phases.forEach((phase, index) => {
|
|
109
|
+
for (const laneId of phase)
|
|
110
|
+
if (!phaseOfLane.has(laneId))
|
|
111
|
+
phaseOfLane.set(laneId, index);
|
|
112
|
+
});
|
|
113
|
+
intersections.set(lane.node, {
|
|
114
|
+
lane,
|
|
115
|
+
phases,
|
|
116
|
+
phaseOfLane,
|
|
117
|
+
elapsed: 0,
|
|
118
|
+
cooldown: 0,
|
|
119
|
+
holder: null,
|
|
120
|
+
waiting: [],
|
|
121
|
+
signal: null,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** Which intersection a lane ends at, when its end is a crossing rather than a plain continuation. */
|
|
125
|
+
const intersectionAtEnd = new Map();
|
|
126
|
+
for (const lane of laneOf.values()) {
|
|
127
|
+
// A lane marked `exit` hands its cars back to the caller at the far end, so there is no crossing
|
|
128
|
+
// to negotiate there.
|
|
129
|
+
if (lane.exit)
|
|
130
|
+
continue;
|
|
131
|
+
const state = intersections.get(lane.to);
|
|
132
|
+
if (state !== undefined)
|
|
133
|
+
intersectionAtEnd.set(lane.id, state);
|
|
134
|
+
}
|
|
135
|
+
let demand = options.demand === undefined ? -1 : Math.max(0, Math.floor(options.demand));
|
|
136
|
+
let counter = 0;
|
|
137
|
+
let spawned = 0;
|
|
138
|
+
let exited = 0;
|
|
139
|
+
let retired = 0;
|
|
140
|
+
let laneChanges = 0;
|
|
141
|
+
let parks = 0;
|
|
142
|
+
let stalls = 0;
|
|
143
|
+
let ticks = 0;
|
|
144
|
+
let maxStillSeconds = 0;
|
|
145
|
+
let maxDeadlockSeconds = 0;
|
|
146
|
+
let slowestTickMs = 0;
|
|
147
|
+
const pose = { x: 0, y: 0, z: 0, heading: 0 };
|
|
148
|
+
function probeLine() {
|
|
149
|
+
let parked = 0;
|
|
150
|
+
let boxed = 0;
|
|
151
|
+
let topStill = 0;
|
|
152
|
+
for (const car of cars) {
|
|
153
|
+
if (car.state === "parked")
|
|
154
|
+
parked += 1;
|
|
155
|
+
if (car.state === "boxed")
|
|
156
|
+
boxed += 1;
|
|
157
|
+
if (car.stillSeconds > topStill)
|
|
158
|
+
topStill = car.stillSeconds;
|
|
159
|
+
}
|
|
160
|
+
return `${cars.length} cars ${parked} parked ${boxed} boxed ${spawned} spawned ${exited} exited ${stalls} stalls ${topStill.toFixed(1)} s longest stop ${slowestTickMs.toFixed(2)} ms slowest`;
|
|
161
|
+
}
|
|
162
|
+
const unregister = options.devtoolsName === undefined ? null : devtools.probes.register(options.devtoolsName, () => probeLine());
|
|
163
|
+
function indexOf(id) {
|
|
164
|
+
return carOf.get(id);
|
|
165
|
+
}
|
|
166
|
+
function removeAt(index) {
|
|
167
|
+
const car = cars[index];
|
|
168
|
+
const last = cars.pop();
|
|
169
|
+
carOf.delete(car.id);
|
|
170
|
+
if (index < cars.length) {
|
|
171
|
+
cars[index] = last;
|
|
172
|
+
carOf.set(last.id, index);
|
|
173
|
+
}
|
|
174
|
+
if (car.spotId !== null) {
|
|
175
|
+
spotOwner.delete(car.spotId);
|
|
176
|
+
car.spotId = null;
|
|
177
|
+
}
|
|
178
|
+
return car;
|
|
179
|
+
}
|
|
180
|
+
function applyPose(car) {
|
|
181
|
+
const lane = laneOf.get(car.laneId);
|
|
182
|
+
if (lane === undefined)
|
|
183
|
+
return;
|
|
184
|
+
lanePoseInto(lane, car.arc, car.lateral, pose);
|
|
185
|
+
car.x = pose.x;
|
|
186
|
+
car.y = pose.y;
|
|
187
|
+
car.z = pose.z;
|
|
188
|
+
car.heading = pose.heading;
|
|
189
|
+
}
|
|
190
|
+
function createCar(id, laneId, arc, speed, variant) {
|
|
191
|
+
const lane = laneOf.get(laneId);
|
|
192
|
+
if (lane === undefined)
|
|
193
|
+
return null;
|
|
194
|
+
const car = {
|
|
195
|
+
id,
|
|
196
|
+
laneId,
|
|
197
|
+
arc: Math.max(0, Math.min(arc, lane.length)),
|
|
198
|
+
speed: Math.max(0, speed),
|
|
199
|
+
maxSpeed: Math.max(1, lane.speed * tuning.speedScale),
|
|
200
|
+
desiredSpeed: 0,
|
|
201
|
+
state: "driving",
|
|
202
|
+
cleared: false,
|
|
203
|
+
nextLane: null,
|
|
204
|
+
spotId: null,
|
|
205
|
+
lateral: 0,
|
|
206
|
+
lateralRate: 0,
|
|
207
|
+
changeCooldown: 0,
|
|
208
|
+
blockedSeconds: 0,
|
|
209
|
+
stillSeconds: 0,
|
|
210
|
+
deadlockSeconds: 0,
|
|
211
|
+
waitingSeconds: 0,
|
|
212
|
+
stalled: false,
|
|
213
|
+
variant,
|
|
214
|
+
queueIndex: -1,
|
|
215
|
+
x: 0,
|
|
216
|
+
y: 0,
|
|
217
|
+
z: 0,
|
|
218
|
+
heading: 0,
|
|
219
|
+
};
|
|
220
|
+
car.desiredSpeed = car.maxSpeed * (0.85 + rng() * 0.3);
|
|
221
|
+
applyPose(car);
|
|
222
|
+
cars.push(car);
|
|
223
|
+
carOf.set(id, cars.length - 1);
|
|
224
|
+
return car;
|
|
225
|
+
}
|
|
226
|
+
function phaseSignals(dt) {
|
|
227
|
+
for (const state of intersections.values()) {
|
|
228
|
+
state.elapsed += dt;
|
|
229
|
+
if (state.cooldown > 0)
|
|
230
|
+
state.cooldown = Math.max(0, state.cooldown - dt);
|
|
231
|
+
const lane = state.lane;
|
|
232
|
+
const program = lane.signal;
|
|
233
|
+
if (program === null || state.phases.length === 0) {
|
|
234
|
+
state.signal = null;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const green = Math.max(0.1, program.greenSeconds);
|
|
238
|
+
const yellow = Math.max(0, program.yellowSeconds);
|
|
239
|
+
const allRed = Math.max(0, program.allRedSeconds ?? 0);
|
|
240
|
+
const slot = green + yellow + allRed;
|
|
241
|
+
const cycle = slot * state.phases.length;
|
|
242
|
+
const local = (((state.elapsed + lane.offsetSeconds) % cycle) + cycle) % cycle;
|
|
243
|
+
const index = Math.min(state.phases.length - 1, Math.floor(local / slot));
|
|
244
|
+
const within = local - index * slot;
|
|
245
|
+
const stateName = within < green ? "green" : within < green + yellow ? "yellow" : "red";
|
|
246
|
+
state.signal = {
|
|
247
|
+
node: lane.node,
|
|
248
|
+
phase: index,
|
|
249
|
+
state: stateName,
|
|
250
|
+
remaining: slot - within,
|
|
251
|
+
lanes: state.phases[index] ?? [],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function noteLaneFirst(laneId, arc) {
|
|
256
|
+
const current = laneFirst.get(laneId);
|
|
257
|
+
const half = tuning.carLength / 2;
|
|
258
|
+
if (current === undefined || arc < current.arc)
|
|
259
|
+
laneFirst.set(laneId, { arc, half });
|
|
260
|
+
}
|
|
261
|
+
/** Outbound options at a lane's end, never the lane the car arrived on unless it is the only one. */
|
|
262
|
+
function exitOptions(lane) {
|
|
263
|
+
const out = graph.outLanes(lane.to).filter((id) => {
|
|
264
|
+
const candidate = laneOf.get(id);
|
|
265
|
+
return candidate !== undefined && candidate.to !== lane.from;
|
|
266
|
+
});
|
|
267
|
+
if (out.length > 0)
|
|
268
|
+
return out;
|
|
269
|
+
return graph.outLanes(lane.to).slice();
|
|
270
|
+
}
|
|
271
|
+
function resolveExit(car, lane) {
|
|
272
|
+
const options = exitOptions(lane);
|
|
273
|
+
if (options.length === 0)
|
|
274
|
+
return null;
|
|
275
|
+
if (route !== undefined) {
|
|
276
|
+
const chosen = route({ carId: car.id, laneId: lane.id, nodeId: lane.to, options });
|
|
277
|
+
if (chosen === null)
|
|
278
|
+
return null;
|
|
279
|
+
if (options.includes(chosen))
|
|
280
|
+
return chosen;
|
|
281
|
+
}
|
|
282
|
+
const heading = graph.heading(lane.id, lane.length);
|
|
283
|
+
let best = options[0];
|
|
284
|
+
let bestDot = -Infinity;
|
|
285
|
+
for (const optionId of options) {
|
|
286
|
+
const dot = Math.cos(headingDelta(heading, graph.heading(optionId, 0)));
|
|
287
|
+
if (dot > bestDot + 1e-9 || (Math.abs(dot - bestDot) <= 1e-9 && optionId < best)) {
|
|
288
|
+
bestDot = dot;
|
|
289
|
+
best = optionId;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return best;
|
|
293
|
+
}
|
|
294
|
+
function exitClear(exitLaneId, speed) {
|
|
295
|
+
const first = laneFirst.get(exitLaneId);
|
|
296
|
+
if (first === undefined)
|
|
297
|
+
return true;
|
|
298
|
+
const room = first.arc - first.half - tuning.carLength / 2;
|
|
299
|
+
return room >= Math.max(tuning.minGap, tuning.boxClearance * Math.min(1, Math.max(0.4, speed / 10)));
|
|
300
|
+
}
|
|
301
|
+
function stopArcOf(lane, state) {
|
|
302
|
+
return Math.max(0, lane.length - state.lane.stopLine);
|
|
303
|
+
}
|
|
304
|
+
function moveCars(dt) {
|
|
305
|
+
for (const car of cars) {
|
|
306
|
+
if (car.state === "parked") {
|
|
307
|
+
car.speed = 0;
|
|
308
|
+
car.stillSeconds += dt;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const lane = laneOf.get(car.laneId);
|
|
312
|
+
if (lane === undefined)
|
|
313
|
+
continue;
|
|
314
|
+
const state = intersectionAtEnd.get(car.laneId) ?? null;
|
|
315
|
+
let hardArc = Number.POSITIVE_INFINITY;
|
|
316
|
+
let limitSpeed = 0;
|
|
317
|
+
let atLine = false;
|
|
318
|
+
let gapBias = tuning.minGap;
|
|
319
|
+
const list = laneCars.get(car.laneId);
|
|
320
|
+
if (list !== undefined && car.queueIndex >= 0) {
|
|
321
|
+
const leader = list[car.queueIndex + 1];
|
|
322
|
+
if (leader !== undefined) {
|
|
323
|
+
hardArc = leader.arc - tuning.carLength - 0.02;
|
|
324
|
+
limitSpeed = leader.speed;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (state !== null && !car.cleared) {
|
|
328
|
+
const stopArc = stopArcOf(lane, state);
|
|
329
|
+
if (stopArc < hardArc) {
|
|
330
|
+
hardArc = stopArc;
|
|
331
|
+
limitSpeed = 0;
|
|
332
|
+
}
|
|
333
|
+
if (car.arc >= stopArc - tuning.minGap - 0.5) {
|
|
334
|
+
state.waiting.push(car);
|
|
335
|
+
car.waitingSeconds += dt;
|
|
336
|
+
atLine = true;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else if (car.cleared) {
|
|
340
|
+
car.waitingSeconds = 0;
|
|
341
|
+
}
|
|
342
|
+
if (car.spotId !== null) {
|
|
343
|
+
const spot = spotOf.get(car.spotId);
|
|
344
|
+
if (spot !== undefined && spot.lane === car.laneId && spot.arc < hardArc) {
|
|
345
|
+
hardArc = spot.arc;
|
|
346
|
+
limitSpeed = 0;
|
|
347
|
+
// A car parks against the kerb, not a car's length short of it.
|
|
348
|
+
gapBias = 0.05;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const target = Math.min(car.desiredSpeed, lane.speed * tuning.speedScale);
|
|
352
|
+
const gap = hardArc - car.arc;
|
|
353
|
+
const accel = idmAccel(car.speed, target, gap, limitSpeed, tuning, gapBias);
|
|
354
|
+
car.speed = Math.max(0, car.speed + accel * dt);
|
|
355
|
+
let arc = car.arc + car.speed * dt;
|
|
356
|
+
if (arc > hardArc) {
|
|
357
|
+
arc = Math.max(car.arc, hardArc);
|
|
358
|
+
car.speed = Math.min(car.speed, limitSpeed);
|
|
359
|
+
}
|
|
360
|
+
if (arc > lane.length)
|
|
361
|
+
arc = lane.length;
|
|
362
|
+
if (car.speed < 0.15) {
|
|
363
|
+
car.stillSeconds += dt;
|
|
364
|
+
if (car.stillSeconds > maxStillSeconds)
|
|
365
|
+
maxStillSeconds = car.stillSeconds;
|
|
366
|
+
// A stopped car only counts as a deadlock when the road ahead is actually clear: a car
|
|
367
|
+
// holding at its stop line or tucked in behind another is waiting, not stuck.
|
|
368
|
+
const leaderGap = Number.isFinite(hardArc) && hardArc < lane.length ? hardArc - arc : Number.POSITIVE_INFINITY;
|
|
369
|
+
const clearAhead = atLine ? false : leaderGap > tuning.minGap + 1.5;
|
|
370
|
+
if (clearAhead) {
|
|
371
|
+
car.deadlockSeconds += dt;
|
|
372
|
+
if (car.deadlockSeconds > maxDeadlockSeconds)
|
|
373
|
+
maxDeadlockSeconds = car.deadlockSeconds;
|
|
374
|
+
if (!car.stalled && car.deadlockSeconds > tuning.watchSeconds) {
|
|
375
|
+
car.stalled = true;
|
|
376
|
+
stalls += 1;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
else if (car.stalled) {
|
|
380
|
+
car.stalled = false;
|
|
381
|
+
car.deadlockSeconds = 0;
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
car.deadlockSeconds = 0;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
car.stillSeconds = 0;
|
|
389
|
+
car.deadlockSeconds = 0;
|
|
390
|
+
car.stalled = false;
|
|
391
|
+
}
|
|
392
|
+
const blocked = list !== undefined && car.queueIndex >= 0 && list[car.queueIndex + 1] !== undefined && car.speed < target * 0.6;
|
|
393
|
+
car.blockedSeconds = blocked ? car.blockedSeconds + dt : 0;
|
|
394
|
+
if (car.changeCooldown > 0)
|
|
395
|
+
car.changeCooldown = Math.max(0, car.changeCooldown - dt);
|
|
396
|
+
car.arc = arc;
|
|
397
|
+
if (car.lateral > EPSILON) {
|
|
398
|
+
car.lateral = Math.max(0, car.lateral - car.lateralRate * dt);
|
|
399
|
+
}
|
|
400
|
+
applyPose(car);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function grantIntersections() {
|
|
404
|
+
for (const state of intersections.values()) {
|
|
405
|
+
if (state.waiting.length === 0)
|
|
406
|
+
continue;
|
|
407
|
+
if (state.holder !== null || state.cooldown > 0)
|
|
408
|
+
continue;
|
|
409
|
+
const signal = state.signal;
|
|
410
|
+
const eligible = [];
|
|
411
|
+
for (const car of state.waiting) {
|
|
412
|
+
if (car.cleared)
|
|
413
|
+
continue;
|
|
414
|
+
if (signal !== null) {
|
|
415
|
+
const phase = state.phaseOfLane.get(car.laneId);
|
|
416
|
+
if (phase === undefined)
|
|
417
|
+
continue;
|
|
418
|
+
if (signal.state !== "green" || signal.phase !== phase)
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
eligible.push(car);
|
|
422
|
+
}
|
|
423
|
+
if (eligible.length === 0)
|
|
424
|
+
continue;
|
|
425
|
+
eligible.sort((a, b) => b.waitingSeconds - a.waitingSeconds ||
|
|
426
|
+
(a.laneId < b.laneId ? -1 : a.laneId > b.laneId ? 1 : 0) ||
|
|
427
|
+
(a.id < b.id ? -1 : 1));
|
|
428
|
+
for (const car of eligible) {
|
|
429
|
+
const lane = laneOf.get(car.laneId);
|
|
430
|
+
if (lane === undefined)
|
|
431
|
+
continue;
|
|
432
|
+
const next = resolveExit(car, lane);
|
|
433
|
+
if (next === null)
|
|
434
|
+
continue;
|
|
435
|
+
if (!exitClear(next, car.speed))
|
|
436
|
+
continue;
|
|
437
|
+
car.cleared = true;
|
|
438
|
+
car.nextLane = next;
|
|
439
|
+
car.state = "boxed";
|
|
440
|
+
car.waitingSeconds = 0;
|
|
441
|
+
state.holder = car;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function transferCars() {
|
|
447
|
+
for (let index = cars.length - 1; index >= 0; index -= 1) {
|
|
448
|
+
const car = cars[index];
|
|
449
|
+
if (car === undefined || car.state === "parked")
|
|
450
|
+
continue;
|
|
451
|
+
const lane = laneOf.get(car.laneId);
|
|
452
|
+
if (lane === undefined)
|
|
453
|
+
continue;
|
|
454
|
+
if (car.arc < lane.length - 1e-6)
|
|
455
|
+
continue;
|
|
456
|
+
const state = intersectionAtEnd.get(car.laneId) ?? null;
|
|
457
|
+
if (state !== null && !car.cleared)
|
|
458
|
+
continue;
|
|
459
|
+
if (lane.exit) {
|
|
460
|
+
removeAt(index);
|
|
461
|
+
exited += 1;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const nextId = car.nextLane ?? resolveExit(car, lane);
|
|
465
|
+
if (nextId === null) {
|
|
466
|
+
removeAt(index);
|
|
467
|
+
exited += 1;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const turn = Math.abs(headingDelta(graph.heading(lane.id, lane.length), graph.heading(nextId, 0)));
|
|
471
|
+
if (turn > Math.PI / 4)
|
|
472
|
+
car.speed = Math.min(car.speed, car.maxSpeed * tuning.turnSpeedFactor);
|
|
473
|
+
// A car always enters its exit lane at the junction end; `exitClear` already guaranteed the
|
|
474
|
+
// room for it, so there is no rear-most car to tuck behind here.
|
|
475
|
+
car.laneId = nextId;
|
|
476
|
+
car.arc = 0;
|
|
477
|
+
car.cleared = false;
|
|
478
|
+
car.nextLane = null;
|
|
479
|
+
car.state = "driving";
|
|
480
|
+
noteLaneFirst(nextId, car.arc);
|
|
481
|
+
if (state !== null) {
|
|
482
|
+
if (state.holder === car)
|
|
483
|
+
state.holder = null;
|
|
484
|
+
state.cooldown = tuning.boxCooldownSeconds;
|
|
485
|
+
}
|
|
486
|
+
if (car.spotId !== null) {
|
|
487
|
+
const spot = spotOf.get(car.spotId);
|
|
488
|
+
if (spot === undefined || spot.lane !== car.laneId) {
|
|
489
|
+
spotOwner.delete(car.spotId);
|
|
490
|
+
car.spotId = null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
applyPose(car);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function changeLanes() {
|
|
497
|
+
for (const car of cars) {
|
|
498
|
+
if (car.state !== "driving" || car.cleared)
|
|
499
|
+
continue;
|
|
500
|
+
if (car.changeCooldown > 0 || car.blockedSeconds < tuning.laneChangeBlockedSeconds)
|
|
501
|
+
continue;
|
|
502
|
+
if (car.spotId !== null)
|
|
503
|
+
continue;
|
|
504
|
+
const lane = laneOf.get(car.laneId);
|
|
505
|
+
if (lane === undefined)
|
|
506
|
+
continue;
|
|
507
|
+
const window = tuning.carLength * tuning.laneChangeGapFactor;
|
|
508
|
+
for (const neighbourId of [lane.left, lane.right]) {
|
|
509
|
+
if (neighbourId === null)
|
|
510
|
+
continue;
|
|
511
|
+
const neighbour = laneOf.get(neighbourId);
|
|
512
|
+
if (neighbour === undefined)
|
|
513
|
+
continue;
|
|
514
|
+
const arc = graph.project(neighbourId, car.x, car.z);
|
|
515
|
+
const headingDot = Math.cos(Math.abs(headingDelta(graph.heading(car.laneId, car.arc), graph.heading(neighbourId, arc))));
|
|
516
|
+
if (headingDot < 0.7)
|
|
517
|
+
continue;
|
|
518
|
+
const list = laneCars.get(neighbourId);
|
|
519
|
+
if (list !== undefined && list.length > 0) {
|
|
520
|
+
let clear = true;
|
|
521
|
+
for (const other of list) {
|
|
522
|
+
if (Math.abs(other.arc - arc) < window + tuning.carLength) {
|
|
523
|
+
clear = false;
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (!clear)
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const nextPose = graph.pose(neighbourId, arc);
|
|
531
|
+
if (nextPose === null)
|
|
532
|
+
continue;
|
|
533
|
+
// Blend across from the old centerline: the lateral offset is the car's current position
|
|
534
|
+
// measured against the lane it is joining, so the switch never moves it in world space.
|
|
535
|
+
const lateral = (car.x - nextPose.position[0]) * Math.cos(nextPose.heading) -
|
|
536
|
+
(car.z - nextPose.position[2]) * Math.sin(nextPose.heading);
|
|
537
|
+
if (Math.abs(lateral) > neighbour.width * 2)
|
|
538
|
+
continue;
|
|
539
|
+
car.laneId = neighbourId;
|
|
540
|
+
car.arc = arc;
|
|
541
|
+
car.lateral = lateral;
|
|
542
|
+
car.lateralRate = tuning.laneChangeSeconds > 0 ? lateral / tuning.laneChangeSeconds : lateral;
|
|
543
|
+
car.changeCooldown = tuning.laneChangeCooldown;
|
|
544
|
+
car.blockedSeconds = 0;
|
|
545
|
+
laneChanges += 1;
|
|
546
|
+
applyPose(car);
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function settleParking() {
|
|
552
|
+
for (const car of cars) {
|
|
553
|
+
if (car.spotId === null || car.state === "parked")
|
|
554
|
+
continue;
|
|
555
|
+
const spot = spotOf.get(car.spotId);
|
|
556
|
+
if (spot === undefined || spot.lane !== car.laneId)
|
|
557
|
+
continue;
|
|
558
|
+
if (car.arc >= spot.arc - 0.6 && car.speed < 0.35) {
|
|
559
|
+
car.state = "parked";
|
|
560
|
+
car.speed = 0;
|
|
561
|
+
car.arc = spot.arc;
|
|
562
|
+
spotOwner.set(spot.id, car.id);
|
|
563
|
+
parks += 1;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
function spawnOn(laneId) {
|
|
568
|
+
const lane = laneOf.get(laneId);
|
|
569
|
+
if (lane === undefined)
|
|
570
|
+
return false;
|
|
571
|
+
const first = laneFirst.get(laneId);
|
|
572
|
+
if (first !== undefined && first.arc - first.half < tuning.spawnGap)
|
|
573
|
+
return false;
|
|
574
|
+
const id = `${idPrefix}-${counter}`;
|
|
575
|
+
counter += 1;
|
|
576
|
+
if (carOf.has(id))
|
|
577
|
+
return false;
|
|
578
|
+
const variant = Math.floor(rng() * 8);
|
|
579
|
+
const car = createCar(id, laneId, 0, Math.min(lane.speed, lane.speed * tuning.spawnSpeedFactor), variant);
|
|
580
|
+
if (car === null)
|
|
581
|
+
return false;
|
|
582
|
+
spawned += 1;
|
|
583
|
+
noteLaneFirst(laneId, car.arc);
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
function applyDemand() {
|
|
587
|
+
if (demand < 0)
|
|
588
|
+
return;
|
|
589
|
+
let budget = tuning.fleetPerTick;
|
|
590
|
+
if (cars.length < demand) {
|
|
591
|
+
for (const laneId of graph.entries()) {
|
|
592
|
+
if (budget <= 0 || cars.length >= demand)
|
|
593
|
+
break;
|
|
594
|
+
while (budget > 0 && cars.length < demand && spawnOn(laneId))
|
|
595
|
+
budget -= 1;
|
|
596
|
+
}
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
while (cars.length > demand && budget > 0) {
|
|
600
|
+
budget -= 1;
|
|
601
|
+
let worst = -1;
|
|
602
|
+
let worstProgress = -1;
|
|
603
|
+
for (let index = 0; index < cars.length; index += 1) {
|
|
604
|
+
const car = cars[index];
|
|
605
|
+
if (car.state === "boxed" || car.spotId !== null)
|
|
606
|
+
continue;
|
|
607
|
+
const lane = laneOf.get(car.laneId);
|
|
608
|
+
const progress = lane === undefined ? 0 : car.arc / Math.max(EPSILON, lane.length);
|
|
609
|
+
if (progress > worstProgress) {
|
|
610
|
+
worstProgress = progress;
|
|
611
|
+
worst = index;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (worst < 0)
|
|
615
|
+
break;
|
|
616
|
+
const car = cars[worst];
|
|
617
|
+
const state = intersections.get(laneOf.get(car.laneId)?.to ?? "");
|
|
618
|
+
if (state !== undefined && state.holder === car)
|
|
619
|
+
state.holder = null;
|
|
620
|
+
removeAt(worst);
|
|
621
|
+
retired += 1;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
function resetLaneArrays() {
|
|
625
|
+
for (const list of laneCars.values())
|
|
626
|
+
list.length = 0;
|
|
627
|
+
laneFirst.clear();
|
|
628
|
+
for (const car of cars) {
|
|
629
|
+
const list = laneCars.get(car.laneId);
|
|
630
|
+
if (list === undefined)
|
|
631
|
+
continue;
|
|
632
|
+
car.queueIndex = list.length;
|
|
633
|
+
list.push(car);
|
|
634
|
+
}
|
|
635
|
+
for (const [laneId, list] of laneCars) {
|
|
636
|
+
if (list.length === 0)
|
|
637
|
+
continue;
|
|
638
|
+
list.sort((a, b) => a.arc - b.arc || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
639
|
+
list.forEach((car, index) => {
|
|
640
|
+
car.queueIndex = index;
|
|
641
|
+
});
|
|
642
|
+
const first = list[0];
|
|
643
|
+
laneFirst.set(laneId, { arc: first.arc, half: tuning.carLength / 2 });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
add(id, spec) {
|
|
648
|
+
if (carOf.has(id))
|
|
649
|
+
return false;
|
|
650
|
+
const car = createCar(id, spec.laneId, spec.arc ?? 0, spec.speed ?? 0, Math.floor(rng() * 8));
|
|
651
|
+
return car !== null;
|
|
652
|
+
},
|
|
653
|
+
remove(id) {
|
|
654
|
+
const at = indexOf(id);
|
|
655
|
+
if (at === undefined)
|
|
656
|
+
return false;
|
|
657
|
+
const car = cars[at];
|
|
658
|
+
const state = intersections.get(laneOf.get(car.laneId)?.to ?? "");
|
|
659
|
+
if (state !== undefined && state.holder === car)
|
|
660
|
+
state.holder = null;
|
|
661
|
+
removeAt(at);
|
|
662
|
+
return true;
|
|
663
|
+
},
|
|
664
|
+
has: (id) => carOf.has(id),
|
|
665
|
+
count: () => cars.length,
|
|
666
|
+
ids: () => cars.map((car) => car.id),
|
|
667
|
+
car(id) {
|
|
668
|
+
const at = indexOf(id);
|
|
669
|
+
if (at === undefined)
|
|
670
|
+
return null;
|
|
671
|
+
const car = cars[at];
|
|
672
|
+
return {
|
|
673
|
+
id: car.id,
|
|
674
|
+
laneId: car.laneId,
|
|
675
|
+
arc: car.arc,
|
|
676
|
+
position: [car.x, car.y, car.z],
|
|
677
|
+
heading: car.heading,
|
|
678
|
+
speed: car.speed,
|
|
679
|
+
state: car.state,
|
|
680
|
+
spotId: car.spotId,
|
|
681
|
+
variant: car.variant,
|
|
682
|
+
};
|
|
683
|
+
},
|
|
684
|
+
forEachCar(visit) {
|
|
685
|
+
for (const car of cars) {
|
|
686
|
+
visit({
|
|
687
|
+
id: car.id,
|
|
688
|
+
laneId: car.laneId,
|
|
689
|
+
arc: car.arc,
|
|
690
|
+
position: [car.x, car.y, car.z],
|
|
691
|
+
heading: car.heading,
|
|
692
|
+
speed: car.speed,
|
|
693
|
+
state: car.state,
|
|
694
|
+
spotId: car.spotId,
|
|
695
|
+
variant: car.variant,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
},
|
|
699
|
+
tick(dt) {
|
|
700
|
+
if (!(dt > 0))
|
|
701
|
+
return;
|
|
702
|
+
const started = performance.now();
|
|
703
|
+
phaseSignals(dt);
|
|
704
|
+
for (const state of intersections.values())
|
|
705
|
+
state.waiting.length = 0;
|
|
706
|
+
resetLaneArrays();
|
|
707
|
+
moveCars(dt);
|
|
708
|
+
grantIntersections();
|
|
709
|
+
transferCars();
|
|
710
|
+
changeLanes();
|
|
711
|
+
settleParking();
|
|
712
|
+
applyDemand();
|
|
713
|
+
ticks += 1;
|
|
714
|
+
slowestTickMs = Math.max(slowestTickMs, performance.now() - started);
|
|
715
|
+
},
|
|
716
|
+
retune(patch) {
|
|
717
|
+
tuning = resolveTuning(tuning, patch);
|
|
718
|
+
for (const car of cars) {
|
|
719
|
+
const lane = laneOf.get(car.laneId);
|
|
720
|
+
if (lane !== undefined)
|
|
721
|
+
car.maxSpeed = Math.max(1, lane.speed * tuning.speedScale);
|
|
722
|
+
}
|
|
723
|
+
},
|
|
724
|
+
setDemand(count) {
|
|
725
|
+
demand = Math.max(0, Math.floor(count));
|
|
726
|
+
},
|
|
727
|
+
demand: () => demand, park(id, spotId) {
|
|
728
|
+
const at = indexOf(id);
|
|
729
|
+
if (at === undefined)
|
|
730
|
+
return false;
|
|
731
|
+
const car = cars[at];
|
|
732
|
+
if (car.state === "parked")
|
|
733
|
+
return true;
|
|
734
|
+
let target = null;
|
|
735
|
+
if (spotId !== undefined) {
|
|
736
|
+
const spot = spotOf.get(spotId);
|
|
737
|
+
if (spot === undefined || spot.lane !== car.laneId)
|
|
738
|
+
return false;
|
|
739
|
+
if (spotOwner.has(spot.id) && spotOwner.get(spot.id) !== car.id)
|
|
740
|
+
return false;
|
|
741
|
+
target = spot.id;
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
let bestArc = Infinity;
|
|
745
|
+
for (const spot of spotOf.values()) {
|
|
746
|
+
if (spot.lane !== car.laneId || spot.arc < car.arc)
|
|
747
|
+
continue;
|
|
748
|
+
const owner = spotOwner.get(spot.id);
|
|
749
|
+
if (owner !== undefined && owner !== car.id)
|
|
750
|
+
continue;
|
|
751
|
+
if (spot.arc < bestArc) {
|
|
752
|
+
bestArc = spot.arc;
|
|
753
|
+
target = spot.id;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (target === null)
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
if (car.spotId !== null && car.spotId !== target) {
|
|
760
|
+
spotOwner.delete(car.spotId);
|
|
761
|
+
}
|
|
762
|
+
car.spotId = target;
|
|
763
|
+
spotOwner.set(target, car.id);
|
|
764
|
+
return true;
|
|
765
|
+
},
|
|
766
|
+
releaseParking(id) {
|
|
767
|
+
const at = indexOf(id);
|
|
768
|
+
if (at === undefined)
|
|
769
|
+
return false;
|
|
770
|
+
const car = cars[at];
|
|
771
|
+
if (car.spotId === null)
|
|
772
|
+
return false;
|
|
773
|
+
spotOwner.delete(car.spotId);
|
|
774
|
+
car.spotId = null;
|
|
775
|
+
if (car.state === "parked")
|
|
776
|
+
car.state = "driving";
|
|
777
|
+
return true;
|
|
778
|
+
},
|
|
779
|
+
spotOwner: (spotId) => spotOwner.get(spotId) ?? null,
|
|
780
|
+
signal(nodeId) {
|
|
781
|
+
const state = intersections.get(nodeId);
|
|
782
|
+
return state?.signal ?? null;
|
|
783
|
+
},
|
|
784
|
+
stats() {
|
|
785
|
+
let parked = 0;
|
|
786
|
+
let boxed = 0;
|
|
787
|
+
let waiting = 0;
|
|
788
|
+
let speedSum = 0;
|
|
789
|
+
let moving = 0;
|
|
790
|
+
for (const car of cars) {
|
|
791
|
+
if (car.state === "parked")
|
|
792
|
+
parked += 1;
|
|
793
|
+
if (car.state === "boxed")
|
|
794
|
+
boxed += 1;
|
|
795
|
+
if (car.waitingSeconds > 0)
|
|
796
|
+
waiting += 1;
|
|
797
|
+
if (car.speed > 0.1) {
|
|
798
|
+
speedSum += car.speed;
|
|
799
|
+
moving += 1;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return {
|
|
803
|
+
cars: cars.length,
|
|
804
|
+
parked,
|
|
805
|
+
boxed,
|
|
806
|
+
waiting,
|
|
807
|
+
spawned,
|
|
808
|
+
exited,
|
|
809
|
+
retired,
|
|
810
|
+
laneChanges,
|
|
811
|
+
parks,
|
|
812
|
+
stalls,
|
|
813
|
+
maxStillSeconds,
|
|
814
|
+
maxDeadlockSeconds,
|
|
815
|
+
ticks,
|
|
816
|
+
slowestTickMs,
|
|
817
|
+
meanSpeed: moving === 0 ? 0 : speedSum / moving,
|
|
818
|
+
};
|
|
819
|
+
},
|
|
820
|
+
snapshot() {
|
|
821
|
+
return {
|
|
822
|
+
tunables: { ...tuning },
|
|
823
|
+
demand,
|
|
824
|
+
counter,
|
|
825
|
+
rng: rngStateOf(rng),
|
|
826
|
+
cars: cars.map((car) => ({
|
|
827
|
+
id: car.id,
|
|
828
|
+
laneId: car.laneId,
|
|
829
|
+
arc: car.arc,
|
|
830
|
+
speed: car.speed,
|
|
831
|
+
maxSpeed: car.maxSpeed,
|
|
832
|
+
desiredSpeed: car.desiredSpeed,
|
|
833
|
+
state: car.state,
|
|
834
|
+
cleared: car.cleared,
|
|
835
|
+
nextLane: car.nextLane,
|
|
836
|
+
spotId: car.spotId,
|
|
837
|
+
lateral: car.lateral,
|
|
838
|
+
lateralRate: car.lateralRate,
|
|
839
|
+
changeCooldown: car.changeCooldown,
|
|
840
|
+
blockedSeconds: car.blockedSeconds,
|
|
841
|
+
stillSeconds: car.stillSeconds,
|
|
842
|
+
deadlockSeconds: car.deadlockSeconds,
|
|
843
|
+
waitingSeconds: car.waitingSeconds,
|
|
844
|
+
stalled: car.stalled,
|
|
845
|
+
variant: car.variant,
|
|
846
|
+
})),
|
|
847
|
+
spots: [...spotOf.keys()].map((spotId) => ({ id: spotId, carId: spotOwner.get(spotId) ?? null })),
|
|
848
|
+
intersections: [...intersections.values()].map((state) => ({
|
|
849
|
+
node: state.lane.node,
|
|
850
|
+
elapsed: state.elapsed,
|
|
851
|
+
cooldown: state.cooldown,
|
|
852
|
+
holder: state.holder?.id ?? null,
|
|
853
|
+
})),
|
|
854
|
+
counters: {
|
|
855
|
+
spawned,
|
|
856
|
+
exited,
|
|
857
|
+
retired,
|
|
858
|
+
laneChanges,
|
|
859
|
+
parks,
|
|
860
|
+
stalls,
|
|
861
|
+
ticks,
|
|
862
|
+
maxStillSeconds,
|
|
863
|
+
maxDeadlockSeconds,
|
|
864
|
+
},
|
|
865
|
+
};
|
|
866
|
+
},
|
|
867
|
+
restore(next) {
|
|
868
|
+
tuning = resolveTuning({ ...DEFAULT_TUNING }, next.tunables);
|
|
869
|
+
demand = next.demand < 0 ? -1 : Math.max(0, Math.floor(next.demand));
|
|
870
|
+
counter = Math.max(0, Math.floor(next.counter));
|
|
871
|
+
if (next.rng !== null)
|
|
872
|
+
restoreRng(rng, next.rng);
|
|
873
|
+
cars.length = 0;
|
|
874
|
+
carOf.clear();
|
|
875
|
+
spotOwner.clear();
|
|
876
|
+
spawned = next.counters.spawned;
|
|
877
|
+
exited = next.counters.exited;
|
|
878
|
+
retired = next.counters.retired;
|
|
879
|
+
laneChanges = next.counters.laneChanges;
|
|
880
|
+
parks = next.counters.parks;
|
|
881
|
+
stalls = next.counters.stalls;
|
|
882
|
+
ticks = next.counters.ticks;
|
|
883
|
+
maxStillSeconds = next.counters.maxStillSeconds;
|
|
884
|
+
maxDeadlockSeconds = next.counters.maxDeadlockSeconds;
|
|
885
|
+
slowestTickMs = 0;
|
|
886
|
+
for (const saved of next.cars) {
|
|
887
|
+
const car = {
|
|
888
|
+
...saved,
|
|
889
|
+
queueIndex: -1,
|
|
890
|
+
x: 0,
|
|
891
|
+
y: 0,
|
|
892
|
+
z: 0,
|
|
893
|
+
heading: 0,
|
|
894
|
+
};
|
|
895
|
+
applyPose(car);
|
|
896
|
+
cars.push(car);
|
|
897
|
+
carOf.set(car.id, cars.length - 1);
|
|
898
|
+
}
|
|
899
|
+
for (const spot of next.spots) {
|
|
900
|
+
if (spot.carId === null)
|
|
901
|
+
continue;
|
|
902
|
+
spotOwner.set(spot.id, spot.carId);
|
|
903
|
+
}
|
|
904
|
+
for (const saved of next.intersections) {
|
|
905
|
+
const state = intersections.get(saved.node);
|
|
906
|
+
if (state === undefined)
|
|
907
|
+
continue;
|
|
908
|
+
state.elapsed = saved.elapsed;
|
|
909
|
+
state.cooldown = saved.cooldown;
|
|
910
|
+
state.holder = saved.holder === null ? null : cars[carOf.get(saved.holder) ?? -1] ?? null;
|
|
911
|
+
}
|
|
912
|
+
resetLaneArrays();
|
|
913
|
+
},
|
|
914
|
+
dispose() {
|
|
915
|
+
unregister?.();
|
|
916
|
+
},
|
|
917
|
+
};
|
|
918
|
+
}
|