@inventure71/paddockjs 0.2.0 → 0.3.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 +6 -0
- package/INSTALL_AND_UPDATE.md +163 -0
- package/README.md +80 -16
- package/docs/architecture.md +41 -6
- package/docs/data_contract.md +241 -7
- package/docs/data_model.md +1 -0
- package/docs/index.md +1 -0
- package/docs/system_specs.md +43 -12
- package/docs/training.md +104 -0
- package/examples/train-basic-policy.mjs +277 -0
- package/package.json +14 -7
- package/src/api/PaddockSimulatorController.js +7 -2
- package/src/app/BrowserExpertAdapter.js +32 -0
- package/src/app/F1SimulatorApp.js +152 -20
- package/src/app/domBindings.js +0 -1
- package/src/config/defaultOptions.js +8 -0
- package/src/data/championship.js +13 -0
- package/src/data/normalizeDrivers.js +7 -2
- package/src/environment/actions.js +45 -0
- package/src/environment/episode.js +17 -0
- package/src/environment/events.js +17 -0
- package/src/environment/index.d.ts +241 -0
- package/src/environment/index.js +2 -0
- package/src/environment/observations.js +112 -0
- package/src/environment/options.js +150 -0
- package/src/environment/rewards.js +69 -0
- package/src/environment/runtime.js +140 -0
- package/src/environment/sensors.js +257 -0
- package/src/environment/specs.js +79 -0
- package/src/index.d.ts +40 -4
- package/src/index.js +6 -2
- package/src/rendering/proceduralTrackAsset.js +7 -1
- package/src/simulation/raceSimulation.js +9 -1
- package/src/styles.css +71 -27
- package/src/ui/componentTemplates.js +31 -7
- package/AGENTS.md +0 -86
- package/src/standalone.js +0 -15
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createPaddockEnvironment, createProgressReward } from '../src/environment/index.js';
|
|
4
|
+
import { CHAMPIONSHIP_ENTRY_BLUEPRINTS } from '../src/data/championship.js';
|
|
5
|
+
import { DEMO_PROJECT_DRIVERS } from '../src/data/demoDrivers.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_OPTIONS = Object.freeze({
|
|
8
|
+
generations: 4,
|
|
9
|
+
candidates: 5,
|
|
10
|
+
episodes: 1,
|
|
11
|
+
steps: 240,
|
|
12
|
+
frameSkip: 4,
|
|
13
|
+
seed: 71,
|
|
14
|
+
traffic: false,
|
|
15
|
+
evalOnly: false,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const INITIAL_POLICY = Object.freeze({
|
|
19
|
+
steeringHeading: -1.2,
|
|
20
|
+
steeringOffset: -0.35,
|
|
21
|
+
steeringRayBalance: 0.45,
|
|
22
|
+
throttleBias: 0.72,
|
|
23
|
+
throttleFrontClearance: 0.28,
|
|
24
|
+
throttleSpeedDamping: 0.18,
|
|
25
|
+
brakeBias: 0,
|
|
26
|
+
brakeFrontDanger: 0.65,
|
|
27
|
+
brakeOffTrack: 0.2,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const POLICY_KEYS = Object.keys(INITIAL_POLICY);
|
|
31
|
+
|
|
32
|
+
main();
|
|
33
|
+
|
|
34
|
+
function main() {
|
|
35
|
+
const options = parseCliArgs(process.argv.slice(2));
|
|
36
|
+
if (options.help) {
|
|
37
|
+
printHelp();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const driverId = DEMO_PROJECT_DRIVERS[0].id;
|
|
42
|
+
const rng = createSeededRandom(options.seed);
|
|
43
|
+
const reward = createProgressReward();
|
|
44
|
+
const evaluate = (policy, seedOffset = 0) => evaluatePolicy({
|
|
45
|
+
policy,
|
|
46
|
+
driverId,
|
|
47
|
+
reward,
|
|
48
|
+
options,
|
|
49
|
+
seedOffset,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (options.evalOnly) {
|
|
53
|
+
const baseline = evaluate(INITIAL_POLICY);
|
|
54
|
+
printSummary({ options, driverId, bestPolicy: INITIAL_POLICY, best: baseline });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let bestPolicy = { ...INITIAL_POLICY };
|
|
59
|
+
let best = evaluate(bestPolicy);
|
|
60
|
+
|
|
61
|
+
console.log(formatGenerationLine(0, best));
|
|
62
|
+
|
|
63
|
+
for (let generation = 1; generation <= options.generations; generation += 1) {
|
|
64
|
+
const mutationScale = 0.45 / Math.sqrt(generation);
|
|
65
|
+
const candidates = [{ policy: bestPolicy, scoreSeedOffset: generation * 1000 }];
|
|
66
|
+
|
|
67
|
+
for (let index = 1; index < options.candidates; index += 1) {
|
|
68
|
+
candidates.push({
|
|
69
|
+
policy: mutatePolicy(bestPolicy, rng, mutationScale),
|
|
70
|
+
scoreSeedOffset: generation * 1000 + index * 100,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const candidate of candidates) {
|
|
75
|
+
const score = evaluate(candidate.policy, candidate.scoreSeedOffset);
|
|
76
|
+
if (score.averageReward > best.averageReward) {
|
|
77
|
+
best = score;
|
|
78
|
+
bestPolicy = candidate.policy;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(formatGenerationLine(generation, best));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
printSummary({ options, driverId, bestPolicy, best });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function evaluatePolicy({ policy, driverId, reward, options, seedOffset = 0 }) {
|
|
89
|
+
let totalReward = 0;
|
|
90
|
+
let totalDistanceMeters = 0;
|
|
91
|
+
let totalCollisions = 0;
|
|
92
|
+
let totalOffTrackSteps = 0;
|
|
93
|
+
let completedEpisodes = 0;
|
|
94
|
+
|
|
95
|
+
for (let episode = 0; episode < options.episodes; episode += 1) {
|
|
96
|
+
const env = createTrainingEnvironment({ driverId, reward, options, episode, seedOffset });
|
|
97
|
+
let result = env.reset();
|
|
98
|
+
let episodeReward = 0;
|
|
99
|
+
|
|
100
|
+
for (let step = 0; step < options.steps && !result.done; step += 1) {
|
|
101
|
+
const observation = result.observation[driverId];
|
|
102
|
+
const action = chooseAction(policy, observation);
|
|
103
|
+
result = env.step({ [driverId]: action });
|
|
104
|
+
episodeReward += result.reward?.[driverId] ?? 0;
|
|
105
|
+
totalCollisions += result.events.filter((event) => event.type === 'collision').length;
|
|
106
|
+
if (result.observation[driverId]?.object?.self?.onTrack === false) totalOffTrackSteps += 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const car = result.state.snapshot.cars.find((entry) => entry.id === driverId);
|
|
110
|
+
totalReward += episodeReward;
|
|
111
|
+
totalDistanceMeters += Number(car?.distanceMeters ?? 0);
|
|
112
|
+
completedEpisodes += 1;
|
|
113
|
+
env.destroy();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
averageReward: totalReward / Math.max(1, completedEpisodes),
|
|
118
|
+
averageDistanceMeters: totalDistanceMeters / Math.max(1, completedEpisodes),
|
|
119
|
+
collisions: totalCollisions,
|
|
120
|
+
offTrackSteps: totalOffTrackSteps,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function createTrainingEnvironment({ driverId, reward, options, episode, seedOffset }) {
|
|
125
|
+
const drivers = options.traffic ? DEMO_PROJECT_DRIVERS.slice(0, 8) : DEMO_PROJECT_DRIVERS.slice(0, 1);
|
|
126
|
+
return createPaddockEnvironment({
|
|
127
|
+
drivers,
|
|
128
|
+
entries: CHAMPIONSHIP_ENTRY_BLUEPRINTS,
|
|
129
|
+
controlledDrivers: [driverId],
|
|
130
|
+
seed: options.seed + seedOffset + episode,
|
|
131
|
+
trackSeed: options.seed + 2026 + seedOffset + episode,
|
|
132
|
+
totalLaps: 3,
|
|
133
|
+
frameSkip: options.frameSkip,
|
|
134
|
+
scenario: {
|
|
135
|
+
participants: options.traffic ? 'all' : 'controlled-only',
|
|
136
|
+
nonControlled: 'ai',
|
|
137
|
+
},
|
|
138
|
+
episode: {
|
|
139
|
+
maxSteps: options.steps,
|
|
140
|
+
endOnRaceFinish: true,
|
|
141
|
+
},
|
|
142
|
+
rules: {
|
|
143
|
+
standingStart: false,
|
|
144
|
+
},
|
|
145
|
+
reward,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function chooseAction(policy, observation) {
|
|
150
|
+
const self = observation.object.self;
|
|
151
|
+
const frontRay = getRayDistance(observation, 0);
|
|
152
|
+
const leftRay = getRayDistance(observation, -60);
|
|
153
|
+
const rightRay = getRayDistance(observation, 60);
|
|
154
|
+
const frontDanger = clamp01((35 - frontRay) / 35);
|
|
155
|
+
const rayBalanceMeters = rightRay - leftRay;
|
|
156
|
+
|
|
157
|
+
const steering = clamp(
|
|
158
|
+
policy.steeringHeading * self.trackHeadingErrorRadians +
|
|
159
|
+
policy.steeringOffset * (self.trackOffsetMeters / 12) +
|
|
160
|
+
policy.steeringRayBalance * (rayBalanceMeters / 120),
|
|
161
|
+
-1,
|
|
162
|
+
1,
|
|
163
|
+
);
|
|
164
|
+
const throttle = clamp01(
|
|
165
|
+
policy.throttleBias +
|
|
166
|
+
policy.throttleFrontClearance * (frontRay / 120) -
|
|
167
|
+
policy.throttleSpeedDamping * (self.speedKph / 320) -
|
|
168
|
+
(self.onTrack ? 0 : 0.35),
|
|
169
|
+
);
|
|
170
|
+
const brake = clamp01(
|
|
171
|
+
policy.brakeBias +
|
|
172
|
+
policy.brakeFrontDanger * frontDanger +
|
|
173
|
+
(self.onTrack ? 0 : policy.brakeOffTrack),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
return { steering, throttle, brake };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getRayDistance(observation, angleDegrees) {
|
|
180
|
+
const ray = observation.object.rays.find((entry) => entry.angleDegrees === angleDegrees);
|
|
181
|
+
return Number(ray?.track?.distanceMeters ?? ray?.lengthMeters ?? 120);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function mutatePolicy(policy, rng, scale) {
|
|
185
|
+
return Object.fromEntries(POLICY_KEYS.map((key) => [
|
|
186
|
+
key,
|
|
187
|
+
policy[key] + randomNormal(rng) * scale,
|
|
188
|
+
]));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseCliArgs(argv) {
|
|
192
|
+
const options = { ...DEFAULT_OPTIONS };
|
|
193
|
+
|
|
194
|
+
for (const arg of argv) {
|
|
195
|
+
const [name, rawValue] = arg.split('=');
|
|
196
|
+
if (name === '--help' || name === '-h') options.help = true;
|
|
197
|
+
else if (name === '--eval-only') options.evalOnly = true;
|
|
198
|
+
else if (name === '--traffic') options.traffic = true;
|
|
199
|
+
else if (name === '--no-traffic') options.traffic = false;
|
|
200
|
+
else if (name === '--generations') options.generations = parsePositiveInt(rawValue, name);
|
|
201
|
+
else if (name === '--candidates') options.candidates = parsePositiveInt(rawValue, name);
|
|
202
|
+
else if (name === '--episodes') options.episodes = parsePositiveInt(rawValue, name);
|
|
203
|
+
else if (name === '--steps') options.steps = parsePositiveInt(rawValue, name);
|
|
204
|
+
else if (name === '--frame-skip') options.frameSkip = parsePositiveInt(rawValue, name);
|
|
205
|
+
else if (name === '--seed') options.seed = parsePositiveInt(rawValue, name);
|
|
206
|
+
else throw new Error(`Unknown option: ${name}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return options;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parsePositiveInt(value, optionName) {
|
|
213
|
+
const parsed = Number.parseInt(value, 10);
|
|
214
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
215
|
+
throw new Error(`${optionName} requires a positive integer value.`);
|
|
216
|
+
}
|
|
217
|
+
return parsed;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function createSeededRandom(seed) {
|
|
221
|
+
let state = seed >>> 0;
|
|
222
|
+
return function random() {
|
|
223
|
+
state += 0x6D2B79F5;
|
|
224
|
+
let value = state;
|
|
225
|
+
value = Math.imul(value ^ (value >>> 15), value | 1);
|
|
226
|
+
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
|
227
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function randomNormal(rng) {
|
|
232
|
+
const a = Math.max(Number.EPSILON, rng());
|
|
233
|
+
const b = Math.max(Number.EPSILON, rng());
|
|
234
|
+
return Math.sqrt(-2 * Math.log(a)) * Math.cos(2 * Math.PI * b);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function clamp(value, min, max) {
|
|
238
|
+
return Math.min(max, Math.max(min, value));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function clamp01(value) {
|
|
242
|
+
return clamp(value, 0, 1);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function formatGenerationLine(generation, score) {
|
|
246
|
+
return [
|
|
247
|
+
`generation=${generation}`,
|
|
248
|
+
`reward=${score.averageReward.toFixed(2)}`,
|
|
249
|
+
`distanceMeters=${score.averageDistanceMeters.toFixed(1)}`,
|
|
250
|
+
`collisions=${score.collisions}`,
|
|
251
|
+
`offTrackSteps=${score.offTrackSteps}`,
|
|
252
|
+
].join(' ');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function printSummary({ options, driverId, bestPolicy, best }) {
|
|
256
|
+
console.log(JSON.stringify({
|
|
257
|
+
driverId,
|
|
258
|
+
options,
|
|
259
|
+
best,
|
|
260
|
+
bestPolicy,
|
|
261
|
+
}, null, 2));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function printHelp() {
|
|
265
|
+
console.log(`Usage: node examples/train-basic-policy.mjs [options]
|
|
266
|
+
|
|
267
|
+
Options:
|
|
268
|
+
--generations=N Random-search generations. Default: ${DEFAULT_OPTIONS.generations}
|
|
269
|
+
--candidates=N Candidate policies per generation. Default: ${DEFAULT_OPTIONS.candidates}
|
|
270
|
+
--episodes=N Episodes per policy evaluation. Default: ${DEFAULT_OPTIONS.episodes}
|
|
271
|
+
--steps=N Maximum manual environment steps per episode. Default: ${DEFAULT_OPTIONS.steps}
|
|
272
|
+
--frame-skip=N Simulator frames advanced by each step(action). Default: ${DEFAULT_OPTIONS.frameSkip}
|
|
273
|
+
--seed=N Deterministic random seed. Default: ${DEFAULT_OPTIONS.seed}
|
|
274
|
+
--traffic Keep built-in AI traffic on track during training.
|
|
275
|
+
--eval-only Evaluate the starter policy without training.
|
|
276
|
+
`);
|
|
277
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inventure71/paddockjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "A data-driven browser F1 simulator component for
|
|
5
|
+
"description": "A data-driven browser F1 simulator component for host websites.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"author": "Matteo Giorgetti",
|
|
8
8
|
"homepage": "https://github.com/Inventure71/PaddockJS#readme",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
32
|
"engines": {
|
|
33
|
-
"node": ">=20"
|
|
33
|
+
"node": ">=20.19.0"
|
|
34
34
|
},
|
|
35
35
|
"main": "src/index.js",
|
|
36
36
|
"types": "src/index.d.ts",
|
|
@@ -40,24 +40,30 @@
|
|
|
40
40
|
"import": "./src/index.js",
|
|
41
41
|
"default": "./src/index.js"
|
|
42
42
|
},
|
|
43
|
+
"./environment": {
|
|
44
|
+
"types": "./src/environment/index.d.ts",
|
|
45
|
+
"import": "./src/environment/index.js",
|
|
46
|
+
"default": "./src/environment/index.js"
|
|
47
|
+
},
|
|
43
48
|
"./styles.css": "./src/styles.css"
|
|
44
49
|
},
|
|
45
50
|
"files": [
|
|
46
|
-
"AGENTS.md",
|
|
47
51
|
"CHANGELOG.md",
|
|
52
|
+
"INSTALL_AND_UPDATE.md",
|
|
48
53
|
"LICENSE",
|
|
49
54
|
"assets",
|
|
50
|
-
"docs",
|
|
55
|
+
"docs/*.md",
|
|
56
|
+
"examples",
|
|
51
57
|
"src/api",
|
|
52
58
|
"src/app",
|
|
53
59
|
"src/config",
|
|
54
60
|
"src/data",
|
|
61
|
+
"src/environment",
|
|
55
62
|
"src/rendering",
|
|
56
63
|
"src/simulation",
|
|
57
64
|
"src/ui",
|
|
58
65
|
"src/index.d.ts",
|
|
59
66
|
"src/index.js",
|
|
60
|
-
"src/standalone.js",
|
|
61
67
|
"src/styles.css",
|
|
62
68
|
"README.md"
|
|
63
69
|
],
|
|
@@ -72,7 +78,8 @@
|
|
|
72
78
|
"showcase:ci": "npm --prefix local-preview ci && npm --prefix local-preview run build",
|
|
73
79
|
"changeset": "changeset",
|
|
74
80
|
"version-packages": "changeset version",
|
|
75
|
-
"release": "npm publish --provenance"
|
|
81
|
+
"release": "npm publish --provenance",
|
|
82
|
+
"example:train": "node examples/train-basic-policy.mjs"
|
|
76
83
|
},
|
|
77
84
|
"dependencies": {
|
|
78
85
|
"pixi.js": "8.18.1"
|
|
@@ -187,6 +187,10 @@ export class PaddockSimulatorController {
|
|
|
187
187
|
return this;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
get expert() {
|
|
191
|
+
return this.app?.expert ?? null;
|
|
192
|
+
}
|
|
193
|
+
|
|
190
194
|
destroy() {
|
|
191
195
|
this.app?.destroy();
|
|
192
196
|
this.app = null;
|
|
@@ -197,9 +201,10 @@ export class PaddockSimulatorController {
|
|
|
197
201
|
}
|
|
198
202
|
|
|
199
203
|
restart(nextOptions = {}) {
|
|
200
|
-
|
|
204
|
+
const nextResolvedOptions = resolveF1SimulatorOptions(mergeResolvedOptions(this.options, nextOptions));
|
|
205
|
+
this.app?.restart(nextResolvedOptions);
|
|
206
|
+
this.options = nextResolvedOptions;
|
|
201
207
|
this.compositeRoot.applyCssVariables();
|
|
202
|
-
this.app?.restart(this.options);
|
|
203
208
|
}
|
|
204
209
|
|
|
205
210
|
selectDriver(driverId) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createEnvironmentRuntime } from '../environment/runtime.js';
|
|
2
|
+
import { resolveEnvironmentOptions } from '../environment/options.js';
|
|
3
|
+
|
|
4
|
+
export function createBrowserExpertAdapter(app, expertOptions = {}) {
|
|
5
|
+
let resolvedOptions = resolveEnvironmentOptions({
|
|
6
|
+
...app.options,
|
|
7
|
+
...expertOptions,
|
|
8
|
+
controlledDrivers: expertOptions.controlledDrivers,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
return createEnvironmentRuntime({
|
|
12
|
+
getSimulation: () => app.sim,
|
|
13
|
+
setSimulation(nextSim) {
|
|
14
|
+
app.sim = nextSim;
|
|
15
|
+
},
|
|
16
|
+
createSimulation(nextOptions) {
|
|
17
|
+
app.applyExpertOptions(nextOptions);
|
|
18
|
+
return app.createRaceSimulation(nextOptions);
|
|
19
|
+
},
|
|
20
|
+
getOptions: () => resolvedOptions,
|
|
21
|
+
setOptions(nextOptions) {
|
|
22
|
+
resolvedOptions = nextOptions;
|
|
23
|
+
},
|
|
24
|
+
afterReset(result) {
|
|
25
|
+
app.renderTrack();
|
|
26
|
+
app.renderExpertFrame(result.state.snapshot, { observation: result.observation });
|
|
27
|
+
},
|
|
28
|
+
afterStep(result) {
|
|
29
|
+
app.renderExpertFrame(result.state.snapshot, { observation: result.observation });
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -2,14 +2,16 @@ import { Application, Assets, Container, Graphics, Sprite, Texture } from 'pixi.
|
|
|
2
2
|
import { DRIVER_STAT_DEFINITIONS, formatDriverNumber, VEHICLE_STAT_DEFINITIONS } from '../data/championship.js';
|
|
3
3
|
import { applyPaddockThemeCssVariables } from '../config/defaultOptions.js';
|
|
4
4
|
import { normalizeCustomFields } from '../data/customFields.js';
|
|
5
|
+
import { getCarRayOrigin, getCarRayVector } from '../environment/sensors.js';
|
|
5
6
|
import { ProceduralTrackAsset } from '../rendering/proceduralTrackAsset.js';
|
|
6
7
|
import { createRenderSnapshot } from '../rendering/renderSnapshot.js';
|
|
7
|
-
import { createRaceSimulation } from '../simulation/raceSimulation.js';
|
|
8
|
+
import { createRaceSimulation, FIXED_STEP } from '../simulation/raceSimulation.js';
|
|
8
9
|
import { clamp } from '../simulation/simMath.js';
|
|
9
10
|
import { offsetTrackPoint, pointAt, WORLD } from '../simulation/trackModel.js';
|
|
11
|
+
import { metersToSimUnits } from '../simulation/units.js';
|
|
12
|
+
import { createBrowserExpertAdapter } from './BrowserExpertAdapter.js';
|
|
10
13
|
import { querySimulatorDom, setText, setTextAll } from './domBindings.js';
|
|
11
14
|
|
|
12
|
-
const FIXED_STEP = 1 / 60;
|
|
13
15
|
const TARGET_RENDER_FPS = 60;
|
|
14
16
|
const TARGET_FRAME_MS = 1000 / TARGET_RENDER_FPS;
|
|
15
17
|
const FRAME_PACING_EPSILON_MS = 0.75;
|
|
@@ -34,6 +36,9 @@ const SHOW_ALL_TOP_RESERVED = 92;
|
|
|
34
36
|
const SHOW_ALL_BOTTOM_RESERVED = 132;
|
|
35
37
|
const DRS_TRAIL_TTL = 0.68;
|
|
36
38
|
const DRS_TRAIL_MIN_DISTANCE = 10;
|
|
39
|
+
const SENSOR_RAY_TRACK_COLOR = 0xf1c65b;
|
|
40
|
+
const SENSOR_RAY_TRACK_ENTRY_COLOR = 0x68d8ff;
|
|
41
|
+
const SENSOR_RAY_CAR_COLOR = 0xff4d5f;
|
|
37
42
|
const RACE_DATA_SELECTED_VISIBLE_MS = 5200;
|
|
38
43
|
const RADIO_BREAK_MIN_MS = 4800;
|
|
39
44
|
const RADIO_BREAK_MAX_MS = 11800;
|
|
@@ -116,6 +121,43 @@ function smoothAngle(current, target, amount) {
|
|
|
116
121
|
return current + diff * amount;
|
|
117
122
|
}
|
|
118
123
|
|
|
124
|
+
function destroyDisplayChildren(container) {
|
|
125
|
+
container?.removeChildren?.().forEach((child) => {
|
|
126
|
+
child.destroy?.({ children: true, texture: false, textureSource: false });
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function expertVisualizesRays(expertOptions) {
|
|
131
|
+
const setting = expertOptions?.visualizeSensors;
|
|
132
|
+
if (setting === true) return true;
|
|
133
|
+
if (!setting || setting === false) return false;
|
|
134
|
+
return Boolean(setting.rays);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function pointFromRay(origin, ray, distance) {
|
|
138
|
+
return {
|
|
139
|
+
x: origin.x + ray.x * distance,
|
|
140
|
+
y: origin.y + ray.y * distance,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hasOwnOption(options, key) {
|
|
145
|
+
return Object.hasOwn(options ?? {}, key);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function expertOptionsChanged(nextExpertOptions, currentExpertOptions) {
|
|
149
|
+
return nextExpertOptions !== currentExpertOptions;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function assetSetsEqual(first = {}, second = {}) {
|
|
153
|
+
const firstTrackTextures = first.trackTextures ?? {};
|
|
154
|
+
const secondTrackTextures = second.trackTextures ?? {};
|
|
155
|
+
const assetKeys = ['car', 'carOverview', 'driverHelmet', 'safetyCar', 'broadcastPanel', 'f1Logo'];
|
|
156
|
+
const textureKeys = new Set([...Object.keys(firstTrackTextures), ...Object.keys(secondTrackTextures)]);
|
|
157
|
+
return assetKeys.every((key) => first[key] === second[key]) &&
|
|
158
|
+
[...textureKeys].every((key) => firstTrackTextures[key] === secondTrackTextures[key]);
|
|
159
|
+
}
|
|
160
|
+
|
|
119
161
|
export class F1SimulatorApp {
|
|
120
162
|
constructor(root, options) {
|
|
121
163
|
this.root = root;
|
|
@@ -134,12 +176,15 @@ export class F1SimulatorApp {
|
|
|
134
176
|
this.visibilityChangeHandler = null;
|
|
135
177
|
this.visibilityObserver = null;
|
|
136
178
|
this.tickerCallback = null;
|
|
179
|
+
this.expert = null;
|
|
180
|
+
this.expertMode = Boolean(options.expert?.enabled);
|
|
137
181
|
this.sim = null;
|
|
138
182
|
this.app = null;
|
|
139
183
|
this.worldLayer = null;
|
|
140
184
|
this.trackAsset = null;
|
|
141
185
|
this.drsLayer = null;
|
|
142
186
|
this.trailLayer = null;
|
|
187
|
+
this.sensorLayer = null;
|
|
143
188
|
this.carLayer = null;
|
|
144
189
|
this.textures = {};
|
|
145
190
|
this.carSprites = new Map();
|
|
@@ -213,29 +258,36 @@ export class F1SimulatorApp {
|
|
|
213
258
|
this.worldLayer = new Container();
|
|
214
259
|
this.drsLayer = new Container();
|
|
215
260
|
this.trailLayer = new Graphics();
|
|
261
|
+
this.sensorLayer = new Graphics();
|
|
216
262
|
this.carLayer = new Container();
|
|
217
263
|
this.app.stage.addChild(this.worldLayer);
|
|
218
264
|
|
|
219
265
|
await this.loadAssets();
|
|
220
266
|
this.trackAsset = new ProceduralTrackAsset({ textures: this.textures, world: WORLD });
|
|
221
|
-
this.worldLayer.addChild(this.trackAsset.container, this.drsLayer, this.trailLayer, this.carLayer);
|
|
267
|
+
this.worldLayer.addChild(this.trackAsset.container, this.drsLayer, this.trailLayer, this.sensorLayer, this.carLayer);
|
|
222
268
|
this.createCars();
|
|
223
269
|
this.bindControls();
|
|
224
270
|
this.renderTrack();
|
|
225
271
|
const snapshot = this.sim.snapshot();
|
|
226
272
|
this.updateDom(snapshot);
|
|
273
|
+
if (this.expertMode) {
|
|
274
|
+
this.expert = createBrowserExpertAdapter(this, this.options.expert);
|
|
275
|
+
}
|
|
227
276
|
this.completeComponentLoading();
|
|
228
277
|
this.emitHostCallback('onLoadingChange', { loading: false, phase: 'ready' });
|
|
229
278
|
this.emitHostCallback('onReady', { snapshot });
|
|
230
279
|
this.resizeHandler = () => this.applyCamera(this.sim.snapshot());
|
|
231
280
|
window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
|
|
232
|
-
this.tickerCallback = () => this.tick();
|
|
233
|
-
this.app.ticker.add(this.tickerCallback);
|
|
234
281
|
this.observeLayoutResize();
|
|
235
|
-
this.
|
|
282
|
+
if (!this.expertMode) {
|
|
283
|
+
this.tickerCallback = () => this.tick();
|
|
284
|
+
this.app.ticker.add(this.tickerCallback);
|
|
285
|
+
this.observeRuntimeVisibility();
|
|
286
|
+
}
|
|
236
287
|
} catch (error) {
|
|
237
288
|
this.emitHostCallback('onLoadingChange', { loading: false, phase: 'error' });
|
|
238
289
|
this.emitHostCallback('onError', error, { phase: 'init' });
|
|
290
|
+
this.destroy();
|
|
239
291
|
throw error;
|
|
240
292
|
}
|
|
241
293
|
}
|
|
@@ -264,12 +316,12 @@ export class F1SimulatorApp {
|
|
|
264
316
|
});
|
|
265
317
|
}
|
|
266
318
|
|
|
267
|
-
createRaceSimulation() {
|
|
319
|
+
createRaceSimulation(options = this.options) {
|
|
268
320
|
return createRaceSimulation({
|
|
269
|
-
seed:
|
|
270
|
-
trackSeed: this.trackSeed,
|
|
271
|
-
drivers: this.drivers,
|
|
272
|
-
totalLaps:
|
|
321
|
+
seed: options.seed,
|
|
322
|
+
trackSeed: options.trackSeed ?? this.trackSeed,
|
|
323
|
+
drivers: options.drivers ?? this.drivers,
|
|
324
|
+
totalLaps: options.totalLaps,
|
|
273
325
|
});
|
|
274
326
|
}
|
|
275
327
|
|
|
@@ -418,10 +470,6 @@ export class F1SimulatorApp {
|
|
|
418
470
|
this.readouts.telemetryDrawerToggle?.addEventListener('click', () => {
|
|
419
471
|
this.setTelemetryDrawerOpen(!this.telemetryDrawerOpen);
|
|
420
472
|
}, eventOptions);
|
|
421
|
-
|
|
422
|
-
this.readouts.telemetryDrawerClose?.addEventListener('click', () => {
|
|
423
|
-
this.setTelemetryDrawerOpen(false);
|
|
424
|
-
}, eventOptions);
|
|
425
473
|
}
|
|
426
474
|
|
|
427
475
|
setTelemetryDrawerOpen(open) {
|
|
@@ -484,6 +532,7 @@ export class F1SimulatorApp {
|
|
|
484
532
|
}
|
|
485
533
|
|
|
486
534
|
syncRuntimeTicker() {
|
|
535
|
+
if (this.expertMode) return;
|
|
487
536
|
if (!this.app?.ticker) return;
|
|
488
537
|
|
|
489
538
|
const shouldRun = this.runtimeViewportVisible && this.runtimeDocumentVisible;
|
|
@@ -507,6 +556,7 @@ export class F1SimulatorApp {
|
|
|
507
556
|
}
|
|
508
557
|
|
|
509
558
|
tick() {
|
|
559
|
+
if (this.expertMode) return;
|
|
510
560
|
const now = performance.now();
|
|
511
561
|
if (now < this.nextGameFrameTime - FRAME_PACING_EPSILON_MS) return;
|
|
512
562
|
|
|
@@ -549,8 +599,20 @@ export class F1SimulatorApp {
|
|
|
549
599
|
}
|
|
550
600
|
}
|
|
551
601
|
|
|
602
|
+
renderExpertFrame(snapshot = this.sim?.snapshot(), { observation } = {}) {
|
|
603
|
+
if (!snapshot) return;
|
|
604
|
+
const renderSnapshot = createRenderSnapshot(snapshot, 0);
|
|
605
|
+
this.applyCamera(renderSnapshot);
|
|
606
|
+
this.renderDrsTrails(renderSnapshot);
|
|
607
|
+
this.renderExpertSensorRays(renderSnapshot, observation);
|
|
608
|
+
this.renderCars(renderSnapshot);
|
|
609
|
+
this.updateDom(snapshot, { emitLifecycle: false });
|
|
610
|
+
this.lastDomUpdateTime = performance.now();
|
|
611
|
+
}
|
|
612
|
+
|
|
552
613
|
renderTrack() {
|
|
553
|
-
this.drsLayer
|
|
614
|
+
destroyDisplayChildren(this.drsLayer);
|
|
615
|
+
this.sensorLayer?.clear();
|
|
554
616
|
const snapshot = this.sim.snapshot();
|
|
555
617
|
const track = snapshot.track;
|
|
556
618
|
|
|
@@ -606,6 +668,60 @@ export class F1SimulatorApp {
|
|
|
606
668
|
}
|
|
607
669
|
}
|
|
608
670
|
|
|
671
|
+
renderExpertSensorRays(snapshot, observation) {
|
|
672
|
+
if (!this.sensorLayer) return;
|
|
673
|
+
this.sensorLayer.clear();
|
|
674
|
+
if (!this.expertMode || !expertVisualizesRays(this.options.expert)) return;
|
|
675
|
+
|
|
676
|
+
const controlledDrivers = this.options.expert?.controlledDrivers ?? [];
|
|
677
|
+
if (!controlledDrivers.length || !observation) return;
|
|
678
|
+
|
|
679
|
+
const carsById = new Map(snapshot.cars.map((car) => [car.id, car]));
|
|
680
|
+
controlledDrivers.forEach((driverId) => {
|
|
681
|
+
const car = carsById.get(driverId);
|
|
682
|
+
const rays = observation?.[driverId]?.object?.rays;
|
|
683
|
+
if (!car || !Array.isArray(rays) || rays.length === 0) return;
|
|
684
|
+
|
|
685
|
+
const origin = getCarRayOrigin(car);
|
|
686
|
+
|
|
687
|
+
rays.forEach((ray) => {
|
|
688
|
+
const rayVector = getCarRayVector(car, Number(ray.angleDegrees) || 0);
|
|
689
|
+
const totalDistanceMeters = Math.max(0, Number(ray.lengthMeters) || 0);
|
|
690
|
+
const trackDistanceMeters = Math.max(0, Number(ray.track?.distanceMeters) || totalDistanceMeters);
|
|
691
|
+
const carDistanceMeters = Math.max(0, Number(ray.car?.distanceMeters) || totalDistanceMeters);
|
|
692
|
+
const trackHit = Boolean(ray.track?.hit) && trackDistanceMeters <= totalDistanceMeters;
|
|
693
|
+
const carHit = Boolean(ray.car?.hit) && carDistanceMeters <= totalDistanceMeters;
|
|
694
|
+
const fullEnd = pointFromRay(origin, rayVector, metersToSimUnits(totalDistanceMeters));
|
|
695
|
+
|
|
696
|
+
this.sensorLayer
|
|
697
|
+
.moveTo(origin.x, origin.y)
|
|
698
|
+
.lineTo(fullEnd.x, fullEnd.y)
|
|
699
|
+
.stroke({ width: 2, color: 0xffffff, alpha: 0.18, cap: 'round' });
|
|
700
|
+
|
|
701
|
+
if (!trackHit && !carHit) return;
|
|
702
|
+
|
|
703
|
+
const carIsClosest = carHit && (!trackHit || carDistanceMeters <= trackDistanceMeters);
|
|
704
|
+
const hitDistanceMeters = carIsClosest ? carDistanceMeters : trackDistanceMeters;
|
|
705
|
+
const hitEnd = pointFromRay(origin, rayVector, metersToSimUnits(hitDistanceMeters));
|
|
706
|
+
const hitColor = carIsClosest
|
|
707
|
+
? SENSOR_RAY_CAR_COLOR
|
|
708
|
+
: ray.track?.kind === 'entry'
|
|
709
|
+
? SENSOR_RAY_TRACK_ENTRY_COLOR
|
|
710
|
+
: SENSOR_RAY_TRACK_COLOR;
|
|
711
|
+
|
|
712
|
+
this.sensorLayer
|
|
713
|
+
.moveTo(origin.x, origin.y)
|
|
714
|
+
.lineTo(hitEnd.x, hitEnd.y)
|
|
715
|
+
.stroke({ width: carIsClosest ? 5 : 3.4, color: hitColor, alpha: carIsClosest ? 0.9 : 0.76, cap: 'round' });
|
|
716
|
+
this.sensorLayer
|
|
717
|
+
.circle(hitEnd.x, hitEnd.y, carIsClosest ? 7 : 5)
|
|
718
|
+
.fill({ color: hitColor, alpha: 0.92 })
|
|
719
|
+
.circle(hitEnd.x, hitEnd.y, carIsClosest ? 10 : 8)
|
|
720
|
+
.stroke({ width: 1.5, color: 0x10131a, alpha: 0.72 });
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
609
725
|
renderDrsTrails(snapshot) {
|
|
610
726
|
if (!this.trailLayer) return;
|
|
611
727
|
|
|
@@ -1357,7 +1473,7 @@ export class F1SimulatorApp {
|
|
|
1357
1473
|
});
|
|
1358
1474
|
}
|
|
1359
1475
|
|
|
1360
|
-
|
|
1476
|
+
applyExpertOptions(nextOptions = {}) {
|
|
1361
1477
|
this.options = {
|
|
1362
1478
|
...this.options,
|
|
1363
1479
|
...nextOptions,
|
|
@@ -1379,16 +1495,32 @@ export class F1SimulatorApp {
|
|
|
1379
1495
|
},
|
|
1380
1496
|
};
|
|
1381
1497
|
this.assets = this.options.assets;
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1498
|
+
if (hasOwnOption(nextOptions, 'trackSeed')) {
|
|
1499
|
+
this.trackSeed = this.options.trackSeed ?? createMountTrackSeed();
|
|
1500
|
+
}
|
|
1385
1501
|
if (nextOptions.drivers) {
|
|
1386
1502
|
this.drivers = nextOptions.drivers;
|
|
1387
1503
|
this.driverById = new Map(this.drivers.map((driver) => [driver.id, driver]));
|
|
1388
1504
|
this.root.style.setProperty('--paddock-entry-count', String(this.drivers.length));
|
|
1389
1505
|
this.createCars();
|
|
1390
1506
|
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
restart(nextOptions = {}) {
|
|
1510
|
+
if (hasOwnOption(nextOptions, 'assets') && !assetSetsEqual(nextOptions.assets, this.options.assets)) {
|
|
1511
|
+
throw new Error('PaddockJS restart() does not support changing assets. Destroy and mount a new simulator with the new assets.');
|
|
1512
|
+
}
|
|
1513
|
+
if (hasOwnOption(nextOptions, 'expert') && expertOptionsChanged(nextOptions.expert, this.options.expert)) {
|
|
1514
|
+
throw new Error('PaddockJS restart() does not support changing expert mode. Destroy and mount a new simulator with the new expert options.');
|
|
1515
|
+
}
|
|
1516
|
+
this.applyExpertOptions(nextOptions);
|
|
1517
|
+
this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
|
|
1518
|
+
this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
|
|
1519
|
+
applyPaddockThemeCssVariables(this.root, this.options.theme);
|
|
1391
1520
|
this.sim = this.createRaceSimulation();
|
|
1521
|
+
if (this.expertMode) {
|
|
1522
|
+
this.expert = createBrowserExpertAdapter(this, this.options.expert);
|
|
1523
|
+
}
|
|
1392
1524
|
this.selectedId = this.drivers[0]?.id ?? null;
|
|
1393
1525
|
this.lastTimingMarkup = null;
|
|
1394
1526
|
this.lastOverviewRenderKey = null;
|