sindicate 0.1.0 → 0.4.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.
Files changed (65) hide show
  1. package/README.md +63 -2
  2. package/docs/api/README.md +28 -0
  3. package/docs/api/anim.md +125 -0
  4. package/docs/api/assets.md +208 -0
  5. package/docs/api/collision.md +101 -0
  6. package/docs/api/fbxloader.md +50 -0
  7. package/docs/api/grass.md +103 -0
  8. package/docs/api/input.md +164 -0
  9. package/docs/api/quality.md +159 -0
  10. package/docs/api/renderer.md +115 -0
  11. package/docs/api/retarget.md +149 -0
  12. package/docs/api/shatter.md +121 -0
  13. package/docs/api/utils-decals.md +188 -0
  14. package/docs/api/water.md +143 -0
  15. package/docs/api/wind-weather-uniforms.md +105 -0
  16. package/docs/contracts/render.md +32 -0
  17. package/docs/contracts/time-feel.md +52 -0
  18. package/package.json +1 -1
  19. package/src/core/anim.js +184 -0
  20. package/src/core/assets.js +596 -0
  21. package/src/core/engine.js +284 -0
  22. package/src/core/input/bindings.js +30 -0
  23. package/src/core/input/gamepad.js +92 -0
  24. package/src/core/input/index.js +62 -0
  25. package/src/core/input/keyboard.js +16 -0
  26. package/src/core/input/mouse.js +48 -0
  27. package/src/core/quality.js +5 -1
  28. package/src/core/renderer.js +104 -0
  29. package/src/core/retarget.js +282 -0
  30. package/src/index.js +19 -2
  31. package/src/systems/aim.js +114 -0
  32. package/src/systems/campfire.js +150 -0
  33. package/src/systems/climbing.js +136 -0
  34. package/src/systems/deadeye.js +332 -0
  35. package/src/systems/encounters.js +123 -0
  36. package/src/systems/entity.js +524 -0
  37. package/src/systems/fistCurl.js +38 -0
  38. package/src/systems/footsteps.js +161 -0
  39. package/src/systems/glass.js +67 -0
  40. package/src/systems/herd.js +277 -0
  41. package/src/systems/horse.js +361 -0
  42. package/src/systems/mountedTraveller.js +518 -0
  43. package/src/systems/nav.js +343 -0
  44. package/src/systems/npc.js +880 -0
  45. package/src/systems/pathFollow.js +107 -0
  46. package/src/systems/platform.js +484 -0
  47. package/src/systems/riding.js +580 -0
  48. package/src/systems/seatedRider.js +396 -0
  49. package/src/systems/skybirds.js +129 -0
  50. package/src/systems/trainCamera.js +414 -0
  51. package/src/systems/traveller.js +254 -0
  52. package/src/systems/tumbleweeds.js +92 -0
  53. package/src/systems/vat.js +327 -0
  54. package/src/systems/vfx.js +472 -0
  55. package/src/world/blobShadows.js +109 -0
  56. package/src/world/clouds.js +61 -0
  57. package/src/world/flora.js +87 -0
  58. package/src/world/footprints.js +117 -0
  59. package/src/world/lampField.js +58 -0
  60. package/src/world/lampGlow.js +92 -0
  61. package/src/world/scatter.js +1077 -0
  62. package/src/world/sky.js +363 -0
  63. package/src/world/tileManager.js +197 -0
  64. package/src/world/walkHumps.js +74 -0
  65. package/src/world/weather.js +312 -0
