sindicate 0.13.0 → 0.16.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,321 @@
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
+ pos: { x, y: (seatY ?? y) + CAR_DEFAULTS.rideHeight, z },
168
+ heading, // where the car POINTS
169
+ travel: heading, // where it is actually GOING — the two differ when it slides
170
+ speed: 0,
171
+ vy: 0,
172
+ airborne: false,
173
+ pitch: 0,
174
+ roll: 0,
175
+ slip: 0,
176
+ steer: 0,
177
+ spin: 0, // wheel rotation, radians
178
+ wheels: CORNERS.map((c) => ({ ...c, compression: 0, grounded: true, contactY: y })),
179
+
180
+ // where each wheel sits in world space right now
181
+ wheelPoints() {
182
+ const s = Math.sin(car.heading), c = Math.cos(car.heading);
183
+ return car.wheels.map((w) => ({
184
+ wheel: w,
185
+ x: car.pos.x + s * w.along - c * w.across,
186
+ z: car.pos.z + c * w.along + s * w.across,
187
+ }));
188
+ },
189
+
190
+ // the body's pose, for whatever is drawing it
191
+ pose() {
192
+ return {
193
+ x: car.pos.x, y: car.pos.y, z: car.pos.z,
194
+ heading: car.heading, pitch: car.pitch, roll: car.roll,
195
+ };
196
+ },
197
+
198
+ update(dt, input = {}) {
199
+ const { throttle = 0, brake = false, handbrake = false, steer = 0 } = input;
200
+ const { engine, brakes, grip, suspension } = cfg;
201
+ dt = Math.min(dt, 0.05);
202
+
203
+ // ── engine, brakes, drag ──
204
+ if (throttle > 0) {
205
+ const pull = Math.min(engine.traction, engine.power / Math.max(Math.abs(car.speed), 3));
206
+ car.speed += throttle * pull * dt;
207
+ } else if (throttle < 0) {
208
+ car.speed += throttle * engine.traction * 1.3 * dt; // reverse, and engine braking
209
+ }
210
+ if (brake) {
211
+ // a brake stops you; it does not reverse you
212
+ const b = brakes.force * dt;
213
+ car.speed = Math.abs(car.speed) <= b ? 0 : car.speed - Math.sign(car.speed) * b;
214
+ }
215
+ if (handbrake) car.speed -= Math.sign(car.speed) * brakes.handbrake * dt;
216
+ const resist = (engine.rollingResistance + engine.airDrag * car.speed * car.speed) * dt;
217
+ car.speed = Math.abs(car.speed) <= resist ? 0 : car.speed - Math.sign(car.speed) * resist;
218
+ car.speed = clamp(car.speed, -engine.reverseSpeed, engine.topSpeed);
219
+
220
+ // ── steering, and the slide it can provoke ──
221
+ const authority = Math.min(1, Math.abs(car.speed) / grip.steerBite) * Math.sign(car.speed || 1);
222
+ car.heading += steer * dt * (handbrake ? grip.steerHandbrake : grip.steerRate) * authority;
223
+ const g = handbrake ? grip.handbrake : grip.normal;
224
+ car.slip = wrapPi(car.heading - car.travel);
225
+ car.travel += car.slip * Math.min(1, g * dt);
226
+
227
+ // ── travel across the ground ──
228
+ const fx = Math.sin(car.travel), fz = Math.cos(car.travel);
229
+ const step = { x: fx * car.speed * dt, z: fz * car.speed * dt };
230
+ const next = { x: car.pos.x + step.x, z: car.pos.z + step.z };
231
+
232
+ // ── each wheel finds its own ground ──
233
+ const sh = Math.sin(car.heading), ch = Math.cos(car.heading);
234
+ let sum = 0, grounded = 0;
235
+ const contacts = car.wheels.map((w) => {
236
+ const wx = next.x + sh * w.along - ch * w.across;
237
+ const wz = next.z + ch * w.along + sh * w.across;
238
+ let y = surfaceAt(wx, wz, car.pos.y);
239
+ // A WHEEL CANNOT CLIMB A GUARDRAIL — but the test must be against where the CAR is
240
+ // now, not where this wheel last touched. Measured against a stale per-wheel value,
241
+ // a single missed contact on a ramp (a seam between two deck meshes, say) leaves the
242
+ // wheel's memory metres below the road, and every real contact after that looks like
243
+ // an unclimbable wall: the car can never get back onto the ramp it is driving up.
244
+ // And while airborne there is no limit at all — you may land on anything.
245
+ const wheelLevel = car.pos.y - cfg.rideHeight;
246
+ if (!car.airborne && y != null && y > wheelLevel + cfg.maxStep) y = null;
247
+ w.grounded = y != null;
248
+ if (y != null) { sum += y; grounded++; w.contactY = y; }
249
+ return y ?? w.contactY;
250
+ });
251
+ // NOTHING UNDER ANY WHEEL means falling, not hovering. Holding the car at its current
252
+ // height when every wheel misses leaves it hanging in mid-air indefinitely.
253
+ if (!grounded) car.airborne = true;
254
+ const targetY = (grounded ? sum / grounded : -Infinity) + cfg.rideHeight;
255
+
256
+ // ── airborne or held down ──
257
+ // NOT a fixed threshold: while the wheels are down the ground dictates our vertical
258
+ // speed, and only when it falls away faster than gravity could take us — by a real
259
+ // margin, or contact noise alone will do it — do the wheels have nothing to push on.
260
+ const rate = grounded ? clamp((targetY - car.pos.y) / Math.max(dt, 1e-4), -30, 30) : -30;
261
+ // vy follows the surface rate rather than being set to it: a single settling frame
262
+ // (a car created below its ride height, a wheel finding a new deck) otherwise leaves a
263
+ // large stale value in vy, and every frame after that reads as a launch.
264
+ if (!car.airborne && rate < car.vy - G * dt - cfg.launchMargin) car.airborne = true;
265
+ else if (!car.airborne) car.vy += (rate - car.vy) * Math.min(1, 20 * dt);
266
+
267
+ car.pos.x = next.x;
268
+ car.pos.z = next.z;
269
+ if (car.airborne) {
270
+ car.vy -= G * dt;
271
+ car.pos.y += car.vy * dt;
272
+ if (car.pos.y <= targetY) {
273
+ const hit = car.vy;
274
+ car.pos.y = targetY;
275
+ car.vy = 0;
276
+ car.airborne = false;
277
+ if (hit < -cfg.landingHit) car.speed *= 0.93; // only a real drop costs you
278
+ }
279
+ } else {
280
+ car.pos.y = targetY;
281
+ }
282
+
283
+ // ── suspension: each wheel's compression, and the lean that comes out of it ──
284
+ for (let i = 0; i < car.wheels.length; i++) {
285
+ const w = car.wheels[i];
286
+ const rest = car.pos.y - cfg.rideHeight;
287
+ const want = clamp(rest - contacts[i], -suspension.travel, suspension.travel);
288
+ w.compression += (want - w.compression) * Math.min(1, suspension.damping * dt);
289
+ }
290
+ // LEAN ONLY FROM WHEELS THAT ARE ACTUALLY ON SOMETHING. A wheel over an edge keeps the
291
+ // last height it touched, and averaging that stale value in tips the car onto a
292
+ // permanent angle it can never recover from — the body ends up leaning at rest.
293
+ const mean = (a, b) => {
294
+ const A = car.wheels[a], B = car.wheels[b];
295
+ if (A.grounded && B.grounded) return (A.contactY + B.contactY) / 2;
296
+ if (A.grounded) return A.contactY;
297
+ if (B.grounded) return B.contactY;
298
+ return null;
299
+ };
300
+ const f = mean(0, 1), r = mean(2, 3), l = mean(0, 2), rt = mean(1, 3);
301
+ const targetPitch = car.airborne || f == null || r == null
302
+ ? Math.atan2(car.vy, Math.max(Math.abs(car.speed), 4))
303
+ : Math.atan2(f - r, cfg.wheelbase);
304
+ const targetRoll = car.airborne || l == null || rt == null ? 0 : Math.atan2(l - rt, cfg.track);
305
+ const k = Math.min(1, dt * (car.airborne ? 2 : 9));
306
+ // whatever the ground says, a car does not lie on its side. A cap here means no
307
+ // contact reading, however odd, can ever leave the body at an impossible angle.
308
+ car.pitch = clamp(car.pitch + (targetPitch - car.pitch) * k, -cfg.maxLean, cfg.maxLean);
309
+ car.roll = clamp(car.roll + (targetRoll - car.roll) * k, -cfg.maxLean, cfg.maxLean);
310
+
311
+ // ── what is standing in the road ──
312
+ if (obstacleAt) resolveObstacles(car, dt, obstacleAt);
313
+
314
+ // ── the wheels themselves ──
315
+ car.spin -= (car.speed * dt) / cfg.wheelRadius * (handbrake ? 0.15 : 1);
316
+ car.steer += (steer * 0.5 - car.steer) * Math.min(1, dt * 8);
317
+ return car;
318
+ },
319
+ };
320
+ return car;
321
+ }
@@ -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
+ }
@@ -0,0 +1,72 @@
1
+ // THE DESIGN STAMPER (designer v3) — renders a World Designer layout (designs/<name>.json,
2
+ // authored in /designer.html by hand or by an MCP-driven session) into the live world.
3
+ // MACHINE-WRITTEN LAYOUTS ARE JSON: { name, prefabs: [{ file, dir, x, y?, z, ry?, s?, tex? }],
4
+ // roads: [[{x,z}, ...], ...] }.
5
+ //
6
+ // Same shape as the kit stamper's prop path: prefabs batch per (file|tex) into ONE
7
+ // InstancedMesh each, on the shared townMaterial pipeline (one material per atlas,
8
+ // lamp-lit). Placement seats on the game's ground through the heightAt option unless the
9
+ // entry pins its own y.
10
+ //
11
+ // COLLISION IS THE BAKE'S BUSINESS: batches are named `scatter:` (solid — the world BVH
12
+ // bake picks them up by name) or `scatterDeco:` (walk-through) via opts.solid. Stamping
13
+ // AFTER world.build's bake gives no collision until the next bake — fine for a dev-flag
14
+ // preview; the production path stamps designs before the bake, exactly like the towns.
15
+ //
16
+ // ROADS: not rendered here. A designer road is a ROUTE, and routes belong to the game's
17
+ // terrain survey (road networks bench the ground, paint the surface and re-cook) — the
18
+ // designer's road polylines feed that pipeline game-side; a painted-on decal would lie
19
+ // about where the carriageway really is.
20
+ import * as THREE from 'three/webgpu';
21
+ import { loadModel } from '../core/assets.js';
22
+ import { flattenToGeometry } from './scatter.js';
23
+ import { townMaterial } from './kit.js';
24
+
25
+ export async function buildDesign(scene, design, { heightAt = () => 0, solid = true, defaultTex = null } = {}) {
26
+ const byFile = new Map(); // `${dir}/${file}|${tex}` -> { url, tex, mats: [] }
27
+ const _p = new THREE.Vector3(), _q = new THREE.Quaternion(), _s = new THREE.Vector3();
28
+ const _up = new THREE.Vector3(0, 1, 0);
29
+ for (const e of design.prefabs ?? []) {
30
+ const bare = e.file.replace(/\.(fbx|glb)$/i, '');
31
+ const ext = e.ext ?? (/\.glb$/i.test(e.file) ? 'glb' : 'fbx');
32
+ const url = `${e.dir}/${bare}`;
33
+ e._url = `${url}.${ext}`;
34
+ const tex = e.tex ?? defaultTex;
35
+ const key = `${e._url}|${tex}`;
36
+ if (!byFile.has(key)) byFile.set(key, { url: e._url, tex, mats: [] });
37
+ byFile.get(key).mats.push(new THREE.Matrix4().compose(
38
+ _p.set(e.x, e.y ?? heightAt(e.x, e.z), e.z),
39
+ _q.setFromAxisAngle(_up, e.ry ?? 0),
40
+ _s.setScalar(e.s ?? 1),
41
+ ));
42
+ }
43
+ const meshes = [];
44
+ let count = 0, failed = 0;
45
+ for (const { url, tex, mats } of byFile.values()) {
46
+ try {
47
+ const master = await loadModel(url, tex ? { texture: tex } : {});
48
+ const flat = flattenToGeometry(master);
49
+ if (!flat) { failed++; continue; }
50
+ // textured prefabs ride the shared town pipeline (one material per atlas, lamp-lit);
51
+ // an untextured one gets a plain slate rather than crashing the whole batch pass
52
+ const mat = tex ? await townMaterial(tex, url) : new THREE.MeshStandardMaterial({ color: 0x9a938a, roughness: 0.9 });
53
+ const mesh = new THREE.InstancedMesh(flat.geometry, mat, mats.length);
54
+ mats.forEach((m, i) => mesh.setMatrixAt(i, m));
55
+ mesh.instanceMatrix.needsUpdate = true;
56
+ mesh.castShadow = mats.length < 12;
57
+ mesh.receiveShadow = true;
58
+ const file = url.split('/').pop();
59
+ mesh.name = `${solid ? 'scatter' : 'scatterDeco'}:${file}`;
60
+ mesh.matrixAutoUpdate = false;
61
+ mesh.updateMatrix();
62
+ scene.add(mesh);
63
+ meshes.push(mesh);
64
+ count += mats.length;
65
+ } catch (e) {
66
+ failed++;
67
+ console.warn(`[design] prefab batch ${url} failed`, e?.message ?? e);
68
+ }
69
+ }
70
+ console.log(`[design] '${design.name ?? 'unnamed'}': ${count} prefabs in ${meshes.length} batches${failed ? ` (${failed} files failed)` : ''}${design.roads?.length ? ` · ${design.roads.length} road route(s) carried for the survey` : ''}`);
71
+ return { meshes, count, roads: design.roads ?? [] };
72
+ }
@@ -0,0 +1,146 @@
1
+ // THE HEIGHTFIELD BUILDER (world splits, step 4) — the chunked terrain mesh + cook harness
2
+ // both games carried byte-identically around their own paint and material:
3
+ //
4
+ // · ONE height lattice (every vertex computed exactly once), chunk meshes aligned to it,
5
+ // normals from lattice CENTRAL DIFFERENCES so chunk borders cannot seam
6
+ // · THE TERRAIN COOK — the lattice fill is ~1.74M heightAt calls and the paint pass as
7
+ // many again, all deterministic: cooked to ONE self-writing bin
8
+ // [u32 hlen][JSON {ver,N} pad4][H f32 N²][sand u8 N²][paint u8 4/vert rgba-as-rgbs]
9
+ // fetched by NAME (the game bakes its content fingerprints into cookName, so stale
10
+ // bins 404 and re-cook), recorded back through the dev middleware on a cold boot
11
+ // · the SAND LATTICE (u8 N²) — the game's paint writes it, other systems (grass blade
12
+ // fields) query exactly what the ground shows; returned for the game's sandAt()
13
+ //
14
+ // The GAME supplies: heightAt, the cook name + ver, the node material, and makePaint —
15
+ // a factory receiving { hAt, step, N, sandLattice } and returning the per-vertex painter
16
+ // (x, z, h, li, lj, colors, sands, k), the exact signature both counties already use.
17
+ import * as THREE from 'three/webgpu';
18
+
19
+ export async function buildHeightfield({
20
+ worldSize, segments, chunkTarget = 360,
21
+ heightAt, cookName, cookVer,
22
+ material, makePaint,
23
+ saveRoute = '/__save-cookbin', cookRoute = '/assets/cook',
24
+ }) {
25
+ const CHUNKS = Math.max(1, Math.round(worldSize / chunkTarget));
26
+ if (segments % CHUNKS !== 0) throw new Error(`segments ${segments} not divisible by ${CHUNKS} chunks`);
27
+ const CSEG = segments / CHUNKS; // segments per chunk side
28
+ const CSIZE = worldSize / CHUNKS; // metres per chunk side
29
+ const STEP = worldSize / segments; // lattice spacing
30
+ const N = segments + 1; // lattice verts per side
31
+
32
+ let cook = null;
33
+ try {
34
+ const r = await fetch(`${cookRoute}/${cookName}`);
35
+ if (r.ok) {
36
+ const buf = await r.arrayBuffer();
37
+ const hlen = new DataView(buf).getUint32(0, true);
38
+ const head = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 4, hlen)));
39
+ if (head.N === N) {
40
+ let o = 4 + hlen;
41
+ cook = { H: new Float32Array(buf.slice(o, o + N * N * 4)) };
42
+ o += N * N * 4;
43
+ cook.sand = new Uint8Array(buf.slice(o, o + N * N));
44
+ o += N * N;
45
+ cook.paint = new Uint8Array(buf.slice(o));
46
+ console.log(`[terrain] COOKED lattice+paint from ${cookName} — heightAt/paint compute skipped`);
47
+ }
48
+ }
49
+ } catch (e) { console.warn('[terrain] cook bin missing/unreadable — falling back to the SLOW compute path (first boot after a VER bump is expected; repeated boots are not)', e?.message ?? e); }
50
+
51
+ // ---- height lattice: every terrain vertex height, computed once ----
52
+ const H = cook ? cook.H : new Float32Array(N * N);
53
+ if (!cook) {
54
+ for (let j = 0; j < N; j++) {
55
+ const z = -worldSize / 2 + j * STEP;
56
+ for (let i = 0; i < N; i++) H[j * N + i] = heightAt(-worldSize / 2 + i * STEP, z);
57
+ }
58
+ }
59
+ const hAt = (i, j) => H[Math.min(N - 1, Math.max(0, j)) * N + Math.min(N - 1, Math.max(0, i))];
60
+ const sandLattice = cook ? cook.sand : new Uint8Array(N * N);
61
+ const paintVertex = cook ? null : makePaint({ hAt, step: STEP, N, sandLattice });
62
+
63
+ // ---- build the chunk grid ----
64
+ const group = new THREE.Group();
65
+ group.name = 'terrain';
66
+ let chunkIdx = 0, vertsPerChunk = 0;
67
+ const recChunks = cook ? null : [];
68
+ for (let cj = 0; cj < CHUNKS; cj++) {
69
+ for (let ci = 0; ci < CHUNKS; ci++) {
70
+ const geo = new THREE.PlaneGeometry(CSIZE, CSIZE, CSEG, CSEG);
71
+ geo.rotateX(-Math.PI / 2);
72
+ const cx = -worldSize / 2 + (ci + 0.5) * CSIZE; // chunk centre (mesh position)
73
+ const cz = -worldSize / 2 + (cj + 0.5) * CSIZE;
74
+ const pos = geo.attributes.position;
75
+ const colors = new Float32Array(pos.count * 3);
76
+ const sands = new Float32Array(pos.count);
77
+ const normals = geo.attributes.normal;
78
+ vertsPerChunk = pos.count;
79
+ for (let k = 0; k < pos.count; k++) {
80
+ const x = pos.getX(k) + cx, z = pos.getZ(k) + cz;
81
+ // lattice indices — chunk grids align exactly to the global lattice
82
+ const li = Math.round((x + worldSize / 2) / STEP);
83
+ const lj = Math.round((z + worldSize / 2) / STEP);
84
+ const h = hAt(li, lj);
85
+ pos.setY(k, h);
86
+ // normal from lattice central differences — identical on both sides of
87
+ // a chunk border (computeVertexNormals would seam: edge verts lack
88
+ // neighbour faces from the adjacent chunk). Cheap H arithmetic — always live.
89
+ const nx = -(hAt(li + 1, lj) - hAt(li - 1, lj)) / (2 * STEP);
90
+ const nz = -(hAt(li, lj + 1) - hAt(li, lj - 1)) / (2 * STEP);
91
+ const inv = 1 / Math.hypot(nx, 1, nz);
92
+ normals.setXYZ(k, nx * inv, inv, nz * inv);
93
+ if (cook) {
94
+ // cooked paint: 4 bytes/vert (r,g,b,sand) in chunk build order
95
+ const po = (chunkIdx * pos.count + k) * 4;
96
+ colors[k * 3] = cook.paint[po] / 255;
97
+ colors[k * 3 + 1] = cook.paint[po + 1] / 255;
98
+ colors[k * 3 + 2] = cook.paint[po + 2] / 255;
99
+ sands[k] = cook.paint[po + 3] / 255;
100
+ } else {
101
+ paintVertex(x, z, h, li, lj, colors, sands, k);
102
+ }
103
+ }
104
+ geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
105
+ geo.setAttribute('aSand', new THREE.BufferAttribute(sands, 1));
106
+ recChunks?.push({ colors, sands });
107
+ chunkIdx++;
108
+ const mesh = new THREE.Mesh(geo, material);
109
+ mesh.position.set(cx, 0, cz);
110
+ mesh.receiveShadow = true;
111
+ mesh.name = `terrain:${ci},${cj}`;
112
+ group.add(mesh);
113
+ }
114
+ }
115
+ console.log(`[terrain] ${CHUNKS}x${CHUNKS} chunks (${CSIZE}m, ${CSEG}x${CSEG} segs each) — frustum-culled${cook ? ' · cooked' : ''}`);
116
+
117
+ // record mode: persist lattice + paint for the next boot (dev middleware writes the file)
118
+ if (recChunks) {
119
+ try {
120
+ let head = JSON.stringify({ ver: cookVer, N });
121
+ while ((head.length + 4) % 4) head += ' '; // 4-byte-align the Float32 payload
122
+ const hb = new TextEncoder().encode(head);
123
+ const paint = new Uint8Array(chunkIdx * vertsPerChunk * 4);
124
+ recChunks.forEach(({ colors, sands }, cIdx) => {
125
+ for (let k = 0; k < sands.length; k++) {
126
+ const po = (cIdx * sands.length + k) * 4;
127
+ paint[po] = Math.min(255, Math.round(colors[k * 3] * 255));
128
+ paint[po + 1] = Math.min(255, Math.round(colors[k * 3 + 1] * 255));
129
+ paint[po + 2] = Math.min(255, Math.round(colors[k * 3 + 2] * 255));
130
+ paint[po + 3] = Math.min(255, Math.round(sands[k] * 255));
131
+ }
132
+ });
133
+ const out = new Uint8Array(4 + hb.length + H.byteLength + sandLattice.byteLength + paint.byteLength);
134
+ const dv = new DataView(out.buffer);
135
+ dv.setUint32(0, hb.length, true);
136
+ out.set(hb, 4);
137
+ let o = 4 + hb.length;
138
+ out.set(new Uint8Array(H.buffer), o); o += H.byteLength;
139
+ out.set(sandLattice, o); o += sandLattice.byteLength;
140
+ out.set(paint, o);
141
+ fetch(`${saveRoute}?name=${cookName}`, { method: 'POST', body: out });
142
+ console.log(`[terrain] cooked → ${cookName} (${(out.length / 1048576).toFixed(1)}MB)`);
143
+ } catch (e) { console.warn('[terrain] cook save failed', e); }
144
+ }
145
+ return { group, sandLattice, N, step: STEP, cooked: !!cook };
146
+ }