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.
- package/CHANGELOG.md +129 -0
- package/package.json +1 -1
- package/src/audio/synth.js +31 -13
- package/src/core/anim.js +22 -0
- package/src/core/engine.js +50 -5
- package/src/core/overrides.js +84 -0
- package/src/core/quality.js +6 -2
- package/src/systems/climbing.js +18 -2
- package/src/systems/coach.js +3 -3
- package/src/systems/combat.js +17 -17
- package/src/systems/crime.js +5 -5
- package/src/systems/deadeye.js +4 -4
- package/src/systems/encounters.js +1 -1
- package/src/systems/enemy.js +9 -9
- package/src/systems/footsteps.js +1 -1
- package/src/systems/loot.js +2 -2
- package/src/systems/npc.js +1 -1
- package/src/systems/player.js +32 -15
- package/src/systems/riding.js +26 -7
- package/src/systems/shop.js +2 -2
- package/src/systems/train.js +32 -9
- package/src/vehicle/car.js +324 -0
- package/src/vehicle/carAudio.js +124 -0
- package/src/world/cityPlan.js +500 -0
- package/src/world/mapWorld.js +120 -0
- package/src/world/roadAssert.js +152 -0
- package/src/world/roadLink.js +162 -16
- package/src/world/roadNetwork.js +40 -0
- package/src/world/roadTerrain.js +150 -8
- package/src/world/roadWorld.js +205 -0
- package/src/world/settlement.js +254 -0
- package/src/world/walkHumps.js +22 -1
- package/tools/modelCatalogue.mjs +59 -0
- package/tools/unityPackageExtract.mjs +56 -0
- package/tools/unitySceneToBuildings.mjs +273 -0
- package/tools/wholeBuildings.mjs +233 -0
|
@@ -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
|
+
}
|