@inventure71/paddockjs 1.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/INSTALL_AND_UPDATE.md +4 -3
  3. package/README.md +219 -12
  4. package/docs/architecture.md +126 -45
  5. package/docs/concepts.md +9 -5
  6. package/docs/custom_model_controller.md +329 -0
  7. package/docs/data_contract.md +317 -22
  8. package/docs/data_model.md +1 -1
  9. package/docs/index.md +10 -0
  10. package/docs/learnings.md +22 -0
  11. package/docs/rules.md +71 -14
  12. package/docs/sense_contract.md +59 -0
  13. package/docs/system_specs.md +75 -13
  14. package/docs/training.md +280 -32
  15. package/examples/python/README.md +62 -0
  16. package/examples/python/base_policy_server.py +191 -0
  17. package/examples/python/requirements.txt +2 -0
  18. package/package.json +12 -4
  19. package/src/api/PaddockSimulatorController.js +6 -33
  20. package/src/app/BrowserExpertAdapter.js +76 -9
  21. package/src/app/BrowserExpertExternalRenderer.js +303 -0
  22. package/src/app/F1SimulatorApp.js +291 -1630
  23. package/src/app/banners/bannerFormatters.js +55 -0
  24. package/src/app/banners/raceDataBannerController.js +141 -0
  25. package/src/app/banners/stewardMessageController.js +99 -0
  26. package/src/app/camera/cameraConstants.js +21 -0
  27. package/src/app/camera/cameraController.js +265 -0
  28. package/src/app/camera/cameraGeometry.js +176 -0
  29. package/src/app/domBindings.js +4 -0
  30. package/src/app/readouts/carOverviewRenderer.js +109 -0
  31. package/src/app/readouts/controlStateRenderer.js +37 -0
  32. package/src/app/readouts/raceStatusRenderer.js +133 -0
  33. package/src/app/readouts/readoutFormatters.js +58 -0
  34. package/src/app/readouts/telemetryRenderer.js +113 -0
  35. package/src/app/readouts/timingTowerRenderer.js +177 -0
  36. package/src/app/rendering/appAssets.js +38 -0
  37. package/src/app/rendering/carRenderer.js +286 -0
  38. package/src/app/rendering/displayUtils.js +22 -0
  39. package/src/app/rendering/drsTrailRenderer.js +90 -0
  40. package/src/app/rendering/expertSensorRenderer.js +126 -0
  41. package/src/app/rendering/pitLaneStatusRenderer.js +50 -0
  42. package/src/app/rendering/replayGhostRenderer.js +124 -0
  43. package/src/app/rendering/trackRenderer.js +25 -0
  44. package/src/app/runtime/frameLoop.js +66 -0
  45. package/src/app/runtime/runtimeTiming.js +34 -0
  46. package/src/app/runtime/runtimeVisibility.js +45 -0
  47. package/src/config/defaultOptions.js +20 -0
  48. package/src/config/restartOptions.js +34 -0
  49. package/src/environment/actions.js +13 -5
  50. package/src/environment/controllerLoop.js +261 -0
  51. package/src/environment/episode.js +98 -2
  52. package/src/environment/evaluation.js +54 -32
  53. package/src/environment/index.d.ts +376 -10
  54. package/src/environment/index.js +1 -0
  55. package/src/environment/metrics.js +159 -0
  56. package/src/environment/observationOptions.js +22 -1
  57. package/src/environment/observationVector.js +200 -0
  58. package/src/environment/observations.js +199 -141
  59. package/src/environment/options.js +37 -5
  60. package/src/environment/recorder.js +13 -6
  61. package/src/environment/rewards.js +13 -2
  62. package/src/environment/runtime.js +272 -23
  63. package/src/environment/scenarios.js +57 -9
  64. package/src/environment/sensors/bodySenses.js +23 -0
  65. package/src/environment/sensors/boundarySenses.js +17 -0
  66. package/src/environment/sensors/carRays.js +75 -0
  67. package/src/environment/sensors/contactSenses.js +32 -0
  68. package/src/environment/sensors/index.js +157 -0
  69. package/src/environment/sensors/indexedRayBands.js +103 -0
  70. package/src/environment/sensors/nearbyCars.js +52 -0
  71. package/src/environment/sensors/opponentRadar.js +75 -0
  72. package/src/environment/sensors/rayConfig.js +110 -0
  73. package/src/environment/sensors/rayDefaults.js +6 -0
  74. package/src/environment/sensors/rayGeometry.js +60 -0
  75. package/src/environment/sensors/rayGuards.js +19 -0
  76. package/src/environment/sensors/sensorTargets.js +66 -0
  77. package/src/environment/sensors/surfaceRays.js +360 -0
  78. package/src/environment/sensors/trackRays.js +424 -0
  79. package/src/environment/sensors.js +1 -367
  80. package/src/environment/specs.js +159 -26
  81. package/src/environment/workerProtocol.js +13 -1
  82. package/src/index.d.ts +171 -6
  83. package/src/index.js +6 -32
  84. package/src/rendering/proceduralTrackAsset.js +20 -823
  85. package/src/rendering/renderSnapshot.js +38 -12
  86. package/src/rendering/track/finishLineRenderer.js +34 -0
  87. package/src/rendering/track/gridRenderer.js +47 -0
  88. package/src/rendering/track/offsetStrokeSafety.js +136 -0
  89. package/src/rendering/track/pitLaneRenderer.js +248 -0
  90. package/src/rendering/track/trackMaterialRenderer.js +326 -0
  91. package/src/rendering/track/trackRenderConstants.js +38 -0
  92. package/src/rendering/track/trackRenderGeometry.js +94 -0
  93. package/src/simulation/collisionGeometry.js +1 -1
  94. package/src/simulation/driver/driverControlConstants.js +28 -0
  95. package/src/simulation/driver/driverController.js +47 -0
  96. package/src/simulation/driver/driverInput.js +28 -0
  97. package/src/simulation/driver/driverMath.js +6 -0
  98. package/src/simulation/driver/driverPersonality.js +25 -0
  99. package/src/simulation/driver/edgeRecovery.js +48 -0
  100. package/src/simulation/driver/racingControls.js +399 -0
  101. package/src/simulation/driver/racingLinePlan.js +166 -0
  102. package/src/simulation/driver/recoveryDynamics.js +38 -0
  103. package/src/simulation/driver/rejoinControls.js +213 -0
  104. package/src/simulation/driver/safetyCarControls.js +35 -0
  105. package/src/simulation/driver/trafficScan.js +51 -0
  106. package/src/simulation/driverController.js +4 -619
  107. package/src/simulation/participants/participantInteractions.js +120 -0
  108. package/src/simulation/pit/pitFlow.js +239 -0
  109. package/src/simulation/pit/pitIntent.js +92 -0
  110. package/src/simulation/pit/pitKinematics.js +24 -0
  111. package/src/simulation/pit/pitOccupancy.js +47 -0
  112. package/src/simulation/pit/pitPenaltyService.js +71 -0
  113. package/src/simulation/pit/pitQueue.js +60 -0
  114. package/src/simulation/pit/pitRouteMovement.js +167 -0
  115. package/src/simulation/pit/pitRouting.js +319 -0
  116. package/src/simulation/pit/pitService.js +43 -0
  117. package/src/simulation/pit/pitServiceConstants.js +21 -0
  118. package/src/simulation/pit/pitServiceProfile.js +45 -0
  119. package/src/simulation/pit/pitSnapshots.js +61 -0
  120. package/src/simulation/pit/pitState.js +113 -0
  121. package/src/simulation/pit/pitTireService.js +94 -0
  122. package/src/simulation/race/classification.js +104 -0
  123. package/src/simulation/race/gridPenalties.js +37 -0
  124. package/src/simulation/race/raceControlState.js +38 -0
  125. package/src/simulation/race/raceDistance.js +32 -0
  126. package/src/simulation/race/raceFinish.js +65 -0
  127. package/src/simulation/race/raceLifecycle.js +67 -0
  128. package/src/simulation/race/raceOrder.js +113 -0
  129. package/src/simulation/race/raceProgress.js +129 -0
  130. package/src/simulation/race/raceSetup.js +93 -0
  131. package/src/simulation/race/raceStep.js +80 -0
  132. package/src/simulation/race/redFlag.js +15 -0
  133. package/src/simulation/race/retirements.js +44 -0
  134. package/src/simulation/race/safetyCar.js +92 -0
  135. package/src/simulation/race/stalledDnf.js +77 -0
  136. package/src/simulation/raceSimulation.js +231 -2817
  137. package/src/simulation/replay/replayGhosts.js +128 -0
  138. package/src/simulation/rules/moduleConfig.js +72 -0
  139. package/src/simulation/rules/penaltyConfig.js +99 -0
  140. package/src/simulation/rules/penaltyEvents.js +18 -0
  141. package/src/simulation/rules/penaltyStats.js +26 -0
  142. package/src/simulation/rules/ruleConfigMerge.js +41 -0
  143. package/src/simulation/rules/ruleDefaults.js +85 -0
  144. package/src/simulation/rules/rulesReview.js +144 -0
  145. package/src/simulation/rules/rulesetPresets.js +47 -0
  146. package/src/simulation/rules/trackLimitsSteward.js +1 -1
  147. package/src/simulation/rulesConfig.js +5 -323
  148. package/src/simulation/snapshots/raceSnapshots.js +193 -0
  149. package/src/simulation/timing/drsTiming.js +87 -0
  150. package/src/simulation/timing/gapEstimation.js +38 -0
  151. package/src/simulation/timing/lapTelemetry.js +182 -0
  152. package/src/simulation/timing/raceTiming.js +15 -0
  153. package/src/simulation/timing/sectorPerformance.js +60 -0
  154. package/src/simulation/timing/timingConstants.js +5 -0
  155. package/src/simulation/timing/timingHistory.js +46 -0
  156. package/src/simulation/timing/timingLines.js +79 -0
  157. package/src/simulation/track/centerlineSpline.js +59 -0
  158. package/src/simulation/track/drsZones.js +67 -0
  159. package/src/simulation/track/pitLaneAccess.js +103 -0
  160. package/src/simulation/track/pitLaneLayout.js +395 -0
  161. package/src/simulation/track/pitLaneState.js +160 -0
  162. package/src/simulation/track/proceduralCenterline.js +237 -0
  163. package/src/simulation/track/sampleGeometry.js +49 -0
  164. package/src/simulation/track/spatialQueries.js +154 -0
  165. package/src/simulation/track/startStraight.js +141 -0
  166. package/src/simulation/track/trackConstants.js +109 -0
  167. package/src/simulation/track/trackGenerationOptions.js +283 -0
  168. package/src/simulation/track/trackMath.js +241 -0
  169. package/src/simulation/track/trackModel.js +240 -0
  170. package/src/simulation/track/trackQueryGrid.js +94 -0
  171. package/src/simulation/track/trackQueryIndex.js +659 -0
  172. package/src/simulation/track/trackQueryPitIndex.js +140 -0
  173. package/src/simulation/track/trackQueryScratch.js +46 -0
  174. package/src/simulation/track/trackQueryStats.js +28 -0
  175. package/src/simulation/track/trackSectors.js +19 -0
  176. package/src/simulation/track/trackStatePolicy.js +24 -0
  177. package/src/simulation/track/trackValidation.js +98 -0
  178. package/src/simulation/trackModel.js +1 -1706
  179. package/src/simulation/vehicle/contactResolution.js +181 -0
  180. package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
  181. package/src/simulation/vehicle/pitWheelSurface.js +69 -0
  182. package/src/simulation/vehicle/runoffResponse.js +87 -0
  183. package/src/simulation/vehicle/surfacePriority.js +28 -0
  184. package/src/simulation/vehicle/vehicleGeometry.js +247 -0
  185. package/src/simulation/vehicle/vehicleKinematics.js +37 -0
  186. package/src/simulation/vehicle/vehiclePhysics.js +364 -0
  187. package/src/simulation/vehicle/vehicleSnapshots.js +327 -0
  188. package/src/simulation/vehicle/vehicleState.js +220 -0
  189. package/src/simulation/vehicle/wheelSurface.js +152 -0
  190. package/src/simulation/vehicleGeometry.js +1 -247
  191. package/src/simulation/vehiclePhysics.js +1 -136
  192. package/src/simulation/warmup/runtimeWarmup.js +224 -0
  193. package/src/simulation/wheelSurface.js +1 -338
  194. package/src/styles.css +42 -0
  195. package/src/ui/cameraControlsTemplate.js +20 -0
  196. package/src/ui/carOverviewTemplate.js +33 -0
  197. package/src/ui/componentTemplates.js +14 -360
  198. package/src/ui/raceCanvasTemplate.js +69 -0
  199. package/src/ui/raceControlsTemplate.js +36 -0
  200. package/src/ui/shellTemplate.js +5 -4
  201. package/src/ui/telemetryDrawerTemplate.js +55 -0
  202. package/src/ui/telemetryTemplates.js +136 -0
  203. package/src/ui/timingTowerTemplate.js +34 -0
