sindicate 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +129 -0
- package/package.json +1 -1
- package/src/audio/synth.js +31 -13
- package/src/core/anim.js +22 -0
- package/src/core/engine.js +50 -5
- package/src/core/overrides.js +84 -0
- package/src/core/quality.js +6 -2
- package/src/systems/climbing.js +18 -2
- package/src/systems/coach.js +3 -3
- package/src/systems/combat.js +17 -17
- package/src/systems/crime.js +5 -5
- package/src/systems/deadeye.js +4 -4
- package/src/systems/encounters.js +1 -1
- package/src/systems/enemy.js +9 -9
- package/src/systems/footsteps.js +1 -1
- package/src/systems/loot.js +2 -2
- package/src/systems/npc.js +1 -1
- package/src/systems/player.js +32 -15
- package/src/systems/riding.js +26 -7
- package/src/systems/shop.js +2 -2
- package/src/systems/train.js +32 -9
- package/src/vehicle/car.js +324 -0
- package/src/vehicle/carAudio.js +124 -0
- package/src/world/cityPlan.js +500 -0
- package/src/world/mapWorld.js +120 -0
- package/src/world/roadAssert.js +152 -0
- package/src/world/roadLink.js +162 -16
- package/src/world/roadNetwork.js +40 -0
- package/src/world/roadTerrain.js +150 -8
- package/src/world/roadWorld.js +205 -0
- package/src/world/settlement.js +254 -0
- package/src/world/walkHumps.js +22 -1
- package/tools/modelCatalogue.mjs +59 -0
- package/tools/unityPackageExtract.mjs +56 -0
- package/tools/unitySceneToBuildings.mjs +273 -0
- package/tools/wholeBuildings.mjs +233 -0
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
// 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,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
heightAt: makeRoadTerrain(ground, { pads, corridors }),
|
|
189
|
+
links,
|
|
190
|
+
pads,
|
|
191
|
+
corridors,
|
|
192
|
+
placed,
|
|
193
|
+
sites: chosen,
|
|
194
|
+
network: net,
|
|
195
|
+
order,
|
|
196
|
+
// the median strip through each junction: its two carriageways are separate meshes, and
|
|
197
|
+
// the ground would otherwise be visible dropping away between them
|
|
198
|
+
medianDecks: [...placed.values()].flatMap((p) => {
|
|
199
|
+
const mw = (p.sockets ?? []).filter((s) => s.kind === 'motorway');
|
|
200
|
+
if (mw.length < 2) return [];
|
|
201
|
+
const inner = (mw[0].section?.lines ?? []).map((b) => Math.abs(b.at)).sort((x, y) => x - y)[0] ?? 1.1;
|
|
202
|
+
return [buildDeck({ from: mw[0].pos, to: mw[1].pos, width: inner * 2 + 0.6 })];
|
|
203
|
+
}),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -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`);
|
|
@@ -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
|
+
}
|