@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.
@@ -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.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
- "description": "A data-driven browser F1 simulator component for project portfolios.",
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
- this.options = resolveF1SimulatorOptions(mergeResolvedOptions(this.options, nextOptions));
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.observeRuntimeVisibility();
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: this.options.seed,
270
- trackSeed: this.trackSeed,
271
- drivers: this.drivers,
272
- totalLaps: this.options.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.removeChildren();
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
- restart(nextOptions = {}) {
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
- this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
1383
- this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
1384
- applyPaddockThemeCssVariables(this.root, this.options.theme);
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;