@inventure71/paddockjs 0.3.0 → 2.0.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.
Files changed (202) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/INSTALL_AND_UPDATE.md +5 -2
  3. package/README.md +328 -13
  4. package/docs/architecture.md +150 -41
  5. package/docs/concepts.md +22 -3
  6. package/docs/custom_model_controller.md +329 -0
  7. package/docs/data_contract.md +474 -34
  8. package/docs/data_model.md +1 -1
  9. package/docs/index.md +1 -0
  10. package/docs/learnings.md +23 -1
  11. package/docs/rules.md +169 -21
  12. package/docs/system_specs.md +56 -20
  13. package/docs/training.md +369 -25
  14. package/examples/python/README.md +62 -0
  15. package/examples/python/base_policy_server.py +191 -0
  16. package/examples/python/requirements.txt +2 -0
  17. package/examples/train-basic-policy.mjs +5 -6
  18. package/examples/trainingData.mjs +133 -0
  19. package/package.json +15 -4
  20. package/src/api/PaddockSimulatorController.js +36 -2
  21. package/src/app/BrowserExpertAdapter.js +367 -9
  22. package/src/app/F1SimulatorApp.js +615 -940
  23. package/src/app/banners/bannerFormatters.js +55 -0
  24. package/src/app/banners/raceDataBannerController.js +141 -0
  25. package/src/app/banners/stewardMessageController.js +99 -0
  26. package/src/app/camera/cameraConstants.js +21 -0
  27. package/src/app/camera/cameraController.js +265 -0
  28. package/src/app/camera/cameraGeometry.js +176 -0
  29. package/src/app/domBindings.js +14 -1
  30. package/src/app/readouts/carOverviewRenderer.js +109 -0
  31. package/src/app/readouts/controlStateRenderer.js +37 -0
  32. package/src/app/readouts/raceStatusRenderer.js +133 -0
  33. package/src/app/readouts/readoutFormatters.js +58 -0
  34. package/src/app/readouts/telemetryRenderer.js +113 -0
  35. package/src/app/readouts/timingTowerRenderer.js +177 -0
  36. package/src/app/rendering/appAssets.js +38 -0
  37. package/src/app/rendering/carRenderer.js +286 -0
  38. package/src/app/rendering/displayUtils.js +22 -0
  39. package/src/app/rendering/drsTrailRenderer.js +90 -0
  40. package/src/app/rendering/expertSensorRenderer.js +126 -0
  41. package/src/app/rendering/pitLaneStatusRenderer.js +50 -0
  42. package/src/app/rendering/replayGhostRenderer.js +124 -0
  43. package/src/app/rendering/trackRenderer.js +25 -0
  44. package/src/app/runtime/frameLoop.js +66 -0
  45. package/src/app/runtime/runtimeTiming.js +34 -0
  46. package/src/app/runtime/runtimeVisibility.js +45 -0
  47. package/src/config/defaultOptions.js +24 -4
  48. package/src/environment/actions.js +38 -7
  49. package/src/environment/controllerLoop.js +246 -0
  50. package/src/environment/episode.js +86 -2
  51. package/src/environment/evaluation.js +206 -0
  52. package/src/environment/index.d.ts +638 -6
  53. package/src/environment/index.js +14 -0
  54. package/src/environment/metrics.js +159 -0
  55. package/src/environment/observationOptions.js +33 -0
  56. package/src/environment/observations.js +500 -43
  57. package/src/environment/options.js +41 -2
  58. package/src/environment/recorder.js +28 -0
  59. package/src/environment/runtime.js +260 -24
  60. package/src/environment/scenarios.js +249 -0
  61. package/src/environment/sensors/bodySenses.js +23 -0
  62. package/src/environment/sensors/boundarySenses.js +17 -0
  63. package/src/environment/sensors/carRays.js +75 -0
  64. package/src/environment/sensors/contactSenses.js +32 -0
  65. package/src/environment/sensors/index.js +157 -0
  66. package/src/environment/sensors/indexedRayBands.js +103 -0
  67. package/src/environment/sensors/nearbyCars.js +52 -0
  68. package/src/environment/sensors/opponentRadar.js +65 -0
  69. package/src/environment/sensors/rayConfig.js +110 -0
  70. package/src/environment/sensors/rayDefaults.js +6 -0
  71. package/src/environment/sensors/rayGeometry.js +60 -0
  72. package/src/environment/sensors/rayGuards.js +19 -0
  73. package/src/environment/sensors/sensorTargets.js +60 -0
  74. package/src/environment/sensors/surfaceRays.js +296 -0
  75. package/src/environment/sensors/trackRays.js +261 -0
  76. package/src/environment/sensors.js +1 -257
  77. package/src/environment/specs.js +188 -9
  78. package/src/environment/workerProtocol.js +64 -0
  79. package/src/index.d.ts +533 -7
  80. package/src/index.js +26 -0
  81. package/src/rendering/proceduralTrackAsset.js +20 -510
  82. package/src/rendering/renderSnapshot.js +38 -12
  83. package/src/rendering/track/finishLineRenderer.js +34 -0
  84. package/src/rendering/track/gridRenderer.js +47 -0
  85. package/src/rendering/track/offsetStrokeSafety.js +136 -0
  86. package/src/rendering/track/pitLaneRenderer.js +248 -0
  87. package/src/rendering/track/trackMaterialRenderer.js +326 -0
  88. package/src/rendering/track/trackRenderConstants.js +38 -0
  89. package/src/rendering/track/trackRenderGeometry.js +94 -0
  90. package/src/simulation/collisionGeometry.js +214 -0
  91. package/src/simulation/driver/driverControlConstants.js +28 -0
  92. package/src/simulation/driver/driverController.js +47 -0
  93. package/src/simulation/driver/driverInput.js +28 -0
  94. package/src/simulation/driver/driverMath.js +6 -0
  95. package/src/simulation/driver/driverPersonality.js +25 -0
  96. package/src/simulation/driver/edgeRecovery.js +48 -0
  97. package/src/simulation/driver/racingControls.js +399 -0
  98. package/src/simulation/driver/racingLinePlan.js +166 -0
  99. package/src/simulation/driver/recoveryDynamics.js +38 -0
  100. package/src/simulation/driver/rejoinControls.js +213 -0
  101. package/src/simulation/driver/safetyCarControls.js +35 -0
  102. package/src/simulation/driver/trafficScan.js +51 -0
  103. package/src/simulation/driverController.js +4 -253
  104. package/src/simulation/participants/participantInteractions.js +120 -0
  105. package/src/simulation/pit/pitFlow.js +234 -0
  106. package/src/simulation/pit/pitIntent.js +92 -0
  107. package/src/simulation/pit/pitOccupancy.js +47 -0
  108. package/src/simulation/pit/pitPenaltyService.js +71 -0
  109. package/src/simulation/pit/pitQueue.js +55 -0
  110. package/src/simulation/pit/pitRouteMovement.js +160 -0
  111. package/src/simulation/pit/pitRouting.js +319 -0
  112. package/src/simulation/pit/pitService.js +41 -0
  113. package/src/simulation/pit/pitServiceConstants.js +21 -0
  114. package/src/simulation/pit/pitServiceProfile.js +45 -0
  115. package/src/simulation/pit/pitSnapshots.js +61 -0
  116. package/src/simulation/pit/pitState.js +113 -0
  117. package/src/simulation/pit/pitTireService.js +92 -0
  118. package/src/simulation/race/classification.js +104 -0
  119. package/src/simulation/race/gridPenalties.js +37 -0
  120. package/src/simulation/race/raceControlState.js +38 -0
  121. package/src/simulation/race/raceDistance.js +32 -0
  122. package/src/simulation/race/raceFinish.js +65 -0
  123. package/src/simulation/race/raceLifecycle.js +71 -0
  124. package/src/simulation/race/raceOrder.js +113 -0
  125. package/src/simulation/race/raceProgress.js +124 -0
  126. package/src/simulation/race/raceSetup.js +93 -0
  127. package/src/simulation/race/raceStep.js +80 -0
  128. package/src/simulation/race/redFlag.js +15 -0
  129. package/src/simulation/race/retirements.js +44 -0
  130. package/src/simulation/race/safetyCar.js +86 -0
  131. package/src/simulation/race/stalledDnf.js +86 -0
  132. package/src/simulation/raceSimulation.js +386 -1012
  133. package/src/simulation/replay/replayGhosts.js +128 -0
  134. package/src/simulation/rules/collisionSteward.js +69 -0
  135. package/src/simulation/rules/moduleConfig.js +72 -0
  136. package/src/simulation/rules/penaltyConfig.js +99 -0
  137. package/src/simulation/rules/penaltyEvents.js +18 -0
  138. package/src/simulation/rules/penaltyLedger.js +153 -0
  139. package/src/simulation/rules/penaltyStats.js +26 -0
  140. package/src/simulation/rules/pitLaneSpeedingSteward.js +71 -0
  141. package/src/simulation/rules/ruleConfigMerge.js +41 -0
  142. package/src/simulation/rules/ruleDefaults.js +85 -0
  143. package/src/simulation/rules/rulesReview.js +141 -0
  144. package/src/simulation/rules/rulesetPresets.js +47 -0
  145. package/src/simulation/rules/tireRequirementSteward.js +29 -0
  146. package/src/simulation/rules/trackLimitsSteward.js +85 -0
  147. package/src/simulation/rulesConfig.js +34 -0
  148. package/src/simulation/snapshots/raceSnapshots.js +192 -0
  149. package/src/simulation/timing/drsTiming.js +87 -0
  150. package/src/simulation/timing/gapEstimation.js +38 -0
  151. package/src/simulation/timing/lapTelemetry.js +182 -0
  152. package/src/simulation/timing/raceTiming.js +15 -0
  153. package/src/simulation/timing/sectorPerformance.js +60 -0
  154. package/src/simulation/timing/timingConstants.js +5 -0
  155. package/src/simulation/timing/timingHistory.js +46 -0
  156. package/src/simulation/timing/timingLines.js +79 -0
  157. package/src/simulation/track/centerlineSpline.js +59 -0
  158. package/src/simulation/track/drsZones.js +67 -0
  159. package/src/simulation/track/pitLaneAccess.js +103 -0
  160. package/src/simulation/track/pitLaneLayout.js +395 -0
  161. package/src/simulation/track/pitLaneState.js +160 -0
  162. package/src/simulation/track/proceduralCenterline.js +237 -0
  163. package/src/simulation/track/sampleGeometry.js +49 -0
  164. package/src/simulation/track/spatialQueries.js +154 -0
  165. package/src/simulation/track/startStraight.js +141 -0
  166. package/src/simulation/track/trackConstants.js +109 -0
  167. package/src/simulation/track/trackGenerationOptions.js +283 -0
  168. package/src/simulation/track/trackMath.js +241 -0
  169. package/src/simulation/track/trackModel.js +240 -0
  170. package/src/simulation/track/trackQueryIndex.js +945 -0
  171. package/src/simulation/track/trackSectors.js +19 -0
  172. package/src/simulation/track/trackStatePolicy.js +24 -0
  173. package/src/simulation/track/trackValidation.js +98 -0
  174. package/src/simulation/trackModel.js +1 -628
  175. package/src/simulation/units.js +6 -4
  176. package/src/simulation/vehicle/contactResolution.js +116 -0
  177. package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
  178. package/src/simulation/vehicle/pitWheelSurface.js +69 -0
  179. package/src/simulation/vehicle/runoffResponse.js +88 -0
  180. package/src/simulation/vehicle/surfacePriority.js +28 -0
  181. package/src/simulation/vehicle/vehicleGeometry.js +247 -0
  182. package/src/simulation/vehicle/vehiclePhysics.js +363 -0
  183. package/src/simulation/vehicle/vehicleSnapshots.js +316 -0
  184. package/src/simulation/vehicle/vehicleState.js +186 -0
  185. package/src/simulation/vehicle/wheelSurface.js +150 -0
  186. package/src/simulation/vehicleGeometry.js +1 -0
  187. package/src/simulation/vehiclePhysics.js +1 -104
  188. package/src/simulation/warmup/runtimeWarmup.js +224 -0
  189. package/src/simulation/wheelSurface.js +1 -0
  190. package/src/styles.css +342 -42
  191. package/src/ui/bannerTemplates.js +69 -0
  192. package/src/ui/cameraControlsTemplate.js +20 -0
  193. package/src/ui/carOverviewTemplate.js +33 -0
  194. package/src/ui/componentTemplates.js +20 -426
  195. package/src/ui/raceCanvasTemplate.js +69 -0
  196. package/src/ui/raceControlStatusBanner.js +39 -0
  197. package/src/ui/raceControlsTemplate.js +36 -0
  198. package/src/ui/shellTemplate.js +6 -5
  199. package/src/ui/telemetryDrawerTemplate.js +55 -0
  200. package/src/ui/telemetryTemplates.js +136 -0
  201. package/src/ui/templateUtils.js +23 -0
  202. package/src/ui/timingTowerTemplate.js +34 -0
@@ -1,71 +1,70 @@
1
- import { Application, Assets, Container, Graphics, Sprite, Texture } from 'pixi.js';
2
- import { DRIVER_STAT_DEFINITIONS, formatDriverNumber, VEHICLE_STAT_DEFINITIONS } from '../data/championship.js';
1
+ import { Application, Container, Graphics } from 'pixi.js';
3
2
  import { applyPaddockThemeCssVariables } from '../config/defaultOptions.js';
4
- import { normalizeCustomFields } from '../data/customFields.js';
5
- import { getCarRayOrigin, getCarRayVector } from '../environment/sensors.js';
6
3
  import { ProceduralTrackAsset } from '../rendering/proceduralTrackAsset.js';
