@inventure71/paddockjs 4.2.0 → 4.3.1

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.
Files changed (249) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/INSTALL_AND_UPDATE.md +24 -4
  3. package/README.md +31 -2
  4. package/docs/advanced_ai_pace.md +131 -0
  5. package/docs/advanced_physics_rebuild.md +166 -0
  6. package/docs/architecture.md +109 -30
  7. package/docs/cleanup_coverage.md +284 -0
  8. package/docs/component_inventory.md +31 -15
  9. package/docs/composable-layouts.md +2 -0
  10. package/docs/concepts.md +12 -1
  11. package/docs/custom_model_controller.md +1 -1
  12. package/docs/data_contract.md +33 -10
  13. package/docs/data_model.md +4 -2
  14. package/docs/efficiency_cleanup_2026-10-13.md +80 -0
  15. package/docs/grid_walk.md +84 -0
  16. package/docs/index.md +1 -0
  17. package/docs/keyboard_driving_fix.md +118 -0
  18. package/docs/limit_driving_2026-10-14.md +88 -0
  19. package/docs/maintainability_audit_2026-09-08.md +146 -0
  20. package/docs/racing_ai_improvements.md +145 -0
  21. package/docs/rules.md +43 -13
  22. package/docs/sense_contract.md +4 -1
  23. package/docs/system_specs.md +38 -11
  24. package/docs/testing-reliability-guide.md +5 -0
  25. package/docs/v11_improvements.md +108 -0
  26. package/package.json +24 -13
  27. package/src/api/MountedSimulatorComponents.js +101 -0
  28. package/src/api/PaddockSimulatorController.js +56 -81
  29. package/src/app/BrowserExpertAdapter.js +0 -6
  30. package/src/app/BrowserExpertExternalRenderer.js +72 -50
  31. package/src/app/ExternalTrackSnapshot.js +50 -0
  32. package/src/app/F1SimulatorApp.js +242 -297
  33. package/src/app/HostLifecycleEmitter.js +93 -0
  34. package/src/app/banners/raceDataBannerController.js +9 -4
  35. package/src/app/camera/cameraConstants.js +1 -1
  36. package/src/app/camera/cameraController.js +10 -13
  37. package/src/app/camera/cameraGeometry.js +1 -1
  38. package/src/app/controls/SimulatorViewControls.js +45 -0
  39. package/src/app/controls/workbenchBindings.js +64 -0
  40. package/src/app/controls/workbenchDisclosure.js +41 -0
  41. package/src/app/domBindings.js +21 -13
  42. package/src/app/gridWalk/GridWalkController.js +180 -0
  43. package/src/app/gridWalk/gridWalkCamera.js +88 -0
  44. package/src/app/gridWalk/gridWalkControls.js +24 -0
  45. package/src/app/gridWalk/gridWalkCrew.js +246 -0
  46. package/src/app/gridWalk/gridWalkLabels.js +104 -0
  47. package/src/app/gridWalk/gridWalkLayout.js +46 -0
  48. package/src/app/gridWalk/gridWalkNavigation.js +106 -0
  49. package/src/app/gridWalk/gridWalkProjectBanner.js +43 -0
  50. package/src/app/gridWalk/gridWalkSession.js +50 -0
  51. package/src/app/layoutSupport.js +2 -0
  52. package/src/app/raceOverlayClearanceSupport.js +21 -7
  53. package/src/app/readouts/carOverviewRenderer.js +9 -4
  54. package/src/app/readouts/readoutFormatters.js +1 -8
  55. package/src/app/readouts/telemetryRenderer.js +1 -3
  56. package/src/app/readouts/timingTowerRenderer.js +48 -50
  57. package/src/app/rendering/appAssets.js +8 -10
  58. package/src/app/rendering/carRenderer.js +27 -79
  59. package/src/app/rendering/displayUtils.js +0 -6
  60. package/src/app/rendering/expertSensorRenderer.js +1 -1
  61. package/src/app/rendering/replayGhostRenderer.js +4 -1
  62. package/src/app/rendering/trackRenderer.js +2 -2
  63. package/src/app/runtime/frameLoop.js +18 -17
  64. package/src/app/runtime/pixiStartupLifecycle.js +94 -0
  65. package/src/app/runtime/runtimeTiming.js +4 -1
  66. package/src/app/runtime/runtimeVisibility.js +7 -2
  67. package/src/config/cssValues.js +1 -1
  68. package/src/config/defaultOptions.js +12 -173
  69. package/src/config/restartOptions.js +1 -0
  70. package/src/config/runtimeSeed.js +10 -0
  71. package/src/config/simulatorDefaults.js +106 -0
  72. package/src/config/telemetryModules.js +20 -0
  73. package/src/config/theme/cssTokens.js +85 -0
  74. package/src/config/theme/themeCss.js +72 -0
  75. package/src/config/theme/themeInputs.js +155 -0
  76. package/src/config/theme/themeResolver.js +172 -0
  77. package/src/config/theme/themeSelectors.js +69 -0
  78. package/src/config/theme/themeTokens.js +257 -0
  79. package/src/config/theme/themeUtils.js +15 -0
  80. package/src/config/themeOptions.js +14 -830
  81. package/src/config/timingGapMode.js +0 -2
  82. package/src/config/uiOptions.js +53 -0
  83. package/src/data/championship.js +16 -158
  84. package/src/data/championshipDefaults.js +87 -0
  85. package/src/data/driverData.js +10 -20
  86. package/src/data/entryIdentity.js +67 -0
  87. package/src/data/entryPresentation.js +23 -0
  88. package/src/data/entryTypes.d.ts +160 -0
  89. package/src/data/index.d.ts +13 -259
  90. package/src/data/ratingScale.js +14 -0
  91. package/src/data/teamTypes.d.ts +23 -0
  92. package/src/data/trackTypes.d.ts +101 -0
  93. package/src/data/vehicleData.js +12 -23
  94. package/src/environment/actions.js +3 -2
  95. package/src/environment/controllerLoop.js +57 -44
  96. package/src/environment/controllerScheduler.js +30 -0
  97. package/src/environment/driverObservations.js +260 -0
  98. package/src/environment/driverReset.js +51 -0
  99. package/src/environment/episode.js +0 -1
  100. package/src/environment/evaluation.js +6 -90
  101. package/src/environment/evaluationTracker.js +100 -0
  102. package/src/environment/events.js +23 -0
  103. package/src/environment/index.d.ts +21 -140
  104. package/src/environment/metrics.js +3 -17
  105. package/src/environment/observationSchema.js +104 -0
  106. package/src/environment/observationVector.js +16 -115
  107. package/src/environment/observations.js +16 -316
  108. package/src/environment/options.js +5 -24
  109. package/src/environment/renderFrames.js +21 -0
  110. package/src/environment/resetOptions.js +41 -0
  111. package/src/environment/results.js +125 -0
  112. package/src/environment/rewards.js +10 -16
  113. package/src/environment/runtime.js +17 -300
  114. package/src/environment/scenarioOptions.js +90 -0
  115. package/src/environment/scenarioPlacements.js +70 -0
  116. package/src/environment/scenarioPresets.js +78 -0
  117. package/src/environment/scenarios.js +5 -236
  118. package/src/environment/scratch.js +18 -0
  119. package/src/environment/sensorOptions.js +20 -0
  120. package/src/environment/sensors/index.js +2 -556
  121. package/src/environment/sensors/indexedRayBands.js +0 -18
  122. package/src/environment/sensors/nearbyCars.js +18 -15
  123. package/src/environment/sensors/opponentRadar.js +3 -2
  124. package/src/environment/sensors/pitConnectorProximity.js +20 -0
  125. package/src/environment/sensors/rayBandTrace.js +1 -37
  126. package/src/environment/sensors/rayBatch.js +81 -0
  127. package/src/environment/sensors/rayConfig.js +26 -0
  128. package/src/environment/sensors/rayGeometry.js +0 -8
  129. package/src/environment/sensors/rayGuards.js +0 -6
  130. package/src/environment/sensors/rayOutputs.js +75 -0
  131. package/src/environment/sensors/rayQuery.js +110 -0
  132. package/src/environment/sensors/rayScratch.js +146 -0
  133. package/src/environment/sensors/sensorTargets.js +12 -4
  134. package/src/environment/sensors/surfaceRays.js +6 -61
  135. package/src/environment/sensors/trackRays.js +7 -80
  136. package/src/environment/simulationSetup.js +30 -0
  137. package/src/environment/specs.js +15 -109
  138. package/src/index.d.ts +34 -259
  139. package/src/index.js +28 -6
  140. package/src/placeholder/index.js +1 -8
  141. package/src/rendering/displayLifecycle.js +6 -0
  142. package/src/rendering/proceduralTrackAsset.js +1 -1
  143. package/src/rendering/renderSnapshot.js +3 -0
  144. package/src/rendering/track/finishLineRenderer.js +1 -1
  145. package/src/rendering/track/gridRenderer.js +1 -1
  146. package/src/rendering/track/offsetStrokeSafety.js +2 -2
  147. package/src/rendering/track/pitLaneRenderer.js +1 -1
  148. package/src/rendering/track/trackMaterialRenderer.js +4 -4
  149. package/src/rendering/track/trackRenderConstants.js +0 -1
  150. package/src/rendering/track/trackRenderGeometry.js +1 -7
  151. package/src/simulation/collisionGeometry.js +5 -11
  152. package/src/simulation/driver/advancedPathControls.js +53 -0
  153. package/src/simulation/driver/advancedRacePace.js +112 -0
  154. package/src/simulation/driver/advancedRacingControls.js +61 -0
  155. package/src/simulation/driver/advancedRacingEnvelope.js +255 -0
  156. package/src/simulation/driver/advancedRacingSteering.js +76 -0
  157. package/src/simulation/driver/advancedRacingTiming.js +16 -0
  158. package/src/simulation/driver/arcadeRacePace.js +83 -0
  159. package/src/simulation/driver/arcadeRacingControls.js +111 -0
  160. package/src/simulation/driver/arcadeRacingEnvelope.js +63 -0
  161. package/src/simulation/driver/driverControlConstants.js +0 -2
  162. package/src/simulation/driver/driverController.js +14 -3
  163. package/src/simulation/driver/edgeRecovery.js +0 -1
  164. package/src/simulation/driver/racingControls.js +10 -406
  165. package/src/simulation/driver/racingDriverProfile.js +48 -0
  166. package/src/simulation/driver/racingLinePlan.js +48 -15
  167. package/src/simulation/driver/rejoinControls.js +58 -111
  168. package/src/simulation/driver/safetyCarControls.js +12 -0
  169. package/src/simulation/driver/trafficScan.js +89 -6
  170. package/src/simulation/pit/pitCarPlacement.js +29 -0
  171. package/src/simulation/pit/pitFlow.js +8 -30
  172. package/src/simulation/pit/pitIntent.js +4 -3
  173. package/src/simulation/pit/pitOccupancy.js +2 -2
  174. package/src/simulation/pit/pitQueue.js +2 -18
  175. package/src/simulation/pit/pitRouteMovement.js +1 -36
  176. package/src/simulation/pit/pitRouting.js +5 -81
  177. package/src/simulation/pit/pitService.js +3 -32
  178. package/src/simulation/pit/pitServiceConstants.js +1 -0
  179. package/src/simulation/pit/pitServiceProgress.js +34 -0
  180. package/src/simulation/pit/pitSnapshots.js +8 -4
  181. package/src/simulation/pit/pitState.js +1 -5
  182. package/src/simulation/race/classification.js +1 -8
  183. package/src/simulation/race/raceFinish.js +3 -8
  184. package/src/simulation/race/raceOrder.js +2 -13
  185. package/src/simulation/race/raceProgress.js +6 -20
  186. package/src/simulation/race/raceSetup.js +1 -1
  187. package/src/simulation/race/raceStep.js +1 -1
  188. package/src/simulation/race/redFlag.js +2 -4
  189. package/src/simulation/race/safetyCar.js +3 -8
  190. package/src/simulation/race/stalledDnf.js +2 -4
  191. package/src/simulation/raceSimulation.js +6 -38
  192. package/src/simulation/replay/replayGhosts.js +44 -32
  193. package/src/simulation/rules/collisionSteward.js +0 -14
  194. package/src/simulation/rules/penaltyActions.js +37 -0
  195. package/src/simulation/rules/penaltyLedger.js +8 -1
  196. package/src/simulation/rules/rulesReview.js +1 -7
  197. package/src/simulation/simMath.js +4 -0
  198. package/src/simulation/snapshots/raceSnapshots.js +30 -11
  199. package/src/simulation/timing/drsState.js +8 -0
  200. package/src/simulation/timing/drsTiming.js +2 -4
  201. package/src/simulation/timing/lapTelemetry.js +7 -4
  202. package/src/simulation/timing/sectorPerformance.js +3 -3
  203. package/src/simulation/timing/timingLines.js +12 -6
  204. package/src/simulation/track/pitLaneAccess.js +2 -2
  205. package/src/simulation/track/pitLaneLayout.js +4 -135
  206. package/src/simulation/track/pitLaneModel.js +70 -0
  207. package/src/simulation/track/pitLaneState.js +0 -1
  208. package/src/simulation/track/pitServiceLayout.js +121 -0
  209. package/src/simulation/track/proceduralTrack.js +77 -0
  210. package/src/simulation/track/spatialQueries.js +1 -25
  211. package/src/simulation/track/trackBuilder.js +54 -0
  212. package/src/simulation/track/trackConstants.js +0 -22
  213. package/src/simulation/track/trackMath.js +4 -29
  214. package/src/simulation/track/trackModel.js +4 -241
  215. package/src/simulation/track/trackModelCache.js +105 -0
  216. package/src/simulation/track/trackNearestQueries.js +1218 -0
  217. package/src/simulation/track/trackQueryConstants.js +3 -0
  218. package/src/simulation/track/trackQueryConstruction.js +368 -0
  219. package/src/simulation/track/trackQueryIndex.js +12 -1933
  220. package/src/simulation/track/trackQuerySegments.js +174 -0
  221. package/src/simulation/track/trackQueryStats.js +12 -0
  222. package/src/simulation/track/trackRayQueries.js +165 -0
  223. package/src/simulation/vehicle/advancedContactResponse.js +89 -0
  224. package/src/simulation/vehicle/advancedTireForces.js +92 -0
  225. package/src/simulation/vehicle/advancedVehicleModel.js +65 -0
  226. package/src/simulation/vehicle/advancedVehiclePhysics.js +127 -0
  227. package/src/simulation/vehicle/contactResolution.js +20 -6
  228. package/src/simulation/vehicle/mainTrackWheelSurface.js +2 -13
  229. package/src/simulation/vehicle/pitWheelSurface.js +0 -12
  230. package/src/simulation/vehicle/runoffResponse.js +2 -4
  231. package/src/simulation/vehicle/sampledWheelSurface.js +266 -0
  232. package/src/simulation/vehicle/vehicleGeometry.js +0 -56
  233. package/src/simulation/vehicle/vehicleKinematics.js +10 -0
  234. package/src/simulation/vehicle/vehiclePhysics.js +35 -194
  235. package/src/simulation/vehicle/vehicleSnapshots.js +13 -4
  236. package/src/simulation/vehicle/vehicleState.js +2 -1
  237. package/src/simulation/vehicle/wheelSurface.js +13 -432
  238. package/src/simulation/vehicle/wheelSurfaceCache.js +55 -0
  239. package/src/simulation/vehicle/wheelSurfaceSummary.js +101 -0
  240. package/src/styles.css +173 -19
  241. package/src/ui/bannerTemplates.js +4 -2
  242. package/src/ui/cameraControlsTemplate.js +4 -2
  243. package/src/ui/gridWalkTemplate.js +29 -0
  244. package/src/ui/htmlEscaping.js +8 -0
  245. package/src/ui/shellTemplate.js +3 -0
  246. package/src/ui/telemetryDrawerTemplate.js +4 -2
  247. package/src/ui/telemetryTemplates.js +3 -22
  248. package/src/ui/templateUtils.js +2 -8
  249. package/src/ui/viewControlsTemplate.js +21 -0
