littlejsengine 1.18.29 → 1.20.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 (51) hide show
  1. package/FAQ.md +841 -815
  2. package/README.md +211 -199
  3. package/dist/littlejs.d.ts +4383 -812
  4. package/dist/littlejs.esm.js +31848 -16680
  5. package/dist/littlejs.esm.min.js +14 -1
  6. package/dist/littlejs.js +31230 -16166
  7. package/dist/littlejs.min.js +13 -1
  8. package/dist/littlejs.release.js +27419 -15457
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +375 -0
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +740 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -330
  16. package/plugins/math3d.js +911 -0
  17. package/plugins/medalSystem.js +118 -72
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +196 -115
  21. package/plugins/postProcess.js +284 -166
  22. package/plugins/render3d.js +4072 -0
  23. package/plugins/render3dDebug.js +109 -0
  24. package/plugins/render3dExtras.js +1754 -0
  25. package/plugins/render3dVoxels.js +502 -0
  26. package/plugins/textureSheet.js +86 -48
  27. package/plugins/threejs.js +183 -158
  28. package/plugins/tweakables.js +411 -0
  29. package/plugins/tweenSystem.js +646 -526
  30. package/plugins/uiSystem.js +2024 -1687
  31. package/plugins/zzfxm.js +8 -15
  32. package/src/engine.js +345 -194
  33. package/src/engineAudio.js +1022 -777
  34. package/src/engineBuild.mjs +289 -253
  35. package/src/engineDebug.js +912 -735
  36. package/src/engineDraw.js +1774 -1510
  37. package/src/engineEditor.js +2431 -0
  38. package/src/engineExport.js +419 -396
  39. package/src/engineInput.js +1462 -1293
  40. package/src/engineLogo.js +4 -3
  41. package/src/engineMath.js +1274 -1183
  42. package/src/engineObject.js +674 -551
  43. package/src/engineParticles.js +583 -532
  44. package/src/engineRelease.js +67 -45
  45. package/src/engineSettings.js +808 -726
  46. package/src/engineTileLayer.js +995 -741
  47. package/src/engineUtilities.js +32 -13
  48. package/src/engineWebGL.js +1162 -941
  49. package/src/jsconfig.json +17 -13
  50. package/plugins/box2d.wasm.js +0 -630
  51. package/plugins/box2d.wasm.wasm +0 -0
@@ -34,7 +34,7 @@ class PathFinderNode
34
34
  this.pos = vec2(x, y);
35
35
  /** @property {Vector2} - World-space center of this tile (set by buildNodeData) */
36
36
  this.posWorld = vec2();
37
- /** @property {boolean} - True if this cell is passable (cleared each findPath call) */
37
+ /** @property {boolean} - True if this cell is passable (set by buildNodeData) */
38
38
  this.walkable = false;
39
39
  /** @property {number} - Extra cost added to A* G-score for stepping on this cell */
40
40
  this.cost = 0;
@@ -42,26 +42,30 @@ class PathFinderNode
42
42
  this.g = 0;
43
43
  /** @property {number} - A* F-score: G + heuristic */
44
44
  this.f = 0;
45
- /** @property {PathFinderNode|null} - Parent for path reconstruction */
45
+ /** @property {number} - A* heuristic: the estimated cost left to the goal, breaks ties between equal F */
46
+ this.h = 0;
47
+ /** @property {PathFinderNode|null} - Parent for path reconstruction
48
+ * @type {PathFinderNode|null} */
46
49
  this.parent = null;
47
50
  /** @property {boolean} - In the A* open list */
48
51
  this.isOpen = false;
49
52
  /** @property {boolean} - In the A* closed list */
50
53
  this.isClosed = false;
54
+ /** @property {number} - Where it is in the A* open list's heap, while open */
55
+ this.heapIndex = 0;
51
56
  }
52
57
 
53
- /** Reset per-search state (called at the start of buildNodeData). */
54
- reset()
58
+ /** Clear what a search left on this node, keeping walkable and cost */
59
+ resetSearch()
55
60
  {
56
- this.walkable = false;
57
- this.cost = 0;
58
- this.g = 0;
59
- this.f = 0;
61
+ this.g = this.f = this.h = 0;
60
62
  this.parent = null;
61
- this.isOpen = false;
62
- this.isClosed = false;
63
+ this.isOpen = this.isClosed = false;
63
64
  }
