@inventure71/paddockjs 0.2.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 +45 -0
- package/INSTALL_AND_UPDATE.md +165 -0
- package/README.md +217 -20
- package/docs/architecture.md +84 -15
- package/docs/concepts.md +20 -3
- package/docs/data_contract.md +412 -20
- package/docs/data_model.md +2 -1
- package/docs/index.md +1 -0
- package/docs/learnings.md +1 -1
- package/docs/rules.md +140 -19
- package/docs/system_specs.md +74 -20
- package/docs/training.md +206 -0
- package/examples/train-basic-policy.mjs +276 -0
- package/examples/trainingData.mjs +133 -0
- package/package.json +18 -8
- package/src/api/PaddockSimulatorController.js +39 -4
- package/src/app/BrowserExpertAdapter.js +32 -0
- package/src/app/F1SimulatorApp.js +1251 -146
- package/src/app/domBindings.js +10 -2
- package/src/config/defaultOptions.js +11 -3
- package/src/data/championship.js +13 -0
- package/src/data/normalizeDrivers.js +7 -2
- package/src/environment/actions.js +68 -0
- package/src/environment/episode.js +17 -0
- package/src/environment/evaluation.js +184 -0
- package/src/environment/events.js +17 -0
- package/src/environment/index.d.ts +521 -0
- package/src/environment/index.js +15 -0
- package/src/environment/observationOptions.js +12 -0
- package/src/environment/observations.js +284 -0
- package/src/environment/options.js +157 -0
- package/src/environment/recorder.js +28 -0
- package/src/environment/rewards.js +69 -0
- package/src/environment/runtime.js +175 -0
- package/src/environment/scenarios.js +220 -0
- package/src/environment/sensors.js +367 -0
- package/src/environment/specs.js +163 -0
- package/src/environment/workerProtocol.js +52 -0
- package/src/index.d.ts +405 -6
- package/src/index.js +27 -2
- package/src/rendering/proceduralTrackAsset.js +343 -24
- package/src/simulation/collisionGeometry.js +214 -0
- package/src/simulation/driverController.js +431 -65
- package/src/simulation/raceSimulation.js +2140 -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 +369 -67
- package/src/ui/bannerTemplates.js +69 -0
- package/src/ui/componentTemplates.js +45 -81
- package/src/ui/raceControlStatusBanner.js +39 -0
- package/src/ui/shellTemplate.js +1 -1
- package/src/ui/templateUtils.js +23 -0
- package/AGENTS.md +0 -86
- package/src/standalone.js +0 -15
|
@@ -1,45 +1,75 @@
|
|
|
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';
|
|
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';
|
|
11
|
+
import { metersToSimUnits } from '../simulation/units.js';
|
|
12
|
+
import { VEHICLE_GEOMETRY } from '../simulation/vehicleGeometry.js';
|
|
13
|
+
import { getRaceControlStatusBanner } from '../ui/raceControlStatusBanner.js';
|
|
14
|
+
import { createBrowserExpertAdapter } from './BrowserExpertAdapter.js';
|
|
10
15
|
import { querySimulatorDom, setText, setTextAll } from './domBindings.js';
|
|
11
16
|
|
|
12
|
-
const FIXED_STEP = 1 / 60;
|
|
13
17
|
const TARGET_RENDER_FPS = 60;
|
|
14
18
|
const TARGET_FRAME_MS = 1000 / TARGET_RENDER_FPS;
|
|
15
19
|
const FRAME_PACING_EPSILON_MS = 0.75;
|
|
16
20
|
const MAX_FRAME_CATCHUP_COUNT = 4;
|
|
17
|
-
const
|
|
21
|
+
const BASE_MAX_SIMULATION_STEPS_PER_RENDER = 5;
|
|
22
|
+
const HARD_MAX_SIMULATION_STEPS_PER_RENDER = 60;
|
|
18
23
|
const DOM_UPDATE_INTERVAL_MS = 100;
|
|
19
24
|
const TIMING_UPDATE_INTERVAL_MS = 250;
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
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);
|
|
25
34
|
const CAMERA_PRESETS = {
|
|
26
|
-
overview: 1
|
|
27
|
-
leader:
|
|
28
|
-
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
|
+
},
|
|
29
56
|
};
|
|
30
57
|
const SHOW_ALL_PADDING = 520;
|
|
31
|
-
const SHOW_ALL_MIN_ZOOM = 1.1;
|
|
32
|
-
const SHOW_ALL_MAX_ZOOM = 6.4;
|
|
33
58
|
const SHOW_ALL_TOP_RESERVED = 92;
|
|
34
59
|
const SHOW_ALL_BOTTOM_RESERVED = 132;
|
|
35
|
-
const
|
|
36
|
-
const
|
|
60
|
+
const PIT_CAMERA_PADDING = metersToSimUnits(34);
|
|
61
|
+
const DRS_TRAIL_TTL = 0.34;
|
|
62
|
+
const DRS_TRAIL_MIN_DISTANCE = metersToSimUnits(0.9);
|
|
63
|
+
const SENSOR_RAY_TRACK_COLOR = 0xf1c65b;
|
|
64
|
+
const SENSOR_RAY_TRACK_ENTRY_COLOR = 0x68d8ff;
|
|
65
|
+
const SENSOR_RAY_CAR_COLOR = 0xff4d5f;
|
|
37
66
|
const RACE_DATA_SELECTED_VISIBLE_MS = 5200;
|
|
38
67
|
const RADIO_BREAK_MIN_MS = 4800;
|
|
39
68
|
const RADIO_BREAK_MAX_MS = 11800;
|
|
40
69
|
const RADIO_VISIBLE_MIN_MS = 6200;
|
|
41
70
|
const RADIO_VISIBLE_MAX_MS = 9200;
|
|
42
71
|
const RADIO_SCHEDULE_CATCHUP_LIMIT_MS = 30000;
|
|
72
|
+
const PENALTY_BANNER_VISIBLE_MS = 12000;
|
|
43
73
|
const DRS_DRAG_REDUCTION_PERCENT = 58;
|
|
44
74
|
|
|
45
75
|
const VEHICLE_OVERVIEW_FIELDS = [
|
|
@@ -84,6 +114,86 @@ function getTireClass(tire) {
|
|
|
84
114
|
return String(tire ?? 'M').toLowerCase();
|
|
85
115
|
}
|
|
86
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
|
+
|
|
87
197
|
function formatTelemetryTime(value) {
|
|
88
198
|
if (!Number.isFinite(value)) return '--';
|
|
89
199
|
const clamped = Math.max(0, value);
|
|
@@ -109,6 +219,14 @@ function colorToTint(color) {
|
|
|
109
219
|
return normalizedTint;
|
|
110
220
|
}
|
|
111
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
|
+
|
|
112
230
|
function smoothAngle(current, target, amount) {
|
|
113
231
|
if (!Number.isFinite(current)) return target;
|
|
114
232
|
let diff = ((target - current + Math.PI) % (Math.PI * 2)) - Math.PI;
|
|
@@ -116,6 +234,75 @@ function smoothAngle(current, target, amount) {
|
|
|
116
234
|
return current + diff * amount;
|
|
117
235
|
}
|
|
118
236
|
|
|
237
|
+
function destroyDisplayChildren(container) {
|
|
238
|
+
container?.removeChildren?.().forEach((child) => {
|
|
239
|
+
child.destroy?.({ children: true, texture: false, textureSource: false });
|
|
240
|
+
});
|
|
241
|
+
}
|
|
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
|
+
|
|
251
|
+
function expertVisualizesRays(expertOptions) {
|
|
252
|
+
const setting = expertOptions?.visualizeSensors;
|
|
253
|
+
if (setting === true) return true;
|
|
254
|
+
if (!setting || setting === false) return false;
|
|
255
|
+
return Boolean(setting.rays);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function pointFromRay(origin, ray, distance) {
|
|
259
|
+
return {
|
|
260
|
+
x: origin.x + ray.x * distance,
|
|
261
|
+
y: origin.y + ray.y * distance,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function hasOwnOption(options, key) {
|
|
266
|
+
return Object.hasOwn(options ?? {}, key);
|
|
267
|
+
}
|
|
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
|
+
|
|
293
|
+
function expertOptionsChanged(nextExpertOptions, currentExpertOptions) {
|
|
294
|
+
return nextExpertOptions !== currentExpertOptions;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function assetSetsEqual(first = {}, second = {}) {
|
|
298
|
+
const firstTrackTextures = first.trackTextures ?? {};
|
|
299
|
+
const secondTrackTextures = second.trackTextures ?? {};
|
|
300
|
+
const assetKeys = ['car', 'carOverview', 'driverHelmet', 'safetyCar', 'broadcastPanel', 'f1Logo'];
|
|
301
|
+
const textureKeys = new Set([...Object.keys(firstTrackTextures), ...Object.keys(secondTrackTextures)]);
|
|
302
|
+
return assetKeys.every((key) => first[key] === second[key]) &&
|
|
303
|
+
[...textureKeys].every((key) => firstTrackTextures[key] === secondTrackTextures[key]);
|
|
304
|
+
}
|
|
305
|
+
|
|
119
306
|
export class F1SimulatorApp {
|
|
120
307
|
constructor(root, options) {
|
|
121
308
|
this.root = root;
|
|
@@ -134,21 +321,33 @@ export class F1SimulatorApp {
|
|
|
134
321
|
this.visibilityChangeHandler = null;
|
|
135
322
|
this.visibilityObserver = null;
|
|
136
323
|
this.tickerCallback = null;
|
|
324
|
+
this.expert = null;
|
|
325
|
+
this.expertMode = Boolean(options.expert?.enabled);
|
|
137
326
|
this.sim = null;
|
|
138
327
|
this.app = null;
|
|
139
328
|
this.worldLayer = null;
|
|
140
329
|
this.trackAsset = null;
|
|
141
330
|
this.drsLayer = null;
|
|
142
331
|
this.trailLayer = null;
|
|
332
|
+
this.sensorLayer = null;
|
|
333
|
+
this.pitLaneStatusLayer = null;
|
|
143
334
|
this.carLayer = null;
|
|
144
335
|
this.textures = {};
|
|
145
336
|
this.carSprites = new Map();
|
|
146
337
|
this.carHitAreas = new Map();
|
|
338
|
+
this.serviceCountdownLabels = new Map();
|
|
147
339
|
this.drsTrails = new Map();
|
|
148
340
|
this.trackSeed = options.trackSeed ?? createMountTrackSeed();
|
|
149
341
|
this.selectedId = this.drivers[0]?.id ?? null;
|
|
150
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);
|
|
151
346
|
this.activeRaceDataId = null;
|
|
347
|
+
this.activePenaltyBanner = null;
|
|
348
|
+
this.lastPenaltyBannerId = null;
|
|
349
|
+
this.activeStewardMessage = null;
|
|
350
|
+
this.lastStewardMessageKey = null;
|
|
152
351
|
this.lastRaceDataInteraction = performance.now();
|
|
153
352
|
this.radioRandomState = (this.trackSeed ^ 0x9e3779b9) >>> 0;
|
|
154
353
|
this.radioState = {
|
|
@@ -162,17 +361,26 @@ export class F1SimulatorApp {
|
|
|
162
361
|
mode: options.initialCameraMode,
|
|
163
362
|
zoom: CAMERA_PRESETS[options.initialCameraMode] ?? CAMERA_PRESETS.leader,
|
|
164
363
|
scale: null,
|
|
364
|
+
initialized: false,
|
|
165
365
|
x: WORLD.width / 2,
|
|
166
366
|
y: WORLD.height / 2,
|
|
367
|
+
free: false,
|
|
368
|
+
freeTarget: null,
|
|
167
369
|
};
|
|
168
370
|
this.overviewMode = 'vehicle';
|
|
371
|
+
this.simulationSpeed = 1;
|
|
169
372
|
this.accumulator = 0;
|
|
170
373
|
this.lastTime = performance.now();
|
|
171
374
|
this.nextGameFrameTime = this.lastTime + TARGET_FRAME_MS;
|
|
172
375
|
this.lastDomUpdateTime = 0;
|
|
173
376
|
this.lastTimingRenderTime = 0;
|
|
174
377
|
this.lastTimingRaceMode = null;
|
|
378
|
+
this.lastTimingPenaltyKey = '';
|
|
379
|
+
this.lastTimingOrderKey = '';
|
|
175
380
|
this.lastTimingMarkup = null;
|
|
381
|
+
this.lastPitLaneStatusRenderKey = null;
|
|
382
|
+
this.pitCameraBoundsCache = null;
|
|
383
|
+
this.trackCameraBoundsCache = null;
|
|
176
384
|
this.lastOverviewRenderKey = null;
|
|
177
385
|
this.lastFinishClassificationMarkup = null;
|
|
178
386
|
this.runtimeViewportVisible = true;
|
|
@@ -213,29 +421,47 @@ export class F1SimulatorApp {
|
|
|
213
421
|
this.worldLayer = new Container();
|
|
214
422
|
this.drsLayer = new Container();
|
|
215
423
|
this.trailLayer = new Graphics();
|
|
424
|
+
this.sensorLayer = new Graphics();
|
|
425
|
+
this.pitLaneStatusLayer = new Graphics();
|
|
216
426
|
this.carLayer = new Container();
|
|
217
427
|
this.app.stage.addChild(this.worldLayer);
|
|
218
428
|
|
|
219
429
|
await this.loadAssets();
|
|
220
430
|
this.trackAsset = new ProceduralTrackAsset({ textures: this.textures, world: WORLD });
|
|
221
|
-
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
|
+
);
|
|
222
439
|
this.createCars();
|
|
223
440
|
this.bindControls();
|
|
224
441
|
this.renderTrack();
|
|
225
442
|
const snapshot = this.sim.snapshot();
|
|
226
443
|
this.updateDom(snapshot);
|
|
444
|
+
this.lastDomUpdateTime = performance.now();
|
|
445
|
+
if (this.expertMode) {
|
|
446
|
+
this.expert = createBrowserExpertAdapter(this, this.options.expert);
|
|
447
|
+
}
|
|
448
|
+
this.renderInitialFrame(snapshot);
|
|
227
449
|
this.completeComponentLoading();
|
|
228
450
|
this.emitHostCallback('onLoadingChange', { loading: false, phase: 'ready' });
|
|
229
451
|
this.emitHostCallback('onReady', { snapshot });
|
|
230
|
-
this.resizeHandler = () => this.applyCamera(this.sim.snapshot());
|
|
452
|
+
this.resizeHandler = () => this.applyCamera(this.sim.snapshotRender?.() ?? this.sim.snapshot());
|
|
231
453
|
window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
|
|
232
|
-
this.tickerCallback = () => this.tick();
|
|
233
|
-
this.app.ticker.add(this.tickerCallback);
|
|
234
454
|
this.observeLayoutResize();
|
|
235
|
-
this.
|
|
455
|
+
if (!this.expertMode) {
|
|
456
|
+
this.resetFrameClock();
|
|
457
|
+
this.tickerCallback = () => this.tick();
|
|
458
|
+
this.app.ticker.add(this.tickerCallback);
|
|
459
|
+
this.observeRuntimeVisibility();
|
|
460
|
+
}
|
|
236
461
|
} catch (error) {
|
|
237
462
|
this.emitHostCallback('onLoadingChange', { loading: false, phase: 'error' });
|
|
238
463
|
this.emitHostCallback('onError', error, { phase: 'init' });
|
|
464
|
+
this.destroy();
|
|
239
465
|
throw error;
|
|
240
466
|
}
|
|
241
467
|
}
|
|
@@ -264,12 +490,13 @@ export class F1SimulatorApp {
|
|
|
264
490
|
});
|
|
265
491
|
}
|
|
266
492
|
|
|
267
|
-
createRaceSimulation() {
|
|
493
|
+
createRaceSimulation(options = this.options) {
|
|
268
494
|
return createRaceSimulation({
|
|
269
|
-
seed:
|
|
270
|
-
trackSeed: this.trackSeed,
|
|
271
|
-
drivers: this.drivers,
|
|
272
|
-
totalLaps:
|
|
495
|
+
seed: options.seed,
|
|
496
|
+
trackSeed: options.trackSeed ?? this.trackSeed,
|
|
497
|
+
drivers: options.drivers ?? this.drivers,
|
|
498
|
+
totalLaps: options.totalLaps,
|
|
499
|
+
rules: options.rules,
|
|
273
500
|
});
|
|
274
501
|
}
|
|
275
502
|
|
|
@@ -311,8 +538,10 @@ export class F1SimulatorApp {
|
|
|
311
538
|
|
|
312
539
|
this.carSprites.forEach((sprite) => sprite.destroy());
|
|
313
540
|
this.carHitAreas.forEach((hit) => hit.destroy());
|
|
541
|
+
this.serviceCountdownLabels.forEach((label) => label.destroy({ children: true }));
|
|
314
542
|
this.carSprites.clear();
|
|
315
543
|
this.carHitAreas.clear();
|
|
544
|
+
this.serviceCountdownLabels.clear();
|
|
316
545
|
this.drsTrails.clear();
|
|
317
546
|
|
|
318
547
|
if (this.safetySprite) {
|
|
@@ -321,11 +550,17 @@ export class F1SimulatorApp {
|
|
|
321
550
|
}
|
|
322
551
|
|
|
323
552
|
this.drivers.forEach((driver) => {
|
|
324
|
-
const sprite =
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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);
|
|
329
564
|
sprite.eventMode = 'static';
|
|
330
565
|
sprite.cursor = 'pointer';
|
|
331
566
|
sprite.on('pointerdown', () => {
|
|
@@ -343,7 +578,83 @@ export class F1SimulatorApp {
|
|
|
343
578
|
});
|
|
344
579
|
this.carHitAreas.set(driver.id, hit);
|
|
345
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);
|
|
586
|
+
});
|
|
587
|
+
}
|
|
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
|
+
},
|
|
346
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;
|
|
347
658
|
}
|
|
348
659
|
|
|
349
660
|
bindControls() {
|
|
@@ -365,27 +676,38 @@ export class F1SimulatorApp {
|
|
|
365
676
|
this.trailLayer?.clear();
|
|
366
677
|
this.lastTimingRenderTime = 0;
|
|
367
678
|
this.lastTimingRaceMode = null;
|
|
679
|
+
this.lastTimingPenaltyKey = '';
|
|
680
|
+
this.lastTimingOrderKey = '';
|
|
368
681
|
this.renderTrack();
|
|
369
682
|
}, eventOptions);
|
|
370
683
|
|
|
371
684
|
this.cameraButtons.forEach((button) => {
|
|
372
685
|
button.addEventListener('click', () => {
|
|
373
|
-
this.
|
|
374
|
-
if (CAMERA_PRESETS[this.camera.mode]) this.camera.zoom = CAMERA_PRESETS[this.camera.mode];
|
|
375
|
-
this.updateCameraControls();
|
|
686
|
+
this.setCameraMode(button.dataset.cameraMode);
|
|
376
687
|
}, eventOptions);
|
|
377
688
|
});
|
|
378
689
|
|
|
379
690
|
this.zoomInButton?.addEventListener('click', () => {
|
|
380
|
-
this.
|
|
381
|
-
this.updateCameraControls();
|
|
691
|
+
this.adjustCameraZoom(1);
|
|
382
692
|
}, eventOptions);
|
|
383
693
|
|
|
384
694
|
this.zoomOutButton?.addEventListener('click', () => {
|
|
385
|
-
this.
|
|
386
|
-
this.updateCameraControls();
|
|
695
|
+
this.adjustCameraZoom(-1);
|
|
387
696
|
}, eventOptions);
|
|
388
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
|
+
|
|
389
711
|
this.overviewModeButtons.forEach((button) => {
|
|
390
712
|
button.addEventListener('click', () => {
|
|
391
713
|
this.overviewMode = button.dataset.overviewMode === 'driver' ? 'driver' : 'vehicle';
|
|
@@ -399,7 +721,7 @@ export class F1SimulatorApp {
|
|
|
399
721
|
this.timingGapMode = button.dataset.timingGapMode === 'leader' ? 'leader' : 'interval';
|
|
400
722
|
this.syncTimingGapModeControls();
|
|
401
723
|
const snapshot = this.sim?.snapshot?.();
|
|
402
|
-
if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode);
|
|
724
|
+
if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
|
|
403
725
|
}, eventOptions);
|
|
404
726
|
});
|
|
405
727
|
|
|
@@ -415,13 +737,61 @@ export class F1SimulatorApp {
|
|
|
415
737
|
this.options.onDriverOpen?.(driver);
|
|
416
738
|
}, eventOptions);
|
|
417
739
|
|
|
740
|
+
this.readouts.raceDataDismiss?.addEventListener('click', (event) => {
|
|
741
|
+
event.preventDefault();
|
|
742
|
+
event.stopPropagation();
|
|
743
|
+
this.dismissRaceDataPanel(performance.now());
|
|
744
|
+
}, eventOptions);
|
|
745
|
+
|
|
418
746
|
this.readouts.telemetryDrawerToggle?.addEventListener('click', () => {
|
|
419
747
|
this.setTelemetryDrawerOpen(!this.telemetryDrawerOpen);
|
|
420
748
|
}, eventOptions);
|
|
421
749
|
|
|
422
|
-
this.
|
|
423
|
-
|
|
424
|
-
|
|
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 });
|
|
425
795
|
}
|
|
426
796
|
|
|
427
797
|
setTelemetryDrawerOpen(open) {
|
|
@@ -439,12 +809,73 @@ export class F1SimulatorApp {
|
|
|
439
809
|
this.readouts.telemetryDrawerToggle.textContent = this.telemetryDrawerOpen ? 'Close telemetry' : 'Telemetry';
|
|
440
810
|
}
|
|
441
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);
|
|
442
870
|
}
|
|
443
871
|
|
|
444
872
|
observeLayoutResize() {
|
|
445
873
|
if (typeof ResizeObserver !== 'function') return;
|
|
446
874
|
this.layoutResizeObserver?.disconnect?.();
|
|
447
|
-
this.layoutResizeObserver = new ResizeObserver(() =>
|
|
875
|
+
this.layoutResizeObserver = new ResizeObserver(() => {
|
|
876
|
+
this.invalidateCameraSafeArea();
|
|
877
|
+
this.syncRendererToCurrentLayout({ render: true });
|
|
878
|
+
});
|
|
448
879
|
if (this.canvasHost) this.layoutResizeObserver.observe(this.canvasHost);
|
|
449
880
|
if (this.readouts.timingTower) this.layoutResizeObserver.observe(this.readouts.timingTower);
|
|
450
881
|
}
|
|
@@ -484,6 +915,7 @@ export class F1SimulatorApp {
|
|
|
484
915
|
}
|
|
485
916
|
|
|
486
917
|
syncRuntimeTicker() {
|
|
918
|
+
if (this.expertMode) return;
|
|
487
919
|
if (!this.app?.ticker) return;
|
|
488
920
|
|
|
489
921
|
const shouldRun = this.runtimeViewportVisible && this.runtimeDocumentVisible;
|
|
@@ -502,11 +934,13 @@ export class F1SimulatorApp {
|
|
|
502
934
|
this.accumulator = 0;
|
|
503
935
|
this.lastTime = now;
|
|
504
936
|
this.nextGameFrameTime = now + TARGET_FRAME_MS;
|
|
937
|
+
this.fps.current = 0;
|
|
505
938
|
this.fps.frames = 0;
|
|
506
939
|
this.fps.lastSample = now;
|
|
507
940
|
}
|
|
508
941
|
|
|
509
942
|
tick() {
|
|
943
|
+
if (this.expertMode) return;
|
|
510
944
|
const now = performance.now();
|
|
511
945
|
if (now < this.nextGameFrameTime - FRAME_PACING_EPSILON_MS) return;
|
|
512
946
|
|
|
@@ -522,13 +956,16 @@ export class F1SimulatorApp {
|
|
|
522
956
|
}
|
|
523
957
|
this.lastTime = now;
|
|
524
958
|
this.sampleFps(now);
|
|
525
|
-
this.accumulator += frameSeconds *
|
|
959
|
+
this.accumulator += frameSeconds * this.simulationSpeed;
|
|
526
960
|
|
|
527
961
|
let simulationSteps = 0;
|
|
528
|
-
|
|
962
|
+
const stepEvents = [];
|
|
963
|
+
const maxSimulationSteps = maxSimulationStepsForFrame(this.simulationSpeed, elapsedFrameCount);
|
|
964
|
+
while (this.accumulator >= FIXED_STEP && simulationSteps < maxSimulationSteps) {
|
|
529
965
|
this.sim.step(FIXED_STEP);
|
|
530
|
-
|
|
531
|
-
|
|
966
|
+
if (Array.isArray(this.sim.events) && this.sim.events.length > 0) {
|
|
967
|
+
stepEvents.push(...this.sim.events);
|
|
968
|
+
}
|
|
532
969
|
this.accumulator -= FIXED_STEP;
|
|
533
970
|
simulationSteps += 1;
|
|
534
971
|
}
|
|
@@ -538,19 +975,40 @@ export class F1SimulatorApp {
|
|
|
538
975
|
this.nextGameFrameTime = now + TARGET_FRAME_MS;
|
|
539
976
|
}
|
|
540
977
|
|
|
541
|
-
const
|
|
542
|
-
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));
|
|
543
983
|
this.applyCamera(renderSnapshot);
|
|
544
984
|
this.renderDrsTrails(renderSnapshot);
|
|
985
|
+
this.renderPitLaneStatus(renderSnapshot);
|
|
545
986
|
this.renderCars(renderSnapshot);
|
|
546
|
-
if (
|
|
547
|
-
this.updateDom(
|
|
987
|
+
if (shouldUpdateDom) {
|
|
988
|
+
this.updateDom(fullSnapshot, { emitLifecycle: false });
|
|
548
989
|
this.lastDomUpdateTime = now;
|
|
549
990
|
}
|
|
550
991
|
}
|
|
551
992
|
|
|
993
|
+
renderExpertFrame(snapshot = this.sim?.snapshot(), { observation } = {}) {
|
|
994
|
+
if (!snapshot) return;
|
|
995
|
+
const renderSnapshot = createRenderSnapshot(snapshot, 0);
|
|
996
|
+
this.applyCamera(renderSnapshot);
|
|
997
|
+
this.renderDrsTrails(renderSnapshot);
|
|
998
|
+
this.renderExpertSensorRays(renderSnapshot, observation);
|
|
999
|
+
this.renderPitLaneStatus(renderSnapshot);
|
|
1000
|
+
this.renderCars(renderSnapshot);
|
|
1001
|
+
this.updateDom(snapshot, { emitLifecycle: false });
|
|
1002
|
+
this.lastDomUpdateTime = performance.now();
|
|
1003
|
+
}
|
|
1004
|
+
|
|
552
1005
|
renderTrack() {
|
|
553
|
-
this.drsLayer
|
|
1006
|
+
destroyDisplayChildren(this.drsLayer);
|
|
1007
|
+
this.sensorLayer?.clear();
|
|
1008
|
+
this.pitLaneStatusLayer?.clear();
|
|
1009
|
+
this.lastPitLaneStatusRenderKey = null;
|
|
1010
|
+
this.pitCameraBoundsCache = null;
|
|
1011
|
+
this.trackCameraBoundsCache = null;
|
|
554
1012
|
const snapshot = this.sim.snapshot();
|
|
555
1013
|
const track = snapshot.track;
|
|
556
1014
|
|
|
@@ -570,20 +1028,70 @@ export class F1SimulatorApp {
|
|
|
570
1028
|
});
|
|
571
1029
|
}
|
|
572
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
|
+
|
|
573
1070
|
renderCars(snapshot) {
|
|
574
1071
|
snapshot.cars.forEach((car) => {
|
|
575
1072
|
const sprite = this.carSprites.get(car.id);
|
|
576
1073
|
const hit = this.carHitAreas.get(car.id);
|
|
577
1074
|
if (!sprite || !hit) return;
|
|
578
|
-
sprite.x = car.x;
|
|
579
|
-
sprite.y = car.y;
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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);
|
|
587
1095
|
});
|
|
588
1096
|
|
|
589
1097
|
if (!this.safetySprite && snapshot.safetyCar.deployed) {
|
|
@@ -599,58 +1107,149 @@ export class F1SimulatorApp {
|
|
|
599
1107
|
}
|
|
600
1108
|
|
|
601
1109
|
if (this.safetySprite) {
|
|
602
|
-
this.safetySprite.visible
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
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;
|
|
606
1116
|
}
|
|
607
1117
|
}
|
|
608
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;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
renderExpertSensorRays(snapshot, observation) {
|
|
1143
|
+
if (!this.sensorLayer) return;
|
|
1144
|
+
this.sensorLayer.clear();
|
|
1145
|
+
if (!this.expertMode || !expertVisualizesRays(this.options.expert)) return;
|
|
1146
|
+
|
|
1147
|
+
const controlledDrivers = this.options.expert?.controlledDrivers ?? [];
|
|
1148
|
+
if (!controlledDrivers.length || !observation) return;
|
|
1149
|
+
|
|
1150
|
+
const carsById = new Map(snapshot.cars.map((car) => [car.id, car]));
|
|
1151
|
+
controlledDrivers.forEach((driverId) => {
|
|
1152
|
+
const car = carsById.get(driverId);
|
|
1153
|
+
const rays = observation?.[driverId]?.object?.rays;
|
|
1154
|
+
if (!car || !Array.isArray(rays) || rays.length === 0) return;
|
|
1155
|
+
|
|
1156
|
+
const origin = getCarRayOrigin(car);
|
|
1157
|
+
|
|
1158
|
+
rays.forEach((ray) => {
|
|
1159
|
+
const rayVector = getCarRayVector(car, Number(ray.angleDegrees) || 0);
|
|
1160
|
+
const totalDistanceMeters = Math.max(0, Number(ray.lengthMeters) || 0);
|
|
1161
|
+
const trackDistanceMeters = Math.max(0, Number(ray.track?.distanceMeters) || totalDistanceMeters);
|
|
1162
|
+
const carDistanceMeters = Math.max(0, Number(ray.car?.distanceMeters) || totalDistanceMeters);
|
|
1163
|
+
const trackHit = Boolean(ray.track?.hit) && trackDistanceMeters <= totalDistanceMeters;
|
|
1164
|
+
const carHit = Boolean(ray.car?.hit) && carDistanceMeters <= totalDistanceMeters;
|
|
1165
|
+
const fullEnd = pointFromRay(origin, rayVector, metersToSimUnits(totalDistanceMeters));
|
|
1166
|
+
|
|
1167
|
+
this.sensorLayer
|
|
1168
|
+
.moveTo(origin.x, origin.y)
|
|
1169
|
+
.lineTo(fullEnd.x, fullEnd.y)
|
|
1170
|
+
.stroke({ width: 2, color: 0xffffff, alpha: 0.18, cap: 'round' });
|
|
1171
|
+
|
|
1172
|
+
if (!trackHit && !carHit) return;
|
|
1173
|
+
|
|
1174
|
+
const carIsClosest = carHit && (!trackHit || carDistanceMeters <= trackDistanceMeters);
|
|
1175
|
+
const hitDistanceMeters = carIsClosest ? carDistanceMeters : trackDistanceMeters;
|
|
1176
|
+
const hitEnd = pointFromRay(origin, rayVector, metersToSimUnits(hitDistanceMeters));
|
|
1177
|
+
const hitColor = carIsClosest
|
|
1178
|
+
? SENSOR_RAY_CAR_COLOR
|
|
1179
|
+
: ray.track?.kind === 'entry'
|
|
1180
|
+
? SENSOR_RAY_TRACK_ENTRY_COLOR
|
|
1181
|
+
: SENSOR_RAY_TRACK_COLOR;
|
|
1182
|
+
|
|
1183
|
+
this.sensorLayer
|
|
1184
|
+
.moveTo(origin.x, origin.y)
|
|
1185
|
+
.lineTo(hitEnd.x, hitEnd.y)
|
|
1186
|
+
.stroke({ width: carIsClosest ? 5 : 3.4, color: hitColor, alpha: carIsClosest ? 0.9 : 0.76, cap: 'round' });
|
|
1187
|
+
this.sensorLayer
|
|
1188
|
+
.circle(hitEnd.x, hitEnd.y, carIsClosest ? 7 : 5)
|
|
1189
|
+
.fill({ color: hitColor, alpha: 0.92 })
|
|
1190
|
+
.circle(hitEnd.x, hitEnd.y, carIsClosest ? 10 : 8)
|
|
1191
|
+
.stroke({ width: 1.5, color: 0x10131a, alpha: 0.72 });
|
|
1192
|
+
});
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
609
1196
|
renderDrsTrails(snapshot) {
|
|
610
1197
|
if (!this.trailLayer) return;
|
|
611
1198
|
|
|
612
1199
|
snapshot.cars.forEach((car) => {
|
|
613
|
-
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 ?? [];
|
|
614
1203
|
const rear = {
|
|
615
1204
|
x: car.x - Math.cos(car.heading) * CAR_WORLD_LENGTH * 0.46,
|
|
616
1205
|
y: car.y - Math.sin(car.heading) * CAR_WORLD_LENGTH * 0.46,
|
|
617
1206
|
at: snapshot.time,
|
|
618
1207
|
};
|
|
619
|
-
const last =
|
|
1208
|
+
const last = activeHistory[activeHistory.length - 1];
|
|
620
1209
|
|
|
621
1210
|
if (
|
|
622
1211
|
car.drsActive &&
|
|
623
1212
|
(!last || Math.hypot(rear.x - last.x, rear.y - last.y) >= DRS_TRAIL_MIN_DISTANCE)
|
|
624
1213
|
) {
|
|
625
|
-
|
|
1214
|
+
activeHistory.push(rear);
|
|
626
1215
|
}
|
|
627
1216
|
|
|
628
|
-
|
|
629
|
-
if (
|
|
1217
|
+
pruneExpiredTrailPoints(activeHistory, snapshot.time);
|
|
1218
|
+
if (activeHistory.length) this.drsTrails.set(car.id, activeHistory);
|
|
630
1219
|
else this.drsTrails.delete(car.id);
|
|
631
1220
|
});
|
|
632
1221
|
|
|
633
1222
|
this.trailLayer.clear();
|
|
634
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);
|
|
635
1228
|
for (let index = 1; index < history.length; index += 1) {
|
|
636
|
-
|
|
637
|
-
const point = history[index];
|
|
638
|
-
const age = snapshot.time - point.at;
|
|
639
|
-
const life = clamp(1 - age / DRS_TRAIL_TTL, 0, 1);
|
|
640
|
-
this.trailLayer.moveTo(previous.x, previous.y);
|
|
641
|
-
this.trailLayer.lineTo(point.x, point.y);
|
|
642
|
-
this.trailLayer.stroke({
|
|
643
|
-
width: 5 + life * 7,
|
|
644
|
-
color: 0x3be8ff,
|
|
645
|
-
alpha: 0.22 + life * 0.48,
|
|
646
|
-
cap: 'round',
|
|
647
|
-
join: 'round',
|
|
648
|
-
});
|
|
1229
|
+
this.trailLayer.lineTo(history[index].x, history[index].y);
|
|
649
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
|
+
});
|
|
650
1249
|
});
|
|
651
1250
|
}
|
|
652
1251
|
|
|
653
|
-
applyCamera(snapshot) {
|
|
1252
|
+
applyCamera(snapshot, { immediate = false } = {}) {
|
|
654
1253
|
if (!this.worldLayer) return;
|
|
655
1254
|
|
|
656
1255
|
const width = this.canvasHost.clientWidth || 900;
|
|
@@ -660,11 +1259,17 @@ export class F1SimulatorApp {
|
|
|
660
1259
|
const frame = this.getCameraFrame(snapshot, width, height, baseScale, safeArea);
|
|
661
1260
|
const scale = frame.scale;
|
|
662
1261
|
const target = frame.target;
|
|
663
|
-
|
|
664
|
-
this.camera.
|
|
665
|
-
|
|
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
|
|
666
1270
|
? scale
|
|
667
|
-
: this.camera.scale + (scale - this.camera.scale) *
|
|
1271
|
+
: this.camera.scale + (scale - this.camera.scale) * CAMERA_SCALE_LERP;
|
|
1272
|
+
this.camera.initialized = true;
|
|
668
1273
|
this.camera.scale = activeScale;
|
|
669
1274
|
this.worldLayer.scale.set(activeScale);
|
|
670
1275
|
this.worldLayer.position.set(
|
|
@@ -713,6 +1318,28 @@ export class F1SimulatorApp {
|
|
|
713
1318
|
|
|
714
1319
|
getCameraFrame(snapshot, width, height, baseScale, safeArea = { left: 0, width }) {
|
|
715
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
|
+
}
|
|
716
1343
|
|
|
717
1344
|
if (this.camera.mode === 'show-all') {
|
|
718
1345
|
const bounds = snapshot.cars.reduce((box, car) => ({
|
|
@@ -733,30 +1360,44 @@ export class F1SimulatorApp {
|
|
|
733
1360
|
const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX + SHOW_ALL_PADDING);
|
|
734
1361
|
const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY + SHOW_ALL_PADDING);
|
|
735
1362
|
const safeHeight = Math.max(height * 0.48, height - SHOW_ALL_TOP_RESERVED - SHOW_ALL_BOTTOM_RESERVED);
|
|
736
|
-
const
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
baseScale * SHOW_ALL_MAX_ZOOM,
|
|
740
|
-
);
|
|
741
|
-
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({
|
|
742
1366
|
target,
|
|
743
1367
|
scale,
|
|
744
1368
|
screenX: screenCenterX,
|
|
745
|
-
screenY:
|
|
746
|
-
};
|
|
1369
|
+
screenY: height / 2,
|
|
1370
|
+
});
|
|
747
1371
|
}
|
|
748
1372
|
|
|
749
|
-
|
|
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({
|
|
750
1380
|
target: this.getCameraTarget(snapshot),
|
|
751
|
-
scale: baseScale * this.camera.zoom,
|
|
1381
|
+
scale: clampCameraScale(baseScale * this.camera.zoom, baseScale, trackFitScale),
|
|
752
1382
|
screenX: screenCenterX,
|
|
753
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,
|
|
754
1392
|
};
|
|
755
1393
|
}
|
|
756
1394
|
|
|
757
1395
|
getCameraTarget(snapshot) {
|
|
758
|
-
if (this.camera.
|
|
759
|
-
|
|
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);
|
|
760
1401
|
}
|
|
761
1402
|
|
|
762
1403
|
if (this.camera.mode === 'selected') {
|
|
@@ -768,6 +1409,145 @@ export class F1SimulatorApp {
|
|
|
768
1409
|
return leader ? { x: leader.x, y: leader.y } : { x: WORLD.width / 2, y: WORLD.height / 2 };
|
|
769
1410
|
}
|
|
770
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
|
+
|
|
771
1551
|
updateDom(snapshot, { emitLifecycle = true } = {}) {
|
|
772
1552
|
const leader = snapshot.cars[0];
|
|
773
1553
|
const selected = snapshot.cars.find((car) => car.id === this.selectedId) ?? leader;
|
|
@@ -777,20 +1557,28 @@ export class F1SimulatorApp {
|
|
|
777
1557
|
|
|
778
1558
|
if (emitLifecycle) this.emitSnapshotLifecycle(snapshot);
|
|
779
1559
|
|
|
780
|
-
if (
|
|
1560
|
+
if (
|
|
1561
|
+
this.shouldAutoHideActiveRaceData() &&
|
|
1562
|
+
now - this.lastRaceDataInteraction > RACE_DATA_SELECTED_VISIBLE_MS
|
|
1563
|
+
) {
|
|
781
1564
|
this.activeRaceDataId = null;
|
|
782
1565
|
if (this.isRaceDataBannerEnabled('radio')) this.scheduleRadioBreak(now);
|
|
783
1566
|
}
|
|
1567
|
+
this.updateStewardMessageState(snapshot, now);
|
|
784
1568
|
|
|
785
1569
|
if (this.readouts.mode) {
|
|
786
1570
|
const modeLabel = snapshot.raceControl.mode === 'safety-car'
|
|
787
1571
|
? 'SC'
|
|
1572
|
+
: snapshot.raceControl.mode === 'red-flag'
|
|
1573
|
+
? 'RED'
|
|
788
1574
|
: snapshot.raceControl.finished
|
|
789
1575
|
? 'FINISH'
|
|
790
1576
|
: 'GREEN';
|
|
791
1577
|
this.readouts.mode.textContent = modeLabel;
|
|
792
1578
|
this.readouts.mode.style.color = snapshot.raceControl.mode === 'safety-car'
|
|
793
1579
|
? 'var(--yellow)'
|
|
1580
|
+
: snapshot.raceControl.mode === 'red-flag'
|
|
1581
|
+
? 'var(--race-control-red)'
|
|
794
1582
|
: snapshot.raceControl.mode === 'finished'
|
|
795
1583
|
? 'var(--red)'
|
|
796
1584
|
: 'var(--green)';
|
|
@@ -801,13 +1589,26 @@ export class F1SimulatorApp {
|
|
|
801
1589
|
if (this.readouts.towerTotalLaps) this.readouts.towerTotalLaps.textContent = snapshot.totalLaps;
|
|
802
1590
|
if (this.readouts.timingTower) {
|
|
803
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');
|
|
804
1593
|
this.readouts.timingTower.classList.toggle('is-pre-start', snapshot.raceControl.mode === 'pre-start');
|
|
805
1594
|
}
|
|
806
|
-
if (this.readouts.
|
|
807
|
-
|
|
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
|
+
}
|
|
808
1609
|
}
|
|
809
1610
|
if (this.readouts.drs) {
|
|
810
|
-
this.readouts.drs.textContent = snapshot.raceControl.mode
|
|
1611
|
+
this.readouts.drs.textContent = ['safety-car', 'red-flag'].includes(snapshot.raceControl.mode)
|
|
811
1612
|
? 'DISABLED'
|
|
812
1613
|
: activeDrs
|
|
813
1614
|
? `${activeDrs} OPEN`
|
|
@@ -816,30 +1617,37 @@ export class F1SimulatorApp {
|
|
|
816
1617
|
if (this.readouts.contacts) this.readouts.contacts.textContent = String(contactCount);
|
|
817
1618
|
this.renderStartLights(snapshot.raceControl);
|
|
818
1619
|
if (this.readouts.camera) {
|
|
819
|
-
const zoom = this.camera.
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
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}%`;
|
|
823
1623
|
}
|
|
824
1624
|
if (this.readouts.fps) {
|
|
825
1625
|
this.readouts.fps.textContent = this.fps.current ? `${this.fps.current}` : '--';
|
|
826
1626
|
}
|
|
827
1627
|
this.renderRaceFinish(snapshot);
|
|
828
1628
|
|
|
829
|
-
this.updateCameraControls();
|
|
1629
|
+
this.updateCameraControls(snapshot);
|
|
830
1630
|
this.syncTimingGapModeControls();
|
|
1631
|
+
const timingPenaltyKey = this.getTimingPenaltyKey(snapshot.penalties ?? []);
|
|
1632
|
+
const timingOrderKey = this.getTimingOrderKey(snapshot.cars);
|
|
1633
|
+
const timingUpdateInterval = timingUpdateIntervalForSpeed(this.simulationSpeed);
|
|
831
1634
|
if (
|
|
832
|
-
now - this.lastTimingRenderTime >=
|
|
833
|
-
this.lastTimingRaceMode !== snapshot.raceControl.mode
|
|
1635
|
+
now - this.lastTimingRenderTime >= timingUpdateInterval ||
|
|
1636
|
+
this.lastTimingRaceMode !== snapshot.raceControl.mode ||
|
|
1637
|
+
this.lastTimingPenaltyKey !== timingPenaltyKey ||
|
|
1638
|
+
this.lastTimingOrderKey !== timingOrderKey
|
|
834
1639
|
) {
|
|
835
|
-
this.renderTiming(snapshot.cars, snapshot.raceControl.mode);
|
|
1640
|
+
this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
|
|
836
1641
|
this.lastTimingRenderTime = now;
|
|
837
1642
|
this.lastTimingRaceMode = snapshot.raceControl.mode;
|
|
1643
|
+
this.lastTimingPenaltyKey = timingPenaltyKey;
|
|
1644
|
+
this.lastTimingOrderKey = timingOrderKey;
|
|
838
1645
|
}
|
|
839
1646
|
this.renderTelemetry(selected);
|
|
840
1647
|
const activeRaceDataCar = this.activeRaceDataId
|
|
841
1648
|
? snapshot.cars.find((car) => car.id === this.activeRaceDataId)
|
|
842
1649
|
: null;
|
|
1650
|
+
this.renderActiveStewardMessage();
|
|
843
1651
|
if (activeRaceDataCar) {
|
|
844
1652
|
this.renderRaceData(activeRaceDataCar);
|
|
845
1653
|
} else {
|
|
@@ -862,12 +1670,7 @@ export class F1SimulatorApp {
|
|
|
862
1670
|
this.lastLeaderLap = leaderLap;
|
|
863
1671
|
}
|
|
864
1672
|
|
|
865
|
-
snapshot.events
|
|
866
|
-
const key = `${event.type}:${event.at ?? snapshot.time}:${event.carId ?? ''}:${event.otherCarId ?? ''}:${event.winnerId ?? ''}`;
|
|
867
|
-
if (this.emittedRaceEventKeys.has(key)) return;
|
|
868
|
-
this.emittedRaceEventKeys.add(key);
|
|
869
|
-
this.emitHostCallback('onRaceEvent', event, snapshot);
|
|
870
|
-
});
|
|
1673
|
+
this.emitRaceEvents(snapshot.events, snapshot);
|
|
871
1674
|
|
|
872
1675
|
if (snapshot.raceControl.finished && !this.raceFinishEmitted) {
|
|
873
1676
|
this.raceFinishEmitted = true;
|
|
@@ -879,6 +1682,15 @@ export class F1SimulatorApp {
|
|
|
879
1682
|
}
|
|
880
1683
|
}
|
|
881
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
|
+
|
|
882
1694
|
renderRaceFinish(snapshot) {
|
|
883
1695
|
const panel = this.readouts.finishPanel;
|
|
884
1696
|
if (!panel) return;
|
|
@@ -950,9 +1762,7 @@ export class F1SimulatorApp {
|
|
|
950
1762
|
this.lastRaceDataInteraction = performance.now();
|
|
951
1763
|
if (this.isRaceDataBannerEnabled('radio')) this.scheduleRadioBreak(this.lastRaceDataInteraction);
|
|
952
1764
|
if (focus) {
|
|
953
|
-
this.
|
|
954
|
-
this.camera.zoom = CAMERA_PRESETS.selected;
|
|
955
|
-
this.updateCameraControls();
|
|
1765
|
+
this.setCameraMode('selected');
|
|
956
1766
|
}
|
|
957
1767
|
this.lastTimingRenderTime = 0;
|
|
958
1768
|
const snapshot = this.sim.snapshot();
|
|
@@ -961,13 +1771,16 @@ export class F1SimulatorApp {
|
|
|
961
1771
|
if (driver) this.emitHostCallback('onDriverSelect', driver, snapshot);
|
|
962
1772
|
}
|
|
963
1773
|
|
|
964
|
-
renderTiming(cars, raceMode) {
|
|
1774
|
+
renderTiming(cars, raceMode, penalties = []) {
|
|
965
1775
|
if (!this.timingList) return;
|
|
966
1776
|
this.syncTimingGapModeControls();
|
|
1777
|
+
const penaltyByDriver = this.timingPenaltyBadgesEnabled ? this.getPenaltyByDriver(penalties) : new Map();
|
|
967
1778
|
const timingMarkup = cars.map((car) => {
|
|
968
1779
|
const driver = this.driverById.get(car.id);
|
|
969
1780
|
let gap = 'Leader';
|
|
970
|
-
if (raceMode
|
|
1781
|
+
if (this.isWavedFlagCar(car) && raceMode !== 'finished') {
|
|
1782
|
+
gap = 'WAVED';
|
|
1783
|
+
} else if (raceMode === 'finished') {
|
|
971
1784
|
gap = car.rank === 1 ? 'Winner' : 'FIN';
|
|
972
1785
|
} else if (raceMode === 'safety-car' && car.rank > 1) {
|
|
973
1786
|
gap = 'SC';
|
|
@@ -981,6 +1794,10 @@ export class F1SimulatorApp {
|
|
|
981
1794
|
const team = car.team ?? driver?.team ?? null;
|
|
982
1795
|
const icon = team?.icon ?? car.icon ?? driver?.icon ?? timingCode;
|
|
983
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
|
+
: '';
|
|
984
1801
|
|
|
985
1802
|
return `
|
|
986
1803
|
<li>
|
|
@@ -989,7 +1806,7 @@ export class F1SimulatorApp {
|
|
|
989
1806
|
style="--driver-color: ${escapeHtml(car.color)}">
|
|
990
1807
|
<span class="timing-position">${car.rank}</span>
|
|
991
1808
|
<span class="timing-icon timing-team-icon" aria-hidden="true" style="--team-color: ${escapeHtml(iconColor)}">${escapeHtml(icon)}</span>
|
|
992
|
-
<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>
|
|
993
1810
|
<span class="timing-gap">${escapeHtml(gap)}</span>
|
|
994
1811
|
<span class="timing-tire timing-tire--${getTireClass(tire)}">${escapeHtml(tire)}</span>
|
|
995
1812
|
</button>
|
|
@@ -1002,13 +1819,81 @@ export class F1SimulatorApp {
|
|
|
1002
1819
|
}
|
|
1003
1820
|
}
|
|
1004
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
|
+
|
|
1005
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
|
+
|
|
1006
1874
|
const value = this.timingGapMode === 'leader'
|
|
1007
1875
|
? car.leaderGapSeconds
|
|
1008
1876
|
: (car.intervalAheadSeconds ?? car.gapAheadSeconds);
|
|
1009
1877
|
return Number.isFinite(value) ? `+${Math.max(0, value).toFixed(3)}` : '--';
|
|
1010
1878
|
}
|
|
1011
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
|
+
|
|
1012
1897
|
syncTimingGapModeControls() {
|
|
1013
1898
|
const label = this.timingGapMode === 'leader' ? 'Gap' : 'Int';
|
|
1014
1899
|
setText(this.readouts.timingGapLabel, label);
|
|
@@ -1043,15 +1928,15 @@ export class F1SimulatorApp {
|
|
|
1043
1928
|
setTextAll(this.readouts.surface, surface);
|
|
1044
1929
|
setTextAll(
|
|
1045
1930
|
this.readouts.gap,
|
|
1046
|
-
car.rank === 1
|
|
1931
|
+
car.rank === 1
|
|
1047
1932
|
? '--'
|
|
1048
|
-
:
|
|
1933
|
+
: this.formatTelemetryGap(car, 'interval'),
|
|
1049
1934
|
);
|
|
1050
1935
|
setTextAll(
|
|
1051
1936
|
this.readouts.leaderGap,
|
|
1052
|
-
car.rank === 1
|
|
1937
|
+
car.rank === 1
|
|
1053
1938
|
? '--'
|
|
1054
|
-
:
|
|
1939
|
+
: this.formatTelemetryGap(car, 'leader'),
|
|
1055
1940
|
);
|
|
1056
1941
|
|
|
1057
1942
|
this.renderCarDriverOverview(car);
|
|
@@ -1070,28 +1955,50 @@ export class F1SimulatorApp {
|
|
|
1070
1955
|
this.readouts.telemetrySectorBars?.forEach((bar) => {
|
|
1071
1956
|
const sector = Number(bar.dataset.telemetrySectorBar);
|
|
1072
1957
|
const index = sector - 1;
|
|
1073
|
-
const
|
|
1074
|
-
const
|
|
1075
|
-
const
|
|
1076
|
-
|
|
1077
|
-
|
|
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;
|
|
1078
1973
|
const fillValue = `${fill.toFixed(1)}%`;
|
|
1079
1974
|
if (bar.style.getPropertyValue('--sector-fill') !== fillValue) {
|
|
1080
1975
|
bar.style.setProperty('--sector-fill', fillValue);
|
|
1081
1976
|
}
|
|
1082
1977
|
bar.classList.toggle('is-active', isActive);
|
|
1083
1978
|
bar.classList.toggle('is-complete', sectorComplete);
|
|
1084
|
-
setPerformanceClass(bar, telemetry.sectorPerformance?.current?.[index]);
|
|
1979
|
+
setPerformanceClass(bar, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
|
|
1085
1980
|
});
|
|
1086
1981
|
|
|
1087
1982
|
this.readouts.telemetrySectorTimes?.forEach((node) => {
|
|
1088
1983
|
const sector = Number(node.dataset.telemetrySectorTime);
|
|
1089
1984
|
const index = sector - 1;
|
|
1090
|
-
const
|
|
1091
|
-
|
|
1092
|
-
|
|
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;
|
|
1093
2000
|
setText(node, formatTelemetryTime(value));
|
|
1094
|
-
setPerformanceClass(node, telemetry.sectorPerformance?.current?.[index]);
|
|
2001
|
+
setPerformanceClass(node, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
|
|
1095
2002
|
});
|
|
1096
2003
|
|
|
1097
2004
|
this.readouts.telemetrySectorLast?.forEach((node) => {
|
|
@@ -1213,6 +2120,17 @@ export class F1SimulatorApp {
|
|
|
1213
2120
|
}
|
|
1214
2121
|
}
|
|
1215
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
|
+
|
|
1216
2134
|
renderProjectRadio(now = performance.now()) {
|
|
1217
2135
|
if (!this.readouts.raceDataPanel) return;
|
|
1218
2136
|
if (!this.isRaceDataBannerEnabled('radio')) {
|
|
@@ -1247,6 +2165,96 @@ export class F1SimulatorApp {
|
|
|
1247
2165
|
if (this.readouts.raceDataOpen) this.readouts.raceDataOpen.hidden = true;
|
|
1248
2166
|
}
|
|
1249
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
|
+
|
|
1250
2258
|
updateRadioSchedule(now) {
|
|
1251
2259
|
if (!this.isRaceDataBannerEnabled('radio')) {
|
|
1252
2260
|
this.radioState.visible = false;
|
|
@@ -1320,7 +2328,7 @@ export class F1SimulatorApp {
|
|
|
1320
2328
|
}
|
|
1321
2329
|
|
|
1322
2330
|
isRaceDataBannerEnabled(kind) {
|
|
1323
|
-
return this.raceDataBannerConfig.enabled?.includes(kind) ?? true;
|
|
2331
|
+
return !this.raceDataBannersMuted && (this.raceDataBannerConfig.enabled?.includes(kind) ?? true);
|
|
1324
2332
|
}
|
|
1325
2333
|
|
|
1326
2334
|
getProjectRadioQuote() {
|
|
@@ -1334,12 +2342,41 @@ export class F1SimulatorApp {
|
|
|
1334
2342
|
};
|
|
1335
2343
|
}
|
|
1336
2344
|
|
|
1337
|
-
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
|
+
|
|
1338
2351
|
this.cameraButtons.forEach((button) => {
|
|
1339
|
-
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;
|
|
1340
2361
|
button.classList.toggle('is-active', isActive);
|
|
1341
2362
|
button.setAttribute('aria-pressed', String(isActive));
|
|
1342
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);
|
|
1343
2380
|
}
|
|
1344
2381
|
|
|
1345
2382
|
updateOverviewModeButtons() {
|
|
@@ -1357,7 +2394,7 @@ export class F1SimulatorApp {
|
|
|
1357
2394
|
});
|
|
1358
2395
|
}
|
|
1359
2396
|
|
|
1360
|
-
|
|
2397
|
+
applyExpertOptions(nextOptions = {}) {
|
|
1361
2398
|
this.options = {
|
|
1362
2399
|
...this.options,
|
|
1363
2400
|
...nextOptions,
|
|
@@ -1379,16 +2416,34 @@ export class F1SimulatorApp {
|
|
|
1379
2416
|
},
|
|
1380
2417
|
};
|
|
1381
2418
|
this.assets = this.options.assets;
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
2419
|
+
if (hasOwnOption(nextOptions, 'trackSeed')) {
|
|
2420
|
+
this.trackSeed = this.options.trackSeed ?? createMountTrackSeed();
|
|
2421
|
+
}
|
|
1385
2422
|
if (nextOptions.drivers) {
|
|
1386
2423
|
this.drivers = nextOptions.drivers;
|
|
1387
2424
|
this.driverById = new Map(this.drivers.map((driver) => [driver.id, driver]));
|
|
1388
2425
|
this.root.style.setProperty('--paddock-entry-count', String(this.drivers.length));
|
|
1389
2426
|
this.createCars();
|
|
1390
2427
|
}
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
restart(nextOptions = {}) {
|
|
2431
|
+
if (hasOwnOption(nextOptions, 'assets') && !assetSetsEqual(nextOptions.assets, this.options.assets)) {
|
|
2432
|
+
throw new Error('PaddockJS restart() does not support changing assets. Destroy and mount a new simulator with the new assets.');
|
|
2433
|
+
}
|
|
2434
|
+
if (hasOwnOption(nextOptions, 'expert') && expertOptionsChanged(nextOptions.expert, this.options.expert)) {
|
|
2435
|
+
throw new Error('PaddockJS restart() does not support changing expert mode. Destroy and mount a new simulator with the new expert options.');
|
|
2436
|
+
}
|
|
2437
|
+
this.applyExpertOptions(nextOptions);
|
|
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);
|
|
2441
|
+
this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
|
|
2442
|
+
applyPaddockThemeCssVariables(this.root, this.options.theme);
|
|
1391
2443
|
this.sim = this.createRaceSimulation();
|
|
2444
|
+
if (this.expertMode) {
|
|
2445
|
+
this.expert = createBrowserExpertAdapter(this, this.options.expert);
|
|
2446
|
+
}
|
|
1392
2447
|
this.selectedId = this.drivers[0]?.id ?? null;
|
|
1393
2448
|
this.lastTimingMarkup = null;
|
|
1394
2449
|
this.lastOverviewRenderKey = null;
|
|
@@ -1396,11 +2451,20 @@ export class F1SimulatorApp {
|
|
|
1396
2451
|
this.resetRaceDataBannerState(performance.now());
|
|
1397
2452
|
this.lastTimingRenderTime = 0;
|
|
1398
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;
|
|
1399
2460
|
this.lastLeaderLap = null;
|
|
1400
2461
|
this.emittedRaceEventKeys.clear();
|
|
1401
2462
|
this.raceFinishEmitted = false;
|
|
1402
2463
|
this.renderTrack();
|
|
1403
|
-
this.
|
|
2464
|
+
const snapshot = this.sim.snapshot();
|
|
2465
|
+
this.updateDom(snapshot);
|
|
2466
|
+
this.lastDomUpdateTime = performance.now();
|
|
2467
|
+
this.renderInitialFrame(snapshot);
|
|
1404
2468
|
}
|
|
1405
2469
|
|
|
1406
2470
|
setSafetyCarDeployed(deployed) {
|
|
@@ -1409,12 +2473,52 @@ export class F1SimulatorApp {
|
|
|
1409
2473
|
this.syncSafetyCarControls(active);
|
|
1410
2474
|
}
|
|
1411
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
|
+
|
|
1412
2512
|
getSnapshot() {
|
|
1413
2513
|
return this.sim?.snapshot() ?? null;
|
|
1414
2514
|
}
|
|
1415
2515
|
|
|
1416
2516
|
destroy() {
|
|
1417
2517
|
this.abortController.abort();
|
|
2518
|
+
if (this.layoutResizeFrame && typeof cancelAnimationFrame === 'function') {
|
|
2519
|
+
cancelAnimationFrame(this.layoutResizeFrame);
|
|
2520
|
+
}
|
|
2521
|
+
this.layoutResizeFrame = null;
|
|
1418
2522
|
this.layoutResizeObserver?.disconnect?.();
|
|
1419
2523
|
this.layoutResizeObserver = null;
|
|
1420
2524
|
this.visibilityObserver?.disconnect?.();
|
|
@@ -1426,6 +2530,7 @@ export class F1SimulatorApp {
|
|
|
1426
2530
|
this.app = null;
|
|
1427
2531
|
this.carSprites.clear();
|
|
1428
2532
|
this.carHitAreas.clear();
|
|
2533
|
+
this.serviceCountdownLabels.clear();
|
|
1429
2534
|
this.drsTrails.clear();
|
|
1430
2535
|
}
|
|
1431
2536
|
}
|