@inventure71/paddockjs 0.1.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 (44) hide show
  1. package/AGENTS.md +86 -0
  2. package/CHANGELOG.md +5 -0
  3. package/LICENSE +176 -0
  4. package/README.md +165 -0
  5. package/assets/f1-broadcast-panel-surface.png +0 -0
  6. package/assets/f1-car-sprite-game.png +0 -0
  7. package/assets/f1-driver-helmet.png +0 -0
  8. package/assets/f1-safety-car-sprite.png +0 -0
  9. package/assets/f1-texture-asphalt.png +0 -0
  10. package/assets/f1Logo.png +0 -0
  11. package/docs/architecture.md +209 -0
  12. package/docs/concepts.md +178 -0
  13. package/docs/data_contract.md +360 -0
  14. package/docs/data_model.md +167 -0
  15. package/docs/index.md +15 -0
  16. package/docs/learnings.md +47 -0
  17. package/docs/rules.md +181 -0
  18. package/docs/system_specs.md +211 -0
  19. package/package.json +85 -0
  20. package/src/api/PaddockSimulatorController.js +233 -0
  21. package/src/app/F1SimulatorApp.js +1293 -0
  22. package/src/app/domBindings.js +64 -0
  23. package/src/config/defaultAssets.js +29 -0
  24. package/src/config/defaultOptions.js +184 -0
  25. package/src/data/championship.js +225 -0
  26. package/src/data/customFields.js +14 -0
  27. package/src/data/demoDrivers.js +134 -0
  28. package/src/data/driverData.js +71 -0
  29. package/src/data/normalizeDrivers.js +21 -0
  30. package/src/data/vehicleData.js +81 -0
  31. package/src/index.d.ts +399 -0
  32. package/src/index.js +116 -0
  33. package/src/rendering/proceduralTrackAsset.js +517 -0
  34. package/src/rendering/renderSnapshot.js +23 -0
  35. package/src/simulation/driverController.js +253 -0
  36. package/src/simulation/raceSimulation.js +933 -0
  37. package/src/simulation/simMath.js +34 -0
  38. package/src/simulation/trackModel.js +607 -0
  39. package/src/simulation/units.js +30 -0
  40. package/src/simulation/vehiclePhysics.js +104 -0
  41. package/src/standalone.js +15 -0
  42. package/src/styles.css +1818 -0
  43. package/src/ui/componentTemplates.js +229 -0
  44. package/src/ui/shellTemplate.js +43 -0