@@ -0,0 +1,177 @@
1
+ import { setText } from '../domBindings.js';
2
+ import { escapeHtml, formatLapGap, formatRaceGap, getTireClass } from './readoutFormatters.js';
3
+
4
+ function formatPenaltyHeadline(penalty) {
5
+ const serviceType = penalty?.serviceType;
6
+ if (serviceType === 'driveThrough') {
7
+ return penalty?.unserved ? 'unserved drive-through penalty' : 'drive-through penalty';
8
+ }
9
+ if (serviceType === 'stopGo') {
10
+ return penalty?.unserved ? 'unserved stop-go penalty' : 'stop-go penalty';
11
+ }
12
+ const positionDrop = Number(penalty?.positionDrop);
13
+ if (Number.isFinite(positionDrop) && positionDrop > 0) {
14
+ return `${positionDrop}-place position drop`;
15
+ }
16
+ const gridDrop = Number(penalty?.gridDrop);
17
+ if (Number.isFinite(gridDrop) && gridDrop > 0) {
18
+ return `${gridDrop}-place grid drop`;
19
+ }
20
+ if (penalty?.disqualified) return 'disqualification';
21
+ const seconds = Number(penalty?.penaltySeconds);
22
+ if (!Number.isFinite(seconds) || seconds <= 0) return 'Penalty decision';
23
+ const unit = seconds === 1 ? 'second' : 'seconds';
24
+ return `+${seconds} ${unit} time penalty`;
25
+ }
26
+
27
+ function formatPenaltyBadgeLabel(penalty) {
28
+ const seconds = Number(penalty?.penaltySeconds);
29
+ const timeLabel = Number.isFinite(seconds) && seconds > 0 ? `${seconds}s ` : '';
30
+ if (Number.isFinite(seconds) && seconds > 0 && !penalty?.serviceType) {
31
+ return `Penalty: ${timeLabel}${String(penalty?.type ?? 'decision')}`;
32
+ }
33
+ return `Penalty: ${timeLabel}${formatPenaltyHeadline(penalty)}`;
34
+ }
35
+
36
+ export function hasVisiblePenaltyEffect(penalty) {
37
+ if (!penalty || penalty.status === 'cancelled') return false;
38
+ if (penalty.serviceRequired && penalty.status === 'served') return false;
39
+ const penaltySeconds = Number(penalty.penaltySeconds);
40
+ const pendingPenaltySeconds = Number(penalty.pendingPenaltySeconds);
41
+ const positionDrop = Number(penalty.positionDrop);
42
+ const gridDrop = Number(penalty.gridDrop);
43
+ return penalty.serviceRequired ||
44
+ (Number.isFinite(penaltySeconds) && penaltySeconds > 0) ||
45
+ (Number.isFinite(pendingPenaltySeconds) && pendingPenaltySeconds > 0) ||
46
+ (Number.isFinite(positionDrop) && positionDrop > 0) ||
47
+ (Number.isFinite(gridDrop) && gridDrop > 0) ||
48
+ Boolean(penalty.disqualified);
49
+ }
50
+
51
+ export function isWavedFlagCar(car) {
52
+ return Boolean(car?.wavedFlag || car?.raceStatus === 'waved-flag' || car?.status === 'waved-flag');
53
+ }
54
+
55
+ export function isDnfCar(car) {
56
+ return Boolean(car?.dnf || car?.destroyed || car?.outOfRace || car?.raceStatus === 'destroyed' || car?.status === 'destroyed');
57
+ }
58
+
59
+ export function getPenaltyByDriver(penalties = []) {
60
+ const byDriver = new Map();
61
+ penalties.forEach((penalty) => {
62
+ if (!penalty?.driverId || byDriver.has(penalty.driverId)) return;
63
+ if (!hasVisiblePenaltyEffect(penalty)) return;
64
+ byDriver.set(penalty.driverId, penalty);
65
+ });
66
+ return byDriver;
67
+ }
68
+
69
+ export function getTimingPenaltyKey(penalties = [], { timingPenaltyBadgesEnabled = false } = {}) {
70
+ if (!timingPenaltyBadgesEnabled) return '';
71
+ return penalties
72
+ .filter(hasVisiblePenaltyEffect)
73
+ .map((penalty) => [
74
+ penalty.id,
75
+ penalty.driverId,
76
+ penalty.status,
77
+ penalty.penaltySeconds,
78
+ penalty.pendingPenaltySeconds,
79
+ penalty.positionDrop,
80
+ penalty.gridDrop,
81
+ penalty.disqualified,
82
+ ].join(':'))
83
+ .join('|');
84
+ }
85
+
86
+ export function getTimingOrderKey(cars = [], { timingGapMode = 'interval' } = {}) {
87
+ return cars.map((car) => [
88
+ car.id,
89
+ car.rank,
90
+ car.lap,
91
+ formatRaceGap(car, timingGapMode),
92
+ car.penaltySeconds ?? 0,
93
+ car.classifiedRank ?? 0,
94
+ car.finishRank ?? 0,
95
+ car.dnf ? 1 : 0,
96
+ car.destroyed ? 1 : 0,
97
+ car.outOfRace ? 1 : 0,
98
+ car.dnfOrder ?? 0,
99
+ car.dnfReason ?? car.destroyReason ?? '',
100
+ car.raceStatus ?? car.status ?? '',
101
+ car.wavedFlag ? 1 : 0,
102
+ car.tire ?? '',
103
+ ].join(':')).join('|');
104
+ }
105
+
106
+ export function syncTimingGapModeControls({ readouts, buttons, timingGapMode }) {
107
+ const label = timingGapMode === 'leader' ? 'Gap' : 'Int';
108
+ setText(readouts.timingGapLabel, label);
109
+ buttons.forEach((button) => {
110
+ const active = button.dataset.timingGapMode === timingGapMode;
111
+ button.setAttribute('aria-pressed', String(active));
112
+ button.classList?.toggle?.('is-active', active);
113
+ });
114
+ }
115
+
116
+ export function renderTimingTower({
117
+ timingList,
118
+ cars,
119
+ raceMode,
120
+ penalties = [],
121
+ driverById,
122
+ selectedId,
123
+ timingGapMode,
124
+ timingPenaltyBadgesEnabled,
125
+ lastTimingMarkup,
126
+ }) {
127
+ if (!timingList) return lastTimingMarkup;
128
+ const penaltyByDriver = timingPenaltyBadgesEnabled ? getPenaltyByDriver(penalties) : new Map();
129
+ const timingMarkup = cars.map((car) => {
130
+ const driver = driverById.get(car.id);
131
+ const dnf = isDnfCar(car);
132
+ let gap = 'Leader';
133
+ if (dnf) {
134
+ gap = 'DNF';
135
+ } else if (isWavedFlagCar(car) && raceMode !== 'finished') {
136
+ gap = 'WAVED';
137
+ } else if (raceMode === 'finished') {
138
+ gap = car.rank === 1 ? 'Winner' : 'FIN';
139
+ } else if (raceMode === 'safety-car' && car.rank > 1) {
140
+ gap = 'SC';
141
+ } else if (raceMode === 'pre-start') {
142
+ gap = car.rank === 1 ? 'Pole' : 'Grid';
143
+ } else if (car.rank > 1) {
144
+ gap = formatRaceGap(car, timingGapMode);
145
+ }
146
+ const tire = car.tire ?? driver?.tire ?? 'M';
147
+ const timingCode = car.timingCode ?? driver?.timingCode ?? car.code;
148
+ const team = car.team ?? driver?.team ?? null;
149
+ const icon = team?.icon ?? car.icon ?? driver?.icon ?? timingCode;
150
+ const iconColor = team?.color ?? car.color;
151
+ const penalty = penaltyByDriver.get(car.id);
152
+ const penaltyBadge = penalty
153
+ ? `<span class="timing-penalty-badge" aria-label="${escapeHtml(formatPenaltyBadgeLabel(penalty))}" title="${escapeHtml(formatPenaltyBadgeLabel(penalty))}">!</span>`
154
+ : '';
155
+
156
+ return `
157
+ <li>
158
+ <button class="timing-row ${car.id === selectedId ? 'is-selected' : ''} ${dnf ? 'is-dnf' : ''}" type="button"
159
+ data-driver-id="${escapeHtml(car.id)}" aria-label="Select ${escapeHtml(car.name)}"
160
+ style="--driver-color: ${escapeHtml(car.color)}">
161
+ <span class="timing-position">${car.rank}</span>
162
+ <span class="timing-icon timing-team-icon" aria-hidden="true" style="--team-color: ${escapeHtml(iconColor)}">${escapeHtml(icon)}</span>
163
+ <span class="timing-name" title="${escapeHtml(car.name)}"><span>${escapeHtml(timingCode)}</span>${penaltyBadge}</span>
164
+ <span class="timing-gap">${escapeHtml(gap)}</span>
165
+ <span class="timing-tire timing-tire--${getTireClass(tire)}">${escapeHtml(tire)}</span>
166
+ </button>
167
+ </li>
168
+ `;
169
+ }).join('');
170
+ if (timingMarkup !== lastTimingMarkup) {
171
+ timingList.innerHTML = timingMarkup;
172
+ return timingMarkup;
173
+ }
174
+ return lastTimingMarkup;
175
+ }
176
+
177
+ export { formatLapGap, formatRaceGap };
@@ -0,0 +1,38 @@
1
+ import { Assets, Texture } from 'pixi.js';
2
+
3
+ async function loadTexture(url, { configure = null } = {}) {
4
+ try {
5
+ const texture = await Assets.load(url);
6
+ configure?.(texture);
7
+ return texture;
8
+ } catch {
9
+ return Texture.WHITE;
10
+ }
11
+ }
12
+
13
+ export async function loadAppTextures(assets) {
14
+ const textures = {
15
+ car: await loadTexture(assets.car, {
16
+ configure(texture) {
17
+ texture.source.scaleMode = 'linear';
18
+ texture.source.autoGenerateMipmaps = true;
19
+ },
20
+ }),
21
+ safetyCar: await loadTexture(assets.safetyCar, {
22
+ configure(texture) {
23
+ texture.source.scaleMode = 'linear';
24
+ texture.source.autoGenerateMipmaps = true;
25
+ },
26
+ }),
27
+ };
28
+
29
+ await Promise.all(Object.entries(assets.trackTextures).map(async ([key, url]) => {
30
+ textures[key] = await loadTexture(url, {
31
+ configure(texture) {
32
+ texture.source.scaleMode = 'linear';
33
+ },
34
+ });
35
+ }));
36
+
37
+ return textures;
38
+ }
@@ -0,0 +1,286 @@
1
+ import { Container, Graphics, Sprite, Text, Texture } from 'pixi.js';
2
+ import { metersToSimUnits } from '../../simulation/units.js';
3
+ import { VEHICLE_GEOMETRY } from '../../simulation/vehicleGeometry.js';
4
+ import { CAR_WORLD_LENGTH } from '../camera/cameraConstants.js';
5
+ import { colorToTint, smoothAngle } from './displayUtils.js';
6
+
7
+ const CAR_WORLD_WIDTH = VEHICLE_GEOMETRY.visualWidth;
8
+ const TEMP_RENDER_RAW_CAR_GEOMETRY = false;
9
+ const NON_COLLIDING_MARKER_COLOR = 0x38bdf8;
10
+ const SAFETY_CAR_WORLD_LENGTH = metersToSimUnits(5.3);
11
+ const SAFETY_CAR_WORLD_WIDTH = metersToSimUnits(2.1);
12
+ const SERVICE_COUNTDOWN_LABEL_STYLES = {
13
+ penalty: {
14
+ background: 0x110b0b,
15
+ stroke: 0xff2d55,
16
+ fill: 0xffffff,
17
+ },
18
+ pit: {
19
+ background: 0x171305,
20
+ stroke: 0xffd166,
21
+ fill: 0xfff2b0,
22
+ },
23
+ };
24
+
25
+ export class CarRenderer {
26
+ constructor({ carSprites, carHitAreas, serviceCountdownLabels, onSelectCar }) {
27
+ this.carSprites = carSprites;
28
+ this.carHitAreas = carHitAreas;
29
+ this.serviceCountdownLabels = serviceCountdownLabels;
30
+ this.onSelectCar = onSelectCar;
31
+ this.safetySprite = null;
32
+ this.nonCollidingMarkers = new Map();
33
+ }
34
+
35
+ createCars({ drivers, textures, carLayer }) {
36
+ const texture = textures.car ?? Texture.WHITE;
37
+ const baseScale = Math.min(
38
+ CAR_WORLD_LENGTH / Math.max(texture.width, 1),
39
+ CAR_WORLD_WIDTH / Math.max(texture.height, 1),
40
+ );
41
+
42
+ this.carSprites.forEach((sprite) => sprite.destroy());
43
+ this.carHitAreas.forEach((hit) => hit.destroy());
44
+ this.serviceCountdownLabels.forEach((label) => label.destroy({ children: true }));
45
+ this.nonCollidingMarkers.forEach((marker) => marker.destroy());
46
+ this.carSprites.clear();
47
+ this.carHitAreas.clear();
48
+ this.serviceCountdownLabels.clear();
49
+ this.nonCollidingMarkers.clear();
50
+
51
+ if (this.safetySprite) {
52
+ this.safetySprite.destroy();
53
+ this.safetySprite = null;
54
+ }
55
+
56
+ drivers.forEach((driver) => {
57
+ const sprite = TEMP_RENDER_RAW_CAR_GEOMETRY
58
+ ? this.createRawCarGeometryGraphic(driver)
59
+ : new Sprite(texture);
60
+ if (!TEMP_RENDER_RAW_CAR_GEOMETRY) {
61
+ sprite.anchor.set(0.5);
62
+ sprite.baseScale = baseScale;
63
+ sprite.scale.set(baseScale);
64
+ } else {
65
+ sprite.baseScale = 1;
66
+ }
67
+
68
+ const marker = this.createNonCollidingMarker();
69
+ this.nonCollidingMarkers.set(driver.id, marker);
70
+ carLayer.addChild(marker);
71
+
72
+ sprite.tint = TEMP_RENDER_RAW_CAR_GEOMETRY ? 0xffffff : colorToTint(driver.color);
73
+ sprite.eventMode = 'static';
74
+ sprite.cursor = 'pointer';
75
+ sprite.on('pointerdown', () => {
76
+ this.onSelectCar?.(driver.id);
77
+ });
78
+ this.carSprites.set(driver.id, sprite);
79
+ carLayer.addChild(sprite);
80
+
81
+ const hit = new Graphics();
82
+ hit.circle(0, 0, 24).fill({ color: 0xffffff, alpha: 0.001 });
83
+ hit.eventMode = 'static';
84
+ hit.cursor = 'pointer';
85
+ hit.on('pointerdown', () => {
86
+ this.onSelectCar?.(driver.id);
87
+ });
88
+ this.carHitAreas.set(driver.id, hit);
89
+ carLayer.addChild(hit);
90
+
91
+ const serviceLabel = this.createServiceCountdownLabel();
92
+ serviceLabel.visible = false;
93
+ this.serviceCountdownLabels.set(driver.id, serviceLabel);
94
+ carLayer.addChild(serviceLabel);
95
+ });
96
+ }
97
+
98
+ createRawCarGeometryGraphic(driver) {
99
+ const graphic = new Graphics();
100
+ const tint = colorToTint(driver.color);
101
+ const wheelLong = VEHICLE_GEOMETRY.wheelLongitudinalOffset;
102
+ const wheelLat = VEHICLE_GEOMETRY.wheelLateralOffset;
103
+ const wheelLength = VEHICLE_GEOMETRY.wheelLength;
104
+ const wheelWidth = VEHICLE_GEOMETRY.wheelWidth;
105
+
106
+ graphic
107
+ .rect(
108
+ -VEHICLE_GEOMETRY.bodyLength / 2,
109
+ -VEHICLE_GEOMETRY.bodyWidth / 2,
110
+ VEHICLE_GEOMETRY.bodyLength,
111
+ VEHICLE_GEOMETRY.bodyWidth,
112
+ )
113
+ .fill({ color: tint, alpha: 0.42 })
114
+ .stroke({ width: 2.2, color: 0xf8fafc, alpha: 0.95 });
115
+
116
+ [
117
+ [wheelLong, -wheelLat],
118
+ [wheelLong, wheelLat],
119
+ [-wheelLong, -wheelLat],
120
+ [-wheelLong, wheelLat],
121
+ ].forEach(([x, y]) => {
122
+ graphic
123
+ .rect(x - wheelLength / 2, y - wheelWidth / 2, wheelLength, wheelWidth)
124
+ .fill({ color: 0x111827, alpha: 0.95 })
125
+ .stroke({ width: 1.5, color: tint, alpha: 1 });
126
+ });
127
+
128
+ const noseMarkerLength = metersToSimUnits(0.75);
129
+ graphic
130
+ .moveTo(VEHICLE_GEOMETRY.bodyLength / 2 - noseMarkerLength, 0)
131
+ .lineTo(VEHICLE_GEOMETRY.bodyLength / 2 + noseMarkerLength, 0)
132
+ .stroke({ width: 2, color: 0xf1c65b, alpha: 0.95 });
133
+ return graphic;
134
+ }
135
+
136
+ createNonCollidingMarker() {
137
+ const marker = new Graphics();
138
+ marker.visible = false;
139
+ marker.eventMode = 'none';
140
+ marker.interactionVisualRole = 'non-colliding-marker';
141
+ return marker;
142
+ }
143
+
144
+ renderNonCollidingMarker(car) {
145
+ const marker = this.nonCollidingMarkers.get(car.id);
146
+ if (!marker) return;
147
+ const visible = car.interaction?.collidable === false;
148
+ if (marker.visible !== visible) marker.visible = visible;
149
+ if (!visible) return;
150
+
151
+ if (marker.x !== car.x) marker.x = car.x;
152
+ if (marker.y !== car.y) marker.y = car.y;
153
+ if (marker.rotation !== car.heading) marker.rotation = car.heading;
154
+
155
+ marker.clear();
156
+ const length = CAR_WORLD_LENGTH * 0.82;
157
+ const width = CAR_WORLD_WIDTH * 1.32;
158
+ const halfLength = length / 2;
159
+ const halfWidth = width / 2;
160
+ const segment = CAR_WORLD_LENGTH * 0.22;
161
+ const alpha = 0.86;
162
+ marker.ellipse(0, 0, halfLength, halfWidth)
163
+ .stroke({ width: 2.5, color: NON_COLLIDING_MARKER_COLOR, alpha: 0.36 });
164
+ [
165
+ [1, 1],
166
+ [1, -1],
167
+ [-1, 1],
168
+ [-1, -1],
169
+ ].forEach(([longitudinal, lateral]) => {
170
+ marker
171
+ .moveTo(longitudinal * halfLength, lateral * (halfWidth - segment))
172
+ .lineTo(longitudinal * halfLength, lateral * halfWidth)
173
+ .lineTo(longitudinal * (halfLength - segment), lateral * halfWidth)
174
+ .stroke({ width: 4, color: NON_COLLIDING_MARKER_COLOR, alpha });
175
+ });
176
+ }
177
+
178
+ createServiceCountdownLabel() {
179
+ const container = new Container();
180
+ const background = new Graphics();
181
+ const text = new Text({
182
+ text: '0s',
183
+ style: {
184
+ fill: 0xffffff,
185
+ fontFamily: 'Arial, sans-serif',
186
+ fontSize: 16,
187
+ fontWeight: '900',
188
+ align: 'center',
189
+ },
190
+ });
191
+ text.anchor.set(0.5);
192
+ text.resolution = 2;
193
+ container.addChild(background, text);
194
+ container.labelBackground = background;
195
+ container.labelText = text;
196
+ this.applyServiceCountdownTone(container, 'pit');
197
+ return container;
198
+ }
199
+
200
+ applyServiceCountdownTone(label, tone) {
201
+ const style = SERVICE_COUNTDOWN_LABEL_STYLES[tone] ?? SERVICE_COUNTDOWN_LABEL_STYLES.pit;
202
+ if (label.serviceTone === tone) return;
203
+ label.labelBackground?.clear?.();
204
+ label.labelBackground?.roundRect(-28, -14, 56, 28, 4)
205
+ .fill({ color: style.background, alpha: 0.92 })
206
+ .stroke({ width: 2, color: style.stroke, alpha: 0.95 });
207
+ if (label.labelText?.style) label.labelText.style.fill = style.fill;
208
+ label.serviceTone = tone;
209
+ }
210
+
211
+ renderCars(snapshot, { textures, carLayer }) {
212
+ snapshot.cars.forEach((car) => {
213
+ const sprite = this.carSprites.get(car.id);
214
+ const hit = this.carHitAreas.get(car.id);
215
+ if (!sprite || !hit) return;
216
+ if (sprite.x !== car.x) sprite.x = car.x;
217
+ if (sprite.y !== car.y) sprite.y = car.y;
218
+ if (TEMP_RENDER_RAW_CAR_GEOMETRY) {
219
+ sprite.currentRotation = car.heading;
220
+ if (sprite.rotation !== car.heading) sprite.rotation = car.heading;
221
+ } else {
222
+ sprite.currentRotation = smoothAngle(sprite.currentRotation, car.heading, 0.24);
223
+ if (sprite.rotation !== sprite.currentRotation) sprite.rotation = sprite.currentRotation;
224
+ }
225
+ const retired = Boolean(car.destroyed || car.dnf || car.outOfRace);
226
+ const alpha = retired ? 0.48 : snapshot.raceControl.mode === 'safety-car' ? 0.82 : 1;
227
+ if (sprite.alpha !== alpha) sprite.alpha = alpha;
228
+ if (sprite.lastRenderedScale !== sprite.baseScale) {
229
+ sprite.scale.set(sprite.baseScale);
230
+ sprite.lastRenderedScale = sprite.baseScale;
231
+ }
232
+ const tint = retired ? 0x1f2937 : TEMP_RENDER_RAW_CAR_GEOMETRY ? 0xffffff : colorToTint(car.color);
233
+ if (sprite.tint !== tint) sprite.tint = tint;
234
+ if (hit.x !== car.x) hit.x = car.x;
235
+ if (hit.y !== car.y) hit.y = car.y;
236
+ this.renderNonCollidingMarker(car);
237
+ this.renderServiceCountdownLabel(car);
238
+ });
239
+
240
+ if (!this.safetySprite && snapshot.safetyCar.deployed) {
241
+ const texture = textures.safetyCar ?? Texture.WHITE;
242
+ this.safetySprite = new Sprite(texture);
243
+ this.safetySprite.anchor.set(0.5);
244
+ this.safetySprite.baseScale = Math.min(
245
+ SAFETY_CAR_WORLD_LENGTH / Math.max(texture.width, 1),
246
+ SAFETY_CAR_WORLD_WIDTH / Math.max(texture.height, 1),
247
+ );
248
+ this.safetySprite.scale.set(this.safetySprite.baseScale);
249
+ carLayer.addChild(this.safetySprite);
250
+ }
251
+
252
+ if (this.safetySprite) {
253
+ if (this.safetySprite.visible !== snapshot.safetyCar.deployed) {
254
+ this.safetySprite.visible = snapshot.safetyCar.deployed;
255
+ }
256
+ if (this.safetySprite.x !== snapshot.safetyCar.x) this.safetySprite.x = snapshot.safetyCar.x;
257
+ if (this.safetySprite.y !== snapshot.safetyCar.y) this.safetySprite.y = snapshot.safetyCar.y;
258
+ if (this.safetySprite.rotation !== snapshot.safetyCar.heading) {
259
+ this.safetySprite.rotation = snapshot.safetyCar.heading;
260
+ }
261
+ }
262
+ }
263
+
264
+ renderServiceCountdownLabel(car) {
265
+ const label = this.serviceCountdownLabels.get(car.id);
266
+ if (!label) return;
267
+ const penaltyRemaining = Number(car.pitStop?.penaltyServiceRemainingSeconds);
268
+ const serviceRemaining = Number(car.pitStop?.serviceRemainingSeconds);
269
+ const isPenalty = car.pitStop?.phase === 'penalty' && Number.isFinite(penaltyRemaining) && penaltyRemaining > 0;
270
+ const isPitService = car.pitStop?.phase === 'service' && Number.isFinite(serviceRemaining) && serviceRemaining > 0;
271
+ const visible = isPenalty || isPitService;
272
+ label.visible = visible;
273
+ if (!visible) return;
274
+ const tone = isPenalty ? 'penalty' : 'pit';
275
+ const remaining = isPenalty ? penaltyRemaining : serviceRemaining;
276
+ if (label.x !== car.x) label.x = car.x;
277
+ const labelY = car.y - 48;
278
+ if (label.y !== labelY) label.y = labelY;
279
+ if (label.rotation !== 0) label.rotation = 0;
280
+ this.applyServiceCountdownTone(label, tone);
281
+ if (label.labelText) {
282
+ const nextText = `${tone === 'penalty' ? '+' : ''}${Math.ceil(remaining)}s`;
283
+ if (label.labelText.text !== nextText) label.labelText.text = nextText;
284
+ }
285
+ }
286
+ }
@@ -0,0 +1,22 @@
1
+ const TINT_BY_COLOR = new Map();
2
+
3
+ export function colorToTint(color) {
4
+ if (TINT_BY_COLOR.has(color)) return TINT_BY_COLOR.get(color);
5
+ const tint = Number.parseInt(String(color ?? '').replace('#', ''), 16);
6
+ const normalizedTint = Number.isFinite(tint) ? tint : 0xffffff;
7
+ TINT_BY_COLOR.set(color, normalizedTint);
8
+ return normalizedTint;
9
+ }
10
+
11
+ export function smoothAngle(current, target, amount) {
12
+ if (!Number.isFinite(current)) return target;
13
+ let diff = ((target - current + Math.PI) % (Math.PI * 2)) - Math.PI;
14
+ if (diff < -Math.PI) diff += Math.PI * 2;
15
+ return current + diff * amount;
16
+ }
17
+
18
+ export function destroyDisplayChildren(container) {
19
+ container?.removeChildren?.().forEach((child) => {
20
+ child.destroy?.({ children: true, texture: false, textureSource: false });
21
+ });
22
+ }
@@ -0,0 +1,90 @@
1
+ import { metersToSimUnits } from '../../simulation/units.js';
2
+ import { clamp } from '../../simulation/simMath.js';
3
+ import { CAR_WORLD_LENGTH } from '../camera/cameraConstants.js';
4
+
5
+ const DRS_TRAIL_TTL = 0.34;
6
+ const DRS_TRAIL_MIN_DISTANCE = metersToSimUnits(0.9);
7
+
8
+ function pruneExpiredTrailPoints(history, now) {
9
+ let expiredCount = 0;
10
+ while (expiredCount < history.length && now - history[expiredCount].at > DRS_TRAIL_TTL) {
11
+ expiredCount += 1;
12
+ }
13
+ if (expiredCount > 0) history.splice(0, expiredCount);
14
+ }
15
+
16
+ export class DrsTrailRenderer {
17
+ constructor({ trails }) {
18
+ this.trails = trails;
19
+ this.hadVisibleTrails = false;
20
+ }
21
+
22
+ reset() {
23
+ this.trails.clear();
24
+ this.hadVisibleTrails = false;
25
+ }
26
+
27
+ render(snapshot, trailLayer) {
28
+ if (!trailLayer) return;
29
+
30
+ snapshot.cars.forEach((car) => {
31
+ const history = this.trails.get(car.id);
32
+ if (!history && !car.drsActive) return;
33
+ const activeHistory = history ?? [];
34
+ const rear = {
35
+ x: car.x - Math.cos(car.heading) * CAR_WORLD_LENGTH * 0.46,
36
+ y: car.y - Math.sin(car.heading) * CAR_WORLD_LENGTH * 0.46,
37
+ at: snapshot.time,
38
+ };
39
+ const last = activeHistory[activeHistory.length - 1];
40
+
41
+ if (
42
+ car.drsActive &&
43
+ (!last || Math.hypot(rear.x - last.x, rear.y - last.y) >= DRS_TRAIL_MIN_DISTANCE)
44
+ ) {
45
+ activeHistory.push(rear);
46
+ }
47
+
48
+ pruneExpiredTrailPoints(activeHistory, snapshot.time);
49
+ if (activeHistory.length) this.trails.set(car.id, activeHistory);
50
+ else this.trails.delete(car.id);
51
+ });
52
+
53
+ const hasVisibleTrails = Array.from(this.trails.values()).some((history) => history.length >= 2);
54
+ if (!hasVisibleTrails) {
55
+ if (this.hadVisibleTrails) trailLayer.clear();
56
+ this.hadVisibleTrails = false;
57
+ return;
58
+ }
59
+
60
+ trailLayer.clear();
61
+ this.trails.forEach((history) => {
62
+ if (history.length < 2) return;
63
+ const newest = history[history.length - 1];
64
+ const newestLife = clamp(1 - (snapshot.time - newest.at) / DRS_TRAIL_TTL, 0, 1);
65
+ trailLayer.moveTo(history[0].x, history[0].y);
66
+ for (let index = 1; index < history.length; index += 1) {
67
+ trailLayer.lineTo(history[index].x, history[index].y);
68
+ }
69
+ trailLayer.stroke({
70
+ width: 10,
71
+ color: 0x3be8ff,
72
+ alpha: 0.28 + newestLife * 0.34,
73
+ cap: 'round',
74
+ join: 'round',
75
+ });
76
+ trailLayer.moveTo(history[0].x, history[0].y);
77
+ for (let index = 1; index < history.length; index += 1) {
78
+ trailLayer.lineTo(history[index].x, history[index].y);
79
+ }
80
+ trailLayer.stroke({
81
+ width: 3.2,
82
+ color: 0xd8fbff,
83
+ alpha: 0.18 + newestLife * 0.28,
84
+ cap: 'round',
85
+ join: 'round',
86
+ });
87
+ });
88
+ this.hadVisibleTrails = true;
89
+ }
90
+ }