sindicate 0.11.0 → 0.13.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/docs/plans/world-splits-recon.json +387 -0
- package/package.json +9 -2
- package/src/core/assets.js +15 -0
- package/src/core/engine.js +9 -0
- package/src/core/tune.js +50 -0
- package/src/systems/missions.js +2 -2
- package/src/systems/nav.js +4 -1
- package/src/systems/shatter.js +2 -1
- package/src/ui/cartograph.js +6 -0
- package/src/ui/deathScreen.js +3 -3
- package/src/ui/dialogue.js +87 -15
- package/src/ui/intro.js +1 -1
- package/src/ui/pauseMenu.js +50 -14
- package/src/ui/saveLoadScreen.js +16 -12
- package/src/ui/theme.js +34 -0
- package/src/ui/titleScreen.js +28 -34
- package/src/ui/weaponWheel.js +138 -0
- package/src/ui/worldmap.js +119 -161
- package/src/world/bridgeSplice.js +176 -0
- package/src/world/geo.js +53 -0
- package/src/world/railFormation.js +185 -0
- package/tools/devPlugin.js +336 -0
- package/tools/registry.json +45 -0
- package/tools/sindicate-cli.mjs +71 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// THE BRIDGE SPLICER (world splits, step 3) — every road x river crossing becomes a deck.
|
|
2
|
+
// Moved verbatim from the games (their copies were byte-identical except two spacing
|
|
3
|
+
// numbers, parameterized below): find crossings, drop hits an existing deck already
|
|
4
|
+
// carries, dedupe widest-first, then splice the road STRAIGHT across the channel on a
|
|
5
|
+
// perpendicular chord — clear-bank anchors, collinear approach legs, rejoin points walked
|
|
6
|
+
// along the old road, chord mids at chordStep, long legs re-densified under legGate. Runs
|
|
7
|
+
// to a FIXED POINT (a splice can create a new crossing), then pushes any surviving road
|
|
8
|
+
// vertex out of the water to the near bank.
|
|
9
|
+
//
|
|
10
|
+
// MUTATES `roads` IN PLACE — call it before anything snapshots them (chunk indices, road
|
|
11
|
+
// benches, registrations: the same import-order law both counties already live by).
|
|
12
|
+
// Returns the BRIDGE_SPOTS array: { ax, az, bx, bz, x, z, w } chord anchors + centre +
|
|
13
|
+
// local channel half-width; the games' bridge builders and deck holds read it from there.
|
|
14
|
+
//
|
|
15
|
+
// chordStep/legGate: western runs 28/30 (the travellers' ≤30 m route-leg law — see its
|
|
16
|
+
// verify-travellers note); fable runs 36/40 (its minimap's 74 m window). Inserted points
|
|
17
|
+
// are collinear on the chord, so roadDistance, the paint and the terrain are bit-identical
|
|
18
|
+
// under either spacing — only where the walkers get their waypoints changes.
|
|
19
|
+
// Headless by law: no imports at all.
|
|
20
|
+
|
|
21
|
+
function segInt(a, b, c, d) { // 2D segment intersection → {x,z,t (road param),u (river param)}
|
|
22
|
+
const rx = b.x - a.x, rz = b.z - a.z, sx = d.x - c.x, sz = d.z - c.z;
|
|
23
|
+
const denom = rx * sz - rz * sx;
|
|
24
|
+
if (Math.abs(denom) < 1e-9) return null;
|
|
25
|
+
const t = ((c.x - a.x) * sz - (c.z - a.z) * sx) / denom;
|
|
26
|
+
const u = ((c.x - a.x) * rz - (c.z - a.z) * rx) / denom;
|
|
27
|
+
if (t < 0 || t > 1 || u < 0 || u > 1) return null;
|
|
28
|
+
return { x: a.x + rx * t, z: a.z + rz * t, t, u };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function walkRoad(road, i, t, dist) { // walk `dist` m along the polyline → {x,z,seg,clamped}
|
|
32
|
+
let seg = i, tt = t, remaining = Math.abs(dist), clamped = false;
|
|
33
|
+
const dir = Math.sign(dist) || 1;
|
|
34
|
+
while (remaining > 0) {
|
|
35
|
+
const a = road[seg], b = road[seg + 1];
|
|
36
|
+
if (!a || !b) break;
|
|
37
|
+
const len = Math.hypot(b.x - a.x, b.z - a.z) || 1e-6;
|
|
38
|
+
const along = dir > 0 ? (1 - tt) * len : tt * len;
|
|
39
|
+
if (along >= remaining) { tt += dir * (remaining / len); break; }
|
|
40
|
+
remaining -= along;
|
|
41
|
+
seg += dir; tt = dir > 0 ? 0 : 1;
|
|
42
|
+
if (seg < 0 || seg >= road.length - 1) { seg = Math.max(0, Math.min(road.length - 2, seg)); tt = dir > 0 ? 1 : 0; clamped = true; break; }
|
|
43
|
+
}
|
|
44
|
+
const a = road[seg], b = road[seg + 1];
|
|
45
|
+
return { x: a.x + (b.x - a.x) * tt, z: a.z + (b.z - a.z) * tt, seg, clamped };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function spliceBridges(roads, rivers, { chordStep = 28, legGate = 30, approach = 14, margin = 5, pushClear = 6 } = {}) {
|
|
49
|
+
// metres of dry margin OUTSIDE the nearest river channel (negative = inside the water).
|
|
50
|
+
const riverClearance = (x, z) => {
|
|
51
|
+
let best = Infinity;
|
|
52
|
+
for (const R of rivers) {
|
|
53
|
+
for (let i = 0; i < R.pts.length - 1; i++) {
|
|
54
|
+
const a = R.pts[i], b = R.pts[i + 1];
|
|
55
|
+
const dx = b.x - a.x, dz = b.z - a.z;
|
|
56
|
+
const len2 = dx * dx + dz * dz || 1e-9;
|
|
57
|
+
let t = ((x - a.x) * dx + (z - a.z) * dz) / len2;
|
|
58
|
+
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
59
|
+
const d = Math.hypot(x - (a.x + dx * t), z - (a.z + dz * t));
|
|
60
|
+
const ww = R.ww[i] + (R.ww[Math.min(i + 1, R.ww.length - 1)] - R.ww[i]) * t;
|
|
61
|
+
best = Math.min(best, d - ww);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return best;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const BRIDGE_SPOTS = [];
|
|
68
|
+
const deckVerts = new Set();
|
|
69
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
70
|
+
const hits = [];
|
|
71
|
+
roads.forEach((road, ri) => {
|
|
72
|
+
for (let i = 0; i < road.length - 1; i++)
|
|
73
|
+
for (const R of rivers)
|
|
74
|
+
for (let j = 0; j < R.pts.length - 1; j++) {
|
|
75
|
+
const h = segInt(road[i], road[i + 1], R.pts[j], R.pts[j + 1]);
|
|
76
|
+
if (h) hits.push({ ...h, ri, i, w: R.ww[j] + (R.ww[Math.min(j + 1, R.ww.length - 1)] - R.ww[j]) * h.u });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
const carried = (h) => BRIDGE_SPOTS.some((s) => {
|
|
80
|
+
const dx = s.bx - s.ax, dz = s.bz - s.az, l2 = dx * dx + dz * dz || 1e-9;
|
|
81
|
+
let t = ((h.x - s.ax) * dx + (h.z - s.az) * dz) / l2; t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
82
|
+
return Math.hypot(h.x - (s.ax + dx * t), h.z - (s.az + dz * t)) < 8;
|
|
83
|
+
});
|
|
84
|
+
hits.sort((a, b) => b.w - a.w);
|
|
85
|
+
const keep = [];
|
|
86
|
+
for (const h of hits) if (!carried(h) && !keep.some((k) => Math.hypot(k.x - h.x, k.z - h.z) < Math.max(24, k.w * 1.6))) keep.push(h);
|
|
87
|
+
if (!keep.length) break; // fixed point — every crossing is bridged
|
|
88
|
+
keep.sort((a, b) => (b.ri - a.ri) || (b.i - a.i));
|
|
89
|
+
const splicedFrom = new Map();
|
|
90
|
+
for (const h of keep) {
|
|
91
|
+
const road = roads[h.ri];
|
|
92
|
+
const bound = splicedFrom.get(h.ri) ?? road.length;
|
|
93
|
+
if (h.i + 1 >= bound) continue;
|
|
94
|
+
let rv = { x: 1, z: 0 }, bd = Infinity;
|
|
95
|
+
for (const R of rivers) for (let i = 0; i < R.pts.length - 1; i++) {
|
|
96
|
+
const a = R.pts[i], b = R.pts[i + 1];
|
|
97
|
+
const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1e-9;
|
|
98
|
+
let t = ((h.x - a.x) * dx + (h.z - a.z) * dz) / len2; t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
99
|
+
const d = Math.hypot(h.x - (a.x + dx * t), h.z - (a.z + dz * t));
|
|
100
|
+
if (d < bd) { bd = d; const l = Math.hypot(dx, dz) || 1e-9; rv = { x: dx / l, z: dz / l }; }
|
|
101
|
+
}
|
|
102
|
+
let px = -rv.z, pz = rv.x;
|
|
103
|
+
const rd = { x: road[h.i + 1].x - road[h.i].x, z: road[h.i + 1].z - road[h.i].z };
|
|
104
|
+
if (px * rd.x + pz * rd.z < 0) { px = -px; pz = -pz; }
|
|
105
|
+
const clearOut = (sgn) => {
|
|
106
|
+
const cap = h.w + (h.w > 7 ? 80 : 24);
|
|
107
|
+
let d = h.w + margin;
|
|
108
|
+
while (d < cap && riverClearance(h.x + sgn * px * d, h.z + sgn * pz * d) < margin) d += 2;
|
|
109
|
+
return d < cap ? d : h.w + margin;
|
|
110
|
+
};
|
|
111
|
+
const dA = clearOut(-1), dB = clearOut(1);
|
|
112
|
+
const iA = { x: h.x - px * dA, z: h.z - pz * dA }, iB = { x: h.x + px * dB, z: h.z + pz * dB };
|
|
113
|
+
const aA = { x: iA.x - px * approach, z: iA.z - pz * approach };
|
|
114
|
+
const aB = { x: iB.x + px * approach, z: iB.z + pz * approach };
|
|
115
|
+
const rejoin = (sgn, ref) => {
|
|
116
|
+
const base = (sgn < 0 ? dA : dB) + approach + 12;
|
|
117
|
+
let P = null;
|
|
118
|
+
for (let d = base; d <= base + 60; d += 4) {
|
|
119
|
+
P = walkRoad(road, h.i, h.t, sgn * d);
|
|
120
|
+
if (P.clamped || ((P.x - ref.x) * px + (P.z - ref.z) * pz) * sgn >= 4) return P;
|
|
121
|
+
}
|
|
122
|
+
return P;
|
|
123
|
+
};
|
|
124
|
+
const A = rejoin(-1, aA), B = rejoin(1, aB);
|
|
125
|
+
if (A.clamped && B.clamped) continue;
|
|
126
|
+
let to = B.clamped ? road.length - 1 : B.seg;
|
|
127
|
+
const from = A.clamped ? 0 : A.seg + 1;
|
|
128
|
+
const joinB = to >= bound;
|
|
129
|
+
if (joinB) to = bound - 1;
|
|
130
|
+
if (to < from - 1) continue;
|
|
131
|
+
const start = A.clamped ? iA : aA, end = B.clamped ? iB : aB;
|
|
132
|
+
const ins = [];
|
|
133
|
+
if (!A.clamped && Math.hypot(A.x - start.x, A.z - start.z) > 0.01) ins.push({ x: A.x, z: A.z });
|
|
134
|
+
ins.push(start);
|
|
135
|
+
const runL = Math.hypot(end.x - start.x, end.z - start.z);
|
|
136
|
+
for (let d = chordStep; d < runL - 4; d += chordStep) {
|
|
137
|
+
const mid = { x: start.x + px * d, z: start.z + pz * d };
|
|
138
|
+
ins.push(mid); deckVerts.add(mid);
|
|
139
|
+
}
|
|
140
|
+
ins.push(end);
|
|
141
|
+
if (!B.clamped && !joinB && Math.hypot(B.x - end.x, B.z - end.z) > 0.01) ins.push({ x: B.x, z: B.z });
|
|
142
|
+
for (let k = ins.length - 1; k > 0; k--) {
|
|
143
|
+
const p = ins[k - 1], q = ins[k];
|
|
144
|
+
const L = Math.hypot(q.x - p.x, q.z - p.z);
|
|
145
|
+
if (L > legGate) {
|
|
146
|
+
const n = Math.ceil(L / chordStep), legMids = [];
|
|
147
|
+
for (let s = 1; s < n; s++) legMids.push({ x: p.x + (q.x - p.x) * s / n, z: p.z + (q.z - p.z) * s / n });
|
|
148
|
+
ins.splice(k, 0, ...legMids);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
road.splice(from, to - from + 1, ...ins);
|
|
152
|
+
splicedFrom.set(h.ri, from);
|
|
153
|
+
deckVerts.add(start); deckVerts.add(end);
|
|
154
|
+
BRIDGE_SPOTS.push({ ax: iA.x, az: iA.z, bx: iB.x, bz: iB.z, x: (iA.x + iB.x) / 2, z: (iA.z + iB.z) / 2, w: h.w });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Final pass: any road vertex still INSIDE a channel is pushed to the near bank.
|
|
158
|
+
for (const road of roads) for (const v of road) {
|
|
159
|
+
if (deckVerts.has(v)) continue;
|
|
160
|
+
let best = Infinity, nx = 0, nz = 0;
|
|
161
|
+
for (const R of rivers) for (let i = 0; i < R.pts.length - 1; i++) {
|
|
162
|
+
const a = R.pts[i], b = R.pts[i + 1];
|
|
163
|
+
const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1e-9;
|
|
164
|
+
let t = ((v.x - a.x) * dx + (v.z - a.z) * dz) / len2; t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
165
|
+
const cxp = a.x + dx * t, czp = a.z + dz * t;
|
|
166
|
+
const d = Math.hypot(v.x - cxp, v.z - czp);
|
|
167
|
+
const ww = R.ww[i] + (R.ww[Math.min(i + 1, R.ww.length - 1)] - R.ww[i]) * t;
|
|
168
|
+
if (d - ww < best) { best = d - ww; nx = cxp; nz = czp; }
|
|
169
|
+
}
|
|
170
|
+
if (best >= pushClear) continue;
|
|
171
|
+
const d = Math.hypot(v.x - nx, v.z - nz) || 1e-6;
|
|
172
|
+
const k = (pushClear - best) / d;
|
|
173
|
+
v.x += (v.x - nx) * k; v.z += (v.z - nz) * k;
|
|
174
|
+
}
|
|
175
|
+
return BRIDGE_SPOTS;
|
|
176
|
+
}
|
package/src/world/geo.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// THE GEO TOOLKIT (world splits, step 1) — the terrain-math primitives both games carried
|
|
2
|
+
// as byte-identical private copies: the fbm/ridged noise kit (fixed ImprovedNoise
|
|
3
|
+
// permutation → deterministic, one shared instance is safe), the smoothstep/terrace
|
|
4
|
+
// shapers, and the point-to-segment distance the polyline indices are built on.
|
|
5
|
+
//
|
|
6
|
+
// HEADLESS BY LAW: this module imports ONLY three/addons math — never three/webgpu —
|
|
7
|
+
// because the games' terrainData files (its consumers) run in Node for offline solvers,
|
|
8
|
+
// verify scripts and height-equivalence probes. Keep it that way.
|
|
9
|
+
//
|
|
10
|
+
// EQUIVALENCE CONTRACT: these bodies moved verbatim from western/fable terrainData.js.
|
|
11
|
+
// Any numeric change here changes both counties' ground — prove a refactor with the
|
|
12
|
+
// height-grid probe (441 samples per game) before shipping it.
|
|
13
|
+
import { ImprovedNoise } from 'three/addons/math/ImprovedNoise.js';
|
|
14
|
+
|
|
15
|
+
const noise = new ImprovedNoise();
|
|
16
|
+
|
|
17
|
+
export function fbm(x, z, octaves = 4, lacunarity = 2, gain = 0.5, scale = 0.01) {
|
|
18
|
+
let amp = 1, freq = scale, sum = 0, norm = 0;
|
|
19
|
+
for (let i = 0; i < octaves; i++) {
|
|
20
|
+
sum += amp * noise.noise(x * freq, z * freq, 7.31 + i * 13.7);
|
|
21
|
+
norm += amp; amp *= gain; freq *= lacunarity;
|
|
22
|
+
}
|
|
23
|
+
return sum / norm;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
|
27
|
+
export const lerp = (a, b, t) => a + (b - a) * t;
|
|
28
|
+
export function smoothstep(a, b, t) { const x = clamp((t - a) / (b - a), 0, 1); return x * x * (3 - 2 * x); }
|
|
29
|
+
|
|
30
|
+
// ~[0,1]: sharp crests, round valleys — mountain ranges and mesa fields
|
|
31
|
+
export function ridged(x, z, octaves, lac, gain, scale) {
|
|
32
|
+
let amp = 0.5, freq = scale, sum = 0, norm = 0, prev = 1;
|
|
33
|
+
for (let i = 0; i < octaves; i++) {
|
|
34
|
+
let n = 1 - Math.abs(noise.noise(x * freq, z * freq, 41.7 + i * 7.3));
|
|
35
|
+
n *= n; n *= prev; sum += n * amp; norm += amp; prev = n; amp *= gain; freq *= lac;
|
|
36
|
+
}
|
|
37
|
+
return sum / norm;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// quantise to benches with a sharp riser — mesa walls, cliff bands
|
|
41
|
+
export function terrace(h, step, sharp) {
|
|
42
|
+
const f = h / step, k = Math.floor(f), frac = f - k;
|
|
43
|
+
return (k + lerp(frac, smoothstep(0.5 - 0.5 * sharp, 0.5 + 0.5 * sharp, frac), sharp)) * step;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// distance from (px,pz) to segment a→b — the primitive under every polyline index
|
|
47
|
+
export function segDist(px, pz, ax, az, bx, bz) {
|
|
48
|
+
const dx = bx - ax, dz = bz - az;
|
|
49
|
+
const len2 = dx * dx + dz * dz;
|
|
50
|
+
let t = len2 ? ((px - ax) * dx + (pz - az) * dz) / len2 : 0;
|
|
51
|
+
t = clamp(t, 0, 1);
|
|
52
|
+
return Math.hypot(px - (ax + dx * t), pz - (az + dz * t));
|
|
53
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// THE RAIL FORMATION SOLVER (world splits, step 2) — the embankment IS the terrain.
|
|
2
|
+
// Moved verbatim from western's terrainData: the navvy's algorithm that turns a centreline
|
|
3
|
+
// over raw ground into a buildable formation, and the corridor index heightAt interrogates
|
|
4
|
+
// 1.74M times per cold cook. Everything here is PURE MACHINERY — the county supplies the
|
|
5
|
+
// nodes, the surveyed ground, the ruling grade and the weave (its applyRail composition
|
|
6
|
+
// stays game-side, where the river guard and road bench live).
|
|
7
|
+
//
|
|
8
|
+
// HOW A LINE IS SOLVED, at every node of the centreline (~1 m apart):
|
|
9
|
+
// 1. the game SURVEYS the ground into g[] (its own choice of surface — pads in, benches out)
|
|
10
|
+
// 2. LEVEL THE YARDS — each contiguous `flat` run pulled to its own mean
|
|
11
|
+
// 3. THE RULING GRADE — ceil/floor Lipschitz envelopes at maxGrade; their midpoint splits
|
|
12
|
+
// every obstacle evenly between a cutting and an embankment
|
|
13
|
+
// 4. VERTICAL CURVES — a diffusion pass is a curve and cannot break the ruling grade
|
|
14
|
+
// 5. optional WELD onto an already-solved line (one bed through the turnout, no seam)
|
|
15
|
+
// 6. e[] — earthworks weight: 1 = full bench, 0 = past fillMax, flying on a trestle
|
|
16
|
+
//
|
|
17
|
+
// HEADLESS BY LAW, like geo.js: no three imports at all.
|
|
18
|
+
import { clamp, lerp, smoothstep } from './geo.js';
|
|
19
|
+
|
|
20
|
+
// Nearest point on a solved line: how far, and how high the formation is there.
|
|
21
|
+
export function nearestOnLine(nodes, Y, x, z) {
|
|
22
|
+
let bd = Infinity, by = 0;
|
|
23
|
+
for (let i = 0; i < nodes.length - 1; i++) {
|
|
24
|
+
const a = nodes[i], b = nodes[i + 1];
|
|
25
|
+
const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1e-9;
|
|
26
|
+
const t = clamp(((x - a.x) * dx + (z - a.z) * dz) / len2, 0, 1);
|
|
27
|
+
const d = Math.hypot(x - (a.x + dx * t), z - (a.z + dz * t));
|
|
28
|
+
if (d < bd) { bd = d; by = lerp(Y[i], Y[i + 1], t); }
|
|
29
|
+
}
|
|
30
|
+
return { d: bd, y: by };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// nodes: [{x, z, s, w, flat}] — s = arc length, w = bed half-width, flat = yard node.
|
|
34
|
+
// g: Float64Array of surveyed ground per node (the game samples its own surface).
|
|
35
|
+
// mergeWith: { nodes, y } of an already-solved line to weld through the shared bed.
|
|
36
|
+
// Returns { y, g, e }.
|
|
37
|
+
export function solveFormation(nodes, g, { maxGrade, smooth = 90, mergeIn = 7, mergeOut = 32, fillMax, mergeWith = null }) {
|
|
38
|
+
const n = nodes.length;
|
|
39
|
+
g = Float64Array.from(g);
|
|
40
|
+
|
|
41
|
+
// ONE BED THROUGH THE TURNOUT: where this line is still in the other's bed, the ground
|
|
42
|
+
// it is surveyed on IS the other line's formation.
|
|
43
|
+
if (mergeWith) {
|
|
44
|
+
for (let i = 0; i < n; i++) {
|
|
45
|
+
const m = nearestOnLine(mergeWith.nodes, mergeWith.y, nodes[i].x, nodes[i].z);
|
|
46
|
+
g[i] = lerp(m.y, g[i], smoothstep(mergeIn, mergeOut, m.d));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// LEVEL THE YARDS — each contiguous run of `flat` nodes pulled to its own mean.
|
|
51
|
+
for (let i = 0; i < n; i++) {
|
|
52
|
+
if (!nodes[i].flat) continue;
|
|
53
|
+
let j = i; while (j < n && nodes[j].flat) j++;
|
|
54
|
+
let sum = 0; for (let k = i; k < j; k++) sum += g[k];
|
|
55
|
+
const mean = sum / (j - i);
|
|
56
|
+
for (let k = i; k < j; k++) g[k] = mean;
|
|
57
|
+
i = j;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// THE RULING GRADE — the two Lipschitz envelopes, and their midpoint.
|
|
61
|
+
const ceil = Float64Array.from(g), floor = Float64Array.from(g);
|
|
62
|
+
for (let i = 1; i < n; i++) {
|
|
63
|
+
const d = maxGrade * (nodes[i].s - nodes[i - 1].s);
|
|
64
|
+
ceil[i] = Math.max(ceil[i], ceil[i - 1] - d); // may only FALL at the ruling grade
|
|
65
|
+
floor[i] = Math.min(floor[i], floor[i - 1] + d); // may only RISE at it
|
|
66
|
+
}
|
|
67
|
+
for (let i = n - 2; i >= 0; i--) {
|
|
68
|
+
const d = maxGrade * (nodes[i + 1].s - nodes[i].s);
|
|
69
|
+
ceil[i] = Math.max(ceil[i], ceil[i + 1] - d);
|
|
70
|
+
floor[i] = Math.min(floor[i], floor[i + 1] + d);
|
|
71
|
+
}
|
|
72
|
+
const y = new Float64Array(n);
|
|
73
|
+
for (let i = 0; i < n; i++) y[i] = 0.5 * (ceil[i] + floor[i]);
|
|
74
|
+
|
|
75
|
+
// THE VERTICAL CURVES.
|
|
76
|
+
const tmp = new Float64Array(n);
|
|
77
|
+
for (let p = 0; p < smooth; p++) {
|
|
78
|
+
tmp.set(y);
|
|
79
|
+
for (let i = 1; i < n - 1; i++) y[i] = 0.25 * tmp[i - 1] + 0.5 * tmp[i] + 0.25 * tmp[i + 1];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// …and weld the result back onto the line it left — bit-identical through the shared bed.
|
|
83
|
+
if (mergeWith) {
|
|
84
|
+
for (let i = 0; i < n; i++) {
|
|
85
|
+
const m = nearestOnLine(mergeWith.nodes, mergeWith.y, nodes[i].x, nodes[i].z);
|
|
86
|
+
y[i] = lerp(m.y, y[i], smoothstep(mergeIn, mergeOut, m.d));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// HOW MUCH EARTH TO MOVE. Past fillMax the bank would be a wall — stop building and fly.
|
|
91
|
+
const e = new Float64Array(n);
|
|
92
|
+
for (let i = 0; i < n; i++) e[i] = 1 - smoothstep(fillMax, fillMax + 1.5, y[i] - g[i]);
|
|
93
|
+
return { y, g, e };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// THE CORRIDOR INDEX — chunked AABBs per line plus ONE box round the whole railway, because
|
|
97
|
+
// the county is huge, the railway is a ribbon, and the common answer must cost four compares.
|
|
98
|
+
// lines: { name: { nodes, y, e } }. Returns { railInfo, railDistance, records } — records is
|
|
99
|
+
// the fixed scratch array (one record per line, rewritten in place, zero allocation per call)
|
|
100
|
+
// the game's own corridor blend iterates.
|
|
101
|
+
export function makeRailIndex(lines, { near, chunk = 32 } = {}) {
|
|
102
|
+
const chunks = [];
|
|
103
|
+
const box = { minX: Infinity, maxX: -Infinity, minZ: Infinity, maxZ: -Infinity };
|
|
104
|
+
const rl = {};
|
|
105
|
+
for (const name of Object.keys(lines)) {
|
|
106
|
+
rl[name] = { d: Infinity, y: 0, w: 0, e: 1 };
|
|
107
|
+
const { nodes: N, y: Y, e: E } = lines[name];
|
|
108
|
+
for (let i = 0; i < N.length - 1; i += chunk) {
|
|
109
|
+
const end = Math.min(i + chunk, N.length - 1);
|
|
110
|
+
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
|
|
111
|
+
for (let j = i; j <= end; j++) {
|
|
112
|
+
const p = N[j];
|
|
113
|
+
if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
|
|
114
|
+
if (p.z < minZ) minZ = p.z; if (p.z > maxZ) maxZ = p.z;
|
|
115
|
+
}
|
|
116
|
+
chunks.push({ name, N, Y, E, i0: i, i1: end, minX: minX - near, maxX: maxX + near, minZ: minZ - near, maxZ: maxZ + near });
|
|
117
|
+
box.minX = Math.min(box.minX, minX - near); box.maxX = Math.max(box.maxX, maxX + near);
|
|
118
|
+
box.minZ = Math.min(box.minZ, minZ - near); box.maxZ = Math.max(box.maxZ, maxZ + near);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const records = Object.values(rl);
|
|
122
|
+
|
|
123
|
+
function railInfo(x, z) {
|
|
124
|
+
for (const r of records) r.d = Infinity;
|
|
125
|
+
if (x < box.minX || x > box.maxX || z < box.minZ || z > box.maxZ) return rl;
|
|
126
|
+
for (const c of chunks) {
|
|
127
|
+
if (x < c.minX || x > c.maxX || z < c.minZ || z > c.maxZ) continue;
|
|
128
|
+
const R = rl[c.name], N = c.N, Y = c.Y, E = c.E;
|
|
129
|
+
for (let i = c.i0; i < c.i1; i++) {
|
|
130
|
+
const a = N[i], b = N[i + 1];
|
|
131
|
+
const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1e-9;
|
|
132
|
+
const t = clamp(((x - a.x) * dx + (z - a.z) * dz) / len2, 0, 1);
|
|
133
|
+
const d = Math.hypot(x - (a.x + dx * t), z - (a.z + dz * t));
|
|
134
|
+
if (d < R.d) {
|
|
135
|
+
R.d = d;
|
|
136
|
+
R.y = lerp(Y[i], Y[i + 1], t);
|
|
137
|
+
R.w = lerp(a.w, b.w, t);
|
|
138
|
+
R.e = lerp(E[i], E[i + 1], t);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return rl;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function railDistance(x, z) {
|
|
146
|
+
const r = railInfo(x, z);
|
|
147
|
+
let d = Infinity;
|
|
148
|
+
for (const name in r) if (r[name].d < d) d = r[name].d;
|
|
149
|
+
return d;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { railInfo, railDistance, records };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// WHERE THE ROADS CROSS THE RAILS — found, not assumed: every road segment against every
|
|
156
|
+
// rail segment. Returns [{x, z, y, tx, tz}] with y = the formation there and (tx,tz) the
|
|
157
|
+
// rail's unit tangent (the axis the deck is measured along).
|
|
158
|
+
export function findCrossings(roads, lines, { minSpacing = 30 } = {}) {
|
|
159
|
+
const xings = [];
|
|
160
|
+
const hit = (a, b, c, d) => {
|
|
161
|
+
const rx = b.x - a.x, rz = b.z - a.z, sx = d.x - c.x, sz = d.z - c.z;
|
|
162
|
+
const den = rx * sz - rz * sx;
|
|
163
|
+
if (Math.abs(den) < 1e-9) return null;
|
|
164
|
+
const t = ((c.x - a.x) * sz - (c.z - a.z) * sx) / den;
|
|
165
|
+
const u = ((c.x - a.x) * rz - (c.z - a.z) * rx) / den;
|
|
166
|
+
if (t < 0 || t > 1 || u < 0 || u > 1) return null;
|
|
167
|
+
return { x: a.x + rx * t, z: a.z + rz * t };
|
|
168
|
+
};
|
|
169
|
+
for (const road of roads) {
|
|
170
|
+
for (let i = 0; i < road.length - 1; i++) {
|
|
171
|
+
for (const name of Object.keys(lines)) {
|
|
172
|
+
const { nodes: N, y: Y } = lines[name];
|
|
173
|
+
for (let j = 0; j < N.length - 1; j++) {
|
|
174
|
+
const p = hit(road[i], road[i + 1], N[j], N[j + 1]);
|
|
175
|
+
if (p && !xings.some((q) => Math.hypot(q.x - p.x, q.z - p.z) < minSpacing)) {
|
|
176
|
+
const tx0 = N[j + 1].x - N[j].x, tz0 = N[j + 1].z - N[j].z;
|
|
177
|
+
const tl = Math.hypot(tx0, tz0) || 1;
|
|
178
|
+
xings.push({ x: p.x, z: p.z, y: nearestOnLine(N, Y, p.x, p.z).y, tx: tx0 / tl, tz: tz0 / tl });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return xings;
|
|
185
|
+
}
|