@@ -0,0 +1,1293 @@
1
+ import { Application, Assets, Container, Graphics, Sprite, Texture } from 'pixi.js';
2
+ import { DRIVER_STAT_DEFINITIONS, formatDriverNumber, VEHICLE_STAT_DEFINITIONS } from '../data/championship.js';
3
+ import { applyPaddockThemeCssVariables } from '../config/defaultOptions.js';
4
+ import { normalizeCustomFields } from '../data/customFields.js';
5
+ import { ProceduralTrackAsset } from '../rendering/proceduralTrackAsset.js';
6
+ import { createRenderSnapshot } from '../rendering/renderSnapshot.js';
7
+ import { createRaceSimulation } from '../simulation/raceSimulation.js';
8
+ import { clamp } from '../simulation/simMath.js';
9
+ import { offsetTrackPoint, pointAt, WORLD } from '../simulation/trackModel.js';
10
+ import { querySimulatorDom, setText } from './domBindings.js';
11
+
12
+ const FIXED_STEP = 1 / 60;
13
+ const TARGET_RENDER_FPS = 60;
14
+ const TARGET_FRAME_MS = 1000 / TARGET_RENDER_FPS;
15
+ const FRAME_PACING_EPSILON_MS = 0.75;
16
+ const MAX_FRAME_CATCHUP_COUNT = 4;
17
+ const MAX_SIMULATION_STEPS_PER_RENDER = 5;
18
+ const DOM_UPDATE_INTERVAL_MS = 100;
19
+ const TIMING_UPDATE_INTERVAL_MS = 250;
20
+ const SIM_SPEED = 3.25;
21
+ const CAR_WORLD_LENGTH = 66;
22
+ const CAR_WORLD_WIDTH = 23;
23
+ const SAFETY_CAR_WORLD_LENGTH = 92;
24
+ const SAFETY_CAR_WORLD_WIDTH = 38;
25
+ const CAMERA_PRESETS = {
26
+ overview: 1.6,
27
+ leader: 5.35,
28
+ selected: 6.1,
29
+ };
30
+ const SHOW_ALL_PADDING = 520;
31
+ const SHOW_ALL_MIN_ZOOM = 1.1;
32
+ const SHOW_ALL_MAX_ZOOM = 6.4;
33
+ const SHOW_ALL_TOP_RESERVED = 92;
34
+ const SHOW_ALL_BOTTOM_RESERVED = 132;
35
+ const DRS_TRAIL_TTL = 0.68;
36
+ const DRS_TRAIL_MIN_DISTANCE = 10;
37
+ const RACE_DATA_SELECTED_VISIBLE_MS = 5200;
38
+ const RADIO_BREAK_MIN_MS = 4800;
39
+ const RADIO_BREAK_MAX_MS = 11800;
40
+ const RADIO_VISIBLE_MIN_MS = 6200;
41
+ const RADIO_VISIBLE_MAX_MS = 9200;
42
+ const RADIO_SCHEDULE_CATCHUP_LIMIT_MS = 30000;
43
+ const DRS_DRAG_REDUCTION_PERCENT = 58;
44
+
45
+ const VEHICLE_OVERVIEW_FIELDS = [
46
+ ['power', 'Power'],
47
+ ['braking', 'Braking'],
48
+ ['aero', 'Aero'],
49
+ ['dragEfficiency', 'Drag'],
50
+ ['mechanicalGrip', 'Grip'],
51
+ ['weightControl', 'Weight'],
52
+ ];
53
+ const DRIVER_OVERVIEW_FIELDS = [
54
+ ['pace', 'Pace'],
55
+ ['racecraft', 'Racecraft'],
56
+ ['aggression', 'Aggression'],
57
+ ['riskTolerance', 'Risk'],
58
+ ['patience', 'Patience'],
59
+ ['consistency', 'Consistency'],
60
+ ];
61
+
62
+ const TINT_BY_COLOR = new Map();
63
+
64
+ function createMountTrackSeed() {
65
+ const values = new Uint32Array(1);
66
+ try {
67
+ globalThis.crypto?.getRandomValues?.(values);
68
+ } catch {
69
+ values[0] = 0;
70
+ }
71
+ return (values[0] || Math.floor(Date.now() + performance.now() * 1000)) >>> 0;
72
+ }
73
+
74
+ function escapeHtml(value) {
75
+ return String(value ?? '')
76
+ .replaceAll('&', '&')
77
+ .replaceAll('<', '&lt;')
78
+ .replaceAll('>', '&gt;')
79
+ .replaceAll('"', '&quot;')
80
+ .replaceAll("'", '&#39;');
81
+ }
82
+
83
+ function getTireClass(tire) {
84
+ return String(tire ?? 'M').toLowerCase();
85
+ }
86
+
87
+ function colorToTint(color) {
88
+ if (TINT_BY_COLOR.has(color)) return TINT_BY_COLOR.get(color);
89
+ const tint = Number.parseInt(String(color ?? '').replace('#', ''), 16);
90
+ const normalizedTint = Number.isFinite(tint) ? tint : 0xffffff;
91
+ TINT_BY_COLOR.set(color, normalizedTint);
92
+ return normalizedTint;
93
+ }
94
+
95
+ function smoothAngle(current, target, amount) {
96
+ if (!Number.isFinite(current)) return target;
97
+ let diff = ((target - current + Math.PI) % (Math.PI * 2)) - Math.PI;
98
+ if (diff < -Math.PI) diff += Math.PI * 2;
99
+ return current + diff * amount;
100
+ }
101
+
102
+ export class F1SimulatorApp {
103
+ constructor(root, options) {
104
+ this.root = root;
105
+ this.options = options;
106
+ this.drivers = options.drivers;
107
+ this.driverById = new Map(this.drivers.map((driver) => [driver.id, driver]));
108
+ this.assets = options.assets;
109
+ this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
110
+ this.root.style.setProperty('--paddock-entry-count', String(this.drivers.length));
111
+ applyPaddockThemeCssVariables(this.root, options.theme);
112
+ Object.assign(this, querySimulatorDom(root));
113
+ this.abortController = new AbortController();
114
+ this.resizeHandler = null;
115
+ this.layoutResizeObserver = null;
116
+ this.cameraSafeAreaCache = null;
117
+ this.visibilityChangeHandler = null;
118
+ this.visibilityObserver = null;
119
+ this.tickerCallback = null;
120
+ this.sim = null;
121
+ this.app = null;
122
+ this.worldLayer = null;
123
+ this.trackAsset = null;
124
+ this.drsLayer = null;
125
+ this.trailLayer = null;
126
+ this.carLayer = null;
127
+ this.textures = {};
128
+ this.carSprites = new Map();
129
+ this.carHitAreas = new Map();
130
+ this.drsTrails = new Map();
131
+ this.trackSeed = options.trackSeed ?? createMountTrackSeed();
132
+ this.selectedId = this.drivers[0]?.id ?? null;
133
+ this.raceDataBannerConfig = options.ui?.raceDataBanners ?? { initial: 'project', enabled: ['project', 'radio'] };
134
+ this.activeRaceDataId = null;
135
+ this.lastRaceDataInteraction = performance.now();
136
+ this.radioRandomState = (this.trackSeed ^ 0x9e3779b9) >>> 0;
137
+ this.radioState = {
138
+ visible: false,
139
+ nextChangeAt: Number.POSITIVE_INFINITY,
140
+ driverIndex: 0,
141
+ quoteIndex: 0,
142
+ };
143
+ this.resetRaceDataBannerState(this.lastRaceDataInteraction);
144
+ this.camera = {
145
+ mode: options.initialCameraMode,
146
+ zoom: CAMERA_PRESETS[options.initialCameraMode] ?? CAMERA_PRESETS.leader,
147
+ scale: null,
148
+ x: WORLD.width / 2,
149
+ y: WORLD.height / 2,
150
+ };
151
+ this.overviewMode = 'vehicle';
152
+ this.accumulator = 0;
153
+ this.lastTime = performance.now();
154
+ this.nextGameFrameTime = this.lastTime + TARGET_FRAME_MS;
155
+ this.lastDomUpdateTime = 0;
156
+ this.lastTimingRenderTime = 0;
157
+ this.lastTimingRaceMode = null;
158
+ this.runtimeViewportVisible = true;
159
+ this.runtimeDocumentVisible = typeof document === 'undefined' ? true : document.visibilityState !== 'hidden';
160
+ this.runtimeTickerRunning = true;
161
+ this.fps = {
162
+ frames: 0,
163
+ current: 0,
164
+ lastSample: this.lastTime,
165
+ };
166
+ this.lastLeaderLap = null;
167
+ this.emittedRaceEventKeys = new Set();
168
+ this.raceFinishEmitted = false;
169
+ this.timingGapMode = 'interval';
170
+ }
171
+
172
+ async init() {
173
+ this.emitHostCallback('onLoadingChange', { loading: true, phase: 'initializing' });
174
+ try {
175
+ if (!this.canvasHost) {
176
+ throw new Error('F1 simulator canvas host is missing.');
177
+ }
178
+
179
+ this.sim = this.createRaceSimulation();
180
+ this.app = new Application();
181
+ await this.app.init({
182
+ resizeTo: this.canvasHost,
183
+ antialias: true,
184
+ autoDensity: true,
185
+ resolution: Math.min(window.devicePixelRatio || 1, 2),
186
+ backgroundAlpha: 0,
187
+ });
188
+ this.canvasHost.appendChild(this.app.canvas);
189
+
190
+ this.worldLayer = new Container();
191
+ this.drsLayer = new Container();
192
+ this.trailLayer = new Graphics();
193
+ this.carLayer = new Container();
194
+ this.app.stage.addChild(this.worldLayer);
195
+
196
+ await this.loadAssets();
197
+ this.trackAsset = new ProceduralTrackAsset({ textures: this.textures, world: WORLD });
198
+ this.worldLayer.addChild(this.trackAsset.container, this.drsLayer, this.trailLayer, this.carLayer);
199
+ this.createCars();
200
+ this.bindControls();
201
+ this.renderTrack();
202
+ const snapshot = this.sim.snapshot();
203
+ this.updateDom(snapshot);
204
+ this.completeComponentLoading();
205
+ this.emitHostCallback('onLoadingChange', { loading: false, phase: 'ready' });
206
+ this.emitHostCallback('onReady', { snapshot });
207
+ this.resizeHandler = () => this.applyCamera(this.sim.snapshot());
208
+ window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
209
+ this.tickerCallback = () => this.tick();
210
+ this.app.ticker.add(this.tickerCallback);
211
+ this.observeLayoutResize();
212
+ this.observeRuntimeVisibility();
213
+ } catch (error) {
214
+ this.emitHostCallback('onLoadingChange', { loading: false, phase: 'error' });
215
+ this.emitHostCallback('onError', error, { phase: 'init' });
216
+ throw error;
217
+ }
218
+ }
219
+
220
+ emitHostCallback(name, ...args) {
221
+ const callback = this.options?.[name];
222
+ if (typeof callback !== 'function') return;
223
+ try {
224
+ callback(...args);
225
+ } catch (error) {
226
+ if (name !== 'onError' && typeof this.options?.onError === 'function') {
227
+ try {
228
+ this.options.onError(error, { callback: name });
229
+ } catch {
230
+ // Host callbacks should not break the simulator runtime.
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ completeComponentLoading() {
237
+ const loadingNodes = [...(this.root.querySelectorAll?.('[data-paddock-loading]') ?? [])];
238
+ loadingNodes.forEach((node) => {
239
+ node.closest?.('[data-paddock-component]')?.classList?.add?.('is-loaded');
240
+ node.remove?.();
241
+ });
242
+ }
243
+
244
+ createRaceSimulation() {
245
+ return createRaceSimulation({
246
+ seed: this.options.seed,
247
+ trackSeed: this.trackSeed,
248
+ drivers: this.drivers,
249
+ totalLaps: this.options.totalLaps,
250
+ });
251
+ }
252
+
253
+ async loadAssets() {
254
+ this.textures.car = Texture.WHITE;
255
+ try {
256
+ this.textures.car = await Assets.load(this.assets.car);
257
+ this.textures.car.source.scaleMode = 'linear';
258
+ this.textures.car.source.autoGenerateMipmaps = true;
259
+ } catch {
260
+ this.textures.car = Texture.WHITE;
261
+ }
262
+
263
+ try {
264
+ this.textures.safetyCar = await Assets.load(this.assets.safetyCar);
265
+ this.textures.safetyCar.source.scaleMode = 'linear';
266
+ this.textures.safetyCar.source.autoGenerateMipmaps = true;
267
+ } catch {
268
+ this.textures.safetyCar = Texture.WHITE;
269
+ }
270
+
271
+ await Promise.all(Object.entries(this.assets.trackTextures).map(async ([key, url]) => {
272
+ try {
273
+ const texture = await Assets.load(url);
274
+ texture.source.scaleMode = 'linear';
275
+ this.textures[key] = texture;
276
+ } catch {
277
+ this.textures[key] = Texture.WHITE;
278
+ }
279
+ }));
280
+ }
281
+
282
+ createCars() {
283
+ const texture = this.textures.car ?? Texture.WHITE;
284
+ const baseScale = Math.min(
285
+ CAR_WORLD_LENGTH / Math.max(texture.width, 1),
286
+ CAR_WORLD_WIDTH / Math.max(texture.height, 1),
287
+ );
288
+
289
+ this.carSprites.forEach((sprite) => sprite.destroy());
290
+ this.carHitAreas.forEach((hit) => hit.destroy());
291
+ this.carSprites.clear();
292
+ this.carHitAreas.clear();
293
+ this.drsTrails.clear();
294
+
295
+ if (this.safetySprite) {
296
+ this.safetySprite.destroy();
297
+ this.safetySprite = null;
298
+ }
299
+
300
+ this.drivers.forEach((driver) => {
301
+ const sprite = new Sprite(texture);
302
+ sprite.anchor.set(0.5);
303
+ sprite.baseScale = baseScale;
304
+ sprite.scale.set(baseScale);
305
+ sprite.tint = colorToTint(driver.color);
306
+ sprite.eventMode = 'static';
307
+ sprite.cursor = 'pointer';
308
+ sprite.on('pointerdown', () => {
309
+ this.selectCar(driver.id, { focus: true });
310
+ });
311
+ this.carSprites.set(driver.id, sprite);
312
+ this.carLayer.addChild(sprite);
313
+
314
+ const hit = new Graphics();
315
+ hit.circle(0, 0, 24).fill({ color: 0xffffff, alpha: 0.001 });
316
+ hit.eventMode = 'static';
317
+ hit.cursor = 'pointer';
318
+ hit.on('pointerdown', () => {
319
+ this.selectCar(driver.id, { focus: true });
320
+ });
321
+ this.carHitAreas.set(driver.id, hit);
322
+ this.carLayer.addChild(hit);
323
+ });
324
+ }
325
+
326
+ bindControls() {
327
+ const eventOptions = { signal: this.abortController.signal };
328
+
329
+ this.safetyButtons.forEach((button) => {
330
+ button.addEventListener('click', () => {
331
+ const next = this.sim.snapshot().raceControl.mode !== 'safety-car';
332
+ this.setSafetyCarDeployed(next);
333
+ }, eventOptions);
334
+ });
335
+
336
+ this.restartButton?.addEventListener('click', () => {
337
+ this.sim = this.createRaceSimulation();
338
+ this.selectedId = this.drivers[0]?.id ?? null;
339
+ this.resetRaceDataBannerState(performance.now());
340
+ this.syncSafetyCarControls(false);
341
+ this.drsTrails.clear();
342
+ this.trailLayer?.clear();
343
+ this.lastTimingRenderTime = 0;
344
+ this.lastTimingRaceMode = null;
345
+ this.renderTrack();
346
+ }, eventOptions);
347
+
348
+ this.cameraButtons.forEach((button) => {
349
+ button.addEventListener('click', () => {
350
+ this.camera.mode = button.dataset.cameraMode;
351
+ if (CAMERA_PRESETS[this.camera.mode]) this.camera.zoom = CAMERA_PRESETS[this.camera.mode];
352
+ this.updateCameraControls();
353
+ }, eventOptions);
354
+ });
355
+
356
+ this.zoomInButton?.addEventListener('click', () => {
357
+ this.camera.zoom = clamp(this.camera.zoom + 0.42, 0.72, 8.5);
358
+ this.updateCameraControls();
359
+ }, eventOptions);
360
+
361
+ this.zoomOutButton?.addEventListener('click', () => {
362
+ this.camera.zoom = clamp(this.camera.zoom - 0.42, 0.72, 8.5);
363
+ this.updateCameraControls();
364
+ }, eventOptions);
365
+
366
+ this.overviewModeButtons.forEach((button) => {
367
+ button.addEventListener('click', () => {
368
+ this.overviewMode = button.dataset.overviewMode === 'driver' ? 'driver' : 'vehicle';
369
+ const selected = this.sim?.snapshot().cars.find((car) => car.id === this.selectedId);
370
+ this.renderCarDriverOverview(selected);
371
+ }, eventOptions);
372
+ });
373
+
374
+ this.timingGapModeButtons.forEach((button) => {
375
+ button.addEventListener('click', () => {
376
+ this.timingGapMode = button.dataset.timingGapMode === 'leader' ? 'leader' : 'interval';
377
+ this.syncTimingGapModeControls();
378
+ const snapshot = this.sim?.snapshot?.();
379
+ if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode);
380
+ }, eventOptions);
381
+ });
382
+
383
+ this.timingList?.addEventListener?.('pointerdown', (event) => {
384
+ const row = event.target instanceof Element ? event.target.closest('[data-driver-id]') : null;
385
+ if (!row) return;
386
+ this.selectCar(row.dataset.driverId, { focus: true });
387
+ }, eventOptions);
388
+
389
+ this.openButton?.addEventListener('click', () => {
390
+ const driver = this.driverById.get(this.activeRaceDataId ?? this.selectedId);
391
+ if (!driver) return;
392
+ this.options.onDriverOpen?.(driver);
393
+ }, eventOptions);
394
+ }
395
+
396
+ observeLayoutResize() {
397
+ if (typeof ResizeObserver !== 'function') return;
398
+ this.layoutResizeObserver?.disconnect?.();
399
+ this.layoutResizeObserver = new ResizeObserver(() => this.invalidateCameraSafeArea());
400
+ if (this.canvasHost) this.layoutResizeObserver.observe(this.canvasHost);
401
+ if (this.readouts.timingTower) this.layoutResizeObserver.observe(this.readouts.timingTower);
402
+ }
403
+
404
+ invalidateCameraSafeArea() {
405
+ this.cameraSafeAreaCache = null;
406
+ }
407
+
408
+ observeRuntimeVisibility() {
409
+ if (!this.canvasHost) return;
410
+
411
+ if (typeof IntersectionObserver === 'function') {
412
+ this.visibilityObserver?.disconnect?.();
413
+ this.visibilityObserver = new IntersectionObserver((entries) => {
414
+ const entry = entries.find((item) => item.target === this.canvasHost) ?? entries[0];
415
+ this.runtimeViewportVisible = Boolean(entry?.isIntersecting || entry?.intersectionRatio > 0);
416
+ this.syncRuntimeTicker();
417
+ }, {
418
+ root: null,
419
+ rootMargin: '240px 0px',
420
+ threshold: 0,
421
+ });
422
+ this.visibilityObserver.observe(this.canvasHost);
423
+ }
424
+
425
+ if (typeof document !== 'undefined') {
426
+ this.visibilityChangeHandler = () => {
427
+ this.runtimeDocumentVisible = document.visibilityState !== 'hidden';
428
+ this.syncRuntimeTicker();
429
+ };
430
+ document.addEventListener('visibilitychange', this.visibilityChangeHandler, {
431
+ signal: this.abortController.signal,
432
+ });
433
+ }
434
+
435
+ this.syncRuntimeTicker();
436
+ }
437
+
438
+ syncRuntimeTicker() {
439
+ if (!this.app?.ticker) return;
440
+
441
+ const shouldRun = this.runtimeViewportVisible && this.runtimeDocumentVisible;
442
+ if (shouldRun === this.runtimeTickerRunning) return;
443
+
444
+ this.runtimeTickerRunning = shouldRun;
445
+ if (shouldRun) {
446
+ this.resetFrameClock();
447
+ this.app.ticker.start?.();
448
+ } else {
449
+ this.app.ticker.stop?.();
450
+ }
451
+ }
452
+
453
+ resetFrameClock(now = performance.now()) {
454
+ this.accumulator = 0;
455
+ this.lastTime = now;
456
+ this.nextGameFrameTime = now + TARGET_FRAME_MS;
457
+ this.fps.frames = 0;
458
+ this.fps.lastSample = now;
459
+ }
460
+
461
+ tick() {
462
+ const now = performance.now();
463
+ if (now < this.nextGameFrameTime - FRAME_PACING_EPSILON_MS) return;
464
+
465
+ const elapsedFrameCount = clamp(
466
+ Math.floor((now - this.nextGameFrameTime + FRAME_PACING_EPSILON_MS) / TARGET_FRAME_MS) + 1,
467
+ 1,
468
+ MAX_FRAME_CATCHUP_COUNT,
469
+ );
470
+ const frameSeconds = (TARGET_FRAME_MS * elapsedFrameCount) / 1000;
471
+ this.nextGameFrameTime += TARGET_FRAME_MS * elapsedFrameCount;
472
+ if (now - this.nextGameFrameTime > TARGET_FRAME_MS * MAX_FRAME_CATCHUP_COUNT) {
473
+ this.nextGameFrameTime = now + TARGET_FRAME_MS;
474
+ }
475
+ this.lastTime = now;
476
+ this.sampleFps(now);
477
+ this.accumulator += frameSeconds * SIM_SPEED;
478
+
479
+ let simulationSteps = 0;
480
+ while (this.accumulator >= FIXED_STEP && simulationSteps < MAX_SIMULATION_STEPS_PER_RENDER) {
481
+ this.sim.step(FIXED_STEP);
482
+ const stepSnapshot = this.sim.snapshot();
483
+ this.emitSnapshotLifecycle(stepSnapshot);
484
+ this.accumulator -= FIXED_STEP;
485
+ simulationSteps += 1;
486
+ }
487
+
488
+ if (this.accumulator >= FIXED_STEP) {
489
+ this.accumulator %= FIXED_STEP;
490
+ this.nextGameFrameTime = now + TARGET_FRAME_MS;
491
+ }
492
+
493
+ const snapshot = this.sim.snapshot();
494
+ const renderSnapshot = createRenderSnapshot(snapshot, clamp(this.accumulator / FIXED_STEP, 0, 1));
495
+ this.applyCamera(renderSnapshot);
496
+ this.renderDrsTrails(renderSnapshot);
497
+ this.renderCars(renderSnapshot);
498
+ if (now - this.lastDomUpdateTime >= DOM_UPDATE_INTERVAL_MS) {
499
+ this.updateDom(snapshot, { emitLifecycle: false });
500
+ this.lastDomUpdateTime = now;
501
+ }
502
+ }
503
+
504
+ renderTrack() {
505
+ this.drsLayer.removeChildren();
506
+ const snapshot = this.sim.snapshot();
507
+ const track = snapshot.track;
508
+
509
+ this.trackAsset.render(track);
510
+
511
+ track.drsZones.forEach((zone) => {
512
+ const zoneLine = new Graphics();
513
+ const steps = 44;
514
+ for (let index = 0; index <= steps; index += 1) {
515
+ const basePoint = pointAt(track, zone.start + ((zone.end - zone.start) * index) / steps);
516
+ const point = offsetTrackPoint(basePoint, track.width / 2 - 22);
517
+ if (index === 0) zoneLine.moveTo(point.x, point.y);
518
+ else zoneLine.lineTo(point.x, point.y);
519
+ }
520
+ zoneLine.stroke({ width: 8, color: 0x14c784, alpha: 0.5, join: 'round', cap: 'round' });
521
+ this.drsLayer.addChild(zoneLine);
522
+ });
523
+ }
524
+
525
+ renderCars(snapshot) {
526
+ snapshot.cars.forEach((car) => {
527
+ const sprite = this.carSprites.get(car.id);
528
+ const hit = this.carHitAreas.get(car.id);
529
+ if (!sprite || !hit) return;
530
+ sprite.x = car.x;
531
+ sprite.y = car.y;
532
+ sprite.currentRotation = smoothAngle(sprite.currentRotation, car.heading, 0.24);
533
+ sprite.rotation = sprite.currentRotation;
534
+ sprite.alpha = snapshot.raceControl.mode === 'safety-car' ? 0.82 : 1;
535
+ sprite.scale.set(sprite.baseScale);
536
+ sprite.tint = colorToTint(car.color);
537
+ hit.x = car.x;
538
+ hit.y = car.y;
539
+ });
540
+
541
+ if (!this.safetySprite && snapshot.safetyCar.deployed) {
542
+ const texture = this.textures.safetyCar ?? Texture.WHITE;
543
+ this.safetySprite = new Sprite(texture);
544
+ this.safetySprite.anchor.set(0.5);
545
+ this.safetySprite.baseScale = Math.min(
546
+ SAFETY_CAR_WORLD_LENGTH / Math.max(texture.width, 1),
547
+ SAFETY_CAR_WORLD_WIDTH / Math.max(texture.height, 1),
548
+ );
549
+ this.safetySprite.scale.set(this.safetySprite.baseScale);
550
+ this.carLayer.addChild(this.safetySprite);
551
+ }
552
+
553
+ if (this.safetySprite) {
554
+ this.safetySprite.visible = snapshot.safetyCar.deployed;
555
+ this.safetySprite.x = snapshot.safetyCar.x;
556
+ this.safetySprite.y = snapshot.safetyCar.y;
557
+ this.safetySprite.rotation = snapshot.safetyCar.heading;
558
+ }
559
+ }
560
+
561
+ renderDrsTrails(snapshot) {
562
+ if (!this.trailLayer) return;
563
+
564
+ snapshot.cars.forEach((car) => {
565
+ const history = this.drsTrails.get(car.id) ?? [];
566
+ const rear = {
567
+ x: car.x - Math.cos(car.heading) * CAR_WORLD_LENGTH * 0.46,
568
+ y: car.y - Math.sin(car.heading) * CAR_WORLD_LENGTH * 0.46,
569
+ at: snapshot.time,
570
+ };
571
+ const last = history[history.length - 1];
572
+
573
+ if (
574
+ car.drsActive &&
575
+ (!last || Math.hypot(rear.x - last.x, rear.y - last.y) >= DRS_TRAIL_MIN_DISTANCE)
576
+ ) {
577
+ history.push(rear);
578
+ }
579
+
580
+ const activeTrail = history.filter((point) => snapshot.time - point.at <= DRS_TRAIL_TTL);
581
+ if (activeTrail.length) this.drsTrails.set(car.id, activeTrail);
582
+ else this.drsTrails.delete(car.id);
583
+ });
584
+
585
+ this.trailLayer.clear();
586
+ this.drsTrails.forEach((history) => {
587
+ for (let index = 1; index < history.length; index += 1) {
588
+ const previous = history[index - 1];
589
+ const point = history[index];
590
+ const age = snapshot.time - point.at;
591
+ const life = clamp(1 - age / DRS_TRAIL_TTL, 0, 1);
592
+ this.trailLayer.moveTo(previous.x, previous.y);
593
+ this.trailLayer.lineTo(point.x, point.y);
594
+ this.trailLayer.stroke({
595
+ width: 5 + life * 7,
596
+ color: 0x3be8ff,
597
+ alpha: 0.22 + life * 0.48,
598
+ cap: 'round',
599
+ join: 'round',
600
+ });
601
+ }
602
+ });
603
+ }
604
+
605
+ applyCamera(snapshot) {
606
+ if (!this.worldLayer) return;
607
+
608
+ const width = this.canvasHost.clientWidth || 900;
609
+ const height = this.canvasHost.clientHeight || 640;
610
+ const safeArea = this.getCameraSafeArea(width);
611
+ const baseScale = Math.min(safeArea.width / (WORLD.width + 260), height / (WORLD.height + 220));
612
+ const frame = this.getCameraFrame(snapshot, width, height, baseScale, safeArea);
613
+ const scale = frame.scale;
614
+ const target = frame.target;
615
+ this.camera.x += (target.x - this.camera.x) * 0.08;
616
+ this.camera.y += (target.y - this.camera.y) * 0.08;
617
+ const activeScale = this.camera.scale === null
618
+ ? scale
619
+ : this.camera.scale + (scale - this.camera.scale) * 0.12;
620
+ this.camera.scale = activeScale;
621
+ this.worldLayer.scale.set(activeScale);
622
+ this.worldLayer.position.set(
623
+ frame.screenX - this.camera.x * activeScale,
624
+ frame.screenY - this.camera.y * activeScale,
625
+ );
626
+ }
627
+
628
+ getCameraSafeArea(width) {
629
+ if (this.cameraSafeAreaCache?.width === width) {
630
+ return this.cameraSafeAreaCache.safeArea;
631
+ }
632
+
633
+ const canvasRect = this.canvasHost?.getBoundingClientRect?.();
634
+ const towerRect = this.readouts.timingTower?.getBoundingClientRect?.();
635
+ if (!canvasRect || !towerRect) {
636
+ const safeArea = { left: 0, width };
637
+ this.cameraSafeAreaCache = { width, safeArea };
638
+ return safeArea;
639
+ }
640
+ const overlapsCanvas = towerRect.right > canvasRect.left && towerRect.left < canvasRect.right;
641
+ if (!overlapsCanvas) {
642
+ const safeArea = { left: 0, width };
643
+ this.cameraSafeAreaCache = { width, safeArea };
644
+ return safeArea;
645
+ }
646
+
647
+ const overlayGap = 16;
648
+ const reservedLeft = clamp(towerRect.right - canvasRect.left + overlayGap, 0, width * 0.48);
649
+ const safeArea = {
650
+ left: reservedLeft,
651
+ width: Math.max(1, width - reservedLeft),
652
+ };
653
+ this.cameraSafeAreaCache = { width, safeArea };
654
+ return safeArea;
655
+ }
656
+
657
+ getCameraFrame(snapshot, width, height, baseScale, safeArea = { left: 0, width }) {
658
+ const screenCenterX = safeArea.left + safeArea.width / 2;
659
+
660
+ if (this.camera.mode === 'show-all') {
661
+ const bounds = snapshot.cars.reduce((box, car) => ({
662
+ minX: Math.min(box.minX, car.x),
663
+ minY: Math.min(box.minY, car.y),
664
+ maxX: Math.max(box.maxX, car.x),
665
+ maxY: Math.max(box.maxY, car.y),
666
+ }), {
667
+ minX: Infinity,
668
+ minY: Infinity,
669
+ maxX: -Infinity,
670
+ maxY: -Infinity,
671
+ });
672
+ const target = {
673
+ x: (bounds.minX + bounds.maxX) / 2,
674
+ y: (bounds.minY + bounds.maxY) / 2,
675
+ };
676
+ const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX + SHOW_ALL_PADDING);
677
+ const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY + SHOW_ALL_PADDING);
678
+ const safeHeight = Math.max(height * 0.48, height - SHOW_ALL_TOP_RESERVED - SHOW_ALL_BOTTOM_RESERVED);
679
+ const scale = clamp(
680
+ Math.min(safeArea.width / fitWidth, safeHeight / fitHeight),
681
+ baseScale * SHOW_ALL_MIN_ZOOM,
682
+ baseScale * SHOW_ALL_MAX_ZOOM,
683
+ );
684
+ return {
685
+ target,
686
+ scale,
687
+ screenX: screenCenterX,
688
+ screenY: SHOW_ALL_TOP_RESERVED + safeHeight / 2,
689
+ };
690
+ }
691
+
692
+ return {
693
+ target: this.getCameraTarget(snapshot),
694
+ scale: baseScale * this.camera.zoom,
695
+ screenX: screenCenterX,
696
+ screenY: height / 2,
697
+ };
698
+ }
699
+
700
+ getCameraTarget(snapshot) {
701
+ if (this.camera.mode === 'overview') {
702
+ return { x: WORLD.width / 2, y: WORLD.height / 2 };
703
+ }
704
+
705
+ if (this.camera.mode === 'selected') {
706
+ const selected = snapshot.cars.find((car) => car.id === this.selectedId);
707
+ if (selected) return selected;
708
+ }
709
+
710
+ const leader = snapshot.cars[0];
711
+ return leader ? { x: leader.x, y: leader.y } : { x: WORLD.width / 2, y: WORLD.height / 2 };
712
+ }
713
+
714
+ updateDom(snapshot, { emitLifecycle = true } = {}) {
715
+ const leader = snapshot.cars[0];
716
+ const selected = snapshot.cars.find((car) => car.id === this.selectedId) ?? leader;
717
+ const activeDrs = snapshot.cars.filter((car) => car.drsActive).length;
718
+ const contactCount = snapshot.events.filter((event) => event.type === 'contact').length;
719
+ const now = performance.now();
720
+
721
+ if (emitLifecycle) this.emitSnapshotLifecycle(snapshot);
722
+
723
+ if (this.activeRaceDataId && now - this.lastRaceDataInteraction > RACE_DATA_SELECTED_VISIBLE_MS) {
724
+ this.activeRaceDataId = null;
725
+ if (this.isRaceDataBannerEnabled('radio')) this.scheduleRadioBreak(now);
726
+ }
727
+
728
+ if (this.readouts.mode) {
729
+ const modeLabel = snapshot.raceControl.mode === 'safety-car'
730
+ ? 'SC'
731
+ : snapshot.raceControl.finished
732
+ ? 'FINISH'
733
+ : 'GREEN';
734
+ this.readouts.mode.textContent = modeLabel;
735
+ this.readouts.mode.style.color = snapshot.raceControl.mode === 'safety-car'
736
+ ? 'var(--yellow)'
737
+ : snapshot.raceControl.mode === 'finished'
738
+ ? 'var(--red)'
739
+ : 'var(--green)';
740
+ }
741
+ this.syncSafetyCarControls(snapshot.raceControl.mode === 'safety-car');
742
+ if (this.readouts.lap) this.readouts.lap.textContent = `${leader?.lap ?? 1}/${snapshot.totalLaps}`;
743
+ if (this.readouts.towerLap) this.readouts.towerLap.textContent = leader?.lap ?? 1;
744
+ if (this.readouts.towerTotalLaps) this.readouts.towerTotalLaps.textContent = snapshot.totalLaps;
745
+ if (this.readouts.timingTower) {
746
+ this.readouts.timingTower.classList.toggle('is-safety-car', snapshot.raceControl.mode === 'safety-car');
747
+ this.readouts.timingTower.classList.toggle('is-pre-start', snapshot.raceControl.mode === 'pre-start');
748
+ }
749
+ if (this.readouts.towerSafetyBanner) {
750
+ this.readouts.towerSafetyBanner.hidden = snapshot.raceControl.mode !== 'safety-car';
751
+ }
752
+ if (this.readouts.drs) {
753
+ this.readouts.drs.textContent = snapshot.raceControl.mode === 'safety-car'
754
+ ? 'DISABLED'
755
+ : activeDrs
756
+ ? `${activeDrs} OPEN`
757
+ : 'ARMED';
758
+ }
759
+ if (this.readouts.contacts) this.readouts.contacts.textContent = String(contactCount);
760
+ this.renderStartLights(snapshot.raceControl);
761
+ if (this.readouts.camera) {
762
+ const zoom = this.camera.mode === 'show-all'
763
+ ? Math.round(((this.camera.scale ?? 0) / Math.max(0.0001, this.getBaseScale())) * 100)
764
+ : Math.round(this.camera.zoom * 100);
765
+ this.readouts.camera.textContent = `${this.camera.mode.toUpperCase().replace('-', ' ')} ${zoom}%`;
766
+ }
767
+ if (this.readouts.fps) {
768
+ this.readouts.fps.textContent = this.fps.current ? `${this.fps.current}` : '--';
769
+ }
770
+ this.renderRaceFinish(snapshot);
771
+
772
+ this.updateCameraControls();
773
+ this.syncTimingGapModeControls();
774
+ if (
775
+ now - this.lastTimingRenderTime >= TIMING_UPDATE_INTERVAL_MS ||
776
+ this.lastTimingRaceMode !== snapshot.raceControl.mode
777
+ ) {
778
+ this.renderTiming(snapshot.cars, snapshot.raceControl.mode);
779
+ this.lastTimingRenderTime = now;
780
+ this.lastTimingRaceMode = snapshot.raceControl.mode;
781
+ }
782
+ this.renderTelemetry(selected);
783
+ const activeRaceDataCar = this.activeRaceDataId
784
+ ? snapshot.cars.find((car) => car.id === this.activeRaceDataId)
785
+ : null;
786
+ if (activeRaceDataCar) {
787
+ this.renderRaceData(activeRaceDataCar);
788
+ } else {
789
+ this.renderProjectRadio(now);
790
+ }
791
+ }
792
+
793
+ emitSnapshotLifecycle(snapshot) {
794
+ const leader = snapshot.cars[0];
795
+ const leaderLap = leader?.lap;
796
+ if (Number.isFinite(leaderLap)) {
797
+ if (this.lastLeaderLap != null && leaderLap !== this.lastLeaderLap) {
798
+ this.emitHostCallback('onLapChange', {
799
+ previousLeaderLap: this.lastLeaderLap,
800
+ leaderLap,
801
+ leader,
802
+ snapshot,
803
+ });
804
+ }
805
+ this.lastLeaderLap = leaderLap;
806
+ }
807
+
808
+ snapshot.events.forEach((event) => {
809
+ const key = `${event.type}:${event.at ?? snapshot.time}:${event.carId ?? ''}:${event.otherCarId ?? ''}:${event.winnerId ?? ''}`;
810
+ if (this.emittedRaceEventKeys.has(key)) return;
811
+ this.emittedRaceEventKeys.add(key);
812
+ this.emitHostCallback('onRaceEvent', event, snapshot);
813
+ });
814
+
815
+ if (snapshot.raceControl.finished && !this.raceFinishEmitted) {
816
+ this.raceFinishEmitted = true;
817
+ this.emitHostCallback('onRaceFinish', {
818
+ winner: snapshot.raceControl.winner,
819
+ classification: snapshot.raceControl.classification ?? [],
820
+ snapshot,
821
+ });
822
+ }
823
+ }
824
+
825
+ renderRaceFinish(snapshot) {
826
+ const panel = this.readouts.finishPanel;
827
+ if (!panel) return;
828
+
829
+ panel.hidden = !snapshot.raceControl.finished;
830
+ if (!snapshot.raceControl.finished) return;
831
+
832
+ const winner = snapshot.raceControl.winner;
833
+ const winnerDriver = winner ? this.driverById.get(winner.id) : null;
834
+ const winnerName = winnerDriver?.name ?? winner?.name ?? 'Winner';
835
+ panel.style.setProperty('--driver-color', winner?.color ?? winnerDriver?.color ?? 'var(--red)');
836
+ setText(this.readouts.finishWinner, winnerName);
837
+
838
+ if (this.readouts.finishClassification) {
839
+ const topThree = (snapshot.raceControl.classification ?? []).slice(0, 3);
840
+ this.readouts.finishClassification.innerHTML = topThree.map((entry) => `
841
+ <li>
842
+ <span>P${escapeHtml(entry.rank)}</span>
843
+ <strong>${escapeHtml(entry.timingCode ?? entry.code ?? entry.id)}</strong>
844
+ </li>
845
+ `).join('');
846
+ }
847
+ }
848
+
849
+ getBaseScale() {
850
+ const width = this.canvasHost.clientWidth || 900;
851
+ const height = this.canvasHost.clientHeight || 640;
852
+ const safeArea = this.getCameraSafeArea(width);
853
+ return Math.min(safeArea.width / (WORLD.width + 260), height / (WORLD.height + 220));
854
+ }
855
+
856
+ sampleFps(now) {
857
+ this.fps.frames += 1;
858
+ const elapsed = now - this.fps.lastSample;
859
+ if (elapsed < 500) return;
860
+ this.fps.current = Math.round((this.fps.frames * 1000) / elapsed);
861
+ this.fps.frames = 0;
862
+ this.fps.lastSample = now;
863
+ }
864
+
865
+ renderStartLights(raceControl) {
866
+ const panel = this.readouts.startLights;
867
+ if (!panel) return;
868
+
869
+ const start = raceControl.start;
870
+ const visible = Boolean(start?.visible);
871
+ panel.hidden = !visible;
872
+ if (!visible) return;
873
+
874
+ this.startLightNodes.forEach((light, index) => {
875
+ light.classList.toggle('is-lit', index < (start.lightsLit ?? 0));
876
+ });
877
+ panel.classList.toggle('is-lights-out', raceControl.mode === 'green' && start.released);
878
+
879
+ if (this.readouts.startLightsLabel) {
880
+ this.readouts.startLightsLabel.textContent = raceControl.mode === 'green' && start.released
881
+ ? 'Lights out'
882
+ : `${start.lightsLit}/${start.lightCount}`;
883
+ }
884
+ }
885
+
886
+ selectCar(id, { focus = false } = {}) {
887
+ this.selectedId = id;
888
+ this.activeRaceDataId = this.isRaceDataBannerEnabled('project') ? id : null;
889
+ this.lastRaceDataInteraction = performance.now();
890
+ if (this.isRaceDataBannerEnabled('radio')) this.scheduleRadioBreak(this.lastRaceDataInteraction);
891
+ if (focus) {
892
+ this.camera.mode = 'selected';
893
+ this.camera.zoom = CAMERA_PRESETS.selected;
894
+ this.updateCameraControls();
895
+ }
896
+ this.lastTimingRenderTime = 0;
897
+ const snapshot = this.sim.snapshot();
898
+ this.updateDom(snapshot);
899
+ const driver = this.driverById.get(id);
900
+ if (driver) this.emitHostCallback('onDriverSelect', driver, snapshot);
901
+ }
902
+
903
+ renderTiming(cars, raceMode) {
904
+ if (!this.timingList) return;
905
+ this.syncTimingGapModeControls();
906
+ this.timingList.innerHTML = cars.map((car) => {
907
+ const driver = this.driverById.get(car.id);
908
+ let gap = 'Leader';
909
+ if (raceMode === 'finished') {
910
+ gap = car.rank === 1 ? 'Winner' : 'FIN';
911
+ } else if (raceMode === 'safety-car' && car.rank > 1) {
912
+ gap = 'SC';
913
+ } else if (raceMode === 'pre-start') {
914
+ gap = car.rank === 1 ? 'Pole' : 'Grid';
915
+ } else if (car.rank > 1) {
916
+ gap = this.formatTimingGap(car);
917
+ }
918
+ const tire = car.tire ?? driver?.tire ?? 'M';
919
+ const timingCode = car.timingCode ?? driver?.timingCode ?? car.code;
920
+ const team = car.team ?? driver?.team ?? null;
921
+ const icon = team?.icon ?? car.icon ?? driver?.icon ?? timingCode;
922
+ const iconColor = team?.color ?? car.color;
923
+
924
+ return `
925
+ <li>
926
+ <button class="timing-row ${car.id === this.selectedId ? 'is-selected' : ''}" type="button"
927
+ data-driver-id="${escapeHtml(car.id)}" aria-label="Select ${escapeHtml(car.name)}"
928
+ style="--driver-color: ${escapeHtml(car.color)}">
929
+ <span class="timing-position">${car.rank}</span>
930
+ <span class="timing-icon timing-team-icon" aria-hidden="true" style="--team-color: ${escapeHtml(iconColor)}">${escapeHtml(icon)}</span>
931
+ <span class="timing-name" title="${escapeHtml(car.name)}">${escapeHtml(timingCode)}</span>
932
+ <span class="timing-gap">${escapeHtml(gap)}</span>
933
+ <span class="timing-tire timing-tire--${getTireClass(tire)}">${escapeHtml(tire)}</span>
934
+ </button>
935
+ </li>
936
+ `;
937
+ }).join('');
938
+ }
939
+
940
+ formatTimingGap(car) {
941
+ const value = this.timingGapMode === 'leader'
942
+ ? car.leaderGapSeconds
943
+ : (car.intervalAheadSeconds ?? car.gapAheadSeconds);
944
+ return Number.isFinite(value) ? `+${Math.max(0, value).toFixed(3)}` : '--';
945
+ }
946
+
947
+ syncTimingGapModeControls() {
948
+ const label = this.timingGapMode === 'leader' ? 'Gap' : 'Int';
949
+ setText(this.readouts.timingGapLabel, label);
950
+ this.timingGapModeButtons.forEach((button) => {
951
+ const active = button.dataset.timingGapMode === this.timingGapMode;
952
+ button.setAttribute('aria-pressed', String(active));
953
+ button.classList?.toggle?.('is-active', active);
954
+ });
955
+ }
956
+
957
+ renderTelemetry(car) {
958
+ if (!car) return;
959
+ const driver = this.driverById.get(car.id);
960
+ const icon = car.icon ?? driver?.icon ?? car.code;
961
+ const driverNumber = formatDriverNumber(car.driverNumber ?? driver?.driverNumber);
962
+ const drsState = car.drsActive ? 'OPEN' : car.drsEligible ? 'READY' : 'OFF';
963
+ const surface = (car.surface ?? 'track').toUpperCase();
964
+
965
+ setText(this.readouts.selectedCode, car.code);
966
+ if (this.readouts.selectedCode) this.readouts.selectedCode.style.color = car.color;
967
+ setText(this.readouts.selectedName, car.name);
968
+ setText(this.readouts.speed, `${Math.round(car.speedKph)} km/h`);
969
+ setText(this.readouts.throttle, `${Math.round(car.throttle * 100)}%`);
970
+ setText(this.readouts.brake, `${Math.round(car.brake * 100)}%`);
971
+ setText(this.readouts.tyres, `${Math.round(car.tireEnergy ?? 100)}%`);
972
+ setText(this.readouts.selectedDrs, drsState);
973
+ setText(this.readouts.surface, surface);
974
+ setText(
975
+ this.readouts.gap,
976
+ car.rank === 1 || !Number.isFinite(car.gapAheadSeconds)
977
+ ? '--'
978
+ : `${car.gapAheadSeconds.toFixed(2)}s`,
979
+ );
980
+ setText(
981
+ this.readouts.leaderGap,
982
+ car.rank === 1 || !Number.isFinite(car.leaderGapSeconds)
983
+ ? '--'
984
+ : `${car.leaderGapSeconds.toFixed(2)}s`,
985
+ );
986
+
987
+ this.renderCarDriverOverview(car);
988
+ }
989
+
990
+ getOverviewFields(driver, mode) {
991
+ if (mode === 'driver') {
992
+ const ratings = driver?.constructorArgs?.driver?.ratings ?? {};
993
+ return [
994
+ ...DRIVER_OVERVIEW_FIELDS.map(([key, label]) => ({
995
+ key,
996
+ label,
997
+ value: ratings[key] ?? DRIVER_STAT_DEFINITIONS[key]?.neutral,
998
+ })),
999
+ ...normalizeCustomFields([
1000
+ ...(driver?.constructorArgs?.driver?.customFields ?? []),
1001
+ ...(driver?.customFields ?? []),
1002
+ ]),
1003
+ ];
1004
+ }
1005
+
1006
+ const ratings = driver?.constructorArgs?.vehicle?.ratings ?? driver?.vehicle?.ratings ?? {};
1007
+ return [
1008
+ ...VEHICLE_OVERVIEW_FIELDS.map(([key, label]) => ({
1009
+ key,
1010
+ label,
1011
+ value: ratings[key] ?? VEHICLE_STAT_DEFINITIONS[key]?.neutral,
1012
+ })),
1013
+ ...normalizeCustomFields(driver?.vehicle?.customFields ?? driver?.constructorArgs?.vehicle?.customFields ?? []),
1014
+ ];
1015
+ }
1016
+
1017
+ renderCarDriverOverview(car) {
1018
+ if (!car || !this.readouts.carOverview) return;
1019
+ const driver = this.driverById.get(car.id);
1020
+ const icon = car.icon ?? driver?.icon ?? car.code;
1021
+ const driverNumber = formatDriverNumber(car.driverNumber ?? driver?.driverNumber);
1022
+ const mode = this.overviewMode === 'driver' ? 'driver' : 'vehicle';
1023
+ const fields = this.getOverviewFields(driver, mode);
1024
+ const displayFields = fields.length > 0
1025
+ ? fields
1026
+ : [{ label: mode === 'driver' ? 'Driver fields' : 'Car fields', value: 'No custom fields' }];
1027
+
1028
+ this.readouts.carOverview?.style.setProperty('--driver-color', car.color);
1029
+ this.readouts.carOverviewDiagram?.style.setProperty('--driver-color', car.color);
1030
+ if (this.readouts.carOverviewTitle) {
1031
+ this.readouts.carOverviewTitle.textContent = mode === 'driver' ? 'Driver overview' : 'Car overview';
1032
+ }
1033
+ if (this.readouts.carOverviewCode) {
1034
+ this.readouts.carOverviewCode.textContent = mode === 'driver'
1035
+ ? `${car.code} driver`
1036
+ : `${driver?.vehicle?.name ?? car.vehicleName ?? car.code}`;
1037
+ }
1038
+ if (this.readouts.carOverviewIcon) this.readouts.carOverviewIcon.textContent = icon;
1039
+ if (this.readouts.carOverviewImage) {
1040
+ this.readouts.carOverviewImage.src = mode === 'driver'
1041
+ ? (driver?.driverImage ?? driver?.portrait ?? driver?.avatar ?? this.assets.driverHelmet)
1042
+ : this.assets.carOverview;
1043
+ }
1044
+ if (this.readouts.carOverviewNumber) this.readouts.carOverviewNumber.textContent = driverNumber;
1045
+ if (this.readouts.carOverviewCoreStat) this.readouts.carOverviewCoreStat.textContent = mode === 'driver' ? 'Driver' : 'Car';
1046
+ this.readouts.carOverviewFields.forEach((fieldNode, index) => {
1047
+ const field = displayFields[index];
1048
+ fieldNode.hidden = !field;
1049
+ if (!field) return;
1050
+ const labelNode = fieldNode.querySelector('[data-overview-field-label]');
1051
+ const valueNode = fieldNode.querySelector('[data-overview-field-value]');
1052
+ setText(labelNode, field.label);
1053
+ setText(valueNode, field.value);
1054
+ });
1055
+ this.updateOverviewModeButtons();
1056
+ }
1057
+
1058
+ renderRaceData(car) {
1059
+ if (!car || !this.readouts.raceDataPanel || !this.isRaceDataBannerEnabled('project')) return;
1060
+ const driver = this.drivers.find((item) => item.id === car.id);
1061
+ if (!driver) return;
1062
+
1063
+ this.readouts.raceDataPanel.style.setProperty('--driver-color', driver.color);
1064
+ this.readouts.raceDataPanel.classList.remove('is-hidden');
1065
+ this.readouts.raceDataPanel.classList.add('is-project-mode');
1066
+ this.readouts.raceDataPanel.classList.remove('is-radio-mode');
1067
+ this.readouts.raceDataPanel.removeAttribute('data-idle-mode');
1068
+ setText(this.readouts.raceDataKicker, 'Project');
1069
+ setText(this.readouts.raceDataTitle, driver.name);
1070
+ if (this.readouts.raceDataNumber) {
1071
+ this.readouts.raceDataNumber.textContent = formatDriverNumber(car.driverNumber ?? driver.driverNumber);
1072
+ }
1073
+ if (this.readouts.raceDataSubtitle) {
1074
+ this.readouts.raceDataSubtitle.textContent = `${car.code} - P${car.rank} - ${driver.raceData?.[0] ?? 'Project entry'}`;
1075
+ }
1076
+ if (this.readouts.raceDataOpen) {
1077
+ this.readouts.raceDataOpen.hidden = typeof this.options.onDriverOpen !== 'function';
1078
+ }
1079
+ }
1080
+
1081
+ renderProjectRadio(now = performance.now()) {
1082
+ if (!this.readouts.raceDataPanel) return;
1083
+ if (!this.isRaceDataBannerEnabled('radio')) {
1084
+ this.hideRaceDataPanel();
1085
+ return;
1086
+ }
1087
+ this.updateRadioSchedule(now);
1088
+ if (!this.radioState.visible) {
1089
+ this.hideRaceDataPanel();
1090
+ return;
1091
+ }
1092
+
1093
+ const radio = this.getProjectRadioQuote();
1094
+
1095
+ this.readouts.raceDataPanel.style.setProperty('--driver-color', radio.color);
1096
+ this.readouts.raceDataPanel.classList.remove('is-hidden');
1097
+ this.readouts.raceDataPanel.classList.add('is-radio-mode');
1098
+ this.readouts.raceDataPanel.classList.remove('is-project-mode');
1099
+ this.readouts.raceDataPanel.dataset.idleMode = 'quote';
1100
+ setText(this.readouts.raceDataKicker, 'Project radio');
1101
+ setText(this.readouts.raceDataTitle, radio.title);
1102
+ setText(this.readouts.raceDataNumber, '');
1103
+ setText(this.readouts.raceDataSubtitle, radio.subtitle);
1104
+ if (this.readouts.raceDataOpen) this.readouts.raceDataOpen.hidden = true;
1105
+ }
1106
+
1107
+ hideRaceDataPanel() {
1108
+ if (!this.readouts.raceDataPanel) return;
1109
+ this.readouts.raceDataPanel.classList.add('is-hidden');
1110
+ this.readouts.raceDataPanel.classList.remove('is-project-mode', 'is-radio-mode');
1111
+ this.readouts.raceDataPanel.removeAttribute('data-idle-mode');
1112
+ if (this.readouts.raceDataOpen) this.readouts.raceDataOpen.hidden = true;
1113
+ }
1114
+
1115
+ updateRadioSchedule(now) {
1116
+ if (!this.isRaceDataBannerEnabled('radio')) {
1117
+ this.radioState.visible = false;
1118
+ this.radioState.nextChangeAt = Number.POSITIVE_INFINITY;
1119
+ return;
1120
+ }
1121
+ if (
1122
+ Number.isFinite(this.radioState.nextChangeAt) &&
1123
+ now - this.radioState.nextChangeAt > RADIO_SCHEDULE_CATCHUP_LIMIT_MS
1124
+ ) {
1125
+ if (this.radioState.visible) this.scheduleRadioBreak(now);
1126
+ else this.scheduleRadioPopup(now);
1127
+ return;
1128
+ }
1129
+ while (now >= this.radioState.nextChangeAt) {
1130
+ if (this.radioState.visible) {
1131
+ this.scheduleRadioBreak(this.radioState.nextChangeAt);
1132
+ } else {
1133
+ this.scheduleRadioPopup(this.radioState.nextChangeAt);
1134
+ }
1135
+ }
1136
+ }
1137
+
1138
+ scheduleRadioBreak(now) {
1139
+ this.radioState.visible = false;
1140
+ this.radioState.nextChangeAt = this.getNextRadioBreakTime(now);
1141
+ }
1142
+
1143
+ scheduleRadioPopup(now) {
1144
+ if (!this.isRaceDataBannerEnabled('radio')) {
1145
+ this.scheduleRadioBreak(now);
1146
+ return;
1147
+ }
1148
+ const driverIndex = Math.floor(this.nextRadioRandom() * this.drivers.length);
1149
+ const driver = this.drivers[driverIndex] ?? this.drivers[0];
1150
+ const quoteCount = Math.max(1, driver.raceData?.length ?? 1);
1151
+ this.radioState.visible = true;
1152
+ this.radioState.driverIndex = driverIndex;
1153
+ this.radioState.quoteIndex = Math.floor(this.nextRadioRandom() * quoteCount);
1154
+ this.radioState.nextChangeAt = now + this.randomRadioRange(RADIO_VISIBLE_MIN_MS, RADIO_VISIBLE_MAX_MS);
1155
+ }
1156
+
1157
+ randomRadioRange(min, max) {
1158
+ return min + this.nextRadioRandom() * (max - min);
1159
+ }
1160
+
1161
+ nextRadioRandom() {
1162
+ this.radioRandomState = (Math.imul(1664525, this.radioRandomState) + 1013904223) >>> 0;
1163
+ return this.radioRandomState / 0x100000000;
1164
+ }
1165
+
1166
+ getNextRadioBreakTime(now) {
1167
+ return this.isRaceDataBannerEnabled('radio')
1168
+ ? now + this.randomRadioRange(RADIO_BREAK_MIN_MS, RADIO_BREAK_MAX_MS)
1169
+ : Number.POSITIVE_INFINITY;
1170
+ }
1171
+
1172
+ resetRaceDataBannerState(now = performance.now()) {
1173
+ const initialRaceDataMode = this.raceDataBannerConfig.initial ?? 'project';
1174
+ this.activeRaceDataId = this.isRaceDataBannerEnabled('project') && initialRaceDataMode === 'project'
1175
+ ? this.selectedId
1176
+ : null;
1177
+ this.lastRaceDataInteraction = now;
1178
+ const showInitialRadio = this.isRaceDataBannerEnabled('radio') && initialRaceDataMode === 'radio';
1179
+ this.radioState.visible = showInitialRadio;
1180
+ this.radioState.nextChangeAt = showInitialRadio
1181
+ ? now + this.randomRadioRange(RADIO_VISIBLE_MIN_MS, RADIO_VISIBLE_MAX_MS)
1182
+ : this.getNextRadioBreakTime(now);
1183
+ this.radioState.driverIndex = 0;
1184
+ this.radioState.quoteIndex = 0;
1185
+ }
1186
+
1187
+ isRaceDataBannerEnabled(kind) {
1188
+ return this.raceDataBannerConfig.enabled?.includes(kind) ?? true;
1189
+ }
1190
+
1191
+ getProjectRadioQuote() {
1192
+ const driver = this.drivers[this.radioState.driverIndex] ?? this.drivers[0];
1193
+ const quote = driver.raceData?.[this.radioState.quoteIndex] ?? 'Project entry';
1194
+
1195
+ return {
1196
+ color: driver.color,
1197
+ title: driver.name,
1198
+ subtitle: `${driver.code} - "${quote}"`,
1199
+ };
1200
+ }
1201
+
1202
+ updateCameraControls() {
1203
+ this.cameraButtons.forEach((button) => {
1204
+ const isActive = button.dataset.cameraMode === this.camera.mode;
1205
+ button.classList.toggle('is-active', isActive);
1206
+ button.setAttribute('aria-pressed', String(isActive));
1207
+ });
1208
+ }
1209
+
1210
+ updateOverviewModeButtons() {
1211
+ this.overviewModeButtons.forEach((button) => {
1212
+ const isActive = button.dataset.overviewMode === this.overviewMode;
1213
+ button.classList.toggle('is-active', isActive);
1214
+ button.setAttribute('aria-pressed', String(isActive));
1215
+ });
1216
+ }
1217
+
1218
+ syncSafetyCarControls(active) {
1219
+ this.safetyButtons.forEach((button) => {
1220
+ button.classList.toggle('is-active', active);
1221
+ button.setAttribute('aria-pressed', String(active));
1222
+ });
1223
+ }
1224
+
1225
+ restart(nextOptions = {}) {
1226
+ this.options = {
1227
+ ...this.options,
1228
+ ...nextOptions,
1229
+ ui: {
1230
+ ...this.options.ui,
1231
+ ...(nextOptions.ui ?? {}),
1232
+ raceDataBanners: {
1233
+ ...this.options.ui.raceDataBanners,
1234
+ ...(nextOptions.ui?.raceDataBanners ?? {}),
1235
+ },
1236
+ },
1237
+ assets: {
1238
+ ...this.options.assets,
1239
+ ...(nextOptions.assets ?? {}),
1240
+ trackTextures: {
1241
+ ...this.options.assets.trackTextures,
1242
+ ...(nextOptions.assets?.trackTextures ?? {}),
1243
+ },
1244
+ },
1245
+ };
1246
+ this.assets = this.options.assets;
1247
+ this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
1248
+ this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
1249
+ applyPaddockThemeCssVariables(this.root, this.options.theme);
1250
+ if (nextOptions.drivers) {
1251
+ this.drivers = nextOptions.drivers;
1252
+ this.driverById = new Map(this.drivers.map((driver) => [driver.id, driver]));
1253
+ this.root.style.setProperty('--paddock-entry-count', String(this.drivers.length));
1254
+ this.createCars();
1255
+ }
1256
+ this.sim = this.createRaceSimulation();
1257
+ this.selectedId = this.drivers[0]?.id ?? null;
1258
+ this.resetRaceDataBannerState(performance.now());
1259
+ this.lastTimingRenderTime = 0;
1260
+ this.lastTimingRaceMode = null;
1261
+ this.lastLeaderLap = null;
1262
+ this.emittedRaceEventKeys.clear();
1263
+ this.raceFinishEmitted = false;
1264
+ this.renderTrack();
1265
+ this.updateDom(this.sim.snapshot());
1266
+ }
1267
+
1268
+ setSafetyCarDeployed(deployed) {
1269
+ this.sim?.setSafetyCar(Boolean(deployed));
1270
+ const active = this.sim?.snapshot().raceControl.mode === 'safety-car';
1271
+ this.syncSafetyCarControls(active);
1272
+ }
1273
+
1274
+ getSnapshot() {
1275
+ return this.sim?.snapshot() ?? null;
1276
+ }
1277
+
1278
+ destroy() {
1279
+ this.abortController.abort();
1280
+ this.layoutResizeObserver?.disconnect?.();
1281
+ this.layoutResizeObserver = null;
1282
+ this.visibilityObserver?.disconnect?.();
1283
+ this.visibilityObserver = null;
1284
+ if (this.app && this.tickerCallback) {
1285
+ this.app.ticker.remove(this.tickerCallback);
1286
+ }
1287
+ this.app?.destroy(true, { children: true, texture: false });
1288
+ this.app = null;
1289
+ this.carSprites.clear();
1290
+ this.carHitAreas.clear();
1291
+ this.drsTrails.clear();
1292
+ }
1293
+ }