7
- import { createRenderSnapshot } from '../rendering/renderSnapshot.js';
4
+ import { interpolateRenderSnapshotInto } from '../rendering/renderSnapshot.js';
8
5
  import { createRaceSimulation, FIXED_STEP } from '../simulation/raceSimulation.js';
9
6
  import { clamp } from '../simulation/simMath.js';
10
- import { offsetTrackPoint, pointAt, WORLD } from '../simulation/trackModel.js';
11
- import { metersToSimUnits } from '../simulation/units.js';
7
+ import { WORLD } from '../simulation/track/trackModel.js';
8
+ import { CameraController } from './camera/cameraController.js';
12
9
  import { createBrowserExpertAdapter } from './BrowserExpertAdapter.js';
13
- import { querySimulatorDom, setText, setTextAll } from './domBindings.js';
14
-
15
- const TARGET_RENDER_FPS = 60;
16
- const TARGET_FRAME_MS = 1000 / TARGET_RENDER_FPS;
17
- const FRAME_PACING_EPSILON_MS = 0.75;
18
- const MAX_FRAME_CATCHUP_COUNT = 4;
19
- const MAX_SIMULATION_STEPS_PER_RENDER = 5;
20
- const DOM_UPDATE_INTERVAL_MS = 100;
21
- const TIMING_UPDATE_INTERVAL_MS = 250;
22
- const SIM_SPEED = 3.25;
23
- const CAR_WORLD_LENGTH = 66;
24
- const CAR_WORLD_WIDTH = 23;
25
- const SAFETY_CAR_WORLD_LENGTH = 92;
26
- const SAFETY_CAR_WORLD_WIDTH = 38;
27
- const CAMERA_PRESETS = {
28
- overview: 1.6,
29
- leader: 5.35,
30
- selected: 6.1,
31
- };
32
- const SHOW_ALL_PADDING = 520;
33
- const SHOW_ALL_MIN_ZOOM = 1.1;
34
- const SHOW_ALL_MAX_ZOOM = 6.4;
35
- const SHOW_ALL_TOP_RESERVED = 92;
36
- const SHOW_ALL_BOTTOM_RESERVED = 132;
37
- const DRS_TRAIL_TTL = 0.68;
38
- const DRS_TRAIL_MIN_DISTANCE = 10;
39
- const SENSOR_RAY_TRACK_COLOR = 0xf1c65b;
40
- const SENSOR_RAY_TRACK_ENTRY_COLOR = 0x68d8ff;
41
- const SENSOR_RAY_CAR_COLOR = 0xff4d5f;
42
- const RACE_DATA_SELECTED_VISIBLE_MS = 5200;
43
- const RADIO_BREAK_MIN_MS = 4800;
44
- const RADIO_BREAK_MAX_MS = 11800;
45
- const RADIO_VISIBLE_MIN_MS = 6200;
46
- const RADIO_VISIBLE_MAX_MS = 9200;
47
- const RADIO_SCHEDULE_CATCHUP_LIMIT_MS = 30000;
10
+ import { querySimulatorDom } from './domBindings.js';
11
+ import { loadAppTextures } from './rendering/appAssets.js';
12
+ import { CarRenderer } from './rendering/carRenderer.js';
13
+ import { DrsTrailRenderer } from './rendering/drsTrailRenderer.js';
14
+ import { renderExpertSensorRays } from './rendering/expertSensorRenderer.js';
15
+ import { PitLaneStatusRenderer } from './rendering/pitLaneStatusRenderer.js';
16
+ import { ReplayGhostRenderer } from './rendering/replayGhostRenderer.js';
17
+ import { renderTrackSurface } from './rendering/trackRenderer.js';
18
+ import {
19
+ formatLapGap,
20
+ formatRaceGap,
21
+ getPenaltyByDriver,
22
+ getTimingOrderKey,
23
+ getTimingPenaltyKey,
24
+ isWavedFlagCar,
25
+ renderTimingTower,
26
+ syncTimingGapModeControls,
27
+ } from './readouts/timingTowerRenderer.js';
28
+ import { formatTelemetryGap } from './readouts/readoutFormatters.js';
29
+ import { getOverviewFields, renderCarDriverOverview } from './readouts/carOverviewRenderer.js';
30
+ import { renderLapTelemetry, renderTelemetryReadouts } from './readouts/telemetryRenderer.js';
31
+ import {
32
+ renderRaceFinish,
33
+ renderRaceStatusReadouts,
34
+ renderStartLights,
35
+ } from './readouts/raceStatusRenderer.js';
36
+ import {
37
+ updateCameraControlButtons,
38
+ updateModeButtons,
39
+ updateToggleButtons,
40
+ } from './readouts/controlStateRenderer.js';
41
+ import {
42
+ createPenaltyStewardMessage,
43
+ createWarningStewardMessage,
44
+ renderActiveStewardMessage,
45
+ updateStewardMessageState,
46
+ } from './banners/stewardMessageController.js';
47
+ import {
48
+ hideRaceDataPanel,
49
+ getProjectRadioQuote,
50
+ RACE_DATA_SELECTED_VISIBLE_MS,
51
+ RADIO_SCHEDULE_CATCHUP_LIMIT_MS,
52
+ renderProjectRadio,
53
+ renderRaceData,
54
+ resetRaceDataBannerState,
55
+ scheduleRadioBreak,
56
+ scheduleRadioPopup,
57
+ shouldAutoHideActiveRaceData,
58
+ getNextRadioBreakTime,
59
+ } from './banners/raceDataBannerController.js';
60
+ import { runFrameLoopTick } from './runtime/frameLoop.js';
61
+ import { observeRuntimeVisibility, syncRuntimeTicker } from './runtime/runtimeVisibility.js';
62
+ import { TARGET_FRAME_MS, domUpdateIntervalForSpeed, timingUpdateIntervalForSpeed } from './runtime/runtimeTiming.js';
63
+ import { normalizeWarmupOptions } from '../simulation/warmup/runtimeWarmup.js';
64
+
65
+ const SIMULATION_SPEED_STEPS = [1, 2, 3, 4, 5, 10];
48
66
  const DRS_DRAG_REDUCTION_PERCENT = 58;
49
67
 
