@inventure71/paddockjs 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/INSTALL_AND_UPDATE.md +165 -0
- package/README.md +217 -20
- package/docs/architecture.md +84 -15
- package/docs/concepts.md +20 -3
- package/docs/data_contract.md +412 -20
- package/docs/data_model.md +2 -1
- package/docs/index.md +1 -0
- package/docs/learnings.md +1 -1
- package/docs/rules.md +140 -19
- package/docs/system_specs.md +74 -20
- package/docs/training.md +206 -0
- package/examples/train-basic-policy.mjs +276 -0
- package/examples/trainingData.mjs +133 -0
- package/package.json +18 -8
- package/src/api/PaddockSimulatorController.js +39 -4
- package/src/app/BrowserExpertAdapter.js +32 -0
- package/src/app/F1SimulatorApp.js +1251 -146
- package/src/app/domBindings.js +10 -2
- package/src/config/defaultOptions.js +11 -3
- package/src/data/championship.js +13 -0
- package/src/data/normalizeDrivers.js +7 -2
- package/src/environment/actions.js +68 -0
- package/src/environment/episode.js +17 -0
- package/src/environment/evaluation.js +184 -0
- package/src/environment/events.js +17 -0
- package/src/environment/index.d.ts +521 -0
- package/src/environment/index.js +15 -0
- package/src/environment/observationOptions.js +12 -0
- package/src/environment/observations.js +284 -0
- package/src/environment/options.js +157 -0
- package/src/environment/recorder.js +28 -0
- package/src/environment/rewards.js +69 -0
- package/src/environment/runtime.js +175 -0
- package/src/environment/scenarios.js +220 -0
- package/src/environment/sensors.js +367 -0
- package/src/environment/specs.js +163 -0
- package/src/environment/workerProtocol.js +52 -0
- package/src/index.d.ts +405 -6
- package/src/index.js +27 -2
- package/src/rendering/proceduralTrackAsset.js +343 -24
- package/src/simulation/collisionGeometry.js +214 -0
- package/src/simulation/driverController.js +431 -65
- package/src/simulation/raceSimulation.js +2140 -171
- package/src/simulation/rules/collisionSteward.js +69 -0
- package/src/simulation/rules/penaltyLedger.js +153 -0
- package/src/simulation/rules/pitLaneSpeedingSteward.js +71 -0
- package/src/simulation/rules/tireRequirementSteward.js +29 -0
- package/src/simulation/rules/trackLimitsSteward.js +85 -0
- package/src/simulation/rulesConfig.js +352 -0
- package/src/simulation/trackModel.js +1154 -76
- package/src/simulation/units.js +6 -4
- package/src/simulation/vehicleGeometry.js +247 -0
- package/src/simulation/vehiclePhysics.js +82 -50
- package/src/simulation/wheelSurface.js +338 -0
- package/src/styles.css +369 -67
- package/src/ui/bannerTemplates.js +69 -0
- package/src/ui/componentTemplates.js +45 -81
- package/src/ui/raceControlStatusBanner.js +39 -0
- package/src/ui/shellTemplate.js +1 -1
- package/src/ui/templateUtils.js +23 -0
- package/AGENTS.md +0 -86
- package/src/standalone.js +0 -15
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
import { Container, Graphics, Texture, TilingSprite } from 'pixi.js';
|
|
2
2
|
import { nearestTrackState, offsetTrackPoint, pointAt, WORLD } from '../simulation/trackModel.js';
|
|
3
|
+
import { metersToSimUnits } from '../simulation/units.js';
|
|
3
4
|
|
|
4
5
|
const MATERIAL_TILE_SCALE = {
|
|
5
6
|
asphalt: { x: 0.66, y: 0.66 },
|
|
6
7
|
};
|
|
7
|
-
const
|
|
8
|
+
const WORLD_BACKGROUND_PADDING_MULTIPLIER = 8;
|
|
8
9
|
const GRASS_COLOR = 0x2e7d32;
|
|
9
10
|
const GRAVEL_COLOR = 0xb49a68;
|
|
10
11
|
const ASPHALT_COLOR = 0x4a4d52;
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
12
|
+
const PIT_ASPHALT_COLOR = ASPHALT_COLOR;
|
|
13
|
+
const PIT_BOX_COLOR = 0x242831;
|
|
14
|
+
const PIT_LINE_COLOR = 0xf8fafc;
|
|
15
|
+
const PIT_SPEED_LINE_COLOR = 0xffd166;
|
|
16
|
+
const PIT_CONNECTOR_WIDTH = metersToSimUnits(12);
|
|
17
|
+
const PIT_EDGE_WIDTH = metersToSimUnits(0.35);
|
|
18
|
+
const EDGE_REVEAL_OFFSET = metersToSimUnits(0.35);
|
|
19
|
+
const EDGE_REVEAL_WIDTH = metersToSimUnits(0.5);
|
|
20
|
+
const OUTER_BOUNDARY_OFFSET = metersToSimUnits(1.1);
|
|
21
|
+
const OUTER_BOUNDARY_WIDTH = metersToSimUnits(0.75);
|
|
22
|
+
const KERB_OFFSET = metersToSimUnits(0.45);
|
|
23
|
+
const KERB_WIDTH = metersToSimUnits(1.25);
|
|
24
|
+
const FINISH_LINE_DEPTH = metersToSimUnits(8);
|
|
18
25
|
const FINISH_LINE_COLUMNS = 10;
|
|
19
26
|
const START_GRID_SLOT_COUNT = 20;
|
|
20
|
-
const START_GRID_SLOT_SPACING =
|
|
21
|
-
const START_GRID_FIRST_DISTANCE = -
|
|
22
|
-
const START_GRID_LATERAL_OFFSET =
|
|
23
|
-
const START_GRID_BOX_LENGTH =
|
|
24
|
-
const START_GRID_BOX_WIDTH =
|
|
27
|
+
const START_GRID_SLOT_SPACING = metersToSimUnits(8);
|
|
28
|
+
const START_GRID_FIRST_DISTANCE = metersToSimUnits(-6);
|
|
29
|
+
const START_GRID_LATERAL_OFFSET = metersToSimUnits(3.2);
|
|
30
|
+
const START_GRID_BOX_LENGTH = metersToSimUnits(7);
|
|
31
|
+
const START_GRID_BOX_WIDTH = metersToSimUnits(3.2);
|
|
25
32
|
const SEGMENTED_STROKE_STEP = 2;
|
|
26
33
|
const KERB_STEP = 4;
|
|
27
34
|
const KERB_CURVATURE_THRESHOLD = 0.00038;
|
|
@@ -29,6 +36,14 @@ const OFFSET_SEGMENT_SAMPLE_COUNT = 7;
|
|
|
29
36
|
const NON_LOCAL_SAMPLE_STEP = 8;
|
|
30
37
|
const OFFSET_GAP_SAMPLE_COUNT = 4;
|
|
31
38
|
|
|
39
|
+
function colorToNumber(color, fallback = PIT_LINE_COLOR) {
|
|
40
|
+
if (typeof color === 'number' && Number.isFinite(color)) return color;
|
|
41
|
+
if (typeof color !== 'string') return fallback;
|
|
42
|
+
const normalized = color.trim().replace(/^#/, '');
|
|
43
|
+
if (!/^[0-9a-f]{6}$/i.test(normalized)) return fallback;
|
|
44
|
+
return Number.parseInt(normalized, 16);
|
|
45
|
+
}
|
|
46
|
+
|
|
32
47
|
function makeTrackPath(track, offset = 0) {
|
|
33
48
|
const path = new Graphics();
|
|
34
49
|
const samples = track.samples.slice(0, -1);
|
|
@@ -48,6 +63,12 @@ function textureOrWhite(texture) {
|
|
|
48
63
|
return texture ?? Texture.WHITE;
|
|
49
64
|
}
|
|
50
65
|
|
|
66
|
+
function destroyDisplayChildren(container) {
|
|
67
|
+
container.removeChildren().forEach((child) => {
|
|
68
|
+
child.destroy?.({ children: true, texture: false, textureSource: false });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
function arcDistance(track, first, second) {
|
|
52
73
|
const delta = Math.abs(first - second);
|
|
53
74
|
return Math.min(delta, track.length - delta);
|
|
@@ -55,8 +76,8 @@ function arcDistance(track, first, second) {
|
|
|
55
76
|
|
|
56
77
|
function offsetPointIsLocal(track, source, point, offset) {
|
|
57
78
|
const state = nearestTrackState(track, point);
|
|
58
|
-
const localTolerance = Math.max(
|
|
59
|
-
const minimumEdgeDistance = Math.min(track.width / 2 -
|
|
79
|
+
const localTolerance = Math.max(metersToSimUnits(105), Math.abs(offset) * 2.1);
|
|
80
|
+
const minimumEdgeDistance = Math.min(track.width / 2 - metersToSimUnits(4), Math.abs(offset) * 0.72);
|
|
60
81
|
|
|
61
82
|
return (
|
|
62
83
|
arcDistance(track, state.distance, source.distance) <= localTolerance &&
|
|
@@ -65,9 +86,9 @@ function offsetPointIsLocal(track, source, point, offset) {
|
|
|
65
86
|
}
|
|
66
87
|
|
|
67
88
|
function offsetPointOverlapsNonLocalRoad(track, source, point, offset) {
|
|
68
|
-
const localTolerance = Math.max(
|
|
69
|
-
const roadBand = track.width / 2 + (track.kerbWidth ?? 0) +
|
|
70
|
-
const roadBandSquared = (roadBand +
|
|
89
|
+
const localTolerance = Math.max(metersToSimUnits(105), Math.abs(offset) * 2.1);
|
|
90
|
+
const roadBand = track.width / 2 + (track.kerbWidth ?? 0) + metersToSimUnits(4);
|
|
91
|
+
const roadBandSquared = (roadBand + metersToSimUnits(20)) ** 2;
|
|
71
92
|
|
|
72
93
|
for (let index = 0; index < track.samples.length - 1; index += NON_LOCAL_SAMPLE_STEP) {
|
|
73
94
|
const sample = track.samples[index];
|
|
@@ -95,6 +116,135 @@ function pointDistance(first, second) {
|
|
|
95
116
|
return Math.hypot(second.x - first.x, second.y - first.y);
|
|
96
117
|
}
|
|
97
118
|
|
|
119
|
+
function drawPolyline(graphics, points, {
|
|
120
|
+
width,
|
|
121
|
+
color,
|
|
122
|
+
alpha = 1,
|
|
123
|
+
cap = 'round',
|
|
124
|
+
join = 'round',
|
|
125
|
+
}) {
|
|
126
|
+
if (!Array.isArray(points) || points.length < 2) return;
|
|
127
|
+
graphics.moveTo(points[0].x, points[0].y);
|
|
128
|
+
points.slice(1).forEach((point) => {
|
|
129
|
+
graphics.lineTo(point.x, point.y);
|
|
130
|
+
});
|
|
131
|
+
graphics.stroke({
|
|
132
|
+
width,
|
|
133
|
+
color,
|
|
134
|
+
alpha,
|
|
135
|
+
cap,
|
|
136
|
+
join,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function offsetVectorPoint(point, vector, amount) {
|
|
141
|
+
return {
|
|
142
|
+
x: point.x + vector.x * amount,
|
|
143
|
+
y: point.y + vector.y * amount,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function drawMainPitLaneOffsetLine(graphics, pitLane, offset, {
|
|
148
|
+
width,
|
|
149
|
+
color,
|
|
150
|
+
alpha = 1,
|
|
151
|
+
}) {
|
|
152
|
+
const start = offsetVectorPoint(pitLane.mainLane.start, pitLane.serviceNormal, offset);
|
|
153
|
+
const end = offsetVectorPoint(pitLane.mainLane.end, pitLane.serviceNormal, offset);
|
|
154
|
+
drawPolyline(graphics, [start, end], {
|
|
155
|
+
width,
|
|
156
|
+
color,
|
|
157
|
+
alpha,
|
|
158
|
+
cap: 'butt',
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function drawPitRoad(graphics, points, {
|
|
163
|
+
roadWidth,
|
|
164
|
+
asphaltColor = ASPHALT_COLOR,
|
|
165
|
+
edgeAlpha = 0.54,
|
|
166
|
+
cap = 'butt',
|
|
167
|
+
}) {
|
|
168
|
+
drawPolyline(graphics, points, {
|
|
169
|
+
width: roadWidth + 8,
|
|
170
|
+
color: 0x080a0f,
|
|
171
|
+
alpha: edgeAlpha,
|
|
172
|
+
cap,
|
|
173
|
+
});
|
|
174
|
+
drawPolyline(graphics, points, {
|
|
175
|
+
width: roadWidth,
|
|
176
|
+
color: asphaltColor,
|
|
177
|
+
alpha: 1,
|
|
178
|
+
cap,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function drawPitRunoff(graphics, points, roadWidth) {
|
|
183
|
+
drawPolyline(graphics, points, {
|
|
184
|
+
width: roadWidth + 92,
|
|
185
|
+
color: GRAVEL_COLOR,
|
|
186
|
+
alpha: 1,
|
|
187
|
+
cap: 'butt',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function getPitLaneRoadCenterlines(pitLane) {
|
|
192
|
+
return {
|
|
193
|
+
entryRoad: pitLane.entry.roadCenterline ?? [pitLane.entry.edgePoint, pitLane.mainLane.start],
|
|
194
|
+
exitRoad: pitLane.exit.roadCenterline ?? [pitLane.mainLane.end, pitLane.exit.edgePoint],
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function drawCurvedPitRoadEdges(graphics, points, roadWidth) {
|
|
199
|
+
if (!Array.isArray(points) || points.length < 2) return;
|
|
200
|
+
[-1, 1].forEach((side) => {
|
|
201
|
+
const edgePoints = points.map((point, index) => {
|
|
202
|
+
const before = points[Math.max(0, index - 1)];
|
|
203
|
+
const after = points[Math.min(points.length - 1, index + 1)];
|
|
204
|
+
const dx = after.x - before.x;
|
|
205
|
+
const dy = after.y - before.y;
|
|
206
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
207
|
+
return {
|
|
208
|
+
x: point.x + (-dy / len) * side * roadWidth / 2,
|
|
209
|
+
y: point.y + (dx / len) * side * roadWidth / 2,
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
drawPolyline(graphics, edgePoints, {
|
|
213
|
+
width: 2,
|
|
214
|
+
color: PIT_LINE_COLOR,
|
|
215
|
+
alpha: 0.7,
|
|
216
|
+
cap: 'butt',
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function getPitRoadEdgePoints(track, points) {
|
|
222
|
+
if (!Array.isArray(points) || points.length < 2) return points;
|
|
223
|
+
const trackEdge = track.width / 2;
|
|
224
|
+
const firstVisible = points.findIndex((point) => nearestTrackState(track, point).crossTrackError >= trackEdge - 4);
|
|
225
|
+
const lastVisibleReverse = [...points]
|
|
226
|
+
.reverse()
|
|
227
|
+
.findIndex((point) => nearestTrackState(track, point).crossTrackError >= trackEdge - 4);
|
|
228
|
+
|
|
229
|
+
if (firstVisible < 0 || lastVisibleReverse < 0) return points;
|
|
230
|
+
const lastVisible = points.length - 1 - lastVisibleReverse;
|
|
231
|
+
return points.slice(firstVisible, lastVisible + 1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function createTeamPitGroupCorners(boxes) {
|
|
235
|
+
const ordered = [...boxes].sort((left, right) => left.index - right.index);
|
|
236
|
+
const first = ordered[0];
|
|
237
|
+
const last = ordered.at(-1);
|
|
238
|
+
if (!first || !last) return [];
|
|
239
|
+
|
|
240
|
+
return [
|
|
241
|
+
first.corners[0],
|
|
242
|
+
last.corners[1],
|
|
243
|
+
last.corners[2],
|
|
244
|
+
first.corners[3],
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
98
248
|
export function offsetSegmentIsSafe(track, current, next, start, end, offset) {
|
|
99
249
|
const centerDistance = Math.hypot(next.x - current.x, next.y - current.y);
|
|
100
250
|
const edgeDistance = Math.hypot(end.x - start.x, end.y - start.y);
|
|
@@ -123,7 +273,7 @@ export function offsetGapBridgeIsSafe(track, start, end, width) {
|
|
|
123
273
|
const maximumBridgeDistance = Math.max(track.width * 0.08, width * 3.4);
|
|
124
274
|
if (bridgeDistance > maximumBridgeDistance) return false;
|
|
125
275
|
|
|
126
|
-
const minimumEdgeDistance = track.width / 2 - Math.max(width * 2.5,
|
|
276
|
+
const minimumEdgeDistance = track.width / 2 - Math.max(width * 2.5, metersToSimUnits(4.75));
|
|
127
277
|
for (let sample = 1; sample < OFFSET_GAP_SAMPLE_COUNT; sample += 1) {
|
|
128
278
|
const amount = sample / OFFSET_GAP_SAMPLE_COUNT;
|
|
129
279
|
const point = interpolatedSegmentPoint(start, end, amount);
|
|
@@ -269,10 +419,12 @@ export class ProceduralTrackAsset {
|
|
|
269
419
|
}
|
|
270
420
|
|
|
271
421
|
render(track) {
|
|
272
|
-
this.container
|
|
422
|
+
destroyDisplayChildren(this.container);
|
|
273
423
|
this.addGrass();
|
|
274
424
|
this.addGravelRunoff(track);
|
|
275
425
|
this.addBoundaryUnderlay(track);
|
|
426
|
+
this.addPitLaneRunoff(track);
|
|
427
|
+
this.addPitLane(track);
|
|
276
428
|
this.addAsphalt(track);
|
|
277
429
|
this.addKerbs(track);
|
|
278
430
|
this.addBorders(track);
|
|
@@ -282,11 +434,20 @@ export class ProceduralTrackAsset {
|
|
|
282
434
|
|
|
283
435
|
addGrass() {
|
|
284
436
|
const grass = new Graphics();
|
|
437
|
+
const padding = Math.max(this.world.width, this.world.height) * WORLD_BACKGROUND_PADDING_MULTIPLIER;
|
|
438
|
+
const bounds = {
|
|
439
|
+
x: -padding,
|
|
440
|
+
y: -padding,
|
|
441
|
+
width: this.world.width + padding * 2,
|
|
442
|
+
height: this.world.height + padding * 2,
|
|
443
|
+
};
|
|
444
|
+
grass.label = 'world-grass';
|
|
445
|
+
grass.worldGrassBounds = bounds;
|
|
285
446
|
grass.rect(
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
447
|
+
bounds.x,
|
|
448
|
+
bounds.y,
|
|
449
|
+
bounds.width,
|
|
450
|
+
bounds.height,
|
|
290
451
|
).fill(GRASS_COLOR);
|
|
291
452
|
this.container.addChild(grass);
|
|
292
453
|
}
|
|
@@ -391,6 +552,7 @@ export class ProceduralTrackAsset {
|
|
|
391
552
|
|
|
392
553
|
addAsphalt(track) {
|
|
393
554
|
const asphaltBase = makeTrackPath(track);
|
|
555
|
+
asphaltBase.label = 'track-asphalt';
|
|
394
556
|
asphaltBase.stroke({
|
|
395
557
|
width: track.width,
|
|
396
558
|
color: ASPHALT_COLOR,
|
|
@@ -410,8 +572,164 @@ export class ProceduralTrackAsset {
|
|
|
410
572
|
|
|
411
573
|
}
|
|
412
574
|
|
|
575
|
+
addPitLaneRunoff(track) {
|
|
576
|
+
if (!track.pitLane?.enabled) return;
|
|
577
|
+
const pitLane = track.pitLane;
|
|
578
|
+
const pitRunoff = new Graphics();
|
|
579
|
+
pitRunoff.label = 'pit-lane-runoff';
|
|
580
|
+
|
|
581
|
+
// Only the main pit lane gets a gravel shoulder; connectors run through the track's
|
|
582
|
+
// own gravel/runoff zone so they do not need a separate runoff stroke (which would
|
|
583
|
+
// bleed an undesirable gravel band into the track asphalt at the entry/exit endpoints).
|
|
584
|
+
const workingLaneExtent = pitLane.workingLane
|
|
585
|
+
? pitLane.workingLane.offset + pitLane.workingLane.width / 2
|
|
586
|
+
: pitLane.width / 2;
|
|
587
|
+
drawPitRunoff(pitRunoff, pitLane.mainLane.points, Math.max(pitLane.width, workingLaneExtent * 2));
|
|
588
|
+
|
|
589
|
+
this.container.addChild(pitRunoff);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
addPitLane(track) {
|
|
593
|
+
if (!track.pitLane?.enabled) return;
|
|
594
|
+
const pitLane = track.pitLane;
|
|
595
|
+
const pit = new Graphics();
|
|
596
|
+
pit.label = 'pit-lane';
|
|
597
|
+
const { entryRoad, exitRoad } = getPitLaneRoadCenterlines(pitLane);
|
|
598
|
+
|
|
599
|
+
drawPitRoad(pit, entryRoad, {
|
|
600
|
+
roadWidth: PIT_CONNECTOR_WIDTH,
|
|
601
|
+
asphaltColor: ASPHALT_COLOR,
|
|
602
|
+
edgeAlpha: 0.18,
|
|
603
|
+
cap: 'round',
|
|
604
|
+
});
|
|
605
|
+
drawPitRoad(pit, exitRoad, {
|
|
606
|
+
roadWidth: PIT_CONNECTOR_WIDTH,
|
|
607
|
+
asphaltColor: ASPHALT_COLOR,
|
|
608
|
+
edgeAlpha: 0.18,
|
|
609
|
+
cap: 'round',
|
|
610
|
+
});
|
|
611
|
+
drawPitRoad(pit, pitLane.mainLane.points, {
|
|
612
|
+
roadWidth: pitLane.width,
|
|
613
|
+
asphaltColor: PIT_ASPHALT_COLOR,
|
|
614
|
+
edgeAlpha: 0.62,
|
|
615
|
+
});
|
|
616
|
+
if (pitLane.workingLane?.points?.length >= 2) {
|
|
617
|
+
drawPitRoad(pit, pitLane.workingLane.points, {
|
|
618
|
+
roadWidth: pitLane.workingLane.width,
|
|
619
|
+
asphaltColor: PIT_ASPHALT_COLOR,
|
|
620
|
+
edgeAlpha: 0.52,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
drawMainPitLaneOffsetLine(pit, pitLane, -pitLane.width / 2, {
|
|
625
|
+
width: 3,
|
|
626
|
+
color: PIT_SPEED_LINE_COLOR,
|
|
627
|
+
alpha: 0.9,
|
|
628
|
+
});
|
|
629
|
+
drawMainPitLaneOffsetLine(pit, pitLane, pitLane.width / 2, {
|
|
630
|
+
width: 3,
|
|
631
|
+
color: PIT_LINE_COLOR,
|
|
632
|
+
alpha: 0.84,
|
|
633
|
+
});
|
|
634
|
+
if (pitLane.workingLane) {
|
|
635
|
+
drawMainPitLaneOffsetLine(pit, pitLane, pitLane.workingLane.offset - pitLane.workingLane.width / 2, {
|
|
636
|
+
width: 2,
|
|
637
|
+
color: PIT_LINE_COLOR,
|
|
638
|
+
alpha: 0.48,
|
|
639
|
+
});
|
|
640
|
+
drawMainPitLaneOffsetLine(pit, pitLane, pitLane.workingLane.offset + pitLane.workingLane.width / 2, {
|
|
641
|
+
width: 3,
|
|
642
|
+
color: PIT_LINE_COLOR,
|
|
643
|
+
alpha: 0.78,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const heading = pitLane.mainLane.heading;
|
|
648
|
+
const laneNormal = { x: -Math.sin(heading), y: Math.cos(heading) };
|
|
649
|
+
[pitLane.mainLane.start, pitLane.mainLane.end].forEach((point) => {
|
|
650
|
+
const a = offsetVectorPoint(point, laneNormal, -pitLane.width / 2);
|
|
651
|
+
const b = offsetVectorPoint(point, laneNormal, pitLane.width / 2);
|
|
652
|
+
drawPolyline(pit, [a, b], {
|
|
653
|
+
width: 4,
|
|
654
|
+
color: PIT_SPEED_LINE_COLOR,
|
|
655
|
+
alpha: 0.95,
|
|
656
|
+
cap: 'butt',
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
[entryRoad, exitRoad].forEach((road) => {
|
|
661
|
+
drawCurvedPitRoadEdges(pit, getPitRoadEdgePoints(track, road), PIT_CONNECTOR_WIDTH);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
(pitLane.teams ?? []).forEach((team) => {
|
|
665
|
+
const boxes = pitLane.boxes.filter((box) => team.boxIds?.includes(box.id));
|
|
666
|
+
const corners = createTeamPitGroupCorners(boxes);
|
|
667
|
+
if (corners.length < 4) return;
|
|
668
|
+
const color = colorToNumber(team.color);
|
|
669
|
+
pit.poly(corners.flatMap((corner) => [corner.x, corner.y])).fill({
|
|
670
|
+
color,
|
|
671
|
+
alpha: 0.24,
|
|
672
|
+
});
|
|
673
|
+
pit.poly(corners.flatMap((corner) => [corner.x, corner.y])).stroke({
|
|
674
|
+
width: 3,
|
|
675
|
+
color,
|
|
676
|
+
alpha: 0.72,
|
|
677
|
+
join: 'round',
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
(pitLane.serviceAreas ?? []).forEach((serviceArea) => {
|
|
682
|
+
const teamColor = colorToNumber(serviceArea.teamColor, PIT_SPEED_LINE_COLOR);
|
|
683
|
+
if (serviceArea.queueCorners?.length >= 4) {
|
|
684
|
+
pit.poly(serviceArea.queueCorners.flatMap((corner) => [corner.x, corner.y])).fill({
|
|
685
|
+
color: teamColor,
|
|
686
|
+
alpha: 0.13,
|
|
687
|
+
});
|
|
688
|
+
pit.poly(serviceArea.queueCorners.flatMap((corner) => [corner.x, corner.y])).stroke({
|
|
689
|
+
width: 2,
|
|
690
|
+
color: teamColor,
|
|
691
|
+
alpha: 0.44,
|
|
692
|
+
join: 'round',
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
pit.poly(serviceArea.corners.flatMap((corner) => [corner.x, corner.y])).fill({
|
|
696
|
+
color: teamColor,
|
|
697
|
+
alpha: 0.2,
|
|
698
|
+
});
|
|
699
|
+
pit.poly(serviceArea.corners.flatMap((corner) => [corner.x, corner.y])).stroke({
|
|
700
|
+
width: 3,
|
|
701
|
+
color: teamColor,
|
|
702
|
+
alpha: 0.92,
|
|
703
|
+
join: 'round',
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
pitLane.boxes.forEach((box) => {
|
|
708
|
+
const teamColor = colorToNumber(box.teamColor, box.teamBoxIndex === 0 ? PIT_LINE_COLOR : PIT_SPEED_LINE_COLOR);
|
|
709
|
+
pit.poly(box.corners.flatMap((corner) => [corner.x, corner.y])).fill({
|
|
710
|
+
color: PIT_BOX_COLOR,
|
|
711
|
+
alpha: 0.96,
|
|
712
|
+
});
|
|
713
|
+
pit.poly(box.corners.flatMap((corner) => [corner.x, corner.y])).stroke({
|
|
714
|
+
width: 2,
|
|
715
|
+
color: teamColor,
|
|
716
|
+
alpha: 0.9,
|
|
717
|
+
join: 'round',
|
|
718
|
+
});
|
|
719
|
+
drawPolyline(pit, [box.laneTarget, box.center], {
|
|
720
|
+
width: 2,
|
|
721
|
+
color: teamColor,
|
|
722
|
+
alpha: 0.28,
|
|
723
|
+
cap: 'butt',
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
this.container.addChild(pit);
|
|
728
|
+
}
|
|
729
|
+
|
|
413
730
|
addBorders(track) {
|
|
414
731
|
const borders = new Graphics();
|
|
732
|
+
borders.label = 'track-borders';
|
|
415
733
|
|
|
416
734
|
[-1, 1].forEach((side) => {
|
|
417
735
|
drawSegmentedOffsetStroke(borders, track, {
|
|
@@ -438,6 +756,7 @@ export class ProceduralTrackAsset {
|
|
|
438
756
|
|
|
439
757
|
addKerbs(track) {
|
|
440
758
|
const kerbs = new Graphics();
|
|
759
|
+
kerbs.label = 'track-kerbs';
|
|
441
760
|
this.drawKerbStripes(kerbs, track, { clipUnsafeSegments: true });
|
|
442
761
|
this.container.addChild(kerbs);
|
|
443
762
|
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createVehicleGeometry,
|
|
3
|
+
createVehicleGeometryState,
|
|
4
|
+
getVehicleGeometryState,
|
|
5
|
+
interpolateVehiclePose,
|
|
6
|
+
} from './vehicleGeometry.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_SWEEP_STEPS = 16;
|
|
9
|
+
const BROADPHASE_PADDING = 0.001;
|
|
10
|
+
const DEFAULT_DISTANCE_WINDOW = 150;
|
|
11
|
+
|
|
12
|
+
function dot(a, b) {
|
|
13
|
+
return a.x * b.x + a.y * b.y;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function projectShape(shape, axis) {
|
|
17
|
+
let min = Infinity;
|
|
18
|
+
let max = -Infinity;
|
|
19
|
+
for (let index = 0; index < shape.corners.length; index += 1) {
|
|
20
|
+
const value = dot(shape.corners[index], axis);
|
|
21
|
+
if (value < min) min = value;
|
|
22
|
+
if (value > max) max = value;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
min,
|
|
26
|
+
max,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function overlapOnAxis(first, second, axis) {
|
|
31
|
+
const a = projectShape(first, axis);
|
|
32
|
+
const b = projectShape(second, axis);
|
|
33
|
+
return Math.min(a.max, b.max) - Math.max(a.min, b.min);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function shapeContactType(firstShape, secondShape) {
|
|
37
|
+
return `${firstShape.type}-${secondShape.type}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function detectShapeCollision(firstShape, secondShape) {
|
|
41
|
+
const axes = [
|
|
42
|
+
firstShape.forward,
|
|
43
|
+
firstShape.right,
|
|
44
|
+
secondShape.forward,
|
|
45
|
+
secondShape.right,
|
|
46
|
+
];
|
|
47
|
+
let depth = Infinity;
|
|
48
|
+
let axis = null;
|
|
49
|
+
|
|
50
|
+
for (const candidate of axes) {
|
|
51
|
+
const overlap = overlapOnAxis(firstShape, secondShape, candidate);
|
|
52
|
+
if (overlap <= 0) return null;
|
|
53
|
+
if (overlap < depth) {
|
|
54
|
+
depth = overlap;
|
|
55
|
+
axis = candidate;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const direction = {
|
|
60
|
+
x: secondShape.center.x - firstShape.center.x,
|
|
61
|
+
y: secondShape.center.y - firstShape.center.y,
|
|
62
|
+
};
|
|
63
|
+
if (dot(direction, axis) < 0) {
|
|
64
|
+
axis = { x: -axis.x, y: -axis.y };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
axis,
|
|
69
|
+
depth,
|
|
70
|
+
firstShapeId: firstShape.id,
|
|
71
|
+
secondShapeId: secondShape.id,
|
|
72
|
+
contactType: shapeContactType(firstShape, secondShape),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collisionShapes(geometry) {
|
|
77
|
+
return [geometry.body];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function detectGeometryCollision(firstGeometry, secondGeometry) {
|
|
81
|
+
let best = null;
|
|
82
|
+
collisionShapes(firstGeometry).forEach((firstShape) => {
|
|
83
|
+
collisionShapes(secondGeometry).forEach((secondShape) => {
|
|
84
|
+
const collision = detectShapeCollision(firstShape, secondShape);
|
|
85
|
+
if (!collision) return;
|
|
86
|
+
if (!best || collision.depth < best.depth - 1e-6) {
|
|
87
|
+
best = collision;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
return best;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function aabbsOverlap(first, second) {
|
|
95
|
+
return (
|
|
96
|
+
first.minX - BROADPHASE_PADDING <= second.maxX &&
|
|
97
|
+
first.maxX + BROADPHASE_PADDING >= second.minX &&
|
|
98
|
+
first.minY - BROADPHASE_PADDING <= second.maxY &&
|
|
99
|
+
first.maxY + BROADPHASE_PADDING >= second.minY
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createSweptAabb(car) {
|
|
104
|
+
return getVehicleGeometryState(car).sweptBodyAabb;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function withCollisionMetadata(collision, timeOfImpact, swept) {
|
|
108
|
+
return {
|
|
109
|
+
...collision,
|
|
110
|
+
timeOfImpact,
|
|
111
|
+
swept: Boolean(swept),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function detectVehicleCollision(first, second, { sweepSteps = DEFAULT_SWEEP_STEPS } = {}) {
|
|
116
|
+
const firstState = getVehicleGeometryState(first);
|
|
117
|
+
const secondState = getVehicleGeometryState(second);
|
|
118
|
+
const current = detectGeometryCollision(firstState.current, secondState.current);
|
|
119
|
+
if (current) return withCollisionMetadata(current, 1, false);
|
|
120
|
+
|
|
121
|
+
if (!aabbsOverlap(createSweptAabb(first), createSweptAabb(second))) return null;
|
|
122
|
+
|
|
123
|
+
for (let step = 1; step < sweepSteps; step += 1) {
|
|
124
|
+
const amount = step / sweepSteps;
|
|
125
|
+
const collision = detectGeometryCollision(
|
|
126
|
+
createVehicleGeometry(interpolateVehiclePose(first, amount)),
|
|
127
|
+
createVehicleGeometry(interpolateVehiclePose(second, amount)),
|
|
128
|
+
);
|
|
129
|
+
if (collision) return withCollisionMetadata(collision, amount, true);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizedDistance(car, trackLength) {
|
|
136
|
+
if (!Number.isFinite(trackLength) || trackLength <= 0) return car.raceDistance;
|
|
137
|
+
const distance = Number.isFinite(car.progress) ? car.progress : car.raceDistance;
|
|
138
|
+
if (!Number.isFinite(distance)) return null;
|
|
139
|
+
return ((distance % trackLength) + trackLength) % trackLength;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function wrappedDistanceDelta(first, second, trackLength) {
|
|
143
|
+
const delta = Math.abs(first - second);
|
|
144
|
+
return Math.min(delta, trackLength - delta);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function buildCollisionCandidatePairs(cars, {
|
|
148
|
+
trackLength = null,
|
|
149
|
+
distanceWindow = DEFAULT_DISTANCE_WINDOW,
|
|
150
|
+
} = {}) {
|
|
151
|
+
const candidates = [];
|
|
152
|
+
const candidateKeys = new Set();
|
|
153
|
+
const withDistance = [];
|
|
154
|
+
const withoutDistance = [];
|
|
155
|
+
const carOrder = new Map(cars.map((car, index) => [car, index]));
|
|
156
|
+
|
|
157
|
+
const addCandidate = (first, second) => {
|
|
158
|
+
const firstIndex = carOrder.get(first);
|
|
159
|
+
const secondIndex = carOrder.get(second);
|
|
160
|
+
const key = firstIndex < secondIndex ? `${firstIndex}:${secondIndex}` : `${secondIndex}:${firstIndex}`;
|
|
161
|
+
if (candidateKeys.has(key)) return;
|
|
162
|
+
candidateKeys.add(key);
|
|
163
|
+
candidates.push([first, second]);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
cars.forEach((car) => {
|
|
167
|
+
const distance = normalizedDistance(car, trackLength);
|
|
168
|
+
if (Number.isFinite(distance)) withDistance.push({ car, distance });
|
|
169
|
+
else withoutDistance.push(car);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
withDistance.sort((first, second) => first.distance - second.distance);
|
|
173
|
+
for (let i = 0; i < withDistance.length; i += 1) {
|
|
174
|
+
for (let j = i + 1; j < withDistance.length; j += 1) {
|
|
175
|
+
const delta = withDistance[j].distance - withDistance[i].distance;
|
|
176
|
+
if (delta > distanceWindow) break;
|
|
177
|
+
addCandidate(withDistance[i].car, withDistance[j].car);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (Number.isFinite(trackLength) && trackLength > 0) {
|
|
182
|
+
for (let i = 0; i < withDistance.length; i += 1) {
|
|
183
|
+
const threshold = withDistance[i].distance + trackLength - distanceWindow;
|
|
184
|
+
const firstWrappedIndex = lowerBoundDistance(withDistance, threshold);
|
|
185
|
+
for (let j = Math.max(firstWrappedIndex, i + 1); j < withDistance.length; j += 1) {
|
|
186
|
+
if (wrappedDistanceDelta(withDistance[i].distance, withDistance[j].distance, trackLength) <= distanceWindow) {
|
|
187
|
+
addCandidate(withDistance[i].car, withDistance[j].car);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const missingDistance = new Set(withoutDistance);
|
|
194
|
+
for (let i = 0; i < cars.length; i += 1) {
|
|
195
|
+
for (let j = i + 1; j < cars.length; j += 1) {
|
|
196
|
+
if (missingDistance.has(cars[i]) || missingDistance.has(cars[j])) {
|
|
197
|
+
addCandidate(cars[i], cars[j]);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return candidates;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function lowerBoundDistance(entries, target) {
|
|
206
|
+
let low = 0;
|
|
207
|
+
let high = entries.length;
|
|
208
|
+
while (low < high) {
|
|
209
|
+
const middle = Math.floor((low + high) / 2);
|
|
210
|
+
if (entries[middle].distance < target) low = middle + 1;
|
|
211
|
+
else high = middle;
|
|
212
|
+
}
|
|
213
|
+
return low;
|
|
214
|
+
}
|