@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,69 @@
|
|
|
1
|
+
const DEFAULT_PROGRESS_REWARD_WEIGHTS = Object.freeze({
|
|
2
|
+
progress: 1,
|
|
3
|
+
speed: 0.01,
|
|
4
|
+
offTrack: -2,
|
|
5
|
+
collision: -10,
|
|
6
|
+
steering: -0.03,
|
|
7
|
+
brake: -0.02,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export function createProgressReward(options = {}) {
|
|
11
|
+
const weights = {
|
|
12
|
+
...DEFAULT_PROGRESS_REWARD_WEIGHTS,
|
|
13
|
+
...(options.weights ?? {}),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
return function progressReward(context = {}) {
|
|
17
|
+
const driverId = context.driverId;
|
|
18
|
+
const previousCar = findSnapshotCar(context.previous, driverId);
|
|
19
|
+
const currentCar = findSnapshotCar(context.state?.snapshot, driverId);
|
|
20
|
+
const progressMeters = getProgressMeters(previousCar, currentCar);
|
|
21
|
+
const self = context.current?.object?.self ?? {};
|
|
22
|
+
const speedKph = Number(self.speedKph ?? 0);
|
|
23
|
+
const isOnTrack = self.onTrack !== false;
|
|
24
|
+
const collisionCount = countDriverCollisions(context.events, driverId);
|
|
25
|
+
const steering = Math.abs(Number(context.action?.steering ?? 0));
|
|
26
|
+
const brake = Math.abs(Number(context.action?.brake ?? 0));
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
progressMeters * weights.progress +
|
|
30
|
+
(isOnTrack ? speedKph * weights.speed : 0) +
|
|
31
|
+
(isOnTrack ? 0 : weights.offTrack) +
|
|
32
|
+
collisionCount * weights.collision +
|
|
33
|
+
steering * weights.steering +
|
|
34
|
+
brake * weights.brake
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findSnapshotCar(snapshot, driverId) {
|
|
40
|
+
if (!snapshot || !driverId || !Array.isArray(snapshot.cars)) return null;
|
|
41
|
+
return snapshot.cars.find((car) => car?.id === driverId) ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getProgressMeters(previousCar, currentCar) {
|
|
45
|
+
if (!previousCar || !currentCar) return 0;
|
|
46
|
+
const previous = Number(previousCar.distanceMeters ?? previousCar.raceDistanceMeters ?? 0);
|
|
47
|
+
const current = Number(currentCar.distanceMeters ?? currentCar.raceDistanceMeters ?? 0);
|
|
48
|
+
if (!Number.isFinite(previous) || !Number.isFinite(current)) return 0;
|
|
49
|
+
return current - previous;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function countDriverCollisions(events = [], driverId) {
|
|
53
|
+
if (!driverId || !Array.isArray(events)) return 0;
|
|
54
|
+
return events.filter((event) => isCollisionEvent(event) && eventIncludesDriver(event, driverId)).length;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isCollisionEvent(event) {
|
|
58
|
+
return event?.type === 'collision' || event?.type === 'contact';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function eventIncludesDriver(event, driverId) {
|
|
62
|
+
if (event.driverId === driverId ||
|
|
63
|
+
event.carId === driverId ||
|
|
64
|
+
event.primaryDriverId === driverId ||
|
|
65
|
+
event.otherDriverId === driverId) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
return Array.isArray(event.driverIds) && event.driverIds.includes(driverId);
|
|
69
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createRaceSimulation, FIXED_STEP } from '../simulation/raceSimulation.js';
|
|
2
|
+
import { resolveActionMap } from './actions.js';
|
|
3
|
+
import { collectStepEvents } from './events.js';
|
|
4
|
+
import { createEpisodeState, evaluateEpisode } from './episode.js';
|
|
5
|
+
import { buildEnvironmentObservation } from './observations.js';
|
|
6
|
+
import { resolveEnvironmentOptions } from './options.js';
|
|
7
|
+
import { buildActionSpec, buildObservationSpec } from './specs.js';
|
|
8
|
+
|
|
9
|
+
export function createPaddockEnvironment(options = {}) {
|
|
10
|
+
let resolvedOptions = resolveEnvironmentOptions(options);
|
|
11
|
+
let sim = createRaceSimulation(resolvedOptions);
|
|
12
|
+
return createEnvironmentRuntime({
|
|
13
|
+
getSimulation: () => sim,
|
|
14
|
+
setSimulation(nextSim) {
|
|
15
|
+
sim = nextSim;
|
|
16
|
+
},
|
|
17
|
+
createSimulation(nextOptions) {
|
|
18
|
+
return createRaceSimulation(nextOptions);
|
|
19
|
+
},
|
|
20
|
+
getOptions: () => resolvedOptions,
|
|
21
|
+
setOptions(nextOptions) {
|
|
22
|
+
resolvedOptions = nextOptions;
|
|
23
|
+
},
|
|
24
|
+
afterReset() {},
|
|
25
|
+
afterStep() {},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createEnvironmentRuntime(host) {
|
|
30
|
+
const episodeState = createEpisodeState();
|
|
31
|
+
|
|
32
|
+
function reset(nextOptions = {}) {
|
|
33
|
+
const options = resolveEnvironmentOptions({
|
|
34
|
+
...host.getOptions(),
|
|
35
|
+
...nextOptions,
|
|
36
|
+
});
|
|
37
|
+
host.setOptions(options);
|
|
38
|
+
host.setSimulation(host.createSimulation(options));
|
|
39
|
+
episodeState.step = 0;
|
|
40
|
+
episodeState.previousSnapshot = null;
|
|
41
|
+
const result = buildResult({ host, episodeState, events: [], actionErrors: [] });
|
|
42
|
+
episodeState.lastResult = result;
|
|
43
|
+
host.afterReset(result);
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function step(actions = {}) {
|
|
48
|
+
const options = host.getOptions();
|
|
49
|
+
const sim = host.getSimulation();
|
|
50
|
+
const { controlsByDriver, errors } = resolveActionMap(actions, options.controlledDrivers, {
|
|
51
|
+
policy: options.actionPolicy,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
Object.entries(controlsByDriver).forEach(([driverId, controls]) => {
|
|
55
|
+
sim.setCarControls(driverId, controls);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
episodeState.previousSnapshot = sim.snapshot();
|
|
59
|
+
const stepEvents = [];
|
|
60
|
+
for (let index = 0; index < options.frameSkip; index += 1) {
|
|
61
|
+
sim.step(FIXED_STEP);
|
|
62
|
+
stepEvents.push(...collectStepEvents(sim.snapshot().events));
|
|
63
|
+
}
|
|
64
|
+
episodeState.step += 1;
|
|
65
|
+
const result = buildResult({ host, episodeState, events: stepEvents, actionErrors: errors, actions });
|
|
66
|
+
episodeState.lastResult = result;
|
|
67
|
+
host.afterStep(result);
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getObservation() {
|
|
72
|
+
return episodeState.lastResult?.observation ??
|
|
73
|
+
buildResult({ host, episodeState, events: [], actionErrors: [] }).observation;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getState() {
|
|
77
|
+
return { snapshot: host.getSimulation().snapshot() };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getActionSpec() {
|
|
81
|
+
return buildActionSpec(host.getOptions());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getObservationSpec() {
|
|
85
|
+
return buildObservationSpec(host.getOptions());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function destroy() {
|
|
89
|
+
episodeState.lastResult = null;
|
|
90
|
+
episodeState.previousSnapshot = null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { reset, step, getObservation, getState, getActionSpec, getObservationSpec, destroy };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildResult({ host, episodeState, events, actionErrors, actions = {} }) {
|
|
97
|
+
const options = host.getOptions();
|
|
98
|
+
const snapshot = host.getSimulation().snapshot();
|
|
99
|
+
const observation = buildEnvironmentObservation({
|
|
100
|
+
snapshot,
|
|
101
|
+
previousSnapshot: episodeState.previousSnapshot,
|
|
102
|
+
options,
|
|
103
|
+
events,
|
|
104
|
+
});
|
|
105
|
+
const episode = evaluateEpisode(snapshot, options, episodeState);
|
|
106
|
+
const reward = computeReward({ options, observation, events, snapshot, actions, previousSnapshot: episodeState.previousSnapshot });
|
|
107
|
+
return {
|
|
108
|
+
observation,
|
|
109
|
+
reward,
|
|
110
|
+
terminated: episode.terminated,
|
|
111
|
+
truncated: episode.truncated,
|
|
112
|
+
done: episode.terminated || episode.truncated,
|
|
113
|
+
events,
|
|
114
|
+
state: { snapshot },
|
|
115
|
+
info: {
|
|
116
|
+
step: episodeState.step,
|
|
117
|
+
elapsedSeconds: snapshot.time,
|
|
118
|
+
seed: options.seed,
|
|
119
|
+
trackSeed: options.trackSeed,
|
|
120
|
+
controlledDrivers: [...options.controlledDrivers],
|
|
121
|
+
actionErrors,
|
|
122
|
+
endReason: episode.endReason,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function computeReward({ options, observation, events, snapshot, actions, previousSnapshot }) {
|
|
128
|
+
if (!options.reward) return null;
|
|
129
|
+
return Object.fromEntries(options.controlledDrivers.map((driverId) => [
|
|
130
|
+
driverId,
|
|
131
|
+
Number(options.reward({
|
|
132
|
+
driverId,
|
|
133
|
+
previous: previousSnapshot,
|
|
134
|
+
current: observation[driverId],
|
|
135
|
+
action: actions?.[driverId],
|
|
136
|
+
events: observation[driverId]?.events ?? events,
|
|
137
|
+
state: { snapshot },
|
|
138
|
+
}) ?? 0),
|
|
139
|
+
]));
|
|
140
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { nearestTrackState } from '../simulation/trackModel.js';
|
|
2
|
+
import { metersToSimUnits, simUnitsToMeters } from '../simulation/units.js';
|
|
3
|
+
import { VEHICLE_LIMITS } from '../simulation/vehiclePhysics.js';
|
|
4
|
+
|
|
5
|
+
const TRACK_RAY_STEP_METERS = 1;
|
|
6
|
+
const TRACK_RAY_REFINE_STEPS = 12;
|
|
7
|
+
export const DEFAULT_RAY_ANGLES_DEGREES = [-135, -60, -20, 0, 20, 60, 135, 180];
|
|
8
|
+
|
|
9
|
+
export function buildNearbyCars(car, snapshot, { maxCars = 6, radiusMeters = 150 } = {}) {
|
|
10
|
+
return snapshot.cars
|
|
11
|
+
.filter((other) => other.id !== car.id)
|
|
12
|
+
.map((other) => {
|
|
13
|
+
const dx = other.x - car.x;
|
|
14
|
+
const dy = other.y - car.y;
|
|
15
|
+
const distanceMeters = simUnitsToMeters(Math.hypot(dx, dy));
|
|
16
|
+
const forward = Math.cos(car.heading) * dx + Math.sin(car.heading) * dy;
|
|
17
|
+
const right = -Math.sin(car.heading) * dx + Math.cos(car.heading) * dy;
|
|
18
|
+
return {
|
|
19
|
+
id: other.id,
|
|
20
|
+
relativeForwardMeters: simUnitsToMeters(forward),
|
|
21
|
+
relativeRightMeters: simUnitsToMeters(right),
|
|
22
|
+
relativeDistanceMeters: distanceMeters,
|
|
23
|
+
relativeSpeedKph: other.speedKph - car.speedKph,
|
|
24
|
+
relativeHeadingRadians: normalizeRelativeHeading(other.heading - car.heading),
|
|
25
|
+
ahead: forward > 0,
|
|
26
|
+
sameLap: other.lap === car.lap,
|
|
27
|
+
};
|
|
28
|
+
})
|
|
29
|
+
.filter((entry) => entry.relativeDistanceMeters <= radiusMeters)
|
|
30
|
+
.sort((a, b) => a.relativeDistanceMeters - b.relativeDistanceMeters)
|
|
31
|
+
.slice(0, maxCars);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildRaySensors(car, snapshot, rayOptions = {}) {
|
|
35
|
+
const angles = rayOptions.anglesDegrees ?? DEFAULT_RAY_ANGLES_DEGREES;
|
|
36
|
+
const lengthMeters = rayOptions.lengthMeters ?? 120;
|
|
37
|
+
|
|
38
|
+
return angles.map((angleDegrees) => ({
|
|
39
|
+
angleDegrees,
|
|
40
|
+
angleRadians: degreesToRadians(angleDegrees),
|
|
41
|
+
lengthMeters,
|
|
42
|
+
track: rayOptions.detectTrack === false
|
|
43
|
+
? createTrackMiss(lengthMeters)
|
|
44
|
+
: estimateTrackHit(car, snapshot, angleDegrees, lengthMeters),
|
|
45
|
+
car: rayOptions.detectCars === false
|
|
46
|
+
? { hit: false, distanceMeters: lengthMeters, driverId: null, relativeSpeedKph: 0 }
|
|
47
|
+
: estimateCarHit(car, snapshot, angleDegrees, lengthMeters),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function estimateTrackHit(car, snapshot, angleDegrees, lengthMeters) {
|
|
52
|
+
if (!Array.isArray(snapshot.track?.samples) || snapshot.track.samples.length === 0) {
|
|
53
|
+
return estimateLocalTrackHit(car, snapshot, angleDegrees, lengthMeters);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const origin = getCarRayOrigin(car);
|
|
57
|
+
const ray = getCarRayVector(car, angleDegrees);
|
|
58
|
+
const maxDistance = metersToSimUnits(lengthMeters);
|
|
59
|
+
const step = metersToSimUnits(TRACK_RAY_STEP_METERS);
|
|
60
|
+
let previousDistance = 0;
|
|
61
|
+
let previousInside = null;
|
|
62
|
+
|
|
63
|
+
for (let distance = 0; distance <= maxDistance; distance += step) {
|
|
64
|
+
const state = nearestTrackState(snapshot.track, pointOnRay(origin, ray, distance), car.progress);
|
|
65
|
+
const inside = isInsideTrackBorder(state, snapshot.track);
|
|
66
|
+
if (previousInside == null) {
|
|
67
|
+
previousInside = inside;
|
|
68
|
+
previousDistance = distance;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (inside !== previousInside) {
|
|
73
|
+
const kind = previousInside ? 'exit' : 'entry';
|
|
74
|
+
const hitDistance = refineTrackTransitionDistance(
|
|
75
|
+
snapshot.track,
|
|
76
|
+
origin,
|
|
77
|
+
ray,
|
|
78
|
+
car.progress,
|
|
79
|
+
previousDistance,
|
|
80
|
+
distance,
|
|
81
|
+
kind,
|
|
82
|
+
);
|
|
83
|
+
return {
|
|
84
|
+
hit: true,
|
|
85
|
+
distanceMeters: simUnitsToMeters(hitDistance),
|
|
86
|
+
kind,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
previousDistance = distance;
|
|
90
|
+
previousInside = inside;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return createTrackMiss(lengthMeters);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function estimateLocalTrackHit(car, snapshot, angleDegrees, lengthMeters) {
|
|
97
|
+
const trackHalfWidthMeters = simUnitsToMeters(snapshot.track.width / 2);
|
|
98
|
+
const offsetMeters = simUnitsToMeters(car.signedOffset ?? 0);
|
|
99
|
+
const lateral = Math.sin(degreesToRadians(angleDegrees));
|
|
100
|
+
if (Math.abs(lateral) < 0.08) {
|
|
101
|
+
return createTrackMiss(lengthMeters);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const inside = Math.abs(offsetMeters) <= trackHalfWidthMeters;
|
|
105
|
+
const targetEdge = getLocalTrackTransitionTarget({ inside, offsetMeters, lateral, trackHalfWidthMeters });
|
|
106
|
+
if (targetEdge == null) return createTrackMiss(lengthMeters);
|
|
107
|
+
|
|
108
|
+
const distanceMeters = (targetEdge - offsetMeters) / lateral;
|
|
109
|
+
if (distanceMeters < 0 || distanceMeters > lengthMeters) return createTrackMiss(lengthMeters);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
hit: true,
|
|
113
|
+
distanceMeters,
|
|
114
|
+
kind: inside ? 'exit' : 'entry',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getLocalTrackTransitionTarget({ inside, offsetMeters, lateral, trackHalfWidthMeters }) {
|
|
119
|
+
if (inside) return lateral > 0 ? trackHalfWidthMeters : -trackHalfWidthMeters;
|
|
120
|
+
if (offsetMeters > trackHalfWidthMeters) return lateral < 0 ? trackHalfWidthMeters : null;
|
|
121
|
+
if (offsetMeters < -trackHalfWidthMeters) return lateral > 0 ? -trackHalfWidthMeters : null;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function refineTrackTransitionDistance(track, origin, ray, progressHint, lowDistance, highDistance, kind) {
|
|
126
|
+
let low = lowDistance;
|
|
127
|
+
let high = highDistance;
|
|
128
|
+
for (let index = 0; index < TRACK_RAY_REFINE_STEPS; index += 1) {
|
|
129
|
+
const middle = (low + high) / 2;
|
|
130
|
+
const state = nearestTrackState(track, pointOnRay(origin, ray, middle), progressHint);
|
|
131
|
+
const inside = isInsideTrackBorder(state, track);
|
|
132
|
+
if (kind === 'entry') {
|
|
133
|
+
if (inside) high = middle;
|
|
134
|
+
else low = middle;
|
|
135
|
+
} else if (inside) {
|
|
136
|
+
low = middle;
|
|
137
|
+
} else {
|
|
138
|
+
high = middle;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return high;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isInsideTrackBorder(state, track) {
|
|
145
|
+
return state.crossTrackError <= track.width / 2;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createTrackMiss(lengthMeters) {
|
|
149
|
+
return {
|
|
150
|
+
hit: false,
|
|
151
|
+
distanceMeters: lengthMeters,
|
|
152
|
+
kind: null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function estimateCarHit(car, snapshot, angleDegrees, lengthMeters) {
|
|
157
|
+
const origin = getCarRayOrigin(car);
|
|
158
|
+
const ray = getCarRayVector(car, angleDegrees);
|
|
159
|
+
const maxDistance = metersToSimUnits(lengthMeters);
|
|
160
|
+
let closest = null;
|
|
161
|
+
snapshot.cars.forEach((other) => {
|
|
162
|
+
if (other.id === car.id) return;
|
|
163
|
+
const hitDistance = intersectCarFootprint(origin, ray, other);
|
|
164
|
+
if (hitDistance == null || hitDistance > maxDistance) return;
|
|
165
|
+
const distanceMeters = simUnitsToMeters(hitDistance);
|
|
166
|
+
if (!closest || hitDistance < closest.distanceSimUnits) {
|
|
167
|
+
closest = {
|
|
168
|
+
hit: true,
|
|
169
|
+
distanceSimUnits: hitDistance,
|
|
170
|
+
distanceMeters,
|
|
171
|
+
driverId: other.id,
|
|
172
|
+
relativeSpeedKph: other.speedKph - car.speedKph,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
if (!closest) return { hit: false, distanceMeters: lengthMeters, driverId: null, relativeSpeedKph: 0 };
|
|
177
|
+
const { distanceSimUnits, ...publicHit } = closest;
|
|
178
|
+
return publicHit;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function getCarRayOrigin(car) {
|
|
182
|
+
return {
|
|
183
|
+
x: car.x,
|
|
184
|
+
y: car.y,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function getCarRayVector(car, angleDegrees) {
|
|
189
|
+
const angle = car.heading + degreesToRadians(angleDegrees);
|
|
190
|
+
return {
|
|
191
|
+
x: Math.cos(angle),
|
|
192
|
+
y: Math.sin(angle),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function pointOnRay(origin, ray, distance) {
|
|
197
|
+
return {
|
|
198
|
+
x: origin.x + ray.x * distance,
|
|
199
|
+
y: origin.y + ray.y * distance,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function intersectCarFootprint(origin, ray, other) {
|
|
204
|
+
const forward = { x: Math.cos(other.heading), y: Math.sin(other.heading) };
|
|
205
|
+
const right = { x: -Math.sin(other.heading), y: Math.cos(other.heading) };
|
|
206
|
+
const delta = { x: origin.x - other.x, y: origin.y - other.y };
|
|
207
|
+
const localOrigin = {
|
|
208
|
+
x: dot(delta, forward),
|
|
209
|
+
y: dot(delta, right),
|
|
210
|
+
};
|
|
211
|
+
const localRay = {
|
|
212
|
+
x: dot(ray, forward),
|
|
213
|
+
y: dot(ray, right),
|
|
214
|
+
};
|
|
215
|
+
const halfLength = VEHICLE_LIMITS.carLength / 2;
|
|
216
|
+
const halfWidth = VEHICLE_LIMITS.carWidth / 2;
|
|
217
|
+
return intersectAxisAlignedBoxRay(localOrigin, localRay, halfLength, halfWidth);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function intersectAxisAlignedBoxRay(origin, ray, halfLength, halfWidth) {
|
|
221
|
+
let tMin = -Infinity;
|
|
222
|
+
let tMax = Infinity;
|
|
223
|
+
const xRange = intersectSlab(origin.x, ray.x, -halfLength, halfLength);
|
|
224
|
+
const yRange = intersectSlab(origin.y, ray.y, -halfWidth, halfWidth);
|
|
225
|
+
if (!xRange || !yRange) return null;
|
|
226
|
+
tMin = Math.max(tMin, xRange.min, yRange.min);
|
|
227
|
+
tMax = Math.min(tMax, xRange.max, yRange.max);
|
|
228
|
+
if (tMax < 0 || tMin > tMax) return null;
|
|
229
|
+
return Math.max(0, tMin);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function intersectSlab(origin, direction, min, max) {
|
|
233
|
+
if (Math.abs(direction) < 1e-9) {
|
|
234
|
+
return origin >= min && origin <= max ? { min: -Infinity, max: Infinity } : null;
|
|
235
|
+
}
|
|
236
|
+
const first = (min - origin) / direction;
|
|
237
|
+
const second = (max - origin) / direction;
|
|
238
|
+
return {
|
|
239
|
+
min: Math.min(first, second),
|
|
240
|
+
max: Math.max(first, second),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function dot(a, b) {
|
|
245
|
+
return a.x * b.x + a.y * b.y;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function degreesToRadians(degrees) {
|
|
249
|
+
return (degrees * Math.PI) / 180;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function normalizeRelativeHeading(angle) {
|
|
253
|
+
let value = angle;
|
|
254
|
+
while (value > Math.PI) value -= Math.PI * 2;
|
|
255
|
+
while (value < -Math.PI) value += Math.PI * 2;
|
|
256
|
+
return value;
|
|
257
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export function buildActionSpec(options) {
|
|
2
|
+
return {
|
|
3
|
+
version: 1,
|
|
4
|
+
controlledDrivers: [...options.controlledDrivers],
|
|
5
|
+
action: {
|
|
6
|
+
type: 'continuous',
|
|
7
|
+
perDriver: {
|
|
8
|
+
steering: { min: -1, max: 1, unit: 'normalized' },
|
|
9
|
+
throttle: { min: 0, max: 1, unit: 'normalized' },
|
|
10
|
+
brake: { min: 0, max: 1, unit: 'normalized' },
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildObservationSpec(options) {
|
|
17
|
+
const rayOptions = options.sensors.rays;
|
|
18
|
+
const nearbyOptions = options.sensors.nearbyCars;
|
|
19
|
+
return {
|
|
20
|
+
version: 1,
|
|
21
|
+
controlledDrivers: [...options.controlledDrivers],
|
|
22
|
+
object: {
|
|
23
|
+
self: [
|
|
24
|
+
{ name: 'id', unit: 'id' },
|
|
25
|
+
{ name: 'speedKph', unit: 'kph' },
|
|
26
|
+
{ name: 'speedMetersPerSecond', unit: 'm/s' },
|
|
27
|
+
{ name: 'headingRadians', unit: 'rad' },
|
|
28
|
+
{ name: 'steeringAngleRadians', unit: 'rad' },
|
|
29
|
+
{ name: 'throttle', unit: 'normalized' },
|
|
30
|
+
{ name: 'brake', unit: 'normalized' },
|
|
31
|
+
{ name: 'lap', unit: 'count' },
|
|
32
|
+
{ name: 'completedLaps', unit: 'count' },
|
|
33
|
+
{ name: 'lapProgressMeters', unit: 'm' },
|
|
34
|
+
{ name: 'trackOffsetMeters', unit: 'm' },
|
|
35
|
+
{ name: 'trackHeadingErrorRadians', unit: 'rad' },
|
|
36
|
+
{ name: 'onTrack', unit: 'boolean' },
|
|
37
|
+
{ name: 'surface', unit: 'label' },
|
|
38
|
+
{ name: 'tireEnergy', unit: 'nullable:number' },
|
|
39
|
+
],
|
|
40
|
+
race: [
|
|
41
|
+
{ name: 'position', unit: 'rank' },
|
|
42
|
+
{ name: 'totalCars', unit: 'count' },
|
|
43
|
+
{ name: 'raceMode', unit: 'label' },
|
|
44
|
+
{ name: 'totalLaps', unit: 'count' },
|
|
45
|
+
],
|
|
46
|
+
rays: {
|
|
47
|
+
enabled: Boolean(rayOptions.enabled),
|
|
48
|
+
anglesDegrees: [...rayOptions.anglesDegrees],
|
|
49
|
+
lengthMeters: rayOptions.lengthMeters,
|
|
50
|
+
track: {
|
|
51
|
+
distanceMeters: { unit: 'm', noHitValue: rayOptions.lengthMeters },
|
|
52
|
+
hit: { unit: 'boolean' },
|
|
53
|
+
kind: { values: ['exit', 'entry', null] },
|
|
54
|
+
},
|
|
55
|
+
car: {
|
|
56
|
+
distanceMeters: { unit: 'm', noHitValue: rayOptions.lengthMeters },
|
|
57
|
+
hit: { unit: 'boolean' },
|
|
58
|
+
driverId: { nullable: true },
|
|
59
|
+
relativeSpeedKph: { unit: 'kph' },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
nearbyCars: {
|
|
63
|
+
enabled: Boolean(nearbyOptions.enabled),
|
|
64
|
+
maxCars: nearbyOptions.maxCars,
|
|
65
|
+
radiusMeters: nearbyOptions.radiusMeters,
|
|
66
|
+
},
|
|
67
|
+
events: { type: 'array' },
|
|
68
|
+
},
|
|
69
|
+
vector: {
|
|
70
|
+
schema: [
|
|
71
|
+
{ name: 'self.speedKph', unit: 'kph', scale: 'fixed:400' },
|
|
72
|
+
{ name: 'self.trackOffsetMeters', unit: 'm', scale: 'fixed:meters' },
|
|
73
|
+
{ name: 'self.trackHeadingErrorRadians', unit: 'rad', scale: 'fixed:pi' },
|
|
74
|
+
{ name: 'self.onTrack', scale: 'boolean' },
|
|
75
|
+
{ name: 'race.position', scale: 'fixed:field-position' },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import type { PaddockActionSpec, PaddockObservationSpec } from './environment/index.js';
|
|
2
|
+
|
|
3
|
+
export type { PaddockActionSpec, PaddockObservationSpec } from './environment/index.js';
|
|
4
|
+
|
|
1
5
|
export type TireCompound = 'S' | 'M' | 'H';
|
|
2
|
-
export type CameraMode = 'overview' | 'leader' | 'selected';
|
|
6
|
+
export type CameraMode = 'overview' | 'leader' | 'selected' | 'show-all';
|
|
3
7
|
export type RaceBannerMode = 'project' | 'radio' | 'hidden';
|
|
4
8
|
export type RaceBannerEnabledMode = 'project' | 'radio';
|
|
5
9
|
export type RaceDataBannerSize = 'auto' | 'custom';
|
|
@@ -179,6 +183,7 @@ export interface F1SimulatorUiOptions {
|
|
|
179
183
|
enabled?: true | false | RaceBannerEnabledMode[];
|
|
180
184
|
};
|
|
181
185
|
raceDataBannerSize?: RaceDataBannerSize;
|
|
186
|
+
raceDataTelemetryDetail?: boolean;
|
|
182
187
|
timingTowerVerticalFit?: TimingTowerVerticalFit;
|
|
183
188
|
}
|
|
184
189
|
|
|
@@ -323,6 +328,31 @@ export interface F1SimulatorCallbacks {
|
|
|
323
328
|
}) => void;
|
|
324
329
|
}
|
|
325
330
|
|
|
331
|
+
export interface F1SimulatorExpertOptions {
|
|
332
|
+
enabled: boolean;
|
|
333
|
+
controlledDrivers: string[];
|
|
334
|
+
frameSkip?: number;
|
|
335
|
+
visualizeSensors?: boolean | {
|
|
336
|
+
rays?: boolean;
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface F1SimulatorExpertAction {
|
|
341
|
+
steering: number;
|
|
342
|
+
throttle: number;
|
|
343
|
+
brake: number;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface F1SimulatorExpertApi {
|
|
347
|
+
reset(options?: unknown): unknown;
|
|
348
|
+
step(actions: Record<string, F1SimulatorExpertAction>): unknown;
|
|
349
|
+
getObservation(): unknown;
|
|
350
|
+
getState(): unknown;
|
|
351
|
+
getActionSpec(): PaddockActionSpec;
|
|
352
|
+
getObservationSpec(): PaddockObservationSpec;
|
|
353
|
+
destroy(): void;
|
|
354
|
+
}
|
|
355
|
+
|
|
326
356
|
export interface F1SimulatorOptions extends F1SimulatorCallbacks {
|
|
327
357
|
preset?: PaddockPresetName;
|
|
328
358
|
drivers: SimulatorDriver[];
|
|
@@ -339,8 +369,11 @@ export interface F1SimulatorOptions extends F1SimulatorCallbacks {
|
|
|
339
369
|
showBackLink?: boolean;
|
|
340
370
|
ui?: F1SimulatorUiOptions;
|
|
341
371
|
assets?: F1SimulatorAssets;
|
|
372
|
+
expert?: F1SimulatorExpertOptions;
|
|
342
373
|
}
|
|
343
374
|
|
|
375
|
+
export type F1SimulatorRestartOptions = Partial<Omit<F1SimulatorOptions, 'assets' | 'expert'>>;
|
|
376
|
+
|
|
344
377
|
export interface MountRaceCanvasOptions {
|
|
345
378
|
includeRaceDataPanel?: boolean;
|
|
346
379
|
includeTimingTower?: boolean;
|
|
@@ -355,11 +388,13 @@ export interface MountTelemetryPanelOptions {
|
|
|
355
388
|
export interface MountRaceTelemetryDrawerOptions {
|
|
356
389
|
timingTowerVerticalFit?: TimingTowerVerticalFit;
|
|
357
390
|
drawerInitiallyOpen?: boolean;
|
|
391
|
+
raceDataTelemetryDetail?: boolean;
|
|
358
392
|
}
|
|
359
393
|
|
|
360
394
|
export interface F1MountedSimulator {
|
|
395
|
+
readonly expert: F1SimulatorExpertApi | null;
|
|
361
396
|
destroy(): void;
|
|
362
|
-
restart(nextOptions?:
|
|
397
|
+
restart(nextOptions?: F1SimulatorRestartOptions): void;
|
|
363
398
|
selectDriver(driverId: string): void;
|
|
364
399
|
setSafetyCarDeployed(deployed: boolean): void;
|
|
365
400
|
callSafetyCar(): void;
|
|
@@ -369,6 +404,7 @@ export interface F1MountedSimulator {
|
|
|
369
404
|
}
|
|
370
405
|
|
|
371
406
|
export interface PaddockSimulatorController {
|
|
407
|
+
readonly expert: F1SimulatorExpertApi | null;
|
|
372
408
|
mountRaceControls<T extends Element>(root: T): T;
|
|
373
409
|
mountCameraControls<T extends Element>(root: T): T;
|
|
374
410
|
mountSafetyCarControl<T extends Element>(root: T): T;
|
|
@@ -387,7 +423,7 @@ export interface PaddockSimulatorController {
|
|
|
387
423
|
querySelectorAll(selector: string): Element[];
|
|
388
424
|
start(): Promise<PaddockSimulatorController>;
|
|
389
425
|
destroy(): void;
|
|
390
|
-
restart(nextOptions?:
|
|
426
|
+
restart(nextOptions?: F1SimulatorRestartOptions): void;
|
|
391
427
|
selectDriver(driverId: string): void;
|
|
392
428
|
setSafetyCarDeployed(deployed: boolean): void;
|
|
393
429
|
callSafetyCar(): void;
|
|
@@ -416,7 +452,7 @@ export function buildChampionshipDriverGrid(
|
|
|
416
452
|
export function formatDriverNumber(driverNumber: number | string | null | undefined): string;
|
|
417
453
|
export function normalizeSimulatorDrivers(
|
|
418
454
|
drivers: SimulatorDriver[],
|
|
419
|
-
options?: { entries?: ChampionshipEntryBlueprint[] },
|
|
455
|
+
options?: { entries?: ChampionshipEntryBlueprint[]; caller?: string },
|
|
420
456
|
): NormalizedSimulatorDriver[];
|
|
421
457
|
|
|
422
458
|
export function metersToSimUnits(meters: number): number;
|
package/src/index.js
CHANGED
|
@@ -91,13 +91,17 @@ export async function mountF1Simulator(root, options = {}) {
|
|
|
91
91
|
await app.init();
|
|
92
92
|
|
|
93
93
|
return {
|
|
94
|
+
get expert() {
|
|
95
|
+
return app.expert ?? null;
|
|
96
|
+
},
|
|
94
97
|
destroy() {
|
|
95
98
|
app.destroy();
|
|
96
99
|
root.innerHTML = '';
|
|
97
100
|
},
|
|
98
101
|
restart(nextOptions = {}) {
|
|
99
|
-
|
|
100
|
-
app.restart(
|
|
102
|
+
const nextResolvedOptions = resolveF1SimulatorOptions(mergeRestartOptions(resolvedOptions, nextOptions));
|
|
103
|
+
app.restart(nextResolvedOptions);
|
|
104
|
+
resolvedOptions = nextResolvedOptions;
|
|
101
105
|
},
|
|
102
106
|
selectDriver(driverId) {
|
|
103
107
|
app.selectCar(driverId, { focus: true });
|
|
@@ -48,6 +48,12 @@ function textureOrWhite(texture) {
|
|
|
48
48
|
return texture ?? Texture.WHITE;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function destroyDisplayChildren(container) {
|
|
52
|
+
container.removeChildren().forEach((child) => {
|
|
53
|
+
child.destroy?.({ children: true, texture: false, textureSource: false });
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
51
57
|
function arcDistance(track, first, second) {
|
|
52
58
|
const delta = Math.abs(first - second);
|
|
53
59
|
return Math.min(delta, track.length - delta);
|
|
@@ -269,7 +275,7 @@ export class ProceduralTrackAsset {
|
|
|
269
275
|
}
|
|
270
276
|
|
|
271
277
|
render(track) {
|
|
272
|
-
this.container
|
|
278
|
+
destroyDisplayChildren(this.container);
|
|
273
279
|
this.addGrass();
|
|
274
280
|
this.addGravelRunoff(track);
|
|
275
281
|
this.addBoundaryUnderlay(track);
|