murow 0.0.1
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 +61 -0
- package/dist/core/binary-codec/binary-codec.d.ts +159 -0
- package/dist/core/binary-codec/binary-codec.js +336 -0
- package/dist/core/binary-codec/index.d.ts +1 -0
- package/dist/core/binary-codec/index.js +1 -0
- package/dist/core/events/event-system.d.ts +71 -0
- package/dist/core/events/event-system.js +88 -0
- package/dist/core/events/index.d.ts +1 -0
- package/dist/core/events/index.js +1 -0
- package/dist/core/fixed-ticker/fixed-ticker.d.ts +105 -0
- package/dist/core/fixed-ticker/fixed-ticker.js +91 -0
- package/dist/core/fixed-ticker/index.d.ts +1 -0
- package/dist/core/fixed-ticker/index.js +1 -0
- package/dist/core/generate-id/generate-id.d.ts +21 -0
- package/dist/core/generate-id/generate-id.js +25 -0
- package/dist/core/generate-id/index.d.ts +1 -0
- package/dist/core/generate-id/index.js +1 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.js +8 -0
- package/dist/core/lerp/index.d.ts +1 -0
- package/dist/core/lerp/index.js +1 -0
- package/dist/core/lerp/lerp.d.ts +40 -0
- package/dist/core/lerp/lerp.js +42 -0
- package/dist/core/navmesh/index.d.ts +1 -0
- package/dist/core/navmesh/index.js +1 -0
- package/dist/core/navmesh/navmesh.d.ts +116 -0
- package/dist/core/navmesh/navmesh.js +666 -0
- package/dist/core/pooled-codec/index.d.ts +1 -0
- package/dist/core/pooled-codec/index.js +1 -0
- package/dist/core/pooled-codec/pooled-codec.d.ts +140 -0
- package/dist/core/pooled-codec/pooled-codec.js +213 -0
- package/dist/core/prediction/index.d.ts +1 -0
- package/dist/core/prediction/index.js +1 -0
- package/dist/core/prediction/prediction.d.ts +64 -0
- package/dist/core/prediction/prediction.js +90 -0
- package/dist/core.esm.js +1 -0
- package/dist/core.js +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +18 -0
- package/dist/protocol/index.d.ts +43 -0
- package/dist/protocol/index.js +43 -0
- package/dist/protocol/intent/index.d.ts +39 -0
- package/dist/protocol/intent/index.js +38 -0
- package/dist/protocol/intent/intent-registry.d.ts +54 -0
- package/dist/protocol/intent/intent-registry.js +73 -0
- package/dist/protocol/intent/intent.d.ts +12 -0
- package/dist/protocol/intent/intent.js +1 -0
- package/dist/protocol/snapshot/index.d.ts +44 -0
- package/dist/protocol/snapshot/index.js +43 -0
- package/dist/protocol/snapshot/snapshot-codec.d.ts +48 -0
- package/dist/protocol/snapshot/snapshot-codec.js +56 -0
- package/dist/protocol/snapshot/snapshot-registry.d.ts +100 -0
- package/dist/protocol/snapshot/snapshot-registry.js +136 -0
- package/dist/protocol/snapshot/snapshot.d.ts +19 -0
- package/dist/protocol/snapshot/snapshot.js +30 -0
- package/package.json +54 -0
- package/src/core/binary-codec/README.md +60 -0
- package/src/core/binary-codec/binary-codec.test.ts +300 -0
- package/src/core/binary-codec/binary-codec.ts +430 -0
- package/src/core/binary-codec/index.ts +1 -0
- package/src/core/events/README.md +47 -0
- package/src/core/events/event-system.test.ts +243 -0
- package/src/core/events/event-system.ts +140 -0
- package/src/core/events/index.ts +1 -0
- package/src/core/fixed-ticker/README.md +77 -0
- package/src/core/fixed-ticker/fixed-ticker.test.ts +151 -0
- package/src/core/fixed-ticker/fixed-ticker.ts +158 -0
- package/src/core/fixed-ticker/index.ts +1 -0
- package/src/core/generate-id/README.md +18 -0
- package/src/core/generate-id/generate-id.test.ts +79 -0
- package/src/core/generate-id/generate-id.ts +37 -0
- package/src/core/generate-id/index.ts +1 -0
- package/src/core/index.ts +8 -0
- package/src/core/lerp/README.md +79 -0
- package/src/core/lerp/index.ts +1 -0
- package/src/core/lerp/lerp.test.ts +90 -0
- package/src/core/lerp/lerp.ts +42 -0
- package/src/core/navmesh/README.md +124 -0
- package/src/core/navmesh/index.ts +1 -0
- package/src/core/navmesh/navmesh.test.ts +344 -0
- package/src/core/navmesh/navmesh.ts +850 -0
- package/src/core/pooled-codec/README.md +70 -0
- package/src/core/pooled-codec/index.ts +1 -0
- package/src/core/pooled-codec/pooled-codec.test.ts +349 -0
- package/src/core/pooled-codec/pooled-codec.ts +239 -0
- package/src/core/prediction/README.md +64 -0
- package/src/core/prediction/index.ts +1 -0
- package/src/core/prediction/prediction.test.ts +422 -0
- package/src/core/prediction/prediction.ts +101 -0
- package/src/index.ts +20 -0
- package/src/protocol/README.md +310 -0
- package/src/protocol/index.ts +44 -0
- package/src/protocol/intent/index.ts +40 -0
- package/src/protocol/intent/intent-registry.test.ts +237 -0
- package/src/protocol/intent/intent-registry.ts +88 -0
- package/src/protocol/intent/intent.ts +12 -0
- package/src/protocol/snapshot/index.ts +45 -0
- package/src/protocol/snapshot/snapshot-codec.test.ts +138 -0
- package/src/protocol/snapshot/snapshot-codec.ts +71 -0
- package/src/protocol/snapshot/snapshot-registry.test.ts +302 -0
- package/src/protocol/snapshot/snapshot-registry.ts +162 -0
- package/src/protocol/snapshot/snapshot.test.ts +76 -0
- package/src/protocol/snapshot/snapshot.ts +41 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/* ---------------------------------- */
|
|
2
|
+
/* Utils */
|
|
3
|
+
/* ---------------------------------- */
|
|
4
|
+
const dirs = [
|
|
5
|
+
{ x: 1, y: 0 },
|
|
6
|
+
{ x: -1, y: 0 },
|
|
7
|
+
{ x: 0, y: 1 },
|
|
8
|
+
{ x: 0, y: -1 },
|
|
9
|
+
];
|
|
10
|
+
/**
|
|
11
|
+
* Converts world coordinates to grid cell coordinates (floor).
|
|
12
|
+
*/
|
|
13
|
+
const toCell = (v) => ({
|
|
14
|
+
x: Math.floor(v.x),
|
|
15
|
+
y: Math.floor(v.y),
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* Converts grid cell coordinates to world coordinates (cell center).
|
|
19
|
+
*/
|
|
20
|
+
const fromCell = (v) => ({
|
|
21
|
+
x: v.x + 0.5,
|
|
22
|
+
y: v.y + 0.5
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* Generates unique sequential IDs for obstacles.
|
|
26
|
+
*/
|
|
27
|
+
const genId = (() => {
|
|
28
|
+
let i = 1;
|
|
29
|
+
return () => i++;
|
|
30
|
+
})();
|
|
31
|
+
/**
|
|
32
|
+
* Encodes grid coordinates as a single integer for use as Map/Set keys.
|
|
33
|
+
* Uses 32-bit safe packing: lower 16 bits = x, upper 16 bits = y.
|
|
34
|
+
* Supports coordinates in range [-32768, 32767].
|
|
35
|
+
*/
|
|
36
|
+
const encodeCell = (x, y) => (x & 0xffff) | ((y & 0xffff) << 16);
|
|
37
|
+
/**
|
|
38
|
+
* Decodes an encoded cell back to {x, y}.
|
|
39
|
+
* Properly handles sign extension for negative coordinates.
|
|
40
|
+
*/
|
|
41
|
+
const decodeCell = (n) => ({
|
|
42
|
+
x: (n << 16) >> 16,
|
|
43
|
+
y: n >> 16,
|
|
44
|
+
});
|
|
45
|
+
/* ---------------------------------- */
|
|
46
|
+
/* Binary Heap (Priority Queue) */
|
|
47
|
+
/* ---------------------------------- */
|
|
48
|
+
/**
|
|
49
|
+
* Min-heap priority queue for A*.
|
|
50
|
+
* Supports O(log n) insert and extract-min operations.
|
|
51
|
+
*/
|
|
52
|
+
class BinaryHeap {
|
|
53
|
+
constructor(scoreFn) {
|
|
54
|
+
this.scoreFn = scoreFn;
|
|
55
|
+
this.heap = [];
|
|
56
|
+
}
|
|
57
|
+
push(item) {
|
|
58
|
+
this.heap.push(item);
|
|
59
|
+
this.bubbleUp(this.heap.length - 1);
|
|
60
|
+
}
|
|
61
|
+
pop() {
|
|
62
|
+
const result = this.heap[0];
|
|
63
|
+
const end = this.heap.pop();
|
|
64
|
+
if (this.heap.length > 0 && end !== undefined) {
|
|
65
|
+
this.heap[0] = end;
|
|
66
|
+
this.sinkDown(0);
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
get size() {
|
|
71
|
+
return this.heap.length;
|
|
72
|
+
}
|
|
73
|
+
bubbleUp(n) {
|
|
74
|
+
const element = this.heap[n];
|
|
75
|
+
const score = this.scoreFn(element);
|
|
76
|
+
while (n > 0) {
|
|
77
|
+
const parentN = ((n + 1) >> 1) - 1;
|
|
78
|
+
const parent = this.heap[parentN];
|
|
79
|
+
if (score >= this.scoreFn(parent))
|
|
80
|
+
break;
|
|
81
|
+
this.heap[parentN] = element;
|
|
82
|
+
this.heap[n] = parent;
|
|
83
|
+
n = parentN;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
sinkDown(n) {
|
|
87
|
+
const length = this.heap.length;
|
|
88
|
+
const element = this.heap[n];
|
|
89
|
+
const elemScore = this.scoreFn(element);
|
|
90
|
+
while (true) {
|
|
91
|
+
const child2N = (n + 1) << 1;
|
|
92
|
+
const child1N = child2N - 1;
|
|
93
|
+
let swap = null;
|
|
94
|
+
let child1Score;
|
|
95
|
+
if (child1N < length) {
|
|
96
|
+
const child1 = this.heap[child1N];
|
|
97
|
+
child1Score = this.scoreFn(child1);
|
|
98
|
+
if (child1Score < elemScore) {
|
|
99
|
+
swap = child1N;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (child2N < length) {
|
|
103
|
+
const child2 = this.heap[child2N];
|
|
104
|
+
const child2Score = this.scoreFn(child2);
|
|
105
|
+
if (child2Score < (swap === null ? elemScore : child1Score)) {
|
|
106
|
+
swap = child2N;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (swap === null)
|
|
110
|
+
break;
|
|
111
|
+
this.heap[n] = this.heap[swap];
|
|
112
|
+
this.heap[swap] = element;
|
|
113
|
+
n = swap;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/* ---------------------------------- */
|
|
118
|
+
/* Spatial Hash */
|
|
119
|
+
/* ---------------------------------- */
|
|
120
|
+
/**
|
|
121
|
+
* Spatial hash for fast obstacle queries.
|
|
122
|
+
* Divides space into fixed-size cells and indexes obstacles by cell.
|
|
123
|
+
* Provides O(1) average case lookup instead of O(n) linear scan.
|
|
124
|
+
*
|
|
125
|
+
* Cell size = 1 matches grid pathfinding unit cells.
|
|
126
|
+
*/
|
|
127
|
+
class SpatialHash {
|
|
128
|
+
constructor(cellSize = 1) {
|
|
129
|
+
this.grid = new Map();
|
|
130
|
+
this.obstacleCells = new Map();
|
|
131
|
+
this.cellSize = cellSize;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Returns hash key for a world position.
|
|
135
|
+
*/
|
|
136
|
+
hash(x, y) {
|
|
137
|
+
const cx = Math.floor(x / this.cellSize);
|
|
138
|
+
const cy = Math.floor(y / this.cellSize);
|
|
139
|
+
return encodeCell(cx, cy);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Adds an obstacle to the spatial hash.
|
|
143
|
+
*/
|
|
144
|
+
add(id, obstacle) {
|
|
145
|
+
const cells = this.getCellsForObstacle(obstacle);
|
|
146
|
+
for (const cell of cells) {
|
|
147
|
+
if (!this.grid.has(cell)) {
|
|
148
|
+
this.grid.set(cell, new Set());
|
|
149
|
+
}
|
|
150
|
+
this.grid.get(cell).add(id);
|
|
151
|
+
}
|
|
152
|
+
this.obstacleCells.set(id, cells);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Removes an obstacle from the spatial hash.
|
|
156
|
+
*/
|
|
157
|
+
remove(id) {
|
|
158
|
+
const cells = this.obstacleCells.get(id);
|
|
159
|
+
if (!cells)
|
|
160
|
+
return;
|
|
161
|
+
for (const cell of cells) {
|
|
162
|
+
const bucket = this.grid.get(cell);
|
|
163
|
+
if (bucket) {
|
|
164
|
+
bucket.delete(id);
|
|
165
|
+
if (bucket.size === 0) {
|
|
166
|
+
this.grid.delete(cell);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
this.obstacleCells.delete(id);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Returns obstacle IDs that might contain the given point.
|
|
174
|
+
*/
|
|
175
|
+
query(pos) {
|
|
176
|
+
const cell = this.hash(pos.x, pos.y);
|
|
177
|
+
return this.grid.get(cell) || new Set();
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Clears the entire spatial hash.
|
|
181
|
+
*/
|
|
182
|
+
clear() {
|
|
183
|
+
this.grid.clear();
|
|
184
|
+
this.obstacleCells.clear();
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Determines which cells an obstacle overlaps.
|
|
188
|
+
*/
|
|
189
|
+
getCellsForObstacle(o) {
|
|
190
|
+
const cells = new Set();
|
|
191
|
+
if (o.type === 'circle') {
|
|
192
|
+
const r = o.radius;
|
|
193
|
+
const minX = Math.floor((o.pos.x - r) / this.cellSize);
|
|
194
|
+
const maxX = Math.floor((o.pos.x + r) / this.cellSize);
|
|
195
|
+
const minY = Math.floor((o.pos.y - r) / this.cellSize);
|
|
196
|
+
const maxY = Math.floor((o.pos.y + r) / this.cellSize);
|
|
197
|
+
for (let x = minX; x <= maxX; x++) {
|
|
198
|
+
for (let y = minY; y <= maxY; y++) {
|
|
199
|
+
cells.add(encodeCell(x, y));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (o.type === 'rect') {
|
|
204
|
+
const cx = o.pos.x + o.size.x / 2;
|
|
205
|
+
const cy = o.pos.y + o.size.y / 2;
|
|
206
|
+
const hw = o.size.x / 2;
|
|
207
|
+
const hh = o.size.y / 2;
|
|
208
|
+
const diagonal = Math.sqrt(hw * hw + hh * hh);
|
|
209
|
+
const minX = Math.floor((cx - diagonal) / this.cellSize);
|
|
210
|
+
const maxX = Math.floor((cx + diagonal) / this.cellSize);
|
|
211
|
+
const minY = Math.floor((cy - diagonal) / this.cellSize);
|
|
212
|
+
const maxY = Math.floor((cy + diagonal) / this.cellSize);
|
|
213
|
+
for (let x = minX; x <= maxX; x++) {
|
|
214
|
+
for (let y = minY; y <= maxY; y++) {
|
|
215
|
+
cells.add(encodeCell(x, y));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
else if (o.type === 'polygon') {
|
|
220
|
+
const bounds = getPolygonBounds(o);
|
|
221
|
+
const minX = Math.floor(bounds.minX / this.cellSize);
|
|
222
|
+
const maxX = Math.floor(bounds.maxX / this.cellSize);
|
|
223
|
+
const minY = Math.floor(bounds.minY / this.cellSize);
|
|
224
|
+
const maxY = Math.floor(bounds.maxY / this.cellSize);
|
|
225
|
+
for (let x = minX; x <= maxX; x++) {
|
|
226
|
+
for (let y = minY; y <= maxY; y++) {
|
|
227
|
+
cells.add(encodeCell(x, y));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return cells;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/* ---------------------------------- */
|
|
235
|
+
/* Geometry helpers */
|
|
236
|
+
/* ---------------------------------- */
|
|
237
|
+
/**
|
|
238
|
+
* Tests if a point is inside a circle obstacle.
|
|
239
|
+
*/
|
|
240
|
+
function pointInCircle(p, c) {
|
|
241
|
+
const dx = p.x - c.pos.x;
|
|
242
|
+
const dy = p.y - c.pos.y;
|
|
243
|
+
return dx * dx + dy * dy <= c.radius * c.radius;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Tests if a point is inside a rectangle obstacle.
|
|
247
|
+
* Handles rotation around the rectangle's center.
|
|
248
|
+
*/
|
|
249
|
+
function pointInRect(p, r) {
|
|
250
|
+
const cx = r.pos.x + r.size.x / 2;
|
|
251
|
+
const cy = r.pos.y + r.size.y / 2;
|
|
252
|
+
if (r.rotation) {
|
|
253
|
+
const cos = Math.cos(-r.rotation);
|
|
254
|
+
const sin = Math.sin(-r.rotation);
|
|
255
|
+
const dx = p.x - cx;
|
|
256
|
+
const dy = p.y - cy;
|
|
257
|
+
const localX = dx * cos - dy * sin;
|
|
258
|
+
const localY = dx * sin + dy * cos;
|
|
259
|
+
return Math.abs(localX) <= r.size.x / 2 &&
|
|
260
|
+
Math.abs(localY) <= r.size.y / 2;
|
|
261
|
+
}
|
|
262
|
+
return (p.x >= r.pos.x &&
|
|
263
|
+
p.y >= r.pos.y &&
|
|
264
|
+
p.x <= r.pos.x + r.size.x &&
|
|
265
|
+
p.y <= r.pos.y + r.size.y);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Tests if a point is inside a polygon obstacle using ray casting.
|
|
269
|
+
* REQUIRES: polygon.points are defined relative to local origin (0,0).
|
|
270
|
+
* Uses numerically stable intersection test.
|
|
271
|
+
*/
|
|
272
|
+
function pointInPolygon(p, poly) {
|
|
273
|
+
let inside = false;
|
|
274
|
+
const pts = poly.points;
|
|
275
|
+
const cos = poly.rotation ? Math.cos(poly.rotation) : 1;
|
|
276
|
+
const sin = poly.rotation ? Math.sin(poly.rotation) : 0;
|
|
277
|
+
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
|
278
|
+
let xi = pts[i].x;
|
|
279
|
+
let yi = pts[i].y;
|
|
280
|
+
let xj = pts[j].x;
|
|
281
|
+
let yj = pts[j].y;
|
|
282
|
+
if (poly.rotation) {
|
|
283
|
+
const tempXi = xi * cos - yi * sin;
|
|
284
|
+
const tempYi = xi * sin + yi * cos;
|
|
285
|
+
const tempXj = xj * cos - yj * sin;
|
|
286
|
+
const tempYj = xj * sin + yj * cos;
|
|
287
|
+
xi = tempXi;
|
|
288
|
+
yi = tempYi;
|
|
289
|
+
xj = tempXj;
|
|
290
|
+
yj = tempYj;
|
|
291
|
+
}
|
|
292
|
+
xi += poly.pos.x;
|
|
293
|
+
yi += poly.pos.y;
|
|
294
|
+
xj += poly.pos.x;
|
|
295
|
+
yj += poly.pos.y;
|
|
296
|
+
// Stable ray casting test
|
|
297
|
+
const intersect = (yi > p.y) !== (yj > p.y) &&
|
|
298
|
+
p.x < ((xj - xi) * (p.y - yi)) / (yj - yi) + xi;
|
|
299
|
+
if (intersect)
|
|
300
|
+
inside = !inside;
|
|
301
|
+
}
|
|
302
|
+
return inside;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Computes axis-aligned bounding box for a transformed polygon.
|
|
306
|
+
*/
|
|
307
|
+
function getPolygonBounds(poly) {
|
|
308
|
+
let minX = Infinity, minY = Infinity;
|
|
309
|
+
let maxX = -Infinity, maxY = -Infinity;
|
|
310
|
+
const cos = poly.rotation ? Math.cos(poly.rotation) : 1;
|
|
311
|
+
const sin = poly.rotation ? Math.sin(poly.rotation) : 0;
|
|
312
|
+
for (const p of poly.points) {
|
|
313
|
+
let x = p.x;
|
|
314
|
+
let y = p.y;
|
|
315
|
+
if (poly.rotation) {
|
|
316
|
+
const tx = x * cos - y * sin;
|
|
317
|
+
const ty = x * sin + y * cos;
|
|
318
|
+
x = tx;
|
|
319
|
+
y = ty;
|
|
320
|
+
}
|
|
321
|
+
x += poly.pos.x;
|
|
322
|
+
y += poly.pos.y;
|
|
323
|
+
minX = Math.min(minX, x);
|
|
324
|
+
minY = Math.min(minY, y);
|
|
325
|
+
maxX = Math.max(maxX, x);
|
|
326
|
+
maxY = Math.max(maxY, y);
|
|
327
|
+
}
|
|
328
|
+
return { minX, minY, maxX, maxY };
|
|
329
|
+
}
|
|
330
|
+
/* ---------------------------------- */
|
|
331
|
+
/* Obstacles */
|
|
332
|
+
/* ---------------------------------- */
|
|
333
|
+
/**
|
|
334
|
+
* Manages obstacles with spatial hashing for fast queries.
|
|
335
|
+
* Version tracking prevents unnecessary rebuilds.
|
|
336
|
+
*/
|
|
337
|
+
class Obstacles {
|
|
338
|
+
constructor() {
|
|
339
|
+
this.items = new Map();
|
|
340
|
+
this.spatial = new SpatialHash(1); // Match grid cell size
|
|
341
|
+
this._cachedItems = [];
|
|
342
|
+
this.dirty = true;
|
|
343
|
+
this.version = 0;
|
|
344
|
+
}
|
|
345
|
+
add(obstacle) {
|
|
346
|
+
const id = genId();
|
|
347
|
+
const newObstacle = { ...obstacle, id };
|
|
348
|
+
this.items.set(id, newObstacle);
|
|
349
|
+
this.spatial.add(id, newObstacle);
|
|
350
|
+
this.dirty = true;
|
|
351
|
+
this.version++;
|
|
352
|
+
return id;
|
|
353
|
+
}
|
|
354
|
+
move(id, pos) {
|
|
355
|
+
const o = this.items.get(id);
|
|
356
|
+
if (!o)
|
|
357
|
+
return;
|
|
358
|
+
// Remove from old position in spatial hash
|
|
359
|
+
this.spatial.remove(id);
|
|
360
|
+
// Create updated obstacle
|
|
361
|
+
const updated = {
|
|
362
|
+
...o,
|
|
363
|
+
pos: { ...pos },
|
|
364
|
+
};
|
|
365
|
+
this.items.set(id, updated);
|
|
366
|
+
this.spatial.add(id, updated);
|
|
367
|
+
this.dirty = true;
|
|
368
|
+
this.version++;
|
|
369
|
+
}
|
|
370
|
+
remove(id) {
|
|
371
|
+
this.spatial.remove(id);
|
|
372
|
+
this.items.delete(id);
|
|
373
|
+
this.dirty = true;
|
|
374
|
+
this.version++;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Fast spatial query using hash grid.
|
|
378
|
+
* O(1) average case instead of O(n) linear scan.
|
|
379
|
+
*/
|
|
380
|
+
at(pos) {
|
|
381
|
+
const candidates = this.spatial.query(pos);
|
|
382
|
+
for (const id of candidates) {
|
|
383
|
+
const o = this.items.get(id);
|
|
384
|
+
if (!o || o.solid === false)
|
|
385
|
+
continue;
|
|
386
|
+
if (o.type === 'circle' && pointInCircle(pos, o))
|
|
387
|
+
return o;
|
|
388
|
+
if (o.type === 'rect' && pointInRect(pos, o))
|
|
389
|
+
return o;
|
|
390
|
+
if (o.type === 'polygon' && pointInPolygon(pos, o))
|
|
391
|
+
return o;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
get values() {
|
|
395
|
+
if (!this.dirty)
|
|
396
|
+
return this._cachedItems;
|
|
397
|
+
this._cachedItems = [...this.items.values()];
|
|
398
|
+
this.dirty = false;
|
|
399
|
+
return this._cachedItems;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/* ---------------------------------- */
|
|
403
|
+
/* Grid Nav */
|
|
404
|
+
/* ---------------------------------- */
|
|
405
|
+
/**
|
|
406
|
+
* Grid navigation with simple full rebuild.
|
|
407
|
+
* Fast enough for most games (< 1000 obstacles).
|
|
408
|
+
*
|
|
409
|
+
* Performance: O(n * area) where n = obstacle count.
|
|
410
|
+
* Typical rebuild time: < 1ms for 100 obstacles on 100x100 grid.
|
|
411
|
+
*/
|
|
412
|
+
class GridNav {
|
|
413
|
+
constructor(obstacles) {
|
|
414
|
+
this.obstacles = obstacles;
|
|
415
|
+
this.blocked = new Set();
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Rebuilds the entire blocked cell set.
|
|
419
|
+
* Simple and correct - no incremental complexity.
|
|
420
|
+
*/
|
|
421
|
+
rebuild() {
|
|
422
|
+
this.blocked.clear();
|
|
423
|
+
for (const o of this.obstacles.values) {
|
|
424
|
+
if (o.solid === false)
|
|
425
|
+
continue;
|
|
426
|
+
if (o.type === 'circle') {
|
|
427
|
+
const r = Math.ceil(o.radius);
|
|
428
|
+
const cx = Math.floor(o.pos.x);
|
|
429
|
+
const cy = Math.floor(o.pos.y);
|
|
430
|
+
for (let dx = -r; dx <= r; dx++) {
|
|
431
|
+
for (let dy = -r; dy <= r; dy++) {
|
|
432
|
+
const cellX = cx + dx;
|
|
433
|
+
const cellY = cy + dy;
|
|
434
|
+
const cellCenter = { x: cellX + 0.5, y: cellY + 0.5 };
|
|
435
|
+
if (pointInCircle(cellCenter, o)) {
|
|
436
|
+
this.blocked.add(encodeCell(cellX, cellY));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
else if (o.type === 'rect') {
|
|
442
|
+
const cx = o.pos.x + o.size.x / 2;
|
|
443
|
+
const cy = o.pos.y + o.size.y / 2;
|
|
444
|
+
const hw = o.size.x / 2;
|
|
445
|
+
const hh = o.size.y / 2;
|
|
446
|
+
const diagonal = Math.sqrt(hw * hw + hh * hh);
|
|
447
|
+
const minX = Math.floor(cx - diagonal);
|
|
448
|
+
const maxX = Math.ceil(cx + diagonal);
|
|
449
|
+
const minY = Math.floor(cy - diagonal);
|
|
450
|
+
const maxY = Math.ceil(cy + diagonal);
|
|
451
|
+
for (let x = minX; x <= maxX; x++) {
|
|
452
|
+
for (let y = minY; y <= maxY; y++) {
|
|
453
|
+
const cellCenter = { x: x + 0.5, y: y + 0.5 };
|
|
454
|
+
if (pointInRect(cellCenter, o)) {
|
|
455
|
+
this.blocked.add(encodeCell(x, y));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
else if (o.type === 'polygon') {
|
|
461
|
+
const bounds = getPolygonBounds(o);
|
|
462
|
+
const minX = Math.floor(bounds.minX);
|
|
463
|
+
const maxX = Math.ceil(bounds.maxX);
|
|
464
|
+
const minY = Math.floor(bounds.minY);
|
|
465
|
+
const maxY = Math.ceil(bounds.maxY);
|
|
466
|
+
for (let x = minX; x <= maxX; x++) {
|
|
467
|
+
for (let y = minY; y <= maxY; y++) {
|
|
468
|
+
const cellCenter = { x: x + 0.5, y: y + 0.5 };
|
|
469
|
+
if (pointInPolygon(cellCenter, o)) {
|
|
470
|
+
this.blocked.add(encodeCell(x, y));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
findPath(from, to) {
|
|
478
|
+
return aStar(toCell(from), toCell(to), (x, y) => !this.blocked.has(encodeCell(x, y))).map(fromCell);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/* ---------------------------------- */
|
|
482
|
+
/* Graph Nav */
|
|
483
|
+
/* ---------------------------------- */
|
|
484
|
+
/**
|
|
485
|
+
* Line-of-sight navigation with grid fallback.
|
|
486
|
+
* Not a true navmesh - use GridNav for production.
|
|
487
|
+
*/
|
|
488
|
+
class GraphNav {
|
|
489
|
+
constructor(obstacles) {
|
|
490
|
+
this.obstacles = obstacles;
|
|
491
|
+
}
|
|
492
|
+
rebuild() { }
|
|
493
|
+
findPath(from, to) {
|
|
494
|
+
// Uniform sampling along path for LOS check
|
|
495
|
+
const steps = Math.ceil(Math.hypot(to.x - from.x, to.y - from.y) * 2);
|
|
496
|
+
let blocked = false;
|
|
497
|
+
for (let i = 1; i <= steps; i++) {
|
|
498
|
+
const t = i / steps;
|
|
499
|
+
const p = {
|
|
500
|
+
x: from.x + (to.x - from.x) * t,
|
|
501
|
+
y: from.y + (to.y - from.y) * t,
|
|
502
|
+
};
|
|
503
|
+
if (this.obstacles.at(p)) {
|
|
504
|
+
blocked = true;
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (!blocked) {
|
|
509
|
+
return [from, to];
|
|
510
|
+
}
|
|
511
|
+
// Fallback to grid A*
|
|
512
|
+
const cellPath = aStar(toCell(from), toCell(to), (x, y) => {
|
|
513
|
+
const p = { x: x + 0.5, y: y + 0.5 };
|
|
514
|
+
return !this.obstacles.at(p);
|
|
515
|
+
});
|
|
516
|
+
return cellPath.map(fromCell);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Navigation mesh with spatial hashing and smart rebuild.
|
|
521
|
+
*
|
|
522
|
+
* Features:
|
|
523
|
+
* - Spatial hash: O(1) obstacle queries
|
|
524
|
+
* - Version tracking: zero unnecessary rebuilds
|
|
525
|
+
* - Binary heap A*: handles 10k+ node searches
|
|
526
|
+
* - Simple full rebuild: correct and fast enough
|
|
527
|
+
*
|
|
528
|
+
* Performance characteristics:
|
|
529
|
+
* - Obstacle query: O(1) average via spatial hash
|
|
530
|
+
* - Grid rebuild: O(n * area), < 1ms for typical games
|
|
531
|
+
* - Pathfinding: O(b^d * log n) with binary heap
|
|
532
|
+
*
|
|
533
|
+
* Production ready for:
|
|
534
|
+
* - Grid-based games
|
|
535
|
+
* - RTS with < 1000 dynamic obstacles
|
|
536
|
+
* - Turn-based games
|
|
537
|
+
* - Moderate map sizes (< 1M cells)
|
|
538
|
+
*/
|
|
539
|
+
export class NavMesh {
|
|
540
|
+
constructor(type) {
|
|
541
|
+
this.type = type;
|
|
542
|
+
this.lastVersion = -1;
|
|
543
|
+
this.obstacles = new Obstacles();
|
|
544
|
+
if (type === 'grid')
|
|
545
|
+
this.grid = new GridNav(this.obstacles);
|
|
546
|
+
if (type === 'graph')
|
|
547
|
+
this.graph = new GraphNav(this.obstacles);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Adds an obstacle and returns its unique ID.
|
|
551
|
+
* For polygons: ensure points are defined relative to (0,0).
|
|
552
|
+
*/
|
|
553
|
+
addObstacle(obstacle) {
|
|
554
|
+
return this.obstacles.add(obstacle);
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Moves an existing obstacle to a new position.
|
|
558
|
+
*/
|
|
559
|
+
moveObstacle(id, pos) {
|
|
560
|
+
this.obstacles.move(id, pos);
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Removes an obstacle by ID.
|
|
564
|
+
*/
|
|
565
|
+
removeObstacle(id) {
|
|
566
|
+
this.obstacles.remove(id);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Returns all current obstacles.
|
|
570
|
+
*/
|
|
571
|
+
getObstacles() {
|
|
572
|
+
return this.obstacles.values;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Finds a path from start to goal.
|
|
576
|
+
* Automatically rebuilds navigation data if obstacles changed.
|
|
577
|
+
* Returns empty array if no path exists.
|
|
578
|
+
*/
|
|
579
|
+
findPath({ from, to }) {
|
|
580
|
+
this.rebuild();
|
|
581
|
+
return this.type === 'grid'
|
|
582
|
+
? this.grid.findPath(from, to)
|
|
583
|
+
: this.graph.findPath(from, to);
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Smart rebuild - only rebuilds if obstacles changed.
|
|
587
|
+
* Version checking eliminates unnecessary work.
|
|
588
|
+
*/
|
|
589
|
+
rebuild() {
|
|
590
|
+
if (this.lastVersion === this.obstacles.version)
|
|
591
|
+
return;
|
|
592
|
+
this.grid?.rebuild();
|
|
593
|
+
this.graph?.rebuild();
|
|
594
|
+
this.lastVersion = this.obstacles.version;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/* ---------------------------------- */
|
|
598
|
+
/* A* */
|
|
599
|
+
/* ---------------------------------- */
|
|
600
|
+
/**
|
|
601
|
+
* A* pathfinding with binary heap and proper open set tracking.
|
|
602
|
+
*
|
|
603
|
+
* Optimizations:
|
|
604
|
+
* - Binary heap: O(log n) operations
|
|
605
|
+
* - Open set tracking: prevents duplicate nodes
|
|
606
|
+
* - Integer cell encoding: eliminates string allocation
|
|
607
|
+
* - Closed set: avoids reprocessing
|
|
608
|
+
*
|
|
609
|
+
* Performance: Handles 10k+ node searches efficiently.
|
|
610
|
+
* Time: O(b^d * log n) where b = branching, d = depth, n = nodes.
|
|
611
|
+
*/
|
|
612
|
+
function aStar(start, goal, walkable) {
|
|
613
|
+
const cameFrom = new Map();
|
|
614
|
+
const g = new Map();
|
|
615
|
+
const closed = new Set();
|
|
616
|
+
const openSet = new Set();
|
|
617
|
+
const key = (p) => encodeCell(p.x, p.y);
|
|
618
|
+
const h = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
|
|
619
|
+
const open = new BinaryHeap((nodeKey) => {
|
|
620
|
+
const pos = decodeCell(nodeKey);
|
|
621
|
+
return g.get(nodeKey) + h(pos, goal);
|
|
622
|
+
});
|
|
623
|
+
const startKey = key(start);
|
|
624
|
+
g.set(startKey, 0);
|
|
625
|
+
open.push(startKey);
|
|
626
|
+
openSet.add(startKey);
|
|
627
|
+
while (open.size > 0) {
|
|
628
|
+
const currentKey = open.pop();
|
|
629
|
+
openSet.delete(currentKey);
|
|
630
|
+
const current = decodeCell(currentKey);
|
|
631
|
+
if (current.x === goal.x && current.y === goal.y) {
|
|
632
|
+
return reconstruct(cameFrom, current);
|
|
633
|
+
}
|
|
634
|
+
closed.add(currentKey);
|
|
635
|
+
for (const d of dirs) {
|
|
636
|
+
const n = { x: current.x + d.x, y: current.y + d.y };
|
|
637
|
+
if (!walkable(n.x, n.y))
|
|
638
|
+
continue;
|
|
639
|
+
const nk = key(n);
|
|
640
|
+
if (closed.has(nk))
|
|
641
|
+
continue;
|
|
642
|
+
const ng = g.get(currentKey) + 1;
|
|
643
|
+
if (ng < (g.get(nk) ?? Infinity)) {
|
|
644
|
+
g.set(nk, ng);
|
|
645
|
+
cameFrom.set(nk, currentKey);
|
|
646
|
+
if (!openSet.has(nk)) {
|
|
647
|
+
open.push(nk);
|
|
648
|
+
openSet.add(nk);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return [];
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Reconstructs path from A* came-from map.
|
|
657
|
+
*/
|
|
658
|
+
function reconstruct(cameFrom, current) {
|
|
659
|
+
const path = [current];
|
|
660
|
+
let k = encodeCell(current.x, current.y);
|
|
661
|
+
while (cameFrom.has(k)) {
|
|
662
|
+
k = cameFrom.get(k);
|
|
663
|
+
path.push(decodeCell(k));
|
|
664
|
+
}
|
|
665
|
+
return path.reverse();
|
|
666
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './pooled-codec';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './pooled-codec';
|