@inventure71/paddockjs 1.0.0 → 2.0.1
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 +58 -0
- package/INSTALL_AND_UPDATE.md +4 -3
- package/README.md +219 -12
- package/docs/architecture.md +126 -45
- package/docs/concepts.md +9 -5
- package/docs/custom_model_controller.md +329 -0
- package/docs/data_contract.md +317 -22
- package/docs/data_model.md +1 -1
- package/docs/index.md +10 -0
- package/docs/learnings.md +22 -0
- package/docs/rules.md +71 -14
- package/docs/sense_contract.md +59 -0
- package/docs/system_specs.md +75 -13
- package/docs/training.md +280 -32
- package/examples/python/README.md +62 -0
- package/examples/python/base_policy_server.py +191 -0
- package/examples/python/requirements.txt +2 -0
- package/package.json +12 -4
- package/src/api/PaddockSimulatorController.js +6 -33
- package/src/app/BrowserExpertAdapter.js +76 -9
- package/src/app/BrowserExpertExternalRenderer.js +303 -0
- package/src/app/F1SimulatorApp.js +291 -1630
- package/src/app/banners/bannerFormatters.js +55 -0
- package/src/app/banners/raceDataBannerController.js +141 -0
- package/src/app/banners/stewardMessageController.js +99 -0
- package/src/app/camera/cameraConstants.js +21 -0
- package/src/app/camera/cameraController.js +265 -0
- package/src/app/camera/cameraGeometry.js +176 -0
- package/src/app/domBindings.js +4 -0
- package/src/app/readouts/carOverviewRenderer.js +109 -0
- package/src/app/readouts/controlStateRenderer.js +37 -0
- package/src/app/readouts/raceStatusRenderer.js +133 -0
- package/src/app/readouts/readoutFormatters.js +58 -0
- package/src/app/readouts/telemetryRenderer.js +113 -0
- package/src/app/readouts/timingTowerRenderer.js +177 -0
- package/src/app/rendering/appAssets.js +38 -0
- package/src/app/rendering/carRenderer.js +286 -0
- package/src/app/rendering/displayUtils.js +22 -0
- package/src/app/rendering/drsTrailRenderer.js +90 -0
- package/src/app/rendering/expertSensorRenderer.js +126 -0
- package/src/app/rendering/pitLaneStatusRenderer.js +50 -0
- package/src/app/rendering/replayGhostRenderer.js +124 -0
- package/src/app/rendering/trackRenderer.js +25 -0
- package/src/app/runtime/frameLoop.js +66 -0
- package/src/app/runtime/runtimeTiming.js +34 -0
- package/src/app/runtime/runtimeVisibility.js +45 -0
- package/src/config/defaultOptions.js +20 -0
- package/src/config/restartOptions.js +34 -0
- package/src/environment/actions.js +13 -5
- package/src/environment/controllerLoop.js +261 -0
- package/src/environment/episode.js +98 -2
- package/src/environment/evaluation.js +54 -32
- package/src/environment/index.d.ts +376 -10
- package/src/environment/index.js +1 -0
- package/src/environment/metrics.js +159 -0
- package/src/environment/observationOptions.js +22 -1
- package/src/environment/observationVector.js +200 -0
- package/src/environment/observations.js +199 -141
- package/src/environment/options.js +37 -5
- package/src/environment/recorder.js +13 -6
- package/src/environment/rewards.js +13 -2
- package/src/environment/runtime.js +272 -23
- package/src/environment/scenarios.js +57 -9
- package/src/environment/sensors/bodySenses.js +23 -0
- package/src/environment/sensors/boundarySenses.js +17 -0
- package/src/environment/sensors/carRays.js +75 -0
- package/src/environment/sensors/contactSenses.js +32 -0
- package/src/environment/sensors/index.js +157 -0
- package/src/environment/sensors/indexedRayBands.js +103 -0
- package/src/environment/sensors/nearbyCars.js +52 -0
- package/src/environment/sensors/opponentRadar.js +75 -0
- package/src/environment/sensors/rayConfig.js +110 -0
- package/src/environment/sensors/rayDefaults.js +6 -0
- package/src/environment/sensors/rayGeometry.js +60 -0
- package/src/environment/sensors/rayGuards.js +19 -0
- package/src/environment/sensors/sensorTargets.js +66 -0
- package/src/environment/sensors/surfaceRays.js +360 -0
- package/src/environment/sensors/trackRays.js +424 -0
- package/src/environment/sensors.js +1 -367
- package/src/environment/specs.js +159 -26
- package/src/environment/workerProtocol.js +13 -1
- package/src/index.d.ts +171 -6
- package/src/index.js +6 -32
- package/src/rendering/proceduralTrackAsset.js +20 -823
- package/src/rendering/renderSnapshot.js +38 -12
- package/src/rendering/track/finishLineRenderer.js +34 -0
- package/src/rendering/track/gridRenderer.js +47 -0
- package/src/rendering/track/offsetStrokeSafety.js +136 -0
- package/src/rendering/track/pitLaneRenderer.js +248 -0
- package/src/rendering/track/trackMaterialRenderer.js +326 -0
- package/src/rendering/track/trackRenderConstants.js +38 -0
- package/src/rendering/track/trackRenderGeometry.js +94 -0
- package/src/simulation/collisionGeometry.js +1 -1
- package/src/simulation/driver/driverControlConstants.js +28 -0
- package/src/simulation/driver/driverController.js +47 -0
- package/src/simulation/driver/driverInput.js +28 -0
- package/src/simulation/driver/driverMath.js +6 -0
- package/src/simulation/driver/driverPersonality.js +25 -0
- package/src/simulation/driver/edgeRecovery.js +48 -0
- package/src/simulation/driver/racingControls.js +399 -0
- package/src/simulation/driver/racingLinePlan.js +166 -0
- package/src/simulation/driver/recoveryDynamics.js +38 -0
- package/src/simulation/driver/rejoinControls.js +213 -0
- package/src/simulation/driver/safetyCarControls.js +35 -0
- package/src/simulation/driver/trafficScan.js +51 -0
- package/src/simulation/driverController.js +4 -619
- package/src/simulation/participants/participantInteractions.js +120 -0
- package/src/simulation/pit/pitFlow.js +239 -0
- package/src/simulation/pit/pitIntent.js +92 -0
- package/src/simulation/pit/pitKinematics.js +24 -0
- package/src/simulation/pit/pitOccupancy.js +47 -0
- package/src/simulation/pit/pitPenaltyService.js +71 -0
- package/src/simulation/pit/pitQueue.js +60 -0
- package/src/simulation/pit/pitRouteMovement.js +167 -0
- package/src/simulation/pit/pitRouting.js +319 -0
- package/src/simulation/pit/pitService.js +43 -0
- package/src/simulation/pit/pitServiceConstants.js +21 -0
- package/src/simulation/pit/pitServiceProfile.js +45 -0
- package/src/simulation/pit/pitSnapshots.js +61 -0
- package/src/simulation/pit/pitState.js +113 -0
- package/src/simulation/pit/pitTireService.js +94 -0
- package/src/simulation/race/classification.js +104 -0
- package/src/simulation/race/gridPenalties.js +37 -0
- package/src/simulation/race/raceControlState.js +38 -0
- package/src/simulation/race/raceDistance.js +32 -0
- package/src/simulation/race/raceFinish.js +65 -0
- package/src/simulation/race/raceLifecycle.js +67 -0
- package/src/simulation/race/raceOrder.js +113 -0
- package/src/simulation/race/raceProgress.js +129 -0
- package/src/simulation/race/raceSetup.js +93 -0
- package/src/simulation/race/raceStep.js +80 -0
- package/src/simulation/race/redFlag.js +15 -0
- package/src/simulation/race/retirements.js +44 -0
- package/src/simulation/race/safetyCar.js +92 -0
- package/src/simulation/race/stalledDnf.js +77 -0
- package/src/simulation/raceSimulation.js +231 -2817
- package/src/simulation/replay/replayGhosts.js +128 -0
- package/src/simulation/rules/moduleConfig.js +72 -0
- package/src/simulation/rules/penaltyConfig.js +99 -0
- package/src/simulation/rules/penaltyEvents.js +18 -0
- package/src/simulation/rules/penaltyStats.js +26 -0
- package/src/simulation/rules/ruleConfigMerge.js +41 -0
- package/src/simulation/rules/ruleDefaults.js +85 -0
- package/src/simulation/rules/rulesReview.js +144 -0
- package/src/simulation/rules/rulesetPresets.js +47 -0
- package/src/simulation/rules/trackLimitsSteward.js +1 -1
- package/src/simulation/rulesConfig.js +5 -323
- package/src/simulation/snapshots/raceSnapshots.js +193 -0
- package/src/simulation/timing/drsTiming.js +87 -0
- package/src/simulation/timing/gapEstimation.js +38 -0
- package/src/simulation/timing/lapTelemetry.js +182 -0
- package/src/simulation/timing/raceTiming.js +15 -0
- package/src/simulation/timing/sectorPerformance.js +60 -0
- package/src/simulation/timing/timingConstants.js +5 -0
- package/src/simulation/timing/timingHistory.js +46 -0
- package/src/simulation/timing/timingLines.js +79 -0
- package/src/simulation/track/centerlineSpline.js +59 -0
- package/src/simulation/track/drsZones.js +67 -0
- package/src/simulation/track/pitLaneAccess.js +103 -0
- package/src/simulation/track/pitLaneLayout.js +395 -0
- package/src/simulation/track/pitLaneState.js +160 -0
- package/src/simulation/track/proceduralCenterline.js +237 -0
- package/src/simulation/track/sampleGeometry.js +49 -0
- package/src/simulation/track/spatialQueries.js +154 -0
- package/src/simulation/track/startStraight.js +141 -0
- package/src/simulation/track/trackConstants.js +109 -0
- package/src/simulation/track/trackGenerationOptions.js +283 -0
- package/src/simulation/track/trackMath.js +241 -0
- package/src/simulation/track/trackModel.js +240 -0
- package/src/simulation/track/trackQueryGrid.js +94 -0
- package/src/simulation/track/trackQueryIndex.js +659 -0
- package/src/simulation/track/trackQueryPitIndex.js +140 -0
- package/src/simulation/track/trackQueryScratch.js +46 -0
- package/src/simulation/track/trackQueryStats.js +28 -0
- package/src/simulation/track/trackSectors.js +19 -0
- package/src/simulation/track/trackStatePolicy.js +24 -0
- package/src/simulation/track/trackValidation.js +98 -0
- package/src/simulation/trackModel.js +1 -1706
- package/src/simulation/vehicle/contactResolution.js +181 -0
- package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
- package/src/simulation/vehicle/pitWheelSurface.js +69 -0
- package/src/simulation/vehicle/runoffResponse.js +87 -0
- package/src/simulation/vehicle/surfacePriority.js +28 -0
- package/src/simulation/vehicle/vehicleGeometry.js +247 -0
- package/src/simulation/vehicle/vehicleKinematics.js +37 -0
- package/src/simulation/vehicle/vehiclePhysics.js +364 -0
- package/src/simulation/vehicle/vehicleSnapshots.js +327 -0
- package/src/simulation/vehicle/vehicleState.js +220 -0
- package/src/simulation/vehicle/wheelSurface.js +152 -0
- package/src/simulation/vehicleGeometry.js +1 -247
- package/src/simulation/vehiclePhysics.js +1 -136
- package/src/simulation/warmup/runtimeWarmup.js +224 -0
- package/src/simulation/wheelSurface.js +1 -338
- package/src/styles.css +42 -0
- package/src/ui/cameraControlsTemplate.js +20 -0
- package/src/ui/carOverviewTemplate.js +33 -0
- package/src/ui/componentTemplates.js +14 -360
- package/src/ui/raceCanvasTemplate.js +69 -0
- package/src/ui/raceControlsTemplate.js +36 -0
- package/src/ui/shellTemplate.js +5 -4
- package/src/ui/telemetryDrawerTemplate.js +55 -0
- package/src/ui/telemetryTemplates.js +136 -0
- package/src/ui/timingTowerTemplate.js +34 -0
|
@@ -1,96 +1,65 @@
|
|
|
1
|
-
import { Application,
|
|
2
|
-
import { DRIVER_STAT_DEFINITIONS, formatDriverNumber, VEHICLE_STAT_DEFINITIONS } from '../data/championship.js';
|
|
1
|
+
import { Application, Container, Graphics } from 'pixi.js';
|
|
3
2
|
import { applyPaddockThemeCssVariables } from '../config/defaultOptions.js';
|
|
4
|
-
import { normalizeCustomFields } from '../data/customFields.js';
|
|
5
|
-
import { getCarRayOrigin, getCarRayVector } from '../environment/sensors.js';
|
|
6
3
|
import { ProceduralTrackAsset } from '../rendering/proceduralTrackAsset.js';
|
|
7
|
-
import {
|
|
4
|
+
import { interpolateRenderSnapshotInto } from '../rendering/renderSnapshot.js';
|
|
8
5
|
import { createRaceSimulation, FIXED_STEP } from '../simulation/raceSimulation.js';
|
|
9
6
|
import { clamp } from '../simulation/simMath.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { VEHICLE_GEOMETRY } from '../simulation/vehicleGeometry.js';
|
|
13
|
-
import { getRaceControlStatusBanner } from '../ui/raceControlStatusBanner.js';
|
|
7
|
+
import { WORLD } from '../simulation/track/trackModel.js';
|
|
8
|
+
import { CameraController } from './camera/cameraController.js';
|
|
14
9
|
import { createBrowserExpertAdapter } from './BrowserExpertAdapter.js';
|
|
15
|
-
import { querySimulatorDom
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
10
|
+
import { querySimulatorDom } from './domBindings.js';
|
|
11
|
+
import { loadAppTextures } from './rendering/appAssets.js';
|
|
12
|
+
import { CarRenderer } from './rendering/carRenderer.js';
|
|
13
|
+
import { DrsTrailRenderer } from './rendering/drsTrailRenderer.js';
|
|
14
|
+
import { renderExpertSensorRays } from './rendering/expertSensorRenderer.js';
|
|
15
|
+
import { PitLaneStatusRenderer } from './rendering/pitLaneStatusRenderer.js';
|
|
16
|
+
import { ReplayGhostRenderer } from './rendering/replayGhostRenderer.js';
|
|
17
|
+
import { renderTrackSurface } from './rendering/trackRenderer.js';
|
|
18
|
+
import {
|
|
19
|
+
getTimingOrderKey,
|
|
20
|
+
getTimingPenaltyKey,
|
|
21
|
+
renderTimingTower,
|
|
22
|
+
syncTimingGapModeControls,
|
|
23
|
+
} from './readouts/timingTowerRenderer.js';
|
|
24
|
+
import { renderCarDriverOverview } from './readouts/carOverviewRenderer.js';
|
|
25
|
+
import { renderLapTelemetry, renderTelemetryReadouts } from './readouts/telemetryRenderer.js';
|
|
26
|
+
import {
|
|
27
|
+
renderRaceFinish,
|
|
28
|
+
renderRaceStatusReadouts,
|
|
29
|
+
renderStartLights,
|
|
30
|
+
} from './readouts/raceStatusRenderer.js';
|
|
31
|
+
import {
|
|
32
|
+
updateCameraControlButtons,
|
|
33
|
+
updateModeButtons,
|
|
34
|
+
updateToggleButtons,
|
|
35
|
+
} from './readouts/controlStateRenderer.js';
|
|
36
|
+
import {
|
|
37
|
+
createPenaltyStewardMessage,
|
|
38
|
+
createWarningStewardMessage,
|
|
39
|
+
renderActiveStewardMessage,
|
|
40
|
+
updateStewardMessageState,
|
|
41
|
+
} from './banners/stewardMessageController.js';
|
|
42
|
+
import {
|
|
43
|
+
hideRaceDataPanel,
|
|
44
|
+
getProjectRadioQuote,
|
|
45
|
+
RACE_DATA_SELECTED_VISIBLE_MS,
|
|
46
|
+
RADIO_SCHEDULE_CATCHUP_LIMIT_MS,
|
|
47
|
+
renderProjectRadio,
|
|
48
|
+
renderRaceData,
|
|
49
|
+
resetRaceDataBannerState,
|
|
50
|
+
scheduleRadioBreak,
|
|
51
|
+
scheduleRadioPopup,
|
|
52
|
+
shouldAutoHideActiveRaceData,
|
|
53
|
+
getNextRadioBreakTime,
|
|
54
|
+
} from './banners/raceDataBannerController.js';
|
|
55
|
+
import { runFrameLoopTick } from './runtime/frameLoop.js';
|
|
56
|
+
import { observeRuntimeVisibility, syncRuntimeTicker } from './runtime/runtimeVisibility.js';
|
|
57
|
+
import { TARGET_FRAME_MS, domUpdateIntervalForSpeed, timingUpdateIntervalForSpeed } from './runtime/runtimeTiming.js';
|
|
58
|
+
import { normalizeWarmupOptions } from '../simulation/warmup/runtimeWarmup.js';
|
|
59
|
+
|
|
27
60
|
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);
|
|
34
|
-
const CAMERA_PRESETS = {
|
|
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
|
-
},
|
|
56
|
-
};
|
|
57
|
-
const SHOW_ALL_PADDING = 520;
|
|
58
|
-
const SHOW_ALL_TOP_RESERVED = 92;
|
|
59
|
-
const SHOW_ALL_BOTTOM_RESERVED = 132;
|
|
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;
|
|
66
|
-
const RACE_DATA_SELECTED_VISIBLE_MS = 5200;
|
|
67
|
-
const RADIO_BREAK_MIN_MS = 4800;
|
|
68
|
-
const RADIO_BREAK_MAX_MS = 11800;
|
|
69
|
-
const RADIO_VISIBLE_MIN_MS = 6200;
|
|
70
|
-
const RADIO_VISIBLE_MAX_MS = 9200;
|
|
71
|
-
const RADIO_SCHEDULE_CATCHUP_LIMIT_MS = 30000;
|
|
72
|
-
const PENALTY_BANNER_VISIBLE_MS = 12000;
|
|
73
61
|
const DRS_DRAG_REDUCTION_PERCENT = 58;
|
|
74
62
|
|
|
75
|
-
const VEHICLE_OVERVIEW_FIELDS = [
|
|
76
|
-
['power', 'Power'],
|
|
77
|
-
['braking', 'Braking'],
|
|
78
|
-
['aero', 'Aero'],
|
|
79
|
-
['dragEfficiency', 'Drag'],
|
|
80
|
-
['mechanicalGrip', 'Grip'],
|
|
81
|
-
['weightControl', 'Weight'],
|
|
82
|
-
];
|
|
83
|
-
const DRIVER_OVERVIEW_FIELDS = [
|
|
84
|
-
['pace', 'Pace'],
|
|
85
|
-
['racecraft', 'Racecraft'],
|
|
86
|
-
['aggression', 'Aggression'],
|
|
87
|
-
['riskTolerance', 'Risk'],
|
|
88
|
-
['patience', 'Patience'],
|
|
89
|
-
['consistency', 'Consistency'],
|
|
90
|
-
];
|
|
91
|
-
|
|
92
|
-
const TINT_BY_COLOR = new Map();
|
|
93
|
-
|
|
94
63
|
function createMountTrackSeed() {
|
|
95
64
|
const values = new Uint32Array(1);
|
|
96
65
|
try {
|
|
@@ -101,199 +70,25 @@ function createMountTrackSeed() {
|
|
|
101
70
|
return (values[0] || Math.floor(Date.now() + performance.now() * 1000)) >>> 0;
|
|
102
71
|
}
|
|
103
72
|
|
|
104
|
-
function escapeHtml(value) {
|
|
105
|
-
return String(value ?? '')
|
|
106
|
-
.replaceAll('&', '&')
|
|
107
|
-
.replaceAll('<', '<')
|
|
108
|
-
.replaceAll('>', '>')
|
|
109
|
-
.replaceAll('"', '"')
|
|
110
|
-
.replaceAll("'", ''');
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function getTireClass(tire) {
|
|
114
|
-
return String(tire ?? 'M').toLowerCase();
|
|
115
|
-
}
|
|
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
|
-
|
|
197
|
-
function formatTelemetryTime(value) {
|
|
198
|
-
if (!Number.isFinite(value)) return '--';
|
|
199
|
-
const clamped = Math.max(0, value);
|
|
200
|
-
if (clamped >= 60) {
|
|
201
|
-
const minutes = Math.floor(clamped / 60);
|
|
202
|
-
const seconds = clamped - minutes * 60;
|
|
203
|
-
return `${minutes}:${seconds.toFixed(3).padStart(6, '0')}`;
|
|
204
|
-
}
|
|
205
|
-
return `${clamped.toFixed(3)}s`;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function setPerformanceClass(node, status) {
|
|
209
|
-
['overall-best', 'personal-best', 'slower'].forEach((name) => {
|
|
210
|
-
node?.classList?.toggle?.(`is-${name}`, status === name);
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function colorToTint(color) {
|
|
215
|
-
if (TINT_BY_COLOR.has(color)) return TINT_BY_COLOR.get(color);
|
|
216
|
-
const tint = Number.parseInt(String(color ?? '').replace('#', ''), 16);
|
|
217
|
-
const normalizedTint = Number.isFinite(tint) ? tint : 0xffffff;
|
|
218
|
-
TINT_BY_COLOR.set(color, normalizedTint);
|
|
219
|
-
return normalizedTint;
|
|
220
|
-
}
|
|
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
|
-
|
|
230
|
-
function smoothAngle(current, target, amount) {
|
|
231
|
-
if (!Number.isFinite(current)) return target;
|
|
232
|
-
let diff = ((target - current + Math.PI) % (Math.PI * 2)) - Math.PI;
|
|
233
|
-
if (diff < -Math.PI) diff += Math.PI * 2;
|
|
234
|
-
return current + diff * amount;
|
|
235
|
-
}
|
|
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
73
|
function hasOwnOption(options, key) {
|
|
266
74
|
return Object.hasOwn(options ?? {}, key);
|
|
267
75
|
}
|
|
268
76
|
|
|
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
77
|
function expertOptionsChanged(nextExpertOptions, currentExpertOptions) {
|
|
294
78
|
return nextExpertOptions !== currentExpertOptions;
|
|
295
79
|
}
|
|
296
80
|
|
|
81
|
+
function withSelectedExpertSensorDriver(visualizeSensors, selectedDriverId) {
|
|
82
|
+
if (!visualizeSensors) return visualizeSensors;
|
|
83
|
+
if (visualizeSensors === true) return { rays: true, selectedDriverId };
|
|
84
|
+
if (typeof visualizeSensors !== 'object') return visualizeSensors;
|
|
85
|
+
if (visualizeSensors.drivers === 'all' || Array.isArray(visualizeSensors.drivers)) return visualizeSensors;
|
|
86
|
+
return {
|
|
87
|
+
...visualizeSensors,
|
|
88
|
+
selectedDriverId,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
297
92
|
function assetSetsEqual(first = {}, second = {}) {
|
|
298
93
|
const firstTrackTextures = first.trackTextures ?? {};
|
|
299
94
|
const secondTrackTextures = second.trackTextures ?? {};
|
|
@@ -317,7 +112,7 @@ export class F1SimulatorApp {
|
|
|
317
112
|
this.abortController = new AbortController();
|
|
318
113
|
this.resizeHandler = null;
|
|
319
114
|
this.layoutResizeObserver = null;
|
|
320
|
-
this.
|
|
115
|
+
this.cameraController = null;
|
|
321
116
|
this.visibilityChangeHandler = null;
|
|
322
117
|
this.visibilityObserver = null;
|
|
323
118
|
this.tickerCallback = null;
|
|
@@ -331,12 +126,22 @@ export class F1SimulatorApp {
|
|
|
331
126
|
this.trailLayer = null;
|
|
332
127
|
this.sensorLayer = null;
|
|
333
128
|
this.pitLaneStatusLayer = null;
|
|
129
|
+
this.replayGhostLayer = null;
|
|
334
130
|
this.carLayer = null;
|
|
335
131
|
this.textures = {};
|
|
336
132
|
this.carSprites = new Map();
|
|
337
133
|
this.carHitAreas = new Map();
|
|
338
134
|
this.serviceCountdownLabels = new Map();
|
|
135
|
+
this.carRenderer = new CarRenderer({
|
|
136
|
+
carSprites: this.carSprites,
|
|
137
|
+
carHitAreas: this.carHitAreas,
|
|
138
|
+
serviceCountdownLabels: this.serviceCountdownLabels,
|
|
139
|
+
onSelectCar: (driverId) => this.selectCar(driverId, { focus: true }),
|
|
140
|
+
});
|
|
339
141
|
this.drsTrails = new Map();
|
|
142
|
+
this.drsTrailRenderer = new DrsTrailRenderer({ trails: this.drsTrails });
|
|
143
|
+
this.replayGhostRenderer = new ReplayGhostRenderer();
|
|
144
|
+
this.pitLaneStatusRenderer = new PitLaneStatusRenderer();
|
|
340
145
|
this.trackSeed = options.trackSeed ?? createMountTrackSeed();
|
|
341
146
|
this.selectedId = this.drivers[0]?.id ?? null;
|
|
342
147
|
this.raceDataBannerConfig = options.ui?.raceDataBanners ?? { initial: 'project', enabled: ['project', 'radio'] };
|
|
@@ -357,16 +162,12 @@ export class F1SimulatorApp {
|
|
|
357
162
|
quoteIndex: 0,
|
|
358
163
|
};
|
|
359
164
|
this.resetRaceDataBannerState(this.lastRaceDataInteraction);
|
|
360
|
-
this.
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
y: WORLD.height / 2,
|
|
367
|
-
free: false,
|
|
368
|
-
freeTarget: null,
|
|
369
|
-
};
|
|
165
|
+
this.cameraController = new CameraController({
|
|
166
|
+
canvasHost: this.canvasHost,
|
|
167
|
+
readouts: this.readouts,
|
|
168
|
+
initialMode: options.initialCameraMode,
|
|
169
|
+
});
|
|
170
|
+
this.camera = this.cameraController.camera;
|
|
370
171
|
this.overviewMode = 'vehicle';
|
|
371
172
|
this.simulationSpeed = 1;
|
|
372
173
|
this.accumulator = 0;
|
|
@@ -378,9 +179,6 @@ export class F1SimulatorApp {
|
|
|
378
179
|
this.lastTimingPenaltyKey = '';
|
|
379
180
|
this.lastTimingOrderKey = '';
|
|
380
181
|
this.lastTimingMarkup = null;
|
|
381
|
-
this.lastPitLaneStatusRenderKey = null;
|
|
382
|
-
this.pitCameraBoundsCache = null;
|
|
383
|
-
this.trackCameraBoundsCache = null;
|
|
384
182
|
this.lastOverviewRenderKey = null;
|
|
385
183
|
this.lastFinishClassificationMarkup = null;
|
|
386
184
|
this.runtimeViewportVisible = true;
|
|
@@ -391,6 +189,7 @@ export class F1SimulatorApp {
|
|
|
391
189
|
current: 0,
|
|
392
190
|
lastSample: this.lastTime,
|
|
393
191
|
};
|
|
192
|
+
this.renderSnapshotBuffer = {};
|
|
394
193
|
this.lastLeaderLap = null;
|
|
395
194
|
this.emittedRaceEventKeys = new Set();
|
|
396
195
|
this.raceFinishEmitted = false;
|
|
@@ -423,6 +222,7 @@ export class F1SimulatorApp {
|
|
|
423
222
|
this.trailLayer = new Graphics();
|
|
424
223
|
this.sensorLayer = new Graphics();
|
|
425
224
|
this.pitLaneStatusLayer = new Graphics();
|
|
225
|
+
this.replayGhostLayer = new Container();
|
|
426
226
|
this.carLayer = new Container();
|
|
427
227
|
this.app.stage.addChild(this.worldLayer);
|
|
428
228
|
|
|
@@ -434,6 +234,7 @@ export class F1SimulatorApp {
|
|
|
434
234
|
this.trailLayer,
|
|
435
235
|
this.pitLaneStatusLayer,
|
|
436
236
|
this.sensorLayer,
|
|
237
|
+
this.replayGhostLayer,
|
|
437
238
|
this.carLayer,
|
|
438
239
|
);
|
|
439
240
|
this.createCars();
|
|
@@ -452,7 +253,9 @@ export class F1SimulatorApp {
|
|
|
452
253
|
this.resizeHandler = () => this.applyCamera(this.sim.snapshotRender?.() ?? this.sim.snapshot());
|
|
453
254
|
window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
|
|
454
255
|
this.observeLayoutResize();
|
|
455
|
-
if (
|
|
256
|
+
if (this.expertMode) {
|
|
257
|
+
this.app.ticker.stop?.();
|
|
258
|
+
} else {
|
|
456
259
|
this.resetFrameClock();
|
|
457
260
|
this.tickerCallback = () => this.tick();
|
|
458
261
|
this.app.ticker.add(this.tickerCallback);
|
|
@@ -494,167 +297,29 @@ export class F1SimulatorApp {
|
|
|
494
297
|
return createRaceSimulation({
|
|
495
298
|
seed: options.seed,
|
|
496
299
|
trackSeed: options.trackSeed ?? this.trackSeed,
|
|
300
|
+
trackGeneration: options.trackGeneration,
|
|
301
|
+
physicsMode: options.physicsMode,
|
|
497
302
|
drivers: options.drivers ?? this.drivers,
|
|
498
303
|
totalLaps: options.totalLaps,
|
|
499
304
|
rules: options.rules,
|
|
305
|
+
participantInteractions: options.participantInteractions,
|
|
306
|
+
replayGhosts: options.replayGhosts,
|
|
307
|
+
trackQueryIndex: options.trackQueryIndex,
|
|
308
|
+
warmup: normalizeWarmupOptions(options.warmup, 'browser'),
|
|
500
309
|
});
|
|
501
310
|
}
|
|
502
311
|
|
|
503
312
|
async loadAssets() {
|
|
504
|
-
this.textures
|
|
505
|
-
try {
|
|
506
|
-
this.textures.car = await Assets.load(this.assets.car);
|
|
507
|
-
this.textures.car.source.scaleMode = 'linear';
|
|
508
|
-
this.textures.car.source.autoGenerateMipmaps = true;
|
|
509
|
-
} catch {
|
|
510
|
-
this.textures.car = Texture.WHITE;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
try {
|
|
514
|
-
this.textures.safetyCar = await Assets.load(this.assets.safetyCar);
|
|
515
|
-
this.textures.safetyCar.source.scaleMode = 'linear';
|
|
516
|
-
this.textures.safetyCar.source.autoGenerateMipmaps = true;
|
|
517
|
-
} catch {
|
|
518
|
-
this.textures.safetyCar = Texture.WHITE;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
await Promise.all(Object.entries(this.assets.trackTextures).map(async ([key, url]) => {
|
|
522
|
-
try {
|
|
523
|
-
const texture = await Assets.load(url);
|
|
524
|
-
texture.source.scaleMode = 'linear';
|
|
525
|
-
this.textures[key] = texture;
|
|
526
|
-
} catch {
|
|
527
|
-
this.textures[key] = Texture.WHITE;
|
|
528
|
-
}
|
|
529
|
-
}));
|
|
313
|
+
this.textures = await loadAppTextures(this.assets);
|
|
530
314
|
}
|
|
531
315
|
|
|
532
316
|
createCars() {
|
|
533
|
-
const texture = this.textures.car ?? Texture.WHITE;
|
|
534
|
-
const baseScale = Math.min(
|
|
535
|
-
CAR_WORLD_LENGTH / Math.max(texture.width, 1),
|
|
536
|
-
CAR_WORLD_WIDTH / Math.max(texture.height, 1),
|
|
537
|
-
);
|
|
538
|
-
|
|
539
|
-
this.carSprites.forEach((sprite) => sprite.destroy());
|
|
540
|
-
this.carHitAreas.forEach((hit) => hit.destroy());
|
|
541
|
-
this.serviceCountdownLabels.forEach((label) => label.destroy({ children: true }));
|
|
542
|
-
this.carSprites.clear();
|
|
543
|
-
this.carHitAreas.clear();
|
|
544
|
-
this.serviceCountdownLabels.clear();
|
|
545
317
|
this.drsTrails.clear();
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
this.
|
|
549
|
-
this.
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
this.drivers.forEach((driver) => {
|
|
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);
|
|
564
|
-
sprite.eventMode = 'static';
|
|
565
|
-
sprite.cursor = 'pointer';
|
|
566
|
-
sprite.on('pointerdown', () => {
|
|
567
|
-
this.selectCar(driver.id, { focus: true });
|
|
568
|
-
});
|
|
569
|
-
this.carSprites.set(driver.id, sprite);
|
|
570
|
-
this.carLayer.addChild(sprite);
|
|
571
|
-
|
|
572
|
-
const hit = new Graphics();
|
|
573
|
-
hit.circle(0, 0, 24).fill({ color: 0xffffff, alpha: 0.001 });
|
|
574
|
-
hit.eventMode = 'static';
|
|
575
|
-
hit.cursor = 'pointer';
|
|
576
|
-
hit.on('pointerdown', () => {
|
|
577
|
-
this.selectCar(driver.id, { focus: true });
|
|
578
|
-
});
|
|
579
|
-
this.carHitAreas.set(driver.id, hit);
|
|
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
|
-
},
|
|
318
|
+
this.carRenderer.createCars({
|
|
319
|
+
drivers: this.drivers,
|
|
320
|
+
textures: this.textures,
|
|
321
|
+
carLayer: this.carLayer,
|
|
639
322
|
});
|
|
640
|
-
text.anchor.set(0.5);
|
|
641
|
-
text.resolution = 2;
|
|
642
|
-
container.addChild(background, text);
|
|
643
|
-
container.labelBackground = background;
|
|
644
|
-
container.labelText = text;
|
|
645
|
-
this.applyServiceCountdownTone(container, 'pit');
|
|
646
|
-
return container;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
applyServiceCountdownTone(label, tone) {
|
|
650
|
-
const style = SERVICE_COUNTDOWN_LABEL_STYLES[tone] ?? SERVICE_COUNTDOWN_LABEL_STYLES.pit;
|
|
651
|
-
if (label.serviceTone === tone) return;
|
|
652
|
-
label.labelBackground?.clear?.();
|
|
653
|
-
label.labelBackground?.roundRect(-28, -14, 56, 28, 4)
|
|
654
|
-
.fill({ color: style.background, alpha: 0.92 })
|
|
655
|
-
.stroke({ width: 2, color: style.stroke, alpha: 0.95 });
|
|
656
|
-
if (label.labelText?.style) label.labelText.style.fill = style.fill;
|
|
657
|
-
label.serviceTone = tone;
|
|
658
323
|
}
|
|
659
324
|
|
|
660
325
|
bindControls() {
|
|
@@ -752,18 +417,13 @@ export class F1SimulatorApp {
|
|
|
752
417
|
|
|
753
418
|
setCameraMode(mode) {
|
|
754
419
|
const snapshot = this.sim?.snapshot?.();
|
|
755
|
-
if (!this.
|
|
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];
|
|
420
|
+
if (!this.cameraController.setMode(mode, snapshot)) return false;
|
|
760
421
|
this.updateCameraControls(snapshot);
|
|
761
422
|
return true;
|
|
762
423
|
}
|
|
763
424
|
|
|
764
425
|
adjustCameraZoom(direction) {
|
|
765
|
-
|
|
766
|
-
this.camera.zoom = clamp(this.camera.zoom + steps * CAMERA_ZOOM_STEP, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
|
|
426
|
+
this.cameraController.adjustZoom(direction);
|
|
767
427
|
this.updateCameraControls();
|
|
768
428
|
return this.camera.zoom;
|
|
769
429
|
}
|
|
@@ -834,7 +494,7 @@ export class F1SimulatorApp {
|
|
|
834
494
|
}
|
|
835
495
|
|
|
836
496
|
renderInitialFrame(snapshot) {
|
|
837
|
-
const renderSnapshot =
|
|
497
|
+
const renderSnapshot = interpolateRenderSnapshotInto(this.renderSnapshotBuffer, snapshot, 0);
|
|
838
498
|
this.resizeRendererToCanvasHost();
|
|
839
499
|
this.applyCamera(renderSnapshot, { immediate: true });
|
|
840
500
|
this.renderDrsTrails(renderSnapshot);
|
|
@@ -847,7 +507,11 @@ export class F1SimulatorApp {
|
|
|
847
507
|
this.resizeRendererToCanvasHost();
|
|
848
508
|
const snapshot = this.sim?.snapshotRender?.() ?? this.sim?.snapshot?.();
|
|
849
509
|
if (!snapshot) return;
|
|
850
|
-
const renderSnapshot =
|
|
510
|
+
const renderSnapshot = interpolateRenderSnapshotInto(
|
|
511
|
+
this.renderSnapshotBuffer,
|
|
512
|
+
snapshot,
|
|
513
|
+
clamp(this.accumulator / FIXED_STEP, 0, 1),
|
|
514
|
+
);
|
|
851
515
|
this.applyCamera(renderSnapshot);
|
|
852
516
|
if (render) {
|
|
853
517
|
this.renderDrsTrails(renderSnapshot);
|
|
@@ -881,53 +545,15 @@ export class F1SimulatorApp {
|
|
|
881
545
|
}
|
|
882
546
|
|
|
883
547
|
invalidateCameraSafeArea() {
|
|
884
|
-
this.
|
|
548
|
+
this.cameraController.invalidateSafeArea();
|
|
885
549
|
}
|
|
886
550
|
|
|
887
551
|
observeRuntimeVisibility() {
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
if (typeof IntersectionObserver === 'function') {
|
|
891
|
-
this.visibilityObserver?.disconnect?.();
|
|
892
|
-
this.visibilityObserver = new IntersectionObserver((entries) => {
|
|
893
|
-
const entry = entries.find((item) => item.target === this.canvasHost) ?? entries[0];
|
|
894
|
-
this.runtimeViewportVisible = Boolean(entry?.isIntersecting || entry?.intersectionRatio > 0);
|
|
895
|
-
this.syncRuntimeTicker();
|
|
896
|
-
}, {
|
|
897
|
-
root: null,
|
|
898
|
-
rootMargin: '240px 0px',
|
|
899
|
-
threshold: 0,
|
|
900
|
-
});
|
|
901
|
-
this.visibilityObserver.observe(this.canvasHost);
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
if (typeof document !== 'undefined') {
|
|
905
|
-
this.visibilityChangeHandler = () => {
|
|
906
|
-
this.runtimeDocumentVisible = document.visibilityState !== 'hidden';
|
|
907
|
-
this.syncRuntimeTicker();
|
|
908
|
-
};
|
|
909
|
-
document.addEventListener('visibilitychange', this.visibilityChangeHandler, {
|
|
910
|
-
signal: this.abortController.signal,
|
|
911
|
-
});
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
this.syncRuntimeTicker();
|
|
552
|
+
observeRuntimeVisibility(this);
|
|
915
553
|
}
|
|
916
554
|
|
|
917
555
|
syncRuntimeTicker() {
|
|
918
|
-
|
|
919
|
-
if (!this.app?.ticker) return;
|
|
920
|
-
|
|
921
|
-
const shouldRun = this.runtimeViewportVisible && this.runtimeDocumentVisible;
|
|
922
|
-
if (shouldRun === this.runtimeTickerRunning) return;
|
|
923
|
-
|
|
924
|
-
this.runtimeTickerRunning = shouldRun;
|
|
925
|
-
if (shouldRun) {
|
|
926
|
-
this.resetFrameClock();
|
|
927
|
-
this.app.ticker.start?.();
|
|
928
|
-
} else {
|
|
929
|
-
this.app.ticker.stop?.();
|
|
930
|
-
}
|
|
556
|
+
syncRuntimeTicker(this);
|
|
931
557
|
}
|
|
932
558
|
|
|
933
559
|
resetFrameClock(now = performance.now()) {
|
|
@@ -940,612 +566,107 @@ export class F1SimulatorApp {
|
|
|
940
566
|
}
|
|
941
567
|
|
|
942
568
|
tick() {
|
|
943
|
-
|
|
944
|
-
const now = performance.now();
|
|
945
|
-
if (now < this.nextGameFrameTime - FRAME_PACING_EPSILON_MS) return;
|
|
946
|
-
|
|
947
|
-
const elapsedFrameCount = clamp(
|
|
948
|
-
Math.floor((now - this.nextGameFrameTime + FRAME_PACING_EPSILON_MS) / TARGET_FRAME_MS) + 1,
|
|
949
|
-
1,
|
|
950
|
-
MAX_FRAME_CATCHUP_COUNT,
|
|
951
|
-
);
|
|
952
|
-
const frameSeconds = (TARGET_FRAME_MS * elapsedFrameCount) / 1000;
|
|
953
|
-
this.nextGameFrameTime += TARGET_FRAME_MS * elapsedFrameCount;
|
|
954
|
-
if (now - this.nextGameFrameTime > TARGET_FRAME_MS * MAX_FRAME_CATCHUP_COUNT) {
|
|
955
|
-
this.nextGameFrameTime = now + TARGET_FRAME_MS;
|
|
956
|
-
}
|
|
957
|
-
this.lastTime = now;
|
|
958
|
-
this.sampleFps(now);
|
|
959
|
-
this.accumulator += frameSeconds * this.simulationSpeed;
|
|
960
|
-
|
|
961
|
-
let simulationSteps = 0;
|
|
962
|
-
const stepEvents = [];
|
|
963
|
-
const maxSimulationSteps = maxSimulationStepsForFrame(this.simulationSpeed, elapsedFrameCount);
|
|
964
|
-
while (this.accumulator >= FIXED_STEP && simulationSteps < maxSimulationSteps) {
|
|
965
|
-
this.sim.step(FIXED_STEP);
|
|
966
|
-
if (Array.isArray(this.sim.events) && this.sim.events.length > 0) {
|
|
967
|
-
stepEvents.push(...this.sim.events);
|
|
968
|
-
}
|
|
969
|
-
this.accumulator -= FIXED_STEP;
|
|
970
|
-
simulationSteps += 1;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
if (this.accumulator >= FIXED_STEP) {
|
|
974
|
-
this.accumulator %= FIXED_STEP;
|
|
975
|
-
this.nextGameFrameTime = now + TARGET_FRAME_MS;
|
|
976
|
-
}
|
|
977
|
-
|
|
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));
|
|
983
|
-
this.applyCamera(renderSnapshot);
|
|
984
|
-
this.renderDrsTrails(renderSnapshot);
|
|
985
|
-
this.renderPitLaneStatus(renderSnapshot);
|
|
986
|
-
this.renderCars(renderSnapshot);
|
|
987
|
-
if (shouldUpdateDom) {
|
|
988
|
-
this.updateDom(fullSnapshot, { emitLifecycle: false });
|
|
989
|
-
this.lastDomUpdateTime = now;
|
|
990
|
-
}
|
|
569
|
+
runFrameLoopTick(this);
|
|
991
570
|
}
|
|
992
571
|
|
|
993
|
-
renderExpertFrame(snapshot = this.sim?.snapshot(), { observation } = {}) {
|
|
572
|
+
renderExpertFrame(snapshot = this.sim?.snapshot(), { forceDomUpdate = false, observation } = {}) {
|
|
994
573
|
if (!snapshot) return;
|
|
995
|
-
const
|
|
574
|
+
const now = performance.now();
|
|
575
|
+
const renderSnapshot = interpolateRenderSnapshotInto(this.renderSnapshotBuffer, snapshot, 0);
|
|
996
576
|
this.applyCamera(renderSnapshot);
|
|
997
577
|
this.renderDrsTrails(renderSnapshot);
|
|
998
578
|
this.renderExpertSensorRays(renderSnapshot, observation);
|
|
999
579
|
this.renderPitLaneStatus(renderSnapshot);
|
|
1000
580
|
this.renderCars(renderSnapshot);
|
|
1001
|
-
this.
|
|
1002
|
-
this.lastDomUpdateTime
|
|
581
|
+
this.app?.render?.();
|
|
582
|
+
if (forceDomUpdate || now - this.lastDomUpdateTime >= domUpdateIntervalForSpeed(this.simulationSpeed)) {
|
|
583
|
+
this.updateDom(snapshot, { emitLifecycle: false });
|
|
584
|
+
this.lastDomUpdateTime = now;
|
|
585
|
+
}
|
|
1003
586
|
}
|
|
1004
587
|
|
|
1005
588
|
renderTrack() {
|
|
1006
|
-
|
|
1007
|
-
this.
|
|
1008
|
-
this.pitLaneStatusLayer?.clear();
|
|
1009
|
-
this.lastPitLaneStatusRenderKey = null;
|
|
1010
|
-
this.pitCameraBoundsCache = null;
|
|
1011
|
-
this.trackCameraBoundsCache = null;
|
|
589
|
+
this.pitLaneStatusRenderer.reset();
|
|
590
|
+
this.cameraController.invalidateTrackCaches();
|
|
1012
591
|
const snapshot = this.sim.snapshot();
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
const steps = 44;
|
|
1020
|
-
for (let index = 0; index <= steps; index += 1) {
|
|
1021
|
-
const basePoint = pointAt(track, zone.start + ((zone.end - zone.start) * index) / steps);
|
|
1022
|
-
const point = offsetTrackPoint(basePoint, track.width / 2 - 22);
|
|
1023
|
-
if (index === 0) zoneLine.moveTo(point.x, point.y);
|
|
1024
|
-
else zoneLine.lineTo(point.x, point.y);
|
|
1025
|
-
}
|
|
1026
|
-
zoneLine.stroke({ width: 8, color: 0x14c784, alpha: 0.5, join: 'round', cap: 'round' });
|
|
1027
|
-
this.drsLayer.addChild(zoneLine);
|
|
592
|
+
renderTrackSurface({
|
|
593
|
+
drsLayer: this.drsLayer,
|
|
594
|
+
sensorLayer: this.sensorLayer,
|
|
595
|
+
pitLaneStatusLayer: this.pitLaneStatusLayer,
|
|
596
|
+
trackAsset: this.trackAsset,
|
|
597
|
+
snapshot,
|
|
1028
598
|
});
|
|
1029
599
|
}
|
|
1030
600
|
|
|
1031
601
|
renderPitLaneStatus(snapshot) {
|
|
1032
|
-
|
|
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 });
|
|
602
|
+
this.pitLaneStatusRenderer.render(snapshot, this.pitLaneStatusLayer);
|
|
1068
603
|
}
|
|
1069
604
|
|
|
1070
605
|
renderCars(snapshot) {
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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);
|
|
606
|
+
this.replayGhostRenderer.render(snapshot, {
|
|
607
|
+
textures: this.textures,
|
|
608
|
+
replayGhostLayer: this.replayGhostLayer,
|
|
609
|
+
});
|
|
610
|
+
this.carRenderer.renderCars(snapshot, {
|
|
611
|
+
textures: this.textures,
|
|
612
|
+
carLayer: this.carLayer,
|
|
1095
613
|
});
|
|
1096
|
-
|
|
1097
|
-
if (!this.safetySprite && snapshot.safetyCar.deployed) {
|
|
1098
|
-
const texture = this.textures.safetyCar ?? Texture.WHITE;
|
|
1099
|
-
this.safetySprite = new Sprite(texture);
|
|
1100
|
-
this.safetySprite.anchor.set(0.5);
|
|
1101
|
-
this.safetySprite.baseScale = Math.min(
|
|
1102
|
-
SAFETY_CAR_WORLD_LENGTH / Math.max(texture.width, 1),
|
|
1103
|
-
SAFETY_CAR_WORLD_WIDTH / Math.max(texture.height, 1),
|
|
1104
|
-
);
|
|
1105
|
-
this.safetySprite.scale.set(this.safetySprite.baseScale);
|
|
1106
|
-
this.carLayer.addChild(this.safetySprite);
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
if (this.safetySprite) {
|
|
1110
|
-
if (this.safetySprite.visible !== snapshot.safetyCar.deployed) {
|
|
1111
|
-
this.safetySprite.visible = snapshot.safetyCar.deployed;
|
|
1112
|
-
}
|
|
1113
|
-
if (this.safetySprite.x !== snapshot.safetyCar.x) this.safetySprite.x = snapshot.safetyCar.x;
|
|
1114
|
-
if (this.safetySprite.y !== snapshot.safetyCar.y) this.safetySprite.y = snapshot.safetyCar.y;
|
|
1115
|
-
if (this.safetySprite.rotation !== snapshot.safetyCar.heading) this.safetySprite.rotation = snapshot.safetyCar.heading;
|
|
1116
|
-
}
|
|
1117
614
|
}
|
|
1118
615
|
|
|
1119
616
|
renderServiceCountdownLabel(car) {
|
|
1120
|
-
|
|
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
|
-
}
|
|
617
|
+
this.carRenderer.renderServiceCountdownLabel(car);
|
|
1140
618
|
}
|
|
1141
619
|
|
|
1142
620
|
renderExpertSensorRays(snapshot, observation) {
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
-
});
|
|
621
|
+
renderExpertSensorRays({
|
|
622
|
+
snapshot,
|
|
623
|
+
observation,
|
|
624
|
+
sensorLayer: this.sensorLayer,
|
|
625
|
+
expertMode: this.expertMode,
|
|
626
|
+
expertOptions: {
|
|
627
|
+
...this.options.expert,
|
|
628
|
+
visualizeSensors: withSelectedExpertSensorDriver(this.options.expert?.visualizeSensors, this.selectedId),
|
|
629
|
+
},
|
|
1193
630
|
});
|
|
1194
631
|
}
|
|
1195
632
|
|
|
1196
633
|
renderDrsTrails(snapshot) {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
snapshot.cars.forEach((car) => {
|
|
1200
|
-
const history = this.drsTrails.get(car.id);
|
|
1201
|
-
if (!history && !car.drsActive) return;
|
|
1202
|
-
const activeHistory = history ?? [];
|
|
1203
|
-
const rear = {
|
|
1204
|
-
x: car.x - Math.cos(car.heading) * CAR_WORLD_LENGTH * 0.46,
|
|
1205
|
-
y: car.y - Math.sin(car.heading) * CAR_WORLD_LENGTH * 0.46,
|
|
1206
|
-
at: snapshot.time,
|
|
1207
|
-
};
|
|
1208
|
-
const last = activeHistory[activeHistory.length - 1];
|
|
1209
|
-
|
|
1210
|
-
if (
|
|
1211
|
-
car.drsActive &&
|
|
1212
|
-
(!last || Math.hypot(rear.x - last.x, rear.y - last.y) >= DRS_TRAIL_MIN_DISTANCE)
|
|
1213
|
-
) {
|
|
1214
|
-
activeHistory.push(rear);
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
pruneExpiredTrailPoints(activeHistory, snapshot.time);
|
|
1218
|
-
if (activeHistory.length) this.drsTrails.set(car.id, activeHistory);
|
|
1219
|
-
else this.drsTrails.delete(car.id);
|
|
1220
|
-
});
|
|
1221
|
-
|
|
1222
|
-
this.trailLayer.clear();
|
|
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);
|
|
1228
|
-
for (let index = 1; index < history.length; index += 1) {
|
|
1229
|
-
this.trailLayer.lineTo(history[index].x, history[index].y);
|
|
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
|
-
});
|
|
1249
|
-
});
|
|
634
|
+
this.drsTrailRenderer.render(snapshot, this.trailLayer);
|
|
1250
635
|
}
|
|
1251
636
|
|
|
1252
637
|
applyCamera(snapshot, { immediate = false } = {}) {
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
const safeArea = this.getCameraSafeArea(width);
|
|
1258
|
-
const baseScale = Math.min(safeArea.width / (WORLD.width + 260), height / (WORLD.height + 220));
|
|
1259
|
-
const frame = this.getCameraFrame(snapshot, width, height, baseScale, safeArea);
|
|
1260
|
-
const scale = frame.scale;
|
|
1261
|
-
const target = frame.target;
|
|
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
|
|
1270
|
-
? scale
|
|
1271
|
-
: this.camera.scale + (scale - this.camera.scale) * CAMERA_SCALE_LERP;
|
|
1272
|
-
this.camera.initialized = true;
|
|
1273
|
-
this.camera.scale = activeScale;
|
|
1274
|
-
this.worldLayer.scale.set(activeScale);
|
|
1275
|
-
this.worldLayer.position.set(
|
|
1276
|
-
frame.screenX - this.camera.x * activeScale,
|
|
1277
|
-
frame.screenY - this.camera.y * activeScale,
|
|
1278
|
-
);
|
|
638
|
+
this.cameraController.applyToWorldLayer(this.worldLayer, snapshot, {
|
|
639
|
+
immediate,
|
|
640
|
+
selectedId: this.selectedId,
|
|
641
|
+
});
|
|
1279
642
|
}
|
|
1280
643
|
|
|
1281
644
|
getCameraSafeArea(width) {
|
|
1282
|
-
|
|
1283
|
-
return this.cameraSafeAreaCache.safeArea;
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
const canvasRect = this.canvasHost?.getBoundingClientRect?.();
|
|
1287
|
-
const towerRect = this.readouts.timingTower?.getBoundingClientRect?.();
|
|
1288
|
-
if (!canvasRect || !towerRect) {
|
|
1289
|
-
const safeArea = { left: 0, width };
|
|
1290
|
-
this.cameraSafeAreaCache = { width, safeArea };
|
|
1291
|
-
return safeArea;
|
|
1292
|
-
}
|
|
1293
|
-
const overlapsCanvasHorizontally = towerRect.right > canvasRect.left && towerRect.left < canvasRect.right;
|
|
1294
|
-
const hasVerticalBounds = Number.isFinite(canvasRect.top) &&
|
|
1295
|
-
Number.isFinite(canvasRect.bottom) &&
|
|
1296
|
-
Number.isFinite(towerRect.top) &&
|
|
1297
|
-
Number.isFinite(towerRect.bottom);
|
|
1298
|
-
const overlapsCanvasVertically = !hasVerticalBounds ||
|
|
1299
|
-
(towerRect.bottom > canvasRect.top && towerRect.top < canvasRect.bottom);
|
|
1300
|
-
const canvasWidth = Math.max(1, canvasRect.right - canvasRect.left || width);
|
|
1301
|
-
const towerWidth = Math.max(0, towerRect.right - towerRect.left);
|
|
1302
|
-
const isSideGutter = towerWidth < canvasWidth * 0.6;
|
|
1303
|
-
if (!overlapsCanvasHorizontally || !overlapsCanvasVertically || !isSideGutter) {
|
|
1304
|
-
const safeArea = { left: 0, width };
|
|
1305
|
-
this.cameraSafeAreaCache = { width, safeArea };
|
|
1306
|
-
return safeArea;
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
const overlayGap = 16;
|
|
1310
|
-
const reservedLeft = clamp(towerRect.right - canvasRect.left + overlayGap, 0, width * 0.48);
|
|
1311
|
-
const safeArea = {
|
|
1312
|
-
left: reservedLeft,
|
|
1313
|
-
width: Math.max(1, width - reservedLeft),
|
|
1314
|
-
};
|
|
1315
|
-
this.cameraSafeAreaCache = { width, safeArea };
|
|
1316
|
-
return safeArea;
|
|
645
|
+
return this.cameraController.getSafeArea(width);
|
|
1317
646
|
}
|
|
1318
647
|
|
|
1319
648
|
getCameraFrame(snapshot, width, height, baseScale, safeArea = { left: 0, width }) {
|
|
1320
|
-
|
|
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
|
-
}
|
|
1343
|
-
|
|
1344
|
-
if (this.camera.mode === 'show-all') {
|
|
1345
|
-
const bounds = snapshot.cars.reduce((box, car) => ({
|
|
1346
|
-
minX: Math.min(box.minX, car.x),
|
|
1347
|
-
minY: Math.min(box.minY, car.y),
|
|
1348
|
-
maxX: Math.max(box.maxX, car.x),
|
|
1349
|
-
maxY: Math.max(box.maxY, car.y),
|
|
1350
|
-
}), {
|
|
1351
|
-
minX: Infinity,
|
|
1352
|
-
minY: Infinity,
|
|
1353
|
-
maxX: -Infinity,
|
|
1354
|
-
maxY: -Infinity,
|
|
1355
|
-
});
|
|
1356
|
-
const target = {
|
|
1357
|
-
x: (bounds.minX + bounds.maxX) / 2,
|
|
1358
|
-
y: (bounds.minY + bounds.maxY) / 2,
|
|
1359
|
-
};
|
|
1360
|
-
const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX + SHOW_ALL_PADDING);
|
|
1361
|
-
const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY + SHOW_ALL_PADDING);
|
|
1362
|
-
const safeHeight = Math.max(height * 0.48, height - SHOW_ALL_TOP_RESERVED - SHOW_ALL_BOTTOM_RESERVED);
|
|
1363
|
-
const fitScale = Math.min(safeArea.width / fitWidth, safeHeight / fitHeight);
|
|
1364
|
-
const scale = clampCameraScale(fitScale * this.camera.zoom, fitScale, trackFitScale);
|
|
1365
|
-
return this.applyFreeCameraFrame({
|
|
1366
|
-
target,
|
|
1367
|
-
scale,
|
|
1368
|
-
screenX: screenCenterX,
|
|
1369
|
-
screenY: height / 2,
|
|
1370
|
-
});
|
|
1371
|
-
}
|
|
1372
|
-
|
|
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({
|
|
1380
|
-
target: this.getCameraTarget(snapshot),
|
|
1381
|
-
scale: clampCameraScale(baseScale * this.camera.zoom, baseScale, trackFitScale),
|
|
1382
|
-
screenX: screenCenterX,
|
|
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,
|
|
1392
|
-
};
|
|
649
|
+
return this.cameraController.getFrame(snapshot, width, height, baseScale, safeArea, this.selectedId);
|
|
1393
650
|
}
|
|
1394
651
|
|
|
1395
652
|
getCameraTarget(snapshot) {
|
|
1396
|
-
|
|
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);
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
if (this.camera.mode === 'selected') {
|
|
1404
|
-
const selected = snapshot.cars.find((car) => car.id === this.selectedId);
|
|
1405
|
-
if (selected) return selected;
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
const leader = snapshot.cars[0];
|
|
1409
|
-
return leader ? { x: leader.x, y: leader.y } : { x: WORLD.width / 2, y: WORLD.height / 2 };
|
|
653
|
+
return this.cameraController.getTarget(snapshot, this.selectedId);
|
|
1410
654
|
}
|
|
1411
655
|
|
|
1412
656
|
getTrackCameraBounds(track) {
|
|
1413
|
-
|
|
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
|
-
};
|
|
657
|
+
return this.cameraController.getTrackBounds(track);
|
|
1464
658
|
}
|
|
1465
659
|
|
|
1466
660
|
getCameraBoundsFitScale(bounds, height, safeArea) {
|
|
1467
|
-
|
|
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;
|
|
661
|
+
return this.cameraController.getBoundsFitScale(bounds, height, safeArea);
|
|
1472
662
|
}
|
|
1473
663
|
|
|
1474
664
|
getPitCameraBounds(pitLane) {
|
|
1475
|
-
|
|
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
|
-
};
|
|
665
|
+
return this.cameraController.getPitBounds(pitLane);
|
|
1517
666
|
}
|
|
1518
667
|
|
|
1519
668
|
getPitCameraFrame(pitLane, height, baseScale, safeArea, screenCenterX, minimumScale = null) {
|
|
1520
|
-
|
|
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
|
-
};
|
|
669
|
+
return this.cameraController.getPitFrame(pitLane, height, baseScale, safeArea, screenCenterX, minimumScale);
|
|
1549
670
|
}
|
|
1550
671
|
|
|
1551
672
|
updateDom(snapshot, { emitLifecycle = true } = {}) {
|
|
@@ -1566,65 +687,18 @@ export class F1SimulatorApp {
|
|
|
1566
687
|
}
|
|
1567
688
|
this.updateStewardMessageState(snapshot, now);
|
|
1568
689
|
|
|
1569
|
-
if (this.readouts.mode) {
|
|
1570
|
-
const modeLabel = snapshot.raceControl.mode === 'safety-car'
|
|
1571
|
-
? 'SC'
|
|
1572
|
-
: snapshot.raceControl.mode === 'red-flag'
|
|
1573
|
-
? 'RED'
|
|
1574
|
-
: snapshot.raceControl.finished
|
|
1575
|
-
? 'FINISH'
|
|
1576
|
-
: 'GREEN';
|
|
1577
|
-
this.readouts.mode.textContent = modeLabel;
|
|
1578
|
-
this.readouts.mode.style.color = snapshot.raceControl.mode === 'safety-car'
|
|
1579
|
-
? 'var(--yellow)'
|
|
1580
|
-
: snapshot.raceControl.mode === 'red-flag'
|
|
1581
|
-
? 'var(--race-control-red)'
|
|
1582
|
-
: snapshot.raceControl.mode === 'finished'
|
|
1583
|
-
? 'var(--red)'
|
|
1584
|
-
: 'var(--green)';
|
|
1585
|
-
}
|
|
1586
690
|
this.syncSafetyCarControls(snapshot.raceControl.mode === 'safety-car');
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
this.
|
|
1592
|
-
this.
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
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
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
if (this.readouts.drs) {
|
|
1611
|
-
this.readouts.drs.textContent = ['safety-car', 'red-flag'].includes(snapshot.raceControl.mode)
|
|
1612
|
-
? 'DISABLED'
|
|
1613
|
-
: activeDrs
|
|
1614
|
-
? `${activeDrs} OPEN`
|
|
1615
|
-
: 'ARMED';
|
|
1616
|
-
}
|
|
1617
|
-
if (this.readouts.contacts) this.readouts.contacts.textContent = String(contactCount);
|
|
1618
|
-
this.renderStartLights(snapshot.raceControl);
|
|
1619
|
-
if (this.readouts.camera) {
|
|
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}%`;
|
|
1623
|
-
}
|
|
1624
|
-
if (this.readouts.fps) {
|
|
1625
|
-
this.readouts.fps.textContent = this.fps.current ? `${this.fps.current}` : '--';
|
|
1626
|
-
}
|
|
1627
|
-
this.renderRaceFinish(snapshot);
|
|
691
|
+
this.lastFinishClassificationMarkup = renderRaceStatusReadouts({
|
|
692
|
+
readouts: this.readouts,
|
|
693
|
+
startLightNodes: this.startLightNodes,
|
|
694
|
+
snapshot,
|
|
695
|
+
camera: this.camera,
|
|
696
|
+
fps: this.fps,
|
|
697
|
+
activeDrs,
|
|
698
|
+
contactCount,
|
|
699
|
+
driverById: this.driverById,
|
|
700
|
+
lastFinishClassificationMarkup: this.lastFinishClassificationMarkup,
|
|
701
|
+
});
|
|
1628
702
|
|
|
1629
703
|
this.updateCameraControls(snapshot);
|
|
1630
704
|
this.syncTimingGapModeControls();
|
|
@@ -1692,38 +766,12 @@ export class F1SimulatorApp {
|
|
|
1692
766
|
}
|
|
1693
767
|
|
|
1694
768
|
renderRaceFinish(snapshot) {
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
const winner = snapshot.raceControl.winner;
|
|
1702
|
-
const winnerDriver = winner ? this.driverById.get(winner.id) : null;
|
|
1703
|
-
const winnerName = winnerDriver?.name ?? winner?.name ?? 'Winner';
|
|
1704
|
-
panel.style.setProperty('--driver-color', winner?.color ?? winnerDriver?.color ?? 'var(--red)');
|
|
1705
|
-
setText(this.readouts.finishWinner, winnerName);
|
|
1706
|
-
|
|
1707
|
-
if (this.readouts.finishClassification) {
|
|
1708
|
-
const topThree = (snapshot.raceControl.classification ?? []).slice(0, 3);
|
|
1709
|
-
const classificationMarkup = topThree.map((entry) => `
|
|
1710
|
-
<li>
|
|
1711
|
-
<span>P${escapeHtml(entry.rank)}</span>
|
|
1712
|
-
<strong>${escapeHtml(entry.timingCode ?? entry.code ?? entry.id)}</strong>
|
|
1713
|
-
</li>
|
|
1714
|
-
`).join('');
|
|
1715
|
-
if (classificationMarkup !== this.lastFinishClassificationMarkup) {
|
|
1716
|
-
this.readouts.finishClassification.innerHTML = classificationMarkup;
|
|
1717
|
-
this.lastFinishClassificationMarkup = classificationMarkup;
|
|
1718
|
-
}
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
getBaseScale() {
|
|
1723
|
-
const width = this.canvasHost.clientWidth || 900;
|
|
1724
|
-
const height = this.canvasHost.clientHeight || 640;
|
|
1725
|
-
const safeArea = this.getCameraSafeArea(width);
|
|
1726
|
-
return Math.min(safeArea.width / (WORLD.width + 260), height / (WORLD.height + 220));
|
|
769
|
+
this.lastFinishClassificationMarkup = renderRaceFinish({
|
|
770
|
+
readouts: this.readouts,
|
|
771
|
+
snapshot,
|
|
772
|
+
driverById: this.driverById,
|
|
773
|
+
lastFinishClassificationMarkup: this.lastFinishClassificationMarkup,
|
|
774
|
+
});
|
|
1727
775
|
}
|
|
1728
776
|
|
|
1729
777
|
sampleFps(now) {
|
|
@@ -1736,24 +784,7 @@ export class F1SimulatorApp {
|
|
|
1736
784
|
}
|
|
1737
785
|
|
|
1738
786
|
renderStartLights(raceControl) {
|
|
1739
|
-
|
|
1740
|
-
if (!panel) return;
|
|
1741
|
-
|
|
1742
|
-
const start = raceControl.start;
|
|
1743
|
-
const visible = Boolean(start?.visible);
|
|
1744
|
-
panel.hidden = !visible;
|
|
1745
|
-
if (!visible) return;
|
|
1746
|
-
|
|
1747
|
-
this.startLightNodes.forEach((light, index) => {
|
|
1748
|
-
light.classList.toggle('is-lit', index < (start.lightsLit ?? 0));
|
|
1749
|
-
});
|
|
1750
|
-
panel.classList.toggle('is-lights-out', raceControl.mode === 'green' && start.released);
|
|
1751
|
-
|
|
1752
|
-
if (this.readouts.startLightsLabel) {
|
|
1753
|
-
this.readouts.startLightsLabel.textContent = raceControl.mode === 'green' && start.released
|
|
1754
|
-
? 'Lights out'
|
|
1755
|
-
: `${start.lightsLit}/${start.lightCount}`;
|
|
1756
|
-
}
|
|
787
|
+
renderStartLights(this.readouts, this.startLightNodes, raceControl);
|
|
1757
788
|
}
|
|
1758
789
|
|
|
1759
790
|
selectCar(id, { focus = false } = {}) {
|
|
@@ -1772,363 +803,83 @@ export class F1SimulatorApp {
|
|
|
1772
803
|
}
|
|
1773
804
|
|
|
1774
805
|
renderTiming(cars, raceMode, penalties = []) {
|
|
1775
|
-
if (!this.timingList) return;
|
|
1776
806
|
this.syncTimingGapModeControls();
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
gap = car.rank === 1 ? 'Pole' : 'Grid';
|
|
1789
|
-
} else if (car.rank > 1) {
|
|
1790
|
-
gap = this.formatTimingGap(car);
|
|
1791
|
-
}
|
|
1792
|
-
const tire = car.tire ?? driver?.tire ?? 'M';
|
|
1793
|
-
const timingCode = car.timingCode ?? driver?.timingCode ?? car.code;
|
|
1794
|
-
const team = car.team ?? driver?.team ?? null;
|
|
1795
|
-
const icon = team?.icon ?? car.icon ?? driver?.icon ?? timingCode;
|
|
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
|
-
: '';
|
|
1801
|
-
|
|
1802
|
-
return `
|
|
1803
|
-
<li>
|
|
1804
|
-
<button class="timing-row ${car.id === this.selectedId ? 'is-selected' : ''}" type="button"
|
|
1805
|
-
data-driver-id="${escapeHtml(car.id)}" aria-label="Select ${escapeHtml(car.name)}"
|
|
1806
|
-
style="--driver-color: ${escapeHtml(car.color)}">
|
|
1807
|
-
<span class="timing-position">${car.rank}</span>
|
|
1808
|
-
<span class="timing-icon timing-team-icon" aria-hidden="true" style="--team-color: ${escapeHtml(iconColor)}">${escapeHtml(icon)}</span>
|
|
1809
|
-
<span class="timing-name" title="${escapeHtml(car.name)}"><span>${escapeHtml(timingCode)}</span>${penaltyBadge}</span>
|
|
1810
|
-
<span class="timing-gap">${escapeHtml(gap)}</span>
|
|
1811
|
-
<span class="timing-tire timing-tire--${getTireClass(tire)}">${escapeHtml(tire)}</span>
|
|
1812
|
-
</button>
|
|
1813
|
-
</li>
|
|
1814
|
-
`;
|
|
1815
|
-
}).join('');
|
|
1816
|
-
if (timingMarkup !== this.lastTimingMarkup) {
|
|
1817
|
-
this.timingList.innerHTML = timingMarkup;
|
|
1818
|
-
this.lastTimingMarkup = timingMarkup;
|
|
1819
|
-
}
|
|
807
|
+
this.lastTimingMarkup = renderTimingTower({
|
|
808
|
+
timingList: this.timingList,
|
|
809
|
+
cars,
|
|
810
|
+
raceMode,
|
|
811
|
+
penalties,
|
|
812
|
+
driverById: this.driverById,
|
|
813
|
+
selectedId: this.selectedId,
|
|
814
|
+
timingGapMode: this.timingGapMode,
|
|
815
|
+
timingPenaltyBadgesEnabled: this.timingPenaltyBadgesEnabled,
|
|
816
|
+
lastTimingMarkup: this.lastTimingMarkup,
|
|
817
|
+
});
|
|
1820
818
|
}
|
|
1821
819
|
|
|
1822
820
|
getTimingOrderKey(cars = []) {
|
|
1823
|
-
return cars.
|
|
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;
|
|
821
|
+
return getTimingOrderKey(cars, { timingGapMode: this.timingGapMode });
|
|
1849
822
|
}
|
|
1850
823
|
|
|
1851
824
|
getTimingPenaltyKey(penalties = []) {
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1874
|
-
const value = this.timingGapMode === 'leader'
|
|
1875
|
-
? car.leaderGapSeconds
|
|
1876
|
-
: (car.intervalAheadSeconds ?? car.gapAheadSeconds);
|
|
1877
|
-
return Number.isFinite(value) ? `+${Math.max(0, value).toFixed(3)}` : '--';
|
|
1878
|
-
}
|
|
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` : '--';
|
|
825
|
+
return getTimingPenaltyKey(penalties, {
|
|
826
|
+
timingPenaltyBadgesEnabled: this.timingPenaltyBadgesEnabled,
|
|
827
|
+
});
|
|
1895
828
|
}
|
|
1896
829
|
|
|
1897
830
|
syncTimingGapModeControls() {
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
button.setAttribute('aria-pressed', String(active));
|
|
1903
|
-
button.classList?.toggle?.('is-active', active);
|
|
831
|
+
syncTimingGapModeControls({
|
|
832
|
+
readouts: this.readouts,
|
|
833
|
+
buttons: this.timingGapModeButtons,
|
|
834
|
+
timingGapMode: this.timingGapMode,
|
|
1904
835
|
});
|
|
1905
836
|
}
|
|
1906
837
|
|
|
1907
838
|
renderTelemetry(car) {
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
const drsState = car.drsActive ? 'OPEN' : car.drsEligible ? 'READY' : 'OFF';
|
|
1913
|
-
const surface = (car.surface ?? 'track').toUpperCase();
|
|
1914
|
-
|
|
1915
|
-
setTextAll(this.readouts.selectedCode, car.code);
|
|
1916
|
-
this.readouts.selectedCode?.forEach?.((node) => {
|
|
1917
|
-
node.style.color = car.color;
|
|
1918
|
-
});
|
|
1919
|
-
this.readouts.telemetrySectorBanners?.forEach?.((node) => {
|
|
1920
|
-
node.style.setProperty('--driver-color', car.color);
|
|
839
|
+
renderTelemetryReadouts({
|
|
840
|
+
readouts: this.readouts,
|
|
841
|
+
car,
|
|
842
|
+
driverById: this.driverById,
|
|
1921
843
|
});
|
|
1922
|
-
setTextAll(this.readouts.selectedName, car.name);
|
|
1923
|
-
setTextAll(this.readouts.speed, `${Math.round(car.speedKph)} km/h`);
|
|
1924
|
-
setTextAll(this.readouts.throttle, `${Math.round(car.throttle * 100)}%`);
|
|
1925
|
-
setTextAll(this.readouts.brake, `${Math.round(car.brake * 100)}%`);
|
|
1926
|
-
setTextAll(this.readouts.tyres, `${Math.round(car.tireEnergy ?? 100)}%`);
|
|
1927
|
-
setTextAll(this.readouts.selectedDrs, drsState);
|
|
1928
|
-
setTextAll(this.readouts.surface, surface);
|
|
1929
|
-
setTextAll(
|
|
1930
|
-
this.readouts.gap,
|
|
1931
|
-
car.rank === 1
|
|
1932
|
-
? '--'
|
|
1933
|
-
: this.formatTelemetryGap(car, 'interval'),
|
|
1934
|
-
);
|
|
1935
|
-
setTextAll(
|
|
1936
|
-
this.readouts.leaderGap,
|
|
1937
|
-
car.rank === 1
|
|
1938
|
-
? '--'
|
|
1939
|
-
: this.formatTelemetryGap(car, 'leader'),
|
|
1940
|
-
);
|
|
1941
|
-
|
|
1942
844
|
this.renderCarDriverOverview(car);
|
|
1943
845
|
this.renderLapTelemetry(car.lapTelemetry);
|
|
1944
846
|
}
|
|
1945
847
|
|
|
1946
848
|
renderLapTelemetry(telemetry) {
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
setTextAll(this.readouts.currentSector, `S${telemetry.currentSector ?? 1}`);
|
|
1950
|
-
setTextAll(this.readouts.completedLaps, `${telemetry.completedLaps ?? 0} laps`);
|
|
1951
|
-
setTextAll(this.readouts.currentLapTime, formatTelemetryTime(telemetry.currentLapTime));
|
|
1952
|
-
setTextAll(this.readouts.lastLapTime, formatTelemetryTime(telemetry.lastLapTime));
|
|
1953
|
-
setTextAll(this.readouts.bestLapTime, formatTelemetryTime(telemetry.bestLapTime));
|
|
1954
|
-
|
|
1955
|
-
this.readouts.telemetrySectorBars?.forEach((bar) => {
|
|
1956
|
-
const sector = Number(bar.dataset.telemetrySectorBar);
|
|
1957
|
-
const index = sector - 1;
|
|
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;
|
|
1973
|
-
const fillValue = `${fill.toFixed(1)}%`;
|
|
1974
|
-
if (bar.style.getPropertyValue('--sector-fill') !== fillValue) {
|
|
1975
|
-
bar.style.setProperty('--sector-fill', fillValue);
|
|
1976
|
-
}
|
|
1977
|
-
bar.classList.toggle('is-active', isActive);
|
|
1978
|
-
bar.classList.toggle('is-complete', sectorComplete);
|
|
1979
|
-
setPerformanceClass(bar, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
|
|
1980
|
-
});
|
|
1981
|
-
|
|
1982
|
-
this.readouts.telemetrySectorTimes?.forEach((node) => {
|
|
1983
|
-
const sector = Number(node.dataset.telemetrySectorTime);
|
|
1984
|
-
const index = sector - 1;
|
|
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;
|
|
2000
|
-
setText(node, formatTelemetryTime(value));
|
|
2001
|
-
setPerformanceClass(node, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
|
|
2002
|
-
});
|
|
2003
|
-
|
|
2004
|
-
this.readouts.telemetrySectorLast?.forEach((node) => {
|
|
2005
|
-
const index = Number(node.dataset.telemetrySectorLast) - 1;
|
|
2006
|
-
setText(node, formatTelemetryTime(telemetry.lastSectors?.[index]));
|
|
2007
|
-
setPerformanceClass(node, telemetry.sectorPerformance?.last?.[index]);
|
|
2008
|
-
});
|
|
2009
|
-
|
|
2010
|
-
this.readouts.telemetrySectorBest?.forEach((node) => {
|
|
2011
|
-
const index = Number(node.dataset.telemetrySectorBest) - 1;
|
|
2012
|
-
setText(node, formatTelemetryTime(telemetry.bestSectors?.[index]));
|
|
2013
|
-
setPerformanceClass(node, telemetry.sectorPerformance?.best?.[index]);
|
|
2014
|
-
});
|
|
2015
|
-
}
|
|
2016
|
-
|
|
2017
|
-
getOverviewFields(driver, mode) {
|
|
2018
|
-
if (mode === 'driver') {
|
|
2019
|
-
const ratings = driver?.constructorArgs?.driver?.ratings ?? {};
|
|
2020
|
-
return [
|
|
2021
|
-
...DRIVER_OVERVIEW_FIELDS.map(([key, label]) => ({
|
|
2022
|
-
key,
|
|
2023
|
-
label,
|
|
2024
|
-
value: ratings[key] ?? DRIVER_STAT_DEFINITIONS[key]?.neutral,
|
|
2025
|
-
})),
|
|
2026
|
-
...normalizeCustomFields([
|
|
2027
|
-
...(driver?.constructorArgs?.driver?.customFields ?? []),
|
|
2028
|
-
...(driver?.customFields ?? []),
|
|
2029
|
-
]),
|
|
2030
|
-
];
|
|
2031
|
-
}
|
|
2032
|
-
|
|
2033
|
-
const ratings = driver?.constructorArgs?.vehicle?.ratings ?? driver?.vehicle?.ratings ?? {};
|
|
2034
|
-
return [
|
|
2035
|
-
...VEHICLE_OVERVIEW_FIELDS.map(([key, label]) => ({
|
|
2036
|
-
key,
|
|
2037
|
-
label,
|
|
2038
|
-
value: ratings[key] ?? VEHICLE_STAT_DEFINITIONS[key]?.neutral,
|
|
2039
|
-
})),
|
|
2040
|
-
...normalizeCustomFields(driver?.vehicle?.customFields ?? driver?.constructorArgs?.vehicle?.customFields ?? []),
|
|
2041
|
-
];
|
|
849
|
+
renderLapTelemetry(this.readouts, telemetry);
|
|
2042
850
|
}
|
|
2043
851
|
|
|
2044
852
|
renderCarDriverOverview(car) {
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
: [{ label: mode === 'driver' ? 'Driver fields' : 'Car fields', value: 'No custom fields' }];
|
|
2054
|
-
const imageSrc = mode === 'driver'
|
|
2055
|
-
? (driver?.driverImage ?? driver?.portrait ?? driver?.avatar ?? this.assets.driverHelmet)
|
|
2056
|
-
: this.assets.carOverview;
|
|
2057
|
-
const overviewCode = mode === 'driver'
|
|
2058
|
-
? `${car.code} driver`
|
|
2059
|
-
: `${driver?.vehicle?.name ?? car.vehicleName ?? car.code}`;
|
|
2060
|
-
const overviewKey = JSON.stringify({
|
|
2061
|
-
id: car.id,
|
|
2062
|
-
mode,
|
|
2063
|
-
color: car.color,
|
|
2064
|
-
code: car.code,
|
|
2065
|
-
icon,
|
|
2066
|
-
driverNumber,
|
|
2067
|
-
imageSrc,
|
|
2068
|
-
overviewCode,
|
|
2069
|
-
fields: displayFields.map((field) => [field.label, field.value]),
|
|
2070
|
-
});
|
|
2071
|
-
if (overviewKey === this.lastOverviewRenderKey) return;
|
|
2072
|
-
this.lastOverviewRenderKey = overviewKey;
|
|
2073
|
-
|
|
2074
|
-
this.readouts.carOverview?.style.setProperty('--driver-color', car.color);
|
|
2075
|
-
this.readouts.carOverviewDiagram?.style.setProperty('--driver-color', car.color);
|
|
2076
|
-
if (this.readouts.carOverviewTitle) {
|
|
2077
|
-
this.readouts.carOverviewTitle.textContent = mode === 'driver' ? 'Driver overview' : 'Car overview';
|
|
2078
|
-
}
|
|
2079
|
-
if (this.readouts.carOverviewCode) {
|
|
2080
|
-
this.readouts.carOverviewCode.textContent = overviewCode;
|
|
2081
|
-
}
|
|
2082
|
-
if (this.readouts.carOverviewIcon) this.readouts.carOverviewIcon.textContent = icon;
|
|
2083
|
-
if (this.readouts.carOverviewImage) {
|
|
2084
|
-
this.readouts.carOverviewImage.src = imageSrc;
|
|
2085
|
-
}
|
|
2086
|
-
if (this.readouts.carOverviewNumber) this.readouts.carOverviewNumber.textContent = driverNumber;
|
|
2087
|
-
if (this.readouts.carOverviewCoreStat) this.readouts.carOverviewCoreStat.textContent = mode === 'driver' ? 'Driver' : 'Car';
|
|
2088
|
-
this.readouts.carOverviewFields.forEach((fieldNode, index) => {
|
|
2089
|
-
const field = displayFields[index];
|
|
2090
|
-
fieldNode.hidden = !field;
|
|
2091
|
-
if (!field) return;
|
|
2092
|
-
const labelNode = fieldNode.querySelector('[data-overview-field-label]');
|
|
2093
|
-
const valueNode = fieldNode.querySelector('[data-overview-field-value]');
|
|
2094
|
-
setText(labelNode, field.label);
|
|
2095
|
-
setText(valueNode, field.value);
|
|
853
|
+
const previousKey = this.lastOverviewRenderKey;
|
|
854
|
+
this.lastOverviewRenderKey = renderCarDriverOverview({
|
|
855
|
+
readouts: this.readouts,
|
|
856
|
+
car,
|
|
857
|
+
driverById: this.driverById,
|
|
858
|
+
assets: this.assets,
|
|
859
|
+
overviewMode: this.overviewMode,
|
|
860
|
+
lastOverviewRenderKey: this.lastOverviewRenderKey,
|
|
2096
861
|
});
|
|
2097
|
-
this.updateOverviewModeButtons();
|
|
862
|
+
if (this.lastOverviewRenderKey !== previousKey) this.updateOverviewModeButtons();
|
|
2098
863
|
}
|
|
2099
864
|
|
|
2100
865
|
renderRaceData(car) {
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
setText(this.readouts.raceDataKicker, 'Project');
|
|
2111
|
-
setText(this.readouts.raceDataTitle, driver.name);
|
|
2112
|
-
if (this.readouts.raceDataNumber) {
|
|
2113
|
-
this.readouts.raceDataNumber.textContent = formatDriverNumber(car.driverNumber ?? driver.driverNumber);
|
|
2114
|
-
}
|
|
2115
|
-
if (this.readouts.raceDataSubtitle) {
|
|
2116
|
-
this.readouts.raceDataSubtitle.textContent = `${car.code} - P${car.rank} - ${driver.raceData?.[0] ?? 'Project entry'}`;
|
|
2117
|
-
}
|
|
2118
|
-
if (this.readouts.raceDataOpen) {
|
|
2119
|
-
this.readouts.raceDataOpen.hidden = typeof this.options.onDriverOpen !== 'function';
|
|
2120
|
-
}
|
|
866
|
+
renderRaceData({
|
|
867
|
+
car,
|
|
868
|
+
drivers: this.drivers,
|
|
869
|
+
readouts: this.readouts,
|
|
870
|
+
options: {
|
|
871
|
+
...this.options,
|
|
872
|
+
isRaceDataBannerEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
|
|
873
|
+
},
|
|
874
|
+
});
|
|
2121
875
|
}
|
|
2122
876
|
|
|
2123
877
|
shouldAutoHideActiveRaceData() {
|
|
2124
|
-
return
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
this.options.ui?.raceDataTelemetryDetail ||
|
|
2130
|
-
this.readouts.raceDataPanel?.classList?.contains?.('race-data-panel--with-telemetry'),
|
|
2131
|
-
);
|
|
878
|
+
return shouldAutoHideActiveRaceData({
|
|
879
|
+
activeRaceDataId: this.activeRaceDataId,
|
|
880
|
+
options: this.options,
|
|
881
|
+
readouts: this.readouts,
|
|
882
|
+
});
|
|
2132
883
|
}
|
|
2133
884
|
|
|
2134
885
|
renderProjectRadio(now = performance.now()) {
|
|
@@ -2143,26 +894,14 @@ export class F1SimulatorApp {
|
|
|
2143
894
|
return;
|
|
2144
895
|
}
|
|
2145
896
|
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
this.readouts.raceDataPanel.classList.add('is-radio-mode');
|
|
2151
|
-
this.readouts.raceDataPanel.classList.remove('is-project-mode');
|
|
2152
|
-
this.readouts.raceDataPanel.dataset.idleMode = 'quote';
|
|
2153
|
-
setText(this.readouts.raceDataKicker, 'Project radio');
|
|
2154
|
-
setText(this.readouts.raceDataTitle, radio.title);
|
|
2155
|
-
setText(this.readouts.raceDataNumber, '');
|
|
2156
|
-
setText(this.readouts.raceDataSubtitle, radio.subtitle);
|
|
2157
|
-
if (this.readouts.raceDataOpen) this.readouts.raceDataOpen.hidden = true;
|
|
897
|
+
renderProjectRadio({
|
|
898
|
+
readouts: this.readouts,
|
|
899
|
+
radio: this.getProjectRadioQuote(),
|
|
900
|
+
});
|
|
2158
901
|
}
|
|
2159
902
|
|
|
2160
903
|
hideRaceDataPanel() {
|
|
2161
|
-
|
|
2162
|
-
this.readouts.raceDataPanel.classList.add('is-hidden');
|
|
2163
|
-
this.readouts.raceDataPanel.classList.remove('is-project-mode', 'is-radio-mode');
|
|
2164
|
-
this.readouts.raceDataPanel.removeAttribute('data-idle-mode');
|
|
2165
|
-
if (this.readouts.raceDataOpen) this.readouts.raceDataOpen.hidden = true;
|
|
904
|
+
hideRaceDataPanel(this.readouts);
|
|
2166
905
|
}
|
|
2167
906
|
|
|
2168
907
|
dismissRaceDataPanel(now = performance.now()) {
|
|
@@ -2174,85 +913,33 @@ export class F1SimulatorApp {
|
|
|
2174
913
|
}
|
|
2175
914
|
|
|
2176
915
|
updateStewardMessageState(snapshot, now = performance.now()) {
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
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
|
-
};
|
|
916
|
+
const state = updateStewardMessageState({
|
|
917
|
+
snapshot,
|
|
918
|
+
now,
|
|
919
|
+
state: {
|
|
920
|
+
activeStewardMessage: this.activeStewardMessage,
|
|
921
|
+
lastPenaltyBannerId: this.lastPenaltyBannerId,
|
|
922
|
+
lastStewardMessageKey: this.lastStewardMessageKey,
|
|
923
|
+
},
|
|
924
|
+
driverById: this.driverById,
|
|
925
|
+
penaltyBannerEnabled: this.penaltyBannerEnabled,
|
|
926
|
+
stewardMessageNode: this.readouts.stewardMessage,
|
|
927
|
+
});
|
|
928
|
+
this.activeStewardMessage = state.activeStewardMessage;
|
|
929
|
+
this.lastPenaltyBannerId = state.lastPenaltyBannerId;
|
|
930
|
+
this.lastStewardMessageKey = state.lastStewardMessageKey;
|
|
2201
931
|
}
|
|
2202
932
|
|
|
2203
933
|
createPenaltyStewardMessage(penalty) {
|
|
2204
|
-
|
|
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
|
-
};
|
|
934
|
+
return createPenaltyStewardMessage(penalty, this.driverById);
|
|
2218
935
|
}
|
|
2219
936
|
|
|
2220
937
|
createWarningStewardMessage(event) {
|
|
2221
|
-
|
|
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
|
-
};
|
|
938
|
+
return createWarningStewardMessage(event, this.driverById);
|
|
2239
939
|
}
|
|
2240
940
|
|
|
2241
941
|
renderActiveStewardMessage() {
|
|
2242
|
-
|
|
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);
|
|
942
|
+
renderActiveStewardMessage(this.readouts, this.activeStewardMessage);
|
|
2256
943
|
}
|
|
2257
944
|
|
|
2258
945
|
updateRadioSchedule(now) {
|
|
@@ -2279,26 +966,22 @@ export class F1SimulatorApp {
|
|
|
2279
966
|
}
|
|
2280
967
|
|
|
2281
968
|
scheduleRadioBreak(now) {
|
|
2282
|
-
|
|
2283
|
-
|
|
969
|
+
scheduleRadioBreak({
|
|
970
|
+
radioState: this.radioState,
|
|
971
|
+
now,
|
|
972
|
+
isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
|
|
973
|
+
nextRandom: () => this.nextRadioRandom(),
|
|
974
|
+
});
|
|
2284
975
|
}
|
|
2285
976
|
|
|
2286
977
|
scheduleRadioPopup(now) {
|
|
2287
|
-
|
|
2288
|
-
this.
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
this.radioState.visible = true;
|
|
2295
|
-
this.radioState.driverIndex = driverIndex;
|
|
2296
|
-
this.radioState.quoteIndex = Math.floor(this.nextRadioRandom() * quoteCount);
|
|
2297
|
-
this.radioState.nextChangeAt = now + this.randomRadioRange(RADIO_VISIBLE_MIN_MS, RADIO_VISIBLE_MAX_MS);
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
randomRadioRange(min, max) {
|
|
2301
|
-
return min + this.nextRadioRandom() * (max - min);
|
|
978
|
+
scheduleRadioPopup({
|
|
979
|
+
radioState: this.radioState,
|
|
980
|
+
now,
|
|
981
|
+
drivers: this.drivers,
|
|
982
|
+
isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
|
|
983
|
+
nextRandom: () => this.nextRadioRandom(),
|
|
984
|
+
});
|
|
2302
985
|
}
|
|
2303
986
|
|
|
2304
987
|
nextRadioRandom() {
|
|
@@ -2307,24 +990,22 @@ export class F1SimulatorApp {
|
|
|
2307
990
|
}
|
|
2308
991
|
|
|
2309
992
|
getNextRadioBreakTime(now) {
|
|
2310
|
-
return
|
|
2311
|
-
|
|
2312
|
-
:
|
|
993
|
+
return getNextRadioBreakTime({
|
|
994
|
+
now,
|
|
995
|
+
isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
|
|
996
|
+
nextRandom: () => this.nextRadioRandom(),
|
|
997
|
+
});
|
|
2313
998
|
}
|
|
2314
999
|
|
|
2315
1000
|
resetRaceDataBannerState(now = performance.now()) {
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
:
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
? now + this.randomRadioRange(RADIO_VISIBLE_MIN_MS, RADIO_VISIBLE_MAX_MS)
|
|
2325
|
-
: this.getNextRadioBreakTime(now);
|
|
2326
|
-
this.radioState.driverIndex = 0;
|
|
2327
|
-
this.radioState.quoteIndex = 0;
|
|
1001
|
+
resetRaceDataBannerState({
|
|
1002
|
+
state: this,
|
|
1003
|
+
now,
|
|
1004
|
+
initialMode: this.raceDataBannerConfig.initial ?? 'project',
|
|
1005
|
+
selectedId: this.selectedId,
|
|
1006
|
+
isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
|
|
1007
|
+
nextRandom: () => this.nextRadioRandom(),
|
|
1008
|
+
});
|
|
2328
1009
|
}
|
|
2329
1010
|
|
|
2330
1011
|
isRaceDataBannerEnabled(kind) {
|
|
@@ -2332,66 +1013,41 @@ export class F1SimulatorApp {
|
|
|
2332
1013
|
}
|
|
2333
1014
|
|
|
2334
1015
|
getProjectRadioQuote() {
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
color: driver.color,
|
|
2340
|
-
title: driver.name,
|
|
2341
|
-
subtitle: `${driver.code} - "${quote}"`,
|
|
2342
|
-
};
|
|
1016
|
+
return getProjectRadioQuote({
|
|
1017
|
+
drivers: this.drivers,
|
|
1018
|
+
radioState: this.radioState,
|
|
1019
|
+
});
|
|
2343
1020
|
}
|
|
2344
1021
|
|
|
2345
1022
|
updateCameraControls(snapshot = this.sim?.snapshot?.()) {
|
|
2346
|
-
|
|
2347
|
-
this.camera
|
|
2348
|
-
this.
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
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;
|
|
2361
|
-
button.classList.toggle('is-active', isActive);
|
|
2362
|
-
button.setAttribute('aria-pressed', String(isActive));
|
|
1023
|
+
updateCameraControlButtons({
|
|
1024
|
+
camera: this.camera,
|
|
1025
|
+
cameraButtons: this.cameraButtons,
|
|
1026
|
+
snapshot,
|
|
1027
|
+
hasPitCamera: (currentSnapshot) => this.hasPitCamera(currentSnapshot),
|
|
1028
|
+
isCameraModeAvailable: (mode, currentSnapshot) => this.isCameraModeAvailable(mode, currentSnapshot),
|
|
2363
1029
|
});
|
|
2364
1030
|
this.updateBannerMuteControls();
|
|
2365
1031
|
}
|
|
2366
1032
|
|
|
2367
1033
|
updateBannerMuteControls() {
|
|
2368
|
-
this.bannerMuteButtons
|
|
2369
|
-
button.classList.toggle('is-active', this.raceDataBannersMuted);
|
|
2370
|
-
button.setAttribute('aria-pressed', String(this.raceDataBannersMuted));
|
|
2371
|
-
});
|
|
1034
|
+
updateToggleButtons(this.bannerMuteButtons, this.raceDataBannersMuted);
|
|
2372
1035
|
}
|
|
2373
1036
|
|
|
2374
1037
|
hasPitCamera(snapshot = this.sim?.snapshot?.()) {
|
|
2375
|
-
return
|
|
1038
|
+
return this.cameraController.hasPitCamera(snapshot);
|
|
2376
1039
|
}
|
|
2377
1040
|
|
|
2378
1041
|
isCameraModeAvailable(mode, snapshot = this.sim?.snapshot?.()) {
|
|
2379
|
-
return
|
|
1042
|
+
return this.cameraController.isModeAvailable(mode, snapshot);
|
|
2380
1043
|
}
|
|
2381
1044
|
|
|
2382
1045
|
updateOverviewModeButtons() {
|
|
2383
|
-
this.overviewModeButtons.
|
|
2384
|
-
const isActive = button.dataset.overviewMode === this.overviewMode;
|
|
2385
|
-
button.classList.toggle('is-active', isActive);
|
|
2386
|
-
button.setAttribute('aria-pressed', String(isActive));
|
|
2387
|
-
});
|
|
1046
|
+
updateModeButtons(this.overviewModeButtons, this.overviewMode, 'overviewMode');
|
|
2388
1047
|
}
|
|
2389
1048
|
|
|
2390
1049
|
syncSafetyCarControls(active) {
|
|
2391
|
-
this.safetyButtons
|
|
2392
|
-
button.classList.toggle('is-active', active);
|
|
2393
|
-
button.setAttribute('aria-pressed', String(active));
|
|
2394
|
-
});
|
|
1050
|
+
updateToggleButtons(this.safetyButtons, active);
|
|
2395
1051
|
}
|
|
2396
1052
|
|
|
2397
1053
|
applyExpertOptions(nextOptions = {}) {
|
|
@@ -2434,6 +1090,8 @@ export class F1SimulatorApp {
|
|
|
2434
1090
|
if (hasOwnOption(nextOptions, 'expert') && expertOptionsChanged(nextOptions.expert, this.options.expert)) {
|
|
2435
1091
|
throw new Error('PaddockJS restart() does not support changing expert mode. Destroy and mount a new simulator with the new expert options.');
|
|
2436
1092
|
}
|
|
1093
|
+
this.expert?.destroy?.();
|
|
1094
|
+
this.expert = null;
|
|
2437
1095
|
this.applyExpertOptions(nextOptions);
|
|
2438
1096
|
this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
|
|
2439
1097
|
this.penaltyBannerEnabled = Boolean(this.options.ui?.penaltyBanners);
|
|
@@ -2526,6 +1184,9 @@ export class F1SimulatorApp {
|
|
|
2526
1184
|
if (this.app && this.tickerCallback) {
|
|
2527
1185
|
this.app.ticker.remove(this.tickerCallback);
|
|
2528
1186
|
}
|
|
1187
|
+
this.expert?.destroy?.();
|
|
1188
|
+
this.expert = null;
|
|
1189
|
+
this.replayGhostRenderer.destroy();
|
|
2529
1190
|
this.app?.destroy(true, { children: true, texture: false });
|
|
2530
1191
|
this.app = null;
|
|
2531
1192
|
this.carSprites.clear();
|