@inventure71/paddockjs 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/INSTALL_AND_UPDATE.md +163 -0
- package/README.md +96 -16
- package/docs/architecture.md +55 -11
- package/docs/concepts.md +5 -1
- package/docs/data_contract.md +324 -8
- package/docs/data_model.md +1 -0
- package/docs/index.md +1 -0
- package/docs/rules.md +2 -0
- package/docs/system_specs.md +66 -11
- package/docs/training.md +104 -0
- package/examples/train-basic-policy.mjs +277 -0
- package/package.json +14 -7
- package/src/api/PaddockSimulatorController.js +64 -3
- package/src/app/BrowserExpertAdapter.js +32 -0
- package/src/app/F1SimulatorApp.js +308 -38
- package/src/app/domBindings.js +35 -11
- package/src/config/defaultOptions.js +33 -0
- package/src/data/championship.js +13 -0
- package/src/data/normalizeDrivers.js +7 -2
- package/src/environment/actions.js +45 -0
- package/src/environment/episode.js +17 -0
- package/src/environment/events.js +17 -0
- package/src/environment/index.d.ts +241 -0
- package/src/environment/index.js +2 -0
- package/src/environment/observations.js +112 -0
- package/src/environment/options.js +150 -0
- package/src/environment/rewards.js +69 -0
- package/src/environment/runtime.js +140 -0
- package/src/environment/sensors.js +257 -0
- package/src/environment/specs.js +79 -0
- package/src/index.d.ts +97 -4
- package/src/index.js +12 -2
- package/src/rendering/proceduralTrackAsset.js +7 -1
- package/src/simulation/raceSimulation.js +214 -1
- package/src/simulation/trackModel.js +21 -0
- package/src/styles.css +495 -2
- package/src/ui/componentTemplates.js +201 -4
- package/AGENTS.md +0 -86
- package/src/standalone.js +0 -15
|
@@ -2,14 +2,16 @@ import { Application, Assets, Container, Graphics, Sprite, Texture } from 'pixi.
|
|
|
2
2
|
import { DRIVER_STAT_DEFINITIONS, formatDriverNumber, VEHICLE_STAT_DEFINITIONS } from '../data/championship.js';
|
|
3
3
|
import { applyPaddockThemeCssVariables } from '../config/defaultOptions.js';
|
|
4
4
|
import { normalizeCustomFields } from '../data/customFields.js';
|
|
5
|
+
import { getCarRayOrigin, getCarRayVector } from '../environment/sensors.js';
|
|
5
6
|
import { ProceduralTrackAsset } from '../rendering/proceduralTrackAsset.js';
|
|
6
7
|
import { createRenderSnapshot } from '../rendering/renderSnapshot.js';
|
|
7
|
-
import { createRaceSimulation } from '../simulation/raceSimulation.js';
|
|
8
|
+
import { createRaceSimulation, FIXED_STEP } from '../simulation/raceSimulation.js';
|
|
8
9
|
import { clamp } from '../simulation/simMath.js';
|
|
9
10
|
import { offsetTrackPoint, pointAt, WORLD } from '../simulation/trackModel.js';
|
|
10
|
-
import {
|
|
11
|
+
import { metersToSimUnits } from '../simulation/units.js';
|
|
12
|
+
import { createBrowserExpertAdapter } from './BrowserExpertAdapter.js';
|
|
13
|
+
import { querySimulatorDom, setText, setTextAll } from './domBindings.js';
|
|
11
14
|
|
|
12
|
-
const FIXED_STEP = 1 / 60;
|
|
13
15
|
const TARGET_RENDER_FPS = 60;
|
|
14
16
|
const TARGET_FRAME_MS = 1000 / TARGET_RENDER_FPS;
|
|
15
17
|
const FRAME_PACING_EPSILON_MS = 0.75;
|
|
@@ -34,6 +36,9 @@ const SHOW_ALL_TOP_RESERVED = 92;
|
|
|
34
36
|
const SHOW_ALL_BOTTOM_RESERVED = 132;
|
|
35
37
|
const DRS_TRAIL_TTL = 0.68;
|
|
36
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;
|
|
37
42
|
const RACE_DATA_SELECTED_VISIBLE_MS = 5200;
|
|
38
43
|
const RADIO_BREAK_MIN_MS = 4800;
|
|
39
44
|
const RADIO_BREAK_MAX_MS = 11800;
|
|
@@ -84,6 +89,23 @@ function getTireClass(tire) {
|
|
|
84
89
|
return String(tire ?? 'M').toLowerCase();
|
|
85
90
|
}
|
|
86
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
|
+
|
|
87
109
|
function colorToTint(color) {
|
|
88
110
|
if (TINT_BY_COLOR.has(color)) return TINT_BY_COLOR.get(color);
|
|
89
111
|
const tint = Number.parseInt(String(color ?? '').replace('#', ''), 16);
|
|
@@ -99,6 +121,43 @@ function smoothAngle(current, target, amount) {
|
|
|
99
121
|
return current + diff * amount;
|
|
100
122
|
}
|
|
101
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
|
+
function hasOwnOption(options, key) {
|
|
145
|
+
return Object.hasOwn(options ?? {}, key);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function expertOptionsChanged(nextExpertOptions, currentExpertOptions) {
|
|
149
|
+
return nextExpertOptions !== currentExpertOptions;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function assetSetsEqual(first = {}, second = {}) {
|
|
153
|
+
const firstTrackTextures = first.trackTextures ?? {};
|
|
154
|
+
const secondTrackTextures = second.trackTextures ?? {};
|
|
155
|
+
const assetKeys = ['car', 'carOverview', 'driverHelmet', 'safetyCar', 'broadcastPanel', 'f1Logo'];
|
|
156
|
+
const textureKeys = new Set([...Object.keys(firstTrackTextures), ...Object.keys(secondTrackTextures)]);
|
|
157
|
+
return assetKeys.every((key) => first[key] === second[key]) &&
|
|
158
|
+
[...textureKeys].every((key) => firstTrackTextures[key] === secondTrackTextures[key]);
|
|
159
|
+
}
|
|
160
|
+
|
|
102
161
|
export class F1SimulatorApp {
|
|
103
162
|
constructor(root, options) {
|
|
104
163
|
this.root = root;
|
|
@@ -117,12 +176,15 @@ export class F1SimulatorApp {
|
|
|
117
176
|
this.visibilityChangeHandler = null;
|
|
118
177
|
this.visibilityObserver = null;
|
|
119
178
|
this.tickerCallback = null;
|
|
179
|
+
this.expert = null;
|
|
180
|
+
this.expertMode = Boolean(options.expert?.enabled);
|
|
120
181
|
this.sim = null;
|
|
121
182
|
this.app = null;
|
|
122
183
|
this.worldLayer = null;
|
|
123
184
|
this.trackAsset = null;
|
|
124
185
|
this.drsLayer = null;
|
|
125
186
|
this.trailLayer = null;
|
|
187
|
+
this.sensorLayer = null;
|
|
126
188
|
this.carLayer = null;
|
|
127
189
|
this.textures = {};
|
|
128
190
|
this.carSprites = new Map();
|
|
@@ -155,6 +217,9 @@ export class F1SimulatorApp {
|
|
|
155
217
|
this.lastDomUpdateTime = 0;
|
|
156
218
|
this.lastTimingRenderTime = 0;
|
|
157
219
|
this.lastTimingRaceMode = null;
|
|
220
|
+
this.lastTimingMarkup = null;
|
|
221
|
+
this.lastOverviewRenderKey = null;
|
|
222
|
+
this.lastFinishClassificationMarkup = null;
|
|
158
223
|
this.runtimeViewportVisible = true;
|
|
159
224
|
this.runtimeDocumentVisible = typeof document === 'undefined' ? true : document.visibilityState !== 'hidden';
|
|
160
225
|
this.runtimeTickerRunning = true;
|
|
@@ -167,6 +232,9 @@ export class F1SimulatorApp {
|
|
|
167
232
|
this.emittedRaceEventKeys = new Set();
|
|
168
233
|
this.raceFinishEmitted = false;
|
|
169
234
|
this.timingGapMode = 'interval';
|
|
235
|
+
this.telemetryDrawerOpen = Boolean(
|
|
236
|
+
this.readouts.telemetryDrawerWorkbench?.classList?.contains?.('is-telemetry-open'),
|
|
237
|
+
);
|
|
170
238
|
}
|
|
171
239
|
|
|
172
240
|
async init() {
|
|
@@ -190,29 +258,36 @@ export class F1SimulatorApp {
|
|
|
190
258
|
this.worldLayer = new Container();
|
|
191
259
|
this.drsLayer = new Container();
|
|
192
260
|
this.trailLayer = new Graphics();
|
|
261
|
+
this.sensorLayer = new Graphics();
|
|
193
262
|
this.carLayer = new Container();
|
|
194
263
|
this.app.stage.addChild(this.worldLayer);
|
|
195
264
|
|
|
196
265
|
await this.loadAssets();
|
|
197
266
|
this.trackAsset = new ProceduralTrackAsset({ textures: this.textures, world: WORLD });
|
|
198
|
-
this.worldLayer.addChild(this.trackAsset.container, this.drsLayer, this.trailLayer, this.carLayer);
|
|
267
|
+
this.worldLayer.addChild(this.trackAsset.container, this.drsLayer, this.trailLayer, this.sensorLayer, this.carLayer);
|
|
199
268
|
this.createCars();
|
|
200
269
|
this.bindControls();
|
|
201
270
|
this.renderTrack();
|
|
202
271
|
const snapshot = this.sim.snapshot();
|
|
203
272
|
this.updateDom(snapshot);
|
|
273
|
+
if (this.expertMode) {
|
|
274
|
+
this.expert = createBrowserExpertAdapter(this, this.options.expert);
|
|
275
|
+
}
|
|
204
276
|
this.completeComponentLoading();
|
|
205
277
|
this.emitHostCallback('onLoadingChange', { loading: false, phase: 'ready' });
|
|
206
278
|
this.emitHostCallback('onReady', { snapshot });
|
|
207
279
|
this.resizeHandler = () => this.applyCamera(this.sim.snapshot());
|
|
208
280
|
window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
|
|
209
|
-
this.tickerCallback = () => this.tick();
|
|
210
|
-
this.app.ticker.add(this.tickerCallback);
|
|
211
281
|
this.observeLayoutResize();
|
|
212
|
-
this.
|
|
282
|
+
if (!this.expertMode) {
|
|
283
|
+
this.tickerCallback = () => this.tick();
|
|
284
|
+
this.app.ticker.add(this.tickerCallback);
|
|
285
|
+
this.observeRuntimeVisibility();
|
|
286
|
+
}
|
|
213
287
|
} catch (error) {
|
|
214
288
|
this.emitHostCallback('onLoadingChange', { loading: false, phase: 'error' });
|
|
215
289
|
this.emitHostCallback('onError', error, { phase: 'init' });
|
|
290
|
+
this.destroy();
|
|
216
291
|
throw error;
|
|
217
292
|
}
|
|
218
293
|
}
|
|
@@ -241,12 +316,12 @@ export class F1SimulatorApp {
|
|
|
241
316
|
});
|
|
242
317
|
}
|
|
243
318
|
|
|
244
|
-
createRaceSimulation() {
|
|
319
|
+
createRaceSimulation(options = this.options) {
|
|
245
320
|
return createRaceSimulation({
|
|
246
|
-
seed:
|
|
247
|
-
trackSeed: this.trackSeed,
|
|
248
|
-
drivers: this.drivers,
|
|
249
|
-
totalLaps:
|
|
321
|
+
seed: options.seed,
|
|
322
|
+
trackSeed: options.trackSeed ?? this.trackSeed,
|
|
323
|
+
drivers: options.drivers ?? this.drivers,
|
|
324
|
+
totalLaps: options.totalLaps,
|
|
250
325
|
});
|
|
251
326
|
}
|
|
252
327
|
|
|
@@ -391,6 +466,27 @@ export class F1SimulatorApp {
|
|
|
391
466
|
if (!driver) return;
|
|
392
467
|
this.options.onDriverOpen?.(driver);
|
|
393
468
|
}, eventOptions);
|
|
469
|
+
|
|
470
|
+
this.readouts.telemetryDrawerToggle?.addEventListener('click', () => {
|
|
471
|
+
this.setTelemetryDrawerOpen(!this.telemetryDrawerOpen);
|
|
472
|
+
}, eventOptions);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
setTelemetryDrawerOpen(open) {
|
|
476
|
+
this.telemetryDrawerOpen = Boolean(open);
|
|
477
|
+
this.readouts.telemetryDrawerWorkbench?.classList?.toggle?.('is-telemetry-open', this.telemetryDrawerOpen);
|
|
478
|
+
if (this.telemetryDrawerOpen) {
|
|
479
|
+
this.readouts.telemetryDrawer?.removeAttribute?.('inert');
|
|
480
|
+
this.readouts.telemetryDrawer?.setAttribute?.('aria-hidden', 'false');
|
|
481
|
+
} else {
|
|
482
|
+
this.readouts.telemetryDrawer?.setAttribute?.('inert', '');
|
|
483
|
+
this.readouts.telemetryDrawer?.setAttribute?.('aria-hidden', 'true');
|
|
484
|
+
}
|
|
485
|
+
this.readouts.telemetryDrawerToggle?.setAttribute?.('aria-expanded', String(this.telemetryDrawerOpen));
|
|
486
|
+
if (this.readouts.telemetryDrawerToggle) {
|
|
487
|
+
this.readouts.telemetryDrawerToggle.textContent = this.telemetryDrawerOpen ? 'Close telemetry' : 'Telemetry';
|
|
488
|
+
}
|
|
489
|
+
this.invalidateCameraSafeArea();
|
|
394
490
|
}
|
|
395
491
|
|
|
396
492
|
observeLayoutResize() {
|
|
@@ -436,6 +532,7 @@ export class F1SimulatorApp {
|
|
|
436
532
|
}
|
|
437
533
|
|
|
438
534
|
syncRuntimeTicker() {
|
|
535
|
+
if (this.expertMode) return;
|
|
439
536
|
if (!this.app?.ticker) return;
|
|
440
537
|
|
|
441
538
|
const shouldRun = this.runtimeViewportVisible && this.runtimeDocumentVisible;
|
|
@@ -459,6 +556,7 @@ export class F1SimulatorApp {
|
|
|
459
556
|
}
|
|
460
557
|
|
|
461
558
|
tick() {
|
|
559
|
+
if (this.expertMode) return;
|
|
462
560
|
const now = performance.now();
|
|
463
561
|
if (now < this.nextGameFrameTime - FRAME_PACING_EPSILON_MS) return;
|
|
464
562
|
|
|
@@ -501,8 +599,20 @@ export class F1SimulatorApp {
|
|
|
501
599
|
}
|
|
502
600
|
}
|
|
503
601
|
|
|
602
|
+
renderExpertFrame(snapshot = this.sim?.snapshot(), { observation } = {}) {
|
|
603
|
+
if (!snapshot) return;
|
|
604
|
+
const renderSnapshot = createRenderSnapshot(snapshot, 0);
|
|
605
|
+
this.applyCamera(renderSnapshot);
|
|
606
|
+
this.renderDrsTrails(renderSnapshot);
|
|
607
|
+
this.renderExpertSensorRays(renderSnapshot, observation);
|
|
608
|
+
this.renderCars(renderSnapshot);
|
|
609
|
+
this.updateDom(snapshot, { emitLifecycle: false });
|
|
610
|
+
this.lastDomUpdateTime = performance.now();
|
|
611
|
+
}
|
|
612
|
+
|
|
504
613
|
renderTrack() {
|
|
505
|
-
this.drsLayer
|
|
614
|
+
destroyDisplayChildren(this.drsLayer);
|
|
615
|
+
this.sensorLayer?.clear();
|
|
506
616
|
const snapshot = this.sim.snapshot();
|
|
507
617
|
const track = snapshot.track;
|
|
508
618
|
|
|
@@ -558,6 +668,60 @@ export class F1SimulatorApp {
|
|
|
558
668
|
}
|
|
559
669
|
}
|
|
560
670
|
|
|
671
|
+
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
|
+
});
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
561
725
|
renderDrsTrails(snapshot) {
|
|
562
726
|
if (!this.trailLayer) return;
|
|
563
727
|
|
|
@@ -637,8 +801,17 @@ export class F1SimulatorApp {
|
|
|
637
801
|
this.cameraSafeAreaCache = { width, safeArea };
|
|
638
802
|
return safeArea;
|
|
639
803
|
}
|
|
640
|
-
const
|
|
641
|
-
|
|
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) {
|
|
642
815
|
const safeArea = { left: 0, width };
|
|
643
816
|
this.cameraSafeAreaCache = { width, safeArea };
|
|
644
817
|
return safeArea;
|
|
@@ -837,12 +1010,16 @@ export class F1SimulatorApp {
|
|
|
837
1010
|
|
|
838
1011
|
if (this.readouts.finishClassification) {
|
|
839
1012
|
const topThree = (snapshot.raceControl.classification ?? []).slice(0, 3);
|
|
840
|
-
|
|
1013
|
+
const classificationMarkup = topThree.map((entry) => `
|
|
841
1014
|
<li>
|
|
842
1015
|
<span>P${escapeHtml(entry.rank)}</span>
|
|
843
1016
|
<strong>${escapeHtml(entry.timingCode ?? entry.code ?? entry.id)}</strong>
|
|
844
1017
|
</li>
|
|
845
1018
|
`).join('');
|
|
1019
|
+
if (classificationMarkup !== this.lastFinishClassificationMarkup) {
|
|
1020
|
+
this.readouts.finishClassification.innerHTML = classificationMarkup;
|
|
1021
|
+
this.lastFinishClassificationMarkup = classificationMarkup;
|
|
1022
|
+
}
|
|
846
1023
|
}
|
|
847
1024
|
}
|
|
848
1025
|
|
|
@@ -903,7 +1080,7 @@ export class F1SimulatorApp {
|
|
|
903
1080
|
renderTiming(cars, raceMode) {
|
|
904
1081
|
if (!this.timingList) return;
|
|
905
1082
|
this.syncTimingGapModeControls();
|
|
906
|
-
|
|
1083
|
+
const timingMarkup = cars.map((car) => {
|
|
907
1084
|
const driver = this.driverById.get(car.id);
|
|
908
1085
|
let gap = 'Leader';
|
|
909
1086
|
if (raceMode === 'finished') {
|
|
@@ -935,6 +1112,10 @@ export class F1SimulatorApp {
|
|
|
935
1112
|
</li>
|
|
936
1113
|
`;
|
|
937
1114
|
}).join('');
|
|
1115
|
+
if (timingMarkup !== this.lastTimingMarkup) {
|
|
1116
|
+
this.timingList.innerHTML = timingMarkup;
|
|
1117
|
+
this.lastTimingMarkup = timingMarkup;
|
|
1118
|
+
}
|
|
938
1119
|
}
|
|
939
1120
|
|
|
940
1121
|
formatTimingGap(car) {
|
|
@@ -962,22 +1143,27 @@ export class F1SimulatorApp {
|
|
|
962
1143
|
const drsState = car.drsActive ? 'OPEN' : car.drsEligible ? 'READY' : 'OFF';
|
|
963
1144
|
const surface = (car.surface ?? 'track').toUpperCase();
|
|
964
1145
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1146
|
+
setTextAll(this.readouts.selectedCode, car.code);
|
|
1147
|
+
this.readouts.selectedCode?.forEach?.((node) => {
|
|
1148
|
+
node.style.color = car.color;
|
|
1149
|
+
});
|
|
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(
|
|
975
1161
|
this.readouts.gap,
|
|
976
1162
|
car.rank === 1 || !Number.isFinite(car.gapAheadSeconds)
|
|
977
1163
|
? '--'
|
|
978
1164
|
: `${car.gapAheadSeconds.toFixed(2)}s`,
|
|
979
1165
|
);
|
|
980
|
-
|
|
1166
|
+
setTextAll(
|
|
981
1167
|
this.readouts.leaderGap,
|
|
982
1168
|
car.rank === 1 || !Number.isFinite(car.leaderGapSeconds)
|
|
983
1169
|
? '--'
|
|
@@ -985,6 +1171,56 @@ export class F1SimulatorApp {
|
|
|
985
1171
|
);
|
|
986
1172
|
|
|
987
1173
|
this.renderCarDriverOverview(car);
|
|
1174
|
+
this.renderLapTelemetry(car.lapTelemetry);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
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
|
+
});
|
|
988
1224
|
}
|
|
989
1225
|
|
|
990
1226
|
getOverviewFields(driver, mode) {
|
|
@@ -1024,6 +1260,25 @@ export class F1SimulatorApp {
|
|
|
1024
1260
|
const displayFields = fields.length > 0
|
|
1025
1261
|
? fields
|
|
1026
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]),
|
|
1279
|
+
});
|
|
1280
|
+
if (overviewKey === this.lastOverviewRenderKey) return;
|
|
1281
|
+
this.lastOverviewRenderKey = overviewKey;
|
|
1027
1282
|
|
|
1028
1283
|
this.readouts.carOverview?.style.setProperty('--driver-color', car.color);
|
|
1029
1284
|
this.readouts.carOverviewDiagram?.style.setProperty('--driver-color', car.color);
|
|
@@ -1031,15 +1286,11 @@ export class F1SimulatorApp {
|
|
|
1031
1286
|
this.readouts.carOverviewTitle.textContent = mode === 'driver' ? 'Driver overview' : 'Car overview';
|
|
1032
1287
|
}
|
|
1033
1288
|
if (this.readouts.carOverviewCode) {
|
|
1034
|
-
this.readouts.carOverviewCode.textContent =
|
|
1035
|
-
? `${car.code} driver`
|
|
1036
|
-
: `${driver?.vehicle?.name ?? car.vehicleName ?? car.code}`;
|
|
1289
|
+
this.readouts.carOverviewCode.textContent = overviewCode;
|
|
1037
1290
|
}
|
|
1038
1291
|
if (this.readouts.carOverviewIcon) this.readouts.carOverviewIcon.textContent = icon;
|
|
1039
1292
|
if (this.readouts.carOverviewImage) {
|
|
1040
|
-
this.readouts.carOverviewImage.src =
|
|
1041
|
-
? (driver?.driverImage ?? driver?.portrait ?? driver?.avatar ?? this.assets.driverHelmet)
|
|
1042
|
-
: this.assets.carOverview;
|
|
1293
|
+
this.readouts.carOverviewImage.src = imageSrc;
|
|
1043
1294
|
}
|
|
1044
1295
|
if (this.readouts.carOverviewNumber) this.readouts.carOverviewNumber.textContent = driverNumber;
|
|
1045
1296
|
if (this.readouts.carOverviewCoreStat) this.readouts.carOverviewCoreStat.textContent = mode === 'driver' ? 'Driver' : 'Car';
|
|
@@ -1222,7 +1473,7 @@ export class F1SimulatorApp {
|
|
|
1222
1473
|
});
|
|
1223
1474
|
}
|
|
1224
1475
|
|
|
1225
|
-
|
|
1476
|
+
applyExpertOptions(nextOptions = {}) {
|
|
1226
1477
|
this.options = {
|
|
1227
1478
|
...this.options,
|
|
1228
1479
|
...nextOptions,
|
|
@@ -1244,17 +1495,36 @@ export class F1SimulatorApp {
|
|
|
1244
1495
|
},
|
|
1245
1496
|
};
|
|
1246
1497
|
this.assets = this.options.assets;
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1498
|
+
if (hasOwnOption(nextOptions, 'trackSeed')) {
|
|
1499
|
+
this.trackSeed = this.options.trackSeed ?? createMountTrackSeed();
|
|
1500
|
+
}
|
|
1250
1501
|
if (nextOptions.drivers) {
|
|
1251
1502
|
this.drivers = nextOptions.drivers;
|
|
1252
1503
|
this.driverById = new Map(this.drivers.map((driver) => [driver.id, driver]));
|
|
1253
1504
|
this.root.style.setProperty('--paddock-entry-count', String(this.drivers.length));
|
|
1254
1505
|
this.createCars();
|
|
1255
1506
|
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
restart(nextOptions = {}) {
|
|
1510
|
+
if (hasOwnOption(nextOptions, 'assets') && !assetSetsEqual(nextOptions.assets, this.options.assets)) {
|
|
1511
|
+
throw new Error('PaddockJS restart() does not support changing assets. Destroy and mount a new simulator with the new assets.');
|
|
1512
|
+
}
|
|
1513
|
+
if (hasOwnOption(nextOptions, 'expert') && expertOptionsChanged(nextOptions.expert, this.options.expert)) {
|
|
1514
|
+
throw new Error('PaddockJS restart() does not support changing expert mode. Destroy and mount a new simulator with the new expert options.');
|
|
1515
|
+
}
|
|
1516
|
+
this.applyExpertOptions(nextOptions);
|
|
1517
|
+
this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
|
|
1518
|
+
this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
|
|
1519
|
+
applyPaddockThemeCssVariables(this.root, this.options.theme);
|
|
1256
1520
|
this.sim = this.createRaceSimulation();
|
|
1521
|
+
if (this.expertMode) {
|
|
1522
|
+
this.expert = createBrowserExpertAdapter(this, this.options.expert);
|
|
1523
|
+
}
|
|
1257
1524
|
this.selectedId = this.drivers[0]?.id ?? null;
|
|
1525
|
+
this.lastTimingMarkup = null;
|
|
1526
|
+
this.lastOverviewRenderKey = null;
|
|
1527
|
+
this.lastFinishClassificationMarkup = null;
|
|
1258
1528
|
this.resetRaceDataBannerState(performance.now());
|
|
1259
1529
|
this.lastTimingRenderTime = 0;
|
|
1260
1530
|
this.lastTimingRaceMode = null;
|
package/src/app/domBindings.js
CHANGED
|
@@ -13,16 +13,26 @@ export function querySimulatorDom(root) {
|
|
|
13
13
|
contacts: root.querySelector('[data-contact-readout]'),
|
|
14
14
|
camera: root.querySelector('[data-camera-readout]'),
|
|
15
15
|
fps: root.querySelector('[data-fps-readout]'),
|
|
16
|
-
selectedCode: root.
|
|
17
|
-
selectedName: root.
|
|
18
|
-
speed: root.
|
|
19
|
-
throttle: root.
|
|
20
|
-
brake: root.
|
|
21
|
-
tyres: root.
|
|
22
|
-
selectedDrs: root.
|
|
23
|
-
surface: root.
|
|
24
|
-
gap: root.
|
|
25
|
-
leaderGap: root.
|
|
16
|
+
selectedCode: root.querySelectorAll('[data-selected-code]'),
|
|
17
|
+
selectedName: root.querySelectorAll('[data-selected-name]'),
|
|
18
|
+
speed: root.querySelectorAll('[data-telemetry-speed]'),
|
|
19
|
+
throttle: root.querySelectorAll('[data-telemetry-throttle]'),
|
|
20
|
+
brake: root.querySelectorAll('[data-telemetry-brake]'),
|
|
21
|
+
tyres: root.querySelectorAll('[data-telemetry-tyres]'),
|
|
22
|
+
selectedDrs: root.querySelectorAll('[data-telemetry-drs]'),
|
|
23
|
+
surface: root.querySelectorAll('[data-telemetry-surface]'),
|
|
24
|
+
gap: root.querySelectorAll('[data-telemetry-gap]'),
|
|
25
|
+
leaderGap: root.querySelectorAll('[data-telemetry-leader-gap]'),
|
|
26
|
+
currentSector: root.querySelectorAll('[data-telemetry-current-sector]'),
|
|
27
|
+
completedLaps: root.querySelectorAll('[data-telemetry-completed-laps]'),
|
|
28
|
+
currentLapTime: root.querySelectorAll('[data-telemetry-current-lap-time]'),
|
|
29
|
+
lastLapTime: root.querySelectorAll('[data-telemetry-last-lap-time]'),
|
|
30
|
+
bestLapTime: root.querySelectorAll('[data-telemetry-best-lap-time]'),
|
|
31
|
+
telemetrySectorBars: root.querySelectorAll('[data-telemetry-sector-bar]'),
|
|
32
|
+
telemetrySectorTimes: root.querySelectorAll('[data-telemetry-sector-time]'),
|
|
33
|
+
telemetrySectorLast: root.querySelectorAll('[data-telemetry-sector-last]'),
|
|
34
|
+
telemetrySectorBest: root.querySelectorAll('[data-telemetry-sector-best]'),
|
|
35
|
+
telemetrySectorBanners: root.querySelectorAll('[data-telemetry-sector-banner]'),
|
|
26
36
|
carOverview: root.querySelector('[data-paddock-component="car-driver-overview"]'),
|
|
27
37
|
carOverviewTitle: root.querySelector('[data-car-overview-title]'),
|
|
28
38
|
carOverviewDiagram: root.querySelector('.car-overview-diagram'),
|
|
@@ -32,6 +42,9 @@ export function querySimulatorDom(root) {
|
|
|
32
42
|
carOverviewNumber: root.querySelector('[data-car-overview-number]'),
|
|
33
43
|
carOverviewCoreStat: root.querySelector('[data-car-overview-core-stat]'),
|
|
34
44
|
carOverviewFields: root.querySelectorAll('[data-overview-field]'),
|
|
45
|
+
telemetryDrawerWorkbench: root.querySelector('[data-race-telemetry-drawer]'),
|
|
46
|
+
telemetryDrawer: root.querySelector('[data-telemetry-drawer]'),
|
|
47
|
+
telemetryDrawerToggle: root.querySelector('[data-telemetry-drawer-toggle]'),
|
|
35
48
|
raceDataPanel: root.querySelector('[data-race-data-panel]'),
|
|
36
49
|
raceDataKicker: root.querySelector('[data-race-data-kicker]'),
|
|
37
50
|
raceDataTitle: root.querySelector('[data-race-data-title]'),
|
|
@@ -60,5 +73,16 @@ export function querySimulatorDom(root) {
|
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
export function setText(node, value) {
|
|
63
|
-
if (node)
|
|
76
|
+
if (!node) return;
|
|
77
|
+
const nextValue = String(value ?? '');
|
|
78
|
+
if (node.textContent !== nextValue) node.textContent = nextValue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function setTextAll(nodes, value) {
|
|
82
|
+
if (!nodes) return;
|
|
83
|
+
if (typeof nodes.forEach === 'function') {
|
|
84
|
+
nodes.forEach((node) => setText(node, value));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
setText(nodes, value);
|
|
64
88
|
}
|