@inventure71/paddockjs 0.1.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 +16 -0
- package/INSTALL_AND_UPDATE.md +163 -0
- package/README.md +96 -16
- package/docs/architecture.md +55 -11
- package/docs/concepts.md +5 -1
- package/docs/data_contract.md +324 -8
- package/docs/data_model.md +1 -0
- package/docs/index.md +1 -0
- package/docs/rules.md +2 -0
- package/docs/system_specs.md +66 -11
- package/docs/training.md +104 -0
- package/examples/train-basic-policy.mjs +277 -0
- package/package.json +14 -7
- package/src/api/PaddockSimulatorController.js +64 -3
- package/src/app/BrowserExpertAdapter.js +32 -0
- package/src/app/F1SimulatorApp.js +308 -38
- package/src/app/domBindings.js +35 -11
- package/src/config/defaultOptions.js +33 -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 +97 -4
- package/src/index.js +12 -2
- package/src/rendering/proceduralTrackAsset.js +7 -1
- package/src/simulation/raceSimulation.js +214 -1
- package/src/simulation/trackModel.js +21 -0
- package/src/styles.css +495 -2
- package/src/ui/componentTemplates.js +201 -4
- package/AGENTS.md +0 -86
- package/src/standalone.js +0 -15
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { CHAMPIONSHIP_ENTRY_BLUEPRINTS } from '../data/championship.js';
|
|
2
|
+
import { normalizeSimulatorDrivers } from '../data/normalizeDrivers.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_SEED = 1971;
|
|
5
|
+
const DEFAULT_FRAME_SKIP = 1;
|
|
6
|
+
const DEFAULT_MAX_STEPS = 10000;
|
|
7
|
+
|
|
8
|
+
const DEFAULT_SENSOR_OPTIONS = {
|
|
9
|
+
rays: {
|
|
10
|
+
enabled: true,
|
|
11
|
+
anglesDegrees: [-135, -60, -20, 0, 20, 60, 135, 180],
|
|
12
|
+
lengthMeters: 120,
|
|
13
|
+
detectTrack: true,
|
|
14
|
+
detectCars: true,
|
|
15
|
+
},
|
|
16
|
+
nearbyCars: {
|
|
17
|
+
enabled: true,
|
|
18
|
+
maxCars: 6,
|
|
19
|
+
radiusMeters: 150,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function resolveEnvironmentOptions(options = {}) {
|
|
24
|
+
if (!Array.isArray(options.controlledDrivers) || options.controlledDrivers.length === 0) {
|
|
25
|
+
throw new Error('PaddockJS environment controlledDrivers is required.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const seed = normalizeSeed(options.seed, createGeneratedSeed);
|
|
29
|
+
const trackSeed = normalizeSeed(options.trackSeed, createGeneratedSeed);
|
|
30
|
+
const normalizedDrivers = normalizeSimulatorDrivers(options.drivers, {
|
|
31
|
+
entries: options.entries ?? CHAMPIONSHIP_ENTRY_BLUEPRINTS,
|
|
32
|
+
caller: 'createPaddockEnvironment',
|
|
33
|
+
});
|
|
34
|
+
const resolved = {
|
|
35
|
+
...options,
|
|
36
|
+
seed,
|
|
37
|
+
trackSeed,
|
|
38
|
+
totalLaps: options.totalLaps,
|
|
39
|
+
drivers: normalizedDrivers,
|
|
40
|
+
};
|
|
41
|
+
const driverIds = new Set(resolved.drivers.map((driver) => driver.id));
|
|
42
|
+
const controlledDrivers = [...new Set(options.controlledDrivers)];
|
|
43
|
+
|
|
44
|
+
controlledDrivers.forEach((driverId) => {
|
|
45
|
+
if (!driverIds.has(driverId)) {
|
|
46
|
+
throw new Error(`PaddockJS environment controlled driver does not exist: ${driverId}`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const scenario = resolveScenario(options.scenario, controlledDrivers, driverIds);
|
|
51
|
+
const participants = resolveParticipants(scenario.participants, controlledDrivers, resolved.drivers);
|
|
52
|
+
const participantDrivers = resolved.drivers.filter((driver) => participants.has(driver.id));
|
|
53
|
+
const participantIds = new Set(participantDrivers.map((driver) => driver.id));
|
|
54
|
+
|
|
55
|
+
controlledDrivers.forEach((driverId) => {
|
|
56
|
+
if (!participantIds.has(driverId)) {
|
|
57
|
+
throw new Error(`PaddockJS environment controlled driver must be included in scenario participants: ${driverId}`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
...resolved,
|
|
63
|
+
drivers: participantDrivers,
|
|
64
|
+
controlledDrivers,
|
|
65
|
+
frameSkip: normalizePositiveInteger(options.frameSkip, DEFAULT_FRAME_SKIP, 'frameSkip'),
|
|
66
|
+
actionPolicy: options.actionPolicy === 'report' ? 'report' : 'strict',
|
|
67
|
+
scenario,
|
|
68
|
+
sensors: mergeSensorOptions(options.sensors),
|
|
69
|
+
sensorsByDriver: options.sensorsByDriver ?? {},
|
|
70
|
+
episode: {
|
|
71
|
+
maxSteps: normalizePositiveInteger(options.episode?.maxSteps, DEFAULT_MAX_STEPS, 'episode.maxSteps'),
|
|
72
|
+
endOnRaceFinish: options.episode?.endOnRaceFinish !== false,
|
|
73
|
+
},
|
|
74
|
+
reward: typeof options.reward === 'function' ? options.reward : null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeSeed(value, fallbackFactory) {
|
|
79
|
+
if (value == null) return fallbackFactory();
|
|
80
|
+
const number = Number(value);
|
|
81
|
+
return Number.isFinite(number) ? number : DEFAULT_SEED;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createGeneratedSeed() {
|
|
85
|
+
const values = new Uint32Array(1);
|
|
86
|
+
try {
|
|
87
|
+
globalThis.crypto?.getRandomValues?.(values);
|
|
88
|
+
} catch {
|
|
89
|
+
values[0] = 0;
|
|
90
|
+
}
|
|
91
|
+
return (values[0] || Math.floor(Date.now() + performance.now() * 1000)) >>> 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveScenario(scenario = {}, controlledDrivers, driverIds) {
|
|
95
|
+
const participants = scenario.participants ?? 'all';
|
|
96
|
+
if (
|
|
97
|
+
participants !== 'all' &&
|
|
98
|
+
participants !== 'controlled-only' &&
|
|
99
|
+
!Array.isArray(participants)
|
|
100
|
+
) {
|
|
101
|
+
throw new Error('PaddockJS environment scenario.participants must be "all", "controlled-only", or an array of driver ids.');
|
|
102
|
+
}
|
|
103
|
+
if (scenario.nonControlled != null && scenario.nonControlled !== 'ai') {
|
|
104
|
+
throw new Error('PaddockJS environment first slice only supports scenario.nonControlled: "ai".');
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(participants)) {
|
|
107
|
+
participants.forEach((driverId) => {
|
|
108
|
+
if (!driverIds.has(driverId)) {
|
|
109
|
+
throw new Error(`PaddockJS environment scenario participant does not exist: ${driverId}`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
controlledDrivers.forEach((driverId) => {
|
|
113
|
+
if (!participants.includes(driverId)) {
|
|
114
|
+
throw new Error(`PaddockJS environment controlled driver must be included in scenario participants: ${driverId}`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
participants,
|
|
120
|
+
nonControlled: 'ai',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function resolveParticipants(participants, controlledDrivers, drivers) {
|
|
125
|
+
if (participants === 'controlled-only') return new Set(controlledDrivers);
|
|
126
|
+
if (Array.isArray(participants)) return new Set(participants);
|
|
127
|
+
return new Set(drivers.map((driver) => driver.id));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizePositiveInteger(value, fallback, label) {
|
|
131
|
+
if (value == null) return fallback;
|
|
132
|
+
const number = Math.floor(Number(value));
|
|
133
|
+
if (!Number.isFinite(number) || number < 1) {
|
|
134
|
+
throw new Error(`PaddockJS environment ${label} must be a positive integer.`);
|
|
135
|
+
}
|
|
136
|
+
return number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function mergeSensorOptions(sensors = {}) {
|
|
140
|
+
return {
|
|
141
|
+
rays: {
|
|
142
|
+
...DEFAULT_SENSOR_OPTIONS.rays,
|
|
143
|
+
...(sensors.rays ?? {}),
|
|
144
|
+
},
|
|
145
|
+
nearbyCars: {
|
|
146
|
+
...DEFAULT_SENSOR_OPTIONS.nearbyCars,
|
|
147
|
+
...(sensors.nearbyCars ?? {}),
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -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
|
+
}
|