@inventure71/paddockjs 0.3.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 +39 -0
- package/INSTALL_AND_UPDATE.md +3 -1
- package/README.md +142 -9
- package/docs/architecture.md +49 -15
- package/docs/concepts.md +20 -3
- package/docs/data_contract.md +185 -27
- package/docs/data_model.md +1 -1
- package/docs/learnings.md +1 -1
- package/docs/rules.md +140 -19
- package/docs/system_specs.md +39 -16
- package/docs/training.md +103 -1
- package/examples/train-basic-policy.mjs +5 -6
- package/examples/trainingData.mjs +133 -0
- package/package.json +5 -2
- package/src/api/PaddockSimulatorController.js +32 -2
- package/src/app/F1SimulatorApp.js +1101 -128
- package/src/app/domBindings.js +10 -1
- package/src/config/defaultOptions.js +4 -4
- package/src/environment/actions.js +25 -2
- package/src/environment/evaluation.js +184 -0
- package/src/environment/index.d.ts +281 -1
- package/src/environment/index.js +13 -0
- package/src/environment/observationOptions.js +12 -0
- package/src/environment/observations.js +189 -17
- package/src/environment/options.js +7 -0
- package/src/environment/recorder.js +28 -0
- package/src/environment/runtime.js +41 -6
- package/src/environment/scenarios.js +220 -0
- package/src/environment/sensors.js +145 -35
- package/src/environment/specs.js +92 -8
- package/src/environment/workerProtocol.js +52 -0
- package/src/index.d.ts +366 -3
- package/src/index.js +21 -0
- package/src/rendering/proceduralTrackAsset.js +336 -23
- package/src/simulation/collisionGeometry.js +214 -0
- package/src/simulation/driverController.js +431 -65
- package/src/simulation/raceSimulation.js +2132 -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 +300 -42
- package/src/ui/bannerTemplates.js +69 -0
- package/src/ui/componentTemplates.js +36 -96
- package/src/ui/raceControlStatusBanner.js +39 -0
- package/src/ui/shellTemplate.js +1 -1
- package/src/ui/templateUtils.js +23 -0
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import { clamp, createMulberry32, normalizeAngle, seededRange,
|
|
1
|
+
import { clamp, createMulberry32, normalizeAngle, seededRange, wrapDistance } from './simMath.js';
|
|
2
|
+
import { metersToSimUnits } from './units.js';
|
|
2
3
|
|
|
3
4
|
export const WORLD = {
|
|
4
|
-
width:
|
|
5
|
-
height:
|
|
5
|
+
width: metersToSimUnits(3550),
|
|
6
|
+
height: metersToSimUnits(2148),
|
|
6
7
|
};
|
|
7
8
|
|
|
8
9
|
export const TRACK = {
|
|
9
10
|
name: 'Apex Harbor GP',
|
|
10
|
-
width:
|
|
11
|
-
kerbWidth:
|
|
12
|
-
gravelWidth:
|
|
13
|
-
runoffWidth:
|
|
11
|
+
width: metersToSimUnits(15),
|
|
12
|
+
kerbWidth: metersToSimUnits(1.5),
|
|
13
|
+
gravelWidth: metersToSimUnits(12),
|
|
14
|
+
runoffWidth: metersToSimUnits(20),
|
|
14
15
|
sampleCount: 3600,
|
|
15
16
|
drsZones: [
|
|
16
17
|
{ id: 'main-straight', startRatio: 0.02, endRatio: 0.18 },
|
|
@@ -19,16 +20,52 @@ export const TRACK = {
|
|
|
19
20
|
],
|
|
20
21
|
};
|
|
21
22
|
|
|
22
|
-
const GENERATED_TRACK_MIN_LENGTH =
|
|
23
|
-
const GENERATED_TRACK_MAX_LENGTH =
|
|
24
|
-
const GENERATED_TRACK_ATTEMPTS =
|
|
25
|
-
const
|
|
26
|
-
const TRACK_BOUNDARY_PADDING =
|
|
23
|
+
const GENERATED_TRACK_MIN_LENGTH = metersToSimUnits(3600);
|
|
24
|
+
const GENERATED_TRACK_MAX_LENGTH = metersToSimUnits(9000);
|
|
25
|
+
const GENERATED_TRACK_ATTEMPTS = 24;
|
|
26
|
+
const GENERATED_FALLBACK_ATTEMPTS = 8;
|
|
27
|
+
const TRACK_BOUNDARY_PADDING = metersToSimUnits(248);
|
|
28
|
+
const MIN_NON_ADJACENT_ARC_DISTANCE = metersToSimUnits(700);
|
|
27
29
|
const MIN_TRACK_CLEARANCE_MULTIPLIER = 1.55;
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
30
|
+
const MIN_TRACK_SHAPE_VARIATION = 0.28;
|
|
31
|
+
const MAX_LOCAL_TURN_RADIANS = 1.42;
|
|
32
|
+
const MAX_SAMPLE_HEADING_DELTA_RADIANS = 0.08;
|
|
33
|
+
const START_STRAIGHT_GRID_LENGTH = metersToSimUnits(496);
|
|
34
|
+
const START_STRAIGHT_EXIT_LENGTH = metersToSimUnits(500);
|
|
35
|
+
const START_STRAIGHT_LOCK_EXTRA = metersToSimUnits(43);
|
|
36
|
+
const START_STRAIGHT_BLEND_LENGTH = metersToSimUnits(320);
|
|
31
37
|
const NEAREST_HINT_WINDOW_SAMPLES = 240;
|
|
38
|
+
const PIT_LANE_WIDTH = metersToSimUnits(12);
|
|
39
|
+
const PIT_LANE_EDGE_GAP = metersToSimUnits(16);
|
|
40
|
+
const PIT_ACCESS_MIN_LENGTH = metersToSimUnits(70);
|
|
41
|
+
const PIT_ACCESS_MAX_LENGTH = metersToSimUnits(150);
|
|
42
|
+
const PIT_ACCESS_TANGENT_RATIO = 0.72;
|
|
43
|
+
const PIT_ACCESS_SAMPLE_STEPS = 24;
|
|
44
|
+
const PIT_ACCESS_TRACK_OVERLAP = PIT_LANE_WIDTH * 0.52;
|
|
45
|
+
const PIT_ACCESS_SEARCH_STEP = metersToSimUnits(8);
|
|
46
|
+
const PIT_ENTRY_SEARCH_BEFORE = metersToSimUnits(230);
|
|
47
|
+
const PIT_ENTRY_SEARCH_AFTER = metersToSimUnits(250);
|
|
48
|
+
const PIT_EXIT_SEARCH_BEFORE = metersToSimUnits(60);
|
|
49
|
+
const PIT_EXIT_SEARCH_AFTER = metersToSimUnits(560);
|
|
50
|
+
const PIT_TEAM_COUNT = 10;
|
|
51
|
+
const PIT_BOXES_PER_TEAM = 2;
|
|
52
|
+
const PIT_BOX_COUNT = PIT_TEAM_COUNT * PIT_BOXES_PER_TEAM;
|
|
53
|
+
const PIT_BOX_LENGTH = metersToSimUnits(9);
|
|
54
|
+
const PIT_BOX_DEPTH = metersToSimUnits(4.5);
|
|
55
|
+
const PIT_BOX_PAIR_GAP = metersToSimUnits(4);
|
|
56
|
+
const PIT_TEAM_GAP = metersToSimUnits(14);
|
|
57
|
+
const PIT_BOX_TO_LANE_GAP = metersToSimUnits(1.5);
|
|
58
|
+
const PIT_WORKING_LANE_GAP = metersToSimUnits(1.5);
|
|
59
|
+
const PIT_WORKING_LANE_WIDTH = metersToSimUnits(8);
|
|
60
|
+
const PIT_SERVICE_AREA_LENGTH = metersToSimUnits(11);
|
|
61
|
+
const PIT_SERVICE_AREA_DEPTH = metersToSimUnits(5);
|
|
62
|
+
const PIT_SERVICE_QUEUE_GAP = metersToSimUnits(12);
|
|
63
|
+
const PIT_LANE_ENTRY_BUFFER = metersToSimUnits(44);
|
|
64
|
+
const PIT_LANE_EXIT_BUFFER = metersToSimUnits(44);
|
|
65
|
+
const PIT_LANE_FINISH_RATIO = 0.64;
|
|
66
|
+
const PIT_WORLD_PADDING = metersToSimUnits(24);
|
|
67
|
+
const PIT_TRACK_CLEARANCE_MARGIN = metersToSimUnits(5);
|
|
68
|
+
const PIT_LANE_OFFSET_SEARCH_STEP = metersToSimUnits(12);
|
|
32
69
|
const PROCEDURAL_TRACK_TEMPLATES = [
|
|
33
70
|
[
|
|
34
71
|
[0.08, 0.55], [0.10, 0.80], [0.22, 0.89], [0.42, 0.84], [0.52, 0.93],
|
|
@@ -73,7 +110,7 @@ const CENTERLINE_CONTROLS = [
|
|
|
73
110
|
{ x: WORLD.width * 0.08, y: WORLD.height * 0.43 },
|
|
74
111
|
];
|
|
75
112
|
|
|
76
|
-
function
|
|
113
|
+
function uniformCatmullRom(p0, p1, p2, p3, t) {
|
|
77
114
|
const t2 = t * t;
|
|
78
115
|
const t3 = t2 * t;
|
|
79
116
|
return {
|
|
@@ -92,6 +129,30 @@ function catmullRom(p0, p1, p2, p3, t) {
|
|
|
92
129
|
};
|
|
93
130
|
}
|
|
94
131
|
|
|
132
|
+
function centripetalCatmullRom(p0, p1, p2, p3, t) {
|
|
133
|
+
const t0 = 0;
|
|
134
|
+
const t1 = t0 + Math.sqrt(Math.max(distance(p0, p1), 0.001));
|
|
135
|
+
const t2 = t1 + Math.sqrt(Math.max(distance(p1, p2), 0.001));
|
|
136
|
+
const t3 = t2 + Math.sqrt(Math.max(distance(p2, p3), 0.001));
|
|
137
|
+
const localT = t1 + (t2 - t1) * t;
|
|
138
|
+
|
|
139
|
+
const interpolate = (a, b, start, end) => {
|
|
140
|
+
const span = Math.max(end - start, 0.001);
|
|
141
|
+
const amount = (localT - start) / span;
|
|
142
|
+
return {
|
|
143
|
+
x: a.x + (b.x - a.x) * amount,
|
|
144
|
+
y: a.y + (b.y - a.y) * amount,
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const a1 = interpolate(p0, p1, t0, t1);
|
|
149
|
+
const a2 = interpolate(p1, p2, t1, t2);
|
|
150
|
+
const a3 = interpolate(p2, p3, t2, t3);
|
|
151
|
+
const b1 = interpolate(a1, a2, t0, t2);
|
|
152
|
+
const b2 = interpolate(a2, a3, t1, t3);
|
|
153
|
+
return interpolate(b1, b2, t1, t2);
|
|
154
|
+
}
|
|
155
|
+
|
|
95
156
|
function normalizeSeed(seed) {
|
|
96
157
|
if (Number.isFinite(seed)) return seed >>> 0;
|
|
97
158
|
let hash = 2166136261;
|
|
@@ -102,7 +163,7 @@ function normalizeSeed(seed) {
|
|
|
102
163
|
return hash >>> 0;
|
|
103
164
|
}
|
|
104
165
|
|
|
105
|
-
function rawCenterPoint(ratio, controls = CENTERLINE_CONTROLS) {
|
|
166
|
+
function rawCenterPoint(ratio, controls = CENTERLINE_CONTROLS, interpolation = 'uniform') {
|
|
106
167
|
const count = controls.length;
|
|
107
168
|
const scaled = ratio * count;
|
|
108
169
|
const index = Math.floor(scaled) % count;
|
|
@@ -111,7 +172,9 @@ function rawCenterPoint(ratio, controls = CENTERLINE_CONTROLS) {
|
|
|
111
172
|
const p1 = controls[index];
|
|
112
173
|
const p2 = controls[(index + 1) % count];
|
|
113
174
|
const p3 = controls[(index + 2) % count];
|
|
114
|
-
return
|
|
175
|
+
return interpolation === 'centripetal'
|
|
176
|
+
? centripetalCatmullRom(p0, p1, p2, p3, localT)
|
|
177
|
+
: uniformCatmullRom(p0, p1, p2, p3, localT);
|
|
115
178
|
}
|
|
116
179
|
|
|
117
180
|
function distance(a, b) {
|
|
@@ -166,7 +229,7 @@ function hasEnoughTrackClearance(samples, totalLength, minimumClearance) {
|
|
|
166
229
|
for (let second = first + 1; second < points.length; second += 1) {
|
|
167
230
|
const arcDistance = Math.abs(points[second].distance - points[first].distance);
|
|
168
231
|
const loopDistance = Math.min(arcDistance, totalLength - arcDistance);
|
|
169
|
-
if (loopDistance <
|
|
232
|
+
if (loopDistance < MIN_NON_ADJACENT_ARC_DISTANCE) continue;
|
|
170
233
|
const dx = points[first].x - points[second].x;
|
|
171
234
|
const dy = points[first].y - points[second].y;
|
|
172
235
|
if (dx * dx + dy * dy < minimumClearanceSquared) return false;
|
|
@@ -177,32 +240,171 @@ function hasEnoughTrackClearance(samples, totalLength, minimumClearance) {
|
|
|
177
240
|
|
|
178
241
|
function hasReasonableTurnSharpness(samples) {
|
|
179
242
|
const usableSamples = samples.slice(0, -1);
|
|
180
|
-
const
|
|
243
|
+
const windows = [30, 36];
|
|
181
244
|
const step = 6;
|
|
182
245
|
|
|
246
|
+
for (let index = 0; index < usableSamples.length; index += 1) {
|
|
247
|
+
const current = usableSamples[index];
|
|
248
|
+
const next = usableSamples[(index + 1) % usableSamples.length];
|
|
249
|
+
if (Math.abs(normalizeAngle(next.heading - current.heading)) > MAX_SAMPLE_HEADING_DELTA_RADIANS) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
183
254
|
for (let index = 0; index < usableSamples.length; index += step) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
255
|
+
for (const window of windows) {
|
|
256
|
+
let accumulatedTurn = 0;
|
|
257
|
+
for (let offset = 0; offset < window; offset += step) {
|
|
258
|
+
const current = usableSamples[(index + offset) % usableSamples.length];
|
|
259
|
+
const next = usableSamples[(index + offset + step) % usableSamples.length];
|
|
260
|
+
accumulatedTurn += Math.abs(normalizeAngle(next.heading - current.heading));
|
|
261
|
+
}
|
|
262
|
+
if (accumulatedTurn > MAX_LOCAL_TURN_RADIANS) return false;
|
|
189
263
|
}
|
|
190
|
-
if (accumulatedTurn > MAX_LOCAL_TURN_RADIANS) return false;
|
|
191
264
|
}
|
|
192
265
|
|
|
193
266
|
return true;
|
|
194
267
|
}
|
|
195
268
|
|
|
269
|
+
function hasEnoughShapeVariation(controls) {
|
|
270
|
+
const center = { x: WORLD.width / 2, y: WORLD.height / 2 };
|
|
271
|
+
const radii = controls.map((point) => distance(point, center));
|
|
272
|
+
const mean = radii.reduce((total, radius) => total + radius, 0) / Math.max(1, radii.length);
|
|
273
|
+
if (mean <= 0) return false;
|
|
274
|
+
const variance = radii.reduce((total, radius) => total + (radius - mean) ** 2, 0) / Math.max(1, radii.length);
|
|
275
|
+
return Math.sqrt(variance) / mean > MIN_TRACK_SHAPE_VARIATION;
|
|
276
|
+
}
|
|
277
|
+
|
|
196
278
|
function distanceForwardAlongTrack(from, to, totalLength) {
|
|
197
279
|
return to >= from ? to - from : totalLength - from + to;
|
|
198
280
|
}
|
|
199
281
|
|
|
282
|
+
function recalculateSampleGeometry(samples) {
|
|
283
|
+
const usableCount = samples.length - 1;
|
|
284
|
+
for (let index = 0; index < usableCount; index += 1) {
|
|
285
|
+
const sample = samples[index];
|
|
286
|
+
const previous = samples[(index - 1 + usableCount) % usableCount];
|
|
287
|
+
const next = samples[(index + 1) % usableCount];
|
|
288
|
+
const nextNext = samples[(index + 2) % usableCount];
|
|
289
|
+
const heading = Math.atan2(next.y - previous.y, next.x - previous.x);
|
|
290
|
+
const nextHeading = Math.atan2(nextNext.y - sample.y, nextNext.x - sample.x);
|
|
291
|
+
sample.heading = heading;
|
|
292
|
+
sample.normalX = -Math.sin(heading);
|
|
293
|
+
sample.normalY = Math.cos(heading);
|
|
294
|
+
sample.curvature = Math.abs(normalizeAngle(nextHeading - heading)) / 28;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
samples[usableCount] = {
|
|
298
|
+
...samples[0],
|
|
299
|
+
distance: samples[usableCount].distance,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
return samples;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function rebuildSampleDistances(samples) {
|
|
306
|
+
const usable = samples.slice(0, -1).map((sample) => ({ ...sample }));
|
|
307
|
+
let rebuiltLength = 0;
|
|
308
|
+
|
|
309
|
+
usable.forEach((sample, index) => {
|
|
310
|
+
if (index > 0) rebuiltLength += distance(usable[index - 1], sample);
|
|
311
|
+
sample.distance = rebuiltLength;
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
rebuiltLength += distance(usable.at(-1), usable[0]);
|
|
315
|
+
const rebuilt = [
|
|
316
|
+
...usable,
|
|
317
|
+
{
|
|
318
|
+
...usable[0],
|
|
319
|
+
distance: rebuiltLength,
|
|
320
|
+
},
|
|
321
|
+
];
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
samples: recalculateSampleGeometry(rebuilt),
|
|
325
|
+
totalLength: rebuiltLength,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function smoothstep(value) {
|
|
330
|
+
const amount = clamp(value, 0, 1);
|
|
331
|
+
return amount * amount * (3 - 2 * amount);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function blendPoint(original, target, amount) {
|
|
335
|
+
return {
|
|
336
|
+
...original,
|
|
337
|
+
x: original.x + (target.x - original.x) * amount,
|
|
338
|
+
y: original.y + (target.y - original.y) * amount,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function straightenStandingStartSamples(samples, totalLength) {
|
|
343
|
+
const start = samples[0];
|
|
344
|
+
const forward = {
|
|
345
|
+
x: Math.cos(start.heading),
|
|
346
|
+
y: Math.sin(start.heading),
|
|
347
|
+
};
|
|
348
|
+
const gridLockLength = START_STRAIGHT_GRID_LENGTH + START_STRAIGHT_LOCK_EXTRA;
|
|
349
|
+
const exitLockLength = START_STRAIGHT_EXIT_LENGTH + START_STRAIGHT_LOCK_EXTRA;
|
|
350
|
+
|
|
351
|
+
const straightened = samples.slice(0, -1).map((sample) => {
|
|
352
|
+
const distanceFromStart = sample.distance;
|
|
353
|
+
const distanceToStart = totalLength - sample.distance;
|
|
354
|
+
let lineDistance = null;
|
|
355
|
+
let blendAmount = 0;
|
|
356
|
+
|
|
357
|
+
if (distanceFromStart <= exitLockLength + START_STRAIGHT_BLEND_LENGTH) {
|
|
358
|
+
lineDistance = distanceFromStart;
|
|
359
|
+
blendAmount = distanceFromStart <= exitLockLength
|
|
360
|
+
? 1
|
|
361
|
+
: 1 - smoothstep((distanceFromStart - exitLockLength) / START_STRAIGHT_BLEND_LENGTH);
|
|
362
|
+
} else if (distanceToStart <= gridLockLength + START_STRAIGHT_BLEND_LENGTH) {
|
|
363
|
+
lineDistance = -distanceToStart;
|
|
364
|
+
blendAmount = distanceToStart <= gridLockLength
|
|
365
|
+
? 1
|
|
366
|
+
: 1 - smoothstep((distanceToStart - gridLockLength) / START_STRAIGHT_BLEND_LENGTH);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (lineDistance == null || blendAmount <= 0) return { ...sample };
|
|
370
|
+
|
|
371
|
+
return blendPoint(sample, {
|
|
372
|
+
...sample,
|
|
373
|
+
x: start.x + forward.x * lineDistance,
|
|
374
|
+
y: start.y + forward.y * lineDistance,
|
|
375
|
+
}, blendAmount);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
return rebuildSampleDistances([
|
|
379
|
+
...straightened,
|
|
380
|
+
{
|
|
381
|
+
...straightened[0],
|
|
382
|
+
distance: totalLength,
|
|
383
|
+
},
|
|
384
|
+
]);
|
|
385
|
+
}
|
|
386
|
+
|
|
200
387
|
function chooseStandingStartIndex(samples, totalLength) {
|
|
201
388
|
const usableSamples = samples.slice(0, -1);
|
|
202
389
|
let best = { index: 0, score: Infinity };
|
|
203
390
|
|
|
204
391
|
for (let index = 0; index < usableSamples.length; index += 1) {
|
|
205
392
|
const candidate = usableSamples[index];
|
|
393
|
+
const candidateForward = {
|
|
394
|
+
x: Math.cos(candidate.heading),
|
|
395
|
+
y: Math.sin(candidate.heading),
|
|
396
|
+
};
|
|
397
|
+
const projectedGridEnd = {
|
|
398
|
+
x: candidate.x - candidateForward.x * START_STRAIGHT_GRID_LENGTH,
|
|
399
|
+
y: candidate.y - candidateForward.y * START_STRAIGHT_GRID_LENGTH,
|
|
400
|
+
};
|
|
401
|
+
const projectedExitEnd = {
|
|
402
|
+
x: candidate.x + candidateForward.x * START_STRAIGHT_EXIT_LENGTH,
|
|
403
|
+
y: candidate.y + candidateForward.y * START_STRAIGHT_EXIT_LENGTH,
|
|
404
|
+
};
|
|
405
|
+
const projectedClearance = Math.min(pointWorldClearance(projectedGridEnd), pointWorldClearance(projectedExitEnd));
|
|
406
|
+
if (projectedClearance < 0) continue;
|
|
407
|
+
|
|
206
408
|
let gridTurn = 0;
|
|
207
409
|
let gridCurvature = 0;
|
|
208
410
|
let gridCount = 0;
|
|
@@ -232,7 +434,8 @@ function chooseStandingStartIndex(samples, totalLength) {
|
|
|
232
434
|
gridTurn * 5 +
|
|
233
435
|
exitTurn * 1.2 +
|
|
234
436
|
(gridCurvature / Math.max(1, gridCount)) * 2600 +
|
|
235
|
-
(exitCurvature / Math.max(1, exitCount)) * 650
|
|
437
|
+
(exitCurvature / Math.max(1, exitCount)) * 650 -
|
|
438
|
+
projectedClearance * 0.002;
|
|
236
439
|
|
|
237
440
|
if (score < best.score) best = { index, score };
|
|
238
441
|
}
|
|
@@ -304,6 +507,27 @@ function rotateControls(controls, random) {
|
|
|
304
507
|
return [...controls.slice(rotation), ...controls.slice(0, rotation)];
|
|
305
508
|
}
|
|
306
509
|
|
|
510
|
+
function strengthenShapeVariation(controls) {
|
|
511
|
+
const center = { x: WORLD.width / 2, y: WORLD.height / 2 };
|
|
512
|
+
const radii = controls.map((point) => distance(point, center));
|
|
513
|
+
const mean = radii.reduce((total, radius) => total + radius, 0) / Math.max(1, radii.length);
|
|
514
|
+
const variation = mean > 0
|
|
515
|
+
? Math.sqrt(radii.reduce((total, radius) => total + (radius - mean) ** 2, 0) / Math.max(1, radii.length)) / mean
|
|
516
|
+
: 0;
|
|
517
|
+
if (variation > MIN_TRACK_SHAPE_VARIATION + 0.015) return controls;
|
|
518
|
+
|
|
519
|
+
return controls.map((point) => {
|
|
520
|
+
const radius = distance(point, center);
|
|
521
|
+
if (radius <= 0) return point;
|
|
522
|
+
const amount = mean + (radius - mean) * 1.18;
|
|
523
|
+
const scale = amount / radius;
|
|
524
|
+
return {
|
|
525
|
+
x: clamp(center.x + (point.x - center.x) * scale, TRACK_BOUNDARY_PADDING, WORLD.width - TRACK_BOUNDARY_PADDING),
|
|
526
|
+
y: clamp(center.y + (point.y - center.y) * scale, TRACK_BOUNDARY_PADDING, WORLD.height - TRACK_BOUNDARY_PADDING),
|
|
527
|
+
};
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
307
531
|
function generateCenterlineControls(seed) {
|
|
308
532
|
const random = createMulberry32(seed);
|
|
309
533
|
const template = PROCEDURAL_TRACK_TEMPLATES[Math.floor(seededRange(random, 0, PROCEDURAL_TRACK_TEMPLATES.length))]
|
|
@@ -313,26 +537,32 @@ function generateCenterlineControls(seed) {
|
|
|
313
537
|
random,
|
|
314
538
|
);
|
|
315
539
|
|
|
316
|
-
return rotateControls(normalized.map(normalizedToWorld), random);
|
|
540
|
+
return rotateControls(strengthenShapeVariation(normalized.map(normalizedToWorld)), random);
|
|
317
541
|
}
|
|
318
542
|
|
|
319
543
|
function generateFallbackCenterlineControls(seed) {
|
|
320
544
|
const random = createMulberry32(seed ^ 0xa5a5a5a5);
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
545
|
+
const template = PROCEDURAL_TRACK_TEMPLATES[(seed >>> 2) % PROCEDURAL_TRACK_TEMPLATES.length] ??
|
|
546
|
+
PROCEDURAL_TRACK_TEMPLATES[0];
|
|
547
|
+
const normalized = applySectorMorph(
|
|
548
|
+
template.map((point, index) => makeTemplatePoint(point, random, index)),
|
|
549
|
+
random,
|
|
550
|
+
);
|
|
325
551
|
|
|
326
|
-
return
|
|
327
|
-
|
|
328
|
-
const rx = WORLD.width * (0.345 + Math.sin(angle * 3 + phaseA) * 0.024);
|
|
329
|
-
const ry = WORLD.height * (0.315 + Math.sin(angle * 2 + phaseB) * 0.022);
|
|
552
|
+
return rotateControls(strengthenShapeVariation(normalized.map(normalizedToWorld)), random);
|
|
553
|
+
}
|
|
330
554
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
555
|
+
function generateSafeFallbackCenterlineControls(seed) {
|
|
556
|
+
const center = { x: WORLD.width / 2, y: WORLD.height / 2 };
|
|
557
|
+
const scale = 0.78 + ((seed >>> 3) % 4) * 0.02;
|
|
558
|
+
const mirrorX = (seed & 1) === 1 ? -1 : 1;
|
|
559
|
+
const mirrorY = (seed & 2) === 2 ? -1 : 1;
|
|
560
|
+
const controls = CENTERLINE_CONTROLS.map((point) => ({
|
|
561
|
+
x: center.x + (point.x - center.x) * scale * mirrorX,
|
|
562
|
+
y: center.y + (point.y - center.y) * scale * mirrorY,
|
|
563
|
+
}));
|
|
564
|
+
|
|
565
|
+
return rotateControls(controls, createMulberry32(seed ^ 0x9e3779b9));
|
|
336
566
|
}
|
|
337
567
|
|
|
338
568
|
function normalizeDrsZone(zone, totalLength) {
|
|
@@ -366,6 +596,603 @@ function createTrackSectors(totalLength) {
|
|
|
366
596
|
});
|
|
367
597
|
}
|
|
368
598
|
|
|
599
|
+
function clonePoint(point) {
|
|
600
|
+
return {
|
|
601
|
+
x: point.x,
|
|
602
|
+
y: point.y,
|
|
603
|
+
heading: point.heading,
|
|
604
|
+
distance: point.distance,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function normalizeVector(vector) {
|
|
609
|
+
const length = Math.hypot(vector.x, vector.y) || 1;
|
|
610
|
+
return {
|
|
611
|
+
x: vector.x / length,
|
|
612
|
+
y: vector.y / length,
|
|
613
|
+
length,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function headingVector(heading) {
|
|
618
|
+
return {
|
|
619
|
+
x: Math.cos(heading),
|
|
620
|
+
y: Math.sin(heading),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function dotVectors(first, second) {
|
|
625
|
+
return first.x * second.x + first.y * second.y;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function angleBetweenVectors(first, second) {
|
|
629
|
+
const firstUnit = normalizeVector(first);
|
|
630
|
+
const secondUnit = normalizeVector(second);
|
|
631
|
+
return Math.acos(clamp(dotVectors(firstUnit, secondUnit), -1, 1));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function signedLateralOffsetToPoint(trackPoint, point) {
|
|
635
|
+
return (point.x - trackPoint.x) * trackPoint.normalX + (point.y - trackPoint.y) * trackPoint.normalY;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function interpolatePitPoint(start, forward, distanceAlong) {
|
|
639
|
+
return {
|
|
640
|
+
x: start.x + forward.x * distanceAlong,
|
|
641
|
+
y: start.y + forward.y * distanceAlong,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function projectPitPoint(startPoint, forward, normal, distanceAlong, lateralOffset) {
|
|
646
|
+
return {
|
|
647
|
+
x: startPoint.x + forward.x * distanceAlong + normal.x * lateralOffset,
|
|
648
|
+
y: startPoint.y + forward.y * distanceAlong + normal.y * lateralOffset,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function pointWorldClearance(point) {
|
|
653
|
+
return Math.min(
|
|
654
|
+
point.x - PIT_WORLD_PADDING,
|
|
655
|
+
WORLD.width - PIT_WORLD_PADDING - point.x,
|
|
656
|
+
point.y - PIT_WORLD_PADDING,
|
|
657
|
+
WORLD.height - PIT_WORLD_PADDING - point.y,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function createStartStraightBasis(track) {
|
|
662
|
+
const finish = pointAt(track, 0);
|
|
663
|
+
return {
|
|
664
|
+
finish,
|
|
665
|
+
forward: {
|
|
666
|
+
x: Math.cos(finish.heading),
|
|
667
|
+
y: Math.sin(finish.heading),
|
|
668
|
+
},
|
|
669
|
+
normal: {
|
|
670
|
+
x: finish.normalX,
|
|
671
|
+
y: finish.normalY,
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function getPitBoxRunLength({
|
|
677
|
+
teamCount = PIT_TEAM_COUNT,
|
|
678
|
+
boxesPerTeam = PIT_BOXES_PER_TEAM,
|
|
679
|
+
boxLength = PIT_BOX_LENGTH,
|
|
680
|
+
pairGap = PIT_BOX_PAIR_GAP,
|
|
681
|
+
teamGap = PIT_TEAM_GAP,
|
|
682
|
+
} = {}) {
|
|
683
|
+
const boxCount = teamCount * boxesPerTeam;
|
|
684
|
+
return (
|
|
685
|
+
boxCount * boxLength +
|
|
686
|
+
teamCount * Math.max(0, boxesPerTeam - 1) * pairGap +
|
|
687
|
+
Math.max(0, teamCount - 1) * teamGap
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function createPitLaneLayout() {
|
|
692
|
+
const runLength = getPitBoxRunLength();
|
|
693
|
+
const length = runLength + PIT_LANE_ENTRY_BUFFER + PIT_LANE_EXIT_BUFFER;
|
|
694
|
+
return {
|
|
695
|
+
runLength,
|
|
696
|
+
length,
|
|
697
|
+
entryDistance: -length * PIT_LANE_FINISH_RATIO,
|
|
698
|
+
exitDistance: length * (1 - PIT_LANE_FINISH_RATIO),
|
|
699
|
+
entryBuffer: PIT_LANE_ENTRY_BUFFER,
|
|
700
|
+
exitBuffer: PIT_LANE_EXIT_BUFFER,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function createPitLaneEndpoints(track, side, laneOffset, layout = createPitLaneLayout()) {
|
|
705
|
+
const basis = createStartStraightBasis(track);
|
|
706
|
+
return {
|
|
707
|
+
...basis,
|
|
708
|
+
start: projectPitPoint(basis.finish, basis.forward, basis.normal, layout.entryDistance, side * laneOffset),
|
|
709
|
+
end: projectPitPoint(basis.finish, basis.forward, basis.normal, layout.exitDistance, side * laneOffset),
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function scorePitLanePlacement(track, side, laneOffset, layout) {
|
|
714
|
+
const placement = createPitLaneEndpoints(track, side, laneOffset, layout);
|
|
715
|
+
const entryLanePoint = placement.start;
|
|
716
|
+
const exitLanePoint = placement.end;
|
|
717
|
+
const worldCenter = { x: WORLD.width / 2, y: WORLD.height / 2 };
|
|
718
|
+
const laneMidpoint = {
|
|
719
|
+
x: (entryLanePoint.x + exitLanePoint.x) / 2,
|
|
720
|
+
y: (entryLanePoint.y + exitLanePoint.y) / 2,
|
|
721
|
+
};
|
|
722
|
+
const outwardDistance = distance(laneMidpoint, worldCenter) - distance(placement.finish, worldCenter);
|
|
723
|
+
const laneVector = normalizeVector({
|
|
724
|
+
x: exitLanePoint.x - entryLanePoint.x,
|
|
725
|
+
y: exitLanePoint.y - entryLanePoint.y,
|
|
726
|
+
});
|
|
727
|
+
const serviceNormal = normalizeVector({
|
|
728
|
+
x: placement.normal.x * side,
|
|
729
|
+
y: placement.normal.y * side,
|
|
730
|
+
});
|
|
731
|
+
const workingLaneOffset = PIT_LANE_WIDTH / 2 + PIT_WORKING_LANE_GAP + PIT_WORKING_LANE_WIDTH / 2;
|
|
732
|
+
const boxLateral = workingLaneOffset + PIT_WORKING_LANE_WIDTH / 2 + PIT_BOX_TO_LANE_GAP + PIT_BOX_DEPTH / 2;
|
|
733
|
+
let minimumClearance = Infinity;
|
|
734
|
+
let minimumTrackClearance = Infinity;
|
|
735
|
+
|
|
736
|
+
for (let index = 0; index <= 48; index += 1) {
|
|
737
|
+
const amount = index / 48;
|
|
738
|
+
const lanePoint = interpolatePitPoint(entryLanePoint, laneVector, laneVector.length * amount);
|
|
739
|
+
const boxPoint = {
|
|
740
|
+
x: lanePoint.x + serviceNormal.x * boxLateral,
|
|
741
|
+
y: lanePoint.y + serviceNormal.y * boxLateral,
|
|
742
|
+
};
|
|
743
|
+
minimumClearance = Math.min(minimumClearance, pointWorldClearance(lanePoint), pointWorldClearance(boxPoint));
|
|
744
|
+
minimumTrackClearance = Math.min(
|
|
745
|
+
minimumTrackClearance,
|
|
746
|
+
nearestTrackState(track, lanePoint).crossTrackError -
|
|
747
|
+
(track.width / 2 + (track.kerbWidth ?? 0) + PIT_TRACK_CLEARANCE_MARGIN),
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
side,
|
|
753
|
+
laneOffset,
|
|
754
|
+
worldClearance: minimumClearance,
|
|
755
|
+
trackClearance: minimumTrackClearance,
|
|
756
|
+
outwardDistance,
|
|
757
|
+
score: minimumTrackClearance * 5 + minimumClearance + outwardDistance * 3,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function choosePitLanePlacement(track, baseLaneOffset, layout) {
|
|
762
|
+
const candidates = [];
|
|
763
|
+
for (let offsetStep = 0; offsetStep <= 9; offsetStep += 1) {
|
|
764
|
+
const laneOffset = baseLaneOffset + offsetStep * PIT_LANE_OFFSET_SEARCH_STEP;
|
|
765
|
+
candidates.push(scorePitLanePlacement(track, -1, laneOffset, layout));
|
|
766
|
+
candidates.push(scorePitLanePlacement(track, 1, laneOffset, layout));
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const valid = candidates
|
|
770
|
+
.filter((candidate) => candidate.worldClearance > 0 && candidate.trackClearance > 0)
|
|
771
|
+
.sort((left, right) => (
|
|
772
|
+
Number(right.outwardDistance > 0) - Number(left.outwardDistance > 0) ||
|
|
773
|
+
right.trackClearance - left.trackClearance ||
|
|
774
|
+
right.worldClearance - left.worldClearance ||
|
|
775
|
+
left.laneOffset - right.laneOffset
|
|
776
|
+
));
|
|
777
|
+
if (valid.length) return valid[0];
|
|
778
|
+
|
|
779
|
+
return candidates.sort((left, right) => right.score - left.score)[0];
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function createPitBoxCorners(center, forward, serviceNormal) {
|
|
783
|
+
const halfLength = PIT_BOX_LENGTH / 2;
|
|
784
|
+
const halfDepth = PIT_BOX_DEPTH / 2;
|
|
785
|
+
return [
|
|
786
|
+
{
|
|
787
|
+
x: center.x + forward.x * halfLength + serviceNormal.x * halfDepth,
|
|
788
|
+
y: center.y + forward.y * halfLength + serviceNormal.y * halfDepth,
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
x: center.x - forward.x * halfLength + serviceNormal.x * halfDepth,
|
|
792
|
+
y: center.y - forward.y * halfLength + serviceNormal.y * halfDepth,
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
x: center.x - forward.x * halfLength - serviceNormal.x * halfDepth,
|
|
796
|
+
y: center.y - forward.y * halfLength - serviceNormal.y * halfDepth,
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
x: center.x + forward.x * halfLength - serviceNormal.x * halfDepth,
|
|
800
|
+
y: center.y + forward.y * halfLength - serviceNormal.y * halfDepth,
|
|
801
|
+
},
|
|
802
|
+
];
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function createPitRectangleCorners(center, forward, serviceNormal, length, depth) {
|
|
806
|
+
const halfLength = length / 2;
|
|
807
|
+
const halfDepth = depth / 2;
|
|
808
|
+
return [
|
|
809
|
+
{
|
|
810
|
+
x: center.x + forward.x * halfLength + serviceNormal.x * halfDepth,
|
|
811
|
+
y: center.y + forward.y * halfLength + serviceNormal.y * halfDepth,
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
x: center.x - forward.x * halfLength + serviceNormal.x * halfDepth,
|
|
815
|
+
y: center.y - forward.y * halfLength + serviceNormal.y * halfDepth,
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
x: center.x - forward.x * halfLength - serviceNormal.x * halfDepth,
|
|
819
|
+
y: center.y - forward.y * halfLength - serviceNormal.y * halfDepth,
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
x: center.x + forward.x * halfLength - serviceNormal.x * halfDepth,
|
|
823
|
+
y: center.y + forward.y * halfLength - serviceNormal.y * halfDepth,
|
|
824
|
+
},
|
|
825
|
+
];
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function createPitBoxes({ laneStart, laneForward, laneLength, serviceNormal }) {
|
|
829
|
+
const runLength = getPitBoxRunLength();
|
|
830
|
+
let cursor = Math.max(PIT_SERVICE_QUEUE_GAP + PIT_BOX_LENGTH / 2, (laneLength - runLength) / 2);
|
|
831
|
+
const workingLaneOffset = PIT_LANE_WIDTH / 2 + PIT_WORKING_LANE_GAP + PIT_WORKING_LANE_WIDTH / 2;
|
|
832
|
+
const boxLateral = workingLaneOffset + PIT_WORKING_LANE_WIDTH / 2 + PIT_BOX_TO_LANE_GAP + PIT_BOX_DEPTH / 2;
|
|
833
|
+
const boxes = [];
|
|
834
|
+
const serviceAreas = [];
|
|
835
|
+
|
|
836
|
+
for (let index = 0; index < PIT_BOX_COUNT; index += 1) {
|
|
837
|
+
const distanceAlongLane = cursor + PIT_BOX_LENGTH / 2;
|
|
838
|
+
const laneTarget = interpolatePitPoint(laneStart, laneForward, distanceAlongLane);
|
|
839
|
+
const center = {
|
|
840
|
+
x: laneTarget.x + serviceNormal.x * boxLateral,
|
|
841
|
+
y: laneTarget.y + serviceNormal.y * boxLateral,
|
|
842
|
+
};
|
|
843
|
+
const teamIndex = Math.floor(index / PIT_BOXES_PER_TEAM);
|
|
844
|
+
const teamBoxIndex = index % PIT_BOXES_PER_TEAM;
|
|
845
|
+
|
|
846
|
+
boxes.push({
|
|
847
|
+
id: `team-${teamIndex + 1}-box-${teamBoxIndex + 1}`,
|
|
848
|
+
index,
|
|
849
|
+
teamIndex,
|
|
850
|
+
teamBoxIndex,
|
|
851
|
+
distanceAlongLane,
|
|
852
|
+
laneTarget,
|
|
853
|
+
center,
|
|
854
|
+
length: PIT_BOX_LENGTH,
|
|
855
|
+
depth: PIT_BOX_DEPTH,
|
|
856
|
+
corners: createPitBoxCorners(center, laneForward, serviceNormal),
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
if (teamBoxIndex === PIT_BOXES_PER_TEAM - 1) {
|
|
860
|
+
const firstTeamBox = boxes[index - (PIT_BOXES_PER_TEAM - 1)];
|
|
861
|
+
const serviceDistance = (firstTeamBox.distanceAlongLane + distanceAlongLane) / 2;
|
|
862
|
+
const queueDistance = Math.max(PIT_SERVICE_QUEUE_GAP / 2, serviceDistance - PIT_SERVICE_QUEUE_GAP);
|
|
863
|
+
const serviceCenter = projectPitPoint(
|
|
864
|
+
laneStart,
|
|
865
|
+
laneForward,
|
|
866
|
+
serviceNormal,
|
|
867
|
+
serviceDistance,
|
|
868
|
+
workingLaneOffset,
|
|
869
|
+
);
|
|
870
|
+
const queuePoint = projectPitPoint(
|
|
871
|
+
laneStart,
|
|
872
|
+
laneForward,
|
|
873
|
+
serviceNormal,
|
|
874
|
+
queueDistance,
|
|
875
|
+
workingLaneOffset,
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
serviceAreas.push({
|
|
879
|
+
id: `team-${teamIndex + 1}-service`,
|
|
880
|
+
index: teamIndex,
|
|
881
|
+
teamIndex,
|
|
882
|
+
distanceAlongLane: serviceDistance,
|
|
883
|
+
queueDistanceAlongLane: queueDistance,
|
|
884
|
+
laneTarget: serviceCenter,
|
|
885
|
+
center: serviceCenter,
|
|
886
|
+
queuePoint,
|
|
887
|
+
length: PIT_SERVICE_AREA_LENGTH,
|
|
888
|
+
depth: PIT_SERVICE_AREA_DEPTH,
|
|
889
|
+
corners: createPitRectangleCorners(serviceCenter, laneForward, serviceNormal, PIT_SERVICE_AREA_LENGTH, PIT_SERVICE_AREA_DEPTH),
|
|
890
|
+
queueCorners: createPitRectangleCorners(queuePoint, laneForward, serviceNormal, PIT_SERVICE_AREA_LENGTH * 0.72, PIT_SERVICE_AREA_DEPTH),
|
|
891
|
+
garageBoxIds: boxes
|
|
892
|
+
.slice(index - (PIT_BOXES_PER_TEAM - 1), index + 1)
|
|
893
|
+
.map((box) => box.id),
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
cursor += PIT_BOX_LENGTH;
|
|
898
|
+
if (teamBoxIndex === 0) cursor += PIT_BOX_PAIR_GAP;
|
|
899
|
+
else if (teamIndex < PIT_TEAM_COUNT - 1) cursor += PIT_TEAM_GAP;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return { boxes, serviceAreas, workingLaneOffset };
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function sampleCubicBezier(p0, p1, p2, p3, steps) {
|
|
906
|
+
return Array.from({ length: steps + 1 }, (_, index) => {
|
|
907
|
+
const t = index / steps;
|
|
908
|
+
const mt = 1 - t;
|
|
909
|
+
const mt2 = mt * mt;
|
|
910
|
+
const t2 = t * t;
|
|
911
|
+
return {
|
|
912
|
+
x: mt2 * mt * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t2 * t * p3.x,
|
|
913
|
+
y: mt2 * mt * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t2 * t * p3.y,
|
|
914
|
+
};
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function createPitAccessRoadCenterline(start, end, startForward, endForward, tangentLength) {
|
|
919
|
+
return sampleCubicBezier(
|
|
920
|
+
start,
|
|
921
|
+
{
|
|
922
|
+
x: start.x + startForward.x * tangentLength,
|
|
923
|
+
y: start.y + startForward.y * tangentLength,
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
x: end.x - endForward.x * tangentLength,
|
|
927
|
+
y: end.y - endForward.y * tangentLength,
|
|
928
|
+
},
|
|
929
|
+
end,
|
|
930
|
+
PIT_ACCESS_SAMPLE_STEPS,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function expandBounds(bounds, point) {
|
|
935
|
+
if (!point) return bounds;
|
|
936
|
+
return {
|
|
937
|
+
minX: Math.min(bounds.minX, point.x),
|
|
938
|
+
maxX: Math.max(bounds.maxX, point.x),
|
|
939
|
+
minY: Math.min(bounds.minY, point.y),
|
|
940
|
+
maxY: Math.max(bounds.maxY, point.y),
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function createPointBounds(points, padding = 0) {
|
|
945
|
+
const bounds = points.filter(Boolean).reduce(
|
|
946
|
+
(current, point) => expandBounds(current, point),
|
|
947
|
+
{ minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity },
|
|
948
|
+
);
|
|
949
|
+
if (!Number.isFinite(bounds.minX)) return null;
|
|
950
|
+
return {
|
|
951
|
+
minX: bounds.minX - padding,
|
|
952
|
+
maxX: bounds.maxX + padding,
|
|
953
|
+
minY: bounds.minY - padding,
|
|
954
|
+
maxY: bounds.maxY + padding,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function pointInsideBounds(point, bounds) {
|
|
959
|
+
if (!bounds) return true;
|
|
960
|
+
return (
|
|
961
|
+
point.x >= bounds.minX &&
|
|
962
|
+
point.x <= bounds.maxX &&
|
|
963
|
+
point.y >= bounds.minY &&
|
|
964
|
+
point.y <= bounds.maxY
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function createLaneFacingTrackConnection(track, distanceFromStart, lanePoint) {
|
|
969
|
+
const trackPoint = pointAt(track, distanceFromStart);
|
|
970
|
+
const laneSide = signedLateralOffsetToPoint(trackPoint, lanePoint) >= 0 ? 1 : -1;
|
|
971
|
+
const edgePoint = offsetTrackPoint(trackPoint, laneSide * (track.width / 2));
|
|
972
|
+
const trackConnectPoint = offsetTrackPoint(
|
|
973
|
+
trackPoint,
|
|
974
|
+
laneSide * Math.max(0, track.width / 2 - PIT_ACCESS_TRACK_OVERLAP),
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
return {
|
|
978
|
+
distanceFromStart,
|
|
979
|
+
trackDistance: wrapDistance(trackPoint.distance, track.length),
|
|
980
|
+
trackPoint,
|
|
981
|
+
edgePoint,
|
|
982
|
+
trackConnectPoint,
|
|
983
|
+
laneSide,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function scorePitAccessConnection(track, connection, lanePoint, laneForward, direction) {
|
|
988
|
+
const roadVector = direction === 'entry'
|
|
989
|
+
? normalizeVector({
|
|
990
|
+
x: lanePoint.x - connection.trackConnectPoint.x,
|
|
991
|
+
y: lanePoint.y - connection.trackConnectPoint.y,
|
|
992
|
+
})
|
|
993
|
+
: normalizeVector({
|
|
994
|
+
x: connection.trackConnectPoint.x - lanePoint.x,
|
|
995
|
+
y: connection.trackConnectPoint.y - lanePoint.y,
|
|
996
|
+
});
|
|
997
|
+
const trackForward = headingVector(connection.trackPoint.heading);
|
|
998
|
+
const trackAngle = angleBetweenVectors(roadVector, trackForward);
|
|
999
|
+
const laneAngle = angleBetweenVectors(roadVector, laneForward);
|
|
1000
|
+
const laneFacingClearance = Math.abs(signedLateralOffsetToPoint(connection.trackPoint, lanePoint)) - track.width / 2;
|
|
1001
|
+
|
|
1002
|
+
return {
|
|
1003
|
+
...connection,
|
|
1004
|
+
roadVector,
|
|
1005
|
+
trackAngle,
|
|
1006
|
+
laneAngle,
|
|
1007
|
+
pathLength: roadVector.length,
|
|
1008
|
+
score:
|
|
1009
|
+
trackAngle * 1100 +
|
|
1010
|
+
laneAngle * 900 +
|
|
1011
|
+
roadVector.length * 0.32 -
|
|
1012
|
+
Math.max(0, laneFacingClearance) * 0.18,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function findPitAccessConnection(track, lanePoint, laneForward, {
|
|
1017
|
+
direction,
|
|
1018
|
+
startDistance,
|
|
1019
|
+
endDistance,
|
|
1020
|
+
fallbackDistance,
|
|
1021
|
+
}) {
|
|
1022
|
+
const start = Math.min(startDistance, endDistance);
|
|
1023
|
+
const end = Math.max(startDistance, endDistance);
|
|
1024
|
+
let best = null;
|
|
1025
|
+
let fallback = null;
|
|
1026
|
+
|
|
1027
|
+
for (let distanceFromStart = start; distanceFromStart <= end; distanceFromStart += PIT_ACCESS_SEARCH_STEP) {
|
|
1028
|
+
const connection = scorePitAccessConnection(
|
|
1029
|
+
track,
|
|
1030
|
+
createLaneFacingTrackConnection(track, distanceFromStart, lanePoint),
|
|
1031
|
+
lanePoint,
|
|
1032
|
+
laneForward,
|
|
1033
|
+
direction,
|
|
1034
|
+
);
|
|
1035
|
+
fallback = !fallback || connection.score < fallback.score ? connection : fallback;
|
|
1036
|
+
if (connection.trackAngle > 1.15 || connection.laneAngle > 1.15) continue;
|
|
1037
|
+
best = !best || connection.score < best.score ? connection : best;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (best) return best;
|
|
1041
|
+
|
|
1042
|
+
return fallback ?? scorePitAccessConnection(
|
|
1043
|
+
track,
|
|
1044
|
+
createLaneFacingTrackConnection(track, fallbackDistance, lanePoint),
|
|
1045
|
+
lanePoint,
|
|
1046
|
+
laneForward,
|
|
1047
|
+
direction,
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function createPitLaneBounds(pitLane) {
|
|
1052
|
+
const points = [
|
|
1053
|
+
...(pitLane.entry?.roadCenterline ?? []),
|
|
1054
|
+
...(pitLane.exit?.roadCenterline ?? []),
|
|
1055
|
+
...(pitLane.mainLane?.points ?? []),
|
|
1056
|
+
...(pitLane.workingLane?.points ?? []),
|
|
1057
|
+
...(pitLane.boxes ?? []).flatMap((box) => box.corners ?? []),
|
|
1058
|
+
...(pitLane.serviceAreas ?? []).flatMap((area) => [
|
|
1059
|
+
...(area.corners ?? []),
|
|
1060
|
+
...(area.queueCorners ?? []),
|
|
1061
|
+
]),
|
|
1062
|
+
];
|
|
1063
|
+
const padding = Math.max(
|
|
1064
|
+
pitLane.width ?? 0,
|
|
1065
|
+
pitLane.workingLane?.width ?? 0,
|
|
1066
|
+
PIT_BOX_DEPTH,
|
|
1067
|
+
PIT_SERVICE_AREA_DEPTH,
|
|
1068
|
+
) / 2 + metersToSimUnits(3);
|
|
1069
|
+
return createPointBounds(points, padding);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function createPitLaneModel(track) {
|
|
1073
|
+
const layout = createPitLaneLayout();
|
|
1074
|
+
const laneOffset = track.width / 2 + (track.kerbWidth ?? 0) + PIT_LANE_EDGE_GAP + PIT_LANE_WIDTH / 2;
|
|
1075
|
+
const placement = choosePitLanePlacement(track, laneOffset, layout);
|
|
1076
|
+
const side = placement.side;
|
|
1077
|
+
const placedLaneOffset = placement.laneOffset;
|
|
1078
|
+
const startStraight = createPitLaneEndpoints(track, side, placedLaneOffset, layout);
|
|
1079
|
+
const laneStart = startStraight.start;
|
|
1080
|
+
const laneEnd = startStraight.end;
|
|
1081
|
+
const laneVector = normalizeVector({
|
|
1082
|
+
x: laneEnd.x - laneStart.x,
|
|
1083
|
+
y: laneEnd.y - laneStart.y,
|
|
1084
|
+
});
|
|
1085
|
+
const lateralSpan = Math.max(0, placedLaneOffset - track.width / 2);
|
|
1086
|
+
const accessLength = clamp(lateralSpan * 0.9, PIT_ACCESS_MIN_LENGTH, PIT_ACCESS_MAX_LENGTH);
|
|
1087
|
+
const serviceNormal = normalizeVector({
|
|
1088
|
+
x: startStraight.normal.x * side,
|
|
1089
|
+
y: startStraight.normal.y * side,
|
|
1090
|
+
});
|
|
1091
|
+
const entryConnection = findPitAccessConnection(track, laneStart, laneVector, {
|
|
1092
|
+
direction: 'entry',
|
|
1093
|
+
startDistance: layout.entryDistance - PIT_ENTRY_SEARCH_BEFORE,
|
|
1094
|
+
endDistance: layout.entryDistance + PIT_ENTRY_SEARCH_AFTER,
|
|
1095
|
+
fallbackDistance: layout.entryDistance - accessLength,
|
|
1096
|
+
});
|
|
1097
|
+
const exitConnection = findPitAccessConnection(track, laneEnd, laneVector, {
|
|
1098
|
+
direction: 'exit',
|
|
1099
|
+
startDistance: layout.exitDistance - PIT_EXIT_SEARCH_BEFORE,
|
|
1100
|
+
endDistance: layout.exitDistance + PIT_EXIT_SEARCH_AFTER,
|
|
1101
|
+
fallbackDistance: layout.exitDistance + accessLength,
|
|
1102
|
+
});
|
|
1103
|
+
const entryTangentLength = clamp(
|
|
1104
|
+
entryConnection.pathLength * PIT_ACCESS_TANGENT_RATIO,
|
|
1105
|
+
PIT_LANE_WIDTH,
|
|
1106
|
+
PIT_ACCESS_MAX_LENGTH,
|
|
1107
|
+
);
|
|
1108
|
+
const exitTangentLength = clamp(
|
|
1109
|
+
exitConnection.pathLength * PIT_ACCESS_TANGENT_RATIO,
|
|
1110
|
+
PIT_LANE_WIDTH,
|
|
1111
|
+
PIT_ACCESS_MAX_LENGTH,
|
|
1112
|
+
);
|
|
1113
|
+
const entryMergePoint = interpolatePitPoint(laneStart, laneVector, Math.min(metersToSimUnits(86), laneVector.length * 0.16));
|
|
1114
|
+
const exitMergePoint = interpolatePitPoint(laneEnd, laneVector, -Math.min(metersToSimUnits(86), laneVector.length * 0.16));
|
|
1115
|
+
|
|
1116
|
+
const entryRoadCenterline = createPitAccessRoadCenterline(
|
|
1117
|
+
entryConnection.trackConnectPoint,
|
|
1118
|
+
laneStart,
|
|
1119
|
+
headingVector(entryConnection.trackPoint.heading),
|
|
1120
|
+
laneVector,
|
|
1121
|
+
entryTangentLength,
|
|
1122
|
+
);
|
|
1123
|
+
const exitRoadCenterline = createPitAccessRoadCenterline(
|
|
1124
|
+
laneEnd,
|
|
1125
|
+
exitConnection.trackConnectPoint,
|
|
1126
|
+
laneVector,
|
|
1127
|
+
headingVector(exitConnection.trackPoint.heading),
|
|
1128
|
+
exitTangentLength,
|
|
1129
|
+
);
|
|
1130
|
+
const { boxes, serviceAreas, workingLaneOffset } = createPitBoxes({
|
|
1131
|
+
laneStart,
|
|
1132
|
+
laneForward: laneVector,
|
|
1133
|
+
laneLength: laneVector.length,
|
|
1134
|
+
serviceNormal,
|
|
1135
|
+
});
|
|
1136
|
+
const workingLaneStart = projectPitPoint(laneStart, laneVector, serviceNormal, 0, workingLaneOffset);
|
|
1137
|
+
const workingLaneEnd = projectPitPoint(laneStart, laneVector, serviceNormal, laneVector.length, workingLaneOffset);
|
|
1138
|
+
|
|
1139
|
+
const pitLane = {
|
|
1140
|
+
enabled: true,
|
|
1141
|
+
side,
|
|
1142
|
+
width: PIT_LANE_WIDTH,
|
|
1143
|
+
offset: placedLaneOffset,
|
|
1144
|
+
layout,
|
|
1145
|
+
boxCount: PIT_BOX_COUNT,
|
|
1146
|
+
teamCount: PIT_TEAM_COUNT,
|
|
1147
|
+
boxesPerTeam: PIT_BOXES_PER_TEAM,
|
|
1148
|
+
entry: {
|
|
1149
|
+
trackDistance: entryConnection.trackDistance,
|
|
1150
|
+
distanceFromStart: entryConnection.distanceFromStart,
|
|
1151
|
+
trackPoint: clonePoint(entryConnection.trackPoint),
|
|
1152
|
+
edgePoint: entryConnection.edgePoint,
|
|
1153
|
+
trackConnectPoint: entryConnection.trackConnectPoint,
|
|
1154
|
+
lanePoint: laneStart,
|
|
1155
|
+
roadCenterline: entryRoadCenterline,
|
|
1156
|
+
connector: [clonePoint(entryConnection.trackPoint), ...entryRoadCenterline, entryMergePoint],
|
|
1157
|
+
},
|
|
1158
|
+
exit: {
|
|
1159
|
+
trackDistance: exitConnection.trackDistance,
|
|
1160
|
+
distanceFromStart: exitConnection.distanceFromStart,
|
|
1161
|
+
trackPoint: clonePoint(exitConnection.trackPoint),
|
|
1162
|
+
edgePoint: exitConnection.edgePoint,
|
|
1163
|
+
trackConnectPoint: exitConnection.trackConnectPoint,
|
|
1164
|
+
lanePoint: laneEnd,
|
|
1165
|
+
roadCenterline: exitRoadCenterline,
|
|
1166
|
+
connector: [exitMergePoint, ...exitRoadCenterline, clonePoint(exitConnection.trackPoint)],
|
|
1167
|
+
},
|
|
1168
|
+
mainLane: {
|
|
1169
|
+
start: laneStart,
|
|
1170
|
+
end: laneEnd,
|
|
1171
|
+
points: [laneStart, laneEnd],
|
|
1172
|
+
length: laneVector.length,
|
|
1173
|
+
heading: Math.atan2(laneVector.y, laneVector.x),
|
|
1174
|
+
},
|
|
1175
|
+
fastLane: {
|
|
1176
|
+
offset: 0,
|
|
1177
|
+
width: PIT_LANE_WIDTH,
|
|
1178
|
+
},
|
|
1179
|
+
workingLane: {
|
|
1180
|
+
start: workingLaneStart,
|
|
1181
|
+
end: workingLaneEnd,
|
|
1182
|
+
points: [workingLaneStart, workingLaneEnd],
|
|
1183
|
+
offset: workingLaneOffset,
|
|
1184
|
+
width: PIT_WORKING_LANE_WIDTH,
|
|
1185
|
+
},
|
|
1186
|
+
serviceNormal,
|
|
1187
|
+
boxes,
|
|
1188
|
+
serviceAreas,
|
|
1189
|
+
};
|
|
1190
|
+
return {
|
|
1191
|
+
...pitLane,
|
|
1192
|
+
bounds: createPitLaneBounds(pitLane),
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
|
|
369
1196
|
function scoreStraightWindow(samples, startIndex, windowSize) {
|
|
370
1197
|
let curvature = 0;
|
|
371
1198
|
for (let offset = 0; offset < windowSize; offset += 1) {
|
|
@@ -409,6 +1236,18 @@ function deriveDrsZones(samples, totalLength) {
|
|
|
409
1236
|
.sort((a, b) => a.startRatio - b.startRatio);
|
|
410
1237
|
}
|
|
411
1238
|
|
|
1239
|
+
function isValidProceduralTrackModel(model) {
|
|
1240
|
+
return (
|
|
1241
|
+
model.length >= GENERATED_TRACK_MIN_LENGTH &&
|
|
1242
|
+
model.length <= GENERATED_TRACK_MAX_LENGTH &&
|
|
1243
|
+
hasEnoughShapeVariation(model.centerlineControls) &&
|
|
1244
|
+
samplesStayInsideWorld(model.samples) &&
|
|
1245
|
+
hasEnoughTrackClearance(model.samples, model.length, model.width * MIN_TRACK_CLEARANCE_MULTIPLIER) &&
|
|
1246
|
+
hasReasonableTurnSharpness(model.samples) &&
|
|
1247
|
+
!hasSelfIntersections(model.samples)
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
412
1251
|
export function buildTrackModel(track = TRACK) {
|
|
413
1252
|
const cached = TRACK_MODEL_CACHE.get(track);
|
|
414
1253
|
if (cached) return cached;
|
|
@@ -416,7 +1255,7 @@ export function buildTrackModel(track = TRACK) {
|
|
|
416
1255
|
const controls = track.centerlineControls ?? CENTERLINE_CONTROLS;
|
|
417
1256
|
const base = [];
|
|
418
1257
|
for (let index = 0; index <= track.sampleCount; index += 1) {
|
|
419
|
-
base.push(rawCenterPoint(index / track.sampleCount, controls));
|
|
1258
|
+
base.push(rawCenterPoint(index / track.sampleCount, controls, track.curveInterpolation));
|
|
420
1259
|
}
|
|
421
1260
|
|
|
422
1261
|
let totalLength = 0;
|
|
@@ -424,22 +1263,12 @@ export function buildTrackModel(track = TRACK) {
|
|
|
424
1263
|
if (index > 0) totalLength += distance(base[index - 1], point);
|
|
425
1264
|
return { ...point, distance: totalLength, heading: 0, normalX: 0, normalY: 0, curvature: 0 };
|
|
426
1265
|
});
|
|
1266
|
+
recalculateSampleGeometry(samples);
|
|
427
1267
|
|
|
428
|
-
samples
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const nextHeading = Math.atan2(
|
|
433
|
-
samples[(index + 2) % (samples.length - 1)].y - sample.y,
|
|
434
|
-
samples[(index + 2) % (samples.length - 1)].x - sample.x,
|
|
435
|
-
);
|
|
436
|
-
sample.heading = heading;
|
|
437
|
-
sample.normalX = -Math.sin(heading);
|
|
438
|
-
sample.normalY = Math.cos(heading);
|
|
439
|
-
sample.curvature = Math.abs(normalizeAngle(nextHeading - heading)) / 28;
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
const normalizedSamples = rotateSamplesToStandingStart(samples, totalLength);
|
|
1268
|
+
const rotatedSamples = rotateSamplesToStandingStart(samples, totalLength);
|
|
1269
|
+
const straightenedTrack = straightenStandingStartSamples(rotatedSamples, totalLength);
|
|
1270
|
+
const normalizedSamples = straightenedTrack.samples;
|
|
1271
|
+
totalLength = straightenedTrack.totalLength;
|
|
443
1272
|
|
|
444
1273
|
const model = {
|
|
445
1274
|
...track,
|
|
@@ -450,6 +1279,7 @@ export function buildTrackModel(track = TRACK) {
|
|
|
450
1279
|
drsZones: (track.drsZones ?? deriveDrsZones(normalizedSamples, totalLength))
|
|
451
1280
|
.map((zone) => normalizeDrsZone(zone, totalLength)),
|
|
452
1281
|
};
|
|
1282
|
+
model.pitLane = createPitLaneModel(model);
|
|
453
1283
|
TRACK_MODEL_CACHE.set(track, model);
|
|
454
1284
|
return model;
|
|
455
1285
|
}
|
|
@@ -459,25 +1289,18 @@ export function createProceduralTrack(seed = Date.now()) {
|
|
|
459
1289
|
const cached = PROCEDURAL_TRACK_CACHE.get(normalizedSeed);
|
|
460
1290
|
if (cached) return cached;
|
|
461
1291
|
|
|
462
|
-
for (let attempt = 0; attempt <
|
|
1292
|
+
for (let attempt = 0; attempt < GENERATED_FALLBACK_ATTEMPTS; attempt += 1) {
|
|
463
1293
|
const candidateSeed = (normalizedSeed + Math.imul(attempt, 2654435761)) >>> 0;
|
|
464
1294
|
const candidate = {
|
|
465
1295
|
...TRACK,
|
|
466
1296
|
name: `Generated GP ${candidateSeed.toString(36).toUpperCase().padStart(6, '0').slice(-6)}`,
|
|
467
1297
|
seed: candidateSeed,
|
|
1298
|
+
curveInterpolation: 'centripetal',
|
|
468
1299
|
centerlineControls: generateCenterlineControls(candidateSeed),
|
|
469
1300
|
drsZones: null,
|
|
470
1301
|
};
|
|
471
1302
|
const model = buildTrackModel(candidate);
|
|
472
|
-
|
|
473
|
-
model.length >= GENERATED_TRACK_MIN_LENGTH &&
|
|
474
|
-
model.length <= GENERATED_TRACK_MAX_LENGTH &&
|
|
475
|
-
samplesStayInsideWorld(model.samples) &&
|
|
476
|
-
hasEnoughTrackClearance(model.samples, model.length, model.width * MIN_TRACK_CLEARANCE_MULTIPLIER) &&
|
|
477
|
-
hasReasonableTurnSharpness(model.samples) &&
|
|
478
|
-
!hasSelfIntersections(model.samples);
|
|
479
|
-
|
|
480
|
-
if (valid) {
|
|
1303
|
+
if (isValidProceduralTrackModel(model)) {
|
|
481
1304
|
const trackDefinition = {
|
|
482
1305
|
...candidate,
|
|
483
1306
|
drsZones: model.drsZones.map(({ id, startRatio, endRatio }) => ({ id, startRatio, endRatio })),
|
|
@@ -487,15 +1310,43 @@ export function createProceduralTrack(seed = Date.now()) {
|
|
|
487
1310
|
}
|
|
488
1311
|
}
|
|
489
1312
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
1313
|
+
let fallback = null;
|
|
1314
|
+
let fallbackModel = null;
|
|
1315
|
+
for (let attempt = 0; attempt < GENERATED_TRACK_ATTEMPTS; attempt += 1) {
|
|
1316
|
+
const candidateSeed = (normalizedSeed ^ Math.imul(attempt + 1, 2246822519)) >>> 0;
|
|
1317
|
+
const candidate = {
|
|
1318
|
+
...TRACK,
|
|
1319
|
+
name: `Generated GP ${normalizedSeed.toString(36).toUpperCase().padStart(6, '0').slice(-6)}`,
|
|
1320
|
+
seed: candidateSeed,
|
|
1321
|
+
curveInterpolation: 'centripetal',
|
|
1322
|
+
centerlineControls: generateFallbackCenterlineControls(candidateSeed),
|
|
1323
|
+
drsZones: null,
|
|
1324
|
+
};
|
|
1325
|
+
const model = buildTrackModel(candidate);
|
|
1326
|
+
if (isValidProceduralTrackModel(model)) {
|
|
1327
|
+
fallback = candidate;
|
|
1328
|
+
fallbackModel = model;
|
|
1329
|
+
break;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (!fallback || !fallbackModel) {
|
|
1334
|
+
fallback = {
|
|
1335
|
+
...TRACK,
|
|
1336
|
+
name: `Generated GP ${normalizedSeed.toString(36).toUpperCase().padStart(6, '0').slice(-6)}`,
|
|
1337
|
+
seed: normalizedSeed,
|
|
1338
|
+
curveInterpolation: 'centripetal',
|
|
1339
|
+
centerlineControls: generateSafeFallbackCenterlineControls(normalizedSeed),
|
|
1340
|
+
drsZones: null,
|
|
1341
|
+
};
|
|
1342
|
+
fallbackModel = buildTrackModel(fallback);
|
|
1343
|
+
}
|
|
1344
|
+
const fallbackDefinition = {
|
|
1345
|
+
...fallback,
|
|
1346
|
+
drsZones: fallbackModel.drsZones.map(({ id, startRatio, endRatio }) => ({ id, startRatio, endRatio })),
|
|
496
1347
|
};
|
|
497
|
-
PROCEDURAL_TRACK_CACHE.set(normalizedSeed,
|
|
498
|
-
return
|
|
1348
|
+
PROCEDURAL_TRACK_CACHE.set(normalizedSeed, fallbackDefinition);
|
|
1349
|
+
return fallbackDefinition;
|
|
499
1350
|
}
|
|
500
1351
|
|
|
501
1352
|
export function pointAt(track, distanceAlong) {
|
|
@@ -556,6 +1407,210 @@ function nearestSampleGlobal(track, position) {
|
|
|
556
1407
|
return nearestSampleInRange(track, position, 0, track.samples.length - 2);
|
|
557
1408
|
}
|
|
558
1409
|
|
|
1410
|
+
function projectPointToSegment(position, start, end) {
|
|
1411
|
+
const dx = end.x - start.x;
|
|
1412
|
+
const dy = end.y - start.y;
|
|
1413
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
1414
|
+
const amount = lengthSquared > 0
|
|
1415
|
+
? clamp(((position.x - start.x) * dx + (position.y - start.y) * dy) / lengthSquared, 0, 1)
|
|
1416
|
+
: 0;
|
|
1417
|
+
const projected = {
|
|
1418
|
+
x: start.x + dx * amount,
|
|
1419
|
+
y: start.y + dy * amount,
|
|
1420
|
+
};
|
|
1421
|
+
const length = Math.sqrt(lengthSquared);
|
|
1422
|
+
const heading = Math.atan2(dy, dx);
|
|
1423
|
+
const normalX = length > 0 ? -dy / length : 0;
|
|
1424
|
+
const normalY = length > 0 ? dx / length : 1;
|
|
1425
|
+
const signedOffset = (position.x - projected.x) * normalX + (position.y - projected.y) * normalY;
|
|
1426
|
+
|
|
1427
|
+
return {
|
|
1428
|
+
point: projected,
|
|
1429
|
+
amount,
|
|
1430
|
+
length,
|
|
1431
|
+
heading,
|
|
1432
|
+
normalX,
|
|
1433
|
+
normalY,
|
|
1434
|
+
signedOffset,
|
|
1435
|
+
crossTrackError: Math.abs(signedOffset),
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function nearestPointOnPolyline(points, position) {
|
|
1440
|
+
if (!Array.isArray(points) || points.length < 2) return null;
|
|
1441
|
+
let best = null;
|
|
1442
|
+
let distanceBefore = 0;
|
|
1443
|
+
let totalLength = 0;
|
|
1444
|
+
|
|
1445
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
1446
|
+
const current = points[index];
|
|
1447
|
+
const next = points[index + 1];
|
|
1448
|
+
const projection = projectPointToSegment(position, current, next);
|
|
1449
|
+
if (!best || projection.crossTrackError < best.crossTrackError) {
|
|
1450
|
+
best = {
|
|
1451
|
+
...projection,
|
|
1452
|
+
segmentIndex: index,
|
|
1453
|
+
distanceAlong: distanceBefore + projection.length * projection.amount,
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
distanceBefore += projection.length;
|
|
1457
|
+
totalLength += projection.length;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
if (!best) return null;
|
|
1461
|
+
return {
|
|
1462
|
+
...best,
|
|
1463
|
+
totalLength,
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function mapPitDistance(track, startDistance, endDistance, amount) {
|
|
1468
|
+
return wrapDistance(startDistance + (endDistance - startDistance) * clamp(amount, 0, 1), track.length);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function createPitRoadState(track, position, pitLane, {
|
|
1472
|
+
points,
|
|
1473
|
+
surface,
|
|
1474
|
+
part,
|
|
1475
|
+
roadWidth,
|
|
1476
|
+
startDistance,
|
|
1477
|
+
endDistance,
|
|
1478
|
+
}) {
|
|
1479
|
+
const projected = nearestPointOnPolyline(points, position);
|
|
1480
|
+
if (!projected || projected.crossTrackError > roadWidth / 2) return null;
|
|
1481
|
+
const amount = projected.totalLength > 0 ? projected.distanceAlong / projected.totalLength : 0;
|
|
1482
|
+
const distanceAlongTrack = mapPitDistance(track, startDistance, endDistance, amount);
|
|
1483
|
+
|
|
1484
|
+
return {
|
|
1485
|
+
x: projected.point.x,
|
|
1486
|
+
y: projected.point.y,
|
|
1487
|
+
heading: projected.heading,
|
|
1488
|
+
normalX: projected.normalX,
|
|
1489
|
+
normalY: projected.normalY,
|
|
1490
|
+
curvature: 0,
|
|
1491
|
+
distance: distanceAlongTrack,
|
|
1492
|
+
signedOffset: projected.signedOffset,
|
|
1493
|
+
crossTrackError: projected.crossTrackError,
|
|
1494
|
+
surface,
|
|
1495
|
+
onTrack: true,
|
|
1496
|
+
inPitLane: true,
|
|
1497
|
+
pitLanePart: part,
|
|
1498
|
+
pitLaneSignedOffset: projected.signedOffset,
|
|
1499
|
+
pitLaneCrossTrackError: projected.crossTrackError,
|
|
1500
|
+
pitLaneDistanceAlong: projected.distanceAlong,
|
|
1501
|
+
pitLaneTotalLength: projected.totalLength,
|
|
1502
|
+
pitLaneRouteAmount: amount,
|
|
1503
|
+
pitLaneRoadWidth: roadWidth,
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function pointIsInsidePolygon(point, polygon) {
|
|
1508
|
+
if (!Array.isArray(polygon) || polygon.length < 3) return false;
|
|
1509
|
+
let inside = false;
|
|
1510
|
+
|
|
1511
|
+
for (let currentIndex = 0, previousIndex = polygon.length - 1; currentIndex < polygon.length; previousIndex = currentIndex, currentIndex += 1) {
|
|
1512
|
+
const current = polygon[currentIndex];
|
|
1513
|
+
const previous = polygon[previousIndex];
|
|
1514
|
+
const crossesY = (current.y > point.y) !== (previous.y > point.y);
|
|
1515
|
+
if (!crossesY) continue;
|
|
1516
|
+
const xAtY = ((previous.x - current.x) * (point.y - current.y)) / (previous.y - current.y) + current.x;
|
|
1517
|
+
if (point.x < xAtY) inside = !inside;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
return inside;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function createPitBoxState(track, position, pitLane) {
|
|
1524
|
+
const serviceArea = pitLane.serviceAreas?.find((candidate) => (
|
|
1525
|
+
pointIsInsidePolygon(position, candidate.corners) ||
|
|
1526
|
+
pointIsInsidePolygon(position, candidate.queueCorners)
|
|
1527
|
+
));
|
|
1528
|
+
const box = serviceArea ?? pitLane.boxes?.find((candidate) => pointIsInsidePolygon(position, candidate.corners));
|
|
1529
|
+
if (!box) return null;
|
|
1530
|
+
const distanceAmount = pitLane.mainLane.length > 0
|
|
1531
|
+
? box.distanceAlongLane / pitLane.mainLane.length
|
|
1532
|
+
: 0;
|
|
1533
|
+
const distanceAlongTrack = mapPitDistance(
|
|
1534
|
+
track,
|
|
1535
|
+
pitLane.layout?.entryDistance ?? pitLane.entry.distanceFromStart,
|
|
1536
|
+
pitLane.layout?.exitDistance ?? pitLane.exit.distanceFromStart,
|
|
1537
|
+
distanceAmount,
|
|
1538
|
+
);
|
|
1539
|
+
const heading = pitLane.mainLane.heading;
|
|
1540
|
+
|
|
1541
|
+
return {
|
|
1542
|
+
x: box.center.x,
|
|
1543
|
+
y: box.center.y,
|
|
1544
|
+
heading,
|
|
1545
|
+
normalX: pitLane.serviceNormal.x,
|
|
1546
|
+
normalY: pitLane.serviceNormal.y,
|
|
1547
|
+
curvature: 0,
|
|
1548
|
+
distance: distanceAlongTrack,
|
|
1549
|
+
signedOffset: 0,
|
|
1550
|
+
crossTrackError: 0,
|
|
1551
|
+
surface: 'pit-box',
|
|
1552
|
+
onTrack: true,
|
|
1553
|
+
inPitLane: true,
|
|
1554
|
+
pitLanePart: serviceArea ? 'service-box' : 'garage-box',
|
|
1555
|
+
pitLaneSignedOffset: 0,
|
|
1556
|
+
pitLaneCrossTrackError: 0,
|
|
1557
|
+
pitBoxId: box.id,
|
|
1558
|
+
pitBoxIndex: box.index,
|
|
1559
|
+
pitTeamId: box.teamId ?? null,
|
|
1560
|
+
pitLaneDistanceAlong: box.distanceAlongLane,
|
|
1561
|
+
pitLaneTotalLength: pitLane.mainLane.length,
|
|
1562
|
+
pitLaneRoadWidth: box.depth,
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function nearestPitLaneState(track, position) {
|
|
1567
|
+
const pitLane = track.pitLane;
|
|
1568
|
+
if (!pitLane?.enabled) return null;
|
|
1569
|
+
if (!pointInsideBounds(position, pitLane.bounds)) return null;
|
|
1570
|
+
|
|
1571
|
+
const boxState = createPitBoxState(track, position, pitLane);
|
|
1572
|
+
if (boxState) return boxState;
|
|
1573
|
+
|
|
1574
|
+
const laneEntryDistance = pitLane.layout?.entryDistance ?? pitLane.entry.distanceFromStart;
|
|
1575
|
+
const laneExitDistance = pitLane.layout?.exitDistance ?? pitLane.exit.distanceFromStart;
|
|
1576
|
+
const candidates = [
|
|
1577
|
+
createPitRoadState(track, position, pitLane, {
|
|
1578
|
+
points: pitLane.entry.roadCenterline,
|
|
1579
|
+
surface: 'pit-entry',
|
|
1580
|
+
part: 'entry',
|
|
1581
|
+
roadWidth: pitLane.width,
|
|
1582
|
+
startDistance: pitLane.entry.distanceFromStart,
|
|
1583
|
+
endDistance: laneEntryDistance,
|
|
1584
|
+
}),
|
|
1585
|
+
createPitRoadState(track, position, pitLane, {
|
|
1586
|
+
points: pitLane.mainLane.points,
|
|
1587
|
+
surface: 'pit-lane',
|
|
1588
|
+
part: 'fast-lane',
|
|
1589
|
+
roadWidth: pitLane.width,
|
|
1590
|
+
startDistance: laneEntryDistance,
|
|
1591
|
+
endDistance: laneExitDistance,
|
|
1592
|
+
}),
|
|
1593
|
+
createPitRoadState(track, position, pitLane, {
|
|
1594
|
+
points: pitLane.workingLane?.points,
|
|
1595
|
+
surface: 'pit-lane',
|
|
1596
|
+
part: 'working-lane',
|
|
1597
|
+
roadWidth: pitLane.workingLane?.width ?? 0,
|
|
1598
|
+
startDistance: laneEntryDistance,
|
|
1599
|
+
endDistance: laneExitDistance,
|
|
1600
|
+
}),
|
|
1601
|
+
createPitRoadState(track, position, pitLane, {
|
|
1602
|
+
points: pitLane.exit.roadCenterline,
|
|
1603
|
+
surface: 'pit-exit',
|
|
1604
|
+
part: 'exit',
|
|
1605
|
+
roadWidth: pitLane.width,
|
|
1606
|
+
startDistance: laneExitDistance,
|
|
1607
|
+
endDistance: pitLane.exit.distanceFromStart,
|
|
1608
|
+
}),
|
|
1609
|
+
].filter(Boolean);
|
|
1610
|
+
|
|
1611
|
+
return candidates.sort((left, right) => left.crossTrackError - right.crossTrackError)[0] ?? null;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
559
1614
|
function createTrackState(track, position, best) {
|
|
560
1615
|
const dx = position.x - best.x;
|
|
561
1616
|
const dy = position.y - best.y;
|
|
@@ -602,7 +1657,30 @@ export function nearestTrackState(track, position, progressHint = null) {
|
|
|
602
1657
|
}
|
|
603
1658
|
|
|
604
1659
|
const best = nearest?.best ?? nearestSampleGlobal(track, position).best;
|
|
605
|
-
|
|
1660
|
+
const trackState = createTrackState(track, position, best);
|
|
1661
|
+
const pitState = nearestPitLaneState(track, position);
|
|
1662
|
+
if (!pitState) return trackState;
|
|
1663
|
+
|
|
1664
|
+
const mainRoadEdge = track.width / 2 + (track.kerbWidth ?? 0);
|
|
1665
|
+
if (trackState.crossTrackError <= mainRoadEdge && pitState.surface !== 'pit-box') return trackState;
|
|
1666
|
+
|
|
1667
|
+
const mergeBuffer = PIT_LANE_WIDTH * 0.16;
|
|
1668
|
+
const isMainTrackMerge =
|
|
1669
|
+
trackState.surface === 'track' &&
|
|
1670
|
+
(
|
|
1671
|
+
(pitState.surface === 'pit-entry' && pitState.pitLaneDistanceAlong <= mergeBuffer) ||
|
|
1672
|
+
(pitState.surface === 'pit-exit' && (pitState.pitLaneTotalLength - pitState.pitLaneDistanceAlong) <= mergeBuffer)
|
|
1673
|
+
);
|
|
1674
|
+
|
|
1675
|
+
if (isMainTrackMerge) return trackState;
|
|
1676
|
+
|
|
1677
|
+
return {
|
|
1678
|
+
...pitState,
|
|
1679
|
+
mainTrackSignedOffset: trackState.signedOffset,
|
|
1680
|
+
mainTrackCrossTrackError: trackState.crossTrackError,
|
|
1681
|
+
signedOffset: trackState.signedOffset,
|
|
1682
|
+
crossTrackError: trackState.crossTrackError,
|
|
1683
|
+
};
|
|
606
1684
|
}
|
|
607
1685
|
|
|
608
1686
|
export function offsetTrackPoint(point, offset) {
|