@inventure71/paddockjs 0.3.0 → 1.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.
- package/CHANGELOG.md +39 -0
- package/INSTALL_AND_UPDATE.md +3 -1
- package/README.md +142 -9
- package/docs/architecture.md +49 -15
- package/docs/concepts.md +20 -3
- package/docs/data_contract.md +185 -27
- package/docs/data_model.md +1 -1
- package/docs/learnings.md +1 -1
- package/docs/rules.md +140 -19
- package/docs/system_specs.md +39 -16
- package/docs/training.md +103 -1
- package/examples/train-basic-policy.mjs +5 -6
- package/examples/trainingData.mjs +133 -0
- package/package.json +5 -2
- package/src/api/PaddockSimulatorController.js +32 -2
- package/src/app/F1SimulatorApp.js +1101 -128
- package/src/app/domBindings.js +10 -1
- package/src/config/defaultOptions.js +4 -4
- package/src/environment/actions.js +25 -2
- package/src/environment/evaluation.js +184 -0
- package/src/environment/index.d.ts +281 -1
- package/src/environment/index.js +13 -0
- package/src/environment/observationOptions.js +12 -0
- package/src/environment/observations.js +189 -17
- package/src/environment/options.js +7 -0
- package/src/environment/recorder.js +28 -0
- package/src/environment/runtime.js +41 -6
- package/src/environment/scenarios.js +220 -0
- package/src/environment/sensors.js +145 -35
- package/src/environment/specs.js +92 -8
- package/src/environment/workerProtocol.js +52 -0
- package/src/index.d.ts +366 -3
- package/src/index.js +21 -0
- package/src/rendering/proceduralTrackAsset.js +336 -23
- package/src/simulation/collisionGeometry.js +214 -0
- package/src/simulation/driverController.js +431 -65
- package/src/simulation/raceSimulation.js +2132 -171
- package/src/simulation/rules/collisionSteward.js +69 -0
- package/src/simulation/rules/penaltyLedger.js +153 -0
- package/src/simulation/rules/pitLaneSpeedingSteward.js +71 -0
- package/src/simulation/rules/tireRequirementSteward.js +29 -0
- package/src/simulation/rules/trackLimitsSteward.js +85 -0
- package/src/simulation/rulesConfig.js +352 -0
- package/src/simulation/trackModel.js +1154 -76
- package/src/simulation/units.js +6 -4
- package/src/simulation/vehicleGeometry.js +247 -0
- package/src/simulation/vehiclePhysics.js +82 -50
- package/src/simulation/wheelSurface.js +338 -0
- package/src/styles.css +300 -42
- package/src/ui/bannerTemplates.js +69 -0
- package/src/ui/componentTemplates.js +36 -96
- package/src/ui/raceControlStatusBanner.js +39 -0
- package/src/ui/shellTemplate.js +1 -1
- package/src/ui/templateUtils.js +23 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Application, Assets, Container, Graphics, Sprite, Texture } from 'pixi.js';
|
|
1
|
+
import { Application, Assets, Container, Graphics, Sprite, Text, Texture } from 'pixi.js';
|
|
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';
|
|
@@ -9,6 +9,8 @@ import { createRaceSimulation, FIXED_STEP } from '../simulation/raceSimulation.j
|
|
|
9
9
|
import { clamp } from '../simulation/simMath.js';
|
|
10
10
|
import { offsetTrackPoint, pointAt, WORLD } from '../simulation/trackModel.js';
|
|
11
11
|
import { metersToSimUnits } from '../simulation/units.js';
|
|
12
|
+
import { VEHICLE_GEOMETRY } from '../simulation/vehicleGeometry.js';
|
|
13
|
+
import { getRaceControlStatusBanner } from '../ui/raceControlStatusBanner.js';
|
|
12
14
|
import { createBrowserExpertAdapter } from './BrowserExpertAdapter.js';
|
|
13
15
|
import { querySimulatorDom, setText, setTextAll } from './domBindings.js';
|
|
14
16
|
|
|
@@ -16,26 +18,48 @@ const TARGET_RENDER_FPS = 60;
|
|
|
16
18
|
const TARGET_FRAME_MS = 1000 / TARGET_RENDER_FPS;
|
|
17
19
|
const FRAME_PACING_EPSILON_MS = 0.75;
|
|
18
20
|
const MAX_FRAME_CATCHUP_COUNT = 4;
|
|
19
|
-
const
|
|
21
|
+
const BASE_MAX_SIMULATION_STEPS_PER_RENDER = 5;
|
|
22
|
+
const HARD_MAX_SIMULATION_STEPS_PER_RENDER = 60;
|
|
20
23
|
const DOM_UPDATE_INTERVAL_MS = 100;
|
|
21
24
|
const TIMING_UPDATE_INTERVAL_MS = 250;
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
25
|
+
const CAMERA_TARGET_LERP = 0.12;
|
|
26
|
+
const CAMERA_SCALE_LERP = 0.12;
|
|
27
|
+
const SIMULATION_SPEED_STEPS = [1, 2, 3, 4, 5, 10];
|
|
28
|
+
const CAR_WORLD_LENGTH = VEHICLE_GEOMETRY.visualLength;
|
|
29
|
+
const CAR_WORLD_WIDTH = VEHICLE_GEOMETRY.visualWidth;
|
|
30
|
+
// Temporary debug view: set to false to restore normal car sprites.
|
|
31
|
+
const TEMP_RENDER_RAW_CAR_GEOMETRY = false;
|
|
32
|
+
const SAFETY_CAR_WORLD_LENGTH = metersToSimUnits(5.3);
|
|
33
|
+
const SAFETY_CAR_WORLD_WIDTH = metersToSimUnits(2.1);
|
|
27
34
|
const CAMERA_PRESETS = {
|
|
28
|
-
overview: 1
|
|
29
|
-
leader:
|
|
30
|
-
selected:
|
|
35
|
+
overview: 1,
|
|
36
|
+
leader: 18,
|
|
37
|
+
selected: 24,
|
|
38
|
+
'show-all': 1,
|
|
39
|
+
pit: 2.35,
|
|
40
|
+
};
|
|
41
|
+
const CAMERA_MIN_ZOOM = 0.55;
|
|
42
|
+
const CAMERA_MAX_ZOOM = 120;
|
|
43
|
+
const CAMERA_ZOOM_STEP = 1.4;
|
|
44
|
+
const TRACK_CAMERA_PADDING = metersToSimUnits(120);
|
|
45
|
+
const SERVICE_COUNTDOWN_LABEL_STYLES = {
|
|
46
|
+
penalty: {
|
|
47
|
+
background: 0x110b0b,
|
|
48
|
+
stroke: 0xff2d55,
|
|
49
|
+
fill: 0xffffff,
|
|
50
|
+
},
|
|
51
|
+
pit: {
|
|
52
|
+
background: 0x171305,
|
|
53
|
+
stroke: 0xffd166,
|
|
54
|
+
fill: 0xfff2b0,
|
|
55
|
+
},
|
|
31
56
|
};
|
|
32
57
|
const SHOW_ALL_PADDING = 520;
|
|
33
|
-
const SHOW_ALL_MIN_ZOOM = 1.1;
|
|
34
|
-
const SHOW_ALL_MAX_ZOOM = 6.4;
|
|
35
58
|
const SHOW_ALL_TOP_RESERVED = 92;
|
|
36
59
|
const SHOW_ALL_BOTTOM_RESERVED = 132;
|
|
37
|
-
const
|
|
38
|
-
const
|
|
60
|
+
const PIT_CAMERA_PADDING = metersToSimUnits(34);
|
|
61
|
+
const DRS_TRAIL_TTL = 0.34;
|
|
62
|
+
const DRS_TRAIL_MIN_DISTANCE = metersToSimUnits(0.9);
|
|
39
63
|
const SENSOR_RAY_TRACK_COLOR = 0xf1c65b;
|
|
40
64
|
const SENSOR_RAY_TRACK_ENTRY_COLOR = 0x68d8ff;
|
|
41
65
|
const SENSOR_RAY_CAR_COLOR = 0xff4d5f;
|
|
@@ -45,6 +69,7 @@ const RADIO_BREAK_MAX_MS = 11800;
|
|
|
45
69
|
const RADIO_VISIBLE_MIN_MS = 6200;
|
|
46
70
|
const RADIO_VISIBLE_MAX_MS = 9200;
|
|
47
71
|
const RADIO_SCHEDULE_CATCHUP_LIMIT_MS = 30000;
|
|
72
|
+
const PENALTY_BANNER_VISIBLE_MS = 12000;
|
|
48
73
|
const DRS_DRAG_REDUCTION_PERCENT = 58;
|
|
49
74
|
|
|
50
75
|
const VEHICLE_OVERVIEW_FIELDS = [
|
|
@@ -89,6 +114,86 @@ function getTireClass(tire) {
|
|
|
89
114
|
return String(tire ?? 'M').toLowerCase();
|
|
90
115
|
}
|
|
91
116
|
|
|
117
|
+
function formatPenaltyType(type) {
|
|
118
|
+
return String(type ?? 'penalty')
|
|
119
|
+
.split('-')
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
122
|
+
.join(' ');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function formatPenaltyHeadline(penalty) {
|
|
126
|
+
const serviceType = penalty?.serviceType;
|
|
127
|
+
if (serviceType === 'driveThrough') {
|
|
128
|
+
return penalty?.unserved ? 'unserved drive-through penalty' : 'drive-through penalty';
|
|
129
|
+
}
|
|
130
|
+
if (serviceType === 'stopGo') {
|
|
131
|
+
return penalty?.unserved ? 'unserved stop-go penalty' : 'stop-go penalty';
|
|
132
|
+
}
|
|
133
|
+
const positionDrop = Number(penalty?.positionDrop);
|
|
134
|
+
if (Number.isFinite(positionDrop) && positionDrop > 0) {
|
|
135
|
+
return `${positionDrop}-place position drop`;
|
|
136
|
+
}
|
|
137
|
+
const gridDrop = Number(penalty?.gridDrop);
|
|
138
|
+
if (Number.isFinite(gridDrop) && gridDrop > 0) {
|
|
139
|
+
return `${gridDrop}-place grid drop`;
|
|
140
|
+
}
|
|
141
|
+
if (penalty?.disqualified) return 'disqualification';
|
|
142
|
+
const seconds = Number(penalty?.penaltySeconds);
|
|
143
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return 'Penalty decision';
|
|
144
|
+
const unit = seconds === 1 ? 'second' : 'seconds';
|
|
145
|
+
return `+${seconds} ${unit} time penalty`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function formatPenaltyChip(penalty) {
|
|
149
|
+
const serviceType = penalty?.serviceType;
|
|
150
|
+
if (serviceType === 'driveThrough') return 'DT';
|
|
151
|
+
if (serviceType === 'stopGo') return 'SG';
|
|
152
|
+
if (penalty?.disqualified) return 'DSQ';
|
|
153
|
+
const positionDrop = Number(penalty?.positionDrop);
|
|
154
|
+
if (Number.isFinite(positionDrop) && positionDrop > 0) return `-${positionDrop}P`;
|
|
155
|
+
const gridDrop = Number(penalty?.gridDrop);
|
|
156
|
+
if (Number.isFinite(gridDrop) && gridDrop > 0) return `-${gridDrop}G`;
|
|
157
|
+
const seconds = Number(penalty?.penaltySeconds);
|
|
158
|
+
return Number.isFinite(seconds) && seconds > 0 ? `+${seconds}s` : 'Penalty';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function formatPenaltyBadgeLabel(penalty) {
|
|
162
|
+
const seconds = Number(penalty?.penaltySeconds);
|
|
163
|
+
const timeLabel = Number.isFinite(seconds) && seconds > 0 ? `${seconds}s ` : '';
|
|
164
|
+
if (Number.isFinite(seconds) && seconds > 0 && !penalty?.serviceType) {
|
|
165
|
+
return `Penalty: ${timeLabel}${String(penalty?.type ?? 'decision')}`;
|
|
166
|
+
}
|
|
167
|
+
return `Penalty: ${timeLabel}${formatPenaltyHeadline(penalty)}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function hasVisiblePenaltyEffect(penalty) {
|
|
171
|
+
if (!penalty || penalty.status === 'cancelled') return false;
|
|
172
|
+
if (penalty.serviceRequired && penalty.status === 'served') return false;
|
|
173
|
+
const penaltySeconds = Number(penalty.penaltySeconds);
|
|
174
|
+
const pendingPenaltySeconds = Number(penalty.pendingPenaltySeconds);
|
|
175
|
+
const positionDrop = Number(penalty.positionDrop);
|
|
176
|
+
const gridDrop = Number(penalty.gridDrop);
|
|
177
|
+
return penalty.serviceRequired ||
|
|
178
|
+
(Number.isFinite(penaltySeconds) && penaltySeconds > 0) ||
|
|
179
|
+
(Number.isFinite(pendingPenaltySeconds) && pendingPenaltySeconds > 0) ||
|
|
180
|
+
(Number.isFinite(positionDrop) && positionDrop > 0) ||
|
|
181
|
+
(Number.isFinite(gridDrop) && gridDrop > 0) ||
|
|
182
|
+
Boolean(penalty.disqualified);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function formatStewardMessageKey(message) {
|
|
186
|
+
if (!message) return '';
|
|
187
|
+
return [
|
|
188
|
+
message.kind,
|
|
189
|
+
message.type,
|
|
190
|
+
message.driverId,
|
|
191
|
+
message.penaltyId,
|
|
192
|
+
message.violationCount,
|
|
193
|
+
message.time,
|
|
194
|
+
].join(':');
|
|
195
|
+
}
|
|
196
|
+
|
|
92
197
|
function formatTelemetryTime(value) {
|
|
93
198
|
if (!Number.isFinite(value)) return '--';
|
|
94
199
|
const clamped = Math.max(0, value);
|
|
@@ -114,6 +219,14 @@ function colorToTint(color) {
|
|
|
114
219
|
return normalizedTint;
|
|
115
220
|
}
|
|
116
221
|
|
|
222
|
+
function pruneExpiredTrailPoints(history, now) {
|
|
223
|
+
let expiredCount = 0;
|
|
224
|
+
while (expiredCount < history.length && now - history[expiredCount].at > DRS_TRAIL_TTL) {
|
|
225
|
+
expiredCount += 1;
|
|
226
|
+
}
|
|
227
|
+
if (expiredCount > 0) history.splice(0, expiredCount);
|
|
228
|
+
}
|
|
229
|
+
|
|
117
230
|
function smoothAngle(current, target, amount) {
|
|
118
231
|
if (!Number.isFinite(current)) return target;
|
|
119
232
|
let diff = ((target - current + Math.PI) % (Math.PI * 2)) - Math.PI;
|
|
@@ -127,6 +240,14 @@ function destroyDisplayChildren(container) {
|
|
|
127
240
|
});
|
|
128
241
|
}
|
|
129
242
|
|
|
243
|
+
function clampCameraScale(scale, anchorScale, minimumScale = null) {
|
|
244
|
+
const safeAnchor = Number.isFinite(anchorScale) && anchorScale > 0 ? anchorScale : 1;
|
|
245
|
+
const safeMinimum = Number.isFinite(minimumScale) && minimumScale > 0
|
|
246
|
+
? minimumScale
|
|
247
|
+
: safeAnchor * CAMERA_MIN_ZOOM;
|
|
248
|
+
return clamp(scale, safeMinimum, safeAnchor * CAMERA_MAX_ZOOM);
|
|
249
|
+
}
|
|
250
|
+
|
|
130
251
|
function expertVisualizesRays(expertOptions) {
|
|
131
252
|
const setting = expertOptions?.visualizeSensors;
|
|
132
253
|
if (setting === true) return true;
|
|
@@ -145,6 +266,30 @@ function hasOwnOption(options, key) {
|
|
|
145
266
|
return Object.hasOwn(options ?? {}, key);
|
|
146
267
|
}
|
|
147
268
|
|
|
269
|
+
function maxSimulationStepsForFrame(simulationSpeed, elapsedFrameCount) {
|
|
270
|
+
const speed = Math.max(1, Number(simulationSpeed) || 1);
|
|
271
|
+
const frameCount = Math.max(1, Number(elapsedFrameCount) || 1);
|
|
272
|
+
return clamp(
|
|
273
|
+
Math.ceil(speed * frameCount) + 1,
|
|
274
|
+
BASE_MAX_SIMULATION_STEPS_PER_RENDER,
|
|
275
|
+
HARD_MAX_SIMULATION_STEPS_PER_RENDER,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function domUpdateIntervalForSpeed(simulationSpeed) {
|
|
280
|
+
const speed = Math.max(1, Number(simulationSpeed) || 1);
|
|
281
|
+
if (speed >= 10) return 250;
|
|
282
|
+
if (speed >= 5) return 180;
|
|
283
|
+
return DOM_UPDATE_INTERVAL_MS;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function timingUpdateIntervalForSpeed(simulationSpeed) {
|
|
287
|
+
const speed = Math.max(1, Number(simulationSpeed) || 1);
|
|
288
|
+
if (speed >= 10) return 600;
|
|
289
|
+
if (speed >= 5) return 400;
|
|
290
|
+
return TIMING_UPDATE_INTERVAL_MS;
|
|
291
|
+
}
|
|
292
|
+
|
|
148
293
|
function expertOptionsChanged(nextExpertOptions, currentExpertOptions) {
|
|
149
294
|
return nextExpertOptions !== currentExpertOptions;
|
|
150
295
|
}
|
|
@@ -185,15 +330,24 @@ export class F1SimulatorApp {
|
|
|
185
330
|
this.drsLayer = null;
|
|
186
331
|
this.trailLayer = null;
|
|
187
332
|
this.sensorLayer = null;
|
|
333
|
+
this.pitLaneStatusLayer = null;
|
|
188
334
|
this.carLayer = null;
|
|
189
335
|
this.textures = {};
|
|
190
336
|
this.carSprites = new Map();
|
|
191
337
|
this.carHitAreas = new Map();
|
|
338
|
+
this.serviceCountdownLabels = new Map();
|
|
192
339
|
this.drsTrails = new Map();
|
|
193
340
|
this.trackSeed = options.trackSeed ?? createMountTrackSeed();
|
|
194
341
|
this.selectedId = this.drivers[0]?.id ?? null;
|
|
195
342
|
this.raceDataBannerConfig = options.ui?.raceDataBanners ?? { initial: 'project', enabled: ['project', 'radio'] };
|
|
343
|
+
this.raceDataBannersMuted = false;
|
|
344
|
+
this.penaltyBannerEnabled = Boolean(options.ui?.penaltyBanners);
|
|
345
|
+
this.timingPenaltyBadgesEnabled = Boolean(options.ui?.timingPenaltyBadges);
|
|
196
346
|
this.activeRaceDataId = null;
|
|
347
|
+
this.activePenaltyBanner = null;
|
|
348
|
+
this.lastPenaltyBannerId = null;
|
|
349
|
+
this.activeStewardMessage = null;
|
|
350
|
+
this.lastStewardMessageKey = null;
|
|
197
351
|
this.lastRaceDataInteraction = performance.now();
|
|
198
352
|
this.radioRandomState = (this.trackSeed ^ 0x9e3779b9) >>> 0;
|
|
199
353
|
this.radioState = {
|
|
@@ -207,17 +361,26 @@ export class F1SimulatorApp {
|
|
|
207
361
|
mode: options.initialCameraMode,
|
|
208
362
|
zoom: CAMERA_PRESETS[options.initialCameraMode] ?? CAMERA_PRESETS.leader,
|
|
209
363
|
scale: null,
|
|
364
|
+
initialized: false,
|
|
210
365
|
x: WORLD.width / 2,
|
|
211
366
|
y: WORLD.height / 2,
|
|
367
|
+
free: false,
|
|
368
|
+
freeTarget: null,
|
|
212
369
|
};
|
|
213
370
|
this.overviewMode = 'vehicle';
|
|
371
|
+
this.simulationSpeed = 1;
|
|
214
372
|
this.accumulator = 0;
|
|
215
373
|
this.lastTime = performance.now();
|
|
216
374
|
this.nextGameFrameTime = this.lastTime + TARGET_FRAME_MS;
|
|
217
375
|
this.lastDomUpdateTime = 0;
|
|
218
376
|
this.lastTimingRenderTime = 0;
|
|
219
377
|
this.lastTimingRaceMode = null;
|
|
378
|
+
this.lastTimingPenaltyKey = '';
|
|
379
|
+
this.lastTimingOrderKey = '';
|
|
220
380
|
this.lastTimingMarkup = null;
|
|
381
|
+
this.lastPitLaneStatusRenderKey = null;
|
|
382
|
+
this.pitCameraBoundsCache = null;
|
|
383
|
+
this.trackCameraBoundsCache = null;
|
|
221
384
|
this.lastOverviewRenderKey = null;
|
|
222
385
|
this.lastFinishClassificationMarkup = null;
|
|
223
386
|
this.runtimeViewportVisible = true;
|
|
@@ -259,27 +422,38 @@ export class F1SimulatorApp {
|
|
|
259
422
|
this.drsLayer = new Container();
|
|
260
423
|
this.trailLayer = new Graphics();
|
|
261
424
|
this.sensorLayer = new Graphics();
|
|
425
|
+
this.pitLaneStatusLayer = new Graphics();
|
|
262
426
|
this.carLayer = new Container();
|
|
263
427
|
this.app.stage.addChild(this.worldLayer);
|
|
264
428
|
|
|
265
429
|
await this.loadAssets();
|
|
266
430
|
this.trackAsset = new ProceduralTrackAsset({ textures: this.textures, world: WORLD });
|
|
267
|
-
this.worldLayer.addChild(
|
|
431
|
+
this.worldLayer.addChild(
|
|
432
|
+
this.trackAsset.container,
|
|
433
|
+
this.drsLayer,
|
|
434
|
+
this.trailLayer,
|
|
435
|
+
this.pitLaneStatusLayer,
|
|
436
|
+
this.sensorLayer,
|
|
437
|
+
this.carLayer,
|
|
438
|
+
);
|
|
268
439
|
this.createCars();
|
|
269
440
|
this.bindControls();
|
|
270
441
|
this.renderTrack();
|
|
271
442
|
const snapshot = this.sim.snapshot();
|
|
272
443
|
this.updateDom(snapshot);
|
|
444
|
+
this.lastDomUpdateTime = performance.now();
|
|
273
445
|
if (this.expertMode) {
|
|
274
446
|
this.expert = createBrowserExpertAdapter(this, this.options.expert);
|
|
275
447
|
}
|
|
448
|
+
this.renderInitialFrame(snapshot);
|
|
276
449
|
this.completeComponentLoading();
|
|
277
450
|
this.emitHostCallback('onLoadingChange', { loading: false, phase: 'ready' });
|
|
278
451
|
this.emitHostCallback('onReady', { snapshot });
|
|
279
|
-
this.resizeHandler = () => this.applyCamera(this.sim.snapshot());
|
|
452
|
+
this.resizeHandler = () => this.applyCamera(this.sim.snapshotRender?.() ?? this.sim.snapshot());
|
|
280
453
|
window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
|
|
281
454
|
this.observeLayoutResize();
|
|
282
455
|
if (!this.expertMode) {
|
|
456
|
+
this.resetFrameClock();
|
|
283
457
|
this.tickerCallback = () => this.tick();
|
|
284
458
|
this.app.ticker.add(this.tickerCallback);
|
|
285
459
|
this.observeRuntimeVisibility();
|
|
@@ -322,6 +496,7 @@ export class F1SimulatorApp {
|
|
|
322
496
|
trackSeed: options.trackSeed ?? this.trackSeed,
|
|
323
497
|
drivers: options.drivers ?? this.drivers,
|
|
324
498
|
totalLaps: options.totalLaps,
|
|
499
|
+
rules: options.rules,
|
|
325
500
|
});
|
|
326
501
|
}
|
|
327
502
|
|
|
@@ -363,8 +538,10 @@ export class F1SimulatorApp {
|
|
|
363
538
|
|
|
364
539
|
this.carSprites.forEach((sprite) => sprite.destroy());
|
|
365
540
|
this.carHitAreas.forEach((hit) => hit.destroy());
|
|
541
|
+
this.serviceCountdownLabels.forEach((label) => label.destroy({ children: true }));
|
|
366
542
|
this.carSprites.clear();
|
|
367
543
|
this.carHitAreas.clear();
|
|
544
|
+
this.serviceCountdownLabels.clear();
|
|
368
545
|
this.drsTrails.clear();
|
|
369
546
|
|
|
370
547
|
if (this.safetySprite) {
|
|
@@ -373,11 +550,17 @@ export class F1SimulatorApp {
|
|
|
373
550
|
}
|
|
374
551
|
|
|
375
552
|
this.drivers.forEach((driver) => {
|
|
376
|
-
const sprite =
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
553
|
+
const sprite = TEMP_RENDER_RAW_CAR_GEOMETRY
|
|
554
|
+
? this.createRawCarGeometryGraphic(driver)
|
|
555
|
+
: new Sprite(texture);
|
|
556
|
+
if (!TEMP_RENDER_RAW_CAR_GEOMETRY) {
|
|
557
|
+
sprite.anchor.set(0.5);
|
|
558
|
+
sprite.baseScale = baseScale;
|
|
559
|
+
sprite.scale.set(baseScale);
|
|
560
|
+
} else {
|
|
561
|
+
sprite.baseScale = 1;
|
|
562
|
+
}
|
|
563
|
+
sprite.tint = TEMP_RENDER_RAW_CAR_GEOMETRY ? 0xffffff : colorToTint(driver.color);
|
|
381
564
|
sprite.eventMode = 'static';
|
|
382
565
|
sprite.cursor = 'pointer';
|
|
383
566
|
sprite.on('pointerdown', () => {
|
|
@@ -395,9 +578,85 @@ export class F1SimulatorApp {
|
|
|
395
578
|
});
|
|
396
579
|
this.carHitAreas.set(driver.id, hit);
|
|
397
580
|
this.carLayer.addChild(hit);
|
|
581
|
+
|
|
582
|
+
const serviceLabel = this.createServiceCountdownLabel();
|
|
583
|
+
serviceLabel.visible = false;
|
|
584
|
+
this.serviceCountdownLabels.set(driver.id, serviceLabel);
|
|
585
|
+
this.carLayer.addChild(serviceLabel);
|
|
398
586
|
});
|
|
399
587
|
}
|
|
400
588
|
|
|
589
|
+
createRawCarGeometryGraphic(driver) {
|
|
590
|
+
const graphic = new Graphics();
|
|
591
|
+
const tint = colorToTint(driver.color);
|
|
592
|
+
const wheelLong = VEHICLE_GEOMETRY.wheelLongitudinalOffset;
|
|
593
|
+
const wheelLat = VEHICLE_GEOMETRY.wheelLateralOffset;
|
|
594
|
+
const wheelLength = VEHICLE_GEOMETRY.wheelLength;
|
|
595
|
+
const wheelWidth = VEHICLE_GEOMETRY.wheelWidth;
|
|
596
|
+
|
|
597
|
+
graphic
|
|
598
|
+
.rect(
|
|
599
|
+
-VEHICLE_GEOMETRY.bodyLength / 2,
|
|
600
|
+
-VEHICLE_GEOMETRY.bodyWidth / 2,
|
|
601
|
+
VEHICLE_GEOMETRY.bodyLength,
|
|
602
|
+
VEHICLE_GEOMETRY.bodyWidth,
|
|
603
|
+
)
|
|
604
|
+
.fill({ color: tint, alpha: 0.42 })
|
|
605
|
+
.stroke({ width: 2.2, color: 0xf8fafc, alpha: 0.95 });
|
|
606
|
+
|
|
607
|
+
[
|
|
608
|
+
[wheelLong, -wheelLat],
|
|
609
|
+
[wheelLong, wheelLat],
|
|
610
|
+
[-wheelLong, -wheelLat],
|
|
611
|
+
[-wheelLong, wheelLat],
|
|
612
|
+
].forEach(([x, y]) => {
|
|
613
|
+
graphic
|
|
614
|
+
.rect(x - wheelLength / 2, y - wheelWidth / 2, wheelLength, wheelWidth)
|
|
615
|
+
.fill({ color: 0x111827, alpha: 0.95 })
|
|
616
|
+
.stroke({ width: 1.5, color: tint, alpha: 1 });
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
const noseMarkerLength = metersToSimUnits(0.75);
|
|
620
|
+
graphic
|
|
621
|
+
.moveTo(VEHICLE_GEOMETRY.bodyLength / 2 - noseMarkerLength, 0)
|
|
622
|
+
.lineTo(VEHICLE_GEOMETRY.bodyLength / 2 + noseMarkerLength, 0)
|
|
623
|
+
.stroke({ width: 2, color: 0xf1c65b, alpha: 0.95 });
|
|
624
|
+
return graphic;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
createServiceCountdownLabel() {
|
|
628
|
+
const container = new Container();
|
|
629
|
+
const background = new Graphics();
|
|
630
|
+
const text = new Text({
|
|
631
|
+
text: '0s',
|
|
632
|
+
style: {
|
|
633
|
+
fill: 0xffffff,
|
|
634
|
+
fontFamily: 'Arial, sans-serif',
|
|
635
|
+
fontSize: 16,
|
|
636
|
+
fontWeight: '900',
|
|
637
|
+
align: 'center',
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
text.anchor.set(0.5);
|
|
641
|
+
text.resolution = 2;
|
|
642
|
+
container.addChild(background, text);
|
|
643
|
+
container.labelBackground = background;
|
|
644
|
+
container.labelText = text;
|
|
645
|
+
this.applyServiceCountdownTone(container, 'pit');
|
|
646
|
+
return container;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
applyServiceCountdownTone(label, tone) {
|
|
650
|
+
const style = SERVICE_COUNTDOWN_LABEL_STYLES[tone] ?? SERVICE_COUNTDOWN_LABEL_STYLES.pit;
|
|
651
|
+
if (label.serviceTone === tone) return;
|
|
652
|
+
label.labelBackground?.clear?.();
|
|
653
|
+
label.labelBackground?.roundRect(-28, -14, 56, 28, 4)
|
|
654
|
+
.fill({ color: style.background, alpha: 0.92 })
|
|
655
|
+
.stroke({ width: 2, color: style.stroke, alpha: 0.95 });
|
|
656
|
+
if (label.labelText?.style) label.labelText.style.fill = style.fill;
|
|
657
|
+
label.serviceTone = tone;
|
|
658
|
+
}
|
|
659
|
+
|
|
401
660
|
bindControls() {
|
|
402
661
|
const eventOptions = { signal: this.abortController.signal };
|
|
403
662
|
|
|
@@ -417,27 +676,38 @@ export class F1SimulatorApp {
|
|
|
417
676
|
this.trailLayer?.clear();
|
|
418
677
|
this.lastTimingRenderTime = 0;
|
|
419
678
|
this.lastTimingRaceMode = null;
|
|
679
|
+
this.lastTimingPenaltyKey = '';
|
|
680
|
+
this.lastTimingOrderKey = '';
|
|
420
681
|
this.renderTrack();
|
|
421
682
|
}, eventOptions);
|
|
422
683
|
|
|
423
684
|
this.cameraButtons.forEach((button) => {
|
|
424
685
|
button.addEventListener('click', () => {
|
|
425
|
-
this.
|
|
426
|
-
if (CAMERA_PRESETS[this.camera.mode]) this.camera.zoom = CAMERA_PRESETS[this.camera.mode];
|
|
427
|
-
this.updateCameraControls();
|
|
686
|
+
this.setCameraMode(button.dataset.cameraMode);
|
|
428
687
|
}, eventOptions);
|
|
429
688
|
});
|
|
430
689
|
|
|
431
690
|
this.zoomInButton?.addEventListener('click', () => {
|
|
432
|
-
this.
|
|
433
|
-
this.updateCameraControls();
|
|
691
|
+
this.adjustCameraZoom(1);
|
|
434
692
|
}, eventOptions);
|
|
435
693
|
|
|
436
694
|
this.zoomOutButton?.addEventListener('click', () => {
|
|
437
|
-
this.
|
|
438
|
-
this.updateCameraControls();
|
|
695
|
+
this.adjustCameraZoom(-1);
|
|
439
696
|
}, eventOptions);
|
|
440
697
|
|
|
698
|
+
this.simulationSpeedButtons?.forEach((button) => {
|
|
699
|
+
button.addEventListener('click', () => {
|
|
700
|
+
this.cycleSimulationSpeed();
|
|
701
|
+
}, eventOptions);
|
|
702
|
+
});
|
|
703
|
+
this.updateSimulationSpeedControls();
|
|
704
|
+
|
|
705
|
+
this.bannerMuteButtons?.forEach((button) => {
|
|
706
|
+
button.addEventListener('click', () => {
|
|
707
|
+
this.setRaceDataBannersMuted(!this.raceDataBannersMuted);
|
|
708
|
+
}, eventOptions);
|
|
709
|
+
});
|
|
710
|
+
|
|
441
711
|
this.overviewModeButtons.forEach((button) => {
|
|
442
712
|
button.addEventListener('click', () => {
|
|
443
713
|
this.overviewMode = button.dataset.overviewMode === 'driver' ? 'driver' : 'vehicle';
|
|
@@ -451,7 +721,7 @@ export class F1SimulatorApp {
|
|
|
451
721
|
this.timingGapMode = button.dataset.timingGapMode === 'leader' ? 'leader' : 'interval';
|
|
452
722
|
this.syncTimingGapModeControls();
|
|
453
723
|
const snapshot = this.sim?.snapshot?.();
|
|
454
|
-
if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode);
|
|
724
|
+
if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
|
|
455
725
|
}, eventOptions);
|
|
456
726
|
});
|
|
457
727
|
|
|
@@ -467,9 +737,61 @@ export class F1SimulatorApp {
|
|
|
467
737
|
this.options.onDriverOpen?.(driver);
|
|
468
738
|
}, eventOptions);
|
|
469
739
|
|
|
740
|
+
this.readouts.raceDataDismiss?.addEventListener('click', (event) => {
|
|
741
|
+
event.preventDefault();
|
|
742
|
+
event.stopPropagation();
|
|
743
|
+
this.dismissRaceDataPanel(performance.now());
|
|
744
|
+
}, eventOptions);
|
|
745
|
+
|
|
470
746
|
this.readouts.telemetryDrawerToggle?.addEventListener('click', () => {
|
|
471
747
|
this.setTelemetryDrawerOpen(!this.telemetryDrawerOpen);
|
|
472
748
|
}, eventOptions);
|
|
749
|
+
|
|
750
|
+
this.bindCameraWheelControls(eventOptions);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
setCameraMode(mode) {
|
|
754
|
+
const snapshot = this.sim?.snapshot?.();
|
|
755
|
+
if (!this.isCameraModeAvailable(mode, snapshot)) return false;
|
|
756
|
+
this.camera.mode = mode;
|
|
757
|
+
this.camera.free = false;
|
|
758
|
+
this.camera.freeTarget = null;
|
|
759
|
+
if (CAMERA_PRESETS[this.camera.mode]) this.camera.zoom = CAMERA_PRESETS[this.camera.mode];
|
|
760
|
+
this.updateCameraControls(snapshot);
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
adjustCameraZoom(direction) {
|
|
765
|
+
const steps = Number.isFinite(Number(direction)) ? Number(direction) : 0;
|
|
766
|
+
this.camera.zoom = clamp(this.camera.zoom + steps * CAMERA_ZOOM_STEP, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
|
|
767
|
+
this.updateCameraControls();
|
|
768
|
+
return this.camera.zoom;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
cycleSimulationSpeed() {
|
|
772
|
+
const currentIndex = SIMULATION_SPEED_STEPS.indexOf(this.simulationSpeed);
|
|
773
|
+
const nextIndex = currentIndex >= 0
|
|
774
|
+
? (currentIndex + 1) % SIMULATION_SPEED_STEPS.length
|
|
775
|
+
: 0;
|
|
776
|
+
this.simulationSpeed = SIMULATION_SPEED_STEPS[nextIndex];
|
|
777
|
+
this.updateSimulationSpeedControls();
|
|
778
|
+
return this.simulationSpeed;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
updateSimulationSpeedControls() {
|
|
782
|
+
this.simulationSpeedButtons?.forEach((button) => {
|
|
783
|
+
const label = `${this.simulationSpeed}x`;
|
|
784
|
+
button.textContent = label;
|
|
785
|
+
button.setAttribute?.('aria-label', `Simulation speed ${label}`);
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
bindCameraWheelControls(eventOptions) {
|
|
790
|
+
if (!this.canvasHost?.addEventListener) return;
|
|
791
|
+
this.canvasHost.addEventListener('wheel', (event) => {
|
|
792
|
+
event.preventDefault?.();
|
|
793
|
+
this.adjustCameraZoom(event.deltaY > 0 ? -1 : 1);
|
|
794
|
+
}, { ...eventOptions, passive: false });
|
|
473
795
|
}
|
|
474
796
|
|
|
475
797
|
setTelemetryDrawerOpen(open) {
|
|
@@ -487,12 +809,73 @@ export class F1SimulatorApp {
|
|
|
487
809
|
this.readouts.telemetryDrawerToggle.textContent = this.telemetryDrawerOpen ? 'Close telemetry' : 'Telemetry';
|
|
488
810
|
}
|
|
489
811
|
this.invalidateCameraSafeArea();
|
|
812
|
+
this.syncRendererToCurrentLayout({ render: true });
|
|
813
|
+
this.scheduleLayoutResizeSync(380);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
setRaceDataBannersMuted(muted, now = performance.now()) {
|
|
817
|
+
this.raceDataBannersMuted = Boolean(muted);
|
|
818
|
+
if (this.raceDataBannersMuted) {
|
|
819
|
+
this.activeRaceDataId = null;
|
|
820
|
+
this.radioState.visible = false;
|
|
821
|
+
this.radioState.nextChangeAt = Number.POSITIVE_INFINITY;
|
|
822
|
+
this.hideRaceDataPanel();
|
|
823
|
+
} else {
|
|
824
|
+
this.resetRaceDataBannerState(now);
|
|
825
|
+
}
|
|
826
|
+
this.updateBannerMuteControls();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
resizeRendererToCanvasHost() {
|
|
830
|
+
if (!this.app?.renderer?.resize || !this.canvasHost) return;
|
|
831
|
+
const width = Math.max(1, Math.round(this.canvasHost.clientWidth || 0));
|
|
832
|
+
const height = Math.max(1, Math.round(this.canvasHost.clientHeight || 0));
|
|
833
|
+
this.app.renderer.resize(width, height);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
renderInitialFrame(snapshot) {
|
|
837
|
+
const renderSnapshot = createRenderSnapshot(snapshot, 0);
|
|
838
|
+
this.resizeRendererToCanvasHost();
|
|
839
|
+
this.applyCamera(renderSnapshot, { immediate: true });
|
|
840
|
+
this.renderDrsTrails(renderSnapshot);
|
|
841
|
+
this.renderPitLaneStatus(renderSnapshot);
|
|
842
|
+
this.renderCars(renderSnapshot);
|
|
843
|
+
this.app?.render?.();
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
syncRendererToCurrentLayout({ render = false } = {}) {
|
|
847
|
+
this.resizeRendererToCanvasHost();
|
|
848
|
+
const snapshot = this.sim?.snapshotRender?.() ?? this.sim?.snapshot?.();
|
|
849
|
+
if (!snapshot) return;
|
|
850
|
+
const renderSnapshot = createRenderSnapshot(snapshot, clamp(this.accumulator / FIXED_STEP, 0, 1));
|
|
851
|
+
this.applyCamera(renderSnapshot);
|
|
852
|
+
if (render) {
|
|
853
|
+
this.renderDrsTrails(renderSnapshot);
|
|
854
|
+
this.renderPitLaneStatus(renderSnapshot);
|
|
855
|
+
this.renderCars(renderSnapshot);
|
|
856
|
+
this.app?.render?.();
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
scheduleLayoutResizeSync(durationMs = 360) {
|
|
861
|
+
if (typeof requestAnimationFrame !== 'function') return;
|
|
862
|
+
const startedAt = performance.now();
|
|
863
|
+
const run = (timestamp = performance.now()) => {
|
|
864
|
+
this.syncRendererToCurrentLayout({ render: true });
|
|
865
|
+
if (timestamp - startedAt < durationMs) {
|
|
866
|
+
this.layoutResizeFrame = requestAnimationFrame(run);
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
this.layoutResizeFrame = requestAnimationFrame(run);
|
|
490
870
|
}
|
|
491
871
|
|
|
492
872
|
observeLayoutResize() {
|
|
493
873
|
if (typeof ResizeObserver !== 'function') return;
|
|
494
874
|
this.layoutResizeObserver?.disconnect?.();
|
|
495
|
-
this.layoutResizeObserver = new ResizeObserver(() =>
|
|
875
|
+
this.layoutResizeObserver = new ResizeObserver(() => {
|
|
876
|
+
this.invalidateCameraSafeArea();
|
|
877
|
+
this.syncRendererToCurrentLayout({ render: true });
|
|
878
|
+
});
|
|
496
879
|
if (this.canvasHost) this.layoutResizeObserver.observe(this.canvasHost);
|
|
497
880
|
if (this.readouts.timingTower) this.layoutResizeObserver.observe(this.readouts.timingTower);
|
|
498
881
|
}
|
|
@@ -551,6 +934,7 @@ export class F1SimulatorApp {
|
|
|
551
934
|
this.accumulator = 0;
|
|
552
935
|
this.lastTime = now;
|
|
553
936
|
this.nextGameFrameTime = now + TARGET_FRAME_MS;
|
|
937
|
+
this.fps.current = 0;
|
|
554
938
|
this.fps.frames = 0;
|
|
555
939
|
this.fps.lastSample = now;
|
|
556
940
|
}
|
|
@@ -572,13 +956,16 @@ export class F1SimulatorApp {
|
|
|
572
956
|
}
|
|
573
957
|
this.lastTime = now;
|
|
574
958
|
this.sampleFps(now);
|
|
575
|
-
this.accumulator += frameSeconds *
|
|
959
|
+
this.accumulator += frameSeconds * this.simulationSpeed;
|
|
576
960
|
|
|
577
961
|
let simulationSteps = 0;
|
|
578
|
-
|
|
962
|
+
const stepEvents = [];
|
|
963
|
+
const maxSimulationSteps = maxSimulationStepsForFrame(this.simulationSpeed, elapsedFrameCount);
|
|
964
|
+
while (this.accumulator >= FIXED_STEP && simulationSteps < maxSimulationSteps) {
|
|
579
965
|
this.sim.step(FIXED_STEP);
|
|
580
|
-
|
|
581
|
-
|
|
966
|
+
if (Array.isArray(this.sim.events) && this.sim.events.length > 0) {
|
|
967
|
+
stepEvents.push(...this.sim.events);
|
|
968
|
+
}
|
|
582
969
|
this.accumulator -= FIXED_STEP;
|
|
583
970
|
simulationSteps += 1;
|
|
584
971
|
}
|
|
@@ -588,13 +975,17 @@ export class F1SimulatorApp {
|
|
|
588
975
|
this.nextGameFrameTime = now + TARGET_FRAME_MS;
|
|
589
976
|
}
|
|
590
977
|
|
|
591
|
-
const
|
|
592
|
-
const
|
|
978
|
+
const shouldUpdateDom = now - this.lastDomUpdateTime >= domUpdateIntervalForSpeed(this.simulationSpeed);
|
|
979
|
+
const fullSnapshot = stepEvents.length > 0 || shouldUpdateDom ? this.sim.snapshot() : null;
|
|
980
|
+
if (stepEvents.length > 0) this.emitRaceEvents(stepEvents, fullSnapshot);
|
|
981
|
+
const renderSource = fullSnapshot ?? this.sim.snapshotRender?.() ?? this.sim.snapshot();
|
|
982
|
+
const renderSnapshot = createRenderSnapshot(renderSource, clamp(this.accumulator / FIXED_STEP, 0, 1));
|
|
593
983
|
this.applyCamera(renderSnapshot);
|
|
594
984
|
this.renderDrsTrails(renderSnapshot);
|
|
985
|
+
this.renderPitLaneStatus(renderSnapshot);
|
|
595
986
|
this.renderCars(renderSnapshot);
|
|
596
|
-
if (
|
|
597
|
-
this.updateDom(
|
|
987
|
+
if (shouldUpdateDom) {
|
|
988
|
+
this.updateDom(fullSnapshot, { emitLifecycle: false });
|
|
598
989
|
this.lastDomUpdateTime = now;
|
|
599
990
|
}
|
|
600
991
|
}
|
|
@@ -605,6 +996,7 @@ export class F1SimulatorApp {
|
|
|
605
996
|
this.applyCamera(renderSnapshot);
|
|
606
997
|
this.renderDrsTrails(renderSnapshot);
|
|
607
998
|
this.renderExpertSensorRays(renderSnapshot, observation);
|
|
999
|
+
this.renderPitLaneStatus(renderSnapshot);
|
|
608
1000
|
this.renderCars(renderSnapshot);
|
|
609
1001
|
this.updateDom(snapshot, { emitLifecycle: false });
|
|
610
1002
|
this.lastDomUpdateTime = performance.now();
|
|
@@ -613,6 +1005,10 @@ export class F1SimulatorApp {
|
|
|
613
1005
|
renderTrack() {
|
|
614
1006
|
destroyDisplayChildren(this.drsLayer);
|
|
615
1007
|
this.sensorLayer?.clear();
|
|
1008
|
+
this.pitLaneStatusLayer?.clear();
|
|
1009
|
+
this.lastPitLaneStatusRenderKey = null;
|
|
1010
|
+
this.pitCameraBoundsCache = null;
|
|
1011
|
+
this.trackCameraBoundsCache = null;
|
|
616
1012
|
const snapshot = this.sim.snapshot();
|
|
617
1013
|
const track = snapshot.track;
|
|
618
1014
|
|
|
@@ -632,20 +1028,70 @@ export class F1SimulatorApp {
|
|
|
632
1028
|
});
|
|
633
1029
|
}
|
|
634
1030
|
|
|
1031
|
+
renderPitLaneStatus(snapshot) {
|
|
1032
|
+
if (!this.pitLaneStatusLayer) return;
|
|
1033
|
+
const pitLane = snapshot.track?.pitLane;
|
|
1034
|
+
if (!pitLane?.enabled || !pitLane.mainLane?.start) {
|
|
1035
|
+
if (this.lastPitLaneStatusRenderKey !== 'none') {
|
|
1036
|
+
this.pitLaneStatusLayer.clear();
|
|
1037
|
+
this.lastPitLaneStatusRenderKey = 'none';
|
|
1038
|
+
}
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
const status = snapshot.pitLaneStatus ?? snapshot.raceControl?.pitLaneStatus;
|
|
1042
|
+
const color = colorToTint(status?.light ?? (status?.open ? '#22c55e' : '#ef4444'));
|
|
1043
|
+
const heading = pitLane.mainLane.heading ?? 0;
|
|
1044
|
+
const normal = pitLane.serviceNormal ?? { x: -Math.sin(heading), y: Math.cos(heading) };
|
|
1045
|
+
const sign = pitLane.workingLane?.offset != null && pitLane.workingLane.offset < 0 ? 1 : -1;
|
|
1046
|
+
const base = pitLane.mainLane.start;
|
|
1047
|
+
const x = base.x + normal.x * sign * (pitLane.width * 0.72);
|
|
1048
|
+
const y = base.y + normal.y * sign * (pitLane.width * 0.72);
|
|
1049
|
+
const renderKey = [
|
|
1050
|
+
color,
|
|
1051
|
+
x.toFixed(3),
|
|
1052
|
+
y.toFixed(3),
|
|
1053
|
+
pitLane.width,
|
|
1054
|
+
status?.open ? 1 : 0,
|
|
1055
|
+
status?.reason ?? '',
|
|
1056
|
+
].join(':');
|
|
1057
|
+
if (renderKey === this.lastPitLaneStatusRenderKey) return;
|
|
1058
|
+
this.lastPitLaneStatusRenderKey = renderKey;
|
|
1059
|
+
|
|
1060
|
+
this.pitLaneStatusLayer.clear();
|
|
1061
|
+
this.pitLaneStatusLayer
|
|
1062
|
+
.circle(x, y, 17)
|
|
1063
|
+
.fill({ color: 0x0b0f14, alpha: 0.92 })
|
|
1064
|
+
.circle(x, y, 11)
|
|
1065
|
+
.fill({ color, alpha: 0.94 })
|
|
1066
|
+
.circle(x, y, 18)
|
|
1067
|
+
.stroke({ width: 3, color: 0xf8fafc, alpha: 0.82 });
|
|
1068
|
+
}
|
|
1069
|
+
|
|
635
1070
|
renderCars(snapshot) {
|
|
636
1071
|
snapshot.cars.forEach((car) => {
|
|
637
1072
|
const sprite = this.carSprites.get(car.id);
|
|
638
1073
|
const hit = this.carHitAreas.get(car.id);
|
|
639
1074
|
if (!sprite || !hit) return;
|
|
640
|
-
sprite.x = car.x;
|
|
641
|
-
sprite.y = car.y;
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
1075
|
+
if (sprite.x !== car.x) sprite.x = car.x;
|
|
1076
|
+
if (sprite.y !== car.y) sprite.y = car.y;
|
|
1077
|
+
if (TEMP_RENDER_RAW_CAR_GEOMETRY) {
|
|
1078
|
+
sprite.currentRotation = car.heading;
|
|
1079
|
+
if (sprite.rotation !== car.heading) sprite.rotation = car.heading;
|
|
1080
|
+
} else {
|
|
1081
|
+
sprite.currentRotation = smoothAngle(sprite.currentRotation, car.heading, 0.24);
|
|
1082
|
+
if (sprite.rotation !== sprite.currentRotation) sprite.rotation = sprite.currentRotation;
|
|
1083
|
+
}
|
|
1084
|
+
const alpha = snapshot.raceControl.mode === 'safety-car' ? 0.82 : 1;
|
|
1085
|
+
if (sprite.alpha !== alpha) sprite.alpha = alpha;
|
|
1086
|
+
if (sprite.lastRenderedScale !== sprite.baseScale) {
|
|
1087
|
+
sprite.scale.set(sprite.baseScale);
|
|
1088
|
+
sprite.lastRenderedScale = sprite.baseScale;
|
|
1089
|
+
}
|
|
1090
|
+
const tint = TEMP_RENDER_RAW_CAR_GEOMETRY ? 0xffffff : colorToTint(car.color);
|
|
1091
|
+
if (sprite.tint !== tint) sprite.tint = tint;
|
|
1092
|
+
if (hit.x !== car.x) hit.x = car.x;
|
|
1093
|
+
if (hit.y !== car.y) hit.y = car.y;
|
|
1094
|
+
this.renderServiceCountdownLabel(car);
|
|
649
1095
|
});
|
|
650
1096
|
|
|
651
1097
|
if (!this.safetySprite && snapshot.safetyCar.deployed) {
|
|
@@ -661,10 +1107,35 @@ export class F1SimulatorApp {
|
|
|
661
1107
|
}
|
|
662
1108
|
|
|
663
1109
|
if (this.safetySprite) {
|
|
664
|
-
this.safetySprite.visible
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
this.safetySprite.
|
|
1110
|
+
if (this.safetySprite.visible !== snapshot.safetyCar.deployed) {
|
|
1111
|
+
this.safetySprite.visible = snapshot.safetyCar.deployed;
|
|
1112
|
+
}
|
|
1113
|
+
if (this.safetySprite.x !== snapshot.safetyCar.x) this.safetySprite.x = snapshot.safetyCar.x;
|
|
1114
|
+
if (this.safetySprite.y !== snapshot.safetyCar.y) this.safetySprite.y = snapshot.safetyCar.y;
|
|
1115
|
+
if (this.safetySprite.rotation !== snapshot.safetyCar.heading) this.safetySprite.rotation = snapshot.safetyCar.heading;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
renderServiceCountdownLabel(car) {
|
|
1120
|
+
const label = this.serviceCountdownLabels.get(car.id);
|
|
1121
|
+
if (!label) return;
|
|
1122
|
+
const penaltyRemaining = Number(car.pitStop?.penaltyServiceRemainingSeconds);
|
|
1123
|
+
const serviceRemaining = Number(car.pitStop?.serviceRemainingSeconds);
|
|
1124
|
+
const isPenalty = car.pitStop?.phase === 'penalty' && Number.isFinite(penaltyRemaining) && penaltyRemaining > 0;
|
|
1125
|
+
const isPitService = car.pitStop?.phase === 'service' && Number.isFinite(serviceRemaining) && serviceRemaining > 0;
|
|
1126
|
+
const visible = isPenalty || isPitService;
|
|
1127
|
+
label.visible = visible;
|
|
1128
|
+
if (!visible) return;
|
|
1129
|
+
const tone = isPenalty ? 'penalty' : 'pit';
|
|
1130
|
+
const remaining = isPenalty ? penaltyRemaining : serviceRemaining;
|
|
1131
|
+
if (label.x !== car.x) label.x = car.x;
|
|
1132
|
+
const labelY = car.y - 48;
|
|
1133
|
+
if (label.y !== labelY) label.y = labelY;
|
|
1134
|
+
if (label.rotation !== 0) label.rotation = 0;
|
|
1135
|
+
this.applyServiceCountdownTone(label, tone);
|
|
1136
|
+
if (label.labelText) {
|
|
1137
|
+
const nextText = `${tone === 'penalty' ? '+' : ''}${Math.ceil(remaining)}s`;
|
|
1138
|
+
if (label.labelText.text !== nextText) label.labelText.text = nextText;
|
|
668
1139
|
}
|
|
669
1140
|
}
|
|
670
1141
|
|
|
@@ -726,47 +1197,59 @@ export class F1SimulatorApp {
|
|
|
726
1197
|
if (!this.trailLayer) return;
|
|
727
1198
|
|
|
728
1199
|
snapshot.cars.forEach((car) => {
|
|
729
|
-
const history = this.drsTrails.get(car.id)
|
|
1200
|
+
const history = this.drsTrails.get(car.id);
|
|
1201
|
+
if (!history && !car.drsActive) return;
|
|
1202
|
+
const activeHistory = history ?? [];
|
|
730
1203
|
const rear = {
|
|
731
1204
|
x: car.x - Math.cos(car.heading) * CAR_WORLD_LENGTH * 0.46,
|
|
732
1205
|
y: car.y - Math.sin(car.heading) * CAR_WORLD_LENGTH * 0.46,
|
|
733
1206
|
at: snapshot.time,
|
|
734
1207
|
};
|
|
735
|
-
const last =
|
|
1208
|
+
const last = activeHistory[activeHistory.length - 1];
|
|
736
1209
|
|
|
737
1210
|
if (
|
|
738
1211
|
car.drsActive &&
|
|
739
1212
|
(!last || Math.hypot(rear.x - last.x, rear.y - last.y) >= DRS_TRAIL_MIN_DISTANCE)
|
|
740
1213
|
) {
|
|
741
|
-
|
|
1214
|
+
activeHistory.push(rear);
|
|
742
1215
|
}
|
|
743
1216
|
|
|
744
|
-
|
|
745
|
-
if (
|
|
1217
|
+
pruneExpiredTrailPoints(activeHistory, snapshot.time);
|
|
1218
|
+
if (activeHistory.length) this.drsTrails.set(car.id, activeHistory);
|
|
746
1219
|
else this.drsTrails.delete(car.id);
|
|
747
1220
|
});
|
|
748
1221
|
|
|
749
1222
|
this.trailLayer.clear();
|
|
750
1223
|
this.drsTrails.forEach((history) => {
|
|
1224
|
+
if (history.length < 2) return;
|
|
1225
|
+
const newest = history[history.length - 1];
|
|
1226
|
+
const newestLife = clamp(1 - (snapshot.time - newest.at) / DRS_TRAIL_TTL, 0, 1);
|
|
1227
|
+
this.trailLayer.moveTo(history[0].x, history[0].y);
|
|
751
1228
|
for (let index = 1; index < history.length; index += 1) {
|
|
752
|
-
|
|
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
|
-
});
|
|
1229
|
+
this.trailLayer.lineTo(history[index].x, history[index].y);
|
|
765
1230
|
}
|
|
1231
|
+
this.trailLayer.stroke({
|
|
1232
|
+
width: 10,
|
|
1233
|
+
color: 0x3be8ff,
|
|
1234
|
+
alpha: 0.28 + newestLife * 0.34,
|
|
1235
|
+
cap: 'round',
|
|
1236
|
+
join: 'round',
|
|
1237
|
+
});
|
|
1238
|
+
this.trailLayer.moveTo(history[0].x, history[0].y);
|
|
1239
|
+
for (let index = 1; index < history.length; index += 1) {
|
|
1240
|
+
this.trailLayer.lineTo(history[index].x, history[index].y);
|
|
1241
|
+
}
|
|
1242
|
+
this.trailLayer.stroke({
|
|
1243
|
+
width: 3.2,
|
|
1244
|
+
color: 0xd8fbff,
|
|
1245
|
+
alpha: 0.18 + newestLife * 0.28,
|
|
1246
|
+
cap: 'round',
|
|
1247
|
+
join: 'round',
|
|
1248
|
+
});
|
|
766
1249
|
});
|
|
767
1250
|
}
|
|
768
1251
|
|
|
769
|
-
applyCamera(snapshot) {
|
|
1252
|
+
applyCamera(snapshot, { immediate = false } = {}) {
|
|
770
1253
|
if (!this.worldLayer) return;
|
|
771
1254
|
|
|
772
1255
|
const width = this.canvasHost.clientWidth || 900;
|
|
@@ -776,11 +1259,17 @@ export class F1SimulatorApp {
|
|
|
776
1259
|
const frame = this.getCameraFrame(snapshot, width, height, baseScale, safeArea);
|
|
777
1260
|
const scale = frame.scale;
|
|
778
1261
|
const target = frame.target;
|
|
779
|
-
|
|
780
|
-
this.camera.
|
|
781
|
-
|
|
1262
|
+
const snapCamera = immediate || !this.camera.initialized;
|
|
1263
|
+
this.camera.x = snapCamera
|
|
1264
|
+
? target.x
|
|
1265
|
+
: this.camera.x + (target.x - this.camera.x) * CAMERA_TARGET_LERP;
|
|
1266
|
+
this.camera.y = snapCamera
|
|
1267
|
+
? target.y
|
|
1268
|
+
: this.camera.y + (target.y - this.camera.y) * CAMERA_TARGET_LERP;
|
|
1269
|
+
const activeScale = snapCamera || this.camera.scale === null
|
|
782
1270
|
? scale
|
|
783
|
-
: this.camera.scale + (scale - this.camera.scale) *
|
|
1271
|
+
: this.camera.scale + (scale - this.camera.scale) * CAMERA_SCALE_LERP;
|
|
1272
|
+
this.camera.initialized = true;
|
|
784
1273
|
this.camera.scale = activeScale;
|
|
785
1274
|
this.worldLayer.scale.set(activeScale);
|
|
786
1275
|
this.worldLayer.position.set(
|
|
@@ -829,6 +1318,28 @@ export class F1SimulatorApp {
|
|
|
829
1318
|
|
|
830
1319
|
getCameraFrame(snapshot, width, height, baseScale, safeArea = { left: 0, width }) {
|
|
831
1320
|
const screenCenterX = safeArea.left + safeArea.width / 2;
|
|
1321
|
+
const trackBounds = this.getTrackCameraBounds(snapshot.track);
|
|
1322
|
+
const trackFitScale = this.getCameraBoundsFitScale(trackBounds, height, safeArea);
|
|
1323
|
+
|
|
1324
|
+
if (this.camera.mode === 'overview') {
|
|
1325
|
+
const target = trackBounds
|
|
1326
|
+
? {
|
|
1327
|
+
x: (trackBounds.minX + trackBounds.maxX) / 2,
|
|
1328
|
+
y: (trackBounds.minY + trackBounds.maxY) / 2,
|
|
1329
|
+
}
|
|
1330
|
+
: { x: WORLD.width / 2, y: WORLD.height / 2 };
|
|
1331
|
+
|
|
1332
|
+
const scale = trackFitScale
|
|
1333
|
+
? clampCameraScale(trackFitScale * this.camera.zoom, trackFitScale, trackFitScale)
|
|
1334
|
+
: clampCameraScale(baseScale * this.camera.zoom, baseScale);
|
|
1335
|
+
|
|
1336
|
+
return this.applyFreeCameraFrame({
|
|
1337
|
+
target,
|
|
1338
|
+
scale,
|
|
1339
|
+
screenX: screenCenterX,
|
|
1340
|
+
screenY: height / 2,
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
832
1343
|
|
|
833
1344
|
if (this.camera.mode === 'show-all') {
|
|
834
1345
|
const bounds = snapshot.cars.reduce((box, car) => ({
|
|
@@ -849,30 +1360,44 @@ export class F1SimulatorApp {
|
|
|
849
1360
|
const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX + SHOW_ALL_PADDING);
|
|
850
1361
|
const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY + SHOW_ALL_PADDING);
|
|
851
1362
|
const safeHeight = Math.max(height * 0.48, height - SHOW_ALL_TOP_RESERVED - SHOW_ALL_BOTTOM_RESERVED);
|
|
852
|
-
const
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
baseScale * SHOW_ALL_MAX_ZOOM,
|
|
856
|
-
);
|
|
857
|
-
return {
|
|
1363
|
+
const fitScale = Math.min(safeArea.width / fitWidth, safeHeight / fitHeight);
|
|
1364
|
+
const scale = clampCameraScale(fitScale * this.camera.zoom, fitScale, trackFitScale);
|
|
1365
|
+
return this.applyFreeCameraFrame({
|
|
858
1366
|
target,
|
|
859
1367
|
scale,
|
|
860
1368
|
screenX: screenCenterX,
|
|
861
|
-
screenY:
|
|
862
|
-
};
|
|
1369
|
+
screenY: height / 2,
|
|
1370
|
+
});
|
|
863
1371
|
}
|
|
864
1372
|
|
|
865
|
-
|
|
1373
|
+
if (this.camera.mode === 'pit' && this.hasPitCamera(snapshot)) {
|
|
1374
|
+
return this.applyFreeCameraFrame(
|
|
1375
|
+
this.getPitCameraFrame(snapshot.track.pitLane, height, baseScale, safeArea, screenCenterX, trackFitScale),
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
return this.applyFreeCameraFrame({
|
|
866
1380
|
target: this.getCameraTarget(snapshot),
|
|
867
|
-
scale: baseScale * this.camera.zoom,
|
|
1381
|
+
scale: clampCameraScale(baseScale * this.camera.zoom, baseScale, trackFitScale),
|
|
868
1382
|
screenX: screenCenterX,
|
|
869
1383
|
screenY: height / 2,
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
applyFreeCameraFrame(frame) {
|
|
1388
|
+
if (!this.camera.free || !this.camera.freeTarget) return frame;
|
|
1389
|
+
return {
|
|
1390
|
+
...frame,
|
|
1391
|
+
target: this.camera.freeTarget,
|
|
870
1392
|
};
|
|
871
1393
|
}
|
|
872
1394
|
|
|
873
1395
|
getCameraTarget(snapshot) {
|
|
874
|
-
if (this.camera.
|
|
875
|
-
|
|
1396
|
+
if (this.camera.free && this.camera.freeTarget) return this.camera.freeTarget;
|
|
1397
|
+
if (this.camera.mode === 'overview') return this.getTrackCameraTarget(snapshot.track);
|
|
1398
|
+
|
|
1399
|
+
if (this.camera.mode === 'pit' && this.hasPitCamera(snapshot)) {
|
|
1400
|
+
return this.getPitCameraTarget(snapshot.track.pitLane);
|
|
876
1401
|
}
|
|
877
1402
|
|
|
878
1403
|
if (this.camera.mode === 'selected') {
|
|
@@ -884,6 +1409,145 @@ export class F1SimulatorApp {
|
|
|
884
1409
|
return leader ? { x: leader.x, y: leader.y } : { x: WORLD.width / 2, y: WORLD.height / 2 };
|
|
885
1410
|
}
|
|
886
1411
|
|
|
1412
|
+
getTrackCameraBounds(track) {
|
|
1413
|
+
if (this.trackCameraBoundsCache && this.trackCameraBoundsCache.track === track) {
|
|
1414
|
+
return this.trackCameraBoundsCache.bounds;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
const bounds = {
|
|
1418
|
+
minX: Infinity,
|
|
1419
|
+
minY: Infinity,
|
|
1420
|
+
maxX: -Infinity,
|
|
1421
|
+
maxY: -Infinity,
|
|
1422
|
+
};
|
|
1423
|
+
const includePoint = (point) => {
|
|
1424
|
+
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
|
|
1425
|
+
bounds.minX = Math.min(bounds.minX, point.x);
|
|
1426
|
+
bounds.minY = Math.min(bounds.minY, point.y);
|
|
1427
|
+
bounds.maxX = Math.max(bounds.maxX, point.x);
|
|
1428
|
+
bounds.maxY = Math.max(bounds.maxY, point.y);
|
|
1429
|
+
};
|
|
1430
|
+
const includeBounds = (box) => {
|
|
1431
|
+
if (!box) return;
|
|
1432
|
+
includePoint({ x: box.minX, y: box.minY });
|
|
1433
|
+
includePoint({ x: box.maxX, y: box.maxY });
|
|
1434
|
+
};
|
|
1435
|
+
|
|
1436
|
+
(track?.samples ?? []).forEach(includePoint);
|
|
1437
|
+
includeBounds(track?.pitLane?.bounds);
|
|
1438
|
+
|
|
1439
|
+
const trackEdgePadding = Math.max(
|
|
1440
|
+
TRACK_CAMERA_PADDING,
|
|
1441
|
+
((track?.width ?? 0) / 2) + (track?.kerbWidth ?? 0) + (track?.runoffWidth ?? 0),
|
|
1442
|
+
);
|
|
1443
|
+
const result = Number.isFinite(bounds.minX)
|
|
1444
|
+
? {
|
|
1445
|
+
minX: bounds.minX - trackEdgePadding,
|
|
1446
|
+
minY: bounds.minY - trackEdgePadding,
|
|
1447
|
+
maxX: bounds.maxX + trackEdgePadding,
|
|
1448
|
+
maxY: bounds.maxY + trackEdgePadding,
|
|
1449
|
+
}
|
|
1450
|
+
: null;
|
|
1451
|
+
|
|
1452
|
+
this.trackCameraBoundsCache = { track, bounds: result };
|
|
1453
|
+
return result;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
getTrackCameraTarget(track) {
|
|
1457
|
+
const bounds = this.getTrackCameraBounds(track);
|
|
1458
|
+
if (!bounds) return { x: WORLD.width / 2, y: WORLD.height / 2 };
|
|
1459
|
+
|
|
1460
|
+
return {
|
|
1461
|
+
x: (bounds.minX + bounds.maxX) / 2,
|
|
1462
|
+
y: (bounds.minY + bounds.maxY) / 2,
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
getCameraBoundsFitScale(bounds, height, safeArea) {
|
|
1467
|
+
if (!bounds) return null;
|
|
1468
|
+
const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX);
|
|
1469
|
+
const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY);
|
|
1470
|
+
const scale = Math.min(safeArea.width / fitWidth, height / fitHeight);
|
|
1471
|
+
return Number.isFinite(scale) && scale > 0 ? scale : null;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
getPitCameraBounds(pitLane) {
|
|
1475
|
+
if (this.pitCameraBoundsCache?.pitLane === pitLane) return this.pitCameraBoundsCache.bounds;
|
|
1476
|
+
|
|
1477
|
+
const bounds = {
|
|
1478
|
+
minX: Infinity,
|
|
1479
|
+
minY: Infinity,
|
|
1480
|
+
maxX: -Infinity,
|
|
1481
|
+
maxY: -Infinity,
|
|
1482
|
+
};
|
|
1483
|
+
const includePoint = (point) => {
|
|
1484
|
+
if (!point) return;
|
|
1485
|
+
bounds.minX = Math.min(bounds.minX, point.x);
|
|
1486
|
+
bounds.minY = Math.min(bounds.minY, point.y);
|
|
1487
|
+
bounds.maxX = Math.max(bounds.maxX, point.x);
|
|
1488
|
+
bounds.maxY = Math.max(bounds.maxY, point.y);
|
|
1489
|
+
};
|
|
1490
|
+
const includePoints = (points = []) => {
|
|
1491
|
+
points.forEach(includePoint);
|
|
1492
|
+
};
|
|
1493
|
+
|
|
1494
|
+
includePoint(pitLane.entry?.lanePoint);
|
|
1495
|
+
includePoints(pitLane.mainLane?.points);
|
|
1496
|
+
includePoints(pitLane.workingLane?.points);
|
|
1497
|
+
includePoint(pitLane.exit?.lanePoint);
|
|
1498
|
+
(pitLane.boxes ?? []).forEach((box) => includePoints(box.corners));
|
|
1499
|
+
(pitLane.serviceAreas ?? []).forEach((area) => {
|
|
1500
|
+
includePoints(area.corners);
|
|
1501
|
+
includePoints(area.queueCorners);
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
const result = Number.isFinite(bounds.minX) ? bounds : null;
|
|
1505
|
+
this.pitCameraBoundsCache = { pitLane, bounds: result };
|
|
1506
|
+
return result;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
getPitCameraTarget(pitLane) {
|
|
1510
|
+
const bounds = this.getPitCameraBounds(pitLane);
|
|
1511
|
+
if (!bounds) return { x: WORLD.width / 2, y: WORLD.height / 2 };
|
|
1512
|
+
|
|
1513
|
+
return {
|
|
1514
|
+
x: (bounds.minX + bounds.maxX) / 2,
|
|
1515
|
+
y: (bounds.minY + bounds.maxY) / 2,
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
getPitCameraFrame(pitLane, height, baseScale, safeArea, screenCenterX, minimumScale = null) {
|
|
1520
|
+
const bounds = this.getPitCameraBounds(pitLane);
|
|
1521
|
+
const target = bounds
|
|
1522
|
+
? {
|
|
1523
|
+
x: (bounds.minX + bounds.maxX) / 2,
|
|
1524
|
+
y: (bounds.minY + bounds.maxY) / 2,
|
|
1525
|
+
}
|
|
1526
|
+
: { x: WORLD.width / 2, y: WORLD.height / 2 };
|
|
1527
|
+
|
|
1528
|
+
const presetScale = clampCameraScale(baseScale * this.camera.zoom, baseScale, minimumScale);
|
|
1529
|
+
if (!bounds) {
|
|
1530
|
+
return {
|
|
1531
|
+
target,
|
|
1532
|
+
scale: presetScale,
|
|
1533
|
+
screenX: screenCenterX,
|
|
1534
|
+
screenY: height / 2,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX + PIT_CAMERA_PADDING);
|
|
1539
|
+
const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY + PIT_CAMERA_PADDING);
|
|
1540
|
+
const fitScale = Math.min(safeArea.width / fitWidth, height / fitHeight);
|
|
1541
|
+
const scale = clampCameraScale(fitScale * this.camera.zoom, fitScale, minimumScale);
|
|
1542
|
+
|
|
1543
|
+
return {
|
|
1544
|
+
target,
|
|
1545
|
+
scale: Number.isFinite(scale) && scale > 0 ? scale : presetScale,
|
|
1546
|
+
screenX: screenCenterX,
|
|
1547
|
+
screenY: height / 2,
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
|
|
887
1551
|
updateDom(snapshot, { emitLifecycle = true } = {}) {
|
|
888
1552
|
const leader = snapshot.cars[0];
|
|
889
1553
|
const selected = snapshot.cars.find((car) => car.id === this.selectedId) ?? leader;
|
|
@@ -893,20 +1557,28 @@ export class F1SimulatorApp {
|
|
|
893
1557
|
|
|
894
1558
|
if (emitLifecycle) this.emitSnapshotLifecycle(snapshot);
|
|
895
1559
|
|
|
896
|
-
if (
|
|
1560
|
+
if (
|
|
1561
|
+
this.shouldAutoHideActiveRaceData() &&
|
|
1562
|
+
now - this.lastRaceDataInteraction > RACE_DATA_SELECTED_VISIBLE_MS
|
|
1563
|
+
) {
|
|
897
1564
|
this.activeRaceDataId = null;
|
|
898
1565
|
if (this.isRaceDataBannerEnabled('radio')) this.scheduleRadioBreak(now);
|
|
899
1566
|
}
|
|
1567
|
+
this.updateStewardMessageState(snapshot, now);
|
|
900
1568
|
|
|
901
1569
|
if (this.readouts.mode) {
|
|
902
1570
|
const modeLabel = snapshot.raceControl.mode === 'safety-car'
|
|
903
1571
|
? 'SC'
|
|
1572
|
+
: snapshot.raceControl.mode === 'red-flag'
|
|
1573
|
+
? 'RED'
|
|
904
1574
|
: snapshot.raceControl.finished
|
|
905
1575
|
? 'FINISH'
|
|
906
1576
|
: 'GREEN';
|
|
907
1577
|
this.readouts.mode.textContent = modeLabel;
|
|
908
1578
|
this.readouts.mode.style.color = snapshot.raceControl.mode === 'safety-car'
|
|
909
1579
|
? 'var(--yellow)'
|
|
1580
|
+
: snapshot.raceControl.mode === 'red-flag'
|
|
1581
|
+
? 'var(--race-control-red)'
|
|
910
1582
|
: snapshot.raceControl.mode === 'finished'
|
|
911
1583
|
? 'var(--red)'
|
|
912
1584
|
: 'var(--green)';
|
|
@@ -917,13 +1589,26 @@ export class F1SimulatorApp {
|
|
|
917
1589
|
if (this.readouts.towerTotalLaps) this.readouts.towerTotalLaps.textContent = snapshot.totalLaps;
|
|
918
1590
|
if (this.readouts.timingTower) {
|
|
919
1591
|
this.readouts.timingTower.classList.toggle('is-safety-car', snapshot.raceControl.mode === 'safety-car');
|
|
1592
|
+
this.readouts.timingTower.classList.toggle('is-red-flag', snapshot.raceControl.mode === 'red-flag');
|
|
920
1593
|
this.readouts.timingTower.classList.toggle('is-pre-start', snapshot.raceControl.mode === 'pre-start');
|
|
921
1594
|
}
|
|
922
|
-
if (this.readouts.
|
|
923
|
-
|
|
1595
|
+
if (this.readouts.towerRaceControlBanner) {
|
|
1596
|
+
const raceControlBanner = getRaceControlStatusBanner(snapshot.raceControl.mode);
|
|
1597
|
+
this.readouts.towerRaceControlBanner.hidden = !raceControlBanner;
|
|
1598
|
+
this.readouts.towerRaceControlBanner.classList?.toggle?.('is-safety-car', raceControlBanner?.status === 'safety-car');
|
|
1599
|
+
this.readouts.towerRaceControlBanner.classList?.toggle?.('is-red-flag', raceControlBanner?.status === 'red-flag');
|
|
1600
|
+
if (raceControlBanner && this.readouts.towerRaceControlBanner.dataset) {
|
|
1601
|
+
this.readouts.towerRaceControlBanner.dataset.raceControlStatus = raceControlBanner.status;
|
|
1602
|
+
} else if (this.readouts.towerRaceControlBanner.dataset) {
|
|
1603
|
+
delete this.readouts.towerRaceControlBanner.dataset.raceControlStatus;
|
|
1604
|
+
}
|
|
1605
|
+
if (raceControlBanner) {
|
|
1606
|
+
setText(this.readouts.towerRaceControlKicker, raceControlBanner.kicker);
|
|
1607
|
+
setText(this.readouts.towerRaceControlTitle, raceControlBanner.title);
|
|
1608
|
+
}
|
|
924
1609
|
}
|
|
925
1610
|
if (this.readouts.drs) {
|
|
926
|
-
this.readouts.drs.textContent = snapshot.raceControl.mode
|
|
1611
|
+
this.readouts.drs.textContent = ['safety-car', 'red-flag'].includes(snapshot.raceControl.mode)
|
|
927
1612
|
? 'DISABLED'
|
|
928
1613
|
: activeDrs
|
|
929
1614
|
? `${activeDrs} OPEN`
|
|
@@ -932,30 +1617,37 @@ export class F1SimulatorApp {
|
|
|
932
1617
|
if (this.readouts.contacts) this.readouts.contacts.textContent = String(contactCount);
|
|
933
1618
|
this.renderStartLights(snapshot.raceControl);
|
|
934
1619
|
if (this.readouts.camera) {
|
|
935
|
-
const zoom = this.camera.
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
this.readouts.camera.textContent = `${this.camera.mode.toUpperCase().replace('-', ' ')} ${zoom}%`;
|
|
1620
|
+
const zoom = Math.round(this.camera.zoom * 100);
|
|
1621
|
+
const mode = this.camera.free ? 'FREE' : this.camera.mode.toUpperCase().replace('-', ' ');
|
|
1622
|
+
this.readouts.camera.textContent = `${mode} ${zoom}%`;
|
|
939
1623
|
}
|
|
940
1624
|
if (this.readouts.fps) {
|
|
941
1625
|
this.readouts.fps.textContent = this.fps.current ? `${this.fps.current}` : '--';
|
|
942
1626
|
}
|
|
943
1627
|
this.renderRaceFinish(snapshot);
|
|
944
1628
|
|
|
945
|
-
this.updateCameraControls();
|
|
1629
|
+
this.updateCameraControls(snapshot);
|
|
946
1630
|
this.syncTimingGapModeControls();
|
|
1631
|
+
const timingPenaltyKey = this.getTimingPenaltyKey(snapshot.penalties ?? []);
|
|
1632
|
+
const timingOrderKey = this.getTimingOrderKey(snapshot.cars);
|
|
1633
|
+
const timingUpdateInterval = timingUpdateIntervalForSpeed(this.simulationSpeed);
|
|
947
1634
|
if (
|
|
948
|
-
now - this.lastTimingRenderTime >=
|
|
949
|
-
this.lastTimingRaceMode !== snapshot.raceControl.mode
|
|
1635
|
+
now - this.lastTimingRenderTime >= timingUpdateInterval ||
|
|
1636
|
+
this.lastTimingRaceMode !== snapshot.raceControl.mode ||
|
|
1637
|
+
this.lastTimingPenaltyKey !== timingPenaltyKey ||
|
|
1638
|
+
this.lastTimingOrderKey !== timingOrderKey
|
|
950
1639
|
) {
|
|
951
|
-
this.renderTiming(snapshot.cars, snapshot.raceControl.mode);
|
|
1640
|
+
this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
|
|
952
1641
|
this.lastTimingRenderTime = now;
|
|
953
1642
|
this.lastTimingRaceMode = snapshot.raceControl.mode;
|
|
1643
|
+
this.lastTimingPenaltyKey = timingPenaltyKey;
|
|
1644
|
+
this.lastTimingOrderKey = timingOrderKey;
|
|
954
1645
|
}
|
|
955
1646
|
this.renderTelemetry(selected);
|
|
956
1647
|
const activeRaceDataCar = this.activeRaceDataId
|
|
957
1648
|
? snapshot.cars.find((car) => car.id === this.activeRaceDataId)
|
|
958
1649
|
: null;
|
|
1650
|
+
this.renderActiveStewardMessage();
|
|
959
1651
|
if (activeRaceDataCar) {
|
|
960
1652
|
this.renderRaceData(activeRaceDataCar);
|
|
961
1653
|
} else {
|
|
@@ -978,12 +1670,7 @@ export class F1SimulatorApp {
|
|
|
978
1670
|
this.lastLeaderLap = leaderLap;
|
|
979
1671
|
}
|
|
980
1672
|
|
|
981
|
-
snapshot.events
|
|
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
|
-
});
|
|
1673
|
+
this.emitRaceEvents(snapshot.events, snapshot);
|
|
987
1674
|
|
|
988
1675
|
if (snapshot.raceControl.finished && !this.raceFinishEmitted) {
|
|
989
1676
|
this.raceFinishEmitted = true;
|
|
@@ -995,6 +1682,15 @@ export class F1SimulatorApp {
|
|
|
995
1682
|
}
|
|
996
1683
|
}
|
|
997
1684
|
|
|
1685
|
+
emitRaceEvents(events = [], snapshot) {
|
|
1686
|
+
events.forEach((event) => {
|
|
1687
|
+
const key = `${event.type}:${event.at ?? snapshot.time}:${event.carId ?? ''}:${event.otherCarId ?? ''}:${event.winnerId ?? ''}`;
|
|
1688
|
+
if (this.emittedRaceEventKeys.has(key)) return;
|
|
1689
|
+
this.emittedRaceEventKeys.add(key);
|
|
1690
|
+
this.emitHostCallback('onRaceEvent', event, snapshot);
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
|
|
998
1694
|
renderRaceFinish(snapshot) {
|
|
999
1695
|
const panel = this.readouts.finishPanel;
|
|
1000
1696
|
if (!panel) return;
|
|
@@ -1066,9 +1762,7 @@ export class F1SimulatorApp {
|
|
|
1066
1762
|
this.lastRaceDataInteraction = performance.now();
|
|
1067
1763
|
if (this.isRaceDataBannerEnabled('radio')) this.scheduleRadioBreak(this.lastRaceDataInteraction);
|
|
1068
1764
|
if (focus) {
|
|
1069
|
-
this.
|
|
1070
|
-
this.camera.zoom = CAMERA_PRESETS.selected;
|
|
1071
|
-
this.updateCameraControls();
|
|
1765
|
+
this.setCameraMode('selected');
|
|
1072
1766
|
}
|
|
1073
1767
|
this.lastTimingRenderTime = 0;
|
|
1074
1768
|
const snapshot = this.sim.snapshot();
|
|
@@ -1077,13 +1771,16 @@ export class F1SimulatorApp {
|
|
|
1077
1771
|
if (driver) this.emitHostCallback('onDriverSelect', driver, snapshot);
|
|
1078
1772
|
}
|
|
1079
1773
|
|
|
1080
|
-
renderTiming(cars, raceMode) {
|
|
1774
|
+
renderTiming(cars, raceMode, penalties = []) {
|
|
1081
1775
|
if (!this.timingList) return;
|
|
1082
1776
|
this.syncTimingGapModeControls();
|
|
1777
|
+
const penaltyByDriver = this.timingPenaltyBadgesEnabled ? this.getPenaltyByDriver(penalties) : new Map();
|
|
1083
1778
|
const timingMarkup = cars.map((car) => {
|
|
1084
1779
|
const driver = this.driverById.get(car.id);
|
|
1085
1780
|
let gap = 'Leader';
|
|
1086
|
-
if (raceMode
|
|
1781
|
+
if (this.isWavedFlagCar(car) && raceMode !== 'finished') {
|
|
1782
|
+
gap = 'WAVED';
|
|
1783
|
+
} else if (raceMode === 'finished') {
|
|
1087
1784
|
gap = car.rank === 1 ? 'Winner' : 'FIN';
|
|
1088
1785
|
} else if (raceMode === 'safety-car' && car.rank > 1) {
|
|
1089
1786
|
gap = 'SC';
|
|
@@ -1097,6 +1794,10 @@ export class F1SimulatorApp {
|
|
|
1097
1794
|
const team = car.team ?? driver?.team ?? null;
|
|
1098
1795
|
const icon = team?.icon ?? car.icon ?? driver?.icon ?? timingCode;
|
|
1099
1796
|
const iconColor = team?.color ?? car.color;
|
|
1797
|
+
const penalty = penaltyByDriver.get(car.id);
|
|
1798
|
+
const penaltyBadge = penalty
|
|
1799
|
+
? `<span class="timing-penalty-badge" aria-label="${escapeHtml(formatPenaltyBadgeLabel(penalty))}" title="${escapeHtml(formatPenaltyBadgeLabel(penalty))}">!</span>`
|
|
1800
|
+
: '';
|
|
1100
1801
|
|
|
1101
1802
|
return `
|
|
1102
1803
|
<li>
|
|
@@ -1105,7 +1806,7 @@ export class F1SimulatorApp {
|
|
|
1105
1806
|
style="--driver-color: ${escapeHtml(car.color)}">
|
|
1106
1807
|
<span class="timing-position">${car.rank}</span>
|
|
1107
1808
|
<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>
|
|
1809
|
+
<span class="timing-name" title="${escapeHtml(car.name)}"><span>${escapeHtml(timingCode)}</span>${penaltyBadge}</span>
|
|
1109
1810
|
<span class="timing-gap">${escapeHtml(gap)}</span>
|
|
1110
1811
|
<span class="timing-tire timing-tire--${getTireClass(tire)}">${escapeHtml(tire)}</span>
|
|
1111
1812
|
</button>
|
|
@@ -1118,13 +1819,81 @@ export class F1SimulatorApp {
|
|
|
1118
1819
|
}
|
|
1119
1820
|
}
|
|
1120
1821
|
|
|
1822
|
+
getTimingOrderKey(cars = []) {
|
|
1823
|
+
return cars.map((car) => [
|
|
1824
|
+
car.id,
|
|
1825
|
+
car.rank,
|
|
1826
|
+
car.lap,
|
|
1827
|
+
this.formatTimingGap(car),
|
|
1828
|
+
car.penaltySeconds ?? 0,
|
|
1829
|
+
car.classifiedRank ?? 0,
|
|
1830
|
+
car.finishRank ?? 0,
|
|
1831
|
+
car.raceStatus ?? car.status ?? '',
|
|
1832
|
+
car.wavedFlag ? 1 : 0,
|
|
1833
|
+
car.tire ?? '',
|
|
1834
|
+
].join(':')).join('|');
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
isWavedFlagCar(car) {
|
|
1838
|
+
return Boolean(car?.wavedFlag || car?.raceStatus === 'waved-flag' || car?.status === 'waved-flag');
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
getPenaltyByDriver(penalties = []) {
|
|
1842
|
+
const byDriver = new Map();
|
|
1843
|
+
penalties.forEach((penalty) => {
|
|
1844
|
+
if (!penalty?.driverId || byDriver.has(penalty.driverId)) return;
|
|
1845
|
+
if (!hasVisiblePenaltyEffect(penalty)) return;
|
|
1846
|
+
byDriver.set(penalty.driverId, penalty);
|
|
1847
|
+
});
|
|
1848
|
+
return byDriver;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
getTimingPenaltyKey(penalties = []) {
|
|
1852
|
+
if (!this.timingPenaltyBadgesEnabled) return '';
|
|
1853
|
+
return penalties
|
|
1854
|
+
.filter(hasVisiblePenaltyEffect)
|
|
1855
|
+
.map((penalty) => [
|
|
1856
|
+
penalty.id,
|
|
1857
|
+
penalty.driverId,
|
|
1858
|
+
penalty.status,
|
|
1859
|
+
penalty.penaltySeconds,
|
|
1860
|
+
penalty.pendingPenaltySeconds,
|
|
1861
|
+
penalty.positionDrop,
|
|
1862
|
+
penalty.gridDrop,
|
|
1863
|
+
penalty.disqualified,
|
|
1864
|
+
].join(':'))
|
|
1865
|
+
.join('|');
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1121
1868
|
formatTimingGap(car) {
|
|
1869
|
+
const lapValue = this.timingGapMode === 'leader'
|
|
1870
|
+
? car.leaderGapLaps
|
|
1871
|
+
: (car.intervalAheadLaps ?? car.gapAheadLaps);
|
|
1872
|
+
if (Number.isFinite(lapValue) && lapValue > 0) return this.formatLapGap(lapValue);
|
|
1873
|
+
|
|
1122
1874
|
const value = this.timingGapMode === 'leader'
|
|
1123
1875
|
? car.leaderGapSeconds
|
|
1124
1876
|
: (car.intervalAheadSeconds ?? car.gapAheadSeconds);
|
|
1125
1877
|
return Number.isFinite(value) ? `+${Math.max(0, value).toFixed(3)}` : '--';
|
|
1126
1878
|
}
|
|
1127
1879
|
|
|
1880
|
+
formatLapGap(laps) {
|
|
1881
|
+
const wholeLaps = Math.max(0, Math.floor(laps));
|
|
1882
|
+
return `+${wholeLaps}`;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
formatTelemetryGap(car, mode) {
|
|
1886
|
+
const lapValue = mode === 'leader'
|
|
1887
|
+
? car.leaderGapLaps
|
|
1888
|
+
: (car.intervalAheadLaps ?? car.gapAheadLaps);
|
|
1889
|
+
if (Number.isFinite(lapValue) && lapValue > 0) return this.formatLapGap(lapValue);
|
|
1890
|
+
|
|
1891
|
+
const seconds = mode === 'leader'
|
|
1892
|
+
? car.leaderGapSeconds
|
|
1893
|
+
: (car.intervalAheadSeconds ?? car.gapAheadSeconds);
|
|
1894
|
+
return Number.isFinite(seconds) ? `${seconds.toFixed(2)}s` : '--';
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1128
1897
|
syncTimingGapModeControls() {
|
|
1129
1898
|
const label = this.timingGapMode === 'leader' ? 'Gap' : 'Int';
|
|
1130
1899
|
setText(this.readouts.timingGapLabel, label);
|
|
@@ -1159,15 +1928,15 @@ export class F1SimulatorApp {
|
|
|
1159
1928
|
setTextAll(this.readouts.surface, surface);
|
|
1160
1929
|
setTextAll(
|
|
1161
1930
|
this.readouts.gap,
|
|
1162
|
-
car.rank === 1
|
|
1931
|
+
car.rank === 1
|
|
1163
1932
|
? '--'
|
|
1164
|
-
:
|
|
1933
|
+
: this.formatTelemetryGap(car, 'interval'),
|
|
1165
1934
|
);
|
|
1166
1935
|
setTextAll(
|
|
1167
1936
|
this.readouts.leaderGap,
|
|
1168
|
-
car.rank === 1
|
|
1937
|
+
car.rank === 1
|
|
1169
1938
|
? '--'
|
|
1170
|
-
:
|
|
1939
|
+
: this.formatTelemetryGap(car, 'leader'),
|
|
1171
1940
|
);
|
|
1172
1941
|
|
|
1173
1942
|
this.renderCarDriverOverview(car);
|
|
@@ -1186,28 +1955,50 @@ export class F1SimulatorApp {
|
|
|
1186
1955
|
this.readouts.telemetrySectorBars?.forEach((bar) => {
|
|
1187
1956
|
const sector = Number(bar.dataset.telemetrySectorBar);
|
|
1188
1957
|
const index = sector - 1;
|
|
1189
|
-
const
|
|
1190
|
-
const
|
|
1191
|
-
const
|
|
1192
|
-
|
|
1193
|
-
|
|
1958
|
+
const activeIndex = clamp((telemetry.currentSector ?? 1) - 1, 0, 2);
|
|
1959
|
+
const isActive = index === activeIndex;
|
|
1960
|
+
const isCompletedCurrentSector = index < activeIndex;
|
|
1961
|
+
const hasLiveProgress = Array.isArray(telemetry.sectorProgress);
|
|
1962
|
+
const progress = index > activeIndex
|
|
1963
|
+
? 0
|
|
1964
|
+
: hasLiveProgress
|
|
1965
|
+
? telemetry.sectorProgress[index]
|
|
1966
|
+
: isActive
|
|
1967
|
+
? telemetry.currentSectorProgress
|
|
1968
|
+
: isCompletedCurrentSector && Number.isFinite(telemetry.currentSectors?.[index])
|
|
1969
|
+
? 1
|
|
1970
|
+
: 0;
|
|
1971
|
+
const fill = clamp((progress ?? 0) * 100, 0, 100);
|
|
1972
|
+
const sectorComplete = fill >= 99.9;
|
|
1194
1973
|
const fillValue = `${fill.toFixed(1)}%`;
|
|
1195
1974
|
if (bar.style.getPropertyValue('--sector-fill') !== fillValue) {
|
|
1196
1975
|
bar.style.setProperty('--sector-fill', fillValue);
|
|
1197
1976
|
}
|
|
1198
1977
|
bar.classList.toggle('is-active', isActive);
|
|
1199
1978
|
bar.classList.toggle('is-complete', sectorComplete);
|
|
1200
|
-
setPerformanceClass(bar, telemetry.sectorPerformance?.current?.[index]);
|
|
1979
|
+
setPerformanceClass(bar, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
|
|
1201
1980
|
});
|
|
1202
1981
|
|
|
1203
1982
|
this.readouts.telemetrySectorTimes?.forEach((node) => {
|
|
1204
1983
|
const sector = Number(node.dataset.telemetrySectorTime);
|
|
1205
1984
|
const index = sector - 1;
|
|
1206
|
-
const
|
|
1207
|
-
|
|
1208
|
-
|
|
1985
|
+
const activeIndex = clamp((telemetry.currentSector ?? 1) - 1, 0, 2);
|
|
1986
|
+
const isActive = index === activeIndex;
|
|
1987
|
+
const isCompletedCurrentSector = index < activeIndex;
|
|
1988
|
+
const hasLiveSectors = Array.isArray(telemetry.liveSectors);
|
|
1989
|
+
const value = index > activeIndex
|
|
1990
|
+
? null
|
|
1991
|
+
: hasLiveSectors
|
|
1992
|
+
? isActive
|
|
1993
|
+
? telemetry.currentSectorElapsed ?? telemetry.liveSectors[index]
|
|
1994
|
+
: telemetry.liveSectors[index]
|
|
1995
|
+
: isActive && !Number.isFinite(telemetry.currentSectors?.[index])
|
|
1996
|
+
? telemetry.currentSectorElapsed
|
|
1997
|
+
: isCompletedCurrentSector
|
|
1998
|
+
? telemetry.currentSectors?.[index]
|
|
1999
|
+
: null;
|
|
1209
2000
|
setText(node, formatTelemetryTime(value));
|
|
1210
|
-
setPerformanceClass(node, telemetry.sectorPerformance?.current?.[index]);
|
|
2001
|
+
setPerformanceClass(node, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
|
|
1211
2002
|
});
|
|
1212
2003
|
|
|
1213
2004
|
this.readouts.telemetrySectorLast?.forEach((node) => {
|
|
@@ -1329,6 +2120,17 @@ export class F1SimulatorApp {
|
|
|
1329
2120
|
}
|
|
1330
2121
|
}
|
|
1331
2122
|
|
|
2123
|
+
shouldAutoHideActiveRaceData() {
|
|
2124
|
+
return Boolean(this.activeRaceDataId && !this.hasPersistentRaceDataTelemetry());
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
hasPersistentRaceDataTelemetry() {
|
|
2128
|
+
return Boolean(
|
|
2129
|
+
this.options.ui?.raceDataTelemetryDetail ||
|
|
2130
|
+
this.readouts.raceDataPanel?.classList?.contains?.('race-data-panel--with-telemetry'),
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
1332
2134
|
renderProjectRadio(now = performance.now()) {
|
|
1333
2135
|
if (!this.readouts.raceDataPanel) return;
|
|
1334
2136
|
if (!this.isRaceDataBannerEnabled('radio')) {
|
|
@@ -1363,6 +2165,96 @@ export class F1SimulatorApp {
|
|
|
1363
2165
|
if (this.readouts.raceDataOpen) this.readouts.raceDataOpen.hidden = true;
|
|
1364
2166
|
}
|
|
1365
2167
|
|
|
2168
|
+
dismissRaceDataPanel(now = performance.now()) {
|
|
2169
|
+
this.activeRaceDataId = null;
|
|
2170
|
+
this.lastRaceDataInteraction = now;
|
|
2171
|
+
this.radioState.visible = false;
|
|
2172
|
+
this.radioState.nextChangeAt = this.getNextRadioBreakTime(now);
|
|
2173
|
+
this.hideRaceDataPanel();
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
updateStewardMessageState(snapshot, now = performance.now()) {
|
|
2177
|
+
if (!this.penaltyBannerEnabled || !this.readouts.stewardMessage) {
|
|
2178
|
+
this.activeStewardMessage = null;
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
if (this.activeStewardMessage && now < this.activeStewardMessage.visibleUntil) return;
|
|
2182
|
+
this.activeStewardMessage = null;
|
|
2183
|
+
|
|
2184
|
+
const warning = [...(snapshot.events ?? [])].reverse().find((event) => (
|
|
2185
|
+
event?.type === 'track-limits' && event.decision === 'warning'
|
|
2186
|
+
));
|
|
2187
|
+
const penalty = [...(snapshot.penalties ?? [])].reverse().find((entry) => (
|
|
2188
|
+
entry?.id && entry.id !== this.lastPenaltyBannerId
|
|
2189
|
+
));
|
|
2190
|
+
const message = penalty
|
|
2191
|
+
? this.createPenaltyStewardMessage(penalty)
|
|
2192
|
+
: this.createWarningStewardMessage(warning);
|
|
2193
|
+
const key = formatStewardMessageKey(message);
|
|
2194
|
+
if (!message || !key || key === this.lastStewardMessageKey) return;
|
|
2195
|
+
this.lastStewardMessageKey = key;
|
|
2196
|
+
if (message.penaltyId) this.lastPenaltyBannerId = message.penaltyId;
|
|
2197
|
+
this.activeStewardMessage = {
|
|
2198
|
+
message,
|
|
2199
|
+
visibleUntil: now + PENALTY_BANNER_VISIBLE_MS,
|
|
2200
|
+
};
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
createPenaltyStewardMessage(penalty) {
|
|
2204
|
+
if (!penalty) return null;
|
|
2205
|
+
const driver = this.driverById.get(penalty.driverId);
|
|
2206
|
+
const code = driver?.timingCode ?? driver?.code ?? penalty.driverId ?? 'CAR';
|
|
2207
|
+
return {
|
|
2208
|
+
kind: 'penalty',
|
|
2209
|
+
type: penalty.type,
|
|
2210
|
+
driverId: penalty.driverId,
|
|
2211
|
+
penaltyId: penalty.id,
|
|
2212
|
+
time: penalty.time,
|
|
2213
|
+
color: driver?.color ?? 'var(--red)',
|
|
2214
|
+
kicker: formatPenaltyChip(penalty),
|
|
2215
|
+
title: `${code} ${formatPenaltyHeadline(penalty).replace(/^\+\d+ seconds? /u, '')}`,
|
|
2216
|
+
detail: `${formatPenaltyType(penalty.type)} - ${penalty.reason ?? 'Steward decision'}`,
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
createWarningStewardMessage(event) {
|
|
2221
|
+
if (!event) return null;
|
|
2222
|
+
const driver = this.driverById.get(event.carId);
|
|
2223
|
+
const code = driver?.timingCode ?? driver?.code ?? event.carId ?? 'CAR';
|
|
2224
|
+
const warningLimit = Number(event.warningsBeforePenalty);
|
|
2225
|
+
const warningProgress = Number.isFinite(warningLimit) && warningLimit > 0
|
|
2226
|
+
? ` ${event.violationCount}/${warningLimit}`
|
|
2227
|
+
: '';
|
|
2228
|
+
return {
|
|
2229
|
+
kind: 'warning',
|
|
2230
|
+
type: event.type,
|
|
2231
|
+
driverId: event.carId,
|
|
2232
|
+
violationCount: event.violationCount,
|
|
2233
|
+
time: event.at,
|
|
2234
|
+
color: driver?.color ?? 'var(--yellow)',
|
|
2235
|
+
kicker: 'Warning',
|
|
2236
|
+
title: `${code} ${formatPenaltyType(event.type).toLowerCase()}`,
|
|
2237
|
+
detail: `${formatPenaltyType(event.type)}${warningProgress}`,
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
renderActiveStewardMessage() {
|
|
2242
|
+
const message = this.activeStewardMessage?.message;
|
|
2243
|
+
const panel = this.readouts.stewardMessage;
|
|
2244
|
+
if (!panel) return;
|
|
2245
|
+
if (!message) {
|
|
2246
|
+
panel.classList.add('is-hidden');
|
|
2247
|
+
panel.classList.remove('is-penalty', 'is-warning');
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
panel.style.setProperty('--steward-color', message.color);
|
|
2251
|
+
panel.classList.remove('is-hidden', 'is-penalty', 'is-warning');
|
|
2252
|
+
panel.classList.add(message.kind === 'penalty' ? 'is-penalty' : 'is-warning');
|
|
2253
|
+
setText(this.readouts.stewardMessageKicker, message.kicker);
|
|
2254
|
+
setText(this.readouts.stewardMessageTitle, message.title);
|
|
2255
|
+
setText(this.readouts.stewardMessageDetail, message.detail);
|
|
2256
|
+
}
|
|
2257
|
+
|
|
1366
2258
|
updateRadioSchedule(now) {
|
|
1367
2259
|
if (!this.isRaceDataBannerEnabled('radio')) {
|
|
1368
2260
|
this.radioState.visible = false;
|
|
@@ -1436,7 +2328,7 @@ export class F1SimulatorApp {
|
|
|
1436
2328
|
}
|
|
1437
2329
|
|
|
1438
2330
|
isRaceDataBannerEnabled(kind) {
|
|
1439
|
-
return this.raceDataBannerConfig.enabled?.includes(kind) ?? true;
|
|
2331
|
+
return !this.raceDataBannersMuted && (this.raceDataBannerConfig.enabled?.includes(kind) ?? true);
|
|
1440
2332
|
}
|
|
1441
2333
|
|
|
1442
2334
|
getProjectRadioQuote() {
|
|
@@ -1450,12 +2342,41 @@ export class F1SimulatorApp {
|
|
|
1450
2342
|
};
|
|
1451
2343
|
}
|
|
1452
2344
|
|
|
1453
|
-
updateCameraControls() {
|
|
2345
|
+
updateCameraControls(snapshot = this.sim?.snapshot?.()) {
|
|
2346
|
+
if (this.camera.mode === 'pit' && !this.hasPitCamera(snapshot)) {
|
|
2347
|
+
this.camera.mode = 'leader';
|
|
2348
|
+
this.camera.zoom = CAMERA_PRESETS.leader;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
1454
2351
|
this.cameraButtons.forEach((button) => {
|
|
1455
|
-
const
|
|
2352
|
+
const mode = button.dataset.cameraMode;
|
|
2353
|
+
const isAvailable = this.isCameraModeAvailable(mode, snapshot);
|
|
2354
|
+
if (mode === 'pit') {
|
|
2355
|
+
button.hidden = !isAvailable;
|
|
2356
|
+
button.disabled = !isAvailable;
|
|
2357
|
+
button.setAttribute('aria-hidden', String(!isAvailable));
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
const isActive = !this.camera.free && isAvailable && mode === this.camera.mode;
|
|
1456
2361
|
button.classList.toggle('is-active', isActive);
|
|
1457
2362
|
button.setAttribute('aria-pressed', String(isActive));
|
|
1458
2363
|
});
|
|
2364
|
+
this.updateBannerMuteControls();
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
updateBannerMuteControls() {
|
|
2368
|
+
this.bannerMuteButtons?.forEach((button) => {
|
|
2369
|
+
button.classList.toggle('is-active', this.raceDataBannersMuted);
|
|
2370
|
+
button.setAttribute('aria-pressed', String(this.raceDataBannersMuted));
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
hasPitCamera(snapshot = this.sim?.snapshot?.()) {
|
|
2375
|
+
return Boolean(snapshot?.track?.pitLane?.enabled);
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
isCameraModeAvailable(mode, snapshot = this.sim?.snapshot?.()) {
|
|
2379
|
+
return mode !== 'pit' || this.hasPitCamera(snapshot);
|
|
1459
2380
|
}
|
|
1460
2381
|
|
|
1461
2382
|
updateOverviewModeButtons() {
|
|
@@ -1515,6 +2436,8 @@ export class F1SimulatorApp {
|
|
|
1515
2436
|
}
|
|
1516
2437
|
this.applyExpertOptions(nextOptions);
|
|
1517
2438
|
this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
|
|
2439
|
+
this.penaltyBannerEnabled = Boolean(this.options.ui?.penaltyBanners);
|
|
2440
|
+
this.timingPenaltyBadgesEnabled = Boolean(this.options.ui?.timingPenaltyBadges);
|
|
1518
2441
|
this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
|
|
1519
2442
|
applyPaddockThemeCssVariables(this.root, this.options.theme);
|
|
1520
2443
|
this.sim = this.createRaceSimulation();
|
|
@@ -1528,11 +2451,20 @@ export class F1SimulatorApp {
|
|
|
1528
2451
|
this.resetRaceDataBannerState(performance.now());
|
|
1529
2452
|
this.lastTimingRenderTime = 0;
|
|
1530
2453
|
this.lastTimingRaceMode = null;
|
|
2454
|
+
this.lastTimingPenaltyKey = '';
|
|
2455
|
+
this.lastTimingOrderKey = '';
|
|
2456
|
+
this.activePenaltyBanner = null;
|
|
2457
|
+
this.lastPenaltyBannerId = null;
|
|
2458
|
+
this.activeStewardMessage = null;
|
|
2459
|
+
this.lastStewardMessageKey = null;
|
|
1531
2460
|
this.lastLeaderLap = null;
|
|
1532
2461
|
this.emittedRaceEventKeys.clear();
|
|
1533
2462
|
this.raceFinishEmitted = false;
|
|
1534
2463
|
this.renderTrack();
|
|
1535
|
-
this.
|
|
2464
|
+
const snapshot = this.sim.snapshot();
|
|
2465
|
+
this.updateDom(snapshot);
|
|
2466
|
+
this.lastDomUpdateTime = performance.now();
|
|
2467
|
+
this.renderInitialFrame(snapshot);
|
|
1536
2468
|
}
|
|
1537
2469
|
|
|
1538
2470
|
setSafetyCarDeployed(deployed) {
|
|
@@ -1541,12 +2473,52 @@ export class F1SimulatorApp {
|
|
|
1541
2473
|
this.syncSafetyCarControls(active);
|
|
1542
2474
|
}
|
|
1543
2475
|
|
|
2476
|
+
setRedFlagDeployed(deployed) {
|
|
2477
|
+
this.sim?.setRedFlag?.(Boolean(deployed));
|
|
2478
|
+
if (this.sim) this.updateDom(this.sim.snapshot());
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
setPitLaneOpen(open) {
|
|
2482
|
+
this.sim?.setPitLaneOpen?.(Boolean(open));
|
|
2483
|
+
if (this.sim) this.updateDom(this.sim.snapshot());
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
setPitIntent(driverId, intent, targetCompound) {
|
|
2487
|
+
const applied = Boolean(this.sim?.setPitIntent?.(driverId, intent, targetCompound));
|
|
2488
|
+
if (applied) this.updateDom(this.sim.snapshot());
|
|
2489
|
+
return applied;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
getPitIntent(driverId) {
|
|
2493
|
+
return this.sim?.getPitIntent?.(driverId) ?? 0;
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
getPitTargetCompound(driverId) {
|
|
2497
|
+
return this.sim?.getPitTargetCompound?.(driverId) ?? null;
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
servePenalty(penaltyId) {
|
|
2501
|
+
const penalty = this.sim?.servePenalty(penaltyId) ?? null;
|
|
2502
|
+
if (penalty) this.updateDom(this.sim.snapshot());
|
|
2503
|
+
return penalty;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
cancelPenalty(penaltyId) {
|
|
2507
|
+
const penalty = this.sim?.cancelPenalty(penaltyId) ?? null;
|
|
2508
|
+
if (penalty) this.updateDom(this.sim.snapshot());
|
|
2509
|
+
return penalty;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
1544
2512
|
getSnapshot() {
|
|
1545
2513
|
return this.sim?.snapshot() ?? null;
|
|
1546
2514
|
}
|
|
1547
2515
|
|
|
1548
2516
|
destroy() {
|
|
1549
2517
|
this.abortController.abort();
|
|
2518
|
+
if (this.layoutResizeFrame && typeof cancelAnimationFrame === 'function') {
|
|
2519
|
+
cancelAnimationFrame(this.layoutResizeFrame);
|
|
2520
|
+
}
|
|
2521
|
+
this.layoutResizeFrame = null;
|
|
1550
2522
|
this.layoutResizeObserver?.disconnect?.();
|
|
1551
2523
|
this.layoutResizeObserver = null;
|
|
1552
2524
|
this.visibilityObserver?.disconnect?.();
|
|
@@ -1558,6 +2530,7 @@ export class F1SimulatorApp {
|
|
|
1558
2530
|
this.app = null;
|
|
1559
2531
|
this.carSprites.clear();
|
|
1560
2532
|
this.carHitAreas.clear();
|
|
2533
|
+
this.serviceCountdownLabels.clear();
|
|
1561
2534
|
this.drsTrails.clear();
|
|
1562
2535
|
}
|
|
1563
2536
|
}
|