@inventure71/paddockjs 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/INSTALL_AND_UPDATE.md +4 -3
- package/README.md +194 -12
- package/docs/architecture.md +119 -44
- package/docs/concepts.md +4 -2
- package/docs/custom_model_controller.md +329 -0
- package/docs/data_contract.md +299 -17
- package/docs/index.md +1 -0
- package/docs/learnings.md +22 -0
- package/docs/rules.md +37 -10
- package/docs/system_specs.md +23 -10
- package/docs/training.md +272 -30
- 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 +4 -0
- package/src/app/BrowserExpertAdapter.js +367 -9
- package/src/app/F1SimulatorApp.js +305 -1603
- 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/environment/actions.js +13 -5
- package/src/environment/controllerLoop.js +246 -0
- package/src/environment/episode.js +86 -2
- package/src/environment/evaluation.js +54 -32
- package/src/environment/index.d.ts +361 -9
- package/src/environment/index.js +1 -0
- package/src/environment/metrics.js +159 -0
- package/src/environment/observationOptions.js +22 -1
- package/src/environment/observations.js +325 -40
- package/src/environment/options.js +37 -5
- package/src/environment/runtime.js +222 -21
- package/src/environment/scenarios.js +37 -8
- 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 +65 -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 +60 -0
- package/src/environment/sensors/surfaceRays.js +296 -0
- package/src/environment/sensors/trackRays.js +261 -0
- package/src/environment/sensors.js +1 -367
- package/src/environment/specs.js +100 -5
- package/src/environment/workerProtocol.js +13 -1
- package/src/index.d.ts +169 -6
- package/src/index.js +5 -0
- 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 +234 -0
- package/src/simulation/pit/pitIntent.js +92 -0
- package/src/simulation/pit/pitOccupancy.js +47 -0
- package/src/simulation/pit/pitPenaltyService.js +71 -0
- package/src/simulation/pit/pitQueue.js +55 -0
- package/src/simulation/pit/pitRouteMovement.js +160 -0
- package/src/simulation/pit/pitRouting.js +319 -0
- package/src/simulation/pit/pitService.js +41 -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 +92 -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 +71 -0
- package/src/simulation/race/raceOrder.js +113 -0
- package/src/simulation/race/raceProgress.js +124 -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 +86 -0
- package/src/simulation/race/stalledDnf.js +86 -0
- package/src/simulation/raceSimulation.js +230 -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 +141 -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 +192 -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/trackQueryIndex.js +945 -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 +116 -0
- package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
- package/src/simulation/vehicle/pitWheelSurface.js +69 -0
- package/src/simulation/vehicle/runoffResponse.js +88 -0
- package/src/simulation/vehicle/surfacePriority.js +28 -0
- package/src/simulation/vehicle/vehicleGeometry.js +247 -0
- package/src/simulation/vehicle/vehiclePhysics.js +363 -0
- package/src/simulation/vehicle/vehicleSnapshots.js +316 -0
- package/src/simulation/vehicle/vehicleState.js +186 -0
- package/src/simulation/vehicle/wheelSurface.js +150 -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,70 @@
|
|
|
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
|
+
formatLapGap,
|
|
20
|
+
formatRaceGap,
|
|
21
|
+
getPenaltyByDriver,
|
|
22
|
+
getTimingOrderKey,
|
|
23
|
+
getTimingPenaltyKey,
|
|
24
|
+
isWavedFlagCar,
|
|
25
|
+
renderTimingTower,
|
|
26
|
+
syncTimingGapModeControls,
|
|
27
|
+
} from './readouts/timingTowerRenderer.js';
|
|
28
|
+
import { formatTelemetryGap } from './readouts/readoutFormatters.js';
|
|
29
|
+
import { getOverviewFields, renderCarDriverOverview } from './readouts/carOverviewRenderer.js';
|
|
30
|
+
import { renderLapTelemetry, renderTelemetryReadouts } from './readouts/telemetryRenderer.js';
|
|
31
|
+
import {
|
|
32
|
+
renderRaceFinish,
|
|
33
|
+
renderRaceStatusReadouts,
|
|
34
|
+
renderStartLights,
|
|
35
|
+
} from './readouts/raceStatusRenderer.js';
|
|
36
|
+
import {
|
|
37
|
+
updateCameraControlButtons,
|
|
38
|
+
updateModeButtons,
|
|
39
|
+
updateToggleButtons,
|
|
40
|
+
} from './readouts/controlStateRenderer.js';
|
|
41
|
+
import {
|
|
42
|
+
createPenaltyStewardMessage,
|
|
43
|
+
createWarningStewardMessage,
|
|
44
|
+
renderActiveStewardMessage,
|
|
45
|
+
updateStewardMessageState,
|
|
46
|
+
} from './banners/stewardMessageController.js';
|
|
47
|
+
import {
|
|
48
|
+
hideRaceDataPanel,
|
|
49
|
+
getProjectRadioQuote,
|
|
50
|
+
RACE_DATA_SELECTED_VISIBLE_MS,
|
|
51
|
+
RADIO_SCHEDULE_CATCHUP_LIMIT_MS,
|
|
52
|
+
renderProjectRadio,
|
|
53
|
+
renderRaceData,
|
|
54
|
+
resetRaceDataBannerState,
|
|
55
|
+
scheduleRadioBreak,
|
|
56
|
+
scheduleRadioPopup,
|
|
57
|
+
shouldAutoHideActiveRaceData,
|
|
58
|
+
getNextRadioBreakTime,
|
|
59
|
+
} from './banners/raceDataBannerController.js';
|
|
60
|
+
import { runFrameLoopTick } from './runtime/frameLoop.js';
|
|
61
|
+
import { observeRuntimeVisibility, syncRuntimeTicker } from './runtime/runtimeVisibility.js';
|
|
62
|
+
import { TARGET_FRAME_MS, domUpdateIntervalForSpeed, timingUpdateIntervalForSpeed } from './runtime/runtimeTiming.js';
|
|
63
|
+
import { normalizeWarmupOptions } from '../simulation/warmup/runtimeWarmup.js';
|
|
64
|
+
|
|
27
65
|
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
66
|
const DRS_DRAG_REDUCTION_PERCENT = 58;
|
|
74
67
|
|
|
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
68
|
function createMountTrackSeed() {
|
|
95
69
|
const values = new Uint32Array(1);
|
|
96
70
|
try {
|
|
@@ -101,199 +75,25 @@ function createMountTrackSeed() {
|
|
|
101
75
|
return (values[0] || Math.floor(Date.now() + performance.now() * 1000)) >>> 0;
|
|
102
76
|
}
|
|
103
77
|
|
|
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
78
|
function hasOwnOption(options, key) {
|
|
266
79
|
return Object.hasOwn(options ?? {}, key);
|
|
267
80
|
}
|
|
268
81
|
|
|
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
82
|
function expertOptionsChanged(nextExpertOptions, currentExpertOptions) {
|
|
294
83
|
return nextExpertOptions !== currentExpertOptions;
|
|
295
84
|
}
|
|
296
85
|
|
|
86
|
+
function withSelectedExpertSensorDriver(visualizeSensors, selectedDriverId) {
|
|
87
|
+
if (!visualizeSensors) return visualizeSensors;
|
|
88
|
+
if (visualizeSensors === true) return { rays: true, selectedDriverId };
|
|
89
|
+
if (typeof visualizeSensors !== 'object') return visualizeSensors;
|
|
90
|
+
if (visualizeSensors.drivers === 'all' || Array.isArray(visualizeSensors.drivers)) return visualizeSensors;
|
|
91
|
+
return {
|
|
92
|
+
...visualizeSensors,
|
|
93
|
+
selectedDriverId,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
297
97
|
function assetSetsEqual(first = {}, second = {}) {
|
|
298
98
|
const firstTrackTextures = first.trackTextures ?? {};
|
|
299
99
|
const secondTrackTextures = second.trackTextures ?? {};
|
|
@@ -317,7 +117,7 @@ export class F1SimulatorApp {
|
|
|
317
117
|
this.abortController = new AbortController();
|
|
318
118
|
this.resizeHandler = null;
|
|
319
119
|
this.layoutResizeObserver = null;
|
|
320
|
-
this.
|
|
120
|
+
this.cameraController = null;
|
|
321
121
|
this.visibilityChangeHandler = null;
|
|
322
122
|
this.visibilityObserver = null;
|
|
323
123
|
this.tickerCallback = null;
|
|
@@ -331,12 +131,22 @@ export class F1SimulatorApp {
|
|
|
331
131
|
this.trailLayer = null;
|
|
332
132
|
this.sensorLayer = null;
|
|
333
133
|
this.pitLaneStatusLayer = null;
|
|
134
|
+
this.replayGhostLayer = null;
|
|
334
135
|
this.carLayer = null;
|
|
335
136
|
this.textures = {};
|
|
336
137
|
this.carSprites = new Map();
|
|
337
138
|
this.carHitAreas = new Map();
|
|
338
139
|
this.serviceCountdownLabels = new Map();
|
|
140
|
+
this.carRenderer = new CarRenderer({
|
|
141
|
+
carSprites: this.carSprites,
|
|
142
|
+
carHitAreas: this.carHitAreas,
|
|
143
|
+
serviceCountdownLabels: this.serviceCountdownLabels,
|
|
144
|
+
onSelectCar: (driverId) => this.selectCar(driverId, { focus: true }),
|
|
145
|
+
});
|
|
339
146
|
this.drsTrails = new Map();
|
|
147
|
+
this.drsTrailRenderer = new DrsTrailRenderer({ trails: this.drsTrails });
|
|
148
|
+
this.replayGhostRenderer = new ReplayGhostRenderer();
|
|
149
|
+
this.pitLaneStatusRenderer = new PitLaneStatusRenderer();
|
|
340
150
|
this.trackSeed = options.trackSeed ?? createMountTrackSeed();
|
|
341
151
|
this.selectedId = this.drivers[0]?.id ?? null;
|
|
342
152
|
this.raceDataBannerConfig = options.ui?.raceDataBanners ?? { initial: 'project', enabled: ['project', 'radio'] };
|
|
@@ -357,16 +167,12 @@ export class F1SimulatorApp {
|
|
|
357
167
|
quoteIndex: 0,
|
|
358
168
|
};
|
|
359
169
|
this.resetRaceDataBannerState(this.lastRaceDataInteraction);
|
|
360
|
-
this.
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
y: WORLD.height / 2,
|
|
367
|
-
free: false,
|
|
368
|
-
freeTarget: null,
|
|
369
|
-
};
|
|
170
|
+
this.cameraController = new CameraController({
|
|
171
|
+
canvasHost: this.canvasHost,
|
|
172
|
+
readouts: this.readouts,
|
|
173
|
+
initialMode: options.initialCameraMode,
|
|
174
|
+
});
|
|
175
|
+
this.camera = this.cameraController.camera;
|
|
370
176
|
this.overviewMode = 'vehicle';
|
|
371
177
|
this.simulationSpeed = 1;
|
|
372
178
|
this.accumulator = 0;
|
|
@@ -378,9 +184,6 @@ export class F1SimulatorApp {
|
|
|
378
184
|
this.lastTimingPenaltyKey = '';
|
|
379
185
|
this.lastTimingOrderKey = '';
|
|
380
186
|
this.lastTimingMarkup = null;
|
|
381
|
-
this.lastPitLaneStatusRenderKey = null;
|
|
382
|
-
this.pitCameraBoundsCache = null;
|
|
383
|
-
this.trackCameraBoundsCache = null;
|
|
384
187
|
this.lastOverviewRenderKey = null;
|
|
385
188
|
this.lastFinishClassificationMarkup = null;
|
|
386
189
|
this.runtimeViewportVisible = true;
|
|
@@ -391,6 +194,7 @@ export class F1SimulatorApp {
|
|
|
391
194
|
current: 0,
|
|
392
195
|
lastSample: this.lastTime,
|
|
393
196
|
};
|
|
197
|
+
this.renderSnapshotBuffer = {};
|
|
394
198
|
this.lastLeaderLap = null;
|
|
395
199
|
this.emittedRaceEventKeys = new Set();
|
|
396
200
|
this.raceFinishEmitted = false;
|
|
@@ -423,6 +227,7 @@ export class F1SimulatorApp {
|
|
|
423
227
|
this.trailLayer = new Graphics();
|
|
424
228
|
this.sensorLayer = new Graphics();
|
|
425
229
|
this.pitLaneStatusLayer = new Graphics();
|
|
230
|
+
this.replayGhostLayer = new Container();
|
|
426
231
|
this.carLayer = new Container();
|
|
427
232
|
this.app.stage.addChild(this.worldLayer);
|
|
428
233
|
|
|
@@ -434,6 +239,7 @@ export class F1SimulatorApp {
|
|
|
434
239
|
this.trailLayer,
|
|
435
240
|
this.pitLaneStatusLayer,
|
|
436
241
|
this.sensorLayer,
|
|
242
|
+
this.replayGhostLayer,
|
|
437
243
|
this.carLayer,
|
|
438
244
|
);
|
|
439
245
|
this.createCars();
|
|
@@ -452,7 +258,9 @@ export class F1SimulatorApp {
|
|
|
452
258
|
this.resizeHandler = () => this.applyCamera(this.sim.snapshotRender?.() ?? this.sim.snapshot());
|
|
453
259
|
window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
|
|
454
260
|
this.observeLayoutResize();
|
|
455
|
-
if (
|
|
261
|
+
if (this.expertMode) {
|
|
262
|
+
this.app.ticker.stop?.();
|
|
263
|
+
} else {
|
|
456
264
|
this.resetFrameClock();
|
|
457
265
|
this.tickerCallback = () => this.tick();
|
|
458
266
|
this.app.ticker.add(this.tickerCallback);
|
|
@@ -494,167 +302,41 @@ export class F1SimulatorApp {
|
|
|
494
302
|
return createRaceSimulation({
|
|
495
303
|
seed: options.seed,
|
|
496
304
|
trackSeed: options.trackSeed ?? this.trackSeed,
|
|
305
|
+
trackGeneration: options.trackGeneration,
|
|
306
|
+
physicsMode: options.physicsMode,
|
|
497
307
|
drivers: options.drivers ?? this.drivers,
|
|
498
308
|
totalLaps: options.totalLaps,
|
|
499
309
|
rules: options.rules,
|
|
310
|
+
participantInteractions: options.participantInteractions,
|
|
311
|
+
replayGhosts: options.replayGhosts,
|
|
312
|
+
trackQueryIndex: options.trackQueryIndex,
|
|
313
|
+
warmup: normalizeWarmupOptions(options.warmup, 'browser'),
|
|
500
314
|
});
|
|
501
315
|
}
|
|
502
316
|
|
|
503
317
|
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
|
-
}));
|
|
318
|
+
this.textures = await loadAppTextures(this.assets);
|
|
530
319
|
}
|
|
531
320
|
|
|
532
321
|
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
322
|
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);
|
|
323
|
+
this.carRenderer.createCars({
|
|
324
|
+
drivers: this.drivers,
|
|
325
|
+
textures: this.textures,
|
|
326
|
+
carLayer: this.carLayer,
|
|
586
327
|
});
|
|
587
328
|
}
|
|
588
329
|
|
|
589
330
|
createRawCarGeometryGraphic(driver) {
|
|
590
|
-
|
|
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;
|
|
331
|
+
return this.carRenderer.createRawCarGeometryGraphic(driver);
|
|
625
332
|
}
|
|
626
333
|
|
|
627
334
|
createServiceCountdownLabel() {
|
|
628
|
-
|
|
629
|
-
const background = new Graphics();
|
|
630
|
-
const text = new Text({
|
|
631
|
-
text: '0s',
|
|
632
|
-
style: {
|
|
633
|
-
fill: 0xffffff,
|
|
634
|
-
fontFamily: 'Arial, sans-serif',
|
|
635
|
-
fontSize: 16,
|
|
636
|
-
fontWeight: '900',
|
|
637
|
-
align: 'center',
|
|
638
|
-
},
|
|
639
|
-
});
|
|
640
|
-
text.anchor.set(0.5);
|
|
641
|
-
text.resolution = 2;
|
|
642
|
-
container.addChild(background, text);
|
|
643
|
-
container.labelBackground = background;
|
|
644
|
-
container.labelText = text;
|
|
645
|
-
this.applyServiceCountdownTone(container, 'pit');
|
|
646
|
-
return container;
|
|
335
|
+
return this.carRenderer.createServiceCountdownLabel();
|
|
647
336
|
}
|
|
648
337
|
|
|
649
338
|
applyServiceCountdownTone(label, tone) {
|
|
650
|
-
|
|
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;
|
|
339
|
+
this.carRenderer.applyServiceCountdownTone(label, tone);
|
|
658
340
|
}
|
|
659
341
|
|
|
660
342
|
bindControls() {
|
|
@@ -752,18 +434,13 @@ export class F1SimulatorApp {
|
|
|
752
434
|
|
|
753
435
|
setCameraMode(mode) {
|
|
754
436
|
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];
|
|
437
|
+
if (!this.cameraController.setMode(mode, snapshot)) return false;
|
|
760
438
|
this.updateCameraControls(snapshot);
|
|
761
439
|
return true;
|
|
762
440
|
}
|
|
763
441
|
|
|
764
442
|
adjustCameraZoom(direction) {
|
|
765
|
-
|
|
766
|
-
this.camera.zoom = clamp(this.camera.zoom + steps * CAMERA_ZOOM_STEP, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
|
|
443
|
+
this.cameraController.adjustZoom(direction);
|
|
767
444
|
this.updateCameraControls();
|
|
768
445
|
return this.camera.zoom;
|
|
769
446
|
}
|
|
@@ -834,7 +511,7 @@ export class F1SimulatorApp {
|
|
|
834
511
|
}
|
|
835
512
|
|
|
836
513
|
renderInitialFrame(snapshot) {
|
|
837
|
-
const renderSnapshot =
|
|
514
|
+
const renderSnapshot = interpolateRenderSnapshotInto(this.renderSnapshotBuffer, snapshot, 0);
|
|
838
515
|
this.resizeRendererToCanvasHost();
|
|
839
516
|
this.applyCamera(renderSnapshot, { immediate: true });
|
|
840
517
|
this.renderDrsTrails(renderSnapshot);
|
|
@@ -847,7 +524,11 @@ export class F1SimulatorApp {
|
|
|
847
524
|
this.resizeRendererToCanvasHost();
|
|
848
525
|
const snapshot = this.sim?.snapshotRender?.() ?? this.sim?.snapshot?.();
|
|
849
526
|
if (!snapshot) return;
|
|
850
|
-
const renderSnapshot =
|
|
527
|
+
const renderSnapshot = interpolateRenderSnapshotInto(
|
|
528
|
+
this.renderSnapshotBuffer,
|
|
529
|
+
snapshot,
|
|
530
|
+
clamp(this.accumulator / FIXED_STEP, 0, 1),
|
|
531
|
+
);
|
|
851
532
|
this.applyCamera(renderSnapshot);
|
|
852
533
|
if (render) {
|
|
853
534
|
this.renderDrsTrails(renderSnapshot);
|
|
@@ -881,53 +562,15 @@ export class F1SimulatorApp {
|
|
|
881
562
|
}
|
|
882
563
|
|
|
883
564
|
invalidateCameraSafeArea() {
|
|
884
|
-
this.
|
|
565
|
+
this.cameraController.invalidateSafeArea();
|
|
885
566
|
}
|
|
886
567
|
|
|
887
568
|
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();
|
|
569
|
+
observeRuntimeVisibility(this);
|
|
915
570
|
}
|
|
916
571
|
|
|
917
572
|
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
|
-
}
|
|
573
|
+
syncRuntimeTicker(this);
|
|
931
574
|
}
|
|
932
575
|
|
|
933
576
|
resetFrameClock(now = performance.now()) {
|
|
@@ -940,612 +583,107 @@ export class F1SimulatorApp {
|
|
|
940
583
|
}
|
|
941
584
|
|
|
942
585
|
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
|
-
}
|
|
586
|
+
runFrameLoopTick(this);
|
|
991
587
|
}
|
|
992
588
|
|
|
993
|
-
renderExpertFrame(snapshot = this.sim?.snapshot(), { observation } = {}) {
|
|
589
|
+
renderExpertFrame(snapshot = this.sim?.snapshot(), { forceDomUpdate = false, observation } = {}) {
|
|
994
590
|
if (!snapshot) return;
|
|
995
|
-
const
|
|
591
|
+
const now = performance.now();
|
|
592
|
+
const renderSnapshot = interpolateRenderSnapshotInto(this.renderSnapshotBuffer, snapshot, 0);
|
|
996
593
|
this.applyCamera(renderSnapshot);
|
|
997
594
|
this.renderDrsTrails(renderSnapshot);
|
|
998
595
|
this.renderExpertSensorRays(renderSnapshot, observation);
|
|
999
596
|
this.renderPitLaneStatus(renderSnapshot);
|
|
1000
597
|
this.renderCars(renderSnapshot);
|
|
1001
|
-
this.
|
|
1002
|
-
this.lastDomUpdateTime
|
|
598
|
+
this.app?.render?.();
|
|
599
|
+
if (forceDomUpdate || now - this.lastDomUpdateTime >= domUpdateIntervalForSpeed(this.simulationSpeed)) {
|
|
600
|
+
this.updateDom(snapshot, { emitLifecycle: false });
|
|
601
|
+
this.lastDomUpdateTime = now;
|
|
602
|
+
}
|
|
1003
603
|
}
|
|
1004
604
|
|
|
1005
605
|
renderTrack() {
|
|
1006
|
-
|
|
1007
|
-
this.
|
|
1008
|
-
this.pitLaneStatusLayer?.clear();
|
|
1009
|
-
this.lastPitLaneStatusRenderKey = null;
|
|
1010
|
-
this.pitCameraBoundsCache = null;
|
|
1011
|
-
this.trackCameraBoundsCache = null;
|
|
606
|
+
this.pitLaneStatusRenderer.reset();
|
|
607
|
+
this.cameraController.invalidateTrackCaches();
|
|
1012
608
|
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);
|
|
609
|
+
renderTrackSurface({
|
|
610
|
+
drsLayer: this.drsLayer,
|
|
611
|
+
sensorLayer: this.sensorLayer,
|
|
612
|
+
pitLaneStatusLayer: this.pitLaneStatusLayer,
|
|
613
|
+
trackAsset: this.trackAsset,
|
|
614
|
+
snapshot,
|
|
1028
615
|
});
|
|
1029
616
|
}
|
|
1030
617
|
|
|
1031
618
|
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 });
|
|
619
|
+
this.pitLaneStatusRenderer.render(snapshot, this.pitLaneStatusLayer);
|
|
1068
620
|
}
|
|
1069
621
|
|
|
1070
622
|
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);
|
|
623
|
+
this.replayGhostRenderer.render(snapshot, {
|
|
624
|
+
textures: this.textures,
|
|
625
|
+
replayGhostLayer: this.replayGhostLayer,
|
|
626
|
+
});
|
|
627
|
+
this.carRenderer.renderCars(snapshot, {
|
|
628
|
+
textures: this.textures,
|
|
629
|
+
carLayer: this.carLayer,
|
|
1095
630
|
});
|
|
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
631
|
}
|
|
1118
632
|
|
|
1119
633
|
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
|
-
}
|
|
634
|
+
this.carRenderer.renderServiceCountdownLabel(car);
|
|
1140
635
|
}
|
|
1141
636
|
|
|
1142
637
|
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
|
-
});
|
|
638
|
+
renderExpertSensorRays({
|
|
639
|
+
snapshot,
|
|
640
|
+
observation,
|
|
641
|
+
sensorLayer: this.sensorLayer,
|
|
642
|
+
expertMode: this.expertMode,
|
|
643
|
+
expertOptions: {
|
|
644
|
+
...this.options.expert,
|
|
645
|
+
visualizeSensors: withSelectedExpertSensorDriver(this.options.expert?.visualizeSensors, this.selectedId),
|
|
646
|
+
},
|
|
1193
647
|
});
|
|
1194
648
|
}
|
|
1195
649
|
|
|
1196
650
|
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
|
-
});
|
|
651
|
+
this.drsTrailRenderer.render(snapshot, this.trailLayer);
|
|
1250
652
|
}
|
|
1251
653
|
|
|
1252
654
|
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
|
-
);
|
|
655
|
+
this.cameraController.applyToWorldLayer(this.worldLayer, snapshot, {
|
|
656
|
+
immediate,
|
|
657
|
+
selectedId: this.selectedId,
|
|
658
|
+
});
|
|
1279
659
|
}
|
|
1280
660
|
|
|
1281
661
|
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;
|
|
662
|
+
return this.cameraController.getSafeArea(width);
|
|
1317
663
|
}
|
|
1318
664
|
|
|
1319
665
|
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
|
-
};
|
|
666
|
+
return this.cameraController.getFrame(snapshot, width, height, baseScale, safeArea, this.selectedId);
|
|
1393
667
|
}
|
|
1394
668
|
|
|
1395
669
|
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 };
|
|
670
|
+
return this.cameraController.getTarget(snapshot, this.selectedId);
|
|
1410
671
|
}
|
|
1411
672
|
|
|
1412
673
|
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
|
-
};
|
|
674
|
+
return this.cameraController.getTrackBounds(track);
|
|
1464
675
|
}
|
|
1465
676
|
|
|
1466
677
|
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;
|
|
678
|
+
return this.cameraController.getBoundsFitScale(bounds, height, safeArea);
|
|
1472
679
|
}
|
|
1473
680
|
|
|
1474
681
|
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
|
-
};
|
|
682
|
+
return this.cameraController.getPitBounds(pitLane);
|
|
1517
683
|
}
|
|
1518
684
|
|
|
1519
685
|
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
|
-
};
|
|
686
|
+
return this.cameraController.getPitFrame(pitLane, height, baseScale, safeArea, screenCenterX, minimumScale);
|
|
1549
687
|
}
|
|
1550
688
|
|
|
1551
689
|
updateDom(snapshot, { emitLifecycle = true } = {}) {
|
|
@@ -1566,65 +704,18 @@ export class F1SimulatorApp {
|
|
|
1566
704
|
}
|
|
1567
705
|
this.updateStewardMessageState(snapshot, now);
|
|
1568
706
|
|
|
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
707
|
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);
|
|
708
|
+
this.lastFinishClassificationMarkup = renderRaceStatusReadouts({
|
|
709
|
+
readouts: this.readouts,
|
|
710
|
+
startLightNodes: this.startLightNodes,
|
|
711
|
+
snapshot,
|
|
712
|
+
camera: this.camera,
|
|
713
|
+
fps: this.fps,
|
|
714
|
+
activeDrs,
|
|
715
|
+
contactCount,
|
|
716
|
+
driverById: this.driverById,
|
|
717
|
+
lastFinishClassificationMarkup: this.lastFinishClassificationMarkup,
|
|
718
|
+
});
|
|
1628
719
|
|
|
1629
720
|
this.updateCameraControls(snapshot);
|
|
1630
721
|
this.syncTimingGapModeControls();
|
|
@@ -1692,38 +783,12 @@ export class F1SimulatorApp {
|
|
|
1692
783
|
}
|
|
1693
784
|
|
|
1694
785
|
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));
|
|
786
|
+
this.lastFinishClassificationMarkup = renderRaceFinish({
|
|
787
|
+
readouts: this.readouts,
|
|
788
|
+
snapshot,
|
|
789
|
+
driverById: this.driverById,
|
|
790
|
+
lastFinishClassificationMarkup: this.lastFinishClassificationMarkup,
|
|
791
|
+
});
|
|
1727
792
|
}
|
|
1728
793
|
|
|
1729
794
|
sampleFps(now) {
|
|
@@ -1736,24 +801,7 @@ export class F1SimulatorApp {
|
|
|
1736
801
|
}
|
|
1737
802
|
|
|
1738
803
|
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
|
-
}
|
|
804
|
+
renderStartLights(this.readouts, this.startLightNodes, raceControl);
|
|
1757
805
|
}
|
|
1758
806
|
|
|
1759
807
|
selectCar(id, { focus = false } = {}) {
|
|
@@ -1772,363 +820,107 @@ export class F1SimulatorApp {
|
|
|
1772
820
|
}
|
|
1773
821
|
|
|
1774
822
|
renderTiming(cars, raceMode, penalties = []) {
|
|
1775
|
-
if (!this.timingList) return;
|
|
1776
823
|
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
|
-
}
|
|
824
|
+
this.lastTimingMarkup = renderTimingTower({
|
|
825
|
+
timingList: this.timingList,
|
|
826
|
+
cars,
|
|
827
|
+
raceMode,
|
|
828
|
+
penalties,
|
|
829
|
+
driverById: this.driverById,
|
|
830
|
+
selectedId: this.selectedId,
|
|
831
|
+
timingGapMode: this.timingGapMode,
|
|
832
|
+
timingPenaltyBadgesEnabled: this.timingPenaltyBadgesEnabled,
|
|
833
|
+
lastTimingMarkup: this.lastTimingMarkup,
|
|
834
|
+
});
|
|
1820
835
|
}
|
|
1821
836
|
|
|
1822
837
|
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('|');
|
|
838
|
+
return getTimingOrderKey(cars, { timingGapMode: this.timingGapMode });
|
|
1835
839
|
}
|
|
1836
840
|
|
|
1837
841
|
isWavedFlagCar(car) {
|
|
1838
|
-
return
|
|
842
|
+
return isWavedFlagCar(car);
|
|
1839
843
|
}
|
|
1840
844
|
|
|
1841
845
|
getPenaltyByDriver(penalties = []) {
|
|
1842
|
-
|
|
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;
|
|
846
|
+
return getPenaltyByDriver(penalties);
|
|
1849
847
|
}
|
|
1850
848
|
|
|
1851
849
|
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('|');
|
|
850
|
+
return getTimingPenaltyKey(penalties, {
|
|
851
|
+
timingPenaltyBadgesEnabled: this.timingPenaltyBadgesEnabled,
|
|
852
|
+
});
|
|
1866
853
|
}
|
|
1867
854
|
|
|
1868
855
|
formatTimingGap(car) {
|
|
1869
|
-
|
|
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)}` : '--';
|
|
856
|
+
return formatRaceGap(car, this.timingGapMode);
|
|
1878
857
|
}
|
|
1879
858
|
|
|
1880
859
|
formatLapGap(laps) {
|
|
1881
|
-
|
|
1882
|
-
return `+${wholeLaps}`;
|
|
860
|
+
return formatLapGap(laps);
|
|
1883
861
|
}
|
|
1884
862
|
|
|
1885
863
|
formatTelemetryGap(car, mode) {
|
|
1886
|
-
|
|
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` : '--';
|
|
864
|
+
return formatTelemetryGap(car, mode);
|
|
1895
865
|
}
|
|
1896
866
|
|
|
1897
867
|
syncTimingGapModeControls() {
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
button.setAttribute('aria-pressed', String(active));
|
|
1903
|
-
button.classList?.toggle?.('is-active', active);
|
|
868
|
+
syncTimingGapModeControls({
|
|
869
|
+
readouts: this.readouts,
|
|
870
|
+
buttons: this.timingGapModeButtons,
|
|
871
|
+
timingGapMode: this.timingGapMode,
|
|
1904
872
|
});
|
|
1905
873
|
}
|
|
1906
874
|
|
|
1907
875
|
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;
|
|
876
|
+
renderTelemetryReadouts({
|
|
877
|
+
readouts: this.readouts,
|
|
878
|
+
car,
|
|
879
|
+
driverById: this.driverById,
|
|
1918
880
|
});
|
|
1919
|
-
this.readouts.telemetrySectorBanners?.forEach?.((node) => {
|
|
1920
|
-
node.style.setProperty('--driver-color', car.color);
|
|
1921
|
-
});
|
|
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
881
|
this.renderCarDriverOverview(car);
|
|
1943
882
|
this.renderLapTelemetry(car.lapTelemetry);
|
|
1944
883
|
}
|
|
1945
884
|
|
|
1946
885
|
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
|
-
});
|
|
886
|
+
renderLapTelemetry(this.readouts, telemetry);
|
|
2015
887
|
}
|
|
2016
888
|
|
|
2017
889
|
getOverviewFields(driver, mode) {
|
|
2018
|
-
|
|
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
|
-
];
|
|
890
|
+
return getOverviewFields(driver, mode);
|
|
2042
891
|
}
|
|
2043
892
|
|
|
2044
893
|
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]),
|
|
894
|
+
const previousKey = this.lastOverviewRenderKey;
|
|
895
|
+
this.lastOverviewRenderKey = renderCarDriverOverview({
|
|
896
|
+
readouts: this.readouts,
|
|
897
|
+
car,
|
|
898
|
+
driverById: this.driverById,
|
|
899
|
+
assets: this.assets,
|
|
900
|
+
overviewMode: this.overviewMode,
|
|
901
|
+
lastOverviewRenderKey: this.lastOverviewRenderKey,
|
|
2070
902
|
});
|
|
2071
|
-
if (
|
|
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);
|
|
2096
|
-
});
|
|
2097
|
-
this.updateOverviewModeButtons();
|
|
903
|
+
if (this.lastOverviewRenderKey !== previousKey) this.updateOverviewModeButtons();
|
|
2098
904
|
}
|
|
2099
905
|
|
|
2100
906
|
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
|
-
}
|
|
907
|
+
renderRaceData({
|
|
908
|
+
car,
|
|
909
|
+
drivers: this.drivers,
|
|
910
|
+
readouts: this.readouts,
|
|
911
|
+
options: {
|
|
912
|
+
...this.options,
|
|
913
|
+
isRaceDataBannerEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
|
|
914
|
+
},
|
|
915
|
+
});
|
|
2121
916
|
}
|
|
2122
917
|
|
|
2123
918
|
shouldAutoHideActiveRaceData() {
|
|
2124
|
-
return
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
this.options.ui?.raceDataTelemetryDetail ||
|
|
2130
|
-
this.readouts.raceDataPanel?.classList?.contains?.('race-data-panel--with-telemetry'),
|
|
2131
|
-
);
|
|
919
|
+
return shouldAutoHideActiveRaceData({
|
|
920
|
+
activeRaceDataId: this.activeRaceDataId,
|
|
921
|
+
options: this.options,
|
|
922
|
+
readouts: this.readouts,
|
|
923
|
+
});
|
|
2132
924
|
}
|
|
2133
925
|
|
|
2134
926
|
renderProjectRadio(now = performance.now()) {
|
|
@@ -2143,26 +935,14 @@ export class F1SimulatorApp {
|
|
|
2143
935
|
return;
|
|
2144
936
|
}
|
|
2145
937
|
|
|
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;
|
|
938
|
+
renderProjectRadio({
|
|
939
|
+
readouts: this.readouts,
|
|
940
|
+
radio: this.getProjectRadioQuote(),
|
|
941
|
+
});
|
|
2158
942
|
}
|
|
2159
943
|
|
|
2160
944
|
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;
|
|
945
|
+
hideRaceDataPanel(this.readouts);
|
|
2166
946
|
}
|
|
2167
947
|
|
|
2168
948
|
dismissRaceDataPanel(now = performance.now()) {
|
|
@@ -2174,85 +954,33 @@ export class F1SimulatorApp {
|
|
|
2174
954
|
}
|
|
2175
955
|
|
|
2176
956
|
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
|
-
};
|
|
957
|
+
const state = updateStewardMessageState({
|
|
958
|
+
snapshot,
|
|
959
|
+
now,
|
|
960
|
+
state: {
|
|
961
|
+
activeStewardMessage: this.activeStewardMessage,
|
|
962
|
+
lastPenaltyBannerId: this.lastPenaltyBannerId,
|
|
963
|
+
lastStewardMessageKey: this.lastStewardMessageKey,
|
|
964
|
+
},
|
|
965
|
+
driverById: this.driverById,
|
|
966
|
+
penaltyBannerEnabled: this.penaltyBannerEnabled,
|
|
967
|
+
stewardMessageNode: this.readouts.stewardMessage,
|
|
968
|
+
});
|
|
969
|
+
this.activeStewardMessage = state.activeStewardMessage;
|
|
970
|
+
this.lastPenaltyBannerId = state.lastPenaltyBannerId;
|
|
971
|
+
this.lastStewardMessageKey = state.lastStewardMessageKey;
|
|
2201
972
|
}
|
|
2202
973
|
|
|
2203
974
|
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
|
-
};
|
|
975
|
+
return createPenaltyStewardMessage(penalty, this.driverById);
|
|
2218
976
|
}
|
|
2219
977
|
|
|
2220
978
|
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
|
-
};
|
|
979
|
+
return createWarningStewardMessage(event, this.driverById);
|
|
2239
980
|
}
|
|
2240
981
|
|
|
2241
982
|
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);
|
|
983
|
+
renderActiveStewardMessage(this.readouts, this.activeStewardMessage);
|
|
2256
984
|
}
|
|
2257
985
|
|
|
2258
986
|
updateRadioSchedule(now) {
|
|
@@ -2279,26 +1007,22 @@ export class F1SimulatorApp {
|
|
|
2279
1007
|
}
|
|
2280
1008
|
|
|
2281
1009
|
scheduleRadioBreak(now) {
|
|
2282
|
-
|
|
2283
|
-
|
|
1010
|
+
scheduleRadioBreak({
|
|
1011
|
+
radioState: this.radioState,
|
|
1012
|
+
now,
|
|
1013
|
+
isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
|
|
1014
|
+
nextRandom: () => this.nextRadioRandom(),
|
|
1015
|
+
});
|
|
2284
1016
|
}
|
|
2285
1017
|
|
|
2286
1018
|
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);
|
|
1019
|
+
scheduleRadioPopup({
|
|
1020
|
+
radioState: this.radioState,
|
|
1021
|
+
now,
|
|
1022
|
+
drivers: this.drivers,
|
|
1023
|
+
isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
|
|
1024
|
+
nextRandom: () => this.nextRadioRandom(),
|
|
1025
|
+
});
|
|
2302
1026
|
}
|
|
2303
1027
|
|
|
2304
1028
|
nextRadioRandom() {
|
|
@@ -2307,24 +1031,22 @@ export class F1SimulatorApp {
|
|
|
2307
1031
|
}
|
|
2308
1032
|
|
|
2309
1033
|
getNextRadioBreakTime(now) {
|
|
2310
|
-
return
|
|
2311
|
-
|
|
2312
|
-
:
|
|
1034
|
+
return getNextRadioBreakTime({
|
|
1035
|
+
now,
|
|
1036
|
+
isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
|
|
1037
|
+
nextRandom: () => this.nextRadioRandom(),
|
|
1038
|
+
});
|
|
2313
1039
|
}
|
|
2314
1040
|
|
|
2315
1041
|
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;
|
|
1042
|
+
resetRaceDataBannerState({
|
|
1043
|
+
state: this,
|
|
1044
|
+
now,
|
|
1045
|
+
initialMode: this.raceDataBannerConfig.initial ?? 'project',
|
|
1046
|
+
selectedId: this.selectedId,
|
|
1047
|
+
isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
|
|
1048
|
+
nextRandom: () => this.nextRadioRandom(),
|
|
1049
|
+
});
|
|
2328
1050
|
}
|
|
2329
1051
|
|
|
2330
1052
|
isRaceDataBannerEnabled(kind) {
|
|
@@ -2332,66 +1054,41 @@ export class F1SimulatorApp {
|
|
|
2332
1054
|
}
|
|
2333
1055
|
|
|
2334
1056
|
getProjectRadioQuote() {
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
color: driver.color,
|
|
2340
|
-
title: driver.name,
|
|
2341
|
-
subtitle: `${driver.code} - "${quote}"`,
|
|
2342
|
-
};
|
|
1057
|
+
return getProjectRadioQuote({
|
|
1058
|
+
drivers: this.drivers,
|
|
1059
|
+
radioState: this.radioState,
|
|
1060
|
+
});
|
|
2343
1061
|
}
|
|
2344
1062
|
|
|
2345
1063
|
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));
|
|
1064
|
+
updateCameraControlButtons({
|
|
1065
|
+
camera: this.camera,
|
|
1066
|
+
cameraButtons: this.cameraButtons,
|
|
1067
|
+
snapshot,
|
|
1068
|
+
hasPitCamera: (currentSnapshot) => this.hasPitCamera(currentSnapshot),
|
|
1069
|
+
isCameraModeAvailable: (mode, currentSnapshot) => this.isCameraModeAvailable(mode, currentSnapshot),
|
|
2363
1070
|
});
|
|
2364
1071
|
this.updateBannerMuteControls();
|
|
2365
1072
|
}
|
|
2366
1073
|
|
|
2367
1074
|
updateBannerMuteControls() {
|
|
2368
|
-
this.bannerMuteButtons
|
|
2369
|
-
button.classList.toggle('is-active', this.raceDataBannersMuted);
|
|
2370
|
-
button.setAttribute('aria-pressed', String(this.raceDataBannersMuted));
|
|
2371
|
-
});
|
|
1075
|
+
updateToggleButtons(this.bannerMuteButtons, this.raceDataBannersMuted);
|
|
2372
1076
|
}
|
|
2373
1077
|
|
|
2374
1078
|
hasPitCamera(snapshot = this.sim?.snapshot?.()) {
|
|
2375
|
-
return
|
|
1079
|
+
return this.cameraController.hasPitCamera(snapshot);
|
|
2376
1080
|
}
|
|
2377
1081
|
|
|
2378
1082
|
isCameraModeAvailable(mode, snapshot = this.sim?.snapshot?.()) {
|
|
2379
|
-
return
|
|
1083
|
+
return this.cameraController.isModeAvailable(mode, snapshot);
|
|
2380
1084
|
}
|
|
2381
1085
|
|
|
2382
1086
|
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
|
-
});
|
|
1087
|
+
updateModeButtons(this.overviewModeButtons, this.overviewMode, 'overviewMode');
|
|
2388
1088
|
}
|
|
2389
1089
|
|
|
2390
1090
|
syncSafetyCarControls(active) {
|
|
2391
|
-
this.safetyButtons
|
|
2392
|
-
button.classList.toggle('is-active', active);
|
|
2393
|
-
button.setAttribute('aria-pressed', String(active));
|
|
2394
|
-
});
|
|
1091
|
+
updateToggleButtons(this.safetyButtons, active);
|
|
2395
1092
|
}
|
|
2396
1093
|
|
|
2397
1094
|
applyExpertOptions(nextOptions = {}) {
|
|
@@ -2434,6 +1131,8 @@ export class F1SimulatorApp {
|
|
|
2434
1131
|
if (hasOwnOption(nextOptions, 'expert') && expertOptionsChanged(nextOptions.expert, this.options.expert)) {
|
|
2435
1132
|
throw new Error('PaddockJS restart() does not support changing expert mode. Destroy and mount a new simulator with the new expert options.');
|
|
2436
1133
|
}
|
|
1134
|
+
this.expert?.destroy?.();
|
|
1135
|
+
this.expert = null;
|
|
2437
1136
|
this.applyExpertOptions(nextOptions);
|
|
2438
1137
|
this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
|
|
2439
1138
|
this.penaltyBannerEnabled = Boolean(this.options.ui?.penaltyBanners);
|
|
@@ -2526,6 +1225,9 @@ export class F1SimulatorApp {
|
|
|
2526
1225
|
if (this.app && this.tickerCallback) {
|
|
2527
1226
|
this.app.ticker.remove(this.tickerCallback);
|
|
2528
1227
|
}
|
|
1228
|
+
this.expert?.destroy?.();
|
|
1229
|
+
this.expert = null;
|
|
1230
|
+
this.replayGhostRenderer.destroy();
|
|
2529
1231
|
this.app?.destroy(true, { children: true, texture: false });
|
|
2530
1232
|
this.app = null;
|
|
2531
1233
|
this.carSprites.clear();
|