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.
- package/README.md +63 -2
- package/docs/api/README.md +28 -0
- package/docs/api/anim.md +125 -0
- package/docs/api/assets.md +208 -0
- package/docs/api/collision.md +101 -0
- package/docs/api/fbxloader.md +50 -0
- package/docs/api/grass.md +103 -0
- package/docs/api/input.md +164 -0
- package/docs/api/quality.md +159 -0
- package/docs/api/renderer.md +115 -0
- package/docs/api/retarget.md +149 -0
- package/docs/api/shatter.md +121 -0
- package/docs/api/utils-decals.md +188 -0
- package/docs/api/water.md +143 -0
- package/docs/api/wind-weather-uniforms.md +105 -0
- package/docs/contracts/render.md +32 -0
- package/docs/contracts/time-feel.md +52 -0
- package/package.json +1 -1
- package/src/core/anim.js +184 -0
- package/src/core/assets.js +596 -0
- package/src/core/engine.js +284 -0
- package/src/core/input/bindings.js +30 -0
- package/src/core/input/gamepad.js +92 -0
- package/src/core/input/index.js +62 -0
- package/src/core/input/keyboard.js +16 -0
- package/src/core/input/mouse.js +48 -0
- package/src/core/quality.js +5 -1
- package/src/core/renderer.js +104 -0
- package/src/core/retarget.js +282 -0
- package/src/index.js +19 -2
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- 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
|
+
}
|