@inventure71/paddockjs 0.1.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/AGENTS.md +86 -0
- package/CHANGELOG.md +5 -0
- package/LICENSE +176 -0
- package/README.md +165 -0
- package/assets/f1-broadcast-panel-surface.png +0 -0
- package/assets/f1-car-sprite-game.png +0 -0
- package/assets/f1-driver-helmet.png +0 -0
- package/assets/f1-safety-car-sprite.png +0 -0
- package/assets/f1-texture-asphalt.png +0 -0
- package/assets/f1Logo.png +0 -0
- package/docs/architecture.md +209 -0
- package/docs/concepts.md +178 -0
- package/docs/data_contract.md +360 -0
- package/docs/data_model.md +167 -0
- package/docs/index.md +15 -0
- package/docs/learnings.md +47 -0
- package/docs/rules.md +181 -0
- package/docs/system_specs.md +211 -0
- package/package.json +85 -0
- package/src/api/PaddockSimulatorController.js +233 -0
- package/src/app/F1SimulatorApp.js +1293 -0
- package/src/app/domBindings.js +64 -0
- package/src/config/defaultAssets.js +29 -0
- package/src/config/defaultOptions.js +184 -0
- package/src/data/championship.js +225 -0
- package/src/data/customFields.js +14 -0
- package/src/data/demoDrivers.js +134 -0
- package/src/data/driverData.js +71 -0
- package/src/data/normalizeDrivers.js +21 -0
- package/src/data/vehicleData.js +81 -0
- package/src/index.d.ts +399 -0
- package/src/index.js +116 -0
- package/src/rendering/proceduralTrackAsset.js +517 -0
- package/src/rendering/renderSnapshot.js +23 -0
- package/src/simulation/driverController.js +253 -0
- package/src/simulation/raceSimulation.js +933 -0
- package/src/simulation/simMath.js +34 -0
- package/src/simulation/trackModel.js +607 -0
- package/src/simulation/units.js +30 -0
- package/src/simulation/vehiclePhysics.js +104 -0
- package/src/standalone.js +15 -0
- package/src/styles.css +1818 -0
- package/src/ui/componentTemplates.js +229 -0
- package/src/ui/shellTemplate.js +43 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const TWO_PI = Math.PI * 2;
|
|
2
|
+
|
|
3
|
+
export function clamp(value, min, max) {
|
|
4
|
+
return Math.min(Math.max(value, min), max);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function lerp(start, end, amount) {
|
|
8
|
+
return start + (end - start) * amount;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function normalizeAngle(angle) {
|
|
12
|
+
let value = ((angle + Math.PI) % TWO_PI) - Math.PI;
|
|
13
|
+
if (value < -Math.PI) value += TWO_PI;
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createMulberry32(seed) {
|
|
18
|
+
let state = seed >>> 0;
|
|
19
|
+
return () => {
|
|
20
|
+
state += 0x6d2b79f5;
|
|
21
|
+
let t = state;
|
|
22
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
23
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
24
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function seededRange(random, min, max) {
|
|
29
|
+
return min + (max - min) * random();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function wrapDistance(value, length) {
|
|
33
|
+
return ((value % length) + length) % length;
|
|
34
|
+
}
|
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { clamp, createMulberry32, normalizeAngle, seededRange, TWO_PI, wrapDistance } from './simMath.js';
|
|
2
|
+
|
|
3
|
+
export const WORLD = {
|
|
4
|
+
width: 7600,
|
|
5
|
+
height: 4600,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const TRACK = {
|
|
9
|
+
name: 'Apex Harbor GP',
|
|
10
|
+
width: 230,
|
|
11
|
+
kerbWidth: 34,
|
|
12
|
+
gravelWidth: 165,
|
|
13
|
+
runoffWidth: 260,
|
|
14
|
+
sampleCount: 3600,
|
|
15
|
+
drsZones: [
|
|
16
|
+
{ id: 'main-straight', startRatio: 0.02, endRatio: 0.18 },
|
|
17
|
+
{ id: 'back-straight', startRatio: 0.43, endRatio: 0.60 },
|
|
18
|
+
{ id: 'harbor-straight', startRatio: 0.82, endRatio: 0.96 },
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const GENERATED_TRACK_MIN_LENGTH = 7600;
|
|
23
|
+
const GENERATED_TRACK_MAX_LENGTH = 14500;
|
|
24
|
+
const GENERATED_TRACK_ATTEMPTS = 320;
|
|
25
|
+
const GENERATED_CONTROL_COUNT = 20;
|
|
26
|
+
const TRACK_BOUNDARY_PADDING = 520;
|
|
27
|
+
const MIN_TRACK_CLEARANCE_MULTIPLIER = 1.55;
|
|
28
|
+
const MAX_LOCAL_TURN_RADIANS = 1.5;
|
|
29
|
+
const START_STRAIGHT_GRID_LENGTH = 760;
|
|
30
|
+
const START_STRAIGHT_EXIT_LENGTH = 260;
|
|
31
|
+
const NEAREST_HINT_WINDOW_SAMPLES = 240;
|
|
32
|
+
const PROCEDURAL_TRACK_TEMPLATES = [
|
|
33
|
+
[
|
|
34
|
+
[0.08, 0.55], [0.10, 0.80], [0.22, 0.89], [0.42, 0.84], [0.52, 0.93],
|
|
35
|
+
[0.60, 0.75], [0.74, 0.88], [0.91, 0.76], [0.94, 0.54], [0.82, 0.46],
|
|
36
|
+
[0.94, 0.28], [0.78, 0.18], [0.62, 0.31], [0.54, 0.13], [0.43, 0.30],
|
|
37
|
+
[0.33, 0.17], [0.20, 0.24], [0.12, 0.38], [0.22, 0.48], [0.13, 0.50],
|
|
38
|
+
],
|
|
39
|
+
[
|
|
40
|
+
[0.07, 0.46], [0.14, 0.72], [0.25, 0.83], [0.39, 0.73], [0.47, 0.88],
|
|
41
|
+
[0.56, 0.70], [0.67, 0.82], [0.88, 0.84], [0.95, 0.63], [0.83, 0.56],
|
|
42
|
+
[0.92, 0.43], [0.79, 0.35], [0.88, 0.20], [0.68, 0.15], [0.58, 0.29],
|
|
43
|
+
[0.47, 0.18], [0.34, 0.30], [0.21, 0.19], [0.10, 0.28], [0.17, 0.39],
|
|
44
|
+
],
|
|
45
|
+
[
|
|
46
|
+
[0.06, 0.61], [0.13, 0.86], [0.31, 0.91], [0.43, 0.79], [0.57, 0.87],
|
|
47
|
+
[0.71, 0.70], [0.92, 0.72], [0.95, 0.50], [0.84, 0.43], [0.91, 0.32],
|
|
48
|
+
[0.74, 0.24], [0.69, 0.11], [0.54, 0.18], [0.45, 0.08], [0.35, 0.22],
|
|
49
|
+
[0.23, 0.17], [0.11, 0.31], [0.24, 0.43], [0.15, 0.52], [0.28, 0.60],
|
|
50
|
+
],
|
|
51
|
+
];
|
|
52
|
+
const PROCEDURAL_TRACK_CACHE = new Map();
|
|
53
|
+
const TRACK_MODEL_CACHE = new WeakMap();
|
|
54
|
+
|
|
55
|
+
const CENTERLINE_CONTROLS = [
|
|
56
|
+
{ x: WORLD.width * 0.05, y: WORLD.height * 0.56 },
|
|
57
|
+
{ x: WORLD.width * 0.10, y: WORLD.height * 0.81 },
|
|
58
|
+
{ x: WORLD.width * 0.23, y: WORLD.height * 0.91 },
|
|
59
|
+
{ x: WORLD.width * 0.35, y: WORLD.height * 0.80 },
|
|
60
|
+
{ x: WORLD.width * 0.48, y: WORLD.height * 0.90 },
|
|
61
|
+
{ x: WORLD.width * 0.59, y: WORLD.height * 0.75 },
|
|
62
|
+
{ x: WORLD.width * 0.71, y: WORLD.height * 0.87 },
|
|
63
|
+
{ x: WORLD.width * 0.82, y: WORLD.height * 0.72 },
|
|
64
|
+
{ x: WORLD.width * 0.94, y: WORLD.height * 0.66 },
|
|
65
|
+
{ x: WORLD.width * 0.96, y: WORLD.height * 0.47 },
|
|
66
|
+
{ x: WORLD.width * 0.88, y: WORLD.height * 0.33 },
|
|
67
|
+
{ x: WORLD.width * 0.74, y: WORLD.height * 0.31 },
|
|
68
|
+
{ x: WORLD.width * 0.64, y: WORLD.height * 0.18 },
|
|
69
|
+
{ x: WORLD.width * 0.54, y: WORLD.height * 0.31 },
|
|
70
|
+
{ x: WORLD.width * 0.43, y: WORLD.height * 0.13 },
|
|
71
|
+
{ x: WORLD.width * 0.29, y: WORLD.height * 0.18 },
|
|
72
|
+
{ x: WORLD.width * 0.17, y: WORLD.height * 0.31 },
|
|
73
|
+
{ x: WORLD.width * 0.08, y: WORLD.height * 0.43 },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
function catmullRom(p0, p1, p2, p3, t) {
|
|
77
|
+
const t2 = t * t;
|
|
78
|
+
const t3 = t2 * t;
|
|
79
|
+
return {
|
|
80
|
+
x: 0.5 * (
|
|
81
|
+
(2 * p1.x) +
|
|
82
|
+
(-p0.x + p2.x) * t +
|
|
83
|
+
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
|
|
84
|
+
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3
|
|
85
|
+
),
|
|
86
|
+
y: 0.5 * (
|
|
87
|
+
(2 * p1.y) +
|
|
88
|
+
(-p0.y + p2.y) * t +
|
|
89
|
+
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
|
|
90
|
+
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3
|
|
91
|
+
),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeSeed(seed) {
|
|
96
|
+
if (Number.isFinite(seed)) return seed >>> 0;
|
|
97
|
+
let hash = 2166136261;
|
|
98
|
+
String(seed ?? 'f1-track').split('').forEach((character) => {
|
|
99
|
+
hash ^= character.charCodeAt(0);
|
|
100
|
+
hash = Math.imul(hash, 16777619);
|
|
101
|
+
});
|
|
102
|
+
return hash >>> 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function rawCenterPoint(ratio, controls = CENTERLINE_CONTROLS) {
|
|
106
|
+
const count = controls.length;
|
|
107
|
+
const scaled = ratio * count;
|
|
108
|
+
const index = Math.floor(scaled) % count;
|
|
109
|
+
const localT = scaled - Math.floor(scaled);
|
|
110
|
+
const p0 = controls[(index - 1 + count) % count];
|
|
111
|
+
const p1 = controls[index];
|
|
112
|
+
const p2 = controls[(index + 1) % count];
|
|
113
|
+
const p3 = controls[(index + 2) % count];
|
|
114
|
+
return catmullRom(p0, p1, p2, p3, localT);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function distance(a, b) {
|
|
118
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function orientation(a, b, c) {
|
|
122
|
+
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function segmentBoxesOverlap(a, b, c, d) {
|
|
126
|
+
return (
|
|
127
|
+
Math.max(Math.min(a.x, b.x), Math.min(c.x, d.x)) <= Math.min(Math.max(a.x, b.x), Math.max(c.x, d.x)) &&
|
|
128
|
+
Math.max(Math.min(a.y, b.y), Math.min(c.y, d.y)) <= Math.min(Math.max(a.y, b.y), Math.max(c.y, d.y))
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function segmentsIntersect(a, b, c, d) {
|
|
133
|
+
if (!segmentBoxesOverlap(a, b, c, d)) return false;
|
|
134
|
+
const abC = orientation(a, b, c);
|
|
135
|
+
const abD = orientation(a, b, d);
|
|
136
|
+
const cdA = orientation(c, d, a);
|
|
137
|
+
const cdB = orientation(c, d, b);
|
|
138
|
+
return abC * abD < 0 && cdA * cdB < 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function hasSelfIntersections(samples) {
|
|
142
|
+
const points = samples.slice(0, -1).filter((_, index) => index % 12 === 0);
|
|
143
|
+
for (let first = 0; first < points.length - 1; first += 1) {
|
|
144
|
+
for (let second = first + 2; second < points.length - 1; second += 1) {
|
|
145
|
+
const sharesLoopClosure = first === 0 && second >= points.length - 3;
|
|
146
|
+
if (sharesLoopClosure) continue;
|
|
147
|
+
if (segmentsIntersect(points[first], points[first + 1], points[second], points[second + 1])) return true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function samplesStayInsideWorld(samples) {
|
|
154
|
+
return samples.every((sample) => (
|
|
155
|
+
sample.x >= TRACK_BOUNDARY_PADDING &&
|
|
156
|
+
sample.x <= WORLD.width - TRACK_BOUNDARY_PADDING &&
|
|
157
|
+
sample.y >= TRACK_BOUNDARY_PADDING &&
|
|
158
|
+
sample.y <= WORLD.height - TRACK_BOUNDARY_PADDING
|
|
159
|
+
));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function hasEnoughTrackClearance(samples, totalLength, minimumClearance) {
|
|
163
|
+
const points = samples.slice(0, -1).filter((_, index) => index % 24 === 0);
|
|
164
|
+
const minimumClearanceSquared = minimumClearance * minimumClearance;
|
|
165
|
+
for (let first = 0; first < points.length; first += 1) {
|
|
166
|
+
for (let second = first + 1; second < points.length; second += 1) {
|
|
167
|
+
const arcDistance = Math.abs(points[second].distance - points[first].distance);
|
|
168
|
+
const loopDistance = Math.min(arcDistance, totalLength - arcDistance);
|
|
169
|
+
if (loopDistance < 700) continue;
|
|
170
|
+
const dx = points[first].x - points[second].x;
|
|
171
|
+
const dy = points[first].y - points[second].y;
|
|
172
|
+
if (dx * dx + dy * dy < minimumClearanceSquared) return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function hasReasonableTurnSharpness(samples) {
|
|
179
|
+
const usableSamples = samples.slice(0, -1);
|
|
180
|
+
const window = 30;
|
|
181
|
+
const step = 6;
|
|
182
|
+
|
|
183
|
+
for (let index = 0; index < usableSamples.length; index += step) {
|
|
184
|
+
let accumulatedTurn = 0;
|
|
185
|
+
for (let offset = 0; offset < window; offset += step) {
|
|
186
|
+
const current = usableSamples[(index + offset) % usableSamples.length];
|
|
187
|
+
const next = usableSamples[(index + offset + step) % usableSamples.length];
|
|
188
|
+
accumulatedTurn += Math.abs(normalizeAngle(next.heading - current.heading));
|
|
189
|
+
}
|
|
190
|
+
if (accumulatedTurn > MAX_LOCAL_TURN_RADIANS) return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function distanceForwardAlongTrack(from, to, totalLength) {
|
|
197
|
+
return to >= from ? to - from : totalLength - from + to;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function chooseStandingStartIndex(samples, totalLength) {
|
|
201
|
+
const usableSamples = samples.slice(0, -1);
|
|
202
|
+
let best = { index: 0, score: Infinity };
|
|
203
|
+
|
|
204
|
+
for (let index = 0; index < usableSamples.length; index += 1) {
|
|
205
|
+
const candidate = usableSamples[index];
|
|
206
|
+
let gridTurn = 0;
|
|
207
|
+
let gridCurvature = 0;
|
|
208
|
+
let gridCount = 0;
|
|
209
|
+
let exitTurn = 0;
|
|
210
|
+
let exitCurvature = 0;
|
|
211
|
+
let exitCount = 0;
|
|
212
|
+
|
|
213
|
+
for (let offset = 1; offset < usableSamples.length; offset += 1) {
|
|
214
|
+
const sample = usableSamples[(index - offset + usableSamples.length) % usableSamples.length];
|
|
215
|
+
const distanceToLine = distanceForwardAlongTrack(sample.distance, candidate.distance, totalLength);
|
|
216
|
+
if (distanceToLine > START_STRAIGHT_GRID_LENGTH) break;
|
|
217
|
+
gridTurn = Math.max(gridTurn, Math.abs(normalizeAngle(candidate.heading - sample.heading)));
|
|
218
|
+
gridCurvature += sample.curvature;
|
|
219
|
+
gridCount += 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (let offset = 1; offset < usableSamples.length; offset += 1) {
|
|
223
|
+
const sample = usableSamples[(index + offset) % usableSamples.length];
|
|
224
|
+
const distanceFromLine = distanceForwardAlongTrack(candidate.distance, sample.distance, totalLength);
|
|
225
|
+
if (distanceFromLine > START_STRAIGHT_EXIT_LENGTH) break;
|
|
226
|
+
exitTurn = Math.max(exitTurn, Math.abs(normalizeAngle(sample.heading - candidate.heading)));
|
|
227
|
+
exitCurvature += sample.curvature;
|
|
228
|
+
exitCount += 1;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const score =
|
|
232
|
+
gridTurn * 5 +
|
|
233
|
+
exitTurn * 1.2 +
|
|
234
|
+
(gridCurvature / Math.max(1, gridCount)) * 2600 +
|
|
235
|
+
(exitCurvature / Math.max(1, exitCount)) * 650;
|
|
236
|
+
|
|
237
|
+
if (score < best.score) best = { index, score };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return best.index;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function rotateSamplesToStandingStart(samples, totalLength) {
|
|
244
|
+
const usableSamples = samples.slice(0, -1);
|
|
245
|
+
const startIndex = chooseStandingStartIndex(samples, totalLength);
|
|
246
|
+
const startDistance = usableSamples[startIndex].distance;
|
|
247
|
+
|
|
248
|
+
const rotated = [
|
|
249
|
+
...usableSamples.slice(startIndex),
|
|
250
|
+
...usableSamples.slice(0, startIndex),
|
|
251
|
+
].map((sample) => ({
|
|
252
|
+
...sample,
|
|
253
|
+
distance: distanceForwardAlongTrack(startDistance, sample.distance, totalLength),
|
|
254
|
+
}));
|
|
255
|
+
|
|
256
|
+
return [
|
|
257
|
+
...rotated,
|
|
258
|
+
{
|
|
259
|
+
...rotated[0],
|
|
260
|
+
distance: totalLength,
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function makeTemplatePoint([x, y], random, index) {
|
|
266
|
+
const edgeDistance = Math.min(x, 1 - x, y, 1 - y);
|
|
267
|
+
const jitter = edgeDistance < 0.13 ? 0.014 : 0.03;
|
|
268
|
+
const chicaneNudge = index % 5 === 2 ? seededRange(random, -0.024, 0.024) : 0;
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
x: clamp(x + seededRange(random, -jitter, jitter) + chicaneNudge, 0.06, 0.94),
|
|
272
|
+
y: clamp(y + seededRange(random, -jitter, jitter) - chicaneNudge * 0.55, 0.08, 0.92),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function applySectorMorph(points, random) {
|
|
277
|
+
const center = { x: 0.5, y: 0.5 };
|
|
278
|
+
const horizontalBias = seededRange(random, -0.035, 0.035);
|
|
279
|
+
const verticalBias = seededRange(random, -0.035, 0.035);
|
|
280
|
+
const squeeze = seededRange(random, 0.94, 1.06);
|
|
281
|
+
const stretch = seededRange(random, 0.93, 1.07);
|
|
282
|
+
const layoutScale = seededRange(random, 0.76, 0.84);
|
|
283
|
+
|
|
284
|
+
return points.map((point, index) => {
|
|
285
|
+
const turnComplex = index % 4 === 1 ? seededRange(random, -0.024, 0.024) : 0;
|
|
286
|
+
return {
|
|
287
|
+
x: clamp(center.x + (point.x - center.x) * stretch * layoutScale + horizontalBias + turnComplex, 0.06, 0.94),
|
|
288
|
+
y: clamp(center.y + (point.y - center.y) * squeeze * layoutScale + verticalBias - turnComplex * 0.4, 0.08, 0.92),
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function normalizedToWorld(point) {
|
|
294
|
+
const usableWidth = WORLD.width - TRACK_BOUNDARY_PADDING * 2;
|
|
295
|
+
const usableHeight = WORLD.height - TRACK_BOUNDARY_PADDING * 2;
|
|
296
|
+
return {
|
|
297
|
+
x: TRACK_BOUNDARY_PADDING + point.x * usableWidth,
|
|
298
|
+
y: TRACK_BOUNDARY_PADDING + point.y * usableHeight,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function rotateControls(controls, random) {
|
|
303
|
+
const rotation = Math.floor(seededRange(random, 0, controls.length));
|
|
304
|
+
return [...controls.slice(rotation), ...controls.slice(0, rotation)];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function generateCenterlineControls(seed) {
|
|
308
|
+
const random = createMulberry32(seed);
|
|
309
|
+
const template = PROCEDURAL_TRACK_TEMPLATES[Math.floor(seededRange(random, 0, PROCEDURAL_TRACK_TEMPLATES.length))]
|
|
310
|
+
?? PROCEDURAL_TRACK_TEMPLATES[0];
|
|
311
|
+
const normalized = applySectorMorph(
|
|
312
|
+
template.map((point, index) => makeTemplatePoint(point, random, index)),
|
|
313
|
+
random,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
return rotateControls(normalized.map(normalizedToWorld), random);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function generateFallbackCenterlineControls(seed) {
|
|
320
|
+
const random = createMulberry32(seed ^ 0xa5a5a5a5);
|
|
321
|
+
const center = { x: WORLD.width / 2, y: WORLD.height / 2 };
|
|
322
|
+
const phaseA = seededRange(random, 0, TWO_PI);
|
|
323
|
+
const phaseB = seededRange(random, 0, TWO_PI);
|
|
324
|
+
const angleStep = TWO_PI / GENERATED_CONTROL_COUNT;
|
|
325
|
+
|
|
326
|
+
return Array.from({ length: GENERATED_CONTROL_COUNT }, (_, index) => {
|
|
327
|
+
const angle = Math.PI + index * angleStep;
|
|
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);
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
x: center.x + Math.cos(angle) * rx,
|
|
333
|
+
y: center.y + Math.sin(angle) * ry,
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function normalizeDrsZone(zone, totalLength) {
|
|
339
|
+
if (Number.isFinite(zone.start) && Number.isFinite(zone.end)) return zone;
|
|
340
|
+
const start = zone.startRatio * totalLength;
|
|
341
|
+
const rawEnd = zone.endRatio * totalLength;
|
|
342
|
+
return {
|
|
343
|
+
...zone,
|
|
344
|
+
start,
|
|
345
|
+
end: rawEnd >= start ? rawEnd : rawEnd + totalLength,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function scoreStraightWindow(samples, startIndex, windowSize) {
|
|
350
|
+
let curvature = 0;
|
|
351
|
+
for (let offset = 0; offset < windowSize; offset += 1) {
|
|
352
|
+
curvature += samples[(startIndex + offset) % (samples.length - 1)].curvature;
|
|
353
|
+
}
|
|
354
|
+
return curvature / windowSize;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function deriveDrsZones(samples, totalLength) {
|
|
358
|
+
const usableSampleCount = samples.length - 1;
|
|
359
|
+
const windowSize = Math.max(96, Math.floor(usableSampleCount * 0.07));
|
|
360
|
+
const candidates = [];
|
|
361
|
+
|
|
362
|
+
for (let index = 0; index < usableSampleCount; index += Math.floor(windowSize / 2)) {
|
|
363
|
+
const start = samples[index];
|
|
364
|
+
const end = samples[(index + windowSize) % usableSampleCount];
|
|
365
|
+
const distance = end.distance >= start.distance
|
|
366
|
+
? end.distance - start.distance
|
|
367
|
+
: totalLength - start.distance + end.distance;
|
|
368
|
+
if (distance < 360) continue;
|
|
369
|
+
candidates.push({
|
|
370
|
+
startRatio: start.distance / totalLength,
|
|
371
|
+
endRatio: (start.distance + Math.min(distance, totalLength * 0.16)) / totalLength,
|
|
372
|
+
score: scoreStraightWindow(samples, index, windowSize),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return candidates
|
|
377
|
+
.sort((a, b) => a.score - b.score)
|
|
378
|
+
.reduce((zones, candidate) => {
|
|
379
|
+
const farEnough = zones.every((zone) => Math.abs(zone.startRatio - candidate.startRatio) > 0.18);
|
|
380
|
+
if (farEnough && zones.length < 3) {
|
|
381
|
+
zones.push({
|
|
382
|
+
id: `generated-drs-${zones.length + 1}`,
|
|
383
|
+
startRatio: candidate.startRatio % 1,
|
|
384
|
+
endRatio: candidate.endRatio % 1,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
return zones;
|
|
388
|
+
}, [])
|
|
389
|
+
.sort((a, b) => a.startRatio - b.startRatio);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function buildTrackModel(track = TRACK) {
|
|
393
|
+
const cached = TRACK_MODEL_CACHE.get(track);
|
|
394
|
+
if (cached) return cached;
|
|
395
|
+
|
|
396
|
+
const controls = track.centerlineControls ?? CENTERLINE_CONTROLS;
|
|
397
|
+
const base = [];
|
|
398
|
+
for (let index = 0; index <= track.sampleCount; index += 1) {
|
|
399
|
+
base.push(rawCenterPoint(index / track.sampleCount, controls));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let totalLength = 0;
|
|
403
|
+
const samples = base.map((point, index) => {
|
|
404
|
+
if (index > 0) totalLength += distance(base[index - 1], point);
|
|
405
|
+
return { ...point, distance: totalLength, heading: 0, normalX: 0, normalY: 0, curvature: 0 };
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
samples.forEach((sample, index) => {
|
|
409
|
+
const previous = samples[(index - 1 + samples.length - 1) % (samples.length - 1)];
|
|
410
|
+
const next = samples[(index + 1) % (samples.length - 1)];
|
|
411
|
+
const heading = Math.atan2(next.y - previous.y, next.x - previous.x);
|
|
412
|
+
const nextHeading = Math.atan2(
|
|
413
|
+
samples[(index + 2) % (samples.length - 1)].y - sample.y,
|
|
414
|
+
samples[(index + 2) % (samples.length - 1)].x - sample.x,
|
|
415
|
+
);
|
|
416
|
+
sample.heading = heading;
|
|
417
|
+
sample.normalX = -Math.sin(heading);
|
|
418
|
+
sample.normalY = Math.cos(heading);
|
|
419
|
+
sample.curvature = Math.abs(normalizeAngle(nextHeading - heading)) / 28;
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const normalizedSamples = rotateSamplesToStandingStart(samples, totalLength);
|
|
423
|
+
|
|
424
|
+
const model = {
|
|
425
|
+
...track,
|
|
426
|
+
centerlineControls: controls,
|
|
427
|
+
length: totalLength,
|
|
428
|
+
samples: normalizedSamples,
|
|
429
|
+
drsZones: (track.drsZones ?? deriveDrsZones(normalizedSamples, totalLength))
|
|
430
|
+
.map((zone) => normalizeDrsZone(zone, totalLength)),
|
|
431
|
+
};
|
|
432
|
+
TRACK_MODEL_CACHE.set(track, model);
|
|
433
|
+
return model;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function createProceduralTrack(seed = Date.now()) {
|
|
437
|
+
const normalizedSeed = normalizeSeed(seed);
|
|
438
|
+
const cached = PROCEDURAL_TRACK_CACHE.get(normalizedSeed);
|
|
439
|
+
if (cached) return cached;
|
|
440
|
+
|
|
441
|
+
for (let attempt = 0; attempt < GENERATED_TRACK_ATTEMPTS; attempt += 1) {
|
|
442
|
+
const candidateSeed = (normalizedSeed + Math.imul(attempt, 2654435761)) >>> 0;
|
|
443
|
+
const candidate = {
|
|
444
|
+
...TRACK,
|
|
445
|
+
name: `Generated GP ${candidateSeed.toString(36).toUpperCase().padStart(6, '0').slice(-6)}`,
|
|
446
|
+
seed: candidateSeed,
|
|
447
|
+
centerlineControls: generateCenterlineControls(candidateSeed),
|
|
448
|
+
drsZones: null,
|
|
449
|
+
};
|
|
450
|
+
const model = buildTrackModel(candidate);
|
|
451
|
+
const valid =
|
|
452
|
+
model.length >= GENERATED_TRACK_MIN_LENGTH &&
|
|
453
|
+
model.length <= GENERATED_TRACK_MAX_LENGTH &&
|
|
454
|
+
samplesStayInsideWorld(model.samples) &&
|
|
455
|
+
hasEnoughTrackClearance(model.samples, model.length, model.width * MIN_TRACK_CLEARANCE_MULTIPLIER) &&
|
|
456
|
+
hasReasonableTurnSharpness(model.samples) &&
|
|
457
|
+
!hasSelfIntersections(model.samples);
|
|
458
|
+
|
|
459
|
+
if (valid) {
|
|
460
|
+
const trackDefinition = {
|
|
461
|
+
...candidate,
|
|
462
|
+
drsZones: model.drsZones.map(({ id, startRatio, endRatio }) => ({ id, startRatio, endRatio })),
|
|
463
|
+
};
|
|
464
|
+
PROCEDURAL_TRACK_CACHE.set(normalizedSeed, trackDefinition);
|
|
465
|
+
return trackDefinition;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const fallback = {
|
|
470
|
+
...TRACK,
|
|
471
|
+
name: `Generated GP ${normalizedSeed.toString(36).toUpperCase().padStart(6, '0').slice(-6)}`,
|
|
472
|
+
seed: normalizedSeed,
|
|
473
|
+
centerlineControls: generateFallbackCenterlineControls(normalizedSeed),
|
|
474
|
+
drsZones: null,
|
|
475
|
+
};
|
|
476
|
+
PROCEDURAL_TRACK_CACHE.set(normalizedSeed, fallback);
|
|
477
|
+
return fallback;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function pointAt(track, distanceAlong) {
|
|
481
|
+
const wrapped = wrapDistance(distanceAlong, track.length);
|
|
482
|
+
const low = sampleIndexAtDistance(track, wrapped);
|
|
483
|
+
|
|
484
|
+
const next = track.samples[low] ?? track.samples[0];
|
|
485
|
+
const previous = track.samples[Math.max(0, low - 1)] ?? next;
|
|
486
|
+
const span = Math.max(1, next.distance - previous.distance);
|
|
487
|
+
const amount = clamp((wrapped - previous.distance) / span, 0, 1);
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
x: previous.x + (next.x - previous.x) * amount,
|
|
491
|
+
y: previous.y + (next.y - previous.y) * amount,
|
|
492
|
+
heading: previous.heading + normalizeAngle(next.heading - previous.heading) * amount,
|
|
493
|
+
normalX: previous.normalX + (next.normalX - previous.normalX) * amount,
|
|
494
|
+
normalY: previous.normalY + (next.normalY - previous.normalY) * amount,
|
|
495
|
+
curvature: previous.curvature + (next.curvature - previous.curvature) * amount,
|
|
496
|
+
distance: wrapped,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function sampleIndexAtDistance(track, distanceAlong) {
|
|
501
|
+
const wrapped = wrapDistance(distanceAlong, track.length);
|
|
502
|
+
let low = 0;
|
|
503
|
+
let high = track.samples.length - 1;
|
|
504
|
+
|
|
505
|
+
while (low < high) {
|
|
506
|
+
const mid = Math.floor((low + high) / 2);
|
|
507
|
+
if (track.samples[mid].distance < wrapped) low = mid + 1;
|
|
508
|
+
else high = mid;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return low;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function nearestSampleInRange(track, position, startIndex, endIndex) {
|
|
515
|
+
const sampleCount = track.samples.length - 1;
|
|
516
|
+
let best = null;
|
|
517
|
+
let bestDistance = Infinity;
|
|
518
|
+
|
|
519
|
+
for (let index = startIndex; index <= endIndex; index += 1) {
|
|
520
|
+
const wrappedIndex = ((index % sampleCount) + sampleCount) % sampleCount;
|
|
521
|
+
const sample = track.samples[wrappedIndex];
|
|
522
|
+
const dx = position.x - sample.x;
|
|
523
|
+
const dy = position.y - sample.y;
|
|
524
|
+
const squared = dx * dx + dy * dy;
|
|
525
|
+
if (squared < bestDistance) {
|
|
526
|
+
bestDistance = squared;
|
|
527
|
+
best = sample;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return { best, bestDistance };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function nearestSampleGlobal(track, position) {
|
|
535
|
+
return nearestSampleInRange(track, position, 0, track.samples.length - 2);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function createTrackState(track, position, best) {
|
|
539
|
+
const dx = position.x - best.x;
|
|
540
|
+
const dy = position.y - best.y;
|
|
541
|
+
const signedOffset = dx * best.normalX + dy * best.normalY;
|
|
542
|
+
const crossTrackError = Math.abs(signedOffset);
|
|
543
|
+
const trackEdge = track.width / 2;
|
|
544
|
+
const kerbEdge = trackEdge + (track.kerbWidth ?? 0);
|
|
545
|
+
const gravelEdge = kerbEdge + track.gravelWidth;
|
|
546
|
+
const runoffEdge = gravelEdge + track.runoffWidth;
|
|
547
|
+
const surface = crossTrackError <= trackEdge
|
|
548
|
+
? 'track'
|
|
549
|
+
: crossTrackError <= kerbEdge
|
|
550
|
+
? 'kerb'
|
|
551
|
+
: crossTrackError <= gravelEdge
|
|
552
|
+
? 'gravel'
|
|
553
|
+
: crossTrackError <= runoffEdge
|
|
554
|
+
? 'grass'
|
|
555
|
+
: 'barrier';
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
...best,
|
|
559
|
+
signedOffset,
|
|
560
|
+
crossTrackError,
|
|
561
|
+
surface,
|
|
562
|
+
onTrack: surface === 'track' || surface === 'kerb',
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function nearestTrackState(track, position, progressHint = null) {
|
|
567
|
+
let nearest = null;
|
|
568
|
+
|
|
569
|
+
if (Number.isFinite(progressHint)) {
|
|
570
|
+
const centerIndex = sampleIndexAtDistance(track, progressHint);
|
|
571
|
+
nearest = nearestSampleInRange(
|
|
572
|
+
track,
|
|
573
|
+
position,
|
|
574
|
+
centerIndex - NEAREST_HINT_WINDOW_SAMPLES,
|
|
575
|
+
centerIndex + NEAREST_HINT_WINDOW_SAMPLES,
|
|
576
|
+
);
|
|
577
|
+
const fallbackDistance = track.width / 2 + (track.kerbWidth ?? 0) + track.gravelWidth + track.runoffWidth + 180;
|
|
578
|
+
if (!nearest.best || nearest.bestDistance > fallbackDistance * fallbackDistance) {
|
|
579
|
+
nearest = null;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const best = nearest?.best ?? nearestSampleGlobal(track, position).best;
|
|
584
|
+
return createTrackState(track, position, best);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function offsetTrackPoint(point, offset) {
|
|
588
|
+
return {
|
|
589
|
+
x: point.x + point.normalX * offset,
|
|
590
|
+
y: point.y + point.normalY * offset,
|
|
591
|
+
heading: point.heading,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export function isInDrsZone(track, progress) {
|
|
596
|
+
const wrapped = wrapDistance(progress, track.length);
|
|
597
|
+
return track.drsZones.some((zone) => {
|
|
598
|
+
const start = wrapDistance(zone.start, track.length);
|
|
599
|
+
const end = wrapDistance(zone.end, track.length);
|
|
600
|
+
if (zone.end - zone.start >= track.length) return true;
|
|
601
|
+
return end >= start
|
|
602
|
+
? wrapped >= start && wrapped <= end
|
|
603
|
+
: wrapped >= start || wrapped <= end;
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export { normalizeAngle };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { VEHICLE_LIMITS } from './vehiclePhysics.js';
|
|
2
|
+
|
|
3
|
+
export const REAL_F1_CAR_LENGTH_METERS = 5.63;
|
|
4
|
+
export const TARGET_F1_TOP_SPEED_KPH = 330;
|
|
5
|
+
export const SIM_UNITS_PER_METER = (VEHICLE_LIMITS.maxSpeed * 3.6) / TARGET_F1_TOP_SPEED_KPH;
|
|
6
|
+
export const VISUAL_CAR_LENGTH_METERS = VEHICLE_LIMITS.carLength / SIM_UNITS_PER_METER;
|
|
7
|
+
|
|
8
|
+
export function metersToSimUnits(meters) {
|
|
9
|
+
return meters * SIM_UNITS_PER_METER;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function simUnitsToMeters(simUnits) {
|
|
13
|
+
return simUnits / SIM_UNITS_PER_METER;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function simSpeedToMetersPerSecond(simUnitsPerSecond) {
|
|
17
|
+
return simUnitsToMeters(simUnitsPerSecond);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function metersPerSecondToSimSpeed(metersPerSecond) {
|
|
21
|
+
return metersToSimUnits(metersPerSecond);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function simSpeedToKph(simUnitsPerSecond) {
|
|
25
|
+
return simSpeedToMetersPerSecond(simUnitsPerSecond) * 3.6;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function kphToSimSpeed(kph) {
|
|
29
|
+
return metersPerSecondToSimSpeed(kph / 3.6);
|
|
30
|
+
}
|