sindicate 0.15.0 → 0.17.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,324 @@
1
+ // A CAR — suspension, wheels, brakes, grip.
2
+ //
3
+ // Deliberately not a rigid-body solver: a planar body with a wheel at each corner, which is
4
+ // what an open-world game actually needs and what can be reasoned about when it misbehaves.
5
+ // The car owns its physics; a game owns its model, its sounds and its camera.
6
+ //
7
+ // const car = createCar({ surfaceAt });
8
+ // car.update(dt, { throttle, brake, handbrake, steer });
9
+ // // then pose the model from car.pose() and the wheels from car.wheels
10
+ //
11
+ // `surfaceAt(x, z, fromY)` returns the height of whatever is driveable under a point, or
12
+ // null for thin air — a raycast against the road meshes, typically. Each wheel asks
13
+ // separately, which is what gives pitch, roll and the ability to hang a wheel over an edge.
14
+ //
15
+ // `obstacleAt(from, dir, reach)` answers what is standing in the way: { normal, distance } or
16
+ // null. The GAME decides how to answer a ray; the CAR decides where to ask and what to do
17
+ // about the answer, because that is the part with all the traps in it — which is why it
18
+ // belongs here and not in a game's page.
19
+ //
20
+ // The four faults this module exists to prevent, all of them found the hard way:
21
+ // · a body snapped to the ground every frame is glued to it and can never leave a ramp;
22
+ // · an airborne test that compares one frame against gravity flickers on contact noise,
23
+ // and if landing costs speed the car sags to a crawl with the throttle held;
24
+ // · a single collision ray down the nose lets the whole body pass through anything it did
25
+ // not drive at head-on;
26
+ // · a "wall" that is really a rail alongside must never bleed speed.
27
+ const G = 9.81;
28
+
29
+ const wrapPi = (a) => Math.atan2(Math.sin(a), Math.cos(a));
30
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
31
+
32
+ export const CAR_DEFAULTS = {
33
+ // measured off the Synty sedan; a game passes its own model's numbers
34
+ wheelbase: 2.88, // front axle to rear axle
35
+ track: 1.66, // left wheel to right wheel
36
+ wheelRadius: 0.36,
37
+ rideHeight: 0.34, // body origin above the contact patch at rest
38
+ mass: 1300,
39
+
40
+ suspension: {
41
+ travel: 0.22, // how far a wheel may move before it tops or bottoms out
42
+ stiffness: 26, // rad/s² per metre of compression — how hard it pushes back
43
+ damping: 7.5, // how quickly it stops oscillating
44
+ },
45
+
46
+ engine: {
47
+ // A CAR IS POWER-LIMITED, not force-limited. A constant acceleration means it pulls as
48
+ // hard at 120 mph as at walking pace — 0 to 123 mph in three seconds, which is madness.
49
+ // Tyres cap what you can put down low; above that it is power over speed, so the pull
50
+ // fades as the car gains speed and top speed is approached slowly, as it should be.
51
+ traction: 6.5, // m/s² — all the grip will take from a standing start
52
+ power: 152, // m²/s³ — acceleration = power / speed once rolling
53
+ drive: 14, // m/s² — legacy name, still honoured if a game sets it
54
+ topSpeed: 55, // ~200 km/h
55
+ reverseSpeed: 12,
56
+ // COASTING. A single per-second decay is not drag: at 90 km/h a 0.55 decay takes
57
+ // 13.75 m/s² out of the car — harder than most braking — so lifting off felt like
58
+ // standing on the pedal. Real resistance is a near-constant rolling term plus air that
59
+ // only bites at speed, and top speed is held by the limiter rather than by drag.
60
+ rollingResistance: 0.35, // m/s², roughly constant
61
+ airDrag: 0.0008, // m/s² per (m/s)² — about 0.5 m/s² at 90 km/h
62
+ },
63
+
64
+ brakes: {
65
+ force: 22, // m/s² on the pedal
66
+ handbrake: 9, // the rear locking up
67
+ },
68
+
69
+ grip: {
70
+ normal: 7.5, // how fast travel direction is dragged round to face the car
71
+ handbrake: 1.1, // ...and how completely it lets go when the rear locks
72
+ steerRate: 1.5, // radians/s of yaw at speed
73
+ steerHandbrake: 2.6,
74
+ steerBite: 12, // m/s at which steering reaches full authority
75
+ },
76
+
77
+ // A GUARDRAIL IS A THIN BAR ON POSTS. One ray at one height slips under the rail or
78
+ // through the gap between two posts, so whether a fence stops you depends on the angle you
79
+ // happened to approach it at. Feeling for it at several heights catches the bar, the post
80
+ // and the kerb alike.
81
+ body: { halfWidth: 1.0, halfLength: 2.4, probeHeights: [0.25, 0.55, 0.9] },
82
+ maxLean: 0.6, // ~35 deg: no contact reading may tip the body further than this
83
+ launchMargin: 3.5, // the ground must fall away this much faster than gravity to fly
84
+ landingHit: 6, // vertical speed below which a landing costs nothing
85
+ maxStep: 0.45, // the tallest thing a wheel may climb onto in one go
86
+ };
87
+
88
+ // TWO DIFFERENT QUESTIONS, and conflating them is what makes a car crawl: something AHEAD
89
+ // obstructs travel, something ALONGSIDE only stops the body moving sideways into it. A rail
90
+ // you are driving down must never bleed speed. Probes cover the whole body, nose to tail —
91
+ // one ray down the middle lets a back end swing clean through a barrier on a turn.
92
+ function resolveObstacles(car, dt, obstacleAt) {
93
+ const { halfWidth, halfLength } = car.cfg.body;
94
+ const heights = car.cfg.body.probeHeights ?? [car.cfg.body.probeHeight ?? 0.4];
95
+ const fx = Math.sin(car.travel), fz = Math.cos(car.travel);
96
+ const rx = -fz, rz = fx; // the car's right, in world terms
97
+ const ask = (ox, oz, dx, dz, reach) => {
98
+ let best = null;
99
+ for (const h of heights) {
100
+ const hit = obstacleAt({ x: car.pos.x + ox, y: car.pos.y + h, z: car.pos.z + oz }, { x: dx, z: dz }, reach);
101
+ if (hit && (!best || hit.distance < best.distance)) best = hit;
102
+ }
103
+ return best;
104
+ };
105
+
106
+ // AHEAD (or behind, in reverse) — only as far as the car will actually travel this frame,
107
+ // or every curve reads as a collision with the barrier running alongside it
108
+ if (Math.abs(car.speed) > 0.4) {
109
+ const sgn = Math.sign(car.speed);
110
+ const dx = fx * sgn, dz = fz * sgn;
111
+ const nose = halfLength * sgn;
112
+ const reach = 0.2 + Math.abs(car.speed) * dt;
113
+ for (const off of [-halfWidth, 0, halfWidth]) {
114
+ const hit = ask(fx * nose + rx * off, fz * nose + rz * off, dx, dz, reach);
115
+ if (!hit) continue;
116
+ const n = hit.normal;
117
+ // slide along it rather than stopping dead, and only scrub speed for a square hit
118
+ const headOn = clamp(-(n.x * dx + n.z * dz), 0, 1);
119
+ car.pos.x += n.x * (reach - hit.distance);
120
+ car.pos.z += n.z * (reach - hit.distance);
121
+ // square-on stops you quickly; a glancing scrape barely costs anything
122
+ car.speed *= 1 - 6 * headOn * Math.min(dt, 0.05);
123
+ // deflect where the car is GOING, not where it is pointing. Turning the facing leaves
124
+ // it crabbing down the road at the angle of whatever it brushed, long after the
125
+ // contact has gone; grip brings the nose round on its own.
126
+ let alongX = -n.z, alongZ = n.x;
127
+ if (alongX * fx + alongZ * fz < 0) { alongX = -alongX; alongZ = -alongZ; }
128
+ car.travel += wrapPi(Math.atan2(alongX, alongZ) - car.travel) * Math.min(1, dt * 6);
129
+ break;
130
+ }
131
+ }
132
+
133
+ // ALONGSIDE — push the body out, never touch its speed
134
+ for (const s of [-1, 1]) {
135
+ for (const along of [halfLength * 0.92, halfLength * 0.3, -halfLength * 0.3, -halfLength * 0.92]) {
136
+ const hit = ask(fx * along + rx * s * halfWidth, fz * along + rz * s * halfWidth, rx * s, rz * s, 0.35);
137
+ if (!hit) continue;
138
+ car.pos.x += hit.normal.x * (0.35 - hit.distance);
139
+ car.pos.z += hit.normal.z * (0.35 - hit.distance);
140
+ }
141
+ }
142
+ }
143
+
144
+ export function createCar({ surfaceAt, obstacleAt = null, x = 0, y = 0, z = 0, heading = 0, ...opts } = {}) {
145
+ const cfg = {
146
+ ...CAR_DEFAULTS, ...opts,
147
+ body: { ...CAR_DEFAULTS.body, ...(opts.body ?? {}) },
148
+ suspension: { ...CAR_DEFAULTS.suspension, ...(opts.suspension ?? {}) },
149
+ engine: { ...CAR_DEFAULTS.engine, ...(opts.engine ?? {}) },
150
+ brakes: { ...CAR_DEFAULTS.brakes, ...(opts.brakes ?? {}) },
151
+ grip: { ...CAR_DEFAULTS.grip, ...(opts.grip ?? {}) },
152
+ };
153
+ const halfL = cfg.wheelbase / 2, halfT = cfg.track / 2;
154
+
155
+ // wheel order: front-left, front-right, rear-left, rear-right
156
+ const CORNERS = [
157
+ { id: 'fl', along: halfL, across: -halfT, front: true },
158
+ { id: 'fr', along: halfL, across: halfT, front: true },
159
+ { id: 'rl', along: -halfL, across: -halfT, front: false },
160
+ { id: 'rr', along: -halfL, across: halfT, front: false },
161
+ ];
162
+
163
+ // seat the car ON the ground it was placed over, so it never starts by falling into place
164
+ const seatY = surfaceAt?.(x, z, y);
165
+ const car = {
166
+ cfg,
167
+ // cfg, NOT CAR_DEFAULTS. Every later frame uses cfg.rideHeight, so spawning from the
168
+ // default drops a vehicle with a custom one at the wrong height and pops it up on the
169
+ // first update — a tank at 0.9 starts 0.56 m low and jumps.
170
+ pos: { x, y: (seatY ?? y) + cfg.rideHeight, z },
171
+ heading, // where the car POINTS
172
+ travel: heading, // where it is actually GOING — the two differ when it slides
173
+ speed: 0,
174
+ vy: 0,
175
+ airborne: false,
176
+ pitch: 0,
177
+ roll: 0,
178
+ slip: 0,
179
+ steer: 0,
180
+ spin: 0, // wheel rotation, radians
181
+ wheels: CORNERS.map((c) => ({ ...c, compression: 0, grounded: true, contactY: y })),
182
+
183
+ // where each wheel sits in world space right now
184
+ wheelPoints() {
185
+ const s = Math.sin(car.heading), c = Math.cos(car.heading);
186
+ return car.wheels.map((w) => ({
187
+ wheel: w,
188
+ x: car.pos.x + s * w.along - c * w.across,
189
+ z: car.pos.z + c * w.along + s * w.across,
190
+ }));
191
+ },
192
+
193
+ // the body's pose, for whatever is drawing it
194
+ pose() {
195
+ return {
196
+ x: car.pos.x, y: car.pos.y, z: car.pos.z,
197
+ heading: car.heading, pitch: car.pitch, roll: car.roll,
198
+ };
199
+ },
200
+
201
+ update(dt, input = {}) {
202
+ const { throttle = 0, brake = false, handbrake = false, steer = 0 } = input;
203
+ const { engine, brakes, grip, suspension } = cfg;
204
+ dt = Math.min(dt, 0.05);
205
+
206
+ // ── engine, brakes, drag ──
207
+ if (throttle > 0) {
208
+ const pull = Math.min(engine.traction, engine.power / Math.max(Math.abs(car.speed), 3));
209
+ car.speed += throttle * pull * dt;
210
+ } else if (throttle < 0) {
211
+ car.speed += throttle * engine.traction * 1.3 * dt; // reverse, and engine braking
212
+ }
213
+ if (brake) {
214
+ // a brake stops you; it does not reverse you
215
+ const b = brakes.force * dt;
216
+ car.speed = Math.abs(car.speed) <= b ? 0 : car.speed - Math.sign(car.speed) * b;
217
+ }
218
+ if (handbrake) car.speed -= Math.sign(car.speed) * brakes.handbrake * dt;
219
+ const resist = (engine.rollingResistance + engine.airDrag * car.speed * car.speed) * dt;
220
+ car.speed = Math.abs(car.speed) <= resist ? 0 : car.speed - Math.sign(car.speed) * resist;
221
+ car.speed = clamp(car.speed, -engine.reverseSpeed, engine.topSpeed);
222
+
223
+ // ── steering, and the slide it can provoke ──
224
+ const authority = Math.min(1, Math.abs(car.speed) / grip.steerBite) * Math.sign(car.speed || 1);
225
+ car.heading += steer * dt * (handbrake ? grip.steerHandbrake : grip.steerRate) * authority;
226
+ const g = handbrake ? grip.handbrake : grip.normal;
227
+ car.slip = wrapPi(car.heading - car.travel);
228
+ car.travel += car.slip * Math.min(1, g * dt);
229
+
230
+ // ── travel across the ground ──
231
+ const fx = Math.sin(car.travel), fz = Math.cos(car.travel);
232
+ const step = { x: fx * car.speed * dt, z: fz * car.speed * dt };
233
+ const next = { x: car.pos.x + step.x, z: car.pos.z + step.z };
234
+
235
+ // ── each wheel finds its own ground ──
236
+ const sh = Math.sin(car.heading), ch = Math.cos(car.heading);
237
+ let sum = 0, grounded = 0;
238
+ const contacts = car.wheels.map((w) => {
239
+ const wx = next.x + sh * w.along - ch * w.across;
240
+ const wz = next.z + ch * w.along + sh * w.across;
241
+ let y = surfaceAt(wx, wz, car.pos.y);
242
+ // A WHEEL CANNOT CLIMB A GUARDRAIL — but the test must be against where the CAR is
243
+ // now, not where this wheel last touched. Measured against a stale per-wheel value,
244
+ // a single missed contact on a ramp (a seam between two deck meshes, say) leaves the
245
+ // wheel's memory metres below the road, and every real contact after that looks like
246
+ // an unclimbable wall: the car can never get back onto the ramp it is driving up.
247
+ // And while airborne there is no limit at all — you may land on anything.
248
+ const wheelLevel = car.pos.y - cfg.rideHeight;
249
+ if (!car.airborne && y != null && y > wheelLevel + cfg.maxStep) y = null;
250
+ w.grounded = y != null;
251
+ if (y != null) { sum += y; grounded++; w.contactY = y; }
252
+ return y ?? w.contactY;
253
+ });
254
+ // NOTHING UNDER ANY WHEEL means falling, not hovering. Holding the car at its current
255
+ // height when every wheel misses leaves it hanging in mid-air indefinitely.
256
+ if (!grounded) car.airborne = true;
257
+ const targetY = (grounded ? sum / grounded : -Infinity) + cfg.rideHeight;
258
+
259
+ // ── airborne or held down ──
260
+ // NOT a fixed threshold: while the wheels are down the ground dictates our vertical
261
+ // speed, and only when it falls away faster than gravity could take us — by a real
262
+ // margin, or contact noise alone will do it — do the wheels have nothing to push on.
263
+ const rate = grounded ? clamp((targetY - car.pos.y) / Math.max(dt, 1e-4), -30, 30) : -30;
264
+ // vy follows the surface rate rather than being set to it: a single settling frame
265
+ // (a car created below its ride height, a wheel finding a new deck) otherwise leaves a
266
+ // large stale value in vy, and every frame after that reads as a launch.
267
+ if (!car.airborne && rate < car.vy - G * dt - cfg.launchMargin) car.airborne = true;
268
+ else if (!car.airborne) car.vy += (rate - car.vy) * Math.min(1, 20 * dt);
269
+
270
+ car.pos.x = next.x;
271
+ car.pos.z = next.z;
272
+ if (car.airborne) {
273
+ car.vy -= G * dt;
274
+ car.pos.y += car.vy * dt;
275
+ if (car.pos.y <= targetY) {
276
+ const hit = car.vy;
277
+ car.pos.y = targetY;
278
+ car.vy = 0;
279
+ car.airborne = false;
280
+ if (hit < -cfg.landingHit) car.speed *= 0.93; // only a real drop costs you
281
+ }
282
+ } else {
283
+ car.pos.y = targetY;
284
+ }
285
+
286
+ // ── suspension: each wheel's compression, and the lean that comes out of it ──
287
+ for (let i = 0; i < car.wheels.length; i++) {
288
+ const w = car.wheels[i];
289
+ const rest = car.pos.y - cfg.rideHeight;
290
+ const want = clamp(rest - contacts[i], -suspension.travel, suspension.travel);
291
+ w.compression += (want - w.compression) * Math.min(1, suspension.damping * dt);
292
+ }
293
+ // LEAN ONLY FROM WHEELS THAT ARE ACTUALLY ON SOMETHING. A wheel over an edge keeps the
294
+ // last height it touched, and averaging that stale value in tips the car onto a
295
+ // permanent angle it can never recover from — the body ends up leaning at rest.
296
+ const mean = (a, b) => {
297
+ const A = car.wheels[a], B = car.wheels[b];
298
+ if (A.grounded && B.grounded) return (A.contactY + B.contactY) / 2;
299
+ if (A.grounded) return A.contactY;
300
+ if (B.grounded) return B.contactY;
301
+ return null;
302
+ };
303
+ const f = mean(0, 1), r = mean(2, 3), l = mean(0, 2), rt = mean(1, 3);
304
+ const targetPitch = car.airborne || f == null || r == null
305
+ ? Math.atan2(car.vy, Math.max(Math.abs(car.speed), 4))
306
+ : Math.atan2(f - r, cfg.wheelbase);
307
+ const targetRoll = car.airborne || l == null || rt == null ? 0 : Math.atan2(l - rt, cfg.track);
308
+ const k = Math.min(1, dt * (car.airborne ? 2 : 9));
309
+ // whatever the ground says, a car does not lie on its side. A cap here means no
310
+ // contact reading, however odd, can ever leave the body at an impossible angle.
311
+ car.pitch = clamp(car.pitch + (targetPitch - car.pitch) * k, -cfg.maxLean, cfg.maxLean);
312
+ car.roll = clamp(car.roll + (targetRoll - car.roll) * k, -cfg.maxLean, cfg.maxLean);
313
+
314
+ // ── what is standing in the road ──
315
+ if (obstacleAt) resolveObstacles(car, dt, obstacleAt);
316
+
317
+ // ── the wheels themselves ──
318
+ car.spin -= (car.speed * dt) / cfg.wheelRadius * (handbrake ? 0.15 : 1);
319
+ car.steer += (steer * 0.5 - car.steer) * Math.min(1, dt * 8);
320
+ return car;
321
+ },
322
+ };
323
+ return car;
324
+ }
@@ -0,0 +1,124 @@
1
+ // CAR AUDIO — the engine you hear is four loops and a crossfade.
2
+ //
3
+ // The classic arrangement, and the one Kiwi used: acceleration and deceleration recorded at
4
+ // low and high revs. Load rises with throttle, revs rise with speed, and the four are mixed
5
+ // so that pulling away sounds like pulling away and lifting off sounds like lifting off.
6
+ // Playback rate stretches each loop between its samples so the pitch tracks the revs
7
+ // continuously rather than in steps.
8
+ //
9
+ // const audio = createCarAudio({ listener, clips }); // clips: AudioBuffers
10
+ // audio.update(dt, { speed, topSpeed, throttle, slip, airborne });
11
+ //
12
+ // A game supplies the buffers, because a game owns its content. Everything here is Web Audio:
13
+ // no three.js, so it can be tested and reasoned about on its own.
14
+
15
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
16
+
17
+ export const CAR_AUDIO_DEFAULTS = {
18
+ idleRevs: 0.12, // revs at a standstill — an engine is never silent
19
+ revSmoothing: 4, // how fast the revs chase the wheels (per second)
20
+ loadSmoothing: 6, // ...and how fast the engine loads up under throttle
21
+ pitchLow: 0.75, // playback rate for a clip at the bottom of its range
22
+ pitchHigh: 1.55, // ...and at the top
23
+ crossover: 0.55, // revs at which the high clips take over from the low
24
+ crossWidth: 0.28, // how broad that handover is
25
+ skidSlip: 0.28, // radians of slide before the tyres start to protest
26
+ masterGain: 0.8,
27
+ };
28
+
29
+ export function createCarAudio({ context, destination, clips = {}, ...opts } = {}) {
30
+ const cfg = { ...CAR_AUDIO_DEFAULTS, ...opts };
31
+ const ctx = context ?? new (globalThis.AudioContext ?? globalThis.webkitAudioContext)();
32
+ const out = ctx.createGain();
33
+ out.gain.value = cfg.masterGain;
34
+ out.connect(destination ?? ctx.destination);
35
+
36
+ // one looping voice per clip; they all run all the time and are mixed by gain, because
37
+ // starting and stopping sources is what makes engine audio stutter
38
+ const voice = (buffer) => {
39
+ if (!buffer) return null;
40
+ const src = ctx.createBufferSource();
41
+ const gain = ctx.createGain();
42
+ src.buffer = buffer;
43
+ src.loop = true;
44
+ gain.gain.value = 0;
45
+ src.connect(gain).connect(out);
46
+ src.start();
47
+ return { src, gain };
48
+ };
49
+ const voices = {
50
+ accelLow: voice(clips.accelLow),
51
+ accelHigh: voice(clips.accelHigh),
52
+ decelLow: voice(clips.decelLow),
53
+ decelHigh: voice(clips.decelHigh),
54
+ skid: voice(clips.skid),
55
+ };
56
+
57
+ const state = { revs: cfg.idleRevs, load: 0 };
58
+
59
+ const set = (v, value, dt) => {
60
+ if (!v) return;
61
+ // ramp rather than assign: a stepped gain is an audible click at 60 fps
62
+ v.gain.gain.setTargetAtTime(value, ctx.currentTime, Math.max(dt, 0.02));
63
+ };
64
+
65
+ return {
66
+ context: ctx,
67
+ state,
68
+
69
+ update(dt, { speed = 0, topSpeed = 55, throttle = 0, slip = 0, airborne = false } = {}) {
70
+ const wheelRevs = clamp01(Math.abs(speed) / Math.max(topSpeed, 1e-3));
71
+ // revs chase the wheels but never fall to silence, and a car in the air revs freely
72
+ const targetRevs = airborne ? Math.max(wheelRevs, 0.7) : Math.max(wheelRevs, cfg.idleRevs);
73
+ state.revs += (targetRevs - state.revs) * Math.min(1, cfg.revSmoothing * dt);
74
+ state.load += (clamp01(throttle) - state.load) * Math.min(1, cfg.loadSmoothing * dt);
75
+
76
+ // low clips hand over to high across a band, so there is no seam at one rev value
77
+ const high = clamp01((state.revs - cfg.crossover) / cfg.crossWidth + 0.5);
78
+ const low = 1 - high;
79
+ const onPower = state.load; // accelerating vs coasting
80
+ const rate = cfg.pitchLow + (cfg.pitchHigh - cfg.pitchLow) * state.revs;
81
+ for (const v of Object.values(voices)) {
82
+ if (v && v !== voices.skid) v.src.playbackRate.setTargetAtTime(rate, ctx.currentTime, 0.05);
83
+ }
84
+ set(voices.accelLow, low * onPower, dt);
85
+ set(voices.accelHigh, high * onPower, dt);
86
+ set(voices.decelLow, low * (1 - onPower), dt);
87
+ set(voices.decelHigh, high * (1 - onPower), dt);
88
+
89
+ // tyres complain in proportion to how far the car is sliding, and not at all in the air
90
+ const squeal = airborne ? 0 : clamp01((Math.abs(slip) - cfg.skidSlip) / 0.6) * clamp01(Math.abs(speed) / 8);
91
+ set(voices.skid, squeal, dt);
92
+ return state;
93
+ },
94
+
95
+ // a one-shot, for the horn or a collision
96
+ play(buffer, gain = 1) {
97
+ if (!buffer) return;
98
+ const src = ctx.createBufferSource();
99
+ const g = ctx.createGain();
100
+ src.buffer = buffer;
101
+ g.gain.value = gain;
102
+ src.connect(g).connect(out);
103
+ src.start();
104
+ },
105
+
106
+ stop() {
107
+ for (const v of Object.values(voices)) v?.src.stop();
108
+ },
109
+ };
110
+ }
111
+
112
+ // Load a set of clips by URL. Returns whatever decoded, so a missing file silences one voice
113
+ // instead of taking the whole car with it.
114
+ export async function loadCarClips(context, urls = {}) {
115
+ const clips = {};
116
+ await Promise.all(Object.entries(urls).map(async ([name, url]) => {
117
+ try {
118
+ const res = await fetch(url);
119
+ if (!res.ok) return;
120
+ clips[name] = await context.decodeAudioData(await res.arrayBuffer());
121
+ } catch { /* a car with no sound still drives */ }
122
+ }));
123
+ return clips;
124
+ }