@@ -1,6 +1,7 @@
1
1
  import { Application, Container, Graphics } from 'pixi.js';
2
2
  import { applyPaddockThemeCssVariables } from '../config/defaultOptions.js';
3
3
  import { formatCssUrl } from '../config/cssValues.js';
4
+ import { createRuntimeSeed } from '../config/runtimeSeed.js';
4
5
  import { getNextTimingGapMode, normalizeTimingGapMode } from '../config/timingGapMode.js';
5
6
  import { ProceduralTrackAsset } from '../rendering/proceduralTrackAsset.js';
6
7
  import { interpolateRenderSnapshotInto } from '../rendering/renderSnapshot.js';
@@ -8,7 +9,15 @@ import { createRaceSimulation, FIXED_STEP } from '../simulation/raceSimulation.j
8
9
  import { clamp } from '../simulation/simMath.js';
9
10
  import { WORLD } from '../simulation/track/trackModel.js';
10
11
  import { CameraController } from './camera/cameraController.js';
12
+ import { GridWalkController } from './gridWalk/GridWalkController.js';
13
+ import { bindWorkbenchControls, bindCameraWheelControls } from './controls/workbenchBindings.js';
14
+ import {
15
+ renderTelemetryDrawerDisclosure,
16
+ isTimingPanelDisclosureMode,
17
+ syncTimingPanelDisclosureState,
18
+ } from './controls/workbenchDisclosure.js';
11
19
  import { createBrowserExpertAdapter } from './BrowserExpertAdapter.js';
