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.
- package/CHANGELOG.md +118 -0
- package/docs/plans/kiwi-road-recon.md +97 -0
- package/docs/plans/road-system.md +142 -0
- package/docs/plans/three-cities.md +43 -0
- package/package.json +3 -2
- package/src/systems/train.js +16 -6
- package/src/vehicle/car.js +321 -0
- package/src/vehicle/carAudio.js +124 -0
- package/src/world/design.js +72 -0
- package/src/world/heightfield.js +146 -0
- package/src/world/kit.js +924 -0
- package/src/world/permanentWay.js +161 -0
- package/src/world/placement.js +309 -0
- package/src/world/roadAssert.js +125 -0
- package/src/world/roadKit.js +110 -0
- package/src/world/roadLink.js +343 -0
- package/src/world/roadNetwork.js +158 -0
- package/src/world/roadTerrain.js +377 -0
- package/src/world/roadWorld.js +200 -0
- package/src/world/waterLevels.js +95 -0
- package/src/world/waterMeshes.js +168 -0
- package/src/world/worldBase.js +493 -0
- package/tools/ExportKiwiRoadMaterials.cs +145 -0
- package/tools/ExportKiwiRoadRefs.cs +94 -0
- package/tools/buildKiwiRoadsPack.mjs +36 -0
- package/tools/designer-mcp.mjs +103 -0
- package/tools/designer.js +316 -0
- package/tools/devPlugin.js +57 -0
- package/tools/fbxToGlb.mjs +115 -0
- package/tools/glbWrite.mjs +62 -0
- package/tools/rebuildSimpleRoundabouts.mjs +211 -0
- package/tools/roadKitAnalyze.mjs +366 -0
- package/tools/truthToAssemblies.mjs +160 -0
- package/tools/unityMeshToGlb.mjs +95 -0
- package/tools/unityPackageExtract.mjs +56 -0
- package/tools/unityPrefabToAssembly.mjs +276 -0
|
@@ -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
|
+
}
|