sindicate 0.15.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 +49 -0
- package/package.json +1 -1
- package/src/systems/train.js +16 -6
- package/src/vehicle/car.js +321 -0
- package/src/vehicle/carAudio.js +124 -0
- package/src/world/roadAssert.js +125 -0
- package/src/world/roadLink.js +74 -1
- package/src/world/roadNetwork.js +40 -0
- package/src/world/roadTerrain.js +122 -8
- package/src/world/roadWorld.js +200 -0
- package/tools/unityPackageExtract.mjs +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0 — driving on it
|
|
4
|
+
|
|
5
|
+
### A car (new: `vehicle/`)
|
|
6
|
+
|
|
7
|
+
- `vehicle/car.js` — a wheel at each corner asking the world for its own ground, so pitch,
|
|
8
|
+
roll and hanging a wheel over an edge all fall out of it. Spring-damped suspension, a
|
|
9
|
+
power-limited engine (0–60 in 4.6 s, not 0–123 in three), brakes, and a handbrake that
|
|
10
|
+
genuinely breaks grip: the body points one way and travels another when it slides. It also
|
|
11
|
+
owns where to probe for obstacles and what to do about a hit — the game only answers a ray.
|
|
12
|
+
- `vehicle/carAudio.js` — the classic four-loop engine: acceleration and deceleration at low
|
|
13
|
+
and high revs, crossfaded by revs and throttle, with tyres that protest in proportion to
|
|
14
|
+
the slide.
|
|
15
|
+
|
|
16
|
+
The header of `car.js` lists the faults it exists to prevent, each found the hard way: a body
|
|
17
|
+
snapped to the ground is glued to it; an airborne test against one frame of gravity flickers
|
|
18
|
+
on contact noise and sags the car to a crawl; one ray down the nose lets the body pass through
|
|
19
|
+
anything not hit head-on; a rail alongside must never bleed speed.
|
|
20
|
+
|
|
21
|
+
### Roads as a world-build step
|
|
22
|
+
|
|
23
|
+
- `world/roadWorld.js` — one call that runs the road build in the only order that works and
|
|
24
|
+
hands back a `heightAt` for `buildHeightfield`, geometry to stamp before the BVH bake, and
|
|
25
|
+
the solved network. Site, seat each piece at ITS grade, wrap the ground with pads, THEN
|
|
26
|
+
generate links over that padded ground, then wrap again with their corridors.
|
|
27
|
+
- `world/roadAssert.js` — what must be true of a road world: no ground above a road, no road
|
|
28
|
+
floating over a gap, every link meeting both sockets, every link drivable at its grade,
|
|
29
|
+
every junction seated on its own ground. Cheap enough for every cold build. It found three
|
|
30
|
+
real faults on its first run.
|
|
31
|
+
- `world/roadNetwork.js` — `planLoop`/`loopTangents` for closed circuits.
|
|
32
|
+
- `world/roadLink.js` — `buildDeck`, and rail lines (median and shoulders) reported with the
|
|
33
|
+
pitch of the ground they sit on.
|
|
34
|
+
- `world/roadTerrain.js` — the ground now guarantees it never sits above a road, pads fade
|
|
35
|
+
into the landscape rather than ending at a rim, `heightAt.road`/`heightAt.deck` answer where
|
|
36
|
+
the driveable surface is, and the whole wrapper is bucketed (25× → 10.7× over bare terrain
|
|
37
|
+
across a 1.7 M-vertex lattice).
|
|
38
|
+
|
|
39
|
+
### Tools
|
|
40
|
+
|
|
41
|
+
- `tools/unityPackageExtract.mjs` — pull named assets out of a `.unitypackage` without staging
|
|
42
|
+
the whole thing.
|
|
43
|
+
- `tools/fbxToGlb.mjs`, `tools/roadKitAnalyze.mjs`, `tools/buildKiwiRoadsPack.mjs`.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **Trains ran beside the track, not on it.** `train.js` still reached for a county's railway
|
|
48
|
+
data through `import.meta.glob`, which inside the engine resolves against the engine's own
|
|
49
|
+
folder and finds nothing — so the fleet silently graded its own polyline. The solved survey
|
|
50
|
+
goes in through `setTrainContent`.
|
|
51
|
+
|
|
3
52
|
## 0.15.0 — the world-building release
|
|
4
53
|
|
|
5
54
|
Everything between the last published version (0.13.0) and here. 0.14.0 was bumped in the
|
package/package.json
CHANGED
package/src/systems/train.js
CHANGED
|
@@ -38,6 +38,13 @@ export function setTrainContent(c = {}) {
|
|
|
38
38
|
if (c.colliders) colliders = c.colliders;
|
|
39
39
|
if (c.dropItemsFor) dropItemsFor = c.dropItemsFor;
|
|
40
40
|
if (c.railPaths) SURVEY_PATHS = c.railPaths;
|
|
41
|
+
// THE TRACK AUTHOR'S OWN SURVEY. Without it the fleet grades its own polyline and runs
|
|
42
|
+
// beside the rails the county actually laid.
|
|
43
|
+
if (c.railLines) LINES_IN = c.railLines;
|
|
44
|
+
if (c.railLineStops) STOPS_IN = c.railLineStops;
|
|
45
|
+
if (c.levelCrossing) XING_IN = c.levelCrossing;
|
|
46
|
+
if (c.junction) JUNC_IN = c.junction;
|
|
47
|
+
if (c.railTop != null) { RAIL_TOP = c.railTop; RAIL_Y = RAIL_TOP - FLANGE; }
|
|
41
48
|
if (c.railStops) SURVEY_STOPS = c.railStops;
|
|
42
49
|
if (c.crossing !== undefined) SURVEY_CROSSING = c.crossing;
|
|
43
50
|
if (c.halts) RAIL_HALTS = c.halts;
|
|
@@ -89,18 +96,21 @@ import { attachTrainCamera } from './trainCamera.js';
|
|
|
89
96
|
const _mods = import.meta.glob(['../world/railway.js', '../world/data/railway.js'], { eager: true });
|
|
90
97
|
const RD = _mods['../world/data/railway.js'] ?? {};
|
|
91
98
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
// ...and inside the ENGINE that glob resolves against the engine's own folder, where no
|
|
100
|
+
// county's railway data lives — so these MUST be injectable. A game hands its solved survey
|
|
101
|
+
// to setTrainContent; the glob remains for a game that still keeps train.js's neighbours.
|
|
102
|
+
let LINES_IN = RD.RAIL_LINES ?? null; // { main: {nodes,len}, spur: {nodes,len} }
|
|
103
|
+
let STOPS_IN = RD.RAIL_STOPS ?? null; // [{ id, name, line, s, platformSide, wait }]
|
|
104
|
+
let XING_IN = RD.LEVEL_CROSSING ?? null; // { line, s, x, z }
|
|
105
|
+
let JUNC_IN = RD.JUNCTION ?? null; // { line, s, x, z }
|
|
96
106
|
|
|
97
107
|
// The railhead above the formation — his number, not mine. A car's origin sits at RAILHEAD CONTACT
|
|
98
108
|
// (measured: min.y = 0 on all four models), so this is exactly how high off the ballast it rides,
|
|
99
109
|
// less a flange: the wheel's lowest point is its flange, not its tread, and standing the tread ON
|
|
100
110
|
// the rail is what closes the hairline of daylight under a stationary loco.
|
|
101
|
-
|
|
111
|
+
let RAIL_TOP = RD.RAIL_TOP ?? 0.3285;
|
|
102
112
|
const FLANGE = 0.06;
|
|
103
|
-
|
|
113
|
+
let RAIL_Y = RAIL_TOP - FLANGE;
|
|
104
114
|
|
|
105
115
|
// ── THE FALLBACK ROUTE (only if data/railway.js has not landed) ───────────────────────────────
|
|
106
116
|
const FALLBACK_PATHS = [
|
|
@@ -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,125 @@
|
|
|
1
|
+
// WHAT MUST BE TRUE OF A ROAD WORLD.
|
|
2
|
+
//
|
|
3
|
+
// Every fault in this file was found by a person driving into it, one at a time, over an
|
|
4
|
+
// afternoon. Each was silent: nothing threw, nothing logged, the world simply looked wrong in
|
|
5
|
+
// one particular place. That is the argument for checking them at build — a road system has
|
|
6
|
+
// no natural failure mode, so it needs assertions or it needs somebody's evening.
|
|
7
|
+
//
|
|
8
|
+
// const report = auditRoadWorld(roads, { ground });
|
|
9
|
+
// if (report.failures.length) console.warn(report.summary);
|
|
10
|
+
//
|
|
11
|
+
// Cheap by design: it samples rather than proves, so a game can afford to run it on every
|
|
12
|
+
// cold build and still boot.
|
|
13
|
+
const NEAR = (a, b, tol) => Math.abs(a - b) <= tol;
|
|
14
|
+
|
|
15
|
+
export function auditRoadWorld(roads, { ground, samples = 24, tolerance = 0.06 } = {}) {
|
|
16
|
+
const checks = [];
|
|
17
|
+
const fail = (rule, detail) => checks.push({ rule, ok: false, detail });
|
|
18
|
+
const pass = (rule, detail) => checks.push({ rule, ok: true, detail });
|
|
19
|
+
const { heightAt, links = [], placed = new Map() } = roads ?? {};
|
|
20
|
+
if (!heightAt || !links.length) return { failures: [], checks, summary: 'no roads to audit' };
|
|
21
|
+
|
|
22
|
+
// 1. NO DIRT ABOVE TARMAC. The ground is cut below every deck; if it is not, a wedge of
|
|
23
|
+
// landscape lies across the carriageway.
|
|
24
|
+
let poke = 0, worstPoke = 0, pokeAt = null, n1 = 0;
|
|
25
|
+
for (const { link, from } of links) {
|
|
26
|
+
const half = from.width / 2;
|
|
27
|
+
const line = link.centreline;
|
|
28
|
+
for (let i = 2; i < line.length - 2; i += Math.max(1, Math.floor(line.length / samples))) {
|
|
29
|
+
const p = line[i], q = line[i + 1];
|
|
30
|
+
const fx = q[0] - p[0], fz = q[2] - p[2], L = Math.hypot(fx, fz) || 1;
|
|
31
|
+
const rx = -fz / L, rz = fx / L;
|
|
32
|
+
for (const off of [-half * 0.95, 0, half * 0.95]) {
|
|
33
|
+
const x = p[0] + rx * off, z = p[2] + rz * off;
|
|
34
|
+
n1++;
|
|
35
|
+
const over = heightAt(x, z) - p[1];
|
|
36
|
+
if (over > tolerance) {
|
|
37
|
+
poke++;
|
|
38
|
+
if (over > worstPoke) { worstPoke = over; pokeAt = [Math.round(x), Math.round(z)]; }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
poke ? fail('no ground above a road', `${poke}/${n1} samples, worst ${worstPoke.toFixed(2)} m at ${pokeAt}`)
|
|
44
|
+
: pass('no ground above a road', `${n1} samples clean`);
|
|
45
|
+
|
|
46
|
+
// 2. NO FLOATING ROADS. Beside the tarmac the ground must come up to meet it — a road with
|
|
47
|
+
// a gap of air under its edge exists nowhere in the world.
|
|
48
|
+
let float = 0, worstFloat = 0, floatAt = null, n2 = 0;
|
|
49
|
+
for (const { link, from } of links) {
|
|
50
|
+
const half = from.width / 2;
|
|
51
|
+
const line = link.centreline;
|
|
52
|
+
for (let i = 2; i < line.length - 2; i += Math.max(1, Math.floor(line.length / samples))) {
|
|
53
|
+
const p = line[i], q = line[i + 1];
|
|
54
|
+
const fx = q[0] - p[0], fz = q[2] - p[2], L = Math.hypot(fx, fz) || 1;
|
|
55
|
+
const rx = -fz / L, rz = fx / L;
|
|
56
|
+
for (const off of [-half - 1.5, half + 1.5]) {
|
|
57
|
+
const x = p[0] + rx * off, z = p[2] + rz * off;
|
|
58
|
+
// A CUTTING IS NOT A FLOATING ROAD. Where a lower carriageway runs alongside — the
|
|
59
|
+
// motorway beneath an interchange, say — the ground beside this road drops to meet
|
|
60
|
+
// it, held by a retaining wall. That is a road ON something, not over nothing.
|
|
61
|
+
// look a little wider than the sample itself: the wall of a cutting stands OUTSIDE
|
|
62
|
+
// the lower carriageway's bed, so asking only at this exact point misses it
|
|
63
|
+
let inCutting = false;
|
|
64
|
+
for (const probe of [0, 6, 12]) {
|
|
65
|
+
const b = heightAt.deck?.(x + rx * Math.sign(off) * probe, z + rz * Math.sign(off) * probe);
|
|
66
|
+
if (b != null && b < p[1] - 2) { inCutting = true; break; }
|
|
67
|
+
}
|
|
68
|
+
if (inCutting) continue;
|
|
69
|
+
n2++;
|
|
70
|
+
const under = p[1] - heightAt(x, z);
|
|
71
|
+
if (under > 1.0) {
|
|
72
|
+
float++;
|
|
73
|
+
if (under > worstFloat) { worstFloat = under; floatAt = [Math.round(x), Math.round(z)]; }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
float ? fail('no road floating over a gap', `${float}/${n2} edge samples, worst ${worstFloat.toFixed(2)} m at ${floatAt}`)
|
|
79
|
+
: pass('no road floating over a gap', `${n2} edge samples clean`);
|
|
80
|
+
|
|
81
|
+
// 3. EVERY LINK MEETS ITS SOCKETS. A road that stops short of the junction it was built to
|
|
82
|
+
// reach is the one fault a player cannot drive around.
|
|
83
|
+
let gaps = 0;
|
|
84
|
+
for (const { link, from, to } of links) {
|
|
85
|
+
const a = link.centreline[0], b = link.centreline[link.centreline.length - 1];
|
|
86
|
+
if (!NEAR(a[0], from.pos[0], 0.1) || !NEAR(a[2], from.pos[2], 0.1) || !NEAR(a[1], from.pos[1], 0.1)) gaps++;
|
|
87
|
+
if (!NEAR(b[0], to.pos[0], 0.1) || !NEAR(b[2], to.pos[2], 0.1) || !NEAR(b[1], to.pos[1], 0.1)) gaps++;
|
|
88
|
+
}
|
|
89
|
+
gaps ? fail('every link meets both sockets', `${gaps} loose ends`)
|
|
90
|
+
: pass('every link meets both sockets', `${links.length * 2} ends joined`);
|
|
91
|
+
|
|
92
|
+
// 4. NOTHING UNDRIVABLE. A link whose grade had to exceed what was asked for is a fact the
|
|
93
|
+
// world should know about, not a surprise on the hill.
|
|
94
|
+
const steep = links.filter(({ link }) => link.feasible === false);
|
|
95
|
+
steep.length ? fail('every link is drivable at its grade', `${steep.length} link(s) needed more grade than allowed`)
|
|
96
|
+
: pass('every link is drivable at its grade', `${links.length} links within grade`);
|
|
97
|
+
|
|
98
|
+
// 5. JUNCTIONS SIT ON THEIR OWN GROUND. A piece seated at the wrong level is the difference
|
|
99
|
+
// between a slip road and a diving board.
|
|
100
|
+
let hanging = 0;
|
|
101
|
+
for (const [, p] of placed) {
|
|
102
|
+
if (!p.site) continue;
|
|
103
|
+
// sample ACROSS the footprint and take the median: a multi-level piece has a motorway
|
|
104
|
+
// running through its middle in a trench, so the ground at its exact centre is
|
|
105
|
+
// legitimately metres below grade. What matters is that the piece as a whole is seated.
|
|
106
|
+
const r = (p.piece?.size?.[0] ?? 60) * 0.35;
|
|
107
|
+
const around = [];
|
|
108
|
+
for (let k = 0; k < 8; k++) {
|
|
109
|
+
const th = (k / 8) * Math.PI * 2;
|
|
110
|
+
around.push(heightAt(p.site.x + Math.cos(th) * r, p.site.z + Math.sin(th) * r));
|
|
111
|
+
}
|
|
112
|
+
around.sort((x, y) => x - y);
|
|
113
|
+
const median = around[Math.floor(around.length / 2)];
|
|
114
|
+
if (Math.abs(median - p.site.y) > 1.5) hanging++;
|
|
115
|
+
}
|
|
116
|
+
hanging ? fail('junctions seated on their own ground', `${hanging} piece(s) more than 1.5 m off`)
|
|
117
|
+
: pass('junctions seated on their own ground', `${placed.size} pieces seated`);
|
|
118
|
+
|
|
119
|
+
const failures = checks.filter((c) => !c.ok);
|
|
120
|
+
const summary = [
|
|
121
|
+
`road audit: ${checks.length - failures.length}/${checks.length} passed`,
|
|
122
|
+
...checks.map((c) => ` ${c.ok ? '✓' : '✗'} ${c.rule} — ${c.detail}`),
|
|
123
|
+
].join('\n');
|
|
124
|
+
return { failures, checks, summary };
|
|
125
|
+
}
|
package/src/world/roadLink.js
CHANGED
|
@@ -79,6 +79,34 @@ function limitGrade(samples, gWanted, yStart, yEnd) {
|
|
|
79
79
|
return { grade: g, feasible: g <= gWanted * 1.001 };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// A PLAIN DECK between two points — no markings, no rails. A junction's two carriageways are
|
|
83
|
+
// separate meshes with a gap down the middle, so the ground shows through under the central
|
|
84
|
+
// reservation and drops away beneath the concrete. A generated link has no such gap because
|
|
85
|
+
// it is one deck across its whole width; this closes the same gap inside a junction.
|
|
86
|
+
export function buildDeck({ from, to, width, y = null, palette = PALETTE }) {
|
|
87
|
+
const ax = from[0], az = from[2], bx = to[0], bz = to[2];
|
|
88
|
+
const dx = bx - ax, dz = bz - az;
|
|
89
|
+
const len = Math.hypot(dx, dz) || 1;
|
|
90
|
+
const rx = -dz / len * (width / 2), rz = dx / len * (width / 2);
|
|
91
|
+
const ay = y ?? from[1], by = y ?? to[1];
|
|
92
|
+
const pos = new Float32Array([
|
|
93
|
+
ax - rx, ay, az - rz, ax + rx, ay, az + rz,
|
|
94
|
+
bx - rx, by, bz - rz, bx + rx, by, bz + rz,
|
|
95
|
+
]);
|
|
96
|
+
const nrm = new Float32Array([0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0]);
|
|
97
|
+
const uv = new Float32Array([
|
|
98
|
+
palette.asphalt[0], palette.asphalt[1], palette.asphalt[0], palette.asphalt[1],
|
|
99
|
+
palette.asphalt[0], palette.asphalt[1], palette.asphalt[0], palette.asphalt[1],
|
|
100
|
+
]);
|
|
101
|
+
const g = new THREE.BufferGeometry();
|
|
102
|
+
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
|
103
|
+
g.setAttribute('normal', new THREE.BufferAttribute(nrm, 3));
|
|
104
|
+
g.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
|
105
|
+
g.setIndex([0, 1, 2, 1, 3, 2]);
|
|
106
|
+
g.computeBoundingSphere();
|
|
107
|
+
return g;
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
export function buildRoadLink({
|
|
83
111
|
from, to,
|
|
84
112
|
heights = null, // extra height control points between the two sockets
|
|
@@ -87,6 +115,9 @@ export function buildRoadLink({
|
|
|
87
115
|
handleScale = 0.42, // Bezier handle length as a fraction of the gap — how gentle the curve is
|
|
88
116
|
maxGrade = 0.08, // 8% — steep but drivable; motorways want ~0.06
|
|
89
117
|
skirt = 0.5, // vertical apron under the road edges (0 = paper-thin)
|
|
118
|
+
railStep = 5, // spacing of the shoulder rail sections
|
|
119
|
+
medianStep = 2, // spacing of the median's blocks
|
|
120
|
+
railInset = 0.55, // how far inside the deck edge the shoulder rail stands
|
|
90
121
|
palette = PALETTE,
|
|
91
122
|
} = {}) {
|
|
92
123
|
const P0 = new THREE.Vector3(...from.pos);
|
|
@@ -252,6 +283,48 @@ export function buildRoadLink({
|
|
|
252
283
|
geometry.setIndex(idx);
|
|
253
284
|
geometry.computeBoundingSphere();
|
|
254
285
|
|
|
286
|
+
// WHERE THE BARRIERS GO. A motorway's median is the gap between its two innermost solid
|
|
287
|
+
// lines; its shoulders are outside the outermost. Returned as posts a game can instance
|
|
288
|
+
// its own guardrail along — the generator lays road, not other people's props.
|
|
289
|
+
const railings = { median: [], left: [], right: [] };
|
|
290
|
+
if (section?.lines?.length >= 4) {
|
|
291
|
+
const byInner = section.lines.map((b) => b.at).sort((a, b) => Math.abs(a) - Math.abs(b));
|
|
292
|
+
const medianHalf = Math.abs(byInner[0]);
|
|
293
|
+
const outer = Math.max(...section.lines.map((b) => Math.abs(b.at)));
|
|
294
|
+
// each line is sampled at ITS OWN prop's length — a concrete median block is not a
|
|
295
|
+
// steel rail section, and posting them at the same spacing leaves gaps or overlaps
|
|
296
|
+
const lay = (into, spacing, offsetOf) => {
|
|
297
|
+
const out = [];
|
|
298
|
+
for (let s = spacing / 2; s <= length - spacing / 2; s += spacing) {
|
|
299
|
+
const sm = along(s);
|
|
300
|
+
const off = offsetOf(sm);
|
|
301
|
+
// PITCH. A rail section is a rigid bar: leave it level on a graded road and each
|
|
302
|
+
// one steps down from the last, which reads as a gap at every joint. Tilt it to the
|
|
303
|
+
// local slope and consecutive sections meet end to end.
|
|
304
|
+
const back = along(Math.max(s - spacing / 2, 0));
|
|
305
|
+
const fore = along(Math.min(s + spacing / 2, length));
|
|
306
|
+
const rise = fore.p.y - back.p.y;
|
|
307
|
+
const run = Math.hypot(fore.p.x - back.p.x, fore.p.z - back.p.z) || 1e-3;
|
|
308
|
+
out.push({
|
|
309
|
+
pos: [
|
|
310
|
+
+(sm.p.x + sm.right.x * off).toFixed(3), +sm.p.y.toFixed(3),
|
|
311
|
+
+(sm.p.z + sm.right.z * off).toFixed(3),
|
|
312
|
+
],
|
|
313
|
+
// a prop modelled along its own X lies along the road at this yaw
|
|
314
|
+
yaw: Math.atan2(sm.right.x, sm.right.z) + (into ? Math.PI : 0),
|
|
315
|
+
pitch: +(Math.atan2(rise, run) * (into ? -1 : 1)).toFixed(5),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
};
|
|
320
|
+
if (medianHalf > 0.5) railings.median = lay(false, medianStep, () => 0);
|
|
321
|
+
// ON THE SHOULDER, not off it. A rail set beyond the deck edge stands in the grass and
|
|
322
|
+
// wanders off the road wherever the carriageway narrows; it belongs on the grey strip
|
|
323
|
+
// outside the outer line, which is still road.
|
|
324
|
+
railings.left = lay(false, railStep, (sm) => -(sm.half - railInset));
|
|
325
|
+
railings.right = lay(true, railStep, (sm) => sm.half - railInset);
|
|
326
|
+
}
|
|
327
|
+
|
|
255
328
|
// the centreline is what traffic, benching and the minimap all consume
|
|
256
329
|
const centreline = samples.map(({ p }) => [+p.x.toFixed(3), +p.y.toFixed(3), +p.z.toFixed(3)]);
|
|
257
330
|
const grades = [];
|
|
@@ -261,7 +334,7 @@ export function buildRoadLink({
|
|
|
261
334
|
grades.push(dy / dxz);
|
|
262
335
|
}
|
|
263
336
|
return {
|
|
264
|
-
geometry, centreline, length,
|
|
337
|
+
geometry, centreline, length, railings,
|
|
265
338
|
maxGrade: grades.length ? Math.max(...grades.map(Math.abs)) : 0,
|
|
266
339
|
// false when the two sockets could not be joined within the requested grade: the road
|
|
267
340
|
// still connects, but the caller should re-site, re-pair, or accept the climb
|
package/src/world/roadNetwork.js
CHANGED
|
@@ -50,6 +50,46 @@ export function planEdges(sites, { extra = 0.35, maxLength = Infinity } = {}) {
|
|
|
50
50
|
return edges;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// A CLOSED CIRCUIT through every site — a ring road or an orbital motorway. Nearest-neighbour
|
|
54
|
+
// tour, then 2-opt until no crossing pair remains, which is enough to stop a small loop from
|
|
55
|
+
// doubling back on itself. Returns the sites in travel order.
|
|
56
|
+
export function planLoop(sites) {
|
|
57
|
+
const n = sites.length;
|
|
58
|
+
if (n < 3) return sites.map((_, i) => i);
|
|
59
|
+
const d = (a, b) => Math.hypot(sites[a].x - sites[b].x, sites[a].z - sites[b].z);
|
|
60
|
+
const tour = [0];
|
|
61
|
+
const left = new Set(sites.map((_, i) => i).slice(1));
|
|
62
|
+
while (left.size) {
|
|
63
|
+
const from = tour[tour.length - 1];
|
|
64
|
+
let best = null;
|
|
65
|
+
for (const i of left) { const dd = d(from, i); if (!best || dd < best.d) best = { i, d: dd }; }
|
|
66
|
+
tour.push(best.i);
|
|
67
|
+
left.delete(best.i);
|
|
68
|
+
}
|
|
69
|
+
const tourLen = (t) => t.reduce((a, _, i) => a + d(t[i], t[(i + 1) % n]), 0);
|
|
70
|
+
for (let pass = 0; pass < 40; pass++) {
|
|
71
|
+
let improved = false;
|
|
72
|
+
for (let i = 1; i < n - 1; i++) {
|
|
73
|
+
for (let j = i + 1; j < n; j++) {
|
|
74
|
+
const cand = [...tour.slice(0, i), ...tour.slice(i, j + 1).reverse(), ...tour.slice(j + 1)];
|
|
75
|
+
if (tourLen(cand) < tourLen(tour) - 1e-6) { tour.splice(0, n, ...cand); improved = true; }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!improved) break;
|
|
79
|
+
}
|
|
80
|
+
return tour;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Where a piece must face to sit ON a loop: along the local tangent, so its through-
|
|
84
|
+
// carriageway continues the circuit rather than pointing across it.
|
|
85
|
+
export function loopTangents(sites, order) {
|
|
86
|
+
const n = order.length;
|
|
87
|
+
return order.map((idx, k) => {
|
|
88
|
+
const prev = sites[order[(k - 1 + n) % n]], next = sites[order[(k + 1) % n]];
|
|
89
|
+
return { index: idx, yaw: Math.atan2(next.x - prev.x, next.z - prev.z) };
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
53
93
|
const TAU = Math.PI * 2;
|
|
54
94
|
const wrap = (a) => ((a % TAU) + TAU) % TAU;
|
|
55
95
|
const angleGap = (a, b) => { const d = Math.abs(wrap(a) - wrap(b)); return Math.min(d, TAU - d); };
|
package/src/world/roadTerrain.js
CHANGED
|
@@ -114,20 +114,113 @@ function rasterAt(r, x, z) {
|
|
|
114
114
|
return { y: r.h[k], d: r.d[k] + Math.hypot(outX, outZ) };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
// Pads get a bucket grid too. A world has tens of junctions and the lattice asks this
|
|
118
|
+
// function ~1.7 million times for a cold cook: scanning every pad per call is 40 million
|
|
119
|
+
// distance tests nobody needs, and it showed up as a 25x cost over the bare terrain.
|
|
120
|
+
function padIndex(pads, cell) {
|
|
121
|
+
const buckets = new Map();
|
|
122
|
+
pads.forEach((p, id) => {
|
|
123
|
+
const reach = (p.halfX != null ? Math.hypot(p.halfX, p.halfZ) : (p.radius ?? 0))
|
|
124
|
+
+ Math.max(p.fade ?? 0, p.maxEarthwork ?? 120);
|
|
125
|
+
const i0 = Math.floor((p.x - reach) / cell), i1 = Math.floor((p.x + reach) / cell);
|
|
126
|
+
const j0 = Math.floor((p.z - reach) / cell), j1 = Math.floor((p.z + reach) / cell);
|
|
127
|
+
for (let i = i0; i <= i1; i++) for (let j = j0; j <= j1; j++) {
|
|
128
|
+
const k = `${i},${j}`;
|
|
129
|
+
if (!buckets.has(k)) buckets.set(k, []);
|
|
130
|
+
buckets.get(k).push(id);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
return (x, z) => buckets.get(`${Math.floor(x / cell)},${Math.floor(z / cell)}`) ?? null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function makeRoadTerrain(baseHeightAt, { pads = [], corridors = [], surfaces = [], cell = 32, clearance = 0.08 } = {}) {
|
|
118
137
|
const index = corridors.length ? segmentIndex(corridors, cell) : null;
|
|
138
|
+
const nearPads = pads.length ? padIndex(pads, cell) : null;
|
|
139
|
+
|
|
140
|
+
// WHERE THE DRIVEABLE SURFACE IS. The ground is deliberately cut a little below the road so
|
|
141
|
+
// the two do not fight for the same pixels, which means anything that drives — the player's
|
|
142
|
+
// car, traffic, a pedestrian on a bridge — must ask for the road, not the dirt.
|
|
143
|
+
// Returns null away from any road, so the caller falls back to the ground.
|
|
144
|
+
heightAt.road = function roadAt(x, z, preferY = null) {
|
|
145
|
+
const found = [];
|
|
146
|
+
// pads are driveable too: a junction's deck is where its own roads sit
|
|
147
|
+
for (const pid of nearPads?.(x, z) ?? []) {
|
|
148
|
+
const p = pads[pid];
|
|
149
|
+
if (padDistance(p, x, z) <= 0) found.push(p.y);
|
|
150
|
+
}
|
|
151
|
+
if (index) {
|
|
152
|
+
const ids = index.near(x, z);
|
|
153
|
+
for (const id of ids ?? []) {
|
|
154
|
+
const s = index.segs[id];
|
|
155
|
+
const dx = s.bx - s.ax, dz = s.bz - s.az;
|
|
156
|
+
const len2 = dx * dx + dz * dz || 1e-6;
|
|
157
|
+
const t = Math.min(1, Math.max(0, ((x - s.ax) * dx + (z - s.az) * dz) / len2));
|
|
158
|
+
if (Math.hypot(x - (s.ax + dx * t), z - (s.az + dz * t)) > s.half) continue;
|
|
159
|
+
found.push(s.ay + (s.by - s.ay) * t);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!found.length) return null;
|
|
163
|
+
// MULTI-LEVEL: where a bridge crosses a cutting, or a link overlaps an interchange's
|
|
164
|
+
// trench, several surfaces cover the same spot. Answer with the one nearest the height
|
|
165
|
+
// the caller is already at — you stay on the deck you are driving on, you do not fall
|
|
166
|
+
// through it onto the road below.
|
|
167
|
+
if (preferY == null) return Math.max(...found);
|
|
168
|
+
// 'low' / 'high' ask for a level outright. (A non-finite preference cannot do this by
|
|
169
|
+
// arithmetic: |y - -Infinity| is Infinity for EVERY candidate, so the nearest-wins
|
|
170
|
+
// comparison is always false and the first deck found is returned regardless.)
|
|
171
|
+
if (preferY === 'low' || preferY === -Infinity) return Math.min(...found);
|
|
172
|
+
if (preferY === 'high' || preferY === Infinity) return Math.max(...found);
|
|
173
|
+
return found.reduce((best, y) => (Math.abs(y - preferY) < Math.abs(best - preferY) ? y : best));
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// JUST THE CARRIAGEWAYS — no pads. A pad is a levelled area a junction stands on, not a
|
|
177
|
+
// road deck: anything asking "is this ground hidden under tarmac?" must not be told yes
|
|
178
|
+
// for the whole footprint of an interchange, or it will remove ground you can plainly see.
|
|
179
|
+
// `shrink` narrows the corridor before answering. A corridor's bed is deliberately wider
|
|
180
|
+
// than its tarmac (the road needs level ground beside it), so anything using this to decide
|
|
181
|
+
// "is the ground hidden here?" must ask about the ROAD, not the bed — or it cuts away a
|
|
182
|
+
// strip of terrain along both edges that nothing is covering, leaving a gap at the kerb.
|
|
183
|
+
heightAt.deck = function deckAt(x, z, shrink = 0) {
|
|
184
|
+
if (!index) return null;
|
|
185
|
+
const ids = index.near(x, z);
|
|
186
|
+
if (!ids) return null;
|
|
187
|
+
let lowest = null;
|
|
188
|
+
for (const id of ids) {
|
|
189
|
+
const s = index.segs[id];
|
|
190
|
+
const dx = s.bx - s.ax, dz = s.bz - s.az;
|
|
191
|
+
const len2 = dx * dx + dz * dz || 1e-6;
|
|
192
|
+
const t = Math.min(1, Math.max(0, ((x - s.ax) * dx + (z - s.az) * dz) / len2));
|
|
193
|
+
if (Math.hypot(x - (s.ax + dx * t), z - (s.az + dz * t)) > s.half - shrink) continue;
|
|
194
|
+
const y = s.ay + (s.by - s.ay) * t;
|
|
195
|
+
if (lowest == null || y < lowest) lowest = y;
|
|
196
|
+
}
|
|
197
|
+
return lowest;
|
|
198
|
+
};
|
|
119
199
|
|
|
120
|
-
|
|
200
|
+
function heightAt(x, z) {
|
|
121
201
|
let h = baseHeightAt(x, z);
|
|
122
202
|
|
|
123
203
|
// ── junction pads: level, because a prefab junction cannot bend ──
|
|
124
|
-
for (const
|
|
204
|
+
for (const pid of nearPads?.(x, z) ?? []) {
|
|
205
|
+
const p = pads[pid];
|
|
125
206
|
const d = padDistance(p, x, z);
|
|
126
207
|
// sit the dirt a little under the deck, or the two surfaces fight for the same pixels
|
|
127
208
|
const y = p.y - (p.drop ?? 0);
|
|
128
209
|
if (d <= 0) { h = y; continue; } // inside the footprint: level
|
|
129
|
-
|
|
130
|
-
|
|
210
|
+
// A FADE, NOT A CUT. An embankment batter is right for a road crossing a valley and
|
|
211
|
+
// wrong for the ground around a junction: it leaves a visible rim, and the eye reads
|
|
212
|
+
// the whole pad as a platform someone dropped on the landscape. Kiwi's answer, and
|
|
213
|
+
// the right one, is to ease back to the ORIGINAL height over a generous distance —
|
|
214
|
+
// far enough that you cannot tell the ground was levelled at all.
|
|
215
|
+
const fade = p.fade ?? 0;
|
|
216
|
+
if (fade > 0) {
|
|
217
|
+
if (d >= fade) continue; // beyond the fade: natural ground
|
|
218
|
+
const t = smoothstep(0, 1, d / fade);
|
|
219
|
+
h = y + (h - y) * t;
|
|
220
|
+
} else {
|
|
221
|
+
const reach = d * (p.batter ?? 0.5); // the old earthwork law
|
|
222
|
+
h = Math.min(Math.max(h, y - reach), y + reach);
|
|
223
|
+
}
|
|
131
224
|
}
|
|
132
225
|
|
|
133
226
|
// ── piece decks: cut the plateau open wherever a deck runs BELOW it ──
|
|
@@ -173,8 +266,20 @@ export function makeRoadTerrain(baseHeightAt, { pads = [], corridors = [], surfa
|
|
|
173
266
|
}
|
|
174
267
|
}
|
|
175
268
|
|
|
269
|
+
// THE GUARANTEE: ground never sits above a road. The clearance is a hair — just enough
|
|
270
|
+
// that the two surfaces do not fight for the same pixels. It was much larger while this
|
|
271
|
+
// clamp was silently broken, and that showed as a step along every road edge: the ground
|
|
272
|
+
// beside a kerb sitting half a metre below the tarmac it should meet. Corridor and pad bookkeeping alone
|
|
273
|
+
// cannot promise it — a terrain lattice is metres wide, so a vertex just outside a
|
|
274
|
+
// road's flat bed drags its triangle up across the carriageway and lays a wedge of dirt
|
|
275
|
+
// over the tarmac. Every consumer of this function (the mesh, the cook, collision,
|
|
276
|
+
// grass) gets the same promise, in every game, without having to know the rule exists.
|
|
277
|
+
const deck = heightAt.road(x, z, 'low');
|
|
278
|
+
if (deck != null && h > deck - clearance) h = deck - clearance;
|
|
176
279
|
return h;
|
|
177
|
-
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return heightAt;
|
|
178
283
|
}
|
|
179
284
|
|
|
180
285
|
// The height a junction should sit at: the average ground across its footprint, so it is
|
|
@@ -216,7 +321,7 @@ export function seatHeight(heightAt, x, z, radius, rings = 3, spokes = 8) {
|
|
|
216
321
|
// law from the road plan, applied automatically.
|
|
217
322
|
// Several sites at once, for a whole map: flat enough to stand a junction on, spread out
|
|
218
323
|
// enough to be separate places. Greedy farthest-point selection keeps them from clumping.
|
|
219
|
-
export function pickManySites(heightAt, { count = 5, half = 600, step = 90, radius = 60, minGap = 300 } = {}) {
|
|
324
|
+
export function pickManySites(heightAt, { count = 5, half = 600, step = 90, radius = 60, minGap = 300, maxGrade = 0 } = {}) {
|
|
220
325
|
const cands = [];
|
|
221
326
|
for (let x = -half; x <= half; x += step) {
|
|
222
327
|
for (let z = -half; z <= half; z += step) {
|
|
@@ -230,7 +335,16 @@ export function pickManySites(heightAt, { count = 5, half = 600, step = 90, radi
|
|
|
230
335
|
const chosen = [cands[0]];
|
|
231
336
|
for (const c of cands) {
|
|
232
337
|
if (chosen.length >= count) break;
|
|
233
|
-
|
|
338
|
+
const ok = chosen.every((s) => {
|
|
339
|
+
const gap = Math.hypot(s.x - c.x, s.z - c.z);
|
|
340
|
+
if (gap < minGap) return false;
|
|
341
|
+
// AND A ROAD MUST BE ABLE TO REACH IT. Picking the flattest spots regardless of each
|
|
342
|
+
// other's height cheerfully pairs a hilltop with a valley floor, and no road between
|
|
343
|
+
// them can be built at a sane grade — the link then quietly exceeds it.
|
|
344
|
+
if (maxGrade > 0 && Math.abs(s.y - c.y) > gap * maxGrade * 0.55) return false;
|
|
345
|
+
return true;
|
|
346
|
+
});
|
|
347
|
+
if (ok) chosen.push(c);
|
|
234
348
|
}
|
|
235
349
|
return chosen;
|
|
236
350
|
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// ROADS AS A WORLD-BUILD STEP.
|
|
2
|
+
//
|
|
3
|
+
// The pieces exist — a kit to read, a network to solve, links to generate, earthworks to cut.
|
|
4
|
+
// This is the one call that runs them in the right order and hands back everything a world
|
|
5
|
+
// needs, so a game does not have to know that order (and cannot get it wrong):
|
|
6
|
+
//
|
|
7
|
+
// const roads = await buildRoadWorld({ sites, kit, candidates, ground, placePiece });
|
|
8
|
+
// const heightAt = roads.heightAt; // ← give THIS to buildHeightfield
|
|
9
|
+
// scene.add(...roads.meshes); // ← and stamp these BEFORE the BVH bake
|
|
10
|
+
// for (const m of roads.colliders) bvhSources.push(m);
|
|
11
|
+
//
|
|
12
|
+
// THE ORDER IS THE POINT, and it is not obvious:
|
|
13
|
+
// 1. site the junctions on flat ground — before anything is placed
|
|
14
|
+
// 2. seat each piece at ITS grade level — multi-level pieces are authored high
|
|
15
|
+
// 3. wrap the ground with the pads — so links can follow a padded surface
|
|
16
|
+
// 4. solve and generate the links — they read that padded ground
|
|
17
|
+
// 5. wrap again with pads AND corridors — the final heightAt the world uses
|
|
18
|
+
// Do 4 before 3 and links follow raw terrain into the sides of junctions; do 5 before 4 and
|
|
19
|
+
// there are no corridors to add yet.
|
|
20
|
+
//
|
|
21
|
+
// `placePiece(name, { x, y, z, yaw })` is the game's: it loads the assembly, adds it to the
|
|
22
|
+
// scene and returns `{ object, sockets, deckPoints }`. The engine never loads assets.
|
|
23
|
+
import { makeRoadTerrain, seatHeight, pickManySites } from './roadTerrain.js';
|
|
24
|
+
import { solveNetwork, planLoop, loopTangents } from './roadNetwork.js';
|
|
25
|
+
import { buildRoadLink, buildDeck } from './roadLink.js';
|
|
26
|
+
import { seatY, gradeLevel } from './roadKit.js';
|
|
27
|
+
|
|
28
|
+
const footprintOf = (piece, margin = 6) => ({
|
|
29
|
+
halfX: (piece.bbox.max[0] - piece.bbox.min[0]) / 2 + margin,
|
|
30
|
+
halfZ: (piece.bbox.max[2] - piece.bbox.min[2]) / 2 + margin,
|
|
31
|
+
cx: (piece.bbox.max[0] + piece.bbox.min[0]) / 2,
|
|
32
|
+
cz: (piece.bbox.max[2] + piece.bbox.min[2]) / 2,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export async function buildRoadWorld({
|
|
36
|
+
kit, // from makeRoadKit
|
|
37
|
+
ground, // the game's natural heightAt
|
|
38
|
+
placePiece, // (name, transform) => { object, sockets, deckPoints }
|
|
39
|
+
sites = null, // explicit sites, or let pickManySites choose
|
|
40
|
+
candidates = [], // junction piece names the solver may use
|
|
41
|
+
count = 6, // how many places, when choosing them
|
|
42
|
+
half = 600, minGap = 420, // where they may go
|
|
43
|
+
loop = false, // a closed circuit rather than a network
|
|
44
|
+
maxGrade = 0.07,
|
|
45
|
+
fade = 90, // how far a junction pad eases back into the landscape
|
|
46
|
+
drop = 0.12, // ground below a deck: a hair, so they do not z-fight
|
|
47
|
+
linkStep = 3,
|
|
48
|
+
handleScale = 0.4,
|
|
49
|
+
} = {}) {
|
|
50
|
+
const pieceOf = (n) => (kit.piece ? kit.piece(n) : kit.pieces?.[n]);
|
|
51
|
+
const pad = Math.max(...candidates.map((n) => pieceOf(n)?.size?.[0] ?? 100)) * 0.55;
|
|
52
|
+
|
|
53
|
+
// 1. WHERE THE PLACES GO — flat enough to stand a junction on, far enough apart to be
|
|
54
|
+
// separate places
|
|
55
|
+
const chosen = sites ?? pickManySites(ground, { count, half, step: 70, radius: pad, minGap, maxGrade });
|
|
56
|
+
if (chosen.length < 2) return { heightAt: ground, meshes: [], colliders: [], network: null, sites: chosen };
|
|
57
|
+
|
|
58
|
+
// 2. THE NETWORK — who joins whom, and what junction each place needs
|
|
59
|
+
const order = loop ? planLoop(chosen) : null;
|
|
60
|
+
const facing = loop ? loopTangents(chosen, order) : null;
|
|
61
|
+
const net = loop ? null : solveNetwork({ sites: chosen, kit, candidates, kinds: ['road'] });
|
|
62
|
+
|
|
63
|
+
// 3. PLACE EACH JUNCTION, seated at its own grade level
|
|
64
|
+
const placed = new Map();
|
|
65
|
+
const pads = [];
|
|
66
|
+
const placeOne = async (index, name, yaw) => {
|
|
67
|
+
const piece = pieceOf(name);
|
|
68
|
+
if (!piece) return;
|
|
69
|
+
const st = chosen[index];
|
|
70
|
+
const p = await placePiece(name, { x: st.x, y: seatY(piece, st.y), z: st.z, yaw });
|
|
71
|
+
if (!p) return;
|
|
72
|
+
const foot = footprintOf(piece);
|
|
73
|
+
const c = Math.cos(yaw), s = Math.sin(yaw);
|
|
74
|
+
pads.push({
|
|
75
|
+
x: st.x + foot.cx * c + foot.cz * s, z: st.z - foot.cx * s + foot.cz * c,
|
|
76
|
+
y: st.y, yaw, halfX: foot.halfX, halfZ: foot.halfZ, drop, fade,
|
|
77
|
+
});
|
|
78
|
+
placed.set(index, { ...p, site: st, yaw, name, piece });
|
|
79
|
+
};
|
|
80
|
+
if (loop) for (const { index, yaw } of facing) await placeOne(index, candidates[0], yaw);
|
|
81
|
+
else for (const node of net.nodes) if (node.name) await placeOne(node.index, node.name, node.yaw);
|
|
82
|
+
|
|
83
|
+
// 4. THE LINKS — generated over ground that already knows about the pads, so a road
|
|
84
|
+
// approaches a junction over its own levelled apron rather than raw hillside
|
|
85
|
+
const padded = makeRoadTerrain(ground, { pads });
|
|
86
|
+
const kinds = loop ? ['motorway'] : ['road'];
|
|
87
|
+
const facingSocket = (p, tx, tz) => (p.sockets ?? [])
|
|
88
|
+
.filter((s) => kinds.includes(s.kind ?? 'road'))
|
|
89
|
+
.reduce((best, s) => {
|
|
90
|
+
const v = [tx - s.pos[0], tz - s.pos[2]];
|
|
91
|
+
const len = Math.hypot(...v) || 1;
|
|
92
|
+
const sc = (s.dir[0] * v[0] + s.dir[1] * v[1]) / len;
|
|
93
|
+
return !best || sc > best.sc ? { s, sc } : best;
|
|
94
|
+
}, null)?.s ?? null;
|
|
95
|
+
|
|
96
|
+
// IN A LOOP THE TWO ENDS ARE ASSIGNED, not chosen independently. A through-piece has
|
|
97
|
+
// exactly two motorway sockets and exactly two neighbours; picking the best-facing socket
|
|
98
|
+
// for each neighbour separately lets both pick the SAME end on a turn, and the second road
|
|
99
|
+
// is then dropped for want of an arm — a circuit with holes in it.
|
|
100
|
+
const assigned = new Map();
|
|
101
|
+
if (loop) {
|
|
102
|
+
for (let k = 0; k < order.length; k++) {
|
|
103
|
+
const me = placed.get(order[k]);
|
|
104
|
+
if (!me) continue;
|
|
105
|
+
const prev = placed.get(order[(k - 1 + order.length) % order.length]);
|
|
106
|
+
const next = placed.get(order[(k + 1) % order.length]);
|
|
107
|
+
const mw = (me.sockets ?? []).filter((sk) => sk.kind === 'motorway');
|
|
108
|
+
if (mw.length < 2 || !prev || !next) continue;
|
|
109
|
+
const toward = (sk, t) => {
|
|
110
|
+
const v = [t.site.x - sk.pos[0], t.site.z - sk.pos[2]];
|
|
111
|
+
const len = Math.hypot(...v) || 1;
|
|
112
|
+
return (sk.dir[0] * v[0] + sk.dir[1] * v[1]) / len;
|
|
113
|
+
};
|
|
114
|
+
// take whichever pairing serves both neighbours better overall
|
|
115
|
+
const straight = toward(mw[0], prev) + toward(mw[1], next);
|
|
116
|
+
const swapped = toward(mw[1], prev) + toward(mw[0], next);
|
|
117
|
+
assigned.set(order[k], straight >= swapped
|
|
118
|
+
? { prev: mw[0], next: mw[1] }
|
|
119
|
+
: { prev: mw[1], next: mw[0] });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const links = [];
|
|
124
|
+
const pairs = loop
|
|
125
|
+
? order.map((a, k) => [a, order[(k + 1) % order.length]])
|
|
126
|
+
: net.edges.map((e) => [e.a, e.b]);
|
|
127
|
+
const used = new Set();
|
|
128
|
+
for (const [ia, ib] of pairs) {
|
|
129
|
+
const A = placed.get(ia), B = placed.get(ib);
|
|
130
|
+
if (!A || !B) continue;
|
|
131
|
+
const from = assigned.get(ia)?.next ?? facingSocket(A, B.site.x, B.site.z);
|
|
132
|
+
const to = assigned.get(ib)?.prev ?? facingSocket(B, A.site.x, A.site.z);
|
|
133
|
+
if (!from || !to) continue;
|
|
134
|
+
const ka = `${ia}:${from.id}`, kb = `${ib}:${to.id}`;
|
|
135
|
+
if (used.has(ka) || used.has(kb)) continue; // one road per arm
|
|
136
|
+
used.add(ka); used.add(kb);
|
|
137
|
+
links.push({
|
|
138
|
+
a: ia, b: ib, from, to,
|
|
139
|
+
link: buildRoadLink({
|
|
140
|
+
from, to, groundAt: (x, z) => padded(x, z) + 0.15,
|
|
141
|
+
handleScale, step: linkStep, maxGrade,
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 5. THE EARTHWORKS the world actually uses: pads AND the corridors those links carry,
|
|
147
|
+
// plus a trench for anything a piece keeps below its own grade
|
|
148
|
+
const corridors = links.map(({ link, from }) => ({
|
|
149
|
+
centreline: link.centreline, halfWidth: from.width / 2 + 5, drop, batter: 0.5,
|
|
150
|
+
}));
|
|
151
|
+
for (const [, p] of placed) {
|
|
152
|
+
const mw = (p.sockets ?? []).filter((s) => s.kind === 'motorway');
|
|
153
|
+
if (mw.length < 2 || !p.deckPoints?.length) continue;
|
|
154
|
+
const a = mw[0].pos, b = mw[1].pos;
|
|
155
|
+
const ax = [b[0] - a[0], b[2] - a[2]];
|
|
156
|
+
const al = Math.hypot(...ax) || 1;
|
|
157
|
+
ax[0] /= al; ax[1] /= al;
|
|
158
|
+
const perp = [-ax[1], ax[0]];
|
|
159
|
+
const grade = p.site.y;
|
|
160
|
+
let wide = 0, lowest = Infinity;
|
|
161
|
+
for (const q of p.deckPoints) {
|
|
162
|
+
if (q[1] > grade - 1.5) continue;
|
|
163
|
+
const lat = Math.abs((q[0] - p.site.x) * perp[0] + (q[2] - p.site.z) * perp[1]);
|
|
164
|
+
if (lat > wide) wide = lat;
|
|
165
|
+
if (q[1] < lowest) lowest = q[1];
|
|
166
|
+
}
|
|
167
|
+
if (!(wide > 0)) continue;
|
|
168
|
+
// KEEP THE TRENCH TIGHT TO THE CARRIAGEWAY. Sizing it from everything below grade lets
|
|
169
|
+
// the slip roads — which sweep far out to either side as they climb — widen the cut until
|
|
170
|
+
// it swallows the whole junction, dropping the entire pad to motorway level and leaving
|
|
171
|
+
// every piece standing 10 m above its own ground.
|
|
172
|
+
const bed = Math.min(wide, Math.max(mw[0].width, mw[1].width) / 2 + 18) + 5;
|
|
173
|
+
corridors.push({
|
|
174
|
+
centreline: [
|
|
175
|
+
[a[0] - ax[0] * 30, Math.min(a[1], lowest), a[2] - ax[1] * 30],
|
|
176
|
+
[b[0] + ax[0] * 30, Math.min(b[1], lowest), b[2] + ax[1] * 30],
|
|
177
|
+
],
|
|
178
|
+
halfWidth: bed, drop, batter: 4,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
heightAt: makeRoadTerrain(ground, { pads, corridors }),
|
|
184
|
+
links,
|
|
185
|
+
pads,
|
|
186
|
+
corridors,
|
|
187
|
+
placed,
|
|
188
|
+
sites: chosen,
|
|
189
|
+
network: net,
|
|
190
|
+
order,
|
|
191
|
+
// the median strip through each junction: its two carriageways are separate meshes, and
|
|
192
|
+
// the ground would otherwise be visible dropping away between them
|
|
193
|
+
medianDecks: [...placed.values()].flatMap((p) => {
|
|
194
|
+
const mw = (p.sockets ?? []).filter((s) => s.kind === 'motorway');
|
|
195
|
+
if (mw.length < 2) return [];
|
|
196
|
+
const inner = (mw[0].section?.lines ?? []).map((b) => Math.abs(b.at)).sort((x, y) => x - y)[0] ?? 1.1;
|
|
197
|
+
return [buildDeck({ from: mw[0].pos, to: mw[1].pos, width: inner * 2 + 0.6 })];
|
|
198
|
+
}),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// UNITYPACKAGE EXTRACT — pull NAMED assets out of a .unitypackage.
|
|
3
|
+
//
|
|
4
|
+
// The dev-server importer takes a whole pack; this is for when you want a handful of things
|
|
5
|
+
// out of a 90 MB archive (the cars from POLYGON City, say) without staging the other 2,000
|
|
6
|
+
// assets. A .unitypackage is a gzip tar of GUID directories, each holding `pathname`,
|
|
7
|
+
// `asset` and `asset.meta`; this rebuilds the real filenames for whatever matches.
|
|
8
|
+
//
|
|
9
|
+
// node tools/unityPackageExtract.mjs <pkg.unitypackage> <outDir> <regex> [--list]
|
|
10
|
+
//
|
|
11
|
+
// The .meta travels with each asset because Unity keeps the FBX import scale there, and
|
|
12
|
+
// Synty packs mix metre- and centimetre-authored models (see tools/fbxToGlb.mjs).
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
const [pkg, outDir, pattern, ...rest] = process.argv.slice(2);
|
|
19
|
+
if (!pkg || !outDir || !pattern) {
|
|
20
|
+
console.error('usage: unityPackageExtract.mjs <pkg.unitypackage> <outDir> <regex> [--list]');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const listOnly = rest.includes('--list');
|
|
24
|
+
const re = new RegExp(pattern, 'i');
|
|
25
|
+
|
|
26
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'unitypkg-'));
|
|
27
|
+
try {
|
|
28
|
+
execFileSync('tar', ['xzf', pkg, '-C', work], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
29
|
+
const hits = [];
|
|
30
|
+
for (const dir of fs.readdirSync(work)) {
|
|
31
|
+
const base = path.join(work, dir);
|
|
32
|
+
const pnFile = path.join(base, 'pathname');
|
|
33
|
+
if (!fs.existsSync(pnFile)) continue;
|
|
34
|
+
const assetPath = fs.readFileSync(pnFile, 'utf8').split('\n')[0].trim();
|
|
35
|
+
if (!re.test(assetPath)) continue;
|
|
36
|
+
if (!fs.existsSync(path.join(base, 'asset'))) continue; // a folder entry, not a file
|
|
37
|
+
hits.push({ dir: base, assetPath });
|
|
38
|
+
}
|
|
39
|
+
hits.sort((a, b) => a.assetPath.localeCompare(b.assetPath));
|
|
40
|
+
if (listOnly) {
|
|
41
|
+
for (const h of hits) console.log(h.assetPath);
|
|
42
|
+
console.log(`\n${hits.length} matching assets`);
|
|
43
|
+
} else {
|
|
44
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
45
|
+
for (const h of hits) {
|
|
46
|
+
const name = path.basename(h.assetPath);
|
|
47
|
+
fs.copyFileSync(path.join(h.dir, 'asset'), path.join(outDir, name));
|
|
48
|
+
const meta = path.join(h.dir, 'asset.meta');
|
|
49
|
+
if (fs.existsSync(meta)) fs.copyFileSync(meta, path.join(outDir, `${name}.meta`));
|
|
50
|
+
console.log(`${name} ← ${h.assetPath}`);
|
|
51
|
+
}
|
|
52
|
+
console.log(`\n${hits.length} assets extracted to ${outDir}`);
|
|
53
|
+
}
|
|
54
|
+
} finally {
|
|
55
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
56
|
+
}
|