20
+ import { HostLifecycleEmitter } from './HostLifecycleEmitter.js';
12
21
  import { installLayoutSupport } from './layoutSupport.js';
13
22
  import { installRaceOverlayClearanceSupport } from './raceOverlayClearanceSupport.js';
14
23
  import { querySimulatorDom, resolveNodes } from './domBindings.js';
@@ -27,11 +36,7 @@ import {
27
36
  } from './readouts/timingTowerRenderer.js';
28
37
  import { renderCarDriverOverview } from './readouts/carOverviewRenderer.js';
29
38
  import { renderLapTelemetry, renderTelemetryReadouts } from './readouts/telemetryRenderer.js';
30
- import {
31
- renderRaceFinish,
32
- renderRaceStatusReadouts,
33
- renderStartLights,
34
- } from './readouts/raceStatusRenderer.js';
39
+ import { renderRaceStatusReadouts } from './readouts/raceStatusRenderer.js';
35
40
  import {
36
41
  updateCameraControlButtons,
37
42
  updateModeButtons,
@@ -39,7 +44,6 @@ import {
39
44
  } from './readouts/controlStateRenderer.js';
40
45
  import {
41
46
  createPenaltyStewardMessage,
42
- createWarningStewardMessage,
43
47
  renderActiveStewardMessage,
44
48
  updateStewardMessageState,
45
49
  } from './banners/stewardMessageController.js';
@@ -47,26 +51,21 @@ import {
47
51
  hideRaceDataPanel,
48
52
  getProjectRadioQuote,
49
53
  RACE_DATA_SELECTED_VISIBLE_MS,
50
- RADIO_SCHEDULE_CATCHUP_LIMIT_MS,
51
54
  renderProjectRadio,
52
55
  renderRaceData,
53
56
  resetRaceDataBannerState,
54
57
  scheduleRadioBreak,
55
- scheduleRadioPopup,
56
58
  shouldAutoHideActiveRaceData,
57
59
  getNextRadioBreakTime,
60
+ updateRadioSchedule,
58
61
  } from './banners/raceDataBannerController.js';
59
62
  import { runFrameLoopTick } from './runtime/frameLoop.js';
60
63
  import { observeRuntimeVisibility, syncRuntimeTicker } from './runtime/runtimeVisibility.js';
61
- import { TARGET_FRAME_MS, domUpdateIntervalForSpeed, timingUpdateIntervalForSpeed } from './runtime/runtimeTiming.js';
64
+ import { configureRuntimeTicker, domUpdateIntervalForSpeed, timingUpdateIntervalForSpeed } from './runtime/runtimeTiming.js';
65
+ import { assertRuntimeStartupActive, createPixiStartupLifecycle, RuntimeStartupCancelledError } from './runtime/pixiStartupLifecycle.js';
62
66
  import { normalizeWarmupOptions } from '../simulation/warmup/runtimeWarmup.js';
63
67
 
64
68
  const SIMULATION_SPEED_STEPS = [1, 2, 3, 4, 5, 10];
65
- const DRS_DRAG_REDUCTION_PERCENT = 58;
66
-
67
- function uniqueNodes(nodes = []) {
68
- return [...new Set([...nodes].filter(Boolean))];
69
- }
70
69
 
71
70
  function normalizeRuntimeSnapshot(snapshot, totalLaps) {
72
71
  const source = snapshot && typeof snapshot === 'object' ? snapshot : {};
@@ -102,16 +101,6 @@ function normalizeRuntimeSnapshot(snapshot, totalLaps) {
102
101
  };
103
102
  }
104
103
 
105
- function createMountTrackSeed() {
106
- const values = new Uint32Array(1);
107
- try {
108
- globalThis.crypto?.getRandomValues?.(values);
109
- } catch {
110
- values[0] = 0;
111
- }
112
- return (values[0] || Math.floor(Date.now() + performance.now() * 1000)) >>> 0;
113
- }
114
-
115
104
  function hasOwnOption(options, key) {
116
105
  return Object.hasOwn(options ?? {}, key);
117
106
  }
@@ -120,6 +109,10 @@ function expertOptionsChanged(nextExpertOptions, currentExpertOptions) {
120
109
  return nextExpertOptions !== currentExpertOptions;
121
110
  }
122
111
 
112
+ function timingRaceMode(snapshot) {
113
+ return snapshot?.raceControl?.finished ? 'finished' : snapshot?.raceControl?.mode;
114
+ }
115
+
123
116
  function withSelectedExpertSensorDriver(visualizeSensors, selectedDriverId) {
124
117
  if (!visualizeSensors) return visualizeSensors;
125
118
  if (visualizeSensors === true) return { rays: true, selectedDriverId };
@@ -144,6 +137,7 @@ export class F1SimulatorApp {
144
137
  constructor(root, options) {
145
138
  this.root = root;
146
139
  this.options = options;
140
+ this.hostLifecycle = new HostLifecycleEmitter(() => this.options, () => this.destroyed ? false : this.sim);
147
141
  this.drivers = options.drivers;
148
142
  this.driverById = new Map(this.drivers.map((driver) => [driver.id, driver]));
149
143
  this.assets = options.assets;
@@ -155,6 +149,7 @@ export class F1SimulatorApp {
155
149
  this.syncThemeContext();
156
150
  Object.assign(this, querySimulatorDom(root));
157
151
  this.abortController = new AbortController();
152
+ this.destroyed = false;
158
153
  this.resizeHandler = null;
159
154
  this.layoutResizeObserver = null;
160
155
  this.layoutSupportCleanup = installLayoutSupport(root);
@@ -168,6 +163,8 @@ export class F1SimulatorApp {
168
163
  this.expertMode = Boolean(options.expert?.enabled);
169
164
  this.sim = null;
170
165
  this.app = null;
166
+ this.pixiStartupLifecycle = null;
167
+ this.destroyCleanupErrors = null;
171
168
  this.worldLayer = null;
172
169
  this.trackAsset = null;
173
170
  this.drsLayer = null;
@@ -184,19 +181,24 @@ export class F1SimulatorApp {
184
181
  carSprites: this.carSprites,
185
182
  carHitAreas: this.carHitAreas,
186
183
  serviceCountdownLabels: this.serviceCountdownLabels,
187
- onSelectCar: (driverId) => this.selectCar(driverId, { focus: true }),
184
+ onSelectCar: (driverId, event) => {
185
+ if (this.gridWalk?.active) {
186
+ this.gridWalk.navigation.selectOnRelease(driverId, event);
187
+ return;
188
+ }
189
+ this.selectCar(driverId, { focus: true });
190
+ },
188
191
  });
189
192
  this.drsTrails = new Map();
190
193
  this.drsTrailRenderer = new DrsTrailRenderer({ trails: this.drsTrails });
191
194
  this.replayGhostRenderer = new ReplayGhostRenderer();
192
195
  this.pitLaneStatusRenderer = new PitLaneStatusRenderer();
193
- this.trackSeed = options.trackSeed ?? createMountTrackSeed();
196
+ this.trackSeed = options.trackSeed ?? createRuntimeSeed();
194
197
  this.raceDataBannerConfig = options.ui?.raceDataBanners ?? { initial: 'project', enabled: ['project', 'radio'] };
195
198
  this.raceDataBannersMuted = false;
196
199
  this.penaltyBannerEnabled = Boolean(options.ui?.penaltyBanners);
197
200
  this.timingPenaltyBadgesEnabled = Boolean(options.ui?.timingPenaltyBadges);
198
201
  this.activeRaceDataId = null;
199
- this.activePenaltyBanner = null;
200
202
  this.lastPenaltyBannerId = null;
201
203
  this.activeStewardMessage = null;
202
204
  this.lastStewardMessageKey = null;
@@ -220,7 +222,6 @@ export class F1SimulatorApp {
220
222
  this.simulationSpeed = 1;
221
223
  this.accumulator = 0;
222
224
  this.lastTime = performance.now();
223
- this.nextGameFrameTime = this.lastTime + TARGET_FRAME_MS;
224
225
  this.lastDomUpdateTime = 0;
225
226
  this.lastTimingRenderTime = 0;
226
227
  this.lastTimingRaceMode = null;
@@ -238,9 +239,6 @@ export class F1SimulatorApp {
238
239
  lastSample: this.lastTime,
239
240
  };
240
241
  this.renderSnapshotBuffer = {};
241
- this.lastLeaderLap = null;
242
- this.emittedRaceEventKeys = new Set();
243
- this.raceFinishEmitted = false;
244
242
  this.timingGapMode = normalizeTimingGapMode(options.ui?.timingGapMode);
245
243
  this.telemetryDrawerOpen = Boolean(
246
244
  this.readouts.telemetryDrawerWorkbench?.classList?.contains?.('is-telemetry-open'),
@@ -250,19 +248,30 @@ export class F1SimulatorApp {
250
248
  async init() {
251
249
  this.emitHostCallback('onLoadingChange', { loading: true, phase: 'initializing' });
252
250
  try {
251
+ assertRuntimeStartupActive(this);
253
252
  if (!this.canvasHost) {
254
253
  throw new Error('F1 simulator canvas host is missing.');
255
254
  }
256
255
 
257
256
  this.sim = this.createRaceSimulation();
258
- this.app = new Application();
259
- await this.app.init({
257
+ const application = new Application();
258
+ const lifecycleEmitter = this.hostLifecycle;
259
+ const startup = createPixiStartupLifecycle(application, (error) => {
260
+ if (this.destroyCleanupErrors) this.destroyCleanupErrors.push(error);
261
+ else lifecycleEmitter?.emitCallback('onError', error, { phase: 'destroy' });
262
+ });
263
+ this.app = application;
264
+ this.pixiStartupLifecycle = startup;
265
+ await startup.initialize({
260
266
  resizeTo: this.canvasHost,
267
+ autoStart: false,
261
268
  antialias: true,
262
269
  autoDensity: true,
263
270
  resolution: Math.min(window.devicePixelRatio || 1, 2),
264
271
  backgroundAlpha: 0,
265
272
  });
273
+ assertRuntimeStartupActive(this);
274
+ configureRuntimeTicker(application.ticker);
266
275
  this.canvasHost.appendChild(this.app.canvas);
267
276
 
268
277
  this.worldLayer = new Container();
@@ -274,7 +283,9 @@ export class F1SimulatorApp {
274
283
  this.carLayer = new Container();
275
284
  this.app.stage.addChild(this.worldLayer);
276
285
 
277
- await this.loadAssets();
286
+ const textures = await this.loadAssets();
287
+ assertRuntimeStartupActive(this);
288
+ this.textures = textures;
278
289
  this.trackAsset = new ProceduralTrackAsset({ textures: this.textures, world: WORLD });
279
290
  this.worldLayer.addChild(
280
291
  this.trackAsset.container,
@@ -286,6 +297,10 @@ export class F1SimulatorApp {
286
297
  this.carLayer,
287
298
  );
288
299
  this.createCars();
300
+ if (this.options.gridWalk?.enabled) {
301
+ this.gridWalk = new GridWalkController(this);
302
+ this.gridWalk.sync({ updateTicker: false });
303
+ }
289
304
  this.bindControls();
290
305
  this.syncTimingPanelDisclosureState();
291
306
  this.renderTrack();
@@ -298,7 +313,9 @@ export class F1SimulatorApp {
298
313
  this.renderInitialFrame(snapshot);
299
314
  this.completeComponentLoading();
300
315
  this.emitHostCallback('onLoadingChange', { loading: false, phase: 'ready' });
316
+ assertRuntimeStartupActive(this);
301
317
  this.emitHostCallback('onReady', { snapshot });
318
+ assertRuntimeStartupActive(this);
302
319
  this.resizeHandler = () => {
303
320
  this.syncTimingPanelDisclosureState();
304
321
  this.renderSourceSnapshotBuffer ??= {};
@@ -315,27 +332,18 @@ export class F1SimulatorApp {
315
332
  this.observeRuntimeVisibility();
316
333
  }
317
334
  } catch (error) {
318
- this.emitHostCallback('onLoadingChange', { loading: false, phase: 'error' });
319
- this.emitHostCallback('onError', error, { phase: 'init' });
335
+ const cancelled = this.destroyed;
336
+ if (!(error instanceof RuntimeStartupCancelledError) && !this.destroyed) {
337
+ this.emitHostCallback('onLoadingChange', { loading: false, phase: 'error' });
338
+ this.emitHostCallback('onError', error, { phase: 'init' });
339
+ }
320
340
  this.destroy();
321
- throw error;
341
+ throw cancelled ? new RuntimeStartupCancelledError() : error;
322
342
  }
323
343
  }
324
344
 
325
345
  emitHostCallback(name, ...args) {
326
- const callback = this.options?.[name];
327
- if (typeof callback !== 'function') return;
328
- try {
329
- callback(...args);
330
- } catch (error) {
331
- if (name !== 'onError' && typeof this.options?.onError === 'function') {
332
- try {
333
- this.options.onError(error, { callback: name });
334
- } catch {
335
- // Host callbacks should not break the simulator runtime.
336
- }
337
- }
338
- }
346
+ this.hostLifecycle?.emitCallback(name, ...args);
339
347
  }
340
348
 
341
349
  completeComponentLoading() {
@@ -362,7 +370,7 @@ export class F1SimulatorApp {
362
370
  }
363
371
 
364
372
  async loadAssets() {
365
- this.textures = await loadAppTextures(this.assets);
373
+ return loadAppTextures(this.assets);
366
374
  }
367
375
 
368
376
  createCars() {
@@ -376,106 +384,54 @@ export class F1SimulatorApp {
376
384
 
377
385
  bindControls() {
378
386
  const eventOptions = { signal: this.abortController.signal };
379
-
380
- this.safetyButtons.forEach((button) => {
381
- button.addEventListener('click', () => {
387
+ bindWorkbenchControls({
388
+ safetyButtons: this.safetyButtons,
389
+ restartButton: this.restartButton,
390
+ restartButtons: this.restartButtons,
391
+ cameraButtons: this.cameraButtons,
392
+ zoomInButton: this.zoomInButton,
393
+ zoomInButtons: this.zoomInButtons,
394
+ zoomOutButton: this.zoomOutButton,
395
+ zoomOutButtons: this.zoomOutButtons,
396
+ simulationSpeedButtons: this.simulationSpeedButtons,
397
+ bannerMuteButtons: this.bannerMuteButtons,
398
+ overviewModeButtons: this.overviewModeButtons,
399
+ timingGapModeButtons: this.timingGapModeButtons,
400
+ timingLists: this.timingLists,
401
+ timingPanelToggles: this.readouts.timingPanelToggles,
402
+ raceDataOpens: this.readouts.raceDataOpens,
403
+ openButton: this.openButton,
404
+ raceDataDismisses: this.readouts.raceDataDismisses,
405
+ raceDataDismiss: this.readouts.raceDataDismiss,
406
+ telemetryDrawerToggle: this.readouts.telemetryDrawerToggle,
407
+ }, {
408
+ toggleSafetyCar: () => {
382
409
  const next = this.sim.snapshot().raceControl.mode !== 'safety-car';
383
410
  this.setSafetyCarDeployed(next);
384
- }, eventOptions);
385
- });
386
-
387
- this.restartButton?.addEventListener('click', () => {
388
- this.sim = this.createRaceSimulation();
389
- this.selectedId = this.drivers[0]?.id ?? null;
390
- this.resetRaceDataBannerState(performance.now());
391
- this.syncSafetyCarControls(false);
392
- this.drsTrails.clear();
393
- this.trailLayer?.clear();
394
- this.lastTimingRenderTime = 0;
395
- this.lastTimingRaceMode = null;
396
- this.lastTimingPenaltyKey = '';
397
- this.lastTimingOrderKey = '';
398
- this.renderTrack();
399
- }, eventOptions);
400
-
401
- this.cameraButtons.forEach((button) => {
402
- button.addEventListener('click', () => {
403
- this.setCameraMode(button.dataset.cameraMode);
404
- }, eventOptions);
405
- });
406
-
407
- this.zoomInButton?.addEventListener('click', () => {
408
- this.adjustCameraZoom(1);
409
- }, eventOptions);
410
-
411
- this.zoomOutButton?.addEventListener('click', () => {
412
- this.adjustCameraZoom(-1);
413
- }, eventOptions);
414
-
415
- this.simulationSpeedButtons?.forEach((button) => {
416
- button.addEventListener('click', () => {
417
- this.cycleSimulationSpeed();
418
- }, eventOptions);
419
- });
420
- this.updateSimulationSpeedControls();
421
-
422
- this.bannerMuteButtons?.forEach((button) => {
423
- button.addEventListener('click', () => {
411
+ },
412
+ restart: () => this.restart(),
413
+ setCameraMode: (mode) => this.setCameraMode(mode),
414
+ adjustCameraZoom: (direction) => this.adjustCameraZoom(direction),
415
+ cycleSimulationSpeed: () => this.cycleSimulationSpeed(),
416
+ updateSimulationSpeedControls: () => this.updateSimulationSpeedControls(),
417
+ toggleRaceDataBanners: () => {
424
418
  this.setRaceDataBannersMuted(!this.raceDataBannersMuted);
425
- }, eventOptions);
426
- });
427
-
428
- this.overviewModeButtons.forEach((button) => {
429
- button.addEventListener('click', () => {
430
- this.overviewMode = button.dataset.overviewMode === 'driver' ? 'driver' : 'vehicle';
419
+ },
420
+ setOverviewMode: (mode) => {
421
+ this.overviewMode = mode;
431
422
  const selected = this.sim?.snapshot().cars.find((car) => car.id === this.selectedId);
432
423
  this.renderCarDriverOverview(selected);
433
- }, eventOptions);
434
- });
435
-
436
- this.timingGapModeButtons.forEach((button) => {
437
- button.addEventListener('click', () => {
438
- this.toggleTimingGapMode();
439
- }, eventOptions);
440
- });
441
-
442
- this.timingLists?.forEach?.((timingList) => {
443
- timingList.addEventListener?.('pointerdown', (event) => {
444
- const row = event.target instanceof Element ? event.target.closest('[data-driver-id]') : null;
445
- if (!row) return;
446
- this.selectCar(row.dataset.driverId, { focus: true });
447
- }, eventOptions);
448
- });
449
-
450
- this.readouts.timingPanelToggles?.forEach?.((toggle) => {
451
- toggle.addEventListener?.('click', () => {
452
- const racePanel = toggle.closest?.('.sim-canvas-panel');
453
- this.setTimingPanelOpen(!racePanel?.classList?.contains?.('is-timing-panel-open'), racePanel);
454
- }, eventOptions);
455
- });
456
-
457
- const raceDataOpenButtons = uniqueNodes(this.readouts.raceDataOpens?.length
458
- ? this.readouts.raceDataOpens
459
- : [this.openButton]);
460
- raceDataOpenButtons.forEach((button) => button.addEventListener('click', () => {
461
- const driver = this.driverById.get(this.activeRaceDataId ?? this.selectedId);
462
- if (!driver) return;
463
- this.options.onDriverOpen?.(driver);
464
- }, eventOptions));
465
-
466
- const raceDataDismissButtons = uniqueNodes(this.readouts.raceDataDismisses?.length
467
- ? this.readouts.raceDataDismisses
468
- : [this.readouts.raceDataDismiss]);
469
- raceDataDismissButtons.forEach((button) => button.addEventListener('click', (event) => {
470
- event.preventDefault();
471
- event.stopPropagation();
472
- this.dismissRaceDataPanel(performance.now());
473
- }, eventOptions));
474
-
475
- this.readouts.telemetryDrawerToggle?.addEventListener('click', () => {
476
- this.setTelemetryDrawerOpen(!this.telemetryDrawerOpen);
477
- }, eventOptions);
478
-
424
+ },
425
+ toggleTimingGapMode: () => this.toggleTimingGapMode(),
426
+ selectCar: (id) => this.selectCar(id, { focus: true }),
427
+ setTimingPanelOpen: (open, panel) => this.setTimingPanelOpen(open, panel),
428
+ openDriver: () => {
429
+ const driver = this.driverById.get(this.activeRaceDataId ?? this.selectedId);
430
+ if (driver) this.emitHostCallback('onDriverOpen', driver);
431
+ },
432
+ dismissRaceDataPanel: (now) => this.dismissRaceDataPanel(now),
433
+ toggleTelemetryDrawer: () => this.setTelemetryDrawerOpen(!this.telemetryDrawerOpen),
434
+ }, eventOptions.signal);
479
435
  this.bindCameraWheelControls(eventOptions);
480
436
  }
481
437
 
@@ -511,31 +467,20 @@ export class F1SimulatorApp {
511
467
  }
512
468
 
513
469
  bindCameraWheelControls(eventOptions) {
514
- if (!this.canvasHost?.addEventListener) return;
515
- this.canvasHost.addEventListener('wheel', (event) => {
516
- event.preventDefault?.();
517
- this.adjustCameraZoom(event.deltaY > 0 ? -1 : 1);
518
- }, { ...eventOptions, passive: false });
470
+ bindCameraWheelControls(this.canvasHost, (direction) => {
471
+ const before = this.camera.zoom;
472
+ return this.adjustCameraZoom(direction) !== before;
473
+ }, eventOptions,
474
+ () => !this.gridWalk?.active);
519
475
  }
520
476
 
521
477
  setTelemetryDrawerOpen(open) {
522
478
  this.telemetryDrawerOpen = Boolean(open);
523
- this.readouts.telemetryDrawerWorkbench?.classList?.toggle?.('is-telemetry-open', this.telemetryDrawerOpen);
524
- if (this.telemetryDrawerOpen) {
525
- this.readouts.telemetryDrawer?.removeAttribute?.('inert');
526
- this.readouts.telemetryDrawer?.setAttribute?.('aria-hidden', 'false');
527
- } else {
528
- this.readouts.telemetryDrawer?.setAttribute?.('inert', '');
529
- this.readouts.telemetryDrawer?.setAttribute?.('aria-hidden', 'true');
530
- }
531
- this.readouts.telemetryDrawerToggle?.setAttribute?.('aria-expanded', String(this.telemetryDrawerOpen));
532
- if (this.readouts.telemetryDrawerToggle) {
533
- this.readouts.telemetryDrawerToggle.textContent = this.telemetryDrawerOpen ? 'Close' : 'Telemetry';
534
- this.readouts.telemetryDrawerToggle.setAttribute(
535
- 'aria-label',
536
- this.telemetryDrawerOpen ? 'Close telemetry' : 'Open telemetry',
537
- );
538
- }
479
+ renderTelemetryDrawerDisclosure({
480
+ workbench: this.readouts.telemetryDrawerWorkbench,
481
+ drawer: this.readouts.telemetryDrawer,
482
+ toggle: this.readouts.telemetryDrawerToggle,
483
+ }, this.telemetryDrawerOpen);
539
484
  this.invalidateCameraSafeArea();
540
485
  this.syncRendererToCurrentLayout({ render: true });
541
486
  this.scheduleLayoutResizeSync(380);
@@ -550,29 +495,15 @@ export class F1SimulatorApp {
550
495
  }
551
496
 
552
497
  isTimingPanelDisclosureMode(toggle = this.readouts.timingPanelToggle) {
553
- if (!toggle) return false;
554
- const style = getComputedStyle(toggle);
555
- return style.display !== 'none' && style.visibility !== 'hidden';
498
+ return isTimingPanelDisclosureMode(toggle);
556
499
  }
557
500
 
558
501
  syncTimingPanelDisclosureState(targetPanel = null) {
559
- const toggles = [...(this.readouts.timingPanelToggles ?? [])];
560
- for (const toggle of toggles) {
561
- const racePanel = toggle.closest?.('.sim-canvas-panel');
562
- if (targetPanel && racePanel !== targetPanel) continue;
563
- const tower = racePanel?.querySelector?.('[data-timing-tower]');
564
- const disclosureMode = this.isTimingPanelDisclosureMode(toggle);
565
- const open = disclosureMode && Boolean(racePanel?.classList?.contains?.('is-timing-panel-open'));
566
- racePanel?.classList?.toggle?.('is-timing-panel-open', open);
567
- if (!disclosureMode || open) {
568
- tower?.removeAttribute?.('inert');
569
- tower?.setAttribute?.('aria-hidden', 'false');
570
- } else {
571
- tower?.setAttribute?.('inert', '');
572
- tower?.setAttribute?.('aria-hidden', 'true');
573
- }
574
- toggle.setAttribute?.('aria-expanded', String(open));
575
- }
502
+ syncTimingPanelDisclosureState({
503
+ toggles: this.readouts.timingPanelToggles,
504
+ targetPanel,
505
+ isDisclosureMode: (toggle) => this.isTimingPanelDisclosureMode(toggle),
506
+ });
576
507
  }
577
508
 
578
509
  setRaceDataBannersMuted(muted, now = performance.now()) {
@@ -586,6 +517,7 @@ export class F1SimulatorApp {
586
517
  this.resetRaceDataBannerState(now);
587
518
  }
588
519
  this.updateBannerMuteControls();
520
+ if (this.gridWalk?.active) this.gridWalk.sync();
589
521
  }
590
522
 
591
523
  resizeRendererToCanvasHost() {
@@ -606,6 +538,11 @@ export class F1SimulatorApp {
606
538
  }
607
539
 
608
540
  syncRendererToCurrentLayout({ render = false } = {}) {
541
+ if (this.gridWalk?.active) {
542
+ this.gridWalk.render();
543
+ return;
544
+ }
545
+ this.gridWalk?.syncLayout();
609
546
  this.resizeRendererToCanvasHost();
610
547
  this.renderSourceSnapshotBuffer ??= {};
611
548
  const snapshot = this.sim?.snapshotRenderInto?.(this.renderSourceSnapshotBuffer) ?? this.sim?.snapshotRender?.() ?? this.sim?.snapshot?.();
@@ -626,10 +563,15 @@ export class F1SimulatorApp {
626
563
 
627
564
  scheduleLayoutResizeSync(durationMs = 360) {
628
565
  if (typeof requestAnimationFrame !== 'function') return;
629
- const startedAt = performance.now();
566
+ this.layoutResizeSyncUntil = Math.max(
567
+ this.layoutResizeSyncUntil ?? 0,
568
+ performance.now() + durationMs,
569
+ );
570
+ if (this.layoutResizeFrame != null) return;
630
571
  const run = (timestamp = performance.now()) => {
572
+ this.layoutResizeFrame = null;
631
573
  this.syncRendererToCurrentLayout({ render: true });
632
- if (timestamp - startedAt < durationMs) {
574
+ if (timestamp < this.layoutResizeSyncUntil) {
633
575
  this.layoutResizeFrame = requestAnimationFrame(run);
634
576
  }
635
577
  };
@@ -664,7 +606,6 @@ export class F1SimulatorApp {
664
606
  resetFrameClock(now = performance.now()) {
665
607
  this.accumulator = 0;
666
608
  this.lastTime = now;
667
- this.nextGameFrameTime = now + TARGET_FRAME_MS;
668
609
  this.fps.current = 0;
669
610
  this.fps.frames = 0;
670
611
  this.fps.lastSample = now;
@@ -675,7 +616,10 @@ export class F1SimulatorApp {
675
616
  }
676
617
 
677
618
  renderExpertFrame(snapshot = this.sim?.snapshot(), { forceDomUpdate = false, observation } = {}) {
678
- if (!snapshot) return;
619
+ if (this.destroyed || !snapshot) return;
620
+ const simulation = this.sim;
621
+ this.emitSnapshotLifecycle(snapshot);
622
+ if (this.destroyed || this.sim !== simulation) return;
679
623
  const now = performance.now();
680
624
  this.sampleFps(now);
681
625
  const renderSnapshot = interpolateRenderSnapshotInto(this.renderSnapshotBuffer, snapshot, 0);
@@ -709,6 +653,7 @@ export class F1SimulatorApp {
709
653
  }
710
654
 
711
655
  renderCars(snapshot) {
656
+ if (this.gridWalk?.active) snapshot = this.gridWalk.session.snapshot;
712
657
  this.replayGhostRenderer.render(snapshot, {
713
658
  textures: this.textures,
714
659
  replayGhostLayer: this.replayGhostLayer,
@@ -716,13 +661,10 @@ export class F1SimulatorApp {
716
661
  this.carRenderer.renderCars(snapshot, {
717
662
  textures: this.textures,
718
663
  carLayer: this.carLayer,
664
+ snapToPose: Boolean(this.gridWalk?.active),
719
665
  });
720
666
  }
721
667
 
722
- renderServiceCountdownLabel(car) {
723
- this.carRenderer.renderServiceCountdownLabel(car);
724
- }
725
-
726
668
  renderExpertSensorRays(snapshot, observation) {
727
669
  renderExpertSensorRays({
728
670
  snapshot,
@@ -740,9 +682,14 @@ export class F1SimulatorApp {
740
682
  this.drsTrailRenderer.render(snapshot, this.trailLayer);
741
683
  }
742
684
 
743
- applyCamera(snapshot, { immediate = false } = {}) {
685
+ applyCamera(snapshot, { immediate = false, elapsedMs } = {}) {
686
+ if (this.gridWalk?.active) {
687
+ this.gridWalk.applyCamera();
688
+ return;
689
+ }
744
690
  this.cameraController.applyToWorldLayer(this.worldLayer, snapshot, {
745
691
  immediate,
692
+ elapsedMs,
746
693
  selectedId: this.selectedId,
747
694
  });
748
695
  }
@@ -771,11 +718,9 @@ export class F1SimulatorApp {
771
718
  return this.cameraController.getPitBounds(pitLane);
772
719
  }
773
720
 
774
- getPitCameraFrame(pitLane, height, baseScale, safeArea, screenCenterX, minimumScale = null) {
775
- return this.cameraController.getPitFrame(pitLane, height, baseScale, safeArea, screenCenterX, minimumScale);
776
- }
777
-
778
721
  updateDom(snapshot, { emitLifecycle = true } = {}) {
722
+ if (this.destroyed) return;
723
+ const simulation = this.sim;
779
724
  snapshot = normalizeRuntimeSnapshot(snapshot, this.totalLaps);
780
725
  const leader = snapshot.cars[0];
781
726
  const selected = snapshot.cars.find((car) => car.id === this.selectedId) ?? leader;
@@ -784,6 +729,7 @@ export class F1SimulatorApp {
784
729
  const now = performance.now();
785
730
 
786
731
  if (emitLifecycle) this.emitSnapshotLifecycle(snapshot);
732
+ if (this.destroyed || this.sim !== simulation) return;
787
733
  this.syncThemeContext(snapshot);
788
734
 
789
735
  if (
@@ -810,18 +756,19 @@ export class F1SimulatorApp {
810
756
 
811
757
  this.updateCameraControls(snapshot);
812
758
  this.syncTimingGapModeControls();
759
+ const currentTimingRaceMode = timingRaceMode(snapshot);
813
760
  const timingPenaltyKey = this.getTimingPenaltyKey(snapshot.penalties ?? []);
814
761
  const timingOrderKey = this.getTimingOrderKey(snapshot.cars);
815
762
  const timingUpdateInterval = timingUpdateIntervalForSpeed(this.simulationSpeed);
816
763
  if (
817
764
  now - this.lastTimingRenderTime >= timingUpdateInterval ||
818
- this.lastTimingRaceMode !== snapshot.raceControl.mode ||
765
+ this.lastTimingRaceMode !== currentTimingRaceMode ||
819
766
  this.lastTimingPenaltyKey !== timingPenaltyKey ||
820
767
  this.lastTimingOrderKey !== timingOrderKey
821
768
  ) {
822
- this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
769
+ this.renderTiming(snapshot.cars, currentTimingRaceMode, snapshot.penalties ?? []);
823
770
  this.lastTimingRenderTime = now;
824
- this.lastTimingRaceMode = snapshot.raceControl.mode;
771
+ this.lastTimingRaceMode = currentTimingRaceMode;
825
772
  this.lastTimingPenaltyKey = timingPenaltyKey;
826
773
  this.lastTimingOrderKey = timingOrderKey;
827
774
  }
@@ -838,48 +785,11 @@ export class F1SimulatorApp {
838
785
  }
839
786
 
840
787
  emitSnapshotLifecycle(snapshot) {
841
- const leader = snapshot.cars[0];
842
- const leaderLap = leader?.lap;
843
- if (Number.isFinite(leaderLap)) {
844
- if (this.lastLeaderLap != null && leaderLap !== this.lastLeaderLap) {
845
- this.emitHostCallback('onLapChange', {
846
- previousLeaderLap: this.lastLeaderLap,
847
- leaderLap,
848
- leader,
849
- snapshot,
850
- });
851
- }
852
- this.lastLeaderLap = leaderLap;
853
- }
854
-
855
- this.emitRaceEvents(snapshot.events, snapshot);
856
-
857
- if (snapshot.raceControl.finished && !this.raceFinishEmitted) {
858
- this.raceFinishEmitted = true;
859
- this.emitHostCallback('onRaceFinish', {
860
- winner: snapshot.raceControl.winner,
861
- classification: snapshot.raceControl.classification ?? [],
862
- snapshot,
863
- });
864
- }
788
+ this.hostLifecycle?.emitSnapshot(snapshot);
865
789
  }
866
790
 
867
791
  emitRaceEvents(events = [], snapshot) {
868
- events.forEach((event) => {
869
- const key = `${event.type}:${event.at ?? snapshot.time}:${event.carId ?? ''}:${event.otherCarId ?? ''}:${event.winnerId ?? ''}`;
870
- if (this.emittedRaceEventKeys.has(key)) return;
871
- this.emittedRaceEventKeys.add(key);
872
- this.emitHostCallback('onRaceEvent', event, snapshot);
873
- });
874
- }
875
-
876
- renderRaceFinish(snapshot) {
877
- this.lastFinishClassificationMarkup = renderRaceFinish({
878
- readouts: this.readouts,
879
- snapshot,
880
- driverById: this.driverById,
881
- lastFinishClassificationMarkup: this.lastFinishClassificationMarkup,
882
- });
792
+ this.hostLifecycle?.emitRaceEvents(events, snapshot);
883
793
  }
884
794
 
885
795
  sampleFps(now) {
@@ -891,25 +801,29 @@ export class F1SimulatorApp {
891
801
  this.fps.lastSample = now;
892
802
  }
893
803
 
894
- renderStartLights(raceControl) {
895
- renderStartLights(this.readouts, this.startLightNodes, raceControl);
896
- }
897
-
898
804
  selectCar(id, { focus = false } = {}) {
805
+ if (!this.driverById.has(id)) return;
899
806
  this.selectedId = id;
900
807
  this.activeRaceDataId = this.isRaceDataBannerEnabled('project') ? id : null;
901
808
  this.lastRaceDataInteraction = performance.now();
902
809
  if (this.isRaceDataBannerEnabled('radio')) this.scheduleRadioBreak(this.lastRaceDataInteraction);
903
- if (focus) {
810
+ if (focus && !this.gridWalk?.active) {
904
811
  this.setCameraMode('selected');
905
812
  }
906
813
  this.lastTimingRenderTime = 0;
907
- const snapshot = this.sim.snapshot();
814
+ const simulation = this.sim;
815
+ const snapshot = simulation.snapshot();
908
816
  this.updateDom(snapshot);
817
+ if (this.destroyed || this.sim !== simulation) return;
909
818
  const driver = this.driverById.get(id);
910
819
  if (driver) this.emitHostCallback('onDriverSelect', driver, snapshot);
820
+ if (!this.destroyed && this.sim === simulation) this.gridWalk?.select(id, { focus });
911
821
  }
912
822
 
823
+ setView(view) { return this.gridWalk?.setView(view) ?? false; }
824
+
825
+ getView() { return this.gridWalk?.session.view ?? 'race'; }
826
+
913
827
  renderTiming(cars, raceMode, penalties = []) {
914
828
  this.syncTimingGapModeControls();
915
829
  this.lastTimingMarkup = renderTimingTowers({
@@ -943,7 +857,7 @@ export class F1SimulatorApp {
943
857
  this.lastTimingRenderTime = 0;
944
858
  this.syncTimingGapModeControls();
945
859
  const snapshot = this.sim?.snapshot?.();
946
- if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
860
+ if (snapshot) this.renderTiming(snapshot.cars, timingRaceMode(snapshot), snapshot.penalties ?? []);
947
861
  return this.timingGapMode;
948
862
  }
949
863
 
@@ -977,7 +891,6 @@ export class F1SimulatorApp {
977
891
  renderTelemetryReadouts({
978
892
  readouts: this.readouts,
979
893
  car,
980
- driverById: this.driverById,
981
894
  });
982
895
  this.renderCarDriverOverview(car);
983
896
  this.renderLapTelemetry(car.lapTelemetry);
@@ -1072,51 +985,24 @@ export class F1SimulatorApp {
1072
985
  return createPenaltyStewardMessage(penalty, this.driverById);
1073
986
  }
1074
987
 
1075
- createWarningStewardMessage(event) {
1076
- return createWarningStewardMessage(event, this.driverById);
1077
- }
1078
-
1079
988
  renderActiveStewardMessage() {
1080
989
  renderActiveStewardMessage(this.readouts, this.activeStewardMessage);
1081
990
  }
1082
991
 
1083
992
  updateRadioSchedule(now) {
1084
- if (!this.isRaceDataBannerEnabled('radio')) {
1085
- this.radioState.visible = false;
1086
- this.radioState.nextChangeAt = Number.POSITIVE_INFINITY;
1087
- return;
1088
- }
1089
- if (
1090
- Number.isFinite(this.radioState.nextChangeAt) &&
1091
- now - this.radioState.nextChangeAt > RADIO_SCHEDULE_CATCHUP_LIMIT_MS
1092
- ) {
1093
- if (this.radioState.visible) this.scheduleRadioBreak(now);
1094
- else this.scheduleRadioPopup(now);
1095
- return;
1096
- }
1097
- while (now >= this.radioState.nextChangeAt) {
1098
- if (this.radioState.visible) {
1099
- this.scheduleRadioBreak(this.radioState.nextChangeAt);
1100
- } else {
1101
- this.scheduleRadioPopup(this.radioState.nextChangeAt);
1102
- }
1103
- }
1104
- }
1105
-
1106
- scheduleRadioBreak(now) {
1107
- scheduleRadioBreak({
993
+ updateRadioSchedule({
1108
994
  radioState: this.radioState,
1109
995
  now,
996
+ drivers: this.drivers,
1110
997
  isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
1111
998
  nextRandom: () => this.nextRadioRandom(),
1112
999
  });
1113
1000
  }
1114
1001
 
1115
- scheduleRadioPopup(now) {
1116
- scheduleRadioPopup({
1002
+ scheduleRadioBreak(now) {
1003
+ scheduleRadioBreak({
1117
1004
  radioState: this.radioState,
1118
1005
  now,
1119
- drivers: this.drivers,
1120
1006
  isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
1121
1007
  nextRandom: () => this.nextRadioRandom(),
1122
1008
  });
@@ -1245,7 +1131,7 @@ export class F1SimulatorApp {
1245
1131
  this.totalLaps = this.options.totalLaps;
1246
1132
  this.assets = this.options.assets;
1247
1133
  if (hasOwnOption(nextOptions, 'trackSeed')) {
1248
- this.trackSeed = this.options.trackSeed ?? createMountTrackSeed();
1134
+ this.trackSeed = this.options.trackSeed ?? createRuntimeSeed();
1249
1135
  }
1250
1136
  if (nextOptions.drivers) {
1251
1137
  this.drivers = nextOptions.drivers;
@@ -1276,6 +1162,10 @@ export class F1SimulatorApp {
1276
1162
  this.lastThemeContextKey = null;
1277
1163
  this.syncThemeContext();
1278
1164
  this.sim = this.createRaceSimulation();
1165
+ this.gridWalk?.destroy();
1166
+ this.gridWalk = null;
1167
+ this.gridWalk = this.options.gridWalk?.enabled ? new GridWalkController(this) : null;
1168
+ this.gridWalk?.sync();
1279
1169
  if (this.expertMode) {
1280
1170
  this.expert = createBrowserExpertAdapter(this, this.options.expert);
1281
1171
  }
@@ -1287,18 +1177,19 @@ export class F1SimulatorApp {
1287
1177
  this.lastTimingRaceMode = null;
1288
1178
  this.lastTimingPenaltyKey = '';
1289
1179
  this.lastTimingOrderKey = '';
1290
- this.activePenaltyBanner = null;
1291
1180
  this.lastPenaltyBannerId = null;
1292
1181
  this.activeStewardMessage = null;
1293
1182
  this.lastStewardMessageKey = null;
1294
- this.lastLeaderLap = null;
1295
- this.emittedRaceEventKeys.clear();
1296
- this.raceFinishEmitted = false;
1183
+ this.hostLifecycle.reset();
1297
1184
  this.renderTrack();
1298
1185
  const snapshot = this.sim.snapshot();
1299
1186
  this.updateDom(snapshot);
1300
1187
  this.lastDomUpdateTime = performance.now();
1188
+ // Restart may create fresh surfaces around the already-ready renderer.
1189
+ // Complete their loading state before measuring the final display frame.
1190
+ this.completeComponentLoading();
1301
1191
  this.renderInitialFrame(snapshot);
1192
+ this.syncRuntimeTicker();
1302
1193
  }
1303
1194
 
1304
1195
  setSafetyCarDeployed(deployed) {
@@ -1348,34 +1239,88 @@ export class F1SimulatorApp {
1348
1239
  }
1349
1240
 
1350
1241
  destroy() {
1351
- this.abortController.abort();
1242
+ if (this.destroyed) return;
1243
+ this.destroyed = true;
1244
+ const lifecycleEmitter = this.hostLifecycle;
1245
+ const cleanupErrors = [];
1246
+ this.destroyCleanupErrors = cleanupErrors;
1247
+ const cleanup = (run) => {
1248
+ try { run(); } catch (error) { cleanupErrors.push(error); }
1249
+ };
1250
+ cleanup(() => this.gridWalk?.destroy());
1251
+ this.gridWalk = null;
1252
+ cleanup(() => this.abortController?.abort?.());
1352
1253
  if (this.layoutResizeFrame && typeof cancelAnimationFrame === 'function') {
1353
- cancelAnimationFrame(this.layoutResizeFrame);
1254
+ cleanup(() => cancelAnimationFrame(this.layoutResizeFrame));
1354
1255
  }
1355
1256
  this.layoutResizeFrame = null;
1257
+ this.layoutResizeSyncUntil = 0;
1356
1258
  if (this.runtimeVisibilitySyncFrame && typeof cancelAnimationFrame === 'function') {
1357
- cancelAnimationFrame(this.runtimeVisibilitySyncFrame);
1259
+ cleanup(() => cancelAnimationFrame(this.runtimeVisibilitySyncFrame));
1358
1260
  }
1359
1261
  this.runtimeVisibilitySyncFrame = null;
1360
- this.layoutResizeObserver?.disconnect?.();
1262
+ cleanup(() => this.layoutResizeObserver?.disconnect?.());
1361
1263
  this.layoutResizeObserver = null;
1362
- this.layoutSupportCleanup?.();
1264
+ cleanup(() => this.layoutSupportCleanup?.());
1363
1265
  this.layoutSupportCleanup = null;
1364
- this.raceOverlayClearanceCleanup?.();
1266
+ cleanup(() => this.raceOverlayClearanceCleanup?.());
1365
1267
  this.raceOverlayClearanceCleanup = null;
1366
- this.visibilityObserver?.disconnect?.();
1268
+ cleanup(() => this.visibilityObserver?.disconnect?.());
1367
1269
  this.visibilityObserver = null;
1368
1270
  if (this.app && this.tickerCallback) {
1369
- this.app.ticker.remove(this.tickerCallback);
1271
+ cleanup(() => this.app.ticker.remove(this.tickerCallback));
1370
1272
  }
1371
- this.expert?.destroy?.();
1273
+ cleanup(() => this.expert?.destroy?.());
1372
1274
  this.expert = null;
1373
- this.replayGhostRenderer.destroy();
1374
- this.app?.destroy(true, { children: true, texture: false });
1275
+ cleanup(() => this.replayGhostRenderer?.destroy?.());
1276
+ if (this.pixiStartupLifecycle) cleanup(() => this.pixiStartupLifecycle.destroy());
1277
+ else cleanup(() => this.app?.destroy(true, { children: true, texture: false }));
1278
+ this.pixiStartupLifecycle = null;
1375
1279
  this.app = null;
1376
1280
  this.carSprites.clear();
1377
1281
  this.carHitAreas.clear();
1378
1282
  this.serviceCountdownLabels.clear();
1379
1283
  this.drsTrails.clear();
1284
+ this.sim = null;
1285
+ this.root = null;
1286
+ this.canvasHost = null;
1287
+ this.readouts = {};
1288
+ this.safetyButtons = [];
1289
+ this.restartButton = null;
1290
+ this.restartButtons = [];
1291
+ this.openButton = null;
1292
+ this.timingList = null;
1293
+ this.timingLists = [];
1294
+ this.timingGapModeButtons = [];
1295
+ this.cameraButtons = [];
1296
+ this.overviewModeButtons = [];
1297
+ this.bannerMuteButtons = [];
1298
+ this.simulationSpeedButtons = [];
1299
+ this.zoomInButton = null;
1300
+ this.zoomInButtons = [];
1301
+ this.zoomOutButton = null;
1302
+ this.zoomOutButtons = [];
1303
+ this.startLightNodes = [];
1304
+ this.worldLayer = null;
1305
+ this.trackAsset = null;
1306
+ this.drsLayer = null;
1307
+ this.trailLayer = null;
1308
+ this.sensorLayer = null;
1309
+ this.pitLaneStatusLayer = null;
1310
+ this.replayGhostLayer = null;
1311
+ this.carLayer = null;
1312
+ this.textures = {};
1313
+ this.renderSnapshotBuffer = null;
1314
+ this.renderSourceSnapshotBuffer = null;
1315
+ this.cameraController = null;
1316
+ this.camera = null;
1317
+ this.carRenderer = null;
1318
+ this.drsTrailRenderer = null;
1319
+ this.replayGhostRenderer = null;
1320
+ this.pitLaneStatusRenderer = null;
1321
+ this.hostLifecycle = null;
1322
+ this.abortController = null;
1323
+ this.destroyCleanupErrors = null;
1324
+ cleanupErrors.forEach((error) => lifecycleEmitter?.emitCallback('onError', error, { phase: 'destroy' }));
1380
1325
  }
1381
1326
  }