sindicate 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/package.json +1 -1
- package/src/audio/synth.js +31 -13
- package/src/core/anim.js +22 -0
- package/src/core/engine.js +50 -5
- package/src/core/overrides.js +84 -0
- package/src/core/quality.js +6 -2
- package/src/systems/climbing.js +18 -2
- package/src/systems/coach.js +3 -3
- package/src/systems/combat.js +17 -17
- package/src/systems/crime.js +5 -5
- package/src/systems/deadeye.js +4 -4
- package/src/systems/encounters.js +1 -1
- package/src/systems/enemy.js +9 -9
- package/src/systems/footsteps.js +1 -1
- package/src/systems/loot.js +2 -2
- package/src/systems/npc.js +1 -1
- package/src/systems/player.js +32 -15
- package/src/systems/riding.js +26 -7
- package/src/systems/shop.js +2 -2
- package/src/systems/train.js +16 -3
- package/src/vehicle/car.js +4 -1
- package/src/world/cityPlan.js +500 -0
- package/src/world/mapWorld.js +120 -0
- package/src/world/roadAssert.js +28 -1
- package/src/world/roadLink.js +88 -15
- package/src/world/roadTerrain.js +36 -8
- package/src/world/roadWorld.js +5 -0
- package/src/world/settlement.js +254 -0
- package/src/world/walkHumps.js +22 -1
- package/tools/modelCatalogue.mjs +59 -0
- package/tools/unitySceneToBuildings.mjs +273 -0
- package/tools/wholeBuildings.mjs +233 -0
package/src/world/roadLink.js
CHANGED
|
@@ -15,14 +15,26 @@
|
|
|
15
15
|
// both ends meet their arms tangentially — no kink at the seam.
|
|
16
16
|
import * as THREE from 'three';
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// TEXELS IN THE SIMPLIGON ATLAS, flat-sampled — Kiwi's own coordinates, from
|
|
19
|
+
// CurvedRoadMeshGenerator: UV_ROAD (0.2871, 0.0361), UV_WHITE (0.2871, 0.1105),
|
|
20
|
+
// UV_CONCRETE top (0.2993, 0.1396) and edge (0.2993, 0.1133).
|
|
21
|
+
//
|
|
22
|
+
// The concrete pair was missing here, so anything that was not tarmac got painted with the
|
|
23
|
+
// WHITE LINE texel — which is why the pavements came out looking like road markings rather
|
|
24
|
+
// than like pavements.
|
|
25
|
+
const PALETTE = {
|
|
19
26
|
asphalt: [0.2871, 0.0361],
|
|
20
27
|
line: [0.2871, 0.1105],
|
|
28
|
+
concrete: [0.2993, 0.1396], // footway top
|
|
29
|
+
kerb: [0.2993, 0.1133], // its edge, and the gutter face
|
|
21
30
|
};
|
|
22
31
|
const MARK_Y = 0.02; // paint sits this far above the asphalt
|
|
23
32
|
const EDGE_W = 0.1; // edge-line width
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
// KIWI'S OWN FIGURES (CurvedRoadMeshGenerator: DASH_LENGTH 1, DASH_GAP 1.5, DASH_WIDTH
|
|
34
|
+
// 0.15, MARKING_HEIGHT 0.02). Mine were 2.5 on and 2.5 off, which reads as a lane divider on
|
|
35
|
+
// a motorway and as sleepers on a street.
|
|
36
|
+
const DASH_W = 0.15; // centre-line width
|
|
37
|
+
const DASH_ON = 1.0, DASH_OFF = 1.5;
|
|
26
38
|
|
|
27
39
|
const catmull = (p, t) => {
|
|
28
40
|
// smooth interpolation through the given heights (duplicated ends = flat entry/exit)
|
|
@@ -196,16 +208,70 @@ export function buildRoadLink({
|
|
|
196
208
|
};
|
|
197
209
|
};
|
|
198
210
|
|
|
199
|
-
// ──
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
211
|
+
// ── the deck ──
|
|
212
|
+
//
|
|
213
|
+
// A CROSS-SECTION WITH HEIGHT IN IT, when the socket carries one. A city street is not a
|
|
214
|
+
// flat ribbon with paint on it: it has a kerb, a gutter that drops below the carriageway,
|
|
215
|
+
// and a footway raised above it. Kiwi builds these by taking a road prefab's front-edge
|
|
216
|
+
// vertices as a profile and extruding that along the spline (CurvedRoadMeshGenerator.
|
|
217
|
+
// ExtractRoadProfile), which is why its streets have kerbs and mine, tiled by hand, did
|
|
218
|
+
// not. `profile` is that list of [lateral, height] pairs; without one the deck is the flat
|
|
219
|
+
// ribbon a motorway wants, where the kerbs are somebody else's barrier props.
|
|
220
|
+
//
|
|
221
|
+
// Lateral offsets scale with the socket width so one profile serves a narrow lane and a
|
|
222
|
+
// wide street, and heights do NOT — a kerb is 125 mm whatever the road.
|
|
223
|
+
const xsec = from.profile ?? to.profile ?? null;
|
|
224
|
+
let refHalfOut = 1;
|
|
225
|
+
if (xsec?.length >= 2) {
|
|
226
|
+
// SCALE AGAINST THE CARRIAGEWAY, NOT THE WHOLE SECTION. A socket's `width` is the
|
|
227
|
+
// driveable width; the profile is wider than that because it includes the kerb, the
|
|
228
|
+
// footway and the verge behind it. Scaling by the outermost point squeezes the entire
|
|
229
|
+
// 20 m section into the 10 m carriageway — every kerb and pavement at half size.
|
|
230
|
+
// The carriageway ends at its OWN outermost vertex, not at the first kerb vertex beyond
|
|
231
|
+
// it. Those are different points — the kerb face begins a few centimetres outside the
|
|
232
|
+
// tarmac edge — and taking the kerb's makes every road that fraction narrow: a 10 m
|
|
233
|
+
// street came out 9.84 m, and every kerb, footway and lane marking with it.
|
|
234
|
+
const kerbAt = xsec.filter(([, y]) => y < -0.01).map(([x]) => Math.abs(x));
|
|
235
|
+
const innerKerb = kerbAt.length ? Math.min(...kerbAt) : Infinity;
|
|
236
|
+
const flat = xsec.filter(([x, y]) => Math.abs(y) <= 0.01 && Math.abs(x) <= innerKerb).map(([x]) => Math.abs(x));
|
|
237
|
+
const refHalf = (flat.length ? Math.max(...flat)
|
|
238
|
+
: Number.isFinite(innerKerb) ? innerKerb
|
|
239
|
+
: Math.max(...xsec.map(([x]) => Math.abs(x)))) || 1;
|
|
240
|
+
refHalfOut = refHalf;
|
|
241
|
+
// WHICH SIDE OF THE KERB IS IT ON? Height alone is not enough, and this is the rule
|
|
242
|
+
// Kiwi's GetSimpligonUV actually uses: cast from the centre line toward the vertex, and
|
|
243
|
+
// if a kerb lies between them, the vertex is footway — even where it is flat. A pavement
|
|
244
|
+
// is mostly level ground BEHIND a kerb, so judging by height alone paints the 0.35 m
|
|
245
|
+
// raised lip as pavement and the four metres of actual footway behind it as tarmac.
|
|
246
|
+
const kerbs = xsec.filter(([, y]) => y < -0.01).map(([x]) => x);
|
|
247
|
+
const pastKerb = (lx) => kerbs.some((k) => (lx > 0 && k > 0 && k < lx) || (lx < 0 && k < 0 && k > lx));
|
|
248
|
+
const rows = samples.map(({ p, right, half }) => xsec.map(([lx, ly]) => {
|
|
249
|
+
const q = p.clone().addScaledVector(right, (lx / refHalf) * half);
|
|
250
|
+
q.y += ly;
|
|
251
|
+
// the same three-way split Kiwi makes: below grade is the kerb face, raised or behind
|
|
252
|
+
// a kerb is footway, everything else is carriageway
|
|
253
|
+
const tex = ly < -0.01 ? palette.kerb
|
|
254
|
+
: (ly > 0.05 || pastKerb(lx)) ? palette.concrete
|
|
255
|
+
: palette.asphalt;
|
|
256
|
+
return vert(q, UP, tex);
|
|
257
|
+
}));
|
|
258
|
+
for (let i = 0; i < rows.length - 1; i++) {
|
|
259
|
+
for (let k = 0; k < xsec.length - 1; k++) {
|
|
260
|
+
const a = rows[i][k], b = rows[i][k + 1], c = rows[i + 1][k], d = rows[i + 1][k + 1];
|
|
261
|
+
idx.push(a, b, c, b, d, c);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
const deck = [];
|
|
266
|
+
for (const { p, right, half } of samples) {
|
|
267
|
+
const l = p.clone().addScaledVector(right, -half);
|
|
268
|
+
const r = p.clone().addScaledVector(right, half);
|
|
269
|
+
deck.push([vert(l, UP, palette.asphalt), vert(r, UP, palette.asphalt)]);
|
|
270
|
+
}
|
|
271
|
+
for (let i = 0; i < deck.length - 1; i++) {
|
|
272
|
+
const [a, b] = deck[i], [c, d] = deck[i + 1];
|
|
273
|
+
idx.push(a, b, c, b, d, c);
|
|
274
|
+
}
|
|
209
275
|
}
|
|
210
276
|
|
|
211
277
|
// ── painted strips: a run of cross-sections at fixed lateral offsets ──
|
|
@@ -260,13 +326,20 @@ export function buildRoadLink({
|
|
|
260
326
|
|
|
261
327
|
// ── side apron so an elevated road is not a floating sheet ──
|
|
262
328
|
if (skirt > 0) {
|
|
329
|
+
// AT THE OUTSIDE OF THE SECTION, not at the carriageway edge. `half` is the driveable
|
|
330
|
+
// half-width, which with a profile is the middle of the ribbon, not its edge — so the
|
|
331
|
+
// apron came down as a wall right where the kerb is, a grey strip alongside the tarmac
|
|
332
|
+
// that reads as part of the road instead of the side of the pavement.
|
|
333
|
+
const outer = xsec?.length ? Math.max(...xsec.map(([x]) => Math.abs(x))) / (refHalfOut || 1) : 1;
|
|
263
334
|
for (const side of [-1, 1]) {
|
|
264
335
|
const wall = [];
|
|
265
336
|
for (const { p, right, half } of samples) {
|
|
266
|
-
const top = p.clone().addScaledVector(right, side * half);
|
|
337
|
+
const top = p.clone().addScaledVector(right, side * half * outer);
|
|
267
338
|
const bot = top.clone(); bot.y -= skirt;
|
|
268
339
|
const n = [right.x * side, 0, right.z * side];
|
|
269
|
-
|
|
340
|
+
// and it is the side of the PAVEMENT out here, not the side of the road
|
|
341
|
+
const tex = xsec?.length ? palette.kerb : palette.asphalt;
|
|
342
|
+
wall.push([vert(top, n, tex), vert(bot, n, tex)]);
|
|
270
343
|
}
|
|
271
344
|
for (let i = 0; i < wall.length - 1; i++) {
|
|
272
345
|
const [a, b] = wall[i], [c, d] = wall[i + 1];
|
package/src/world/roadTerrain.js
CHANGED
|
@@ -14,6 +14,24 @@ import { smoothstep } from './geo.js';
|
|
|
14
14
|
|
|
15
15
|
// segment lookup on a uniform grid — a world's worth of road is far too many segments to
|
|
16
16
|
// walk per lattice vertex, and the lattice is ~1.7M vertices
|
|
17
|
+
// HOW FAR ALONG A SEGMENT A POINT SITS, or null if the segment does not cover it. The two
|
|
18
|
+
// callers below both used to inline this and both got the ends wrong in the same way; the
|
|
19
|
+
// terrain went missing in a rounded blob past the end of every road, because a point out
|
|
20
|
+
// there was told it had tarmac over it and its triangles were dropped as invisible.
|
|
21
|
+
//
|
|
22
|
+
// `narrow` pulls the edges in: a corridor's BED is wider than its carriageway, so anything
|
|
23
|
+
// asking "is the ground hidden here?" must ask about the road, not the earthwork.
|
|
24
|
+
function along(s, x, z, narrow = 0) {
|
|
25
|
+
const dx = s.bx - s.ax, dz = s.bz - s.az;
|
|
26
|
+
const len2 = dx * dx + dz * dz || 1e-6;
|
|
27
|
+
const raw = ((x - s.ax) * dx + (z - s.az) * dz) / len2;
|
|
28
|
+
// past either END of the whole line there is no road, however close the last vertex is
|
|
29
|
+
if (raw < 0 && s.first) return null;
|
|
30
|
+
if (raw > 1 && s.last) return null;
|
|
31
|
+
const t = Math.min(1, Math.max(0, raw));
|
|
32
|
+
return Math.hypot(x - (s.ax + dx * t), z - (s.az + dz * t)) <= s.half - narrow ? t : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
function segmentIndex(corridors, cell) {
|
|
18
36
|
const buckets = new Map();
|
|
19
37
|
const key = (i, j) => `${i},${j}`;
|
|
@@ -28,6 +46,19 @@ function segmentIndex(corridors, cell) {
|
|
|
28
46
|
const s = {
|
|
29
47
|
ax: a[0], ay: a[1], az: a[2], bx: b[0], by: b[1], bz: b[2],
|
|
30
48
|
half: c.halfWidth ?? 5, drop: c.drop ?? 0, batter: c.batter ?? 0.5,
|
|
49
|
+
// DOES THIS CORRIDOR CARRY TARMAC? Most do — a corridor is normally the cut and fill
|
|
50
|
+
// under a road. Some are pure earthwork: the trench that opens the ground under an
|
|
51
|
+
// interchange's lower carriageway is shaped like a road and is not one, and it runs
|
|
52
|
+
// on past the sockets at either end so the batter has somewhere to go. Told that was
|
|
53
|
+
// tarmac, the "don't draw ground you cannot see" pass threw away the terrain over the
|
|
54
|
+
// whole trench and left a hole in the world past the end of the road.
|
|
55
|
+
carries: c.deck !== false,
|
|
56
|
+
// WHERE THE ROAD STOPS. A segment answers questions about points near it by
|
|
57
|
+
// clamping the projection to its own span, which turns each end of a polyline into
|
|
58
|
+
// a half-disc of phantom road reaching a full half-width past the last vertex.
|
|
59
|
+
// Harmless in the middle of a line — the next segment covers that ground anyway —
|
|
60
|
+
// and wrong at the two ends, where it claims tarmac over open country.
|
|
61
|
+
first: i === 0, last: i === line.length - 2,
|
|
31
62
|
};
|
|
32
63
|
const id = segs.push(s) - 1;
|
|
33
64
|
const i0 = Math.floor((Math.min(a[0], b[0]) - reach) / cell), i1 = Math.floor((Math.max(a[0], b[0]) + reach) / cell);
|
|
@@ -152,10 +183,8 @@ export function makeRoadTerrain(baseHeightAt, { pads = [], corridors = [], surfa
|
|
|
152
183
|
const ids = index.near(x, z);
|
|
153
184
|
for (const id of ids ?? []) {
|
|
154
185
|
const s = index.segs[id];
|
|
155
|
-
const
|
|
156
|
-
|
|
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;
|
|
186
|
+
const t = along(s, x, z);
|
|
187
|
+
if (t == null) continue;
|
|
159
188
|
found.push(s.ay + (s.by - s.ay) * t);
|
|
160
189
|
}
|
|
161
190
|
}
|
|
@@ -187,10 +216,9 @@ export function makeRoadTerrain(baseHeightAt, { pads = [], corridors = [], surfa
|
|
|
187
216
|
let lowest = null;
|
|
188
217
|
for (const id of ids) {
|
|
189
218
|
const s = index.segs[id];
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
if (Math.hypot(x - (s.ax + dx * t), z - (s.az + dz * t)) > s.half - shrink) continue;
|
|
219
|
+
if (!s.carries) continue; // an earthwork is not a road surface
|
|
220
|
+
const t = along(s, x, z, shrink);
|
|
221
|
+
if (t == null) continue;
|
|
194
222
|
const y = s.ay + (s.by - s.ay) * t;
|
|
195
223
|
if (lowest == null || y < lowest) lowest = y;
|
|
196
224
|
}
|
package/src/world/roadWorld.js
CHANGED
|
@@ -176,6 +176,11 @@ export async function buildRoadWorld({
|
|
|
176
176
|
[b[0] + ax[0] * 30, Math.min(b[1], lowest), b[2] + ax[1] * 30],
|
|
177
177
|
],
|
|
178
178
|
halfWidth: bed, drop, batter: 4,
|
|
179
|
+
// EARTHWORK ONLY. The tarmac through here belongs to the junction prefab; this just
|
|
180
|
+
// opens the ground under it, and runs 30 m past each socket so the batter has room.
|
|
181
|
+
// Called a deck it would hide the terrain over that overshoot — a hole past the end
|
|
182
|
+
// of the motorway, which is exactly how it was reported.
|
|
183
|
+
deck: false,
|
|
179
184
|
});
|
|
180
185
|
}
|
|
181
186
|
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// SETTLEMENTS — buildings along the roads that already exist.
|
|
2
|
+
//
|
|
3
|
+
// A town is not a scatter of houses; it is frontage. Buildings stand back from a kerb by a
|
|
4
|
+
// consistent distance, face the road, sit shoulder to shoulder without overlapping, and stop
|
|
5
|
+
// short of a junction so they do not block sightlines or stand in the carriageway. Give this
|
|
6
|
+
// the roads and a catalogue of what you own, and it returns where each building goes.
|
|
7
|
+
//
|
|
8
|
+
// const plots = planFrontage({ links, catalogue, groundAt });
|
|
9
|
+
// for (const p of plots) place(p.model, p); // { model, x, y, z, yaw }
|
|
10
|
+
//
|
|
11
|
+
// It places nothing and loads nothing: a game owns its assets, and a designer may take this
|
|
12
|
+
// list, edit it, and save it as a layout.
|
|
13
|
+
import { lcgRng } from './permanentWay.js';
|
|
14
|
+
|
|
15
|
+
const EPS = 0.01;
|
|
16
|
+
|
|
17
|
+
export const FRONTAGE_DEFAULTS = {
|
|
18
|
+
setback: 6, // metres from the kerb to the front wall
|
|
19
|
+
gap: 2.5, // between neighbours
|
|
20
|
+
junctionClear: 45, // no frontage within this of a link's end
|
|
21
|
+
depthClear: 4, // keep this much behind a building free of the next road
|
|
22
|
+
maxSlope: 0.18, // a plot steeper than this is not built on
|
|
23
|
+
maxBuilding: 60, // the widest thing that may stand on a frontage
|
|
24
|
+
seed: 1,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Every plot along one side of one road. Walks the centreline, and for each step chooses a
|
|
28
|
+
// building from the catalogue that fits the gap it has left.
|
|
29
|
+
function frontageOf(link, side, catalogue, cfg, groundAt, taken) {
|
|
30
|
+
const line = link.centreline;
|
|
31
|
+
if (line.length < 4) return [];
|
|
32
|
+
const half = link.width / 2;
|
|
33
|
+
const plots = [];
|
|
34
|
+
|
|
35
|
+
// arc positions along the line, so buildings are spaced by distance rather than by index
|
|
36
|
+
const seg = [];
|
|
37
|
+
let total = 0;
|
|
38
|
+
for (let i = 1; i < line.length; i++) {
|
|
39
|
+
const d = Math.hypot(line[i][0] - line[i - 1][0], line[i][2] - line[i - 1][2]);
|
|
40
|
+
seg.push({ i, s: total, d });
|
|
41
|
+
total += d;
|
|
42
|
+
}
|
|
43
|
+
const at = (s) => {
|
|
44
|
+
let k = 0;
|
|
45
|
+
while (k < seg.length - 1 && seg[k].s + seg[k].d < s) k++;
|
|
46
|
+
const a = line[seg[k].i - 1], b = line[seg[k].i];
|
|
47
|
+
const t = Math.min(1, Math.max(0, (s - seg[k].s) / (seg[k].d || 1)));
|
|
48
|
+
const p = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
|
49
|
+
const fx = b[0] - a[0], fz = b[2] - a[2];
|
|
50
|
+
const L = Math.hypot(fx, fz) || 1;
|
|
51
|
+
return { p, fwd: [fx / L, fz / L], right: [-fz / L, fx / L] };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const rng = lcgRng(cfg.seed * 7919 + (side > 0 ? 13 : 31) + Math.round(total));
|
|
55
|
+
const models = Object.entries(catalogue);
|
|
56
|
+
if (!models.length) return [];
|
|
57
|
+
|
|
58
|
+
let s = cfg.junctionClear;
|
|
59
|
+
const end = total - cfg.junctionClear;
|
|
60
|
+
let guard = 0;
|
|
61
|
+
while (s < end && guard++ < 500) {
|
|
62
|
+
const { p, fwd, right } = at(s);
|
|
63
|
+
// pick something that fits the road it faces: nothing enormous on a country lane
|
|
64
|
+
const room = end - s;
|
|
65
|
+
const fits = models.filter(([, m]) => Math.max(m.size[0], m.size[2]) <= Math.min(room, 26));
|
|
66
|
+
if (!fits.length) break;
|
|
67
|
+
const [model, m] = fits[Math.floor(rng() * fits.length)];
|
|
68
|
+
const width = Math.max(m.size[0], m.size[2]);
|
|
69
|
+
const depth = Math.min(m.size[0], m.size[2]);
|
|
70
|
+
|
|
71
|
+
// stand it back from the kerb, centred on its own frontage
|
|
72
|
+
const off = side * (half + cfg.setback + depth / 2);
|
|
73
|
+
const cx = p[0] + right[0] * off + fwd[0] * (width / 2);
|
|
74
|
+
const cz = p[2] + right[1] * off + fwd[1] * (width / 2);
|
|
75
|
+
|
|
76
|
+
// refuse a plot the ground will not take, and one already spoken for
|
|
77
|
+
const g = groundAt(cx, cz);
|
|
78
|
+
const corner = groundAt(cx + right[0] * depth / 2, cz + right[1] * depth / 2);
|
|
79
|
+
const slope = Math.abs(corner - g) / Math.max(depth / 2, 1);
|
|
80
|
+
const clash = taken.some((t) => Math.hypot(t.x - cx, t.z - cz) < (t.r + width / 2) * 0.85);
|
|
81
|
+
if (slope <= cfg.maxSlope && !clash) {
|
|
82
|
+
// face the road: the building's back is to the outside, its front to the kerb
|
|
83
|
+
const yaw = Math.atan2(-right[0] * side, -right[1] * side);
|
|
84
|
+
plots.push({ model, x: +cx.toFixed(2), y: +g.toFixed(2), z: +cz.toFixed(2), yaw: +yaw.toFixed(4), width, depth });
|
|
85
|
+
taken.push({ x: cx, z: cz, r: width / 2 });
|
|
86
|
+
s += width + cfg.gap;
|
|
87
|
+
} else {
|
|
88
|
+
s += 8; // step past a plot that will not do
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return plots;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// WHICH ROADS HAVE FRONTAGE AT ALL. Houses face a street, a lane, a high road through a
|
|
95
|
+
// village. NOTHING fronts a motorway or a dual carriageway — no door opens onto one anywhere
|
|
96
|
+
// in the world, because you cannot stop, turn across, or walk out of it. A planner handed
|
|
97
|
+
// every road in the network will happily line a motorway with offices, and it looks exactly
|
|
98
|
+
// as wrong as it is.
|
|
99
|
+
//
|
|
100
|
+
// Judged on what the road IS, in this order: what the kit called the socket, then how many
|
|
101
|
+
// lanes it carries, then how wide it is — so a road that arrives with no classification at
|
|
102
|
+
// all is still caught by its size.
|
|
103
|
+
export const FRONTAGE_KINDS = new Set(['road', 'street', 'lane', undefined, null]);
|
|
104
|
+
|
|
105
|
+
export function hasFrontage(entry, maxWidth = 16) {
|
|
106
|
+
const kind = entry?.from?.kind ?? entry?.kind ?? entry?.link?.kind;
|
|
107
|
+
if (kind && !FRONTAGE_KINDS.has(kind)) return false;
|
|
108
|
+
const lanes = entry?.from?.lanes?.length ?? 0;
|
|
109
|
+
if (lanes > 2) return false; // more than one each way is a through route
|
|
110
|
+
const width = entry?.from?.width ?? entry?.width ?? entry?.link?.width ?? 10;
|
|
111
|
+
return width <= maxWidth;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function planFrontage({ links = [], catalogue = {}, groundAt = () => 0, ...opts } = {}) {
|
|
115
|
+
const cfg = { ...FRONTAGE_DEFAULTS, ...opts };
|
|
116
|
+
const taken = [];
|
|
117
|
+
const plots = [];
|
|
118
|
+
let refused = 0;
|
|
119
|
+
for (const entry of links) {
|
|
120
|
+
if (!hasFrontage(entry, cfg.maxFrontageWidth ?? 16)) { refused++; continue; }
|
|
121
|
+
// accept either a roadWorld link or a plain { centreline, width }
|
|
122
|
+
const link = entry.link ?? entry;
|
|
123
|
+
const width = entry.from?.width ?? entry.width ?? link.width ?? 10;
|
|
124
|
+
for (const side of [-1, 1]) {
|
|
125
|
+
plots.push(...frontageOf({ centreline: link.centreline, width }, side, catalogue, cfg, groundAt, taken));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
plots.refused = refused; // say so rather than silently building none
|
|
129
|
+
return plots;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// WHAT BELONGS WHERE. A tower block on a country lane and a barn in the financial district
|
|
133
|
+
// are the same mistake. Kiwi zoned its blocks and stopped; this is what the zone is FOR.
|
|
134
|
+
// Names are the only signal a pack gives us, so read them, and fall back on height — a thing
|
|
135
|
+
// thirty metres tall is downtown wherever it was filed.
|
|
136
|
+
export const ZONE_RULES = {
|
|
137
|
+
downtown: /office|tower|skyscraper|bank|hall|corporate/i,
|
|
138
|
+
commercial: /shop|store|market|mall|retail|cafe|restaurant|hotel|station|cinema/i,
|
|
139
|
+
residential: /house|home|apartment|flat|residential|villa|cottage|terrace/i,
|
|
140
|
+
industrial: /factory|warehouse|depot|industrial|workshop|garage|silo/i,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export function zoneOf(model, entry) {
|
|
144
|
+
for (const [zone, re] of Object.entries(ZONE_RULES)) if (re.test(model)) return zone;
|
|
145
|
+
const h = entry?.size?.[1] ?? 0;
|
|
146
|
+
return h > 24 ? 'downtown' : h > 14 ? 'commercial' : 'residential';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The models a zone will accept, widest choice first. A zone with nothing of its own takes
|
|
150
|
+
// whatever fits rather than leaving the block bare — an empty plot reads as a bug.
|
|
151
|
+
function catalogueFor(catalogue, zone, maxWidth) {
|
|
152
|
+
const all = Object.entries(catalogue).filter(([, m]) => Math.max(m.size[0], m.size[2]) <= maxWidth);
|
|
153
|
+
if (zone === 'park') return [];
|
|
154
|
+
const mine = all.filter(([n, m]) => zoneOf(n, m) === zone);
|
|
155
|
+
return mine.length >= 3 ? mine : all;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// BUILDINGS FILL A BLOCK FROM ITS EDGES INWARD. They stand along the block's boundary with
|
|
159
|
+
// their fronts to the street and their backs to the middle, which is how a city block is
|
|
160
|
+
// built and why its interior is yards and car parks rather than more buildings.
|
|
161
|
+
export function planBlockFrontage({ blocks = [], catalogue = {}, groundAt = () => 0, ...opts } = {}) {
|
|
162
|
+
const cfg = { ...FRONTAGE_DEFAULTS, ...opts };
|
|
163
|
+
const plots = [];
|
|
164
|
+
const taken = [];
|
|
165
|
+
const used = new Map(); // model -> how many are up, so variety spreads itself
|
|
166
|
+
let b = 0;
|
|
167
|
+
for (const block of blocks) {
|
|
168
|
+
const poly = block.polygon;
|
|
169
|
+
const rng = lcgRng(cfg.seed * 7919 + (b++) * 131);
|
|
170
|
+
const cx = poly.reduce((a, p) => a + p[0], 0) / poly.length;
|
|
171
|
+
const cz = poly.reduce((a, p) => a + p[1], 0) / poly.length;
|
|
172
|
+
for (let e = 0; e < poly.length; e++) {
|
|
173
|
+
const A = poly[e], B = poly[(e + 1) % poly.length];
|
|
174
|
+
const ex = B[0] - A[0], ez = B[1] - A[1];
|
|
175
|
+
const len = Math.hypot(ex, ez);
|
|
176
|
+
if (len < 8) continue;
|
|
177
|
+
const fx = ex / len, fz = ez / len;
|
|
178
|
+
// outward: away from the block's middle, i.e. toward the street this edge faces
|
|
179
|
+
let nx = -fz, nz = fx;
|
|
180
|
+
if ((A[0] + fx * len / 2 - cx) * nx + (A[1] + fz * len / 2 - cz) * nz < 0) { nx = -nx; nz = -nz; }
|
|
181
|
+
// NOTHING SO WIDE IT EATS A WHOLE FRONTAGE, and nothing so deep it meets the building
|
|
182
|
+
// backing onto it from the far side. The ceiling belongs to the pack, not to this file:
|
|
183
|
+
// a kit whose smallest building is thirty metres across needs bigger blocks, and a cap
|
|
184
|
+
// of twenty-six would silently place none of them.
|
|
185
|
+
const edgeCap = Math.min(len * 0.55, cfg.maxBuilding ?? 60, (block.depth ?? Infinity));
|
|
186
|
+
|
|
187
|
+
// leave the corners clear so two frontages do not meet at right angles inside a wall
|
|
188
|
+
let s = cfg.gap;
|
|
189
|
+
const end = len - cfg.gap;
|
|
190
|
+
let guard = 0;
|
|
191
|
+
while (s < end && guard++ < 80) {
|
|
192
|
+
const room = end - s;
|
|
193
|
+
// PICK SOMETHING THAT FILLS THE RUN, WITHOUT BUILDING THE SAME STREET TWICE.
|
|
194
|
+
// Choosing at random from everything that fits leaves a block mostly empty, because a
|
|
195
|
+
// small building drawn early strands a gap nothing else wants. Choosing only from the
|
|
196
|
+
// widest few — as this did — fills the run and turns a city into four repeated towers,
|
|
197
|
+
// since the widest few are the same four every time.
|
|
198
|
+
//
|
|
199
|
+
// So weight by width rather than truncating by it — and DECAY THE WEIGHT AS A TYPE
|
|
200
|
+
// GETS USED, or the widest still wins nearly every roll and a sixteen-building pack
|
|
201
|
+
// reads as five. Dividing by how often a model has already gone up makes the
|
|
202
|
+
// distribution spread itself: the first roll favours the big ones, and by the tenth
|
|
203
|
+
// the ones that have not been built yet are the likeliest.
|
|
204
|
+
const fits = catalogueFor(catalogue, block.zone, Math.min(room, edgeCap));
|
|
205
|
+
if (!fits.length) break;
|
|
206
|
+
const weights = fits.map(([n, m]) =>
|
|
207
|
+
Math.max(m.size[0], m.size[2]) / (1 + (used.get(n) ?? 0) * 2));
|
|
208
|
+
const total = weights.reduce((a, b) => a + b, 0);
|
|
209
|
+
let pick = rng() * total, k = 0;
|
|
210
|
+
while (k < weights.length - 1 && (pick -= weights[k]) > 0) k++;
|
|
211
|
+
const [model, m] = fits[k];
|
|
212
|
+
const width = Math.max(m.size[0], m.size[2]);
|
|
213
|
+
const depth = Math.min(m.size[0], m.size[2]);
|
|
214
|
+
// FRONT WALL ON THE BLOCK EDGE. Set back by half its own depth and no more: a
|
|
215
|
+
// building pushed further in leaves a bald strip along every street and, with a
|
|
216
|
+
// deep enough model, ends up standing in the middle of the block instead of on it.
|
|
217
|
+
const px = A[0] + fx * (s + width / 2) - nx * (depth / 2);
|
|
218
|
+
const pz = A[1] + fz * (s + width / 2) - nz * (depth / 2);
|
|
219
|
+
|
|
220
|
+
// AND CLASH ON THE FOOTPRINT, NOT ON A CIRCLE ROUND IT. A circle big enough to hold
|
|
221
|
+
// a 22 m tower keeps the next building 20 m away in every direction, so a block
|
|
222
|
+
// fits three of them and looks abandoned. The blocks are axis-aligned and so are
|
|
223
|
+
// their frontages, so the real footprints compare directly.
|
|
224
|
+
const along = Math.abs(fx) > Math.abs(fz);
|
|
225
|
+
const hw = (along ? width : depth) / 2, hd = (along ? depth : width) / 2;
|
|
226
|
+
const box = { x0: px - hw, x1: px + hw, z0: pz - hd, z1: pz + hd };
|
|
227
|
+
const clash = taken.some((t) =>
|
|
228
|
+
box.x0 < t.x1 - EPS && box.x1 > t.x0 + EPS && box.z0 < t.z1 - EPS && box.z1 > t.z0 + EPS);
|
|
229
|
+
if (!clash) {
|
|
230
|
+
used.set(model, (used.get(model) ?? 0) + 1);
|
|
231
|
+
plots.push({
|
|
232
|
+
model, x: +px.toFixed(2), y: +groundAt(px, pz).toFixed(2), z: +pz.toFixed(2),
|
|
233
|
+
yaw: +Math.atan2(nx, nz).toFixed(4), width, depth, zone: block.zone,
|
|
234
|
+
});
|
|
235
|
+
taken.push({ x0: box.x0 - cfg.gap / 2, x1: box.x1 + cfg.gap / 2, z0: box.z0 - cfg.gap / 2, z1: box.z1 + cfg.gap / 2 });
|
|
236
|
+
s += width + cfg.gap;
|
|
237
|
+
} else {
|
|
238
|
+
s += 4;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return plots;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// A town is frontage plus a name and a middle. Handy for a map, and for deciding which
|
|
247
|
+
// buildings belong where — a bank downtown, a barn on a lane.
|
|
248
|
+
export function summariseSettlement(plots) {
|
|
249
|
+
if (!plots.length) return null;
|
|
250
|
+
const x = plots.reduce((a, p) => a + p.x, 0) / plots.length;
|
|
251
|
+
const z = plots.reduce((a, p) => a + p.z, 0) / plots.length;
|
|
252
|
+
const spread = Math.max(...plots.map((p) => Math.hypot(p.x - x, p.z - z)));
|
|
253
|
+
return { x: +x.toFixed(1), z: +z.toFixed(1), radius: +spread.toFixed(1), buildings: plots.length };
|
|
254
|
+
}
|
package/src/world/walkHumps.js
CHANGED
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
|
|
23
23
|
// Footprint half-extents (x, z) and bbox apex, measured off the GLBs (probe: gltf-transform bbox).
|
|
24
24
|
// The apex rides above the −0.08 m seat placementField sinks every pile by.
|
|
25
|
+
// THE MOUNDS THIS ENGINE HAPPENS TO KNOW. Five Dustwater dust piles, measured off their GLBs.
|
|
26
|
+
// A game with its own mounds — sandbags, rubble, a spoil heap — is not doing anything wrong by
|
|
27
|
+
// having them, so `resHump()` must not crash the world build over one. Games add their own with
|
|
28
|
+
// setHumpDims(); anything still unknown is placed as ordinary scenery and said so once.
|
|
25
29
|
const DIMS = {
|
|
26
30
|
SM_Env_DustPile_Large_01: [5.66, 5.66, 0.82],
|
|
27
31
|
SM_Env_DustPile_Small_02: [1.35, 1.41, 0.82],
|
|
@@ -34,9 +38,26 @@ const CELL = 16, HALF = 512;
|
|
|
34
38
|
const key = (cx, cz) => (cx + HALF) * 4096 + (cz + HALF);
|
|
35
39
|
const _grid = new Map(); // int key → flat rows [x, z, cos, sin, 1/rx, 1/rz, apex] × n
|
|
36
40
|
|
|
41
|
+
// A game declares the footprint of its own mounds: { file: [radiusX, radiusZ, height] } in
|
|
42
|
+
// metres at scale 1, measured off the GLB. Merged over the built-in table, never replacing it.
|
|
43
|
+
export function setHumpDims(dims = {}) { Object.assign(DIMS, dims); }
|
|
44
|
+
|
|
45
|
+
const UNKNOWN = new Set();
|
|
46
|
+
|
|
37
47
|
export function registerHumps(file, placements) {
|
|
38
48
|
const dims = DIMS[file];
|
|
39
|
-
if (!dims)
|
|
49
|
+
if (!dims) {
|
|
50
|
+
// NOT AN ERROR. Throwing from a registration API means a game whose props differ from
|
|
51
|
+
// western's cannot build its world at all; the honest outcome is that this prop is scenery
|
|
52
|
+
// you walk past rather than a mound you walk over.
|
|
53
|
+
if (!UNKNOWN.has(file)) {
|
|
54
|
+
UNKNOWN.add(file);
|
|
55
|
+
console.warn(`[walkHumps] no dome dimensions for ${file} — placed as flat scenery. `
|
|
56
|
+
+ 'Measure the GLB and register it with setHumpDims({ [file]: [rx, rz, height] }) '
|
|
57
|
+
+ 'to make it walkable-over.');
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
40
61
|
for (const p of placements) {
|
|
41
62
|
const rx = dims[0] * p.s, rz = dims[1] * p.s, h = dims[2] * p.s - SEAT;
|
|
42
63
|
if (h <= 0.05) continue; // a 5 cm mound is texture, not terrain
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// MODEL CATALOGUE — measure a pack's GLBs so a placer knows what it is putting down.
|
|
3
|
+
//
|
|
4
|
+
// node tools/modelCatalogue.mjs <packDir> [filterRegex]
|
|
5
|
+
//
|
|
6
|
+
// Writes <packDir>/catalogue.json: for each model, its size and where its origin sits within
|
|
7
|
+
// it. Placing a building without knowing its footprint means either guessing (and overlapping
|
|
8
|
+
// its neighbour) or measuring it at load in every game that uses it.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
const [packDir, filter = '.', exclude = null] = process.argv.slice(2);
|
|
13
|
+
if (!packDir) { console.error('usage: modelCatalogue.mjs <packDir> [filterRegex] [excludeRegex]'); process.exit(1); }
|
|
14
|
+
const re = new RegExp(filter, 'i');
|
|
15
|
+
// Synty ships COMPONENTS as well as whole things — a roof, a floor slab, a length of pipe.
|
|
16
|
+
// A placer handed those puts a chimney on a grass verge, so a catalogue meant for placement
|
|
17
|
+
// needs to exclude them (the assembled versions live in the pack's prefabs).
|
|
18
|
+
const ex = exclude ? new RegExp(exclude, 'i') : null;
|
|
19
|
+
const modelsDir = path.join(packDir, 'models');
|
|
20
|
+
|
|
21
|
+
function bounds(file) {
|
|
22
|
+
const buf = fs.readFileSync(path.join(modelsDir, file));
|
|
23
|
+
const jsonLen = buf.readUInt32LE(12);
|
|
24
|
+
const gltf = JSON.parse(buf.slice(20, 20 + jsonLen).toString('utf8'));
|
|
25
|
+
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
|
26
|
+
for (const mesh of gltf.meshes ?? []) {
|
|
27
|
+
for (const prim of mesh.primitives ?? []) {
|
|
28
|
+
const acc = gltf.accessors[prim.attributes.POSITION];
|
|
29
|
+
if (!acc?.min) continue;
|
|
30
|
+
for (let d = 0; d < 3; d++) {
|
|
31
|
+
if (acc.min[d] < min[d]) min[d] = acc.min[d];
|
|
32
|
+
if (acc.max[d] > max[d]) max[d] = acc.max[d];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return Number.isFinite(min[0]) ? { min, max } : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const out = {};
|
|
40
|
+
let n = 0;
|
|
41
|
+
for (const f of fs.readdirSync(modelsDir)) {
|
|
42
|
+
if (!f.endsWith('.glb') || !re.test(f) || (ex && ex.test(f))) continue;
|
|
43
|
+
const b = bounds(f);
|
|
44
|
+
if (!b) continue;
|
|
45
|
+
// and nothing that is obviously not a building in its own right
|
|
46
|
+
const sz = [0, 1, 2].map((d) => b.max[d] - b.min[d]);
|
|
47
|
+
if (sz[1] < 5 || Math.max(sz[0], sz[2]) < 5) continue;
|
|
48
|
+
out[f] = {
|
|
49
|
+
size: [0, 1, 2].map((d) => +(b.max[d] - b.min[d]).toFixed(2)),
|
|
50
|
+
// where the model's own origin sits in its footprint — a building modelled around its
|
|
51
|
+
// centre and one modelled from a corner need different treatment to stand on a plot
|
|
52
|
+
centre: [0, 1, 2].map((d) => +((b.max[d] + b.min[d]) / 2).toFixed(2)),
|
|
53
|
+
base: +b.min[1].toFixed(2),
|
|
54
|
+
};
|
|
55
|
+
n++;
|
|
56
|
+
}
|
|
57
|
+
fs.writeFileSync(path.join(packDir, 'catalogue.json'), JSON.stringify(out, null, 0));
|
|
58
|
+
const sizes = Object.values(out).map((v) => Math.max(v.size[0], v.size[2]));
|
|
59
|
+
console.log(`${n} models catalogued · footprints ${Math.min(...sizes).toFixed(1)}–${Math.max(...sizes).toFixed(1)} m`);
|