@@ -0,0 +1,343 @@
1
+ // Grid A* pathfinding for settlement walkers — Nick's spec (A* Pathfinding Project style):
2
+ // STATIC obstacles are solved by PATHING (a baked walkability grid per settlement; routes go
3
+ // AROUND barrels/stalls/wells/walls from the start), and the whisker/side-step layer is only
4
+ // for DYNAMIC avoidance (other people, horses, the player).
5
+ //
6
+ // One NavGrid per settlement, baked LAZILY off the first path request (async, ~57k cells,
7
+ // yielded so it never blocks a frame; walkers fall back to direct-walk until it's ready).
8
+ // Cells are 0.5m over ±90m; a cell is blocked if the 2D collider set corrects a point away
9
+ // (world.collide returns the CORRECTED position — blocked means it MOVED) or the building/prop
10
+ // BVH cuts a knee-height cross or a standing post through it. Blocked cells are dilated one
11
+ // cell (~walker radius) so paths keep shoulder clearance.
12
+ import * as THREE from 'three/webgpu';
13
+
14
+ const CELL = 0.5;
15
+ const HALF = 90; // grid half-extent (m) around the settlement centre
16
+ const MAX_EXPAND = 24000; // A* node budget — bail (null) rather than stall a frame
17
+ // The WIDE grid is the horse-clearance layer: a second dilation (radius 2 ≈ 1.0 m vs the walker's
18
+ // 0.5 m) so a ~0.7 m horse doesn't route through a gap only a man fits, PLUS a water/cliff gate a man
19
+ // on foot doesn't get. WATER_BLOCK: deeper than this and a horse won't ford it. SLOPE_BLOCK: a rise
20
+ // this big over one cell (0.6 m over 0.5 m ≈ 50°) is a cliff face, not a bank. Both gate the WIDE grid
21
+ // ONLY — the proven walker grid is untouched.
22
+ const WATER_BLOCK = 0.6;
23
+ const SLOPE_BLOCK = 0.6;
24
+ let _bakeChain = Promise.resolve(); // grids bake ONE AT A TIME (see bake())
25
+
26
+ const _a = new THREE.Vector3(), _b = new THREE.Vector3();
27
+
28
+ class NavGrid {
29
+ constructor(world, cx, cz) {
30
+ this.world = world; this.cx = cx; this.cz = cz;
31
+ this.n = Math.ceil((HALF * 2) / CELL);
32
+ this.blocked = null; // Uint8Array once baked
33
+ this.ready = false; this._baking = false;
34
+ }
35
+
36
+ cell(wx, wz) {
37
+ const ix = Math.floor((wx - (this.cx - HALF)) / CELL);
38
+ const iz = Math.floor((wz - (this.cz - HALF)) / CELL);
39
+ return (ix < 0 || iz < 0 || ix >= this.n || iz >= this.n) ? -1 : iz * this.n + ix;
40
+ }
41
+ worldOf(i, out) { out.x = this.cx - HALF + ((i % this.n) + 0.5) * CELL; out.z = this.cz - HALF + (Math.floor(i / this.n) + 0.5) * CELL; return out; }
42
+
43
+ bake() {
44
+ if (this._baking || this.ready) return;
45
+ this._baking = true;
46
+ // GLOBAL bake queue: grids bake ONE AT A TIME. All the world's camps requesting grids at
47
+ // once (39 of them at boot) ran concurrent bakes over the castle load — the "dead on
48
+ // raising Kingsreach at shitty fps" hang.
49
+ _bakeChain = _bakeChain.then(() => this._bakeNow()).catch((e) => { this._baking = false; console.warn('[nav] grid bake failed — navigation stays un-ready', e?.message ?? e); });
50
+ }
51
+
52
+ async _bakeNow() {
53
+ const t0 = performance.now();
54
+ const { world, n } = this;
55
+ // PRE-BAKED NAV FILES (same scheme as the VAT bins): the grid is a pure function of the
56
+ // settlement's static geometry, so read /assets/nav/<name>.bin when it exists and skip
57
+ // the 6-8s raycast bake entirely. Bins write themselves via /__save-navbin on a miss —
58
+ // but ONLY when no deferred region is still pending inside the grid (caching a wall-less
59
+ // grid before its village streams in would freeze the mistake into a file). Nuke
60
+ // public/assets/nav/ (or bump NAV_BIN_VER) after changing any settlement layout.
61
+ // VER 2: the bin now stores the UNDILATED masks (bit0 = static, bit1 = wide-only water/cliff gate)
62
+ // and both dilations run on load — so tweaking a clearance radius needs no rebake, only a code
63
+ // change. (VER 1 stored the pre-dilated walker grid; its name differs, so old bins are simply not
64
+ // found and rebake once.)
65
+ const NAV_BIN_VER = 4; // VER 4: sample navFloorAt (interior floors walkable, furniture on them blocked) — VER 3 sampled tabletops and speckled interiors, so re-bake once
66
+ const binName = `nav${NAV_BIN_VER}_${this.cx}_${this.cz}_${n}.bin`;
67
+ try {
68
+ const r = await fetch(`/assets/nav/${binName}`);
69
+ if (r.ok) {
70
+ const buf = new Uint8Array(await r.arrayBuffer());
71
+ if (buf.length === n * n) {
72
+ const raw = new Uint8Array(n * n), wideSrc = new Uint8Array(n * n);
73
+ for (let i = 0; i < buf.length; i++) { const v = buf[i]; raw[i] = v & 1; wideSrc[i] = (v & 1) | ((v >> 1) & 1); }
74
+ this.rawMask = raw; // UNDILATED static mask — tight indoor routing (see path/walkable `tight`)
75
+ this.blocked = this._dilate(raw, 1);
76
+ this.blockedWide = this._dilate(wideSrc, 2);
77
+ this.ready = true; this._baking = false;
78
+ console.log(`[nav] grid @(${this.cx},${this.cz}) read from bin (${Math.round(performance.now() - t0)}ms)`);
79
+ return;
80
+ }
81
+ }
82
+ } catch (e) { /* no bin — bake below */ }
83
+ const raw = new Uint8Array(n * n); // static geometry (both grids)
84
+ const gate = new Uint8Array(n * n); // deep water + cliffs (WIDE grid only)
85
+ let k = 0;
86
+ let sliceT0 = performance.now();
87
+ for (let iz = 0; iz < n; iz++) {
88
+ for (let ix = 0; ix < n; ix++, k++) {
89
+ const wx = this.cx - HALF + (ix + 0.5) * CELL;
90
+ const wz = this.cz - HALF + (iz + 0.5) * CELL;
91
+ const gterrain = world.groundAt(wx, wz);
92
+ const y = world.navFloorAt(wx, wz); // the FLOOR a walker stands on — terrain outdoors, the raised floor indoors — so a building INTERIOR is navigable instead of being blocked by its own floorboards. (navFloorAt, not walkSurfaceAt: the latter returns the highest surface = a TABLETOP, which made tables read walkable and speckled the interior; the floor lets the furniture ray below catch the tabletops instead.)
93
+ // ── STATIC geometry (BOTH grids) ──────────────────────────────────────────────────────────
94
+ // 2D circles/rects (trees/fences/wells/stall posts): blocked = the point gets corrected away.
95
+ // Then BVH (buildings/props): knee-height cross + counter-height cross + a DOWNWARD post. The
96
+ // post must cast top→bottom: stall counters/tabletops face UP, and BVH raycasts cull back-faces
97
+ // — an upward ray from under the counter sailed through it (stalls read walkable, walkers routed
98
+ // straight through the stands). A `blk` flag replaces the old per-test `continue` so the gate
99
+ // below still runs on cells the static tests clear.
100
+ let blk = 0;
101
+ const cor = world.collide(wx, wz, 0.1, 0, y);
102
+ if (cor && (Math.abs(cor.x - wx) > 1e-6 || Math.abs(cor.z - wz) > 1e-6)) blk = 1;
103
+ if (!blk) { _a.set(wx - 0.3, y + 0.55, wz); _b.set(wx + 0.3, y + 0.55, wz); if (world.segmentBlocked(_a, _b)) blk = 1; }
104
+ if (!blk) { _a.set(wx, y + 0.55, wz - 0.3); _b.set(wx, y + 0.55, wz + 0.3); if (world.segmentBlocked(_a, _b)) blk = 1; }
105
+ if (!blk) { _a.set(wx - 0.3, y + 0.95, wz); _b.set(wx + 0.3, y + 0.95, wz); if (world.segmentBlocked(_a, _b)) blk = 1; }
106
+ if (!blk) { _a.set(wx, y + 1.6, wz); _b.set(wx, y + 0.15, wz); if (world.segmentBlocked(_a, _b)) blk = 1; }
107
+ raw[k] = blk;
108
+ // ── WIDE-grid-only gate (HORSES): deep water and cliff faces. Kept OUT of raw so the walker
109
+ // grid never changes; only blockedWide routes a horse around a river or a drop-off. ────────
110
+ if (!blk) {
111
+ if ((world.waterDepthAt?.(wx, wz) ?? 0) > WATER_BLOCK) gate[k] = 1;
112
+ else if (Math.abs(world.groundAt(wx + CELL, wz) - gterrain) > SLOPE_BLOCK || Math.abs(world.groundAt(wx, wz + CELL) - gterrain) > SLOPE_BLOCK) gate[k] = 1; // cliffs are a TERRAIN feature → compare terrain, not the walk surface (a raised floor is not a cliff)
113
+ }
114
+ }
115
+ // yield by TIME BUDGET, not row count: 4 rows = ~1,440 cells × several BVH raycasts
116
+ // ≈ 15-25ms per slice — walkers requesting paths right after the reveal held the whole
117
+ // game at ~40fps for the 6-8s each grid took ("fps jumps from 40 to 75+ after 20s").
118
+ // 4ms/slice keeps frames whole; the bake just takes longer wall-clock, which is free —
119
+ // walkers direct-walk until ready.
120
+ if (performance.now() - sliceT0 > 4) {
121
+ await new Promise((r) => setTimeout(r, 0));
122
+ sliceT0 = performance.now();
123
+ }
124
+ }
125
+ // Two dilations from the same raw: the WALKER grid (radius 1 ≈ 0.5 m) and the HORSE-clearance grid
126
+ // (radius 2 ≈ 1.0 m, plus the water/cliff gate OR-ed in). Both are cheap array passes.
127
+ const wideSrc = new Uint8Array(n * n);
128
+ for (let i = 0; i < n * n; i++) wideSrc[i] = raw[i] | gate[i];
129
+ this.rawMask = raw; // UNDILATED static mask — tight indoor routing (see path/walkable `tight`)
130
+ this.blocked = this._dilate(raw, 1);
131
+ this.blockedWide = this._dilate(wideSrc, 2);
132
+ this.ready = true; this._baking = false;
133
+ let nb = 0, nw = 0; for (let i = 0; i < n * n; i++) { nb += this.blocked[i]; nw += this.blockedWide[i]; }
134
+ console.log(`[nav] grid @(${this.cx},${this.cz}) ${n}x${n} baked in ${Math.round(performance.now() - t0)}ms — walker ${(100 * nb / (n * n)).toFixed(1)}% · horse ${(100 * nw / (n * n)).toFixed(1)}% blocked`);
135
+ // self-write the bin (UNDILATED masks: bit0 static + bit1 gate) — unless a deferred region still
136
+ // pends inside this grid (its walls aren't in the world yet; caching now bakes their absence in)
137
+ if (import.meta.env?.DEV) {
138
+ const pending = (world.deferredRegions ?? []).some((rg) => Math.hypot(rg.x - this.cx, rg.z - this.cz) < HALF + 60);
139
+ if (!pending) {
140
+ const packed = new Uint8Array(n * n);
141
+ for (let i = 0; i < n * n; i++) packed[i] = raw[i] | (gate[i] << 1);
142
+ fetch(`/__save-navbin?name=${binName}`, { method: 'POST', body: packed }).catch(() => { /* sink offline */ });
143
+ } else console.log(`[nav] grid @(${this.cx},${this.cz}) NOT cached — a deferred region is still pending inside it`);
144
+ }
145
+ }
146
+
147
+ // Chebyshev dilation: mark any free cell within `radius` cells of a blocked one, for clearance so
148
+ // paths keep their shoulders (and a horse its wider body) off the walls.
149
+ _dilate(src, radius) {
150
+ const n = this.n, out = new Uint8Array(n * n);
151
+ for (let iz = 0; iz < n; iz++) for (let ix = 0; ix < n; ix++) {
152
+ const i = iz * n + ix;
153
+ if (src[i]) { out[i] = 1; continue; }
154
+ let any = 0;
155
+ for (let dz = -radius; dz <= radius && !any; dz++) for (let dx = -radius; dx <= radius; dx++) {
156
+ const jx = ix + dx, jz = iz + dz;
157
+ if (jx >= 0 && jz >= 0 && jx < n && jz < n && src[jz * n + jx]) { any = 1; break; }
158
+ }
159
+ out[i] = any;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ // open-neighbour count — used to reject 1-cell slots enclosed inside a blocked mass
165
+ openness(j, B = this.blocked) {
166
+ const n = this.n, ix = j % n, iz = Math.floor(j / n);
167
+ let open = 0;
168
+ for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
169
+ if (!dx && !dz) continue;
170
+ const jx = ix + dx, jz = iz + dz;
171
+ if (jx >= 0 && jz >= 0 && jx < n && jz < n && !B[jz * n + jx]) open++;
172
+ }
173
+ return open;
174
+ }
175
+
176
+ // nearest open cell to i, spiralling out (a goal beside a stall lands in dilation).
177
+ // PREFERS well-connected cells: a crate warehouse leaves sealed 1-cell pockets inside its
178
+ // blocked mass, and snapping a goal into one made A* (correctly) fail → walkers fell back
179
+ // to walking blind INTO the crates. Poorly-connected candidates are only a last resort.
180
+ nearestOpen(i, maxR = 10, B = this.blocked) {
181
+ if (i < 0) return -1;
182
+ if (!B[i] && this.openness(i, B) >= 5) return i;
183
+ const n = this.n, ix0 = i % n, iz0 = Math.floor(i / n);
184
+ let fallback = !B[i] ? i : -1;
185
+ for (let r = 1; r <= maxR; r++) {
186
+ for (let dz = -r; dz <= r; dz++) for (let dx = -r; dx <= r; dx++) {
187
+ if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue; // ring only
188
+ const ix = ix0 + dx, iz = iz0 + dz;
189
+ if (ix < 0 || iz < 0 || ix >= n || iz >= n) continue;
190
+ const j = iz * n + ix;
191
+ if (B[j]) continue;
192
+ if (this.openness(j, B) >= 5) return j;
193
+ if (fallback < 0) fallback = j;
194
+ }
195
+ }
196
+ return fallback;
197
+ }
198
+
199
+ // is this world position walkable? (goal validation — covers BVH props the circle test misses).
200
+ // wide = the horse-clearance grid (fatter body + water/cliff), else the walker grid.
201
+ walkable(wx, wz, wide = false, tight = false) {
202
+ if (!this.ready) return null; // unknown yet
203
+ const i = this.cell(wx, wz);
204
+ const B = tight ? this.rawMask : (wide ? this.blockedWide : this.blocked);
205
+ return i >= 0 ? !B[i] : false;
206
+ }
207
+
208
+ // Straight line-of-sight between two WORLD points over the blocked grid. true = the line is clear of
209
+ // static geometry (aim straight there), false = something is in the way (an A* detour is needed),
210
+ // null = an endpoint falls outside this grid (unknown). The cheap gate the road followers ask every
211
+ // frame so they only pay for A* when a fence/wall actually blocks the road ahead.
212
+ losWorld(ax, az, bx, bz, wide = false) {
213
+ if (!this.ready) return null;
214
+ const i0 = this.cell(ax, az), i1 = this.cell(bx, bz);
215
+ if (i0 < 0 || i1 < 0) return null;
216
+ return this.los(i0, i1, wide ? this.blockedWide : this.blocked);
217
+ }
218
+
219
+ // grid line-of-sight (supercover walk) — used to smooth A* output to few waypoints
220
+ los(i0, i1, B = this.blocked) {
221
+ const n = this.n;
222
+ let x0 = i0 % n, z0 = Math.floor(i0 / n);
223
+ const x1 = i1 % n, z1 = Math.floor(i1 / n);
224
+ const dx = Math.abs(x1 - x0), dz = Math.abs(z1 - z0);
225
+ const sx = x0 < x1 ? 1 : -1, sz = z0 < z1 ? 1 : -1;
226
+ let err = dx - dz;
227
+ for (;;) {
228
+ if (B[z0 * n + x0]) return false;
229
+ if (x0 === x1 && z0 === z1) return true;
230
+ const e2 = 2 * err;
231
+ if (e2 > -dz) { err -= dz; x0 += sx; }
232
+ if (e2 < dx) { err += dx; z0 += sz; }
233
+ }
234
+ }
235
+
236
+ // A* (8-connected, octile heuristic) → smoothed world waypoints [{x,z}...] or null.
237
+ // wide = route on the horse-clearance grid; maxExpand caps the node budget (lower for on-demand
238
+ // road/chase calls so a bad request can't stall a frame).
239
+ path(x0, z0, x1, z1, wide = false, maxExpand = MAX_EXPAND, tight = false) {
240
+ if (!this.ready) { this.bake(); return null; }
241
+ const B = tight ? this.rawMask : (wide ? this.blockedWide : this.blocked); // tight = route on the UNDILATED mask (a thin person threading a cramped interior the 0.5 m clearance grid would seal shut)
242
+ let s = this.nearestOpen(this.cell(x0, z0), 6, B);
243
+ let t = this.nearestOpen(this.cell(x1, z1), 10, B);
244
+ if (s < 0 || t < 0) return null;
245
+ if (s === t) return [{ x: x1, z: z1 }];
246
+ const n = this.n, N = n * n;
247
+ const g = (this._g ??= new Float32Array(N)); g.fill(Infinity);
248
+ const came = (this._came ??= new Int32Array(N));
249
+ const inClosed = (this._closed ??= new Uint8Array(N)); inClosed.fill(0);
250
+ // small binary heap over [f, idx] pairs
251
+ const heap = []; const push = (f, i) => { heap.push([f, i]); let c = heap.length - 1; while (c > 0) { const p = (c - 1) >> 1; if (heap[p][0] <= heap[c][0]) break; [heap[p], heap[c]] = [heap[c], heap[p]]; c = p; } };
252
+ const pop = () => { const top = heap[0], last = heap.pop(); if (heap.length) { heap[0] = last; let c = 0; for (;;) { const l = c * 2 + 1, r = l + 1; let m = c; if (l < heap.length && heap[l][0] < heap[m][0]) m = l; if (r < heap.length && heap[r][0] < heap[m][0]) m = r; if (m === c) break; [heap[m], heap[c]] = [heap[c], heap[m]]; c = m; } } return top; };
253
+ const tx = t % n, tz = Math.floor(t / n);
254
+ const H = (i) => { const dx = Math.abs((i % n) - tx), dz = Math.abs(Math.floor(i / n) - tz); return (dx + dz) + (Math.SQRT2 - 2) * Math.min(dx, dz); };
255
+ g[s] = 0; came[s] = -1; push(H(s), s);
256
+ let expanded = 0, found = false;
257
+ while (heap.length) {
258
+ const [, cur] = pop();
259
+ if (inClosed[cur]) continue;
260
+ inClosed[cur] = 1;
261
+ if (cur === t) { found = true; break; }
262
+ if (++expanded > maxExpand) break;
263
+ const cx = cur % n, cz = Math.floor(cur / n);
264
+ for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
265
+ if (!dx && !dz) continue;
266
+ const jx = cx + dx, jz = cz + dz;
267
+ if (jx < 0 || jz < 0 || jx >= n || jz >= n) continue;
268
+ const j = jz * n + jx;
269
+ if (B[j] || inClosed[j]) continue;
270
+ if (dx && dz && (B[cz * n + jx] || B[jz * n + cx])) continue; // no corner-cutting
271
+ const cost = g[cur] + (dx && dz ? Math.SQRT2 : 1);
272
+ if (cost < g[j]) { g[j] = cost; came[j] = cur; push(cost + H(j), j); }
273
+ }
274
+ }
275
+ if (!found) return null;
276
+ // reconstruct then greedy line-of-sight smoothing → few waypoints
277
+ const cells = []; for (let i = t; i !== -1; i = came[i]) cells.push(i); cells.reverse();
278
+ const out = []; let anchor = 0;
279
+ for (let i = 2; i < cells.length; i++) {
280
+ if (!this.los(cells[anchor], cells[i], B)) { anchor = i - 1; out.push(cells[anchor]); }
281
+ }
282
+ const P = out.map((i) => this.worldOf(i, { x: 0, z: 0 }));
283
+ P.push({ x: x1, z: z1 }); // exact final goal
284
+ return P;
285
+ }
286
+ }
287
+
288
+ // Region manager: one grid per settlement, keyed by region name, centred on the walkers' own
289
+ // wander points. Grids bake lazily off the first request; path() returns null until ready
290
+ // (callers fall back to direct walking, so nothing pops or stalls while a grid bakes).
291
+ export class Nav {
292
+ constructor(world) { this.world = world; this.grids = new Map(); }
293
+
294
+ gridFor(key, cx, cz) {
295
+ let grd = this.grids.get(key);
296
+ if (!grd) { grd = new NavGrid(this.world, Math.round(cx), Math.round(cz)); this.grids.set(key, grd); grd.bake(); }
297
+ return grd;
298
+ }
299
+
300
+ // path within a named region (wanderers) — null until the grid is baked / if unreachable
301
+ path(key, cx, cz, from, to) {
302
+ const grd = this.gridFor(key, cx, cz);
303
+ if (Math.abs(from.x - grd.cx) > HALF - 2 || Math.abs(from.z - grd.cz) > HALF - 2) return null; // outside this grid
304
+ if (Math.abs(to.x - grd.cx) > HALF - 2 || Math.abs(to.z - grd.cz) > HALF - 2) return null;
305
+ return grd.path(from.x, from.z, to.x, to.z);
306
+ }
307
+
308
+ // ── road-follower access: REUSE a settlement's grid instead of owning a region ─────────────────
309
+ // The wanderers key a grid to their route centroid; a passing traveller doesn't know that centroid,
310
+ // so these let him reuse whatever grid already COVERS where he stands (the town's own, ready or still
311
+ // baking) and only bake a fresh one — centred on his nearest route end — where none does (a lonely
312
+ // ranch with no wanderers). No duplicate bake at a town that already has a grid.
313
+ _covers(grd, p) { return Math.abs(p.x - grd.cx) < HALF - 2 && Math.abs(p.z - grd.cz) < HALF - 2; }
314
+
315
+ coveringGrid(x, z) { // any grid whose ±90 m window covers the point (ready OR baking)
316
+ for (const grd of this.grids.values()) if (Math.abs(x - grd.cx) < HALF - 2 && Math.abs(z - grd.cz) < HALF - 2) return grd;
317
+ return null;
318
+ }
319
+
320
+ _gridHere(from, centre) {
321
+ let grd = this.coveringGrid(from.x, from.z); // reuse a covering grid if one exists…
322
+ if (!grd && centre) grd = this.gridFor(`r${Math.round(centre.x / 80)}_${Math.round(centre.z / 80)}`, centre.x, centre.z); // …else bake this end's
323
+ return grd;
324
+ }
325
+
326
+ // Is the straight line from→to clear of static geometry near a settlement? true/false, or null when
327
+ // no baked grid covers the span (open ground — assume clear, there is nothing out there to route round).
328
+ // wide = test the horse-clearance grid (fatter body + water/cliff), else the walker grid.
329
+ losHere(from, to, centre, wide = false) {
330
+ const grd = this._gridHere(from, centre);
331
+ if (!grd || !grd.ready || !this._covers(grd, from) || !this._covers(grd, to)) return null;
332
+ return grd.losWorld(from.x, from.z, to.x, to.z, wide);
333
+ }
334
+
335
+ // A* around the statics between from→to using a covering (or this-end's) grid — smoothed waypoints,
336
+ // or null until a grid is baked / if unreachable (caller direct-walks + its own stuck backstop). The
337
+ // node budget is capped low (8k) since these fire on demand from the movement loop.
338
+ pathHere(from, to, centre, wide = false, tight = false) {
339
+ const grd = this._gridHere(from, centre);
340
+ if (!grd || !grd.ready || !this._covers(grd, from) || !this._covers(grd, to)) return null;
341
+ return grd.path(from.x, from.z, to.x, to.z, wide, 8000, tight);
342
+ }
343
+ }