64
65
 
66
+ /** Reset per-search state and walkability (called by buildNodeData). */
67
+ reset() { this.walkable = false; this.cost = 0; this.resetSearch(); }
68
+
65
69
  /** True if walkable and not blocked by cost. */
66
70
  isClear()
67
71
  {
@@ -89,29 +93,35 @@ class PathFinder
89
93
  * overrides isWalkable). */
90
94
  constructor(source)
91
95
  {
92
- // Accept either a Vector2 size or a TileCollisionLayer (which has a .size).
93
- // We don't import TileCollisionLayer to avoid coupling; we duck-type on
94
- // .size + .getCollisionData.
96
+ // Accept a Vector2 size, or a TileCollisionLayer or any object with its size and getCollisionData
95
97
  if (isVector2(source))
96
98
  {
97
- /** @property {Vector2} - Grid dimensions in tiles */
98
- this.size = source.floor();
99
- /** @property {TileCollisionLayer|undefined} - Tile layer driving walkability, if any */
99
+ /** @property {Vector2} - Grid dimensions in tiles
100
+ * @type {Vector2} */
101
+ this.size = /** @type {Vector2} */ (source).floor();
102
+ /** @property {TileCollisionLayer|undefined} - Tile layer driving walkability, if any
103
+ * @type {TileCollisionLayer|undefined} */
100
104
  this.tileLayer = undefined;
101
105
  }
102
106
  else
103
107
  {
104
- ASSERT(source && isVector2(source.size) && typeof source.getCollisionData === 'function',
108
+ const layer = /** @type {TileCollisionLayer} */ (source);
109
+ ASSERT(layer && isVector2(layer.size) && typeof layer.getCollisionData === 'function',
105
110
  'PathFinder requires a Vector2 size or a TileCollisionLayer');
106
- this.size = source.size;
107
- this.tileLayer = source;
111
+ this.size = layer.size;
112
+ this.tileLayer = layer;
108
113
  }
109
114
 
110
115
  // Tunables (public, freely re-assignable).
111
116
  /** @property {number} - A* heuristic multiplier (1 = admissible, higher = greedier) */
112
117
  this.heuristicWeight = 1;
113
- /** @property {number} - Maximum A* expansions before giving up */
114
- this.maxLoop = 1e3;
118
+ /** @property {number|undefined} - Most A* expansions before giving up, undefined for the number of cells,
119
+ * so a search always finishes; a lower one caps the time a search takes, see searchGaveUp
120
+ * @type {number|undefined} */
121
+ this.maxLoop = undefined;
122
+ /** @property {boolean} - True when the last search stopped at maxLoop with no path, so it gave up rather
123
+ * than that there is no way through */
124
+ this.searchGaveUp = false;
115
125
  /** @property {boolean} - If true, post-process paths with two-pass smoothing */
116
126
  this.smoothPath = true;
117
127
  /** @property {boolean} - If true, draw debug visualization during findPath */
@@ -119,18 +129,31 @@ class PathFinder
119
129
  /** @property {number} - Debug primitive lifetime in seconds (0 disables drawing) */
120
130
  this.debugTime = 1;
121
131
 
122
- /** @property {Array<PathFinderNode>} - Flat row-major array of size.x*size.y nodes */
132
+ /** @property {Array<PathFinderNode>} - Flat row-major array of size.x*size.y nodes
133
+ * @type {Array<PathFinderNode>} */
123
134
  this.nodes = new Array(this.size.x * this.size.y);
124
135
  for (let y = 0; y < this.size.y; ++y)
125
136
  for (let x = 0; x < this.size.x; ++x)
126
137
  this.nodes[x + y * this.size.x] = new PathFinderNode(x, y);
127
138
 
139
+ // Whether buildNodeData has run, a search without a rebuild builds it the first time.
140
+ /** @private */
141
+ this.nodeDataBuilt = false;
142
+
128
143
  // Scratch Vector2 reused to avoid allocations in the isWalkable hot path.
144
+ /** @private */
129
145
  this.collisionScratch = vec2();
146
+
147
+ // The nodes the last search changed, reset before the next one so a
148
+ // search without a rebuild starts as fresh as one after it.
149
+ /** @type {Array<PathFinderNode>}
150
+ * @private */
151
+ this.searchNodes = [];
130
152
  }
131
153
 
132
154
  /** Default walkability: if a tile layer was provided, returns true when the
133
- * cell has no solid collision data; otherwise returns true. Override on
155
+ * cell has no solid (positive) collision data, so negative data is open
156
+ * like it is to the engine's collision; otherwise returns true. Override on
134
157
  * the instance or via a subclass.
135
158
  * @param {number} x - Tile x
136
159
  * @param {number} y - Tile y
@@ -138,7 +161,7 @@ class PathFinder
138
161
  isWalkable(x, y)
139
162
  {
140
163
  if (!this.tileLayer) return true;
141
- return !this.tileLayer.getCollisionData(this.collisionScratch.set(x, y));
164
+ return !(this.tileLayer.getCollisionData(this.collisionScratch.set(x, y)) > 0);
142
165
  }
143
166
 
144
167
  /** Default extra cost for stepping on a cell. Returns 0 (free) by default.
@@ -163,8 +186,7 @@ class PathFinder
163
186
 
164
187
  /** Convert a world-space position to integer tile coords (no clamping).
165
188
  * @param {Vector2} worldPos
166
- * @returns {Vector2}
167
- * @memberof PathFinding */
189
+ * @returns {Vector2} */
168
190
  worldToTile(worldPos)
169
191
  {
170
192
  const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
@@ -175,8 +197,7 @@ class PathFinder
175
197
  /** Convert integer tile coords to the world-space center of that tile.
176
198
  * @param {number} x
177
199
  * @param {number} y
178
- * @returns {Vector2}
179
- * @memberof PathFinding */
200
+ * @returns {Vector2} */
180
201
  tileToWorld(x, y)
181
202
  {
182
203
  const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
@@ -186,10 +207,10 @@ class PathFinder
186
207
 
187
208
  /** Reset all nodes and re-populate walkable / cost / posWorld from the
188
209
  * current isWalkable / getCost overrides. Called at the start of
189
- * findPath; exposed so tests and tooling can drive it directly.
190
- * @private */
210
+ * findPath; call it directly before searches made with rebuild=false. */
191
211
  buildNodeData()
192
212
  {
213
+ this.nodeDataBuilt = true;
193
214
  const w = this.size.x;
194
215
  const h = this.size.y;
195
216
  const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
@@ -208,16 +229,16 @@ class PathFinder
208
229
  if (this.debug && this.debugTime > 0)
209
230
  {
210
231
  if (!walkable)
211
- debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, 0.25), this.debugTime);
232
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, 0.25), this.debugTime, 0, false, false);
212
233
  else if (cost > 0)
213
- debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, min(0.2, cost * 0.05)), this.debugTime);
234
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, min(0.2, cost * 0.05)), this.debugTime, 0, false, false);
214
235
  }