50
- const VEHICLE_OVERVIEW_FIELDS = [
51
- ['power', 'Power'],
52
- ['braking', 'Braking'],
53
- ['aero', 'Aero'],
54
- ['dragEfficiency', 'Drag'],
55
- ['mechanicalGrip', 'Grip'],
56
- ['weightControl', 'Weight'],
57
- ];
58
- const DRIVER_OVERVIEW_FIELDS = [
59
- ['pace', 'Pace'],
60
- ['racecraft', 'Racecraft'],
61
- ['aggression', 'Aggression'],
62
- ['riskTolerance', 'Risk'],
63
- ['patience', 'Patience'],
64
- ['consistency', 'Consistency'],
65
- ];
66
-
67
- const TINT_BY_COLOR = new Map();
68
-
69
68
  function createMountTrackSeed() {
70
69
  const values = new Uint32Array(1);
71
70
  try {
@@ -76,71 +75,6 @@ function createMountTrackSeed() {
76
75
  return (values[0] || Math.floor(Date.now() + performance.now() * 1000)) >>> 0;
77
76
  }
78
77
 
79
- function escapeHtml(value) {
80
- return String(value ?? '')
81
- .replaceAll('&', '&')
82
- .replaceAll('<', '&lt;')
83
- .replaceAll('>', '&gt;')
84
- .replaceAll('"', '&quot;')
85
- .replaceAll("'", '&#39;');
86
- }
87
-
88
- function getTireClass(tire) {
89
- return String(tire ?? 'M').toLowerCase();
90
- }
91
-
92
- function formatTelemetryTime(value) {
93
- if (!Number.isFinite(value)) return '--';
94
- const clamped = Math.max(0, value);
95
- if (clamped >= 60) {
96
- const minutes = Math.floor(clamped / 60);
97
- const seconds = clamped - minutes * 60;
98
- return `${minutes}:${seconds.toFixed(3).padStart(6, '0')}`;
99
- }
100
- return `${clamped.toFixed(3)}s`;
101
- }
102
-
103
- function setPerformanceClass(node, status) {
104
- ['overall-best', 'personal-best', 'slower'].forEach((name) => {
105
- node?.classList?.toggle?.(`is-${name}`, status === name);
106
- });
107
- }
108
-
109
- function colorToTint(color) {
110
- if (TINT_BY_COLOR.has(color)) return TINT_BY_COLOR.get(color);
111
- const tint = Number.parseInt(String(color ?? '').replace('#', ''), 16);
112
- const normalizedTint = Number.isFinite(tint) ? tint : 0xffffff;
113
- TINT_BY_COLOR.set(color, normalizedTint);
114
- return normalizedTint;
115
- }
116
-
117
- function smoothAngle(current, target, amount) {
118
- if (!Number.isFinite(current)) return target;
119
- let diff = ((target - current + Math.PI) % (Math.PI * 2)) - Math.PI;
120
- if (diff < -Math.PI) diff += Math.PI * 2;
121
- return current + diff * amount;
122
- }
123
-
124
- function destroyDisplayChildren(container) {
125
- container?.removeChildren?.().forEach((child) => {
126
- child.destroy?.({ children: true, texture: false, textureSource: false });
127
- });
128
- }
129
-
130
- function expertVisualizesRays(expertOptions) {
131
- const setting = expertOptions?.visualizeSensors;
132
- if (setting === true) return true;
133
- if (!setting || setting === false) return false;
134
- return Boolean(setting.rays);
135
- }
136
-
137
- function pointFromRay(origin, ray, distance) {
138
- return {
139
- x: origin.x + ray.x * distance,
140
- y: origin.y + ray.y * distance,
141
- };
142
- }
143
-
144
78
  function hasOwnOption(options, key) {
145
79
  return Object.hasOwn(options ?? {}, key);
146
80
  }
@@ -149,6 +83,17 @@ function expertOptionsChanged(nextExpertOptions, currentExpertOptions) {
149
83
  return nextExpertOptions !== currentExpertOptions;
150
84
  }
151
85
 
86
+ function withSelectedExpertSensorDriver(visualizeSensors, selectedDriverId) {
87
+ if (!visualizeSensors) return visualizeSensors;
88
+ if (visualizeSensors === true) return { rays: true, selectedDriverId };
89
+ if (typeof visualizeSensors !== 'object') return visualizeSensors;
90
+ if (visualizeSensors.drivers === 'all' || Array.isArray(visualizeSensors.drivers)) return visualizeSensors;
91
+ return {
92
+ ...visualizeSensors,
93
+ selectedDriverId,
94
+ };
95
+ }
96
+
152
97
  function assetSetsEqual(first = {}, second = {}) {
153
98
  const firstTrackTextures = first.trackTextures ?? {};
154
99
  const secondTrackTextures = second.trackTextures ?? {};
@@ -172,7 +117,7 @@ export class F1SimulatorApp {
172
117
  this.abortController = new AbortController();
173
118
  this.resizeHandler = null;
174
119
  this.layoutResizeObserver = null;
175
- this.cameraSafeAreaCache = null;
120
+ this.cameraController = null;
176
121
  this.visibilityChangeHandler = null;
177
122
  this.visibilityObserver = null;
178
123
  this.tickerCallback = null;
@@ -185,15 +130,34 @@ export class F1SimulatorApp {
185
130
  this.drsLayer = null;
186
131
  this.trailLayer = null;
187
132
  this.sensorLayer = null;
133
+ this.pitLaneStatusLayer = null;
134
+ this.replayGhostLayer = null;
188
135
  this.carLayer = null;
189
136
  this.textures = {};
190
137
  this.carSprites = new Map();
191
138
  this.carHitAreas = new Map();
139
+ this.serviceCountdownLabels = new Map();
140
+ this.carRenderer = new CarRenderer({
141
+ carSprites: this.carSprites,
142
+ carHitAreas: this.carHitAreas,
143
+ serviceCountdownLabels: this.serviceCountdownLabels,
144
+ onSelectCar: (driverId) => this.selectCar(driverId, { focus: true }),
145
+ });
192
146
  this.drsTrails = new Map();
147
+ this.drsTrailRenderer = new DrsTrailRenderer({ trails: this.drsTrails });
148
+ this.replayGhostRenderer = new ReplayGhostRenderer();
149
+ this.pitLaneStatusRenderer = new PitLaneStatusRenderer();
193
150
  this.trackSeed = options.trackSeed ?? createMountTrackSeed();
194
151
  this.selectedId = this.drivers[0]?.id ?? null;
195
152
  this.raceDataBannerConfig = options.ui?.raceDataBanners ?? { initial: 'project', enabled: ['project', 'radio'] };
153
+ this.raceDataBannersMuted = false;
154
+ this.penaltyBannerEnabled = Boolean(options.ui?.penaltyBanners);
155
+ this.timingPenaltyBadgesEnabled = Boolean(options.ui?.timingPenaltyBadges);
196
156
  this.activeRaceDataId = null;
157
+ this.activePenaltyBanner = null;
158
+ this.lastPenaltyBannerId = null;
159
+ this.activeStewardMessage = null;
160
+ this.lastStewardMessageKey = null;
197
161
  this.lastRaceDataInteraction = performance.now();
198
162
  this.radioRandomState = (this.trackSeed ^ 0x9e3779b9) >>> 0;
199
163
  this.radioState = {
@@ -203,20 +167,22 @@ export class F1SimulatorApp {
203
167
  quoteIndex: 0,
204
168
  };
205
169
  this.resetRaceDataBannerState(this.lastRaceDataInteraction);
206
- this.camera = {
207
- mode: options.initialCameraMode,
208
- zoom: CAMERA_PRESETS[options.initialCameraMode] ?? CAMERA_PRESETS.leader,
209
- scale: null,
210
- x: WORLD.width / 2,
211
- y: WORLD.height / 2,
212
- };
170
+ this.cameraController = new CameraController({
171
+ canvasHost: this.canvasHost,
172
+ readouts: this.readouts,
173
+ initialMode: options.initialCameraMode,
174
+ });
175
+ this.camera = this.cameraController.camera;
213
176
  this.overviewMode = 'vehicle';
177
+ this.simulationSpeed = 1;
214
178
  this.accumulator = 0;
215
179
  this.lastTime = performance.now();
216
180
  this.nextGameFrameTime = this.lastTime + TARGET_FRAME_MS;
217
181
  this.lastDomUpdateTime = 0;
218
182
  this.lastTimingRenderTime = 0;
219
183
  this.lastTimingRaceMode = null;
184
+ this.lastTimingPenaltyKey = '';
185
+ this.lastTimingOrderKey = '';
220
186
  this.lastTimingMarkup = null;
221
187
  this.lastOverviewRenderKey = null;
222
188
  this.lastFinishClassificationMarkup = null;
@@ -228,6 +194,7 @@ export class F1SimulatorApp {
228
194
  current: 0,
229
195
  lastSample: this.lastTime,
230
196
  };
197
+ this.renderSnapshotBuffer = {};
231
198
  this.lastLeaderLap = null;
232
199
  this.emittedRaceEventKeys = new Set();
233
200
  this.raceFinishEmitted = false;
@@ -259,27 +226,42 @@ export class F1SimulatorApp {
259
226
  this.drsLayer = new Container();
260
227
  this.trailLayer = new Graphics();
261
228
  this.sensorLayer = new Graphics();
229
+ this.pitLaneStatusLayer = new Graphics();
230
+ this.replayGhostLayer = new Container();
262
231
  this.carLayer = new Container();
263
232
  this.app.stage.addChild(this.worldLayer);
264
233
 
265
234
  await this.loadAssets();
266
235
  this.trackAsset = new ProceduralTrackAsset({ textures: this.textures, world: WORLD });
267
- this.worldLayer.addChild(this.trackAsset.container, this.drsLayer, this.trailLayer, this.sensorLayer, this.carLayer);
236
+ this.worldLayer.addChild(
237
+ this.trackAsset.container,
238
+ this.drsLayer,
239
+ this.trailLayer,
240
+ this.pitLaneStatusLayer,
241
+ this.sensorLayer,
242
+ this.replayGhostLayer,
243
+ this.carLayer,
244
+ );
268
245
  this.createCars();
269
246
  this.bindControls();
270
247
  this.renderTrack();
271
248
  const snapshot = this.sim.snapshot();
272
249
  this.updateDom(snapshot);
250
+ this.lastDomUpdateTime = performance.now();
273
251
  if (this.expertMode) {
274
252
  this.expert = createBrowserExpertAdapter(this, this.options.expert);
275
253
  }
254
+ this.renderInitialFrame(snapshot);
276
255
  this.completeComponentLoading();
277
256
  this.emitHostCallback('onLoadingChange', { loading: false, phase: 'ready' });
278
257
  this.emitHostCallback('onReady', { snapshot });
279
- this.resizeHandler = () => this.applyCamera(this.sim.snapshot());
258
+ this.resizeHandler = () => this.applyCamera(this.sim.snapshotRender?.() ?? this.sim.snapshot());
280
259
  window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
281
260
  this.observeLayoutResize();
282
- if (!this.expertMode) {
261
+ if (this.expertMode) {
262
+ this.app.ticker.stop?.();
263
+ } else {
264
+ this.resetFrameClock();
283
265
  this.tickerCallback = () => this.tick();
284
266
  this.app.ticker.add(this.tickerCallback);
285
267
  this.observeRuntimeVisibility();
@@ -320,82 +302,41 @@ export class F1SimulatorApp {
320
302
  return createRaceSimulation({
321
303
  seed: options.seed,
322
304
  trackSeed: options.trackSeed ?? this.trackSeed,
305
+ trackGeneration: options.trackGeneration,
306
+ physicsMode: options.physicsMode,
323
307
  drivers: options.drivers ?? this.drivers,
324
308
  totalLaps: options.totalLaps,
309
+ rules: options.rules,
310
+ participantInteractions: options.participantInteractions,
311
+ replayGhosts: options.replayGhosts,
312
+ trackQueryIndex: options.trackQueryIndex,
313
+ warmup: normalizeWarmupOptions(options.warmup, 'browser'),
325
314
  });
326
315
  }
327
316
 
328
317
  async loadAssets() {
329
- this.textures.car = Texture.WHITE;
330
- try {
331
- this.textures.car = await Assets.load(this.assets.car);
332
- this.textures.car.source.scaleMode = 'linear';
333
- this.textures.car.source.autoGenerateMipmaps = true;
334
- } catch {
335
- this.textures.car = Texture.WHITE;
336
- }
337
-
338
- try {
339
- this.textures.safetyCar = await Assets.load(this.assets.safetyCar);
340
- this.textures.safetyCar.source.scaleMode = 'linear';
341
- this.textures.safetyCar.source.autoGenerateMipmaps = true;
342
- } catch {
343
- this.textures.safetyCar = Texture.WHITE;
344
- }
345
-
346
- await Promise.all(Object.entries(this.assets.trackTextures).map(async ([key, url]) => {
347
- try {
348
- const texture = await Assets.load(url);
349
- texture.source.scaleMode = 'linear';
350
- this.textures[key] = texture;
351
- } catch {
352
- this.textures[key] = Texture.WHITE;
353
- }
354
- }));
318
+ this.textures = await loadAppTextures(this.assets);
355
319
  }
356
320
 
357
321
  createCars() {
358
- const texture = this.textures.car ?? Texture.WHITE;
359
- const baseScale = Math.min(
360
- CAR_WORLD_LENGTH / Math.max(texture.width, 1),
361
- CAR_WORLD_WIDTH / Math.max(texture.height, 1),
362
- );
363
-
364
- this.carSprites.forEach((sprite) => sprite.destroy());
365
- this.carHitAreas.forEach((hit) => hit.destroy());
366
- this.carSprites.clear();
367
- this.carHitAreas.clear();
368
322
  this.drsTrails.clear();
323
+ this.carRenderer.createCars({
324
+ drivers: this.drivers,
325
+ textures: this.textures,
326
+ carLayer: this.carLayer,
327
+ });
328
+ }
369
329
 
370
- if (this.safetySprite) {
371
- this.safetySprite.destroy();
372
- this.safetySprite = null;
373
- }
330
+ createRawCarGeometryGraphic(driver) {
331
+ return this.carRenderer.createRawCarGeometryGraphic(driver);
332
+ }
374
333
 
375
- this.drivers.forEach((driver) => {
376
- const sprite = new Sprite(texture);
377
- sprite.anchor.set(0.5);
378
- sprite.baseScale = baseScale;
379
- sprite.scale.set(baseScale);
380
- sprite.tint = colorToTint(driver.color);
381
- sprite.eventMode = 'static';
382
- sprite.cursor = 'pointer';
383
- sprite.on('pointerdown', () => {
384
- this.selectCar(driver.id, { focus: true });
385
- });
386
- this.carSprites.set(driver.id, sprite);
387
- this.carLayer.addChild(sprite);
388
-
389
- const hit = new Graphics();
390
- hit.circle(0, 0, 24).fill({ color: 0xffffff, alpha: 0.001 });
391
- hit.eventMode = 'static';
392
- hit.cursor = 'pointer';
393
- hit.on('pointerdown', () => {
394
- this.selectCar(driver.id, { focus: true });
395
- });
396
- this.carHitAreas.set(driver.id, hit);
397
- this.carLayer.addChild(hit);
398
- });
334
+ createServiceCountdownLabel() {
335
+ return this.carRenderer.createServiceCountdownLabel();
336
+ }
337
+
338
+ applyServiceCountdownTone(label, tone) {
339
+ this.carRenderer.applyServiceCountdownTone(label, tone);
399
340
  }
400
341
 
401
342
  bindControls() {
@@ -417,27 +358,38 @@ export class F1SimulatorApp {
417
358
  this.trailLayer?.clear();
418
359
  this.lastTimingRenderTime = 0;
419
360
  this.lastTimingRaceMode = null;
361
+ this.lastTimingPenaltyKey = '';
362
+ this.lastTimingOrderKey = '';
420
363
  this.renderTrack();
421
364
  }, eventOptions);
422
365
 
423
366
  this.cameraButtons.forEach((button) => {
424
367
  button.addEventListener('click', () => {
425
- this.camera.mode = button.dataset.cameraMode;
426
- if (CAMERA_PRESETS[this.camera.mode]) this.camera.zoom = CAMERA_PRESETS[this.camera.mode];
427
- this.updateCameraControls();
368
+ this.setCameraMode(button.dataset.cameraMode);
428
369
  }, eventOptions);
429
370
  });
430
371
 
431
372
  this.zoomInButton?.addEventListener('click', () => {
432
- this.camera.zoom = clamp(this.camera.zoom + 0.42, 0.72, 8.5);
433
- this.updateCameraControls();
373
+ this.adjustCameraZoom(1);
434
374
  }, eventOptions);
435
375
 
436
376
  this.zoomOutButton?.addEventListener('click', () => {
437
- this.camera.zoom = clamp(this.camera.zoom - 0.42, 0.72, 8.5);
438
- this.updateCameraControls();
377
+ this.adjustCameraZoom(-1);
439
378
  }, eventOptions);
440
379
 
380
+ this.simulationSpeedButtons?.forEach((button) => {
381
+ button.addEventListener('click', () => {
382
+ this.cycleSimulationSpeed();
383
+ }, eventOptions);
384
+ });
385
+ this.updateSimulationSpeedControls();
386
+
387
+ this.bannerMuteButtons?.forEach((button) => {
388
+ button.addEventListener('click', () => {
389
+ this.setRaceDataBannersMuted(!this.raceDataBannersMuted);
390
+ }, eventOptions);
391
+ });
392
+
441
393
  this.overviewModeButtons.forEach((button) => {
442
394
  button.addEventListener('click', () => {
443
395
  this.overviewMode = button.dataset.overviewMode === 'driver' ? 'driver' : 'vehicle';
@@ -451,7 +403,7 @@ export class F1SimulatorApp {
451
403
  this.timingGapMode = button.dataset.timingGapMode === 'leader' ? 'leader' : 'interval';
452
404
  this.syncTimingGapModeControls();
453
405
  const snapshot = this.sim?.snapshot?.();
454
- if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode);
406
+ if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
455
407
  }, eventOptions);
456
408
  });
457
409
 
@@ -467,9 +419,56 @@ export class F1SimulatorApp {
467
419
  this.options.onDriverOpen?.(driver);
468
420
  }, eventOptions);
469
421
 
422
+ this.readouts.raceDataDismiss?.addEventListener('click', (event) => {
423
+ event.preventDefault();
424
+ event.stopPropagation();
425
+ this.dismissRaceDataPanel(performance.now());
426
+ }, eventOptions);
427
+
470
428
  this.readouts.telemetryDrawerToggle?.addEventListener('click', () => {
471
429
  this.setTelemetryDrawerOpen(!this.telemetryDrawerOpen);
472
430
  }, eventOptions);
431
+
432
+ this.bindCameraWheelControls(eventOptions);
433
+ }
434
+
435
+ setCameraMode(mode) {
436
+ const snapshot = this.sim?.snapshot?.();
437
+ if (!this.cameraController.setMode(mode, snapshot)) return false;
438
+ this.updateCameraControls(snapshot);
439
+ return true;
440
+ }
441
+
442
+ adjustCameraZoom(direction) {
443
+ this.cameraController.adjustZoom(direction);
444
+ this.updateCameraControls();
445
+ return this.camera.zoom;
446
+ }
447
+
448
+ cycleSimulationSpeed() {
449
+ const currentIndex = SIMULATION_SPEED_STEPS.indexOf(this.simulationSpeed);
450
+ const nextIndex = currentIndex >= 0
451
+ ? (currentIndex + 1) % SIMULATION_SPEED_STEPS.length
452
+ : 0;
453
+ this.simulationSpeed = SIMULATION_SPEED_STEPS[nextIndex];
454
+ this.updateSimulationSpeedControls();
455
+ return this.simulationSpeed;
456
+ }
457
+
458
+ updateSimulationSpeedControls() {
459
+ this.simulationSpeedButtons?.forEach((button) => {
460
+ const label = `${this.simulationSpeed}x`;
461
+ button.textContent = label;
462
+ button.setAttribute?.('aria-label', `Simulation speed ${label}`);
463
+ });
464
+ }
465
+
466
+ bindCameraWheelControls(eventOptions) {
467
+ if (!this.canvasHost?.addEventListener) return;
468
+ this.canvasHost.addEventListener('wheel', (event) => {
469
+ event.preventDefault?.();
470
+ this.adjustCameraZoom(event.deltaY > 0 ? -1 : 1);
471
+ }, { ...eventOptions, passive: false });
473
472
  }
474
473
 
475
474
  setTelemetryDrawerOpen(open) {
@@ -487,401 +486,204 @@ export class F1SimulatorApp {
487
486
  this.readouts.telemetryDrawerToggle.textContent = this.telemetryDrawerOpen ? 'Close telemetry' : 'Telemetry';
488
487
  }
489
488
  this.invalidateCameraSafeArea();
489
+ this.syncRendererToCurrentLayout({ render: true });
490
+ this.scheduleLayoutResizeSync(380);
491
+ }
492
+
493
+ setRaceDataBannersMuted(muted, now = performance.now()) {
494
+ this.raceDataBannersMuted = Boolean(muted);
495
+ if (this.raceDataBannersMuted) {
496
+ this.activeRaceDataId = null;
497
+ this.radioState.visible = false;
498
+ this.radioState.nextChangeAt = Number.POSITIVE_INFINITY;
499
+ this.hideRaceDataPanel();
500
+ } else {
501
+ this.resetRaceDataBannerState(now);
502
+ }
503
+ this.updateBannerMuteControls();
504
+ }
505
+
506
+ resizeRendererToCanvasHost() {
507
+ if (!this.app?.renderer?.resize || !this.canvasHost) return;
508
+ const width = Math.max(1, Math.round(this.canvasHost.clientWidth || 0));
509
+ const height = Math.max(1, Math.round(this.canvasHost.clientHeight || 0));
510
+ this.app.renderer.resize(width, height);
511
+ }
512
+
513
+ renderInitialFrame(snapshot) {
514
+ const renderSnapshot = interpolateRenderSnapshotInto(this.renderSnapshotBuffer, snapshot, 0);
515
+ this.resizeRendererToCanvasHost();
516
+ this.applyCamera(renderSnapshot, { immediate: true });
517
+ this.renderDrsTrails(renderSnapshot);
518
+ this.renderPitLaneStatus(renderSnapshot);
519
+ this.renderCars(renderSnapshot);
520
+ this.app?.render?.();
521
+ }
522
+
523
+ syncRendererToCurrentLayout({ render = false } = {}) {
524
+ this.resizeRendererToCanvasHost();
525
+ const snapshot = this.sim?.snapshotRender?.() ?? this.sim?.snapshot?.();
526
+ if (!snapshot) return;
527
+ const renderSnapshot = interpolateRenderSnapshotInto(
528
+ this.renderSnapshotBuffer,
529
+ snapshot,
530
+ clamp(this.accumulator / FIXED_STEP, 0, 1),
531
+ );
532
+ this.applyCamera(renderSnapshot);
533
+ if (render) {
534
+ this.renderDrsTrails(renderSnapshot);
535
+ this.renderPitLaneStatus(renderSnapshot);
536
+ this.renderCars(renderSnapshot);
537
+ this.app?.render?.();
538
+ }
539
+ }
540
+
541
+ scheduleLayoutResizeSync(durationMs = 360) {
542
+ if (typeof requestAnimationFrame !== 'function') return;
543
+ const startedAt = performance.now();
544
+ const run = (timestamp = performance.now()) => {
545
+ this.syncRendererToCurrentLayout({ render: true });
546
+ if (timestamp - startedAt < durationMs) {
547
+ this.layoutResizeFrame = requestAnimationFrame(run);
548
+ }
549
+ };
550
+ this.layoutResizeFrame = requestAnimationFrame(run);
490
551
  }
491
552
 
492
553
  observeLayoutResize() {
493
554
  if (typeof ResizeObserver !== 'function') return;
494
555
  this.layoutResizeObserver?.disconnect?.();
495
- this.layoutResizeObserver = new ResizeObserver(() => this.invalidateCameraSafeArea());
556
+ this.layoutResizeObserver = new ResizeObserver(() => {
557
+ this.invalidateCameraSafeArea();
558
+ this.syncRendererToCurrentLayout({ render: true });
559
+ });
496
560
  if (this.canvasHost) this.layoutResizeObserver.observe(this.canvasHost);
497
561
  if (this.readouts.timingTower) this.layoutResizeObserver.observe(this.readouts.timingTower);
498
562
  }
499
563
 
500
564
  invalidateCameraSafeArea() {
501
- this.cameraSafeAreaCache = null;
565
+ this.cameraController.invalidateSafeArea();
502
566
  }
503
567
 
504
568
  observeRuntimeVisibility() {
505
- if (!this.canvasHost) return;
506
-
507
- if (typeof IntersectionObserver === 'function') {
508
- this.visibilityObserver?.disconnect?.();
509
- this.visibilityObserver = new IntersectionObserver((entries) => {
510
- const entry = entries.find((item) => item.target === this.canvasHost) ?? entries[0];
511
- this.runtimeViewportVisible = Boolean(entry?.isIntersecting || entry?.intersectionRatio > 0);
512
- this.syncRuntimeTicker();
513
- }, {
514
- root: null,
515
- rootMargin: '240px 0px',
516
- threshold: 0,
517
- });
518
- this.visibilityObserver.observe(this.canvasHost);
519
- }
520
-
521
- if (typeof document !== 'undefined') {
522
- this.visibilityChangeHandler = () => {
523
- this.runtimeDocumentVisible = document.visibilityState !== 'hidden';
524
- this.syncRuntimeTicker();
525
- };
526
- document.addEventListener('visibilitychange', this.visibilityChangeHandler, {
527
- signal: this.abortController.signal,
528
- });
529
- }
530
-
531
- this.syncRuntimeTicker();
569
+ observeRuntimeVisibility(this);
532
570
  }
533
571
 
534
572
  syncRuntimeTicker() {
535
- if (this.expertMode) return;
536
- if (!this.app?.ticker) return;
537
-
538
- const shouldRun = this.runtimeViewportVisible && this.runtimeDocumentVisible;
539
- if (shouldRun === this.runtimeTickerRunning) return;
540
-
541
- this.runtimeTickerRunning = shouldRun;
542
- if (shouldRun) {
543
- this.resetFrameClock();
544
- this.app.ticker.start?.();
545
- } else {
546
- this.app.ticker.stop?.();
547
- }
573
+ syncRuntimeTicker(this);
548
574
  }
549
575
 
550
576
  resetFrameClock(now = performance.now()) {
551
577
  this.accumulator = 0;
552
578
  this.lastTime = now;
553
579
  this.nextGameFrameTime = now + TARGET_FRAME_MS;
580
+ this.fps.current = 0;
554
581
  this.fps.frames = 0;
555
582
  this.fps.lastSample = now;
556
583
  }
557
584
 
558
585
  tick() {
559
- if (this.expertMode) return;
560
- const now = performance.now();
561
- if (now < this.nextGameFrameTime - FRAME_PACING_EPSILON_MS) return;
562
-
563
- const elapsedFrameCount = clamp(
564
- Math.floor((now - this.nextGameFrameTime + FRAME_PACING_EPSILON_MS) / TARGET_FRAME_MS) + 1,
565
- 1,
566
- MAX_FRAME_CATCHUP_COUNT,
567
- );
568
- const frameSeconds = (TARGET_FRAME_MS * elapsedFrameCount) / 1000;
569
- this.nextGameFrameTime += TARGET_FRAME_MS * elapsedFrameCount;
570
- if (now - this.nextGameFrameTime > TARGET_FRAME_MS * MAX_FRAME_CATCHUP_COUNT) {
571
- this.nextGameFrameTime = now + TARGET_FRAME_MS;
572
- }
573
- this.lastTime = now;
574
- this.sampleFps(now);
575
- this.accumulator += frameSeconds * SIM_SPEED;
576
-
577
- let simulationSteps = 0;
578
- while (this.accumulator >= FIXED_STEP && simulationSteps < MAX_SIMULATION_STEPS_PER_RENDER) {
579
- this.sim.step(FIXED_STEP);
580
- const stepSnapshot = this.sim.snapshot();
581
- this.emitSnapshotLifecycle(stepSnapshot);
582
- this.accumulator -= FIXED_STEP;
583
- simulationSteps += 1;
584
- }
585
-
586
- if (this.accumulator >= FIXED_STEP) {
587
- this.accumulator %= FIXED_STEP;
588
- this.nextGameFrameTime = now + TARGET_FRAME_MS;
589
- }
590
-
591
- const snapshot = this.sim.snapshot();
592
- const renderSnapshot = createRenderSnapshot(snapshot, clamp(this.accumulator / FIXED_STEP, 0, 1));
593
- this.applyCamera(renderSnapshot);
594
- this.renderDrsTrails(renderSnapshot);
595
- this.renderCars(renderSnapshot);
596
- if (now - this.lastDomUpdateTime >= DOM_UPDATE_INTERVAL_MS) {
597
- this.updateDom(snapshot, { emitLifecycle: false });
598
- this.lastDomUpdateTime = now;
599
- }
586
+ runFrameLoopTick(this);
600
587
  }
601
588
 
602
- renderExpertFrame(snapshot = this.sim?.snapshot(), { observation } = {}) {
589
+ renderExpertFrame(snapshot = this.sim?.snapshot(), { forceDomUpdate = false, observation } = {}) {
603
590
  if (!snapshot) return;
604
- const renderSnapshot = createRenderSnapshot(snapshot, 0);
591
+ const now = performance.now();
592
+ const renderSnapshot = interpolateRenderSnapshotInto(this.renderSnapshotBuffer, snapshot, 0);
605
593
  this.applyCamera(renderSnapshot);
606
594
  this.renderDrsTrails(renderSnapshot);
607
595
  this.renderExpertSensorRays(renderSnapshot, observation);
596
+ this.renderPitLaneStatus(renderSnapshot);
608
597
  this.renderCars(renderSnapshot);
609
- this.updateDom(snapshot, { emitLifecycle: false });
610
- this.lastDomUpdateTime = performance.now();
598
+ this.app?.render?.();
599
+ if (forceDomUpdate || now - this.lastDomUpdateTime >= domUpdateIntervalForSpeed(this.simulationSpeed)) {
600
+ this.updateDom(snapshot, { emitLifecycle: false });
601
+ this.lastDomUpdateTime = now;
602
+ }
611
603
  }
612
604
 
613
605
  renderTrack() {
614
- destroyDisplayChildren(this.drsLayer);
615
- this.sensorLayer?.clear();
606
+ this.pitLaneStatusRenderer.reset();
607
+ this.cameraController.invalidateTrackCaches();
616
608
  const snapshot = this.sim.snapshot();
617
- const track = snapshot.track;
618
-
619
- this.trackAsset.render(track);
620
-
621
- track.drsZones.forEach((zone) => {
622
- const zoneLine = new Graphics();
623
- const steps = 44;
624
- for (let index = 0; index <= steps; index += 1) {
625
- const basePoint = pointAt(track, zone.start + ((zone.end - zone.start) * index) / steps);
626
- const point = offsetTrackPoint(basePoint, track.width / 2 - 22);
627
- if (index === 0) zoneLine.moveTo(point.x, point.y);
628
- else zoneLine.lineTo(point.x, point.y);
629
- }
630
- zoneLine.stroke({ width: 8, color: 0x14c784, alpha: 0.5, join: 'round', cap: 'round' });
631
- this.drsLayer.addChild(zoneLine);
609
+ renderTrackSurface({
610
+ drsLayer: this.drsLayer,
611
+ sensorLayer: this.sensorLayer,
612
+ pitLaneStatusLayer: this.pitLaneStatusLayer,
613
+ trackAsset: this.trackAsset,
614
+ snapshot,
632
615
  });
633
616
  }
634
617
 
618
+ renderPitLaneStatus(snapshot) {
619
+ this.pitLaneStatusRenderer.render(snapshot, this.pitLaneStatusLayer);
620
+ }
621
+
635
622
  renderCars(snapshot) {
636
- snapshot.cars.forEach((car) => {
637
- const sprite = this.carSprites.get(car.id);
638
- const hit = this.carHitAreas.get(car.id);
639
- if (!sprite || !hit) return;
640
- sprite.x = car.x;
641
- sprite.y = car.y;
642
- sprite.currentRotation = smoothAngle(sprite.currentRotation, car.heading, 0.24);
643
- sprite.rotation = sprite.currentRotation;
644
- sprite.alpha = snapshot.raceControl.mode === 'safety-car' ? 0.82 : 1;
645
- sprite.scale.set(sprite.baseScale);
646
- sprite.tint = colorToTint(car.color);
647
- hit.x = car.x;
648
- hit.y = car.y;
623
+ this.replayGhostRenderer.render(snapshot, {
624
+ textures: this.textures,
625
+ replayGhostLayer: this.replayGhostLayer,
649
626
  });
627
+ this.carRenderer.renderCars(snapshot, {
628
+ textures: this.textures,
629
+ carLayer: this.carLayer,
630
+ });
631
+ }
650
632
 
651
- if (!this.safetySprite && snapshot.safetyCar.deployed) {
652
- const texture = this.textures.safetyCar ?? Texture.WHITE;
653
- this.safetySprite = new Sprite(texture);
654
- this.safetySprite.anchor.set(0.5);
655
- this.safetySprite.baseScale = Math.min(
656
- SAFETY_CAR_WORLD_LENGTH / Math.max(texture.width, 1),
657
- SAFETY_CAR_WORLD_WIDTH / Math.max(texture.height, 1),
658
- );
659
- this.safetySprite.scale.set(this.safetySprite.baseScale);
660
- this.carLayer.addChild(this.safetySprite);
661
- }
662
-
663
- if (this.safetySprite) {
664
- this.safetySprite.visible = snapshot.safetyCar.deployed;
665
- this.safetySprite.x = snapshot.safetyCar.x;
666
- this.safetySprite.y = snapshot.safetyCar.y;
667
- this.safetySprite.rotation = snapshot.safetyCar.heading;
668
- }
633
+ renderServiceCountdownLabel(car) {
634
+ this.carRenderer.renderServiceCountdownLabel(car);
669
635
  }
670
636
 
671
637
  renderExpertSensorRays(snapshot, observation) {
672
- if (!this.sensorLayer) return;
673
- this.sensorLayer.clear();
674
- if (!this.expertMode || !expertVisualizesRays(this.options.expert)) return;
675
-
676
- const controlledDrivers = this.options.expert?.controlledDrivers ?? [];
677
- if (!controlledDrivers.length || !observation) return;
678
-
679
- const carsById = new Map(snapshot.cars.map((car) => [car.id, car]));
680
- controlledDrivers.forEach((driverId) => {
681
- const car = carsById.get(driverId);
682
- const rays = observation?.[driverId]?.object?.rays;
683
- if (!car || !Array.isArray(rays) || rays.length === 0) return;
684
-
685
- const origin = getCarRayOrigin(car);
686
-
687
- rays.forEach((ray) => {
688
- const rayVector = getCarRayVector(car, Number(ray.angleDegrees) || 0);
689
- const totalDistanceMeters = Math.max(0, Number(ray.lengthMeters) || 0);
690
- const trackDistanceMeters = Math.max(0, Number(ray.track?.distanceMeters) || totalDistanceMeters);
691
- const carDistanceMeters = Math.max(0, Number(ray.car?.distanceMeters) || totalDistanceMeters);
692
- const trackHit = Boolean(ray.track?.hit) && trackDistanceMeters <= totalDistanceMeters;
693
- const carHit = Boolean(ray.car?.hit) && carDistanceMeters <= totalDistanceMeters;
694
- const fullEnd = pointFromRay(origin, rayVector, metersToSimUnits(totalDistanceMeters));
695
-
696
- this.sensorLayer
697
- .moveTo(origin.x, origin.y)
698
- .lineTo(fullEnd.x, fullEnd.y)
699
- .stroke({ width: 2, color: 0xffffff, alpha: 0.18, cap: 'round' });
700
-
701
- if (!trackHit && !carHit) return;
702
-
703
- const carIsClosest = carHit && (!trackHit || carDistanceMeters <= trackDistanceMeters);
704
- const hitDistanceMeters = carIsClosest ? carDistanceMeters : trackDistanceMeters;
705
- const hitEnd = pointFromRay(origin, rayVector, metersToSimUnits(hitDistanceMeters));
706
- const hitColor = carIsClosest
707
- ? SENSOR_RAY_CAR_COLOR
708
- : ray.track?.kind === 'entry'
709
- ? SENSOR_RAY_TRACK_ENTRY_COLOR
710
- : SENSOR_RAY_TRACK_COLOR;
711
-
712
- this.sensorLayer
713
- .moveTo(origin.x, origin.y)
714
- .lineTo(hitEnd.x, hitEnd.y)
715
- .stroke({ width: carIsClosest ? 5 : 3.4, color: hitColor, alpha: carIsClosest ? 0.9 : 0.76, cap: 'round' });
716
- this.sensorLayer
717
- .circle(hitEnd.x, hitEnd.y, carIsClosest ? 7 : 5)
718
- .fill({ color: hitColor, alpha: 0.92 })
719
- .circle(hitEnd.x, hitEnd.y, carIsClosest ? 10 : 8)
720
- .stroke({ width: 1.5, color: 0x10131a, alpha: 0.72 });
721
- });
638
+ renderExpertSensorRays({
639
+ snapshot,
640
+ observation,
641
+ sensorLayer: this.sensorLayer,
642
+ expertMode: this.expertMode,
643
+ expertOptions: {
644
+ ...this.options.expert,
645
+ visualizeSensors: withSelectedExpertSensorDriver(this.options.expert?.visualizeSensors, this.selectedId),
646
+ },
722
647
  });
723
648
  }
724
649
 
725
650
  renderDrsTrails(snapshot) {
726
- if (!this.trailLayer) return;
727
-
728
- snapshot.cars.forEach((car) => {
729
- const history = this.drsTrails.get(car.id) ?? [];
730
- const rear = {
731
- x: car.x - Math.cos(car.heading) * CAR_WORLD_LENGTH * 0.46,
732
- y: car.y - Math.sin(car.heading) * CAR_WORLD_LENGTH * 0.46,
733
- at: snapshot.time,
734
- };
735
- const last = history[history.length - 1];
736
-
737
- if (
738
- car.drsActive &&
739
- (!last || Math.hypot(rear.x - last.x, rear.y - last.y) >= DRS_TRAIL_MIN_DISTANCE)
740
- ) {
741
- history.push(rear);
742
- }
743
-
744
- const activeTrail = history.filter((point) => snapshot.time - point.at <= DRS_TRAIL_TTL);
745
- if (activeTrail.length) this.drsTrails.set(car.id, activeTrail);
746
- else this.drsTrails.delete(car.id);
747
- });
748
-
749
- this.trailLayer.clear();
750
- this.drsTrails.forEach((history) => {
751
- for (let index = 1; index < history.length; index += 1) {
752
- const previous = history[index - 1];
753
- const point = history[index];
754
- const age = snapshot.time - point.at;
755
- const life = clamp(1 - age / DRS_TRAIL_TTL, 0, 1);
756
- this.trailLayer.moveTo(previous.x, previous.y);
757
- this.trailLayer.lineTo(point.x, point.y);
758
- this.trailLayer.stroke({
759
- width: 5 + life * 7,
760
- color: 0x3be8ff,
761
- alpha: 0.22 + life * 0.48,
762
- cap: 'round',
763
- join: 'round',
764
- });
765
- }
766
- });
651
+ this.drsTrailRenderer.render(snapshot, this.trailLayer);
767
652
  }
768
653
 
769
- applyCamera(snapshot) {
770
- if (!this.worldLayer) return;
771
-
772
- const width = this.canvasHost.clientWidth || 900;
773
- const height = this.canvasHost.clientHeight || 640;
774
- const safeArea = this.getCameraSafeArea(width);
775
- const baseScale = Math.min(safeArea.width / (WORLD.width + 260), height / (WORLD.height + 220));
776
- const frame = this.getCameraFrame(snapshot, width, height, baseScale, safeArea);
777
- const scale = frame.scale;
778
- const target = frame.target;
779
- this.camera.x += (target.x - this.camera.x) * 0.08;
780
- this.camera.y += (target.y - this.camera.y) * 0.08;
781
- const activeScale = this.camera.scale === null
782
- ? scale
783
- : this.camera.scale + (scale - this.camera.scale) * 0.12;
784
- this.camera.scale = activeScale;
785
- this.worldLayer.scale.set(activeScale);
786
- this.worldLayer.position.set(
787
- frame.screenX - this.camera.x * activeScale,
788
- frame.screenY - this.camera.y * activeScale,
789
- );
654
+ applyCamera(snapshot, { immediate = false } = {}) {
655
+ this.cameraController.applyToWorldLayer(this.worldLayer, snapshot, {
656
+ immediate,
657
+ selectedId: this.selectedId,
658
+ });
790
659
  }
791
660
 
792
661
  getCameraSafeArea(width) {
793
- if (this.cameraSafeAreaCache?.width === width) {
794
- return this.cameraSafeAreaCache.safeArea;
795
- }
796
-
797
- const canvasRect = this.canvasHost?.getBoundingClientRect?.();
798
- const towerRect = this.readouts.timingTower?.getBoundingClientRect?.();
799
- if (!canvasRect || !towerRect) {
800
- const safeArea = { left: 0, width };
801
- this.cameraSafeAreaCache = { width, safeArea };
802
- return safeArea;
803
- }
804
- const overlapsCanvasHorizontally = towerRect.right > canvasRect.left && towerRect.left < canvasRect.right;
805
- const hasVerticalBounds = Number.isFinite(canvasRect.top) &&
806
- Number.isFinite(canvasRect.bottom) &&
807
- Number.isFinite(towerRect.top) &&
808
- Number.isFinite(towerRect.bottom);
809
- const overlapsCanvasVertically = !hasVerticalBounds ||
810
- (towerRect.bottom > canvasRect.top && towerRect.top < canvasRect.bottom);
811
- const canvasWidth = Math.max(1, canvasRect.right - canvasRect.left || width);
812
- const towerWidth = Math.max(0, towerRect.right - towerRect.left);
813
- const isSideGutter = towerWidth < canvasWidth * 0.6;
814
- if (!overlapsCanvasHorizontally || !overlapsCanvasVertically || !isSideGutter) {
815
- const safeArea = { left: 0, width };
816
- this.cameraSafeAreaCache = { width, safeArea };
817
- return safeArea;
818
- }
819
-
820
- const overlayGap = 16;
821
- const reservedLeft = clamp(towerRect.right - canvasRect.left + overlayGap, 0, width * 0.48);
822
- const safeArea = {
823
- left: reservedLeft,
824
- width: Math.max(1, width - reservedLeft),
825
- };
826
- this.cameraSafeAreaCache = { width, safeArea };
827
- return safeArea;
662
+ return this.cameraController.getSafeArea(width);
828
663
  }
829
664
 
830
665
  getCameraFrame(snapshot, width, height, baseScale, safeArea = { left: 0, width }) {
831
- const screenCenterX = safeArea.left + safeArea.width / 2;
832
-
833
- if (this.camera.mode === 'show-all') {
834
- const bounds = snapshot.cars.reduce((box, car) => ({
835
- minX: Math.min(box.minX, car.x),
836
- minY: Math.min(box.minY, car.y),
837
- maxX: Math.max(box.maxX, car.x),
838
- maxY: Math.max(box.maxY, car.y),
839
- }), {
840
- minX: Infinity,
841
- minY: Infinity,
842
- maxX: -Infinity,
843
- maxY: -Infinity,
844
- });
845
- const target = {
846
- x: (bounds.minX + bounds.maxX) / 2,
847
- y: (bounds.minY + bounds.maxY) / 2,
848
- };
849
- const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX + SHOW_ALL_PADDING);
850
- const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY + SHOW_ALL_PADDING);
851
- const safeHeight = Math.max(height * 0.48, height - SHOW_ALL_TOP_RESERVED - SHOW_ALL_BOTTOM_RESERVED);
852
- const scale = clamp(
853
- Math.min(safeArea.width / fitWidth, safeHeight / fitHeight),
854
- baseScale * SHOW_ALL_MIN_ZOOM,
855
- baseScale * SHOW_ALL_MAX_ZOOM,
856
- );
857
- return {
858
- target,
859
- scale,
860
- screenX: screenCenterX,
861
- screenY: SHOW_ALL_TOP_RESERVED + safeHeight / 2,
862
- };
863
- }
864
-
865
- return {
866
- target: this.getCameraTarget(snapshot),
867
- scale: baseScale * this.camera.zoom,
868
- screenX: screenCenterX,
869
- screenY: height / 2,
870
- };
666
+ return this.cameraController.getFrame(snapshot, width, height, baseScale, safeArea, this.selectedId);
871
667
  }
872
668
 
873
669
  getCameraTarget(snapshot) {
874
- if (this.camera.mode === 'overview') {
875
- return { x: WORLD.width / 2, y: WORLD.height / 2 };
876
- }
670
+ return this.cameraController.getTarget(snapshot, this.selectedId);
671
+ }
877
672
 
878
- if (this.camera.mode === 'selected') {
879
- const selected = snapshot.cars.find((car) => car.id === this.selectedId);
880
- if (selected) return selected;
881
- }
673
+ getTrackCameraBounds(track) {
674
+ return this.cameraController.getTrackBounds(track);
675
+ }
882
676
 
883
- const leader = snapshot.cars[0];
884
- return leader ? { x: leader.x, y: leader.y } : { x: WORLD.width / 2, y: WORLD.height / 2 };
677
+ getCameraBoundsFitScale(bounds, height, safeArea) {
678
+ return this.cameraController.getBoundsFitScale(bounds, height, safeArea);
679
+ }
680
+
681
+ getPitCameraBounds(pitLane) {
682
+ return this.cameraController.getPitBounds(pitLane);
683
+ }
684
+
685
+ getPitCameraFrame(pitLane, height, baseScale, safeArea, screenCenterX, minimumScale = null) {
686
+ return this.cameraController.getPitFrame(pitLane, height, baseScale, safeArea, screenCenterX, minimumScale);
885
687
  }
886
688
 
887
689
  updateDom(snapshot, { emitLifecycle = true } = {}) {
@@ -893,69 +695,50 @@ export class F1SimulatorApp {
893
695
 
894
696
  if (emitLifecycle) this.emitSnapshotLifecycle(snapshot);
895
697
 
896
- if (this.activeRaceDataId && now - this.lastRaceDataInteraction > RACE_DATA_SELECTED_VISIBLE_MS) {
698
+ if (
699
+ this.shouldAutoHideActiveRaceData() &&
700
+ now - this.lastRaceDataInteraction > RACE_DATA_SELECTED_VISIBLE_MS
701
+ ) {
897
702
  this.activeRaceDataId = null;
898
703
  if (this.isRaceDataBannerEnabled('radio')) this.scheduleRadioBreak(now);
899
704
  }
705
+ this.updateStewardMessageState(snapshot, now);
900
706
 
901
- if (this.readouts.mode) {
902
- const modeLabel = snapshot.raceControl.mode === 'safety-car'
903
- ? 'SC'
904
- : snapshot.raceControl.finished
905
- ? 'FINISH'
906
- : 'GREEN';
907
- this.readouts.mode.textContent = modeLabel;
908
- this.readouts.mode.style.color = snapshot.raceControl.mode === 'safety-car'
909
- ? 'var(--yellow)'
910
- : snapshot.raceControl.mode === 'finished'
911
- ? 'var(--red)'
912
- : 'var(--green)';
913
- }
914
707
  this.syncSafetyCarControls(snapshot.raceControl.mode === 'safety-car');
915
- if (this.readouts.lap) this.readouts.lap.textContent = `${leader?.lap ?? 1}/${snapshot.totalLaps}`;
916
- if (this.readouts.towerLap) this.readouts.towerLap.textContent = leader?.lap ?? 1;
917
- if (this.readouts.towerTotalLaps) this.readouts.towerTotalLaps.textContent = snapshot.totalLaps;
918
- if (this.readouts.timingTower) {
919
- this.readouts.timingTower.classList.toggle('is-safety-car', snapshot.raceControl.mode === 'safety-car');
920
- this.readouts.timingTower.classList.toggle('is-pre-start', snapshot.raceControl.mode === 'pre-start');
921
- }
922
- if (this.readouts.towerSafetyBanner) {
923
- this.readouts.towerSafetyBanner.hidden = snapshot.raceControl.mode !== 'safety-car';
924
- }
925
- if (this.readouts.drs) {
926
- this.readouts.drs.textContent = snapshot.raceControl.mode === 'safety-car'
927
- ? 'DISABLED'
928
- : activeDrs
929
- ? `${activeDrs} OPEN`
930
- : 'ARMED';
931
- }
932
- if (this.readouts.contacts) this.readouts.contacts.textContent = String(contactCount);
933
- this.renderStartLights(snapshot.raceControl);
934
- if (this.readouts.camera) {
935
- const zoom = this.camera.mode === 'show-all'
936
- ? Math.round(((this.camera.scale ?? 0) / Math.max(0.0001, this.getBaseScale())) * 100)
937
- : Math.round(this.camera.zoom * 100);
938
- this.readouts.camera.textContent = `${this.camera.mode.toUpperCase().replace('-', ' ')} ${zoom}%`;
939
- }
940
- if (this.readouts.fps) {
941
- this.readouts.fps.textContent = this.fps.current ? `${this.fps.current}` : '--';
942
- }
943
- this.renderRaceFinish(snapshot);
708
+ this.lastFinishClassificationMarkup = renderRaceStatusReadouts({
709
+ readouts: this.readouts,
710
+ startLightNodes: this.startLightNodes,
711
+ snapshot,
712
+ camera: this.camera,
713
+ fps: this.fps,
714
+ activeDrs,
715
+ contactCount,
716
+ driverById: this.driverById,
717
+ lastFinishClassificationMarkup: this.lastFinishClassificationMarkup,
718
+ });
944
719
 
945
- this.updateCameraControls();
720
+ this.updateCameraControls(snapshot);
946
721
  this.syncTimingGapModeControls();
722
+ const timingPenaltyKey = this.getTimingPenaltyKey(snapshot.penalties ?? []);
723
+ const timingOrderKey = this.getTimingOrderKey(snapshot.cars);
724
+ const timingUpdateInterval = timingUpdateIntervalForSpeed(this.simulationSpeed);
947
725
  if (
948
- now - this.lastTimingRenderTime >= TIMING_UPDATE_INTERVAL_MS ||
949
- this.lastTimingRaceMode !== snapshot.raceControl.mode
726
+ now - this.lastTimingRenderTime >= timingUpdateInterval ||
727
+ this.lastTimingRaceMode !== snapshot.raceControl.mode ||
728
+ this.lastTimingPenaltyKey !== timingPenaltyKey ||
729
+ this.lastTimingOrderKey !== timingOrderKey
950
730
  ) {
951
- this.renderTiming(snapshot.cars, snapshot.raceControl.mode);
731
+ this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
952
732
  this.lastTimingRenderTime = now;
953
733
  this.lastTimingRaceMode = snapshot.raceControl.mode;
734
+ this.lastTimingPenaltyKey = timingPenaltyKey;
735
+ this.lastTimingOrderKey = timingOrderKey;
954
736
  }
955
737
  this.renderTelemetry(selected);
956
738
  const activeRaceDataCar = this.activeRaceDataId
957
739
  ? snapshot.cars.find((car) => car.id === this.activeRaceDataId)
958
740
  : null;
741
+ this.renderActiveStewardMessage();
959
742
  if (activeRaceDataCar) {
960
743
  this.renderRaceData(activeRaceDataCar);
961
744
  } else {
@@ -978,12 +761,7 @@ export class F1SimulatorApp {
978
761
  this.lastLeaderLap = leaderLap;
979
762
  }
980
763
 
981
- snapshot.events.forEach((event) => {
982
- const key = `${event.type}:${event.at ?? snapshot.time}:${event.carId ?? ''}:${event.otherCarId ?? ''}:${event.winnerId ?? ''}`;
983
- if (this.emittedRaceEventKeys.has(key)) return;
984
- this.emittedRaceEventKeys.add(key);
985
- this.emitHostCallback('onRaceEvent', event, snapshot);
986
- });
764
+ this.emitRaceEvents(snapshot.events, snapshot);
987
765
 
988
766
  if (snapshot.raceControl.finished && !this.raceFinishEmitted) {
989
767
  this.raceFinishEmitted = true;
@@ -995,39 +773,22 @@ export class F1SimulatorApp {
995
773
  }
996
774
  }
997
775
 
998
- renderRaceFinish(snapshot) {
999
- const panel = this.readouts.finishPanel;
1000
- if (!panel) return;
1001
-
1002
- panel.hidden = !snapshot.raceControl.finished;
1003
- if (!snapshot.raceControl.finished) return;
1004
-
1005
- const winner = snapshot.raceControl.winner;
1006
- const winnerDriver = winner ? this.driverById.get(winner.id) : null;
1007
- const winnerName = winnerDriver?.name ?? winner?.name ?? 'Winner';
1008
- panel.style.setProperty('--driver-color', winner?.color ?? winnerDriver?.color ?? 'var(--red)');
1009
- setText(this.readouts.finishWinner, winnerName);
1010
-
1011
- if (this.readouts.finishClassification) {
1012
- const topThree = (snapshot.raceControl.classification ?? []).slice(0, 3);
1013
- const classificationMarkup = topThree.map((entry) => `
1014
- <li>
1015
- <span>P${escapeHtml(entry.rank)}</span>
1016
- <strong>${escapeHtml(entry.timingCode ?? entry.code ?? entry.id)}</strong>
1017
- </li>
1018
- `).join('');
1019
- if (classificationMarkup !== this.lastFinishClassificationMarkup) {
1020
- this.readouts.finishClassification.innerHTML = classificationMarkup;
1021
- this.lastFinishClassificationMarkup = classificationMarkup;
1022
- }
1023
- }
776
+ emitRaceEvents(events = [], snapshot) {
777
+ events.forEach((event) => {
778
+ const key = `${event.type}:${event.at ?? snapshot.time}:${event.carId ?? ''}:${event.otherCarId ?? ''}:${event.winnerId ?? ''}`;
779
+ if (this.emittedRaceEventKeys.has(key)) return;
780
+ this.emittedRaceEventKeys.add(key);
781
+ this.emitHostCallback('onRaceEvent', event, snapshot);
782
+ });
1024
783
  }
1025
784
 
1026
- getBaseScale() {
1027
- const width = this.canvasHost.clientWidth || 900;
1028
- const height = this.canvasHost.clientHeight || 640;
1029
- const safeArea = this.getCameraSafeArea(width);
1030
- return Math.min(safeArea.width / (WORLD.width + 260), height / (WORLD.height + 220));
785
+ renderRaceFinish(snapshot) {
786
+ this.lastFinishClassificationMarkup = renderRaceFinish({
787
+ readouts: this.readouts,
788
+ snapshot,
789
+ driverById: this.driverById,
790
+ lastFinishClassificationMarkup: this.lastFinishClassificationMarkup,
791
+ });
1031
792
  }
1032
793
 
1033
794
  sampleFps(now) {
@@ -1040,24 +801,7 @@ export class F1SimulatorApp {
1040
801
  }
1041
802
 
1042
803
  renderStartLights(raceControl) {
1043
- const panel = this.readouts.startLights;
1044
- if (!panel) return;
1045
-
1046
- const start = raceControl.start;
1047
- const visible = Boolean(start?.visible);
1048
- panel.hidden = !visible;
1049
- if (!visible) return;
1050
-
1051
- this.startLightNodes.forEach((light, index) => {
1052
- light.classList.toggle('is-lit', index < (start.lightsLit ?? 0));
1053
- });
1054
- panel.classList.toggle('is-lights-out', raceControl.mode === 'green' && start.released);
1055
-
1056
- if (this.readouts.startLightsLabel) {
1057
- this.readouts.startLightsLabel.textContent = raceControl.mode === 'green' && start.released
1058
- ? 'Lights out'
1059
- : `${start.lightsLit}/${start.lightCount}`;
1060
- }
804
+ renderStartLights(this.readouts, this.startLightNodes, raceControl);
1061
805
  }
1062
806
 
1063
807
  selectCar(id, { focus = false } = {}) {
@@ -1066,9 +810,7 @@ export class F1SimulatorApp {
1066
810
  this.lastRaceDataInteraction = performance.now();
1067
811
  if (this.isRaceDataBannerEnabled('radio')) this.scheduleRadioBreak(this.lastRaceDataInteraction);
1068
812
  if (focus) {
1069
- this.camera.mode = 'selected';
1070
- this.camera.zoom = CAMERA_PRESETS.selected;
1071
- this.updateCameraControls();
813
+ this.setCameraMode('selected');
1072
814
  }
1073
815
  this.lastTimingRenderTime = 0;
1074
816
  const snapshot = this.sim.snapshot();
@@ -1077,256 +819,108 @@ export class F1SimulatorApp {
1077
819
  if (driver) this.emitHostCallback('onDriverSelect', driver, snapshot);
1078
820
  }
1079
821
 
1080
- renderTiming(cars, raceMode) {
1081
- if (!this.timingList) return;
822
+ renderTiming(cars, raceMode, penalties = []) {
1082
823
  this.syncTimingGapModeControls();
1083
- const timingMarkup = cars.map((car) => {
1084
- const driver = this.driverById.get(car.id);
1085
- let gap = 'Leader';
1086
- if (raceMode === 'finished') {
1087
- gap = car.rank === 1 ? 'Winner' : 'FIN';
1088
- } else if (raceMode === 'safety-car' && car.rank > 1) {
1089
- gap = 'SC';
1090
- } else if (raceMode === 'pre-start') {
1091
- gap = car.rank === 1 ? 'Pole' : 'Grid';
1092
- } else if (car.rank > 1) {
1093
- gap = this.formatTimingGap(car);
1094
- }
1095
- const tire = car.tire ?? driver?.tire ?? 'M';
1096
- const timingCode = car.timingCode ?? driver?.timingCode ?? car.code;
1097
- const team = car.team ?? driver?.team ?? null;
1098
- const icon = team?.icon ?? car.icon ?? driver?.icon ?? timingCode;
1099
- const iconColor = team?.color ?? car.color;
1100
-
1101
- return `
1102
- <li>
1103
- <button class="timing-row ${car.id === this.selectedId ? 'is-selected' : ''}" type="button"
1104
- data-driver-id="${escapeHtml(car.id)}" aria-label="Select ${escapeHtml(car.name)}"
1105
- style="--driver-color: ${escapeHtml(car.color)}">
1106
- <span class="timing-position">${car.rank}</span>
1107
- <span class="timing-icon timing-team-icon" aria-hidden="true" style="--team-color: ${escapeHtml(iconColor)}">${escapeHtml(icon)}</span>
1108
- <span class="timing-name" title="${escapeHtml(car.name)}">${escapeHtml(timingCode)}</span>
1109
- <span class="timing-gap">${escapeHtml(gap)}</span>
1110
- <span class="timing-tire timing-tire--${getTireClass(tire)}">${escapeHtml(tire)}</span>
1111
- </button>
1112
- </li>
1113
- `;
1114
- }).join('');
1115
- if (timingMarkup !== this.lastTimingMarkup) {
1116
- this.timingList.innerHTML = timingMarkup;
1117
- this.lastTimingMarkup = timingMarkup;
1118
- }
824
+ this.lastTimingMarkup = renderTimingTower({
825
+ timingList: this.timingList,
826
+ cars,
827
+ raceMode,
828
+ penalties,
829
+ driverById: this.driverById,
830
+ selectedId: this.selectedId,
831
+ timingGapMode: this.timingGapMode,
832
+ timingPenaltyBadgesEnabled: this.timingPenaltyBadgesEnabled,
833
+ lastTimingMarkup: this.lastTimingMarkup,
834
+ });
835
+ }
836
+
837
+ getTimingOrderKey(cars = []) {
838
+ return getTimingOrderKey(cars, { timingGapMode: this.timingGapMode });
839
+ }
840
+
841
+ isWavedFlagCar(car) {
842
+ return isWavedFlagCar(car);
843
+ }
844
+
845
+ getPenaltyByDriver(penalties = []) {
846
+ return getPenaltyByDriver(penalties);
847
+ }
848
+
849
+ getTimingPenaltyKey(penalties = []) {
850
+ return getTimingPenaltyKey(penalties, {
851
+ timingPenaltyBadgesEnabled: this.timingPenaltyBadgesEnabled,
852
+ });
1119
853
  }
1120
854
 
1121
855
  formatTimingGap(car) {
1122
- const value = this.timingGapMode === 'leader'
1123
- ? car.leaderGapSeconds
1124
- : (car.intervalAheadSeconds ?? car.gapAheadSeconds);
1125
- return Number.isFinite(value) ? `+${Math.max(0, value).toFixed(3)}` : '--';
856
+ return formatRaceGap(car, this.timingGapMode);
857
+ }
858
+
859
+ formatLapGap(laps) {
860
+ return formatLapGap(laps);
861
+ }
862
+
863
+ formatTelemetryGap(car, mode) {
864
+ return formatTelemetryGap(car, mode);
1126
865
  }
1127
866
 
1128
867
  syncTimingGapModeControls() {
1129
- const label = this.timingGapMode === 'leader' ? 'Gap' : 'Int';
1130
- setText(this.readouts.timingGapLabel, label);
1131
- this.timingGapModeButtons.forEach((button) => {
1132
- const active = button.dataset.timingGapMode === this.timingGapMode;
1133
- button.setAttribute('aria-pressed', String(active));
1134
- button.classList?.toggle?.('is-active', active);
868
+ syncTimingGapModeControls({
869
+ readouts: this.readouts,
870
+ buttons: this.timingGapModeButtons,
871
+ timingGapMode: this.timingGapMode,
1135
872
  });
1136
873
  }
1137
874
 
1138
875
  renderTelemetry(car) {
1139
- if (!car) return;
1140
- const driver = this.driverById.get(car.id);
1141
- const icon = car.icon ?? driver?.icon ?? car.code;
1142
- const driverNumber = formatDriverNumber(car.driverNumber ?? driver?.driverNumber);
1143
- const drsState = car.drsActive ? 'OPEN' : car.drsEligible ? 'READY' : 'OFF';
1144
- const surface = (car.surface ?? 'track').toUpperCase();
1145
-
1146
- setTextAll(this.readouts.selectedCode, car.code);
1147
- this.readouts.selectedCode?.forEach?.((node) => {
1148
- node.style.color = car.color;
876
+ renderTelemetryReadouts({
877
+ readouts: this.readouts,
878
+ car,
879
+ driverById: this.driverById,
1149
880
  });
1150
- this.readouts.telemetrySectorBanners?.forEach?.((node) => {
1151
- node.style.setProperty('--driver-color', car.color);
1152
- });
1153
- setTextAll(this.readouts.selectedName, car.name);
1154
- setTextAll(this.readouts.speed, `${Math.round(car.speedKph)} km/h`);
1155
- setTextAll(this.readouts.throttle, `${Math.round(car.throttle * 100)}%`);
1156
- setTextAll(this.readouts.brake, `${Math.round(car.brake * 100)}%`);
1157
- setTextAll(this.readouts.tyres, `${Math.round(car.tireEnergy ?? 100)}%`);
1158
- setTextAll(this.readouts.selectedDrs, drsState);
1159
- setTextAll(this.readouts.surface, surface);
1160
- setTextAll(
1161
- this.readouts.gap,
1162
- car.rank === 1 || !Number.isFinite(car.gapAheadSeconds)
1163
- ? '--'
1164
- : `${car.gapAheadSeconds.toFixed(2)}s`,
1165
- );
1166
- setTextAll(
1167
- this.readouts.leaderGap,
1168
- car.rank === 1 || !Number.isFinite(car.leaderGapSeconds)
1169
- ? '--'
1170
- : `${car.leaderGapSeconds.toFixed(2)}s`,
1171
- );
1172
-
1173
881
  this.renderCarDriverOverview(car);
1174
882
  this.renderLapTelemetry(car.lapTelemetry);
1175
883
  }
1176
884
 
1177
885
  renderLapTelemetry(telemetry) {
1178
- if (!telemetry) return;
1179
-
1180
- setTextAll(this.readouts.currentSector, `S${telemetry.currentSector ?? 1}`);
1181
- setTextAll(this.readouts.completedLaps, `${telemetry.completedLaps ?? 0} laps`);
1182
- setTextAll(this.readouts.currentLapTime, formatTelemetryTime(telemetry.currentLapTime));
1183
- setTextAll(this.readouts.lastLapTime, formatTelemetryTime(telemetry.lastLapTime));
1184
- setTextAll(this.readouts.bestLapTime, formatTelemetryTime(telemetry.bestLapTime));
1185
-
1186
- this.readouts.telemetrySectorBars?.forEach((bar) => {
1187
- const sector = Number(bar.dataset.telemetrySectorBar);
1188
- const index = sector - 1;
1189
- const isActive = sector === telemetry.currentSector;
1190
- const sectorComplete = Number.isFinite(telemetry.currentSectors?.[index]);
1191
- const fill = sectorComplete
1192
- ? 100
1193
- : (isActive ? clamp((telemetry.currentSectorProgress ?? 0) * 100, 0, 100) : 0);
1194
- const fillValue = `${fill.toFixed(1)}%`;
1195
- if (bar.style.getPropertyValue('--sector-fill') !== fillValue) {
1196
- bar.style.setProperty('--sector-fill', fillValue);
1197
- }
1198
- bar.classList.toggle('is-active', isActive);
1199
- bar.classList.toggle('is-complete', sectorComplete);
1200
- setPerformanceClass(bar, telemetry.sectorPerformance?.current?.[index]);
1201
- });
1202
-
1203
- this.readouts.telemetrySectorTimes?.forEach((node) => {
1204
- const sector = Number(node.dataset.telemetrySectorTime);
1205
- const index = sector - 1;
1206
- const value = sector === telemetry.currentSector && !Number.isFinite(telemetry.currentSectors?.[index])
1207
- ? telemetry.currentSectorElapsed
1208
- : telemetry.currentSectors?.[index];
1209
- setText(node, formatTelemetryTime(value));
1210
- setPerformanceClass(node, telemetry.sectorPerformance?.current?.[index]);
1211
- });
1212
-
1213
- this.readouts.telemetrySectorLast?.forEach((node) => {
1214
- const index = Number(node.dataset.telemetrySectorLast) - 1;
1215
- setText(node, formatTelemetryTime(telemetry.lastSectors?.[index]));
1216
- setPerformanceClass(node, telemetry.sectorPerformance?.last?.[index]);
1217
- });
1218
-
1219
- this.readouts.telemetrySectorBest?.forEach((node) => {
1220
- const index = Number(node.dataset.telemetrySectorBest) - 1;
1221
- setText(node, formatTelemetryTime(telemetry.bestSectors?.[index]));
1222
- setPerformanceClass(node, telemetry.sectorPerformance?.best?.[index]);
1223
- });
886
+ renderLapTelemetry(this.readouts, telemetry);
1224
887
  }
1225
888
 
1226
889
  getOverviewFields(driver, mode) {
1227
- if (mode === 'driver') {
1228
- const ratings = driver?.constructorArgs?.driver?.ratings ?? {};
1229
- return [
1230
- ...DRIVER_OVERVIEW_FIELDS.map(([key, label]) => ({
1231
- key,
1232
- label,
1233
- value: ratings[key] ?? DRIVER_STAT_DEFINITIONS[key]?.neutral,
1234
- })),
1235
- ...normalizeCustomFields([
1236
- ...(driver?.constructorArgs?.driver?.customFields ?? []),
1237
- ...(driver?.customFields ?? []),
1238
- ]),
1239
- ];
1240
- }
1241
-
1242
- const ratings = driver?.constructorArgs?.vehicle?.ratings ?? driver?.vehicle?.ratings ?? {};
1243
- return [
1244
- ...VEHICLE_OVERVIEW_FIELDS.map(([key, label]) => ({
1245
- key,
1246
- label,
1247
- value: ratings[key] ?? VEHICLE_STAT_DEFINITIONS[key]?.neutral,
1248
- })),
1249
- ...normalizeCustomFields(driver?.vehicle?.customFields ?? driver?.constructorArgs?.vehicle?.customFields ?? []),
1250
- ];
890
+ return getOverviewFields(driver, mode);
1251
891
  }
1252
892
 
1253
893
  renderCarDriverOverview(car) {
1254
- if (!car || !this.readouts.carOverview) return;
1255
- const driver = this.driverById.get(car.id);
1256
- const icon = car.icon ?? driver?.icon ?? car.code;
1257
- const driverNumber = formatDriverNumber(car.driverNumber ?? driver?.driverNumber);
1258
- const mode = this.overviewMode === 'driver' ? 'driver' : 'vehicle';
1259
- const fields = this.getOverviewFields(driver, mode);
1260
- const displayFields = fields.length > 0
1261
- ? fields
1262
- : [{ label: mode === 'driver' ? 'Driver fields' : 'Car fields', value: 'No custom fields' }];
1263
- const imageSrc = mode === 'driver'
1264
- ? (driver?.driverImage ?? driver?.portrait ?? driver?.avatar ?? this.assets.driverHelmet)
1265
- : this.assets.carOverview;
1266
- const overviewCode = mode === 'driver'
1267
- ? `${car.code} driver`
1268
- : `${driver?.vehicle?.name ?? car.vehicleName ?? car.code}`;
1269
- const overviewKey = JSON.stringify({
1270
- id: car.id,
1271
- mode,
1272
- color: car.color,
1273
- code: car.code,
1274
- icon,
1275
- driverNumber,
1276
- imageSrc,
1277
- overviewCode,
1278
- fields: displayFields.map((field) => [field.label, field.value]),
894
+ const previousKey = this.lastOverviewRenderKey;
895
+ this.lastOverviewRenderKey = renderCarDriverOverview({
896
+ readouts: this.readouts,
897
+ car,
898
+ driverById: this.driverById,
899
+ assets: this.assets,
900
+ overviewMode: this.overviewMode,
901
+ lastOverviewRenderKey: this.lastOverviewRenderKey,
1279
902
  });
1280
- if (overviewKey === this.lastOverviewRenderKey) return;
1281
- this.lastOverviewRenderKey = overviewKey;
903
+ if (this.lastOverviewRenderKey !== previousKey) this.updateOverviewModeButtons();
904
+ }
1282
905
 
1283
- this.readouts.carOverview?.style.setProperty('--driver-color', car.color);
1284
- this.readouts.carOverviewDiagram?.style.setProperty('--driver-color', car.color);
1285
- if (this.readouts.carOverviewTitle) {
1286
- this.readouts.carOverviewTitle.textContent = mode === 'driver' ? 'Driver overview' : 'Car overview';
1287
- }
1288
- if (this.readouts.carOverviewCode) {
1289
- this.readouts.carOverviewCode.textContent = overviewCode;
1290
- }
1291
- if (this.readouts.carOverviewIcon) this.readouts.carOverviewIcon.textContent = icon;
1292
- if (this.readouts.carOverviewImage) {
1293
- this.readouts.carOverviewImage.src = imageSrc;
1294
- }
1295
- if (this.readouts.carOverviewNumber) this.readouts.carOverviewNumber.textContent = driverNumber;
1296
- if (this.readouts.carOverviewCoreStat) this.readouts.carOverviewCoreStat.textContent = mode === 'driver' ? 'Driver' : 'Car';
1297
- this.readouts.carOverviewFields.forEach((fieldNode, index) => {
1298
- const field = displayFields[index];
1299
- fieldNode.hidden = !field;
1300
- if (!field) return;
1301
- const labelNode = fieldNode.querySelector('[data-overview-field-label]');
1302
- const valueNode = fieldNode.querySelector('[data-overview-field-value]');
1303
- setText(labelNode, field.label);
1304
- setText(valueNode, field.value);
906
+ renderRaceData(car) {
907
+ renderRaceData({
908
+ car,
909
+ drivers: this.drivers,
910
+ readouts: this.readouts,
911
+ options: {
912
+ ...this.options,
913
+ isRaceDataBannerEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
914
+ },
1305
915
  });
1306
- this.updateOverviewModeButtons();
1307
916
  }
1308
917
 
1309
- renderRaceData(car) {
1310
- if (!car || !this.readouts.raceDataPanel || !this.isRaceDataBannerEnabled('project')) return;
1311
- const driver = this.drivers.find((item) => item.id === car.id);
1312
- if (!driver) return;
1313
-
1314
- this.readouts.raceDataPanel.style.setProperty('--driver-color', driver.color);
1315
- this.readouts.raceDataPanel.classList.remove('is-hidden');
1316
- this.readouts.raceDataPanel.classList.add('is-project-mode');
1317
- this.readouts.raceDataPanel.classList.remove('is-radio-mode');
1318
- this.readouts.raceDataPanel.removeAttribute('data-idle-mode');
1319
- setText(this.readouts.raceDataKicker, 'Project');
1320
- setText(this.readouts.raceDataTitle, driver.name);
1321
- if (this.readouts.raceDataNumber) {
1322
- this.readouts.raceDataNumber.textContent = formatDriverNumber(car.driverNumber ?? driver.driverNumber);
1323
- }
1324
- if (this.readouts.raceDataSubtitle) {
1325
- this.readouts.raceDataSubtitle.textContent = `${car.code} - P${car.rank} - ${driver.raceData?.[0] ?? 'Project entry'}`;
1326
- }
1327
- if (this.readouts.raceDataOpen) {
1328
- this.readouts.raceDataOpen.hidden = typeof this.options.onDriverOpen !== 'function';
1329
- }
918
+ shouldAutoHideActiveRaceData() {
919
+ return shouldAutoHideActiveRaceData({
920
+ activeRaceDataId: this.activeRaceDataId,
921
+ options: this.options,
922
+ readouts: this.readouts,
923
+ });
1330
924
  }
1331
925
 
1332
926
  renderProjectRadio(now = performance.now()) {
@@ -1341,26 +935,52 @@ export class F1SimulatorApp {
1341
935
  return;
1342
936
  }
1343
937
 
1344
- const radio = this.getProjectRadioQuote();
1345
-
1346
- this.readouts.raceDataPanel.style.setProperty('--driver-color', radio.color);
1347
- this.readouts.raceDataPanel.classList.remove('is-hidden');
1348
- this.readouts.raceDataPanel.classList.add('is-radio-mode');
1349
- this.readouts.raceDataPanel.classList.remove('is-project-mode');
1350
- this.readouts.raceDataPanel.dataset.idleMode = 'quote';
1351
- setText(this.readouts.raceDataKicker, 'Project radio');
1352
- setText(this.readouts.raceDataTitle, radio.title);
1353
- setText(this.readouts.raceDataNumber, '');
1354
- setText(this.readouts.raceDataSubtitle, radio.subtitle);
1355
- if (this.readouts.raceDataOpen) this.readouts.raceDataOpen.hidden = true;
938
+ renderProjectRadio({
939
+ readouts: this.readouts,
940
+ radio: this.getProjectRadioQuote(),
941
+ });
1356
942
  }
1357
943
 
1358
944
  hideRaceDataPanel() {
1359
- if (!this.readouts.raceDataPanel) return;
1360
- this.readouts.raceDataPanel.classList.add('is-hidden');
1361
- this.readouts.raceDataPanel.classList.remove('is-project-mode', 'is-radio-mode');
1362
- this.readouts.raceDataPanel.removeAttribute('data-idle-mode');
1363
- if (this.readouts.raceDataOpen) this.readouts.raceDataOpen.hidden = true;
945
+ hideRaceDataPanel(this.readouts);
946
+ }
947
+
948
+ dismissRaceDataPanel(now = performance.now()) {
949
+ this.activeRaceDataId = null;
950
+ this.lastRaceDataInteraction = now;
951
+ this.radioState.visible = false;
952
+ this.radioState.nextChangeAt = this.getNextRadioBreakTime(now);
953
+ this.hideRaceDataPanel();
954
+ }
955
+
956
+ updateStewardMessageState(snapshot, now = performance.now()) {
957
+ const state = updateStewardMessageState({
958
+ snapshot,
959
+ now,
960
+ state: {
961
+ activeStewardMessage: this.activeStewardMessage,
962
+ lastPenaltyBannerId: this.lastPenaltyBannerId,
963
+ lastStewardMessageKey: this.lastStewardMessageKey,
964
+ },
965
+ driverById: this.driverById,
966
+ penaltyBannerEnabled: this.penaltyBannerEnabled,
967
+ stewardMessageNode: this.readouts.stewardMessage,
968
+ });
969
+ this.activeStewardMessage = state.activeStewardMessage;
970
+ this.lastPenaltyBannerId = state.lastPenaltyBannerId;
971
+ this.lastStewardMessageKey = state.lastStewardMessageKey;
972
+ }
973
+
974
+ createPenaltyStewardMessage(penalty) {
975
+ return createPenaltyStewardMessage(penalty, this.driverById);
976
+ }
977
+
978
+ createWarningStewardMessage(event) {
979
+ return createWarningStewardMessage(event, this.driverById);
980
+ }
981
+
982
+ renderActiveStewardMessage() {
983
+ renderActiveStewardMessage(this.readouts, this.activeStewardMessage);
1364
984
  }
1365
985
 
1366
986
  updateRadioSchedule(now) {
@@ -1387,26 +1007,22 @@ export class F1SimulatorApp {
1387
1007
  }
1388
1008
 
1389
1009
  scheduleRadioBreak(now) {
1390
- this.radioState.visible = false;
1391
- this.radioState.nextChangeAt = this.getNextRadioBreakTime(now);
1010
+ scheduleRadioBreak({
1011
+ radioState: this.radioState,
1012
+ now,
1013
+ isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
1014
+ nextRandom: () => this.nextRadioRandom(),
1015
+ });
1392
1016
  }
1393
1017
 
1394
1018
  scheduleRadioPopup(now) {
1395
- if (!this.isRaceDataBannerEnabled('radio')) {
1396
- this.scheduleRadioBreak(now);
1397
- return;
1398
- }
1399
- const driverIndex = Math.floor(this.nextRadioRandom() * this.drivers.length);
1400
- const driver = this.drivers[driverIndex] ?? this.drivers[0];
1401
- const quoteCount = Math.max(1, driver.raceData?.length ?? 1);
1402
- this.radioState.visible = true;
1403
- this.radioState.driverIndex = driverIndex;
1404
- this.radioState.quoteIndex = Math.floor(this.nextRadioRandom() * quoteCount);
1405
- this.radioState.nextChangeAt = now + this.randomRadioRange(RADIO_VISIBLE_MIN_MS, RADIO_VISIBLE_MAX_MS);
1406
- }
1407
-
1408
- randomRadioRange(min, max) {
1409
- return min + this.nextRadioRandom() * (max - min);
1019
+ scheduleRadioPopup({
1020
+ radioState: this.radioState,
1021
+ now,
1022
+ drivers: this.drivers,
1023
+ isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
1024
+ nextRandom: () => this.nextRadioRandom(),
1025
+ });
1410
1026
  }
1411
1027
 
1412
1028
  nextRadioRandom() {
@@ -1415,62 +1031,64 @@ export class F1SimulatorApp {
1415
1031
  }
1416
1032
 
1417
1033
  getNextRadioBreakTime(now) {
1418
- return this.isRaceDataBannerEnabled('radio')
1419
- ? now + this.randomRadioRange(RADIO_BREAK_MIN_MS, RADIO_BREAK_MAX_MS)
1420
- : Number.POSITIVE_INFINITY;
1034
+ return getNextRadioBreakTime({
1035
+ now,
1036
+ isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
1037
+ nextRandom: () => this.nextRadioRandom(),
1038
+ });
1421
1039
  }
1422
1040
 
1423
1041
  resetRaceDataBannerState(now = performance.now()) {
1424
- const initialRaceDataMode = this.raceDataBannerConfig.initial ?? 'project';
1425
- this.activeRaceDataId = this.isRaceDataBannerEnabled('project') && initialRaceDataMode === 'project'
1426
- ? this.selectedId
1427
- : null;
1428
- this.lastRaceDataInteraction = now;
1429
- const showInitialRadio = this.isRaceDataBannerEnabled('radio') && initialRaceDataMode === 'radio';
1430
- this.radioState.visible = showInitialRadio;
1431
- this.radioState.nextChangeAt = showInitialRadio
1432
- ? now + this.randomRadioRange(RADIO_VISIBLE_MIN_MS, RADIO_VISIBLE_MAX_MS)
1433
- : this.getNextRadioBreakTime(now);
1434
- this.radioState.driverIndex = 0;
1435
- this.radioState.quoteIndex = 0;
1042
+ resetRaceDataBannerState({
1043
+ state: this,
1044
+ now,
1045
+ initialMode: this.raceDataBannerConfig.initial ?? 'project',
1046
+ selectedId: this.selectedId,
1047
+ isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
1048
+ nextRandom: () => this.nextRadioRandom(),
1049
+ });
1436
1050
  }
1437
1051
 
1438
1052
  isRaceDataBannerEnabled(kind) {
1439
- return this.raceDataBannerConfig.enabled?.includes(kind) ?? true;
1053
+ return !this.raceDataBannersMuted && (this.raceDataBannerConfig.enabled?.includes(kind) ?? true);
1440
1054
  }
1441
1055
 
1442
1056
  getProjectRadioQuote() {
1443
- const driver = this.drivers[this.radioState.driverIndex] ?? this.drivers[0];
1444
- const quote = driver.raceData?.[this.radioState.quoteIndex] ?? 'Project entry';
1445
-
1446
- return {
1447
- color: driver.color,
1448
- title: driver.name,
1449
- subtitle: `${driver.code} - "${quote}"`,
1450
- };
1057
+ return getProjectRadioQuote({
1058
+ drivers: this.drivers,
1059
+ radioState: this.radioState,
1060
+ });
1451
1061
  }
1452
1062
 
1453
- updateCameraControls() {
1454
- this.cameraButtons.forEach((button) => {
1455
- const isActive = button.dataset.cameraMode === this.camera.mode;
1456
- button.classList.toggle('is-active', isActive);
1457
- button.setAttribute('aria-pressed', String(isActive));
1063
+ updateCameraControls(snapshot = this.sim?.snapshot?.()) {
1064
+ updateCameraControlButtons({
1065
+ camera: this.camera,
1066
+ cameraButtons: this.cameraButtons,
1067
+ snapshot,
1068
+ hasPitCamera: (currentSnapshot) => this.hasPitCamera(currentSnapshot),
1069
+ isCameraModeAvailable: (mode, currentSnapshot) => this.isCameraModeAvailable(mode, currentSnapshot),
1458
1070
  });
1071
+ this.updateBannerMuteControls();
1072
+ }
1073
+
1074
+ updateBannerMuteControls() {
1075
+ updateToggleButtons(this.bannerMuteButtons, this.raceDataBannersMuted);
1076
+ }
1077
+
1078
+ hasPitCamera(snapshot = this.sim?.snapshot?.()) {
1079
+ return this.cameraController.hasPitCamera(snapshot);
1080
+ }
1081
+
1082
+ isCameraModeAvailable(mode, snapshot = this.sim?.snapshot?.()) {
1083
+ return this.cameraController.isModeAvailable(mode, snapshot);
1459
1084
  }
1460
1085
 
1461
1086
  updateOverviewModeButtons() {
1462
- this.overviewModeButtons.forEach((button) => {
1463
- const isActive = button.dataset.overviewMode === this.overviewMode;
1464
- button.classList.toggle('is-active', isActive);
1465
- button.setAttribute('aria-pressed', String(isActive));
1466
- });
1087
+ updateModeButtons(this.overviewModeButtons, this.overviewMode, 'overviewMode');
1467
1088
  }
1468
1089
 
1469
1090
  syncSafetyCarControls(active) {
1470
- this.safetyButtons.forEach((button) => {
1471
- button.classList.toggle('is-active', active);
1472
- button.setAttribute('aria-pressed', String(active));
1473
- });
1091
+ updateToggleButtons(this.safetyButtons, active);
1474
1092
  }
1475
1093
 
1476
1094
  applyExpertOptions(nextOptions = {}) {
@@ -1513,8 +1131,12 @@ export class F1SimulatorApp {
1513
1131
  if (hasOwnOption(nextOptions, 'expert') && expertOptionsChanged(nextOptions.expert, this.options.expert)) {
1514
1132
  throw new Error('PaddockJS restart() does not support changing expert mode. Destroy and mount a new simulator with the new expert options.');
1515
1133
  }
1134
+ this.expert?.destroy?.();
1135
+ this.expert = null;
1516
1136
  this.applyExpertOptions(nextOptions);
1517
1137
  this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
1138
+ this.penaltyBannerEnabled = Boolean(this.options.ui?.penaltyBanners);
1139
+ this.timingPenaltyBadgesEnabled = Boolean(this.options.ui?.timingPenaltyBadges);
1518
1140
  this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
1519
1141
  applyPaddockThemeCssVariables(this.root, this.options.theme);
1520
1142
  this.sim = this.createRaceSimulation();
@@ -1528,11 +1150,20 @@ export class F1SimulatorApp {
1528
1150
  this.resetRaceDataBannerState(performance.now());
1529
1151
  this.lastTimingRenderTime = 0;
1530
1152
  this.lastTimingRaceMode = null;
1153
+ this.lastTimingPenaltyKey = '';
1154
+ this.lastTimingOrderKey = '';
1155
+ this.activePenaltyBanner = null;
1156
+ this.lastPenaltyBannerId = null;
1157
+ this.activeStewardMessage = null;
1158
+ this.lastStewardMessageKey = null;
1531
1159
  this.lastLeaderLap = null;
1532
1160
  this.emittedRaceEventKeys.clear();
1533
1161
  this.raceFinishEmitted = false;
1534
1162
  this.renderTrack();
1535
- this.updateDom(this.sim.snapshot());
1163
+ const snapshot = this.sim.snapshot();
1164
+ this.updateDom(snapshot);
1165
+ this.lastDomUpdateTime = performance.now();
1166
+ this.renderInitialFrame(snapshot);
1536
1167
  }
1537
1168
 
1538
1169
  setSafetyCarDeployed(deployed) {
@@ -1541,12 +1172,52 @@ export class F1SimulatorApp {
1541
1172
  this.syncSafetyCarControls(active);
1542
1173
  }
1543
1174
 
1175
+ setRedFlagDeployed(deployed) {
1176
+ this.sim?.setRedFlag?.(Boolean(deployed));
1177
+ if (this.sim) this.updateDom(this.sim.snapshot());
1178
+ }
1179
+
1180
+ setPitLaneOpen(open) {
1181
+ this.sim?.setPitLaneOpen?.(Boolean(open));
1182
+ if (this.sim) this.updateDom(this.sim.snapshot());
1183
+ }
1184
+
1185
+ setPitIntent(driverId, intent, targetCompound) {
1186
+ const applied = Boolean(this.sim?.setPitIntent?.(driverId, intent, targetCompound));
1187
+ if (applied) this.updateDom(this.sim.snapshot());
1188
+ return applied;
1189
+ }
1190
+
1191
+ getPitIntent(driverId) {
1192
+ return this.sim?.getPitIntent?.(driverId) ?? 0;
1193
+ }
1194
+
1195
+ getPitTargetCompound(driverId) {
1196
+ return this.sim?.getPitTargetCompound?.(driverId) ?? null;
1197
+ }
1198
+
1199
+ servePenalty(penaltyId) {
1200
+ const penalty = this.sim?.servePenalty(penaltyId) ?? null;
1201
+ if (penalty) this.updateDom(this.sim.snapshot());
1202
+ return penalty;
1203
+ }
1204
+
1205
+ cancelPenalty(penaltyId) {
1206
+ const penalty = this.sim?.cancelPenalty(penaltyId) ?? null;
1207
+ if (penalty) this.updateDom(this.sim.snapshot());
1208
+ return penalty;
1209
+ }
1210
+
1544
1211
  getSnapshot() {
1545
1212
  return this.sim?.snapshot() ?? null;
1546
1213
  }
1547
1214
 
1548
1215
  destroy() {
1549
1216
  this.abortController.abort();
1217
+ if (this.layoutResizeFrame && typeof cancelAnimationFrame === 'function') {
1218
+ cancelAnimationFrame(this.layoutResizeFrame);
1219
+ }
1220
+ this.layoutResizeFrame = null;
1550
1221
  this.layoutResizeObserver?.disconnect?.();
1551
1222
  this.layoutResizeObserver = null;
1552
1223
  this.visibilityObserver?.disconnect?.();
@@ -1554,10 +1225,14 @@ export class F1SimulatorApp {
1554
1225
  if (this.app && this.tickerCallback) {
1555
1226
  this.app.ticker.remove(this.tickerCallback);
1556
1227
  }
1228
+ this.expert?.destroy?.();
1229
+ this.expert = null;
1230
+ this.replayGhostRenderer.destroy();
1557
1231
  this.app?.destroy(true, { children: true, texture: false });
1558
1232
  this.app = null;
1559
1233
  this.carSprites.clear();
1560
1234
  this.carHitAreas.clear();
1235
+ this.serviceCountdownLabels.clear();
1561
1236
  this.drsTrails.clear();
1562
1237
  }
1563
1238
  }