@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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export function formatPenaltyType(type) {
|
|
2
|
+
return String(type ?? 'penalty')
|
|
3
|
+
.split('-')
|
|
4
|
+
.filter(Boolean)
|
|
5
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
6
|
+
.join(' ');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatPenaltyHeadline(penalty) {
|
|
10
|
+
const serviceType = penalty?.serviceType;
|
|
11
|
+
if (serviceType === 'driveThrough') {
|
|
12
|
+
return penalty?.unserved ? 'unserved drive-through penalty' : 'drive-through penalty';
|
|
13
|
+
}
|
|
14
|
+
if (serviceType === 'stopGo') {
|
|
15
|
+
return penalty?.unserved ? 'unserved stop-go penalty' : 'stop-go penalty';
|
|
16
|
+
}
|
|
17
|
+
const positionDrop = Number(penalty?.positionDrop);
|
|
18
|
+
if (Number.isFinite(positionDrop) && positionDrop > 0) {
|
|
19
|
+
return `${positionDrop}-place position drop`;
|
|
20
|
+
}
|
|
21
|
+
const gridDrop = Number(penalty?.gridDrop);
|
|
22
|
+
if (Number.isFinite(gridDrop) && gridDrop > 0) {
|
|
23
|
+
return `${gridDrop}-place grid drop`;
|
|
24
|
+
}
|
|
25
|
+
if (penalty?.disqualified) return 'disqualification';
|
|
26
|
+
const seconds = Number(penalty?.penaltySeconds);
|
|
27
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return 'Penalty decision';
|
|
28
|
+
const unit = seconds === 1 ? 'second' : 'seconds';
|
|
29
|
+
return `+${seconds} ${unit} time penalty`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatPenaltyChip(penalty) {
|
|
33
|
+
const serviceType = penalty?.serviceType;
|
|
34
|
+
if (serviceType === 'driveThrough') return 'DT';
|
|
35
|
+
if (serviceType === 'stopGo') return 'SG';
|
|
36
|
+
if (penalty?.disqualified) return 'DSQ';
|
|
37
|
+
const positionDrop = Number(penalty?.positionDrop);
|
|
38
|
+
if (Number.isFinite(positionDrop) && positionDrop > 0) return `-${positionDrop}P`;
|
|
39
|
+
const gridDrop = Number(penalty?.gridDrop);
|
|
40
|
+
if (Number.isFinite(gridDrop) && gridDrop > 0) return `-${gridDrop}G`;
|
|
41
|
+
const seconds = Number(penalty?.penaltySeconds);
|
|
42
|
+
return Number.isFinite(seconds) && seconds > 0 ? `+${seconds}s` : 'Penalty';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatStewardMessageKey(message) {
|
|
46
|
+
if (!message) return '';
|
|
47
|
+
return [
|
|
48
|
+
message.kind,
|
|
49
|
+
message.type,
|
|
50
|
+
message.driverId,
|
|
51
|
+
message.penaltyId,
|
|
52
|
+
message.violationCount,
|
|
53
|
+
message.time,
|
|
54
|
+
].join(':');
|
|
55
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { formatDriverNumber } from '../../data/championship.js';
|
|
2
|
+
import { setText } from '../domBindings.js';
|
|
3
|
+
|
|
4
|
+
export const RACE_DATA_SELECTED_VISIBLE_MS = 5200;
|
|
5
|
+
export const RADIO_BREAK_MIN_MS = 4800;
|
|
6
|
+
export const RADIO_BREAK_MAX_MS = 11800;
|
|
7
|
+
export const RADIO_VISIBLE_MIN_MS = 6200;
|
|
8
|
+
export const RADIO_VISIBLE_MAX_MS = 9200;
|
|
9
|
+
export const RADIO_SCHEDULE_CATCHUP_LIMIT_MS = 30000;
|
|
10
|
+
|
|
11
|
+
export function shouldAutoHideActiveRaceData({ activeRaceDataId, options, readouts }) {
|
|
12
|
+
return Boolean(
|
|
13
|
+
activeRaceDataId &&
|
|
14
|
+
!(
|
|
15
|
+
options.ui?.raceDataTelemetryDetail ||
|
|
16
|
+
readouts.raceDataPanel?.classList?.contains?.('race-data-panel--with-telemetry')
|
|
17
|
+
),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renderRaceData({ car, drivers, readouts, options }) {
|
|
22
|
+
if (!car || !readouts.raceDataPanel) return false;
|
|
23
|
+
if (typeof options.isRaceDataBannerEnabled === 'function' && !options.isRaceDataBannerEnabled('project')) return false;
|
|
24
|
+
const driver = drivers.find((item) => item.id === car.id);
|
|
25
|
+
if (!driver) return false;
|
|
26
|
+
|
|
27
|
+
readouts.raceDataPanel.style.setProperty('--driver-color', driver.color);
|
|
28
|
+
readouts.raceDataPanel.classList.remove('is-hidden');
|
|
29
|
+
readouts.raceDataPanel.classList.add('is-project-mode');
|
|
30
|
+
readouts.raceDataPanel.classList.remove('is-radio-mode');
|
|
31
|
+
readouts.raceDataPanel.removeAttribute('data-idle-mode');
|
|
32
|
+
setText(readouts.raceDataKicker, 'Project');
|
|
33
|
+
setText(readouts.raceDataTitle, driver.name);
|
|
34
|
+
if (readouts.raceDataNumber) {
|
|
35
|
+
readouts.raceDataNumber.textContent = formatDriverNumber(car.driverNumber ?? driver.driverNumber);
|
|
36
|
+
}
|
|
37
|
+
if (readouts.raceDataSubtitle) {
|
|
38
|
+
readouts.raceDataSubtitle.textContent = `${car.code} - P${car.rank} - ${driver.raceData?.[0] ?? 'Project entry'}`;
|
|
39
|
+
}
|
|
40
|
+
if (readouts.raceDataOpen) {
|
|
41
|
+
readouts.raceDataOpen.hidden = typeof options.onDriverOpen !== 'function';
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hideRaceDataPanel(readouts) {
|
|
47
|
+
if (!readouts.raceDataPanel) return;
|
|
48
|
+
readouts.raceDataPanel.classList.add('is-hidden');
|
|
49
|
+
readouts.raceDataPanel.classList.remove('is-project-mode', 'is-radio-mode');
|
|
50
|
+
readouts.raceDataPanel.removeAttribute('data-idle-mode');
|
|
51
|
+
if (readouts.raceDataOpen) readouts.raceDataOpen.hidden = true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getProjectRadioQuote({ drivers, radioState }) {
|
|
55
|
+
const driver = drivers[radioState.driverIndex] ?? drivers[0];
|
|
56
|
+
const quote = driver.raceData?.[radioState.quoteIndex] ?? 'Project entry';
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
color: driver.color,
|
|
60
|
+
title: driver.name,
|
|
61
|
+
subtitle: `${driver.code} - "${quote}"`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function renderProjectRadio({ readouts, radio }) {
|
|
66
|
+
if (!readouts.raceDataPanel) return;
|
|
67
|
+
readouts.raceDataPanel.style.setProperty('--driver-color', radio.color);
|
|
68
|
+
readouts.raceDataPanel.classList.remove('is-hidden');
|
|
69
|
+
readouts.raceDataPanel.classList.add('is-radio-mode');
|
|
70
|
+
readouts.raceDataPanel.classList.remove('is-project-mode');
|
|
71
|
+
readouts.raceDataPanel.dataset.idleMode = 'quote';
|
|
72
|
+
setText(readouts.raceDataKicker, 'Project radio');
|
|
73
|
+
setText(readouts.raceDataTitle, radio.title);
|
|
74
|
+
setText(readouts.raceDataNumber, '');
|
|
75
|
+
setText(readouts.raceDataSubtitle, radio.subtitle);
|
|
76
|
+
if (readouts.raceDataOpen) readouts.raceDataOpen.hidden = true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function randomRadioRange(nextRandom, min, max) {
|
|
80
|
+
return min + nextRandom() * (max - min);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getNextRadioBreakTime({ now, isEnabled, nextRandom }) {
|
|
84
|
+
return isEnabled('radio')
|
|
85
|
+
? now + randomRadioRange(nextRandom, RADIO_BREAK_MIN_MS, RADIO_BREAK_MAX_MS)
|
|
86
|
+
: Number.POSITIVE_INFINITY;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function scheduleRadioBreak({ radioState, now, isEnabled, nextRandom }) {
|
|
90
|
+
radioState.visible = false;
|
|
91
|
+
radioState.nextChangeAt = getNextRadioBreakTime({ now, isEnabled, nextRandom });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function scheduleRadioPopup({ radioState, now, drivers, isEnabled, nextRandom }) {
|
|
95
|
+
if (!isEnabled('radio')) {
|
|
96
|
+
scheduleRadioBreak({ radioState, now, isEnabled, nextRandom });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const driverIndex = Math.floor(nextRandom() * drivers.length);
|
|
100
|
+
const driver = drivers[driverIndex] ?? drivers[0];
|
|
101
|
+
const quoteCount = Math.max(1, driver.raceData?.length ?? 1);
|
|
102
|
+
radioState.visible = true;
|
|
103
|
+
radioState.driverIndex = driverIndex;
|
|
104
|
+
radioState.quoteIndex = Math.floor(nextRandom() * quoteCount);
|
|
105
|
+
radioState.nextChangeAt = now + randomRadioRange(nextRandom, RADIO_VISIBLE_MIN_MS, RADIO_VISIBLE_MAX_MS);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function updateRadioSchedule({ radioState, now, drivers, isEnabled, nextRandom }) {
|
|
109
|
+
if (!isEnabled('radio')) {
|
|
110
|
+
radioState.visible = false;
|
|
111
|
+
radioState.nextChangeAt = Number.POSITIVE_INFINITY;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (
|
|
115
|
+
Number.isFinite(radioState.nextChangeAt) &&
|
|
116
|
+
now - radioState.nextChangeAt > RADIO_SCHEDULE_CATCHUP_LIMIT_MS
|
|
117
|
+
) {
|
|
118
|
+
if (radioState.visible) scheduleRadioBreak({ radioState, now, isEnabled, nextRandom });
|
|
119
|
+
else scheduleRadioPopup({ radioState, now, drivers, isEnabled, nextRandom });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
while (now >= radioState.nextChangeAt) {
|
|
123
|
+
if (radioState.visible) {
|
|
124
|
+
scheduleRadioBreak({ radioState, now: radioState.nextChangeAt, isEnabled, nextRandom });
|
|
125
|
+
} else {
|
|
126
|
+
scheduleRadioPopup({ radioState, now: radioState.nextChangeAt, drivers, isEnabled, nextRandom });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function resetRaceDataBannerState({ state, now, initialMode, selectedId, isEnabled, nextRandom }) {
|
|
132
|
+
state.activeRaceDataId = isEnabled('project') && initialMode === 'project' ? selectedId : null;
|
|
133
|
+
state.lastRaceDataInteraction = now;
|
|
134
|
+
const showInitialRadio = isEnabled('radio') && initialMode === 'radio';
|
|
135
|
+
state.radioState.visible = showInitialRadio;
|
|
136
|
+
state.radioState.nextChangeAt = showInitialRadio
|
|
137
|
+
? now + randomRadioRange(nextRandom, RADIO_VISIBLE_MIN_MS, RADIO_VISIBLE_MAX_MS)
|
|
138
|
+
: getNextRadioBreakTime({ now, isEnabled, nextRandom });
|
|
139
|
+
state.radioState.driverIndex = 0;
|
|
140
|
+
state.radioState.quoteIndex = 0;
|
|
141
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { setText } from '../domBindings.js';
|
|
2
|
+
import {
|
|
3
|
+
formatPenaltyChip,
|
|
4
|
+
formatPenaltyHeadline,
|
|
5
|
+
formatPenaltyType,
|
|
6
|
+
formatStewardMessageKey,
|
|
7
|
+
} from './bannerFormatters.js';
|
|
8
|
+
|
|
9
|
+
export const PENALTY_BANNER_VISIBLE_MS = 12000;
|
|
10
|
+
|
|
11
|
+
export function createPenaltyStewardMessage(penalty, driverById) {
|
|
12
|
+
if (!penalty) return null;
|
|
13
|
+
const driver = driverById.get(penalty.driverId);
|
|
14
|
+
const code = driver?.timingCode ?? driver?.code ?? penalty.driverId ?? 'CAR';
|
|
15
|
+
return {
|
|
16
|
+
kind: 'penalty',
|
|
17
|
+
type: penalty.type,
|
|
18
|
+
driverId: penalty.driverId,
|
|
19
|
+
penaltyId: penalty.id,
|
|
20
|
+
time: penalty.time,
|
|
21
|
+
color: driver?.color ?? 'var(--red)',
|
|
22
|
+
kicker: formatPenaltyChip(penalty),
|
|
23
|
+
title: `${code} ${formatPenaltyHeadline(penalty).replace(/^\+\d+ seconds? /u, '')}`,
|
|
24
|
+
detail: `${formatPenaltyType(penalty.type)} - ${penalty.reason ?? 'Steward decision'}`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createWarningStewardMessage(event, driverById) {
|
|
29
|
+
if (!event) return null;
|
|
30
|
+
const driver = driverById.get(event.carId);
|
|
31
|
+
const code = driver?.timingCode ?? driver?.code ?? event.carId ?? 'CAR';
|
|
32
|
+
const warningLimit = Number(event.warningsBeforePenalty);
|
|
33
|
+
const warningProgress = Number.isFinite(warningLimit) && warningLimit > 0
|
|
34
|
+
? ` ${event.violationCount}/${warningLimit}`
|
|
35
|
+
: '';
|
|
36
|
+
return {
|
|
37
|
+
kind: 'warning',
|
|
38
|
+
type: event.type,
|
|
39
|
+
driverId: event.carId,
|
|
40
|
+
violationCount: event.violationCount,
|
|
41
|
+
time: event.at,
|
|
42
|
+
color: driver?.color ?? 'var(--yellow)',
|
|
43
|
+
kicker: 'Warning',
|
|
44
|
+
title: `${code} ${formatPenaltyType(event.type).toLowerCase()}`,
|
|
45
|
+
detail: `${formatPenaltyType(event.type)}${warningProgress}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function updateStewardMessageState({
|
|
50
|
+
snapshot,
|
|
51
|
+
now,
|
|
52
|
+
state,
|
|
53
|
+
driverById,
|
|
54
|
+
penaltyBannerEnabled,
|
|
55
|
+
stewardMessageNode,
|
|
56
|
+
}) {
|
|
57
|
+
if (!penaltyBannerEnabled || !stewardMessageNode) {
|
|
58
|
+
state.activeStewardMessage = null;
|
|
59
|
+
return state;
|
|
60
|
+
}
|
|
61
|
+
if (state.activeStewardMessage && now < state.activeStewardMessage.visibleUntil) return state;
|
|
62
|
+
state.activeStewardMessage = null;
|
|
63
|
+
|
|
64
|
+
const warning = [...(snapshot.events ?? [])].reverse().find((event) => (
|
|
65
|
+
event?.type === 'track-limits' && event.decision === 'warning'
|
|
66
|
+
));
|
|
67
|
+
const penalty = [...(snapshot.penalties ?? [])].reverse().find((entry) => (
|
|
68
|
+
entry?.id && entry.id !== state.lastPenaltyBannerId
|
|
69
|
+
));
|
|
70
|
+
const message = penalty
|
|
71
|
+
? createPenaltyStewardMessage(penalty, driverById)
|
|
72
|
+
: createWarningStewardMessage(warning, driverById);
|
|
73
|
+
const key = formatStewardMessageKey(message);
|
|
74
|
+
if (!message || !key || key === state.lastStewardMessageKey) return state;
|
|
75
|
+
state.lastStewardMessageKey = key;
|
|
76
|
+
if (message.penaltyId) state.lastPenaltyBannerId = message.penaltyId;
|
|
77
|
+
state.activeStewardMessage = {
|
|
78
|
+
message,
|
|
79
|
+
visibleUntil: now + PENALTY_BANNER_VISIBLE_MS,
|
|
80
|
+
};
|
|
81
|
+
return state;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function renderActiveStewardMessage(readouts, activeStewardMessage) {
|
|
85
|
+
const message = activeStewardMessage?.message;
|
|
86
|
+
const panel = readouts.stewardMessage;
|
|
87
|
+
if (!panel) return;
|
|
88
|
+
if (!message) {
|
|
89
|
+
panel.classList.add('is-hidden');
|
|
90
|
+
panel.classList.remove('is-penalty', 'is-warning');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
panel.style.setProperty('--steward-color', message.color);
|
|
94
|
+
panel.classList.remove('is-hidden', 'is-penalty', 'is-warning');
|
|
95
|
+
panel.classList.add(message.kind === 'penalty' ? 'is-penalty' : 'is-warning');
|
|
96
|
+
setText(readouts.stewardMessageKicker, message.kicker);
|
|
97
|
+
setText(readouts.stewardMessageTitle, message.title);
|
|
98
|
+
setText(readouts.stewardMessageDetail, message.detail);
|
|
99
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { metersToSimUnits } from '../../simulation/units.js';
|
|
2
|
+
import { VEHICLE_GEOMETRY } from '../../simulation/vehicleGeometry.js';
|
|
3
|
+
|
|
4
|
+
export const CAMERA_TARGET_LERP = 0.12;
|
|
5
|
+
export const CAMERA_SCALE_LERP = 0.12;
|
|
6
|
+
export const CAMERA_PRESETS = {
|
|
7
|
+
overview: 1,
|
|
8
|
+
leader: 18,
|
|
9
|
+
selected: 24,
|
|
10
|
+
'show-all': 1,
|
|
11
|
+
pit: 2.35,
|
|
12
|
+
};
|
|
13
|
+
export const CAMERA_MIN_ZOOM = 0.55;
|
|
14
|
+
export const CAMERA_MAX_ZOOM = 120;
|
|
15
|
+
export const CAMERA_ZOOM_STEP = 1.4;
|
|
16
|
+
export const TRACK_CAMERA_PADDING = metersToSimUnits(120);
|
|
17
|
+
export const SHOW_ALL_PADDING = 520;
|
|
18
|
+
export const SHOW_ALL_TOP_RESERVED = 92;
|
|
19
|
+
export const SHOW_ALL_BOTTOM_RESERVED = 132;
|
|
20
|
+
export const PIT_CAMERA_PADDING = metersToSimUnits(34);
|
|
21
|
+
export const CAR_WORLD_LENGTH = VEHICLE_GEOMETRY.visualLength;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { WORLD } from '../../simulation/trackModel.js';
|
|
2
|
+
import { clamp } from '../../simulation/simMath.js';
|
|
3
|
+
import {
|
|
4
|
+
CAMERA_PRESETS,
|
|
5
|
+
CAMERA_SCALE_LERP,
|
|
6
|
+
CAMERA_TARGET_LERP,
|
|
7
|
+
CAMERA_ZOOM_STEP,
|
|
8
|
+
CAMERA_MIN_ZOOM,
|
|
9
|
+
CAMERA_MAX_ZOOM,
|
|
10
|
+
} from './cameraConstants.js';
|
|
11
|
+
import {
|
|
12
|
+
clampCameraScale,
|
|
13
|
+
getCameraBoundsFitScale,
|
|
14
|
+
getPitCameraBounds,
|
|
15
|
+
getPitCameraFrame,
|
|
16
|
+
getPitCameraTarget,
|
|
17
|
+
getShowAllCameraFrame,
|
|
18
|
+
getTrackCameraBounds,
|
|
19
|
+
getTrackCameraTarget,
|
|
20
|
+
} from './cameraGeometry.js';
|
|
21
|
+
|
|
22
|
+
export function createCameraState(initialCameraMode) {
|
|
23
|
+
return {
|
|
24
|
+
mode: initialCameraMode,
|
|
25
|
+
zoom: CAMERA_PRESETS[initialCameraMode] ?? CAMERA_PRESETS.leader,
|
|
26
|
+
scale: null,
|
|
27
|
+
initialized: false,
|
|
28
|
+
x: WORLD.width / 2,
|
|
29
|
+
y: WORLD.height / 2,
|
|
30
|
+
free: false,
|
|
31
|
+
freeTarget: null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class CameraController {
|
|
36
|
+
constructor({ canvasHost, readouts, initialMode }) {
|
|
37
|
+
this.canvasHost = canvasHost;
|
|
38
|
+
this.readouts = readouts;
|
|
39
|
+
this.camera = createCameraState(initialMode);
|
|
40
|
+
this.safeAreaCache = null;
|
|
41
|
+
this.pitBoundsCache = null;
|
|
42
|
+
this.trackBoundsCache = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setMode(mode, snapshot) {
|
|
46
|
+
if (!this.isModeAvailable(mode, snapshot)) return false;
|
|
47
|
+
this.camera.mode = mode;
|
|
48
|
+
this.camera.free = false;
|
|
49
|
+
this.camera.freeTarget = null;
|
|
50
|
+
if (CAMERA_PRESETS[this.camera.mode]) this.camera.zoom = CAMERA_PRESETS[this.camera.mode];
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
adjustZoom(direction) {
|
|
55
|
+
const steps = Number.isFinite(Number(direction)) ? Number(direction) : 0;
|
|
56
|
+
this.camera.zoom = clamp(this.camera.zoom + steps * CAMERA_ZOOM_STEP, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
|
|
57
|
+
return this.camera.zoom;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
invalidateSafeArea() {
|
|
61
|
+
this.safeAreaCache = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
invalidateTrackCaches() {
|
|
65
|
+
this.pitBoundsCache = null;
|
|
66
|
+
this.trackBoundsCache = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getSafeArea(width) {
|
|
70
|
+
if (this.safeAreaCache?.width === width) {
|
|
71
|
+
return this.safeAreaCache.safeArea;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const canvasRect = this.canvasHost?.getBoundingClientRect?.();
|
|
75
|
+
const towerRect = this.readouts?.timingTower?.getBoundingClientRect?.();
|
|
76
|
+
if (!canvasRect || !towerRect) {
|
|
77
|
+
const safeArea = { left: 0, width };
|
|
78
|
+
this.safeAreaCache = { width, safeArea };
|
|
79
|
+
return safeArea;
|
|
80
|
+
}
|
|
81
|
+
const overlapsCanvasHorizontally = towerRect.right > canvasRect.left && towerRect.left < canvasRect.right;
|
|
82
|
+
const hasVerticalBounds = Number.isFinite(canvasRect.top) &&
|
|
83
|
+
Number.isFinite(canvasRect.bottom) &&
|
|
84
|
+
Number.isFinite(towerRect.top) &&
|
|
85
|
+
Number.isFinite(towerRect.bottom);
|
|
86
|
+
const overlapsCanvasVertically = !hasVerticalBounds ||
|
|
87
|
+
(towerRect.bottom > canvasRect.top && towerRect.top < canvasRect.bottom);
|
|
88
|
+
const canvasWidth = Math.max(1, canvasRect.right - canvasRect.left || width);
|
|
89
|
+
const towerWidth = Math.max(0, towerRect.right - towerRect.left);
|
|
90
|
+
const isSideGutter = towerWidth < canvasWidth * 0.6;
|
|
91
|
+
if (!overlapsCanvasHorizontally || !overlapsCanvasVertically || !isSideGutter) {
|
|
92
|
+
const safeArea = { left: 0, width };
|
|
93
|
+
this.safeAreaCache = { width, safeArea };
|
|
94
|
+
return safeArea;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const overlayGap = 16;
|
|
98
|
+
const reservedLeft = clamp(towerRect.right - canvasRect.left + overlayGap, 0, width * 0.48);
|
|
99
|
+
const safeArea = {
|
|
100
|
+
left: reservedLeft,
|
|
101
|
+
width: Math.max(1, width - reservedLeft),
|
|
102
|
+
};
|
|
103
|
+
this.safeAreaCache = { width, safeArea };
|
|
104
|
+
return safeArea;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
getTrackBounds(track) {
|
|
108
|
+
if (this.trackBoundsCache && this.trackBoundsCache.track === track) {
|
|
109
|
+
return this.trackBoundsCache.bounds;
|
|
110
|
+
}
|
|
111
|
+
const bounds = getTrackCameraBounds(track);
|
|
112
|
+
this.trackBoundsCache = { track, bounds };
|
|
113
|
+
return bounds;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
getTrackTarget(track) {
|
|
117
|
+
return getTrackCameraTarget(this.getTrackBounds(track));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getBoundsFitScale(bounds, height, safeArea) {
|
|
121
|
+
return getCameraBoundsFitScale(bounds, height, safeArea);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getPitBounds(pitLane) {
|
|
125
|
+
if (this.pitBoundsCache && this.pitBoundsCache.pitLane === pitLane) return this.pitBoundsCache.bounds;
|
|
126
|
+
const bounds = getPitCameraBounds(pitLane);
|
|
127
|
+
this.pitBoundsCache = { pitLane, bounds };
|
|
128
|
+
return bounds;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getPitTarget(pitLane) {
|
|
132
|
+
return getPitCameraTarget(this.getPitBounds(pitLane));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getPitFrame(pitLane, height, baseScale, safeArea, screenCenterX, minimumScale = null) {
|
|
136
|
+
return getPitCameraFrame({
|
|
137
|
+
pitBounds: this.getPitBounds(pitLane),
|
|
138
|
+
cameraZoom: this.camera.zoom,
|
|
139
|
+
height,
|
|
140
|
+
baseScale,
|
|
141
|
+
safeArea,
|
|
142
|
+
screenCenterX,
|
|
143
|
+
minimumScale,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
hasPitCamera(snapshot) {
|
|
148
|
+
return Boolean(snapshot?.track?.pitLane?.enabled);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
isModeAvailable(mode, snapshot) {
|
|
152
|
+
return mode !== 'pit' || this.hasPitCamera(snapshot);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
getFrame(snapshot, width, height, baseScale, safeArea = { left: 0, width }, selectedId = null) {
|
|
156
|
+
const screenCenterX = safeArea.left + safeArea.width / 2;
|
|
157
|
+
const trackBounds = this.getTrackBounds(snapshot.track);
|
|
158
|
+
const trackFitScale = this.getBoundsFitScale(trackBounds, height, safeArea);
|
|
159
|
+
|
|
160
|
+
if (this.camera.mode === 'overview') {
|
|
161
|
+
const target = trackBounds
|
|
162
|
+
? {
|
|
163
|
+
x: (trackBounds.minX + trackBounds.maxX) / 2,
|
|
164
|
+
y: (trackBounds.minY + trackBounds.maxY) / 2,
|
|
165
|
+
}
|
|
166
|
+
: { x: WORLD.width / 2, y: WORLD.height / 2 };
|
|
167
|
+
|
|
168
|
+
const scale = trackFitScale
|
|
169
|
+
? clampCameraScale(trackFitScale * this.camera.zoom, trackFitScale, trackFitScale)
|
|
170
|
+
: clampCameraScale(baseScale * this.camera.zoom, baseScale);
|
|
171
|
+
|
|
172
|
+
return this.applyFreeFrame({
|
|
173
|
+
target,
|
|
174
|
+
scale,
|
|
175
|
+
screenX: screenCenterX,
|
|
176
|
+
screenY: height / 2,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (this.camera.mode === 'show-all') {
|
|
181
|
+
return this.applyFreeFrame(getShowAllCameraFrame({
|
|
182
|
+
cars: snapshot.cars,
|
|
183
|
+
cameraZoom: this.camera.zoom,
|
|
184
|
+
height,
|
|
185
|
+
safeArea,
|
|
186
|
+
screenCenterX,
|
|
187
|
+
trackFitScale,
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (this.camera.mode === 'pit' && this.hasPitCamera(snapshot)) {
|
|
192
|
+
return this.applyFreeFrame(
|
|
193
|
+
this.getPitFrame(snapshot.track.pitLane, height, baseScale, safeArea, screenCenterX, trackFitScale),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return this.applyFreeFrame({
|
|
198
|
+
target: this.getTarget(snapshot, selectedId),
|
|
199
|
+
scale: clampCameraScale(baseScale * this.camera.zoom, baseScale, trackFitScale),
|
|
200
|
+
screenX: screenCenterX,
|
|
201
|
+
screenY: height / 2,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
applyFreeFrame(frame) {
|
|
206
|
+
if (!this.camera.free || !this.camera.freeTarget) return frame;
|
|
207
|
+
return {
|
|
208
|
+
...frame,
|
|
209
|
+
target: this.camera.freeTarget,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
getTarget(snapshot, selectedId = null) {
|
|
214
|
+
if (this.camera.free && this.camera.freeTarget) return this.camera.freeTarget;
|
|
215
|
+
if (this.camera.mode === 'overview') return this.getTrackTarget(snapshot.track);
|
|
216
|
+
|
|
217
|
+
if (this.camera.mode === 'pit' && this.hasPitCamera(snapshot)) {
|
|
218
|
+
return this.getPitTarget(snapshot.track.pitLane);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (this.camera.mode === 'selected') {
|
|
222
|
+
const selected = snapshot.cars.find((car) => car.id === selectedId);
|
|
223
|
+
if (selected) return selected;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const leader = snapshot.cars[0];
|
|
227
|
+
return leader ? { x: leader.x, y: leader.y } : { x: WORLD.width / 2, y: WORLD.height / 2 };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
getBaseScale() {
|
|
231
|
+
const width = this.canvasHost.clientWidth || 900;
|
|
232
|
+
const height = this.canvasHost.clientHeight || 640;
|
|
233
|
+
const safeArea = this.getSafeArea(width);
|
|
234
|
+
return Math.min(safeArea.width / (WORLD.width + 260), height / (WORLD.height + 220));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
applyToWorldLayer(worldLayer, snapshot, { immediate = false, selectedId = null } = {}) {
|
|
238
|
+
if (!worldLayer) return;
|
|
239
|
+
|
|
240
|
+
const width = this.canvasHost.clientWidth || 900;
|
|
241
|
+
const height = this.canvasHost.clientHeight || 640;
|
|
242
|
+
const safeArea = this.getSafeArea(width);
|
|
243
|
+
const baseScale = Math.min(safeArea.width / (WORLD.width + 260), height / (WORLD.height + 220));
|
|
244
|
+
const frame = this.getFrame(snapshot, width, height, baseScale, safeArea, selectedId);
|
|
245
|
+
const scale = frame.scale;
|
|
246
|
+
const target = frame.target;
|
|
247
|
+
const snapCamera = immediate || !this.camera.initialized;
|
|
248
|
+
this.camera.x = snapCamera
|
|
249
|
+
? target.x
|
|
250
|
+
: this.camera.x + (target.x - this.camera.x) * CAMERA_TARGET_LERP;
|
|
251
|
+
this.camera.y = snapCamera
|
|
252
|
+
? target.y
|
|
253
|
+
: this.camera.y + (target.y - this.camera.y) * CAMERA_TARGET_LERP;
|
|
254
|
+
const activeScale = snapCamera || this.camera.scale === null
|
|
255
|
+
? scale
|
|
256
|
+
: this.camera.scale + (scale - this.camera.scale) * CAMERA_SCALE_LERP;
|
|
257
|
+
this.camera.initialized = true;
|
|
258
|
+
this.camera.scale = activeScale;
|
|
259
|
+
worldLayer.scale.set(activeScale);
|
|
260
|
+
worldLayer.position.set(
|
|
261
|
+
frame.screenX - this.camera.x * activeScale,
|
|
262
|
+
frame.screenY - this.camera.y * activeScale,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|