215
236
  }
216
237
  }
217
238
 
218
239
  /** Core A* search loop. Expects buildNodeData() to have been called first.
219
240
  * Marks node.parent for path reconstruction. Returns true if endNode was
220
- * reached; false on disconnected goal or maxLoop exhaustion.
241
+ * reached; false on disconnected goal or maxLoop exhaustion, which sets searchGaveUp.
221
242
  * @param {PathFinderNode} startNode
222
243
  * @param {PathFinderNode} endNode
223
244
  * @returns {boolean}
@@ -228,36 +249,78 @@ class PathFinder
228
249
  ASSERT(startNode !== endNode, 'aStarSearch: start and end must differ — caller should handle trivial case');
229
250
  ASSERT(startNode.walkable && endNode.walkable, 'aStarSearch: endpoints must be walkable');
230
251
 
231
- const openList = [startNode];
232
- startNode.isOpen = true;
233
- let loopCount = 0;
234
-
235
- while (openList.length > 0)
252
+ // Undo what the last search changed, so one without a rebuild starts
253
+ // from the same state; after buildNodeData these are fresh already.
254
+ const searchNodes = this.searchNodes;
255
+ for (const n of searchNodes) n.resetSearch();
256
+ searchNodes.length = 0;
257
+ searchNodes.push(startNode);
258
+
259
+ // The open list is a binary heap with the smallest f score on top, so a big map searches quickly.
260
+ // Equal scores go to the node nearer the goal, so open ground is
261
+ // crossed nearly straight instead of widening in a band of ties;
262
+ // the path is just as short, only which of equal paths can change.
263
+ // Scores are sums of diagonals, so equal is within a hair.
264
+ const openList = [];
265
+ const isBetter = (a, b)=> a.f < b.f - 1e-9 || a.f < b.f + 1e-9 && a.h < b.h;
266
+ const siftUp = (node)=>
267
+ {
268
+ // a new node, or one whose score went down, moves up past the ones it now beats
269
+ let i = node.heapIndex;
270
+ while (i)
271
+ {
272
+ const parent = (i - 1) >> 1;
273
+ if (!isBetter(node, openList[parent])) break;
274
+ (openList[i] = openList[parent]).heapIndex = i;
275
+ i = parent;
276
+ }
277
+ (openList[i] = node).heapIndex = i;
278
+ };
279
+ const popBest = ()=>
236
280
  {
237
- // Find the open node with the smallest f score (linear scan).
238
- // Same as the C++ — fine up to a few thousand nodes.
239
- let bestIndex = 0;
240
- let bestF = openList[0].f;
241
- for (let i = 1; i < openList.length; ++i)
281
+ // take the top, then the last node sinks down from the top to where it belongs
282
+ const best = openList[0], last = openList.pop();
283
+ if (last !== best)
242
284
  {
243
- if (openList[i].f < bestF)
285
+ let i = 0;
286
+ for (;;)
244
287
  {
245
- bestF = openList[i].f;
246
- bestIndex = i;
288
+ const left = 2*i + 1, right = left + 1;
289
+ if (left >= openList.length) break;
290
+ const child = right < openList.length && isBetter(openList[right], openList[left]) ? right : left;
291
+ if (!isBetter(openList[child], last)) break;
292
+ (openList[i] = openList[child]).heapIndex = i;
293
+ i = child;
247
294
  }
295
+ (openList[i] = last).heapIndex = i;
248
296
  }
249
- const current = openList[bestIndex];
297
+ return best;
298
+ };
299
+ startNode.isOpen = true;
300
+ startNode.heapIndex = openList.length;
301
+ siftUp(startNode);
302
+ const maxLoop = this.maxLoop ?? this.size.x * this.size.y;
303
+ let loopCount = 0;
304
+ this.searchGaveUp = false;
250
305
 
306
+ while (openList.length > 0)
307
+ {
308
+ const current = openList[0];
251
309
  if (current === endNode) break;
252
- if (++loopCount > this.maxLoop) break;
310
+ if (++loopCount > maxLoop)
311
+ {
312
+ // the goal may be found already, waiting its turn, and then the path to it comes back
313
+ this.searchGaveUp = !endNode.parent;
314
+ break;
315
+ }
253
316
 
254
317
  // Move current from open to closed.
318
+ popBest();
255
319
  current.isOpen = false;
256
- openList.splice(bestIndex, 1);
257
320
  current.isClosed = true;
258
321
 
259
322
  if (this.debug && this.debugTime > 0)
260
- debugRect(current.posWorld, PATHFINDER_TILE_VEC, rgb(1, 1, 1, 0.05), this.debugTime);
323
+ debugRect(current.posWorld, PATHFINDER_TILE_VEC, rgb(1, 1, 1, 0.05), this.debugTime, 0, false, false);
261
324
 
262
325
  // Expand all 8 neighbors.
263
326
  for (let dy = -1; dy <= 1; ++dy)
@@ -282,10 +345,13 @@ class PathFinder
282
345
  }
283
346
 
284
347
  const tentativeG = current.g + stepCost + neighbor.cost;
285
- if (!neighbor.isOpen)
348
+ const wasOpen = neighbor.isOpen;
349
+ if (!wasOpen)
286
350
  {
287
351
  neighbor.isOpen = true;
352
+ neighbor.heapIndex = openList.length;
288
353
  openList.push(neighbor);
354
+ searchNodes.push(neighbor);
289
355
  }
290
356
  else if (tentativeG >= neighbor.g)
291
357
  {
@@ -300,7 +366,9 @@ class PathFinder
300
366
  const adx = abs(endNode.pos.x - neighbor.pos.x);
301
367
  const ady = abs(endNode.pos.y - neighbor.pos.y);
302
368
  const h = max(adx, ady) + (Math.SQRT2 - 1) * min(adx, ady);
369
+ neighbor.h = h;
303
370
  neighbor.f = neighbor.g + h * this.heuristicWeight;
371
+ siftUp(neighbor); // its place in the heap, new or with a lower score now
304
372
  }
305
373
  }
306
374
 
@@ -319,47 +387,12 @@ class PathFinder
319
387
  * @param {Vector2} worldPos
320
388
  * @param {number} [searchRange=10] - Max box-radius in tiles
321
389
  * @param {boolean} [rebuild=true] - Whether to call buildNodeData first
322
- * @returns {PathFinderNode|null}
323
- * @memberof PathFinding */
390
+ * @returns {PathFinderNode|null} */
324
391
  getNearestClearNode(worldPos, searchRange = 10, rebuild = true)
325
392
  {
326
393
  ASSERT(isVector2(worldPos), 'worldPos must be a Vector2');
327
- if (rebuild) this.buildNodeData();
328
-
329
- // Inline worldToTile to avoid a Vector2 allocation per call.
330
- const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
331
- const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
332
- const centerX = floor(worldPos.x - ox);
333
- const centerY = floor(worldPos.y - oy);
334
-
335
- for (let offset = 0; offset <= searchRange; ++offset)
336
- {
337
- let nearest = null;
338
- let nearestDistSq = 0;
339
-
340
- for (let dy = -offset; dy <= offset; ++dy)
341
- for (let dx = -offset; dx <= offset; ++dx)
342
- {
343
- // Only scan the perimeter of the current ring (skip the
344
- // interior we've already searched in earlier iterations).
345
- if (offset > 0 && abs(dx) !== offset && abs(dy) !== offset)
346
- continue;
347
-
348
- const node = this.getNode(centerX + dx, centerY + dy);
349
- if (!node || !node.isClear()) continue;
350
-
351
- const ddx = node.posWorld.x - worldPos.x;
352
- const ddy = node.posWorld.y - worldPos.y;
353
- const distSq = ddx * ddx + ddy * ddy;
354
- if (!nearest || distSq < nearestDistSq)
355
- {
356
- nearest = node;
357
- nearestDistSq = distSq;
358
- }
359
- }
360
- if (nearest) return nearest;
361
- }
362
- return null;
394
+ if (rebuild || !this.nodeDataBuilt) this.buildNodeData(); // a grid never built has nothing to walk yet
395
+ return pathFinderNearestNode(this, worldPos, searchRange, (node)=> node.isClear());
363
396
  }
364
397
 
365
398
  /** Smooth a node path by removing redundant turns and tightening corners
@@ -383,8 +416,9 @@ class PathFinder
383
416
  const dy = next.pos.y - prev.pos.y;
384
417
  const lenSq = dx * dx + dy * dy;
385
418
 
386
- // dx,dy is the prev-to-current step direction; needed for the
387
- // 135° "mostly vertical/horizontal" disambiguation.
419
+ // stepDx,stepDy is the prev-to-node step and stepDxNext,stepDyNext the node-to-next step; the 135°
420
+ // case uses them to tell mostly vertical from mostly horizontal, the straight case to tell a line
421
+ // from a bump
388
422
  const stepDx = node.pos.x - prev.pos.x;
389
423
  const stepDy = node.pos.y - prev.pos.y;
390
424
  const stepDxNext = next.pos.x - node.pos.x;
@@ -394,7 +428,7 @@ class PathFinder
394
428
  {
395
429
  // 45° angle — middle node is off the straight line. Drop it.
396
430
  if (this.debug && this.debugTime > 0)
397
- debugCircle(node.posWorld, 0.3, rgb(0.5, 0, 0.5, 0.5), this.debugTime);
431
+ debugCircle(node.posWorld, 0.3, rgb(0.5, 0, 0.5, 0.5), this.debugTime, false, false);
398
432
  path.splice(i, 1);
399
433
  i = max(1, i - 1);
400
434
  continue;
@@ -403,7 +437,7 @@ class PathFinder
403
437
  {
404
438
  // 90° corner. Check the alternative-diagonal cell.
405
439
  if (this.debug && this.debugTime > 0)
406
- debugCircle(node.posWorld, 0.3, rgb(1, 0, 0, 0.5), this.debugTime);
440
+ debugCircle(node.posWorld, 0.3, rgb(1, 0, 0, 0.5), this.debugTime, false, false);
407
441
 
408
442
  let sx, sy;
409
443
  if (prev.pos.y === node.pos.y && next.pos.x === node.pos.x)
@@ -425,7 +459,7 @@ class PathFinder
425
459
  // middle node to whichever of two candidate cells is closer
426
460
  // to prev-of-prev, and only if the corner cut is also clear.
427
461
  if (this.debug && this.debugTime > 0)
428
- debugCircle(node.posWorld, 0.3, rgb(1, 1, 0, 0.5), this.debugTime);
462
+ debugCircle(node.posWorld, 0.3, rgb(1, 1, 0, 0.5), this.debugTime, false, false);
429
463
 
430
464
  const prevPrev = i >= 2 ? path[i - 2] : prev;
431
465
  let s1x, s1y, s2x, s2y;
@@ -447,8 +481,8 @@ class PathFinder
447
481
  const dd2y = s2y - prevPrev.pos.y;
448
482
  const dist1Sq = dd1x * dd1x + dd1y * dd1y;
449
483
  const dist2Sq = dd2x * dd2x + dd2y * dd2y;
450
- const sx = dist1Sq < dist2Sq ? s1x : s1x === s2x && s1y === s2y ? s1x : s2x;
451
- const sy = dist1Sq < dist2Sq ? s1y : s1x === s2x && s1y === s2y ? s1y : s2y;
484
+ const useFirst = dist1Sq < dist2Sq;
485
+ const sx = useFirst ? s1x : s2x, sy = useFirst ? s1y : s2y;
452
486
 
453
487
  const shortcut = this.getNode(sx, sy);
454
488
  if (shortcut && shortcut !== node && shortcut.isClear())
@@ -469,7 +503,7 @@ class PathFinder
469
503
  {
470
504
  // Straight line or a 1-cell bump.
471
505
  if (this.debug && this.debugTime > 0)
472
- debugCircle(node.posWorld, 0.3, rgb(0, 1, 0, 0.5), this.debugTime);
506
+ debugCircle(node.posWorld, 0.3, rgb(0, 1, 0, 0.5), this.debugTime, false, false);
473
507
 
474
508
  if (stepDx === stepDxNext && stepDy === stepDyNext)
475
509
  {
@@ -504,89 +538,42 @@ class PathFinder
504
538
  * isLineClear permits, so the result can leave grid centers and cut
505
539
  * cleanly across open spaces.
506
540
  *
507
- * Bails (leaves the path unchanged) if any node has nonzero cost — a
508
- * straight geometric shortcut can't be trusted to be the lowest-cost
509
- * route when cost-weighted terrain is in play.
541
+ * A node with a cost is kept, and a shortcut only runs between clear
542
+ * nodes: isLineClear passes only through clear cells, so a straight line
543
+ * it accepts costs no more than the grid path it replaces.
510
544
  *
511
- * Port of ShortenPath2() in pathFinding.cpp.
545
+ * Replaces the port of ShortenPath2() in pathFinding.cpp, which could
546
+ * add a segment it had not checked.
512
547
  * @param {PathFinderNode[]} path
513
548
  * @private */
514
549
  smoothPathStringPull(path)
515
550
  {
516
551
  if (path.length <= 2) return;
517
- for (const n of path)
518
- {
519
- if (!n.isClear()) return;
520
- }
521
552
 
553
+ // Greedy: from each kept node, jump to the furthest node with a clear
554
+ // line to it, or else the next node. Every segment is one isLineClear
555
+ // accepted or one the path already had, so none can cross a wall.
522
556
  const original = path.slice();
523
557
  path.length = 0;
524
558
  path.push(original[0]);
525
- let searchIndex = 0;
526
-
527
- for (let i = 1; i < original.length; ++i)
559
+ for (let k = 0; k < original.length - 1;)
528
560
  {
529
- const node = original[i];
530
-
531
- // Skip if node is collinear with the search-window start and the
532
- // previous node — it adds no information. Note: a == b is the
533
- // degenerate i=1, searchIndex=0 case; skip the test then.
534
- {
535
- const a = original[searchIndex];
536
- const b = original[i - 1];
537
- if (a !== b)
538
- {
539
- const cross =
540
- (b.pos.x - a.pos.x) * (node.pos.y - a.pos.y) -
541
- (b.pos.y - a.pos.y) * (node.pos.x - a.pos.x);
542
- if (cross === 0) continue;
543
- }
544
- }
545
-
546
- if (!this.isLineClear(node.pos, path[path.length - 1].pos))
547
- {
548
- // Look ahead — if any later node has a clear shot to the
549
- // back of our new path, skip this node and try later.
550
- let foundClearAfter = false;
551
- for (let j = i + 1; j < original.length; ++j)
552
- {
553
- if (this.isLineClear(original[j].pos, path[path.length - 1].pos))
554
- {
555
- foundClearAfter = true;
556
- break;
557
- }
558
- }
559
- if (foundClearAfter)
560
- {
561
- if (this.debug && this.debugTime > 0)
562
- debugLine(node.posWorld, path[path.length - 1].posWorld, rgb(0, 0, 1, 0.3), 0.02, this.debugTime);
563
- continue;
564
- }
565
-
566
- // No clear line ahead — fall back to the last waypoint we did
567
- // have a clear line to. searchIndex tracks our scan position.
568
- for (; searchIndex < original.length; ++searchIndex)
569
- {
570
- const cand = original[searchIndex];
571
- if (this.isLineClear(node.pos, cand.pos))
572
- {
573
- path.push(cand);
574
- i = searchIndex;
575
- break;
576
- }
577
- }
578
- ASSERT(searchIndex < original.length, 'smoothPathStringPull: ran out of candidates');
579
- }
561
+ let j = original.length - 1;
562
+ if (original[k].isClear()) // isLineClear needs both ends clear
563
+ while (j > k + 1 && !(original[j].isClear() && this.isLineClear(original[k].pos, original[j].pos)))
564
+ --j;
565
+ else
566
+ j = k + 1;
567
+ path.push(original[j]);
568
+ k = j;
580
569
  }
581
-
582
- path.push(original[original.length - 1]);
583
570
  }
584
571
 
585
572
  /** Drop any middle node that lies exactly on the line through its two
586
573
  * neighbors. Backstop for the smoothing passes — the corners pass
587
574
  * intentionally keeps truly-straight runs, and the string-pulling pass
588
- * checks collinearity against the original path, not the in-progress
589
- * result, so it can leave 3+ collinear nodes in some edge cases.
575
+ * falls back to the next node where no longer line is clear, so it can
576
+ * leave 3+ collinear nodes in some edge cases.
590
577
  * @param {PathFinderNode[]} path
591
578
  * @private */
592
579
  dropCollinearNodes(path)
@@ -733,22 +720,30 @@ class PathFinder
733
720
  /** Find a path from startPos to endPos in world space. Returns an array
734
721
  * of world-space Vector2 points; empty array if no path exists.
735
722
  *
736
- * Start and end are snapped to the nearest walkable tile via
737
- * getNearestClearNode. Intermediate points are tile centers unless the
723
+ * Start and end are snapped to the nearest walkable tile (a costed one
724
+ * counts), within 10 tiles. Intermediate points are tile centers unless the
738
725
  * string-pulling smoothing pass moves them off-grid.
726
+ *
727
+ * By default, calls `buildNodeData()` first, which asks isWalkable and
728
+ * getCost about every cell. When finding many paths with unchanged
729
+ * walkability, pass `rebuild=false` and call `buildNodeData()` once
730
+ * externally; the paths found are the same.
739
731
  * @param {Vector2} startPos - World-space start
740
732
  * @param {Vector2} endPos - World-space end
741
- * @returns {Vector2[]}
742
- * @memberof PathFinding */
743
- findPath(startPos, endPos)
733
+ * @param {boolean} [rebuild] - Whether to call buildNodeData first
734
+ * @returns {Vector2[]} */
735
+ findPath(startPos, endPos, rebuild = true)
744
736
  {
745
737
  ASSERT(isVector2(startPos) && isVector2(endPos), 'findPath needs Vector2 endpoints');
746
738
 
747
- this.buildNodeData();
739
+ this.searchGaveUp = false;
740
+ if (rebuild || !this.nodeDataBuilt) this.buildNodeData(); // a grid never built has nothing to walk yet
748
741
 
749
742
  // rebuild=false because we just built — avoid redundant work per snap.
750
- const startNode = this.getNearestClearNode(startPos, 10, false);
751
- const endNode = this.getNearestClearNode(endPos, 10, false);
743
+ // the ends go to the nearest cell that can be walked, whatever it costs to cross
744
+ const walkable = (node)=> node.walkable;
745
+ const startNode = pathFinderNearestNode(this, startPos, 10, walkable);
746
+ const endNode = pathFinderNearestNode(this, endPos, 10, walkable);
752
747
  if (!startNode || !endNode) return [];
753
748
 
754
749
  // Trivial case: start and end snapped to the same tile.
@@ -777,13 +772,51 @@ class PathFinder
777
772
  if (this.debug && this.debugTime > 0 && result.length > 0)
778
773
  {
779
774
  for (let i = 1; i < result.length; ++i)
780
- debugLine(result[i - 1], result[i], RED, 0.1, this.debugTime);
775
+ debugLine(result[i - 1], result[i], RED, 0.1, this.debugTime, false);
781
776
  for (const p of result)
782
- debugCircle(p, 0.5, rgb(1, 0, 0, 0.3), this.debugTime);
783
- debugCircle(result[0], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
784
- debugCircle(result[result.length - 1], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
777
+ debugCircle(p, 0.5, rgb(1, 0, 0, 0.3), this.debugTime, false, false);
778
+ debugCircle(result[0], 0.5, rgb(0, 1, 0, 0.5), this.debugTime, false, false);
779
+ debugCircle(result[result.length - 1], 0.5, rgb(0, 1, 0, 0.5), this.debugTime, false, false);
785
780
  }
786
781
 
787
782
  return result;
788
783
  }
789
784
  }
785
+
786
+ // the node nearest a world position that passes a test, within a range of tiles, or null: the rings of cells around
787
+ // the position are searched outward until the next ring cannot hold anything nearer than the best found, since the
788
+ // best of one ring is not always the nearest, a cell one ring out can be closer
789
+ function pathFinderNearestNode(finder, worldPos, searchRange, test)
790
+ {
791
+ const center = finder.worldToTile(worldPos);
792
+ const centerX = center.x, centerY = center.y;
793
+
794
+ let nearest = null, nearestDistSq = 0;
795
+ for (let offset = 0; offset <= searchRange; ++offset)
796
+ {
797
+ // every cell of this ring is more than offset - .5 away along one axis
798
+ const bound = max(0, offset - .5);
799
+ if (nearest && bound * bound >= nearestDistSq) break;
800
+
801
+ for (let dy = -offset; dy <= offset; ++dy)
802
+ for (let dx = -offset; dx <= offset; ++dx)
803
+ {
804
+ // only the ring itself, the inside was searched already
805
+ if (offset > 0 && abs(dx) !== offset && abs(dy) !== offset)
806
+ continue;
807
+
808
+ const node = finder.getNode(centerX + dx, centerY + dy);
809
+ if (!node || !test(node)) continue;
810
+
811
+ const ddx = node.posWorld.x - worldPos.x;
812
+ const ddy = node.posWorld.y - worldPos.y;
813
+ const distSq = ddx * ddx + ddy * ddy;
814
+ if (!nearest || distSq < nearestDistSq)
815
+ {
816
+ nearest = node;
817
+ nearestDistSq = distSq;
818
+ }
819
+ }
820
+ }
821
+ return nearest;
822
+ }