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
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// A CITY IS A STREET NETWORK, AND A BLOCK IS A HOLE IN IT.
|
|
2
|
+
//
|
|
3
|
+
// This is Kiwi's model, brought over: a settlement is a grid of streets laid on ground flat
|
|
4
|
+
// enough to take them, the faces enclosed by those streets are BLOCKS, each block is zoned,
|
|
5
|
+
// and buildings fill the blocks. Kiwi got as far as the blocks — its `BlockData` carries the
|
|
6
|
+
// comment "For building placement later" and nothing ever filled them. `settlement.js` is
|
|
7
|
+
// that later.
|
|
8
|
+
//
|
|
9
|
+
// const city = planCity({ centre: { x: 0, z: 0 }, population: 12000, groundAt: ground });
|
|
10
|
+
// city.streets // [{ a, b, width, kind }] centrelines between junctions
|
|
11
|
+
// city.junctions // [{ x, y, z, arms, type, yaw }] where streets meet, benched
|
|
12
|
+
// city.blocks // [{ polygon, centre, area, zone }] what the streets enclose
|
|
13
|
+
// const heightAt = makeRoadTerrain(ground, city.earthworks);
|
|
14
|
+
//
|
|
15
|
+
// WHY A GRID AND NOT NOISE. A city read from above is legible: you can see where you are
|
|
16
|
+
// because the streets agree with each other. Scattered buildings on winding paths read as
|
|
17
|
+
// woodland with houses in it. The grid is what makes it a town.
|
|
18
|
+
//
|
|
19
|
+
// TERRAIN IS A VETO, NOT A SUGGESTION (Kiwi's rule): a cell whose ground is under water or
|
|
20
|
+
// steeper than a street can climb is not built on, so a city grows along its valley and stops
|
|
21
|
+
// at the hillside rather than paving over it. Population then decides how many of the cells
|
|
22
|
+
// that pass actually get used.
|
|
23
|
+
import { lcgRng } from './permanentWay.js';
|
|
24
|
+
|
|
25
|
+
export const CITY_DEFAULTS = {
|
|
26
|
+
blockSize: 50, // Kiwi's BLOCK_SIZE — everything else derives from it
|
|
27
|
+
streetWidth: 10, // kerb to kerb — the kit's city junctions are authored for this
|
|
28
|
+
avenueWidth: 10, // the kit has ONE street width, so an avenue is a name, not a size
|
|
29
|
+
junctionSize: 20, // Kiwi's JUNCTION_SIZE, and the City junction pieces' footprint
|
|
30
|
+
pavement: 5, // the footway between the kerb and the buildings, each side
|
|
31
|
+
latticeClear: 8, // margin beyond the section — must exceed a terrain lattice cell
|
|
32
|
+
waterLevel: 5, // below this is water and cannot be built on
|
|
33
|
+
maxSlope: 0.16, // a cell this steep will not hold a level block
|
|
34
|
+
maxStreetGrade: 0.08, // 8% — what a street actually climbs, not what one can survive
|
|
35
|
+
relax: 60, // passes of the grade relaxation below
|
|
36
|
+
peoplePerBlock: null, // Kiwi's rate was 200 at its 50 m block — see below
|
|
37
|
+
population: 8000,
|
|
38
|
+
maxRadius: 900, // a hard stop, whatever the population asks for
|
|
39
|
+
bench: 1.0, // Kiwi's ROAD_BED_OFFSET. A tenth of this is what I had, and on a
|
|
40
|
+
// terrain lattice several metres across the interpolation between
|
|
41
|
+
// two vertices eats 12 cm of clearance without noticing.
|
|
42
|
+
fade: 5, // Kiwi's INDENT_WIDTH 2 + SURROUNDING_WIDTH 3
|
|
43
|
+
blockFade: 5, // one block's bench easing into its neighbour's
|
|
44
|
+
batter: 3.5, // metres of skirt per metre of fill — how gently it comes down
|
|
45
|
+
seed: 1,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const key = (i, j) => `${i},${j}`;
|
|
49
|
+
|
|
50
|
+
// WHICH PIECE STANDS HERE, AND WHICH WAY ROUND. Kiwi classified a junction purely by how
|
|
51
|
+
// many streets met at it, and so does this. The kit's pieces are authored with a fixed set of
|
|
52
|
+
// arms — a 4-way has all four, a T is missing its north arm, an L-corner keeps east and south
|
|
53
|
+
// — so the rotation is whatever best lines those up with the streets that actually arrive.
|
|
54
|
+
//
|
|
55
|
+
// TRY ALL FOUR AND SCORE, rather than deriving the angle. A derivation has a special case per
|
|
56
|
+
// piece and gets one of them backwards; scoring cannot, and costs sixteen cosines.
|
|
57
|
+
export const PIECE_ARMS = {
|
|
58
|
+
cross: [0, Math.PI / 2, Math.PI, -Math.PI / 2], // N E S W
|
|
59
|
+
tee: [Math.PI / 2, Math.PI, -Math.PI / 2], // E S W — the missing arm is north
|
|
60
|
+
corner: [Math.PI / 2, Math.PI], // E S
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function fitPiece(arms, type) {
|
|
64
|
+
const want = PIECE_ARMS[type];
|
|
65
|
+
if (!want) return { piece: null, yaw: arms[0] ?? 0 };
|
|
66
|
+
let best = { yaw: 0, score: -Infinity };
|
|
67
|
+
for (let k = 0; k < 4; k++) {
|
|
68
|
+
const yaw = (k * Math.PI) / 2;
|
|
69
|
+
let score = 0;
|
|
70
|
+
for (const a of arms) {
|
|
71
|
+
score += Math.max(...want.map((w) => Math.cos(a - (w + yaw))));
|
|
72
|
+
}
|
|
73
|
+
if (score > best.score) best = { yaw, score };
|
|
74
|
+
}
|
|
75
|
+
return { piece: type, yaw: best.yaw };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// WHAT THE GROUND WILL TAKE. Sampled at the cell's corners and centre: a cell that straddles
|
|
79
|
+
// a stream bank has a fine average and is still unbuildable.
|
|
80
|
+
function cellSlope(cx, cz, r, groundAt, cfg) {
|
|
81
|
+
const at = [
|
|
82
|
+
[cx, cz], [cx - r, cz - r], [cx + r, cz - r], [cx - r, cz + r], [cx + r, cz + r],
|
|
83
|
+
].map(([x, z]) => groundAt(x, z));
|
|
84
|
+
if (Math.min(...at) < cfg.waterLevel) return null;
|
|
85
|
+
const slope = (Math.max(...at) - Math.min(...at)) / (r * 2);
|
|
86
|
+
return { y: at.reduce((a, b) => a + b, 0) / at.length, slope };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cellUsable(cx, cz, r, groundAt, cfg) {
|
|
90
|
+
const s = cellSlope(cx, cz, r, groundAt, cfg);
|
|
91
|
+
return s && s.slope <= cfg.maxSlope ? s.y : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Kiwi kept two rejection registers, and the distinction is the useful part: a cell rejected
|
|
95
|
+
// for SLOPE could be recovered by flattening the terrain, a cell rejected for WATER could not.
|
|
96
|
+
function cellWet(cx, cz, r, groundAt, cfg) {
|
|
97
|
+
return cellSlope(cx, cz, r, groundAt, cfg) == null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function planCity({ centre = { x: 0, z: 0 }, groundAt = () => 0, ...opts } = {}) {
|
|
101
|
+
const cfg = { ...CITY_DEFAULTS, ...opts };
|
|
102
|
+
const B = cfg.blockSize;
|
|
103
|
+
// HOW MANY PEOPLE A BLOCK HOLDS SCALES WITH ITS AREA. Kiwi's figure was 200 for its own
|
|
104
|
+
// 50 m block; carried over unchanged to an 80 m block it asks for two and a half times the
|
|
105
|
+
// blocks the population needs, and the town reports itself two thirds short of a demand
|
|
106
|
+
// that was never real.
|
|
107
|
+
const perBlock = cfg.peoplePerBlock ?? 200 * (B / 50) ** 2;
|
|
108
|
+
const want = Math.max(4, Math.round(cfg.population / perBlock));
|
|
109
|
+
const reach = Math.min(cfg.maxRadius, Math.sqrt(want / Math.PI) * B * 1.6);
|
|
110
|
+
// LOOK FURTHER THAN THE TOWN WILL NEED. If the ground it wants is half hillside, a search
|
|
111
|
+
// stopping at the ideal radius hands back half a city; searching wider lets the same
|
|
112
|
+
// population spread along the flat instead, which is what a real town on a valley floor
|
|
113
|
+
// does. The frontier is nearest-first, so a town with good ground all round stays compact.
|
|
114
|
+
const span = Math.ceil(Math.min(cfg.maxRadius, reach * 2.2) / B);
|
|
115
|
+
const scan = span * B;
|
|
116
|
+
|
|
117
|
+
// 1. WHICH CELLS THE LAND ALLOWS
|
|
118
|
+
const usable = new Map();
|
|
119
|
+
for (let i = -span; i <= span; i++) {
|
|
120
|
+
for (let j = -span; j <= span; j++) {
|
|
121
|
+
const cx = centre.x + i * B, cz = centre.z + j * B;
|
|
122
|
+
if (Math.hypot(cx - centre.x, cz - centre.z) > scan) continue;
|
|
123
|
+
const y = cellUsable(cx, cz, B / 2, groundAt, cfg);
|
|
124
|
+
if (y != null) usable.set(key(i, j), { i, j, x: cx, z: cz, y });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 2. HOW MANY OF THEM GET BUILT ON — grow outward from the middle, taking only cells that
|
|
129
|
+
// touch what is already town, so a city is one place and not a rash of hamlets.
|
|
130
|
+
const seedCell = usable.get(key(0, 0))
|
|
131
|
+
?? [...usable.values()].sort((a, b) => (a.i ** 2 + a.j ** 2) - (b.i ** 2 + b.j ** 2))[0];
|
|
132
|
+
const built = new Map();
|
|
133
|
+
if (seedCell) {
|
|
134
|
+
const frontier = [seedCell];
|
|
135
|
+
const queued = new Set([key(seedCell.i, seedCell.j)]);
|
|
136
|
+
while (frontier.length && built.size < want) {
|
|
137
|
+
// nearest-first, so the town fills in rather than growing a tentacle
|
|
138
|
+
frontier.sort((a, b) => Math.hypot(a.x - centre.x, a.z - centre.z) - Math.hypot(b.x - centre.x, b.z - centre.z));
|
|
139
|
+
const c = frontier.shift();
|
|
140
|
+
built.set(key(c.i, c.j), c);
|
|
141
|
+
for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
142
|
+
const k = key(c.i + di, c.j + dj);
|
|
143
|
+
if (usable.has(k) && !queued.has(k)) { queued.add(k); frontier.push(usable.get(k)); }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 3. THE STREETS ARE THE CELL EDGES, and a street too steep to climb is not built.
|
|
149
|
+
//
|
|
150
|
+
// THIS IS KIWI'S RULE, and it is the one that matters. Kiwi benched each junction at the
|
|
151
|
+
// average terrain height in its own box, then measured the grade BETWEEN JUNCTION
|
|
152
|
+
// HEIGHTS — "this is the actual slope the built road will have" — and rejected the road
|
|
153
|
+
// outright if it was too steep, recording the cell as one that terrain flattening might
|
|
154
|
+
// recover later. Grading each block against its own patch of hillside and then joining
|
|
155
|
+
// them up regardless is what produces a town full of ski jumps.
|
|
156
|
+
//
|
|
157
|
+
// So: bench, measure, drop the cell, and do it again, because dropping a cell changes
|
|
158
|
+
// the average at every corner it touched and can make a neighbour too steep in turn.
|
|
159
|
+
let corners, rejectedForSlope = 0;
|
|
160
|
+
const cornersOf = (cells) => {
|
|
161
|
+
const map = new Map();
|
|
162
|
+
const at = (i, j) => {
|
|
163
|
+
const k = key(i, j);
|
|
164
|
+
if (!map.has(k)) {
|
|
165
|
+
map.set(k, { i, j, x: centre.x + (i - 0.5) * B, z: centre.z + (j - 0.5) * B, y: 0, arms: [], n: 0 });
|
|
166
|
+
}
|
|
167
|
+
return map.get(k);
|
|
168
|
+
};
|
|
169
|
+
for (const c of cells.values()) {
|
|
170
|
+
for (const q of [at(c.i, c.j), at(c.i + 1, c.j), at(c.i, c.j + 1), at(c.i + 1, c.j + 1)]) {
|
|
171
|
+
q.y += c.y; q.n++;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const q of map.values()) if (q.n) q.y /= q.n;
|
|
175
|
+
return { map, at };
|
|
176
|
+
};
|
|
177
|
+
const edgesOf = (c, at) => {
|
|
178
|
+
const c00 = at(c.i, c.j), c10 = at(c.i + 1, c.j), c01 = at(c.i, c.j + 1), c11 = at(c.i + 1, c.j + 1);
|
|
179
|
+
return [[c00, c10], [c10, c11], [c11, c01], [c01, c00]];
|
|
180
|
+
};
|
|
181
|
+
for (let pass = 0; pass < 24; pass++) {
|
|
182
|
+
const { map, at } = cornersOf(built);
|
|
183
|
+
const drop = [];
|
|
184
|
+
for (const c of built.values()) {
|
|
185
|
+
const steep = edgesOf(c, at).some(([a, b]) =>
|
|
186
|
+
Math.abs(a.y - b.y) / (Math.hypot(b.x - a.x, b.z - a.z) || 1) > cfg.maxStreetGrade);
|
|
187
|
+
if (steep) drop.push(key(c.i, c.j));
|
|
188
|
+
}
|
|
189
|
+
corners = map;
|
|
190
|
+
if (!drop.length) break;
|
|
191
|
+
// never take the last of it: a city reduced to nothing is worse than a slightly steep one
|
|
192
|
+
if (built.size - drop.length < 4) break;
|
|
193
|
+
for (const k of drop) { built.delete(k); rejectedForSlope++; }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 3b. ONE PLACE, NOT SEVERAL. Dropping cells can cut the town in half; Kiwi finished its
|
|
197
|
+
// grid with a largest-connected-component filter, and so does this.
|
|
198
|
+
if (built.size) {
|
|
199
|
+
const seen = new Set();
|
|
200
|
+
let biggest = [];
|
|
201
|
+
for (const start of built.values()) {
|
|
202
|
+
const k0 = key(start.i, start.j);
|
|
203
|
+
if (seen.has(k0)) continue;
|
|
204
|
+
const group = [], queue = [start];
|
|
205
|
+
seen.add(k0);
|
|
206
|
+
while (queue.length) {
|
|
207
|
+
const c = queue.pop();
|
|
208
|
+
group.push(c);
|
|
209
|
+
for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
210
|
+
const k = key(c.i + di, c.j + dj);
|
|
211
|
+
if (built.has(k) && !seen.has(k)) { seen.add(k); queue.push(built.get(k)); }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (group.length > biggest.length) biggest = group;
|
|
215
|
+
}
|
|
216
|
+
const keep = new Set(biggest.map((c) => key(c.i, c.j)));
|
|
217
|
+
for (const k of [...built.keys()]) if (!keep.has(k)) built.delete(k);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 3bb. FILL THE HOLES. A cell the slope gate refused, with town on three sides of it, is not
|
|
221
|
+
// countryside — it is a crater, and a town with craters in it reads as bomb damage.
|
|
222
|
+
// This is the recovery Kiwi described and never wrote: it tracked slope-rejected cells
|
|
223
|
+
// separately from water-rejected ones precisely because "cells rejected due to slope
|
|
224
|
+
// COULD be flattened" to make the population back up. Water still cannot be filled.
|
|
225
|
+
// The cell takes its level from the neighbours that enclose it, so the earthworks make
|
|
226
|
+
// up the difference and the streets around it already agree with each other.
|
|
227
|
+
let recovered = 0;
|
|
228
|
+
for (let pass = 0; pass < 8; pass++) {
|
|
229
|
+
const add = [];
|
|
230
|
+
for (const c of built.values()) {
|
|
231
|
+
for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
232
|
+
const i = c.i + di, j = c.j + dj, k = key(i, j);
|
|
233
|
+
if (built.has(k) || add.some((q) => q.i === i && q.j === j)) continue;
|
|
234
|
+
const around = [[1, 0], [-1, 0], [0, 1], [0, -1]]
|
|
235
|
+
.map(([ai, aj]) => built.get(key(i + ai, j + aj))).filter(Boolean);
|
|
236
|
+
if (around.length < 3) continue;
|
|
237
|
+
const x = centre.x + i * B, z = centre.z + j * B;
|
|
238
|
+
if (cellWet(x, z, B / 2, groundAt, cfg)) continue; // water is not recoverable
|
|
239
|
+
add.push({ i, j, x, z, y: around.reduce((a, q) => a + q.y, 0) / around.length });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (!add.length) break;
|
|
243
|
+
for (const c of add) { built.set(key(c.i, c.j), c); recovered++; }
|
|
244
|
+
}
|
|
245
|
+
corners = cornersOf(built).map;
|
|
246
|
+
|
|
247
|
+
const cornerAt = (i, j) => corners.get(key(i, j)) ?? { i, j, x: centre.x + (i - 0.5) * B, z: centre.z + (j - 0.5) * B, y: 0, arms: [], n: 0 };
|
|
248
|
+
const streets = new Map();
|
|
249
|
+
for (const c of built.values()) {
|
|
250
|
+
const c00 = cornerAt(c.i, c.j), c10 = cornerAt(c.i + 1, c.j);
|
|
251
|
+
const c01 = cornerAt(c.i, c.j + 1), c11 = cornerAt(c.i + 1, c.j + 1);
|
|
252
|
+
for (const [a, b] of [[c00, c10], [c10, c11], [c11, c01], [c01, c00]]) {
|
|
253
|
+
const k = a.i < b.i || (a.i === b.i && a.j < b.j)
|
|
254
|
+
? `${a.i},${a.j}|${b.i},${b.j}` : `${b.i},${b.j}|${a.i},${a.j}`;
|
|
255
|
+
if (!streets.has(k)) streets.set(k, { a, b });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 3b. AND THEN FLATTEN THEM INTO A CITY. Averaging the terrain under each block
|
|
260
|
+
// independently gives every block its own level and every street between two of them
|
|
261
|
+
// whatever grade the hillside happened to have — which on rolling ground is a set of
|
|
262
|
+
// ramps no street in the world climbs. A city on a slope is TERRACED: each junction
|
|
263
|
+
// sits at a height that differs from its neighbours' by no more than a street can
|
|
264
|
+
// climb, and the earthworks make up the difference.
|
|
265
|
+
//
|
|
266
|
+
// Same Lipschitz envelope the railway formation uses, run over the street graph
|
|
267
|
+
// instead of along a line: repeatedly pull the two ends of any over-steep street
|
|
268
|
+
// toward each other until none is left. It converges because every pass strictly
|
|
269
|
+
// reduces the worst violation, and it settles near the terrain because that is where
|
|
270
|
+
// it started.
|
|
271
|
+
const edges = [...streets.values()].map((s) => ({
|
|
272
|
+
a: s.a, b: s.b, len: Math.hypot(s.b.x - s.a.x, s.b.z - s.a.z) || 1,
|
|
273
|
+
}));
|
|
274
|
+
for (let pass = 0; pass < cfg.relax; pass++) {
|
|
275
|
+
let worst = 0;
|
|
276
|
+
for (const e of edges) {
|
|
277
|
+
const limit = e.len * cfg.maxStreetGrade;
|
|
278
|
+
const d = e.a.y - e.b.y;
|
|
279
|
+
const over = Math.abs(d) - limit;
|
|
280
|
+
if (over <= 0) continue;
|
|
281
|
+
worst = Math.max(worst, over);
|
|
282
|
+
const shift = (over / 2) * Math.sign(d);
|
|
283
|
+
e.a.y -= shift;
|
|
284
|
+
e.b.y += shift;
|
|
285
|
+
}
|
|
286
|
+
if (worst < 0.01) break;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 4. JUNCTIONS: what meets here decides which piece stands here (Kiwi classified purely by
|
|
290
|
+
// connection count, and so does this).
|
|
291
|
+
for (const s of streets.values()) {
|
|
292
|
+
const ang = (from, to) => Math.atan2(to.x - from.x, to.z - from.z);
|
|
293
|
+
s.a.arms.push(ang(s.a, s.b));
|
|
294
|
+
s.b.arms.push(ang(s.b, s.a));
|
|
295
|
+
}
|
|
296
|
+
const junctions = [];
|
|
297
|
+
for (const q of corners.values()) {
|
|
298
|
+
if (!q.arms.length) continue;
|
|
299
|
+
const arms = q.arms.slice().sort((x, y) => x - y);
|
|
300
|
+
// TWO ARMS IS NOT ONE CASE. Kiwi branches on WHICH two (_CityJunctions.cs): two opposite
|
|
301
|
+
// arms are a street running straight through and get back-to-back straights, two
|
|
302
|
+
// PERPENDICULAR arms are a corner and get the L-piece. Mapping arm count straight to a
|
|
303
|
+
// name — as this did — means a corner is never produced at all, so at every turn in the
|
|
304
|
+
// grid the ribbons simply stop and nothing joins them round.
|
|
305
|
+
const opposed = arms.length === 2 && Math.cos(arms[0] - arms[1]) < -0.5;
|
|
306
|
+
const type = arms.length >= 4 ? 'cross'
|
|
307
|
+
: arms.length === 3 ? 'tee'
|
|
308
|
+
: arms.length === 2 ? (opposed ? 'through' : 'corner')
|
|
309
|
+
: arms.length === 1 ? 'end' : 'none';
|
|
310
|
+
const fit = fitPiece(arms, type);
|
|
311
|
+
q.piece = fit.piece; // remembered so the streets know where to stop
|
|
312
|
+
junctions.push({ x: q.x, y: q.y, z: q.z, arms, type, ...fit, size: cfg.junctionSize });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 5. AND THE BLOCKS — for a grid the faces are the cells themselves, inset by the streets
|
|
316
|
+
// around them. Zoned by how central they are: offices downtown, houses at the edge.
|
|
317
|
+
const rng = lcgRng(cfg.seed * 104729 + built.size);
|
|
318
|
+
const half = B / 2;
|
|
319
|
+
// zone against the town that EXISTS: a city that had to sprawl has its downtown at the
|
|
320
|
+
// middle of what got built, not at the middle of what it hoped for
|
|
321
|
+
const spread = Math.max(B, ...[...built.values()].map((c) => Math.hypot(c.x - centre.x, c.z - centre.z)));
|
|
322
|
+
const blocks = [];
|
|
323
|
+
for (const c of built.values()) {
|
|
324
|
+
// A BLOCK NEVER STANDS ABOVE THE STREETS AROUND IT. Its own terrain average would undo
|
|
325
|
+
// the relaxation; the average of its corners still leaves it proud wherever the two
|
|
326
|
+
// streets it sits between are lower than the two it does not, and then its paving stands
|
|
327
|
+
// over the carriageway with a lip you can see from the air. The LOWEST corner is the only
|
|
328
|
+
// height that cannot do that: the block tucks under every street that touches it.
|
|
329
|
+
const own = [cornerAt(c.i, c.j), cornerAt(c.i + 1, c.j), cornerAt(c.i, c.j + 1), cornerAt(c.i + 1, c.j + 1)];
|
|
330
|
+
c.y = Math.min(...own.map((q) => q.y));
|
|
331
|
+
const central = Math.hypot(c.x - centre.x, c.z - centre.z) / spread;
|
|
332
|
+
const wide = central < 0.3 ? cfg.avenueWidth : cfg.streetWidth;
|
|
333
|
+
// A BLOCK STOPS AT THE BACK OF THE PAVEMENT, not at the kerb. Inset by the carriageway
|
|
334
|
+
// alone and the buildings stand in the gutter with nowhere to walk — which is what a city
|
|
335
|
+
// with no footways looks like, and it looks wrong before you can say why.
|
|
336
|
+
const in_ = half - wide / 2 - cfg.pavement;
|
|
337
|
+
blocks.push({
|
|
338
|
+
centre: { x: c.x, y: c.y, z: c.z },
|
|
339
|
+
polygon: [[c.x - in_, c.z - in_], [c.x + in_, c.z - in_], [c.x + in_, c.z + in_], [c.x - in_, c.z + in_]],
|
|
340
|
+
area: (in_ * 2) ** 2,
|
|
341
|
+
street: wide,
|
|
342
|
+
pavement: cfg.pavement,
|
|
343
|
+
zone: central < 0.28 ? 'downtown' : central < 0.55 ? 'commercial' : central < 0.85 ? 'residential' : 'industrial',
|
|
344
|
+
rank: central,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
// one square with nothing on it: every town has somewhere to stand
|
|
348
|
+
if (blocks.length > 6) blocks[Math.floor(rng() * Math.min(4, blocks.length))].zone = 'park';
|
|
349
|
+
|
|
350
|
+
// 6. THE EARTHWORKS, ready for makeRoadTerrain: a bench under each block and a corridor
|
|
351
|
+
// under each street. Same machinery the inter-city roads use — a city street is a road.
|
|
352
|
+
// A BLOCK'S BENCH MUST STOP AT ITS OWN BOUNDARY. Made the full width of the cell it
|
|
353
|
+
// reaches to the middle of every street around it, so each half of a street is benched
|
|
354
|
+
// to a different block's level and the carriageway has a step down its centre line —
|
|
355
|
+
// metres of it where the town is terraced. The street belongs to its own corridor.
|
|
356
|
+
//
|
|
357
|
+
// And a fade is for the edge of town, not for the inside of it: eased over thirty metres
|
|
358
|
+
// every interior block would drag its neighbours' levels back toward raw hillside,
|
|
359
|
+
// putting the ground above the pavement it was cut for. Interior blocks ease over a few
|
|
360
|
+
// metres into the block next door; only the edge of town blends into the countryside.
|
|
361
|
+
const neighbours = (c) => [[1, 0], [-1, 0], [0, 1], [0, -1]]
|
|
362
|
+
.filter(([di, dj]) => built.has(key(c.i + di, c.j + dj))).length;
|
|
363
|
+
|
|
364
|
+
// HOW FAR THE GROUND NEEDS TO GET BACK DOWN. Kiwi's skirt is a fixed 3 m because Kiwi never
|
|
365
|
+
// fills much: it REJECTS a cell it cannot sit on. This relaxes instead, terracing the city
|
|
366
|
+
// so it keeps cells Kiwi would have thrown away — and where the land falls away from a
|
|
367
|
+
// terrace, that is an embankment several metres tall. A fixed skirt then leaves it standing
|
|
368
|
+
// as a cliff with the ground overhanging at the edge of town.
|
|
369
|
+
//
|
|
370
|
+
// So the skirt is the fill it has to lose, laid back at `batter`. A block sitting level with
|
|
371
|
+
// its own ground keeps the short skirt; one holding up six metres of fill gets the width to
|
|
372
|
+
// come down at a walkable slope.
|
|
373
|
+
const skirtFor = (c) => {
|
|
374
|
+
const fill = c.y - groundAt(c.x, c.z);
|
|
375
|
+
return Math.max(neighbours(c) === 4 ? cfg.blockFade : cfg.fade, Math.abs(fill) * cfg.batter);
|
|
376
|
+
};
|
|
377
|
+
const earthworks = {
|
|
378
|
+
pads: [...built.values()].map((c) => {
|
|
379
|
+
const b = blocks.find((q) => q.centre.x === c.x && q.centre.z === c.z);
|
|
380
|
+
const inset = (B - (b?.street ?? cfg.streetWidth)) / 2;
|
|
381
|
+
return {
|
|
382
|
+
x: c.x, z: c.z, y: c.y, yaw: 0,
|
|
383
|
+
halfX: inset, halfZ: inset, drop: cfg.bench,
|
|
384
|
+
fade: skirtFor(c),
|
|
385
|
+
};
|
|
386
|
+
}),
|
|
387
|
+
corridors: [...streets.values()].map((s) => ({
|
|
388
|
+
// the corridor uses the FULL span: the ground has to be right under the junction too,
|
|
389
|
+
// and wide enough to meet the block benches on either side with nothing left between
|
|
390
|
+
centreline: [[s.a.x, s.a.y, s.a.z], [s.b.x, s.b.y, s.b.z]],
|
|
391
|
+
// WIDER THAN THE SECTION BY MORE THAN ONE LATTICE CELL. A street is not just its
|
|
392
|
+
// carriageway — the ribbon carries a kerb, a footway and a verge either side — and
|
|
393
|
+
// clearing that by a metre or two is still not enough: the terrain is DRAWN as a
|
|
394
|
+
// lattice, and between two vertices that straddle the edge of the cut the mesh
|
|
395
|
+
// interpolates straight across, riding over the kerb even though the height function
|
|
396
|
+
// it was sampled from never does. Every analytic probe says clean and the picture
|
|
397
|
+
// still shows grass over the pavement. Clear the whole section by a good margin.
|
|
398
|
+
halfWidth: cfg.streetWidth / 2 + cfg.pavement + cfg.latticeClear,
|
|
399
|
+
drop: cfg.bench, batter: 0.6,
|
|
400
|
+
})),
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// 7. WHERE A ROAD OUT OF TOWN ATTACHES. A junction on the edge of the grid has a cardinal
|
|
404
|
+
// arm with no street on it, pointing at open country — that is a socket, in the same
|
|
405
|
+
// shape the road kit uses, so an inter-city link can be built to it with no special case.
|
|
406
|
+
// Kiwi never did this: its inter-city window was a manual tool you typed two points into,
|
|
407
|
+
// so nothing ever joined a town to the road that passes it.
|
|
408
|
+
const CARDINAL = [[0, [0, 1]], [Math.PI / 2, [1, 0]], [Math.PI, [0, -1]], [-Math.PI / 2, [-1, 0]]];
|
|
409
|
+
const exits = [];
|
|
410
|
+
for (const j of junctions) {
|
|
411
|
+
if (j.arms.length >= 4) continue;
|
|
412
|
+
for (const [ang, dir] of CARDINAL) {
|
|
413
|
+
if (j.arms.some((a) => Math.cos(a - ang) > 0.99)) continue; // a street already uses it
|
|
414
|
+
// and it must face OUT of town, or a link leaves through the middle of the place
|
|
415
|
+
if ((j.x - centre.x) * dir[0] + (j.z - centre.z) * dir[1] < spread * 0.15) continue;
|
|
416
|
+
exits.push({
|
|
417
|
+
id: `exit_${exits.length}`,
|
|
418
|
+
pos: [j.x + dir[0] * cfg.junctionSize / 2, j.y, j.z + dir[1] * cfg.junctionSize / 2],
|
|
419
|
+
dir, width: cfg.streetWidth, kind: 'road',
|
|
420
|
+
lanes: [{ side: 'L', width: cfg.streetWidth / 2 }, { side: 'R', width: cfg.streetWidth / 2 }],
|
|
421
|
+
junction: j,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
centre: { x: centre.x, z: centre.z },
|
|
428
|
+
radius: +spread.toFixed(1),
|
|
429
|
+
exits,
|
|
430
|
+
population: cfg.population,
|
|
431
|
+
blocks,
|
|
432
|
+
junctions,
|
|
433
|
+
// A STREET RUNS BETWEEN THE PIECES, NOT THROUGH THEM. The junction prefab already owns
|
|
434
|
+
// its own twenty metres of tarmac; a ribbon drawn corner to corner would lie on top of it
|
|
435
|
+
// and the two would fight for the same pixels all the way across the town.
|
|
436
|
+
streets: [...streets.values()].map((s) => {
|
|
437
|
+
const dx = s.b.x - s.a.x, dz = s.b.z - s.a.z;
|
|
438
|
+
const L = Math.hypot(dx, dz) || 1;
|
|
439
|
+
// ONLY where a piece stands. A straight-through corner has no prefab on it — Kiwi laid
|
|
440
|
+
// two back-to-back straights there and this kit has none — so the ribbon must run
|
|
441
|
+
// through it unbroken or the street has a hole in the middle of the tarmac.
|
|
442
|
+
const cut = Math.min(cfg.junctionSize / 2, L / 2 - 1);
|
|
443
|
+
const ta = s.a.piece ? cut : 0, tb = s.b.piece ? cut : 0;
|
|
444
|
+
// MEET THE JUNCTION AT THE JUNCTION'S HEIGHT. A junction piece is a flat slab seated at
|
|
445
|
+
// its corner's level, so its arm ends at that level however the street beyond it
|
|
446
|
+
// climbs. Interpolating the height along the trim starts the ribbon ten metres in at a
|
|
447
|
+
// height the piece never reaches — on an 8% street that is a 0.8 m step at every arm,
|
|
448
|
+
// which is exactly what it looks like. The grade belongs to the span BETWEEN the
|
|
449
|
+
// pieces; the ends are the pieces' own.
|
|
450
|
+
const lerp = (k, y) => [s.a.x + (dx / L) * k, y, s.a.z + (dz / L) * k];
|
|
451
|
+
return {
|
|
452
|
+
a: lerp(ta, s.a.y), b: lerp(L - tb, s.b.y),
|
|
453
|
+
full: [[s.a.x, s.a.y, s.a.z], [s.b.x, s.b.y, s.b.z]],
|
|
454
|
+
width: cfg.streetWidth,
|
|
455
|
+
kind: Math.hypot((s.a.x + s.b.x) / 2 - centre.x, (s.a.z + s.b.z) / 2 - centre.z) < spread * 0.3 ? 'avenue' : 'street',
|
|
456
|
+
};
|
|
457
|
+
}),
|
|
458
|
+
earthworks,
|
|
459
|
+
// how much of what the population asked for the land actually allowed — a city squeezed
|
|
460
|
+
// into a valley is a fact worth reporting, not a silent shortfall
|
|
461
|
+
demand: want,
|
|
462
|
+
supply: built.size,
|
|
463
|
+
rejectedForSlope,
|
|
464
|
+
recovered,
|
|
465
|
+
// the steepest street the plan ended up with, so a caller can see the relaxation worked
|
|
466
|
+
grade: +Math.max(0, ...[...streets.values()].map((s) =>
|
|
467
|
+
Math.abs(s.a.y - s.b.y) / (Math.hypot(s.b.x - s.a.x, s.b.z - s.a.z) || 1))).toFixed(4),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Where a city should stand: flat, dry, and not on top of the last one.
|
|
472
|
+
export function pickCitySites(groundAt, { count = 3, half = 900, step = 120, minGap = 700, ...opts } = {}) {
|
|
473
|
+
const cfg = { ...CITY_DEFAULTS, ...opts };
|
|
474
|
+
const scored = [];
|
|
475
|
+
for (let x = -half; x <= half; x += step) {
|
|
476
|
+
for (let z = -half; z <= half; z += step) {
|
|
477
|
+
const here = cellSlope(x, z, cfg.blockSize * 1.5, groundAt, cfg);
|
|
478
|
+
if (!here || here.slope > cfg.maxSlope) continue;
|
|
479
|
+
// HOW MUCH GOOD GROUND IS WITHIN A TOWN'S REACH, scored on a continuum rather than
|
|
480
|
+
// counted. A tally ties everywhere on gentle terrain and the winner is then whichever
|
|
481
|
+
// corner of the map the loop happened to visit first; flatness has to be a number.
|
|
482
|
+
let good = 0;
|
|
483
|
+
for (let a = 0; a < 8; a++) {
|
|
484
|
+
const th = (a / 8) * Math.PI * 2;
|
|
485
|
+
for (const r of [120, 260, 400]) {
|
|
486
|
+
const s = cellSlope(x + Math.cos(th) * r, z + Math.sin(th) * r, cfg.blockSize / 2, groundAt, cfg);
|
|
487
|
+
if (s && s.slope <= cfg.maxSlope) good += 1 - s.slope / cfg.maxSlope;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
scored.push({ x, y: here.y, z, good: +good.toFixed(3), slope: +here.slope.toFixed(3) });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
scored.sort((a, b) => b.good - a.good);
|
|
494
|
+
const out = [];
|
|
495
|
+
for (const s of scored) {
|
|
496
|
+
if (out.length >= count) break;
|
|
497
|
+
if (out.every((o) => Math.hypot(o.x - s.x, o.z - s.z) >= minGap)) out.push(s);
|
|
498
|
+
}
|
|
499
|
+
return out;
|
|
500
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// A MAP: SEVERAL PLACES, AND THE ROADS BETWEEN THEM.
|
|
2
|
+
//
|
|
3
|
+
// `roadWorld.js` builds a road system out of junction prefabs. `cityPlan.js` builds a town.
|
|
4
|
+
// This is the thing they were both for — a map that reads like a real one, with towns where
|
|
5
|
+
// the land allows and roads joining them across the country in between:
|
|
6
|
+
//
|
|
7
|
+
// const map = await buildMapWorld({ ground, kit, placePiece });
|
|
8
|
+
// const heightAt = map.heightAt; // ← give THIS to buildHeightfield
|
|
9
|
+
// for (const c of map.cities) drawCity(c);
|
|
10
|
+
// for (const l of map.links) drawRoad(l);
|
|
11
|
+
//
|
|
12
|
+
// THE ORDER MATTERS, for the same reason it does in roadWorld:
|
|
13
|
+
// 1. choose the sites — flat, dry, far enough apart to be separate places
|
|
14
|
+
// 2. plan each city — each one benches its own ground
|
|
15
|
+
// 3. wrap the ground with the towns — so a road out of one leaves over its real level
|
|
16
|
+
// 4. plan and build the links — they read that ground
|
|
17
|
+
// 5. wrap again with the corridors — the final heightAt the world uses
|
|
18
|
+
// Build the links before the towns exist in the ground and every road arrives at a town by
|
|
19
|
+
// running up the hillside the town was cut into.
|
|
20
|
+
//
|
|
21
|
+
// Kiwi had both halves and never joined them: its inter-city window was a manual tool you
|
|
22
|
+
// typed two points into, so a road never started at a town. A city's `exits` — a junction on
|
|
23
|
+
// the edge of the grid with a cardinal arm facing open country — is what makes the join
|
|
24
|
+
// automatic, and it is deliberately shaped like a road-kit socket so `buildRoadLink` needs
|
|
25
|
+
// no special case for it.
|
|
26
|
+
import { planCity, pickCitySites } from './cityPlan.js';
|
|
27
|
+
import { makeRoadTerrain } from './roadTerrain.js';
|
|
28
|
+
import { planEdges } from './roadNetwork.js';
|
|
29
|
+
import { buildRoadLink } from './roadLink.js';
|
|
30
|
+
|
|
31
|
+
export async function buildMapWorld({
|
|
32
|
+
ground, // the game's natural heightAt
|
|
33
|
+
cities = 3, // how many places
|
|
34
|
+
population = 7000, // each
|
|
35
|
+
half = 900, // where they may go
|
|
36
|
+
minGap = 700, // and how far apart they must be
|
|
37
|
+
cityOptions = {}, // passed through to planCity
|
|
38
|
+
maxGrade = 0.07, // for the roads BETWEEN towns — a country road, not a street
|
|
39
|
+
linkStep = 3,
|
|
40
|
+
handleScale = 0.4,
|
|
41
|
+
drop = 0.12,
|
|
42
|
+
extra = 0.35, // how many links beyond a spanning tree, so the map has loops
|
|
43
|
+
} = {}) {
|
|
44
|
+
// 1. WHERE THE PLACES GO
|
|
45
|
+
const sites = pickCitySites(ground, { count: cities, half, minGap, ...cityOptions });
|
|
46
|
+
if (!sites.length) return { heightAt: ground, cities: [], links: [], sites: [] };
|
|
47
|
+
|
|
48
|
+
// 2. EACH PLACE, PLANNED ON THE RAW LAND. A town benches its own ground; it does not care
|
|
49
|
+
// what the neighbouring town did, because they are a kilometre apart.
|
|
50
|
+
const plans = sites.map((s) => planCity({ centre: s, groundAt: ground, population, ...cityOptions }))
|
|
51
|
+
.filter((c) => c.blocks.length);
|
|
52
|
+
if (!plans.length) return { heightAt: ground, cities: [], links: [], sites };
|
|
53
|
+
|
|
54
|
+
// 3. THE GROUND WITH THE TOWNS IN IT
|
|
55
|
+
const townworks = {
|
|
56
|
+
pads: plans.flatMap((c) => c.earthworks.pads),
|
|
57
|
+
corridors: plans.flatMap((c) => c.earthworks.corridors),
|
|
58
|
+
};
|
|
59
|
+
const townGround = makeRoadTerrain(ground, townworks);
|
|
60
|
+
|
|
61
|
+
// 4. WHO JOINS WHOM, and through which gate. A town has many exits; the one a given road
|
|
62
|
+
// uses is the one whose arm points closest to where the road is going — anything else
|
|
63
|
+
// leaves through the back of the town and doubles round it.
|
|
64
|
+
const centres = plans.map((c) => ({ x: c.centre.x, z: c.centre.z }));
|
|
65
|
+
const pairs = planEdges(centres, { extra });
|
|
66
|
+
const used = new Set();
|
|
67
|
+
const facing = (city, tx, tz, taken) => {
|
|
68
|
+
let best = null;
|
|
69
|
+
for (const e of city.exits) {
|
|
70
|
+
const k = `${city.centre.x},${city.centre.z}:${e.id}`;
|
|
71
|
+
if (taken.has(k)) continue;
|
|
72
|
+
const vx = tx - e.pos[0], vz = tz - e.pos[2];
|
|
73
|
+
const len = Math.hypot(vx, vz) || 1;
|
|
74
|
+
// point the right way FIRST, then prefer the exit nearest the target: an exit facing
|
|
75
|
+
// north is no use to a road going south however close to it the far town happens to be
|
|
76
|
+
const aim = (e.dir[0] * vx + e.dir[1] * vz) / len;
|
|
77
|
+
if (aim < 0.2) continue;
|
|
78
|
+
const score = aim * 2 - len / 4000;
|
|
79
|
+
if (!best || score > best.score) best = { e, k, score };
|
|
80
|
+
}
|
|
81
|
+
return best;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const links = [];
|
|
85
|
+
for (const { a, b } of pairs) {
|
|
86
|
+
const A = plans[a], B = plans[b];
|
|
87
|
+
const from = facing(A, B.centre.x, B.centre.z, used);
|
|
88
|
+
const to = facing(B, A.centre.x, A.centre.z, used);
|
|
89
|
+
if (!from || !to) continue;
|
|
90
|
+
used.add(from.k); used.add(to.k);
|
|
91
|
+
links.push({
|
|
92
|
+
a, b, from: from.e, to: to.e,
|
|
93
|
+
link: buildRoadLink({
|
|
94
|
+
from: from.e, to: to.e,
|
|
95
|
+
groundAt: (x, z) => townGround(x, z) + 0.15,
|
|
96
|
+
handleScale, step: linkStep, maxGrade,
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 5. THE GROUND THE WORLD ACTUALLY USES
|
|
102
|
+
const corridors = [
|
|
103
|
+
...townworks.corridors,
|
|
104
|
+
...links.map(({ link, from }) => ({
|
|
105
|
+
centreline: link.centreline, halfWidth: from.width / 2 + 5, drop, batter: 0.5,
|
|
106
|
+
})),
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
heightAt: makeRoadTerrain(ground, { pads: townworks.pads, corridors }),
|
|
111
|
+
cities: plans,
|
|
112
|
+
links,
|
|
113
|
+
sites,
|
|
114
|
+
pads: townworks.pads,
|
|
115
|
+
corridors,
|
|
116
|
+
// what the map came out as, for a caller that wants to say so
|
|
117
|
+
summary: `${plans.length} places · ${plans.reduce((n, c) => n + c.blocks.length, 0)} blocks · `
|
|
118
|
+
+ `${links.length} roads between them · ${links.filter((l) => l.link.feasible === false).length} over grade`,
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/world/roadAssert.js
CHANGED
|
@@ -95,7 +95,34 @@ export function auditRoadWorld(roads, { ground, samples = 24, tolerance = 0.06 }
|
|
|
95
95
|
steep.length ? fail('every link is drivable at its grade', `${steep.length} link(s) needed more grade than allowed`)
|
|
96
96
|
: pass('every link is drivable at its grade', `${links.length} links within grade`);
|
|
97
97
|
|
|
98
|
-
// 5.
|
|
98
|
+
// 5. NOTHING CLAIMS TARMAC IT HAS NOT GOT. A game that hides the ground under its roads —
|
|
99
|
+
// the only cure for two surfaces a few centimetres apart fighting for the same pixels —
|
|
100
|
+
// trusts `heightAt.deck` absolutely: wherever it answers, the terrain is thrown away.
|
|
101
|
+
// So an answer past the end of a road does not make a small error, it makes a hole in
|
|
102
|
+
// the world with the sky showing through it. This was reported from the driver's seat
|
|
103
|
+
// as a black patch at the end of every road, twice.
|
|
104
|
+
let phantom = 0, phantomAt = null, n5 = 0;
|
|
105
|
+
for (const { link, from } of links) {
|
|
106
|
+
const line = link.centreline;
|
|
107
|
+
const half = from.width / 2;
|
|
108
|
+
for (const [p, q] of [[line[0], line[1]], [line[line.length - 1], line[line.length - 2]]]) {
|
|
109
|
+
if (!p || !q) continue;
|
|
110
|
+
const ax = p[0] - q[0], az = p[2] - q[2];
|
|
111
|
+
const L = Math.hypot(ax, az) || 1; // outward, away from the road
|
|
112
|
+
for (const past of [1.5, half, half * 2 + 4]) {
|
|
113
|
+
for (const side of [-0.8, 0, 0.8]) {
|
|
114
|
+
const x = p[0] + (ax / L) * past - (az / L) * side * half;
|
|
115
|
+
const z = p[2] + (az / L) * past + (ax / L) * side * half;
|
|
116
|
+
n5++;
|
|
117
|
+
if (heightAt.deck?.(x, z) != null) { phantom++; phantomAt ??= [Math.round(x), Math.round(z)]; }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
phantom ? fail('no phantom tarmac past a road end', `${phantom}/${n5} probes claimed a deck, first at ${phantomAt}`)
|
|
123
|
+
: pass('no phantom tarmac past a road end', `${n5} probes beyond the ends clean`);
|
|
124
|
+
|
|
125
|
+
// 6. JUNCTIONS SIT ON THEIR OWN GROUND. A piece seated at the wrong level is the difference
|
|
99
126
|
// between a slip road and a diving board.
|
|
100
127
|
let hanging = 0;
|
|
101
128
|
for (const [, p] of placed) {
|