littlejsengine 1.18.2 → 1.18.7

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 (172) hide show
  1. package/COPYRIGHT.txt +38 -0
  2. package/FAQ.md +633 -0
  3. package/LICENSE +5 -27
  4. package/README.md +19 -1
  5. package/dist/littlejs.d.ts +550 -57
  6. package/dist/littlejs.esm.js +2208 -492
  7. package/dist/littlejs.esm.min.js +1 -1
  8. package/dist/littlejs.js +2165 -480
  9. package/dist/littlejs.min.js +1 -1
  10. package/dist/littlejs.release.js +2166 -481
  11. package/package.json +8 -1
  12. package/plugins/box2d.js +36 -9
  13. package/{src/engineMedals.js → plugins/medalSystem.js} +248 -196
  14. package/plugins/pathFinder.js +758 -0
  15. package/plugins/pluginExport.js +26 -0
  16. package/plugins/tween.js +509 -0
  17. package/plugins/tweenSystem.js +509 -0
  18. package/plugins/uiSystem.js +151 -12
  19. package/src/engine.js +9 -145
  20. package/src/engineAudio.js +8 -5
  21. package/src/engineBuild.mjs +4 -1
  22. package/src/engineDebug.js +1 -1
  23. package/src/engineDraw.js +154 -5
  24. package/src/engineExport.js +17 -12
  25. package/src/engineInput.js +27 -1
  26. package/src/engineLogo.js +146 -0
  27. package/src/engineMath.js +10 -3
  28. package/src/engineObject.js +11 -5
  29. package/src/engineParticles.js +6 -3
  30. package/src/engineRelease.js +1 -1
  31. package/src/engineSettings.js +17 -47
  32. package/src/engineUtilities.js +7 -2
  33. package/src/engineWebGL.js +31 -8
  34. package/src/jsconfig.json +3 -1
  35. package/.claude/settings.local.json +0 -7
  36. package/.github/workflows/test.yml +0 -17
  37. package/AI.md +0 -172
  38. package/CLAUDE.md +0 -1
  39. package/examples/box2d/game.js +0 -192
  40. package/examples/box2d/gameObjects.js +0 -564
  41. package/examples/box2d/index.html +0 -9
  42. package/examples/box2d/scenes.js +0 -194
  43. package/examples/box2d/tiles.png +0 -0
  44. package/examples/breakout/game.js +0 -175
  45. package/examples/breakout/gameObjects.js +0 -147
  46. package/examples/breakout/index.html +0 -8
  47. package/examples/breakout/tiles.png +0 -0
  48. package/examples/breakoutTutorial/README.md +0 -521
  49. package/examples/breakoutTutorial/game.js +0 -191
  50. package/examples/breakoutTutorial/images/1.png +0 -0
  51. package/examples/breakoutTutorial/images/10.png +0 -0
  52. package/examples/breakoutTutorial/images/11.png +0 -0
  53. package/examples/breakoutTutorial/images/2.png +0 -0
  54. package/examples/breakoutTutorial/images/3.png +0 -0
  55. package/examples/breakoutTutorial/images/4.png +0 -0
  56. package/examples/breakoutTutorial/images/5.png +0 -0
  57. package/examples/breakoutTutorial/images/6.png +0 -0
  58. package/examples/breakoutTutorial/images/7.png +0 -0
  59. package/examples/breakoutTutorial/images/8.png +0 -0
  60. package/examples/breakoutTutorial/images/9.png +0 -0
  61. package/examples/breakoutTutorial/index.html +0 -8
  62. package/examples/electron/build.mjs +0 -127
  63. package/examples/electron/electron.js +0 -35
  64. package/examples/electron/game.js +0 -54
  65. package/examples/electron/index.html +0 -13
  66. package/examples/electron/package.json +0 -5
  67. package/examples/electron/tiles.png +0 -0
  68. package/examples/empty/game.js +0 -51
  69. package/examples/empty/index.html +0 -5
  70. package/examples/empty/tiles.png +0 -0
  71. package/examples/favicon.png +0 -0
  72. package/examples/games.jpg +0 -0
  73. package/examples/htmlMenu/game.js +0 -82
  74. package/examples/htmlMenu/index.html +0 -45
  75. package/examples/htmlMenu/tiles.png +0 -0
  76. package/examples/index.html +0 -800
  77. package/examples/logo.png +0 -0
  78. package/examples/logo2.png +0 -0
  79. package/examples/module/build.mjs +0 -124
  80. package/examples/module/game.js +0 -132
  81. package/examples/module/index.html +0 -10
  82. package/examples/module/tiles.png +0 -0
  83. package/examples/particles/index.html +0 -426
  84. package/examples/particles/tiles.png +0 -0
  85. package/examples/platformer/data/gameLevelData.tmx +0 -143
  86. package/examples/platformer/data/gameLevelData.tsx +0 -4
  87. package/examples/platformer/game.js +0 -147
  88. package/examples/platformer/gameCharacter.js +0 -309
  89. package/examples/platformer/gameEffects.js +0 -278
  90. package/examples/platformer/gameLevel.js +0 -205
  91. package/examples/platformer/gameLevelData.json +0 -171
  92. package/examples/platformer/gameObjects.js +0 -378
  93. package/examples/platformer/gamePlayer.js +0 -35
  94. package/examples/platformer/index.html +0 -9
  95. package/examples/platformer/tiles.png +0 -0
  96. package/examples/platformer/tilesLevel.png +0 -0
  97. package/examples/puzzle/game.js +0 -331
  98. package/examples/puzzle/index.html +0 -8
  99. package/examples/puzzle/tiles.png +0 -0
  100. package/examples/screenshot.jpg +0 -0
  101. package/examples/shorts/animation.js +0 -18
  102. package/examples/shorts/base.html +0 -54
  103. package/examples/shorts/blending.js +0 -14
  104. package/examples/shorts/box2d.js +0 -51
  105. package/examples/shorts/box2dCar.js +0 -59
  106. package/examples/shorts/box2dPool.js +0 -107
  107. package/examples/shorts/box2dTileLayer.js +0 -48
  108. package/examples/shorts/cameraDrag.js +0 -22
  109. package/examples/shorts/clock.js +0 -21
  110. package/examples/shorts/colors.js +0 -25
  111. package/examples/shorts/debugDraw.js +0 -37
  112. package/examples/shorts/empty.js +0 -30
  113. package/examples/shorts/flappyGame.js +0 -55
  114. package/examples/shorts/fontImage.js +0 -17
  115. package/examples/shorts/fps.js +0 -90
  116. package/examples/shorts/helloWorld.js +0 -11
  117. package/examples/shorts/hillGlideGame.js +0 -63
  118. package/examples/shorts/input.js +0 -64
  119. package/examples/shorts/landerGame.js +0 -57
  120. package/examples/shorts/maze.js +0 -48
  121. package/examples/shorts/medals.js +0 -51
  122. package/examples/shorts/music.js +0 -78
  123. package/examples/shorts/musicPlayer.js +0 -137
  124. package/examples/shorts/nineSlice.js +0 -40
  125. package/examples/shorts/parallax.js +0 -72
  126. package/examples/shorts/particles.js +0 -28
  127. package/examples/shorts/piano.js +0 -44
  128. package/examples/shorts/platformer.js +0 -45
  129. package/examples/shorts/pongGame.js +0 -41
  130. package/examples/shorts/postProcess.js +0 -62
  131. package/examples/shorts/sequencer.js +0 -124
  132. package/examples/shorts/shader.js +0 -29
  133. package/examples/shorts/shapes.js +0 -21
  134. package/examples/shorts/slidingPuzzle.js +0 -52
  135. package/examples/shorts/song.mp3 +0 -0
  136. package/examples/shorts/sound.js +0 -36
  137. package/examples/shorts/spaceGame.js +0 -58
  138. package/examples/shorts/spriteAtlas.js +0 -31
  139. package/examples/shorts/starfield.js +0 -15
  140. package/examples/shorts/texture.js +0 -16
  141. package/examples/shorts/tileLayer.js +0 -48
  142. package/examples/shorts/tileRaycast.js +0 -39
  143. package/examples/shorts/tiles.png +0 -0
  144. package/examples/shorts/tiltedView.js +0 -63
  145. package/examples/shorts/timers.js +0 -53
  146. package/examples/shorts/topDown.js +0 -38
  147. package/examples/shorts/uiSystem.js +0 -61
  148. package/examples/shorts/video.webm +0 -0
  149. package/examples/shorts/videoPlayer.js +0 -34
  150. package/examples/starter/build.bat +0 -7
  151. package/examples/starter/build.mjs +0 -126
  152. package/examples/starter/game.js +0 -137
  153. package/examples/starter/index.html +0 -35
  154. package/examples/starter/tiles.png +0 -0
  155. package/examples/stress/index.html +0 -173
  156. package/examples/style.css +0 -150
  157. package/examples/typescript/build.mjs +0 -60
  158. package/examples/typescript/game.js +0 -100
  159. package/examples/typescript/game.ts +0 -132
  160. package/examples/typescript/index.html +0 -10
  161. package/examples/typescript/tiles.png +0 -0
  162. package/examples/typescript/tsconfig.json +0 -17
  163. package/examples/uiSystem/game.js +0 -139
  164. package/examples/uiSystem/index.html +0 -10
  165. package/examples/uiSystem/tiles.png +0 -0
  166. package/jsconfig.json +0 -12
  167. package/plugins/desktop.ini +0 -2
  168. package/reference.md +0 -447
  169. package/test/math.test.mjs +0 -774
  170. package/test/setup.mjs +0 -22
  171. package/test/smoke.test.mjs +0 -258
  172. package/test/util.test.mjs +0 -80
@@ -0,0 +1,758 @@
1
+ /**
2
+ * LittleJS PathFinder Plugin
3
+ * - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
4
+ * - Works directly on a TileCollisionLayer, or override isWalkable/getCost for any grid
5
+ * - Debug visualization via engine debug primitives (stripped in release builds)
6
+ * - Port of frankforce.com pathFindingBase.cpp (2018)
7
+ * @namespace PathFinding
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ ///////////////////////////////////////////////////////////////////////////////
13
+
14
+ // Diagonal step cost — pre-computed for the A* expansion inner loop.
15
+ const PATHFINDER_DIAGONAL_COST = Math.SQRT2;
16
+
17
+ // Shared 1x1 size vector for per-tile debugRect calls. debugRect copies the
18
+ // argument internally, so reusing one instance is safe.
19
+ const PATHFINDER_TILE_VEC = vec2(1);
20
+
21
+ ///////////////////////////////////////////////////////////////////////////////
22
+
23
+ /** A single grid cell tracked by the pathfinder. Allocated once per cell at
24
+ * PathFinder construction; reset (not reallocated) at the start of every
25
+ * findPath call.
26
+ * @memberof PathFinding */
27
+ class PathFinderNode
28
+ {
29
+ /** @param {number} x - Tile x
30
+ * @param {number} y - Tile y */
31
+ constructor(x, y)
32
+ {
33
+ /** @property {Vector2} - Tile coords (integer) */
34
+ this.pos = vec2(x, y);
35
+ /** @property {Vector2} - World-space center of this tile (set by buildNodeData) */
36
+ this.posWorld = vec2();
37
+ /** @property {boolean} - True if this cell is passable (cleared each findPath call) */
38
+ this.walkable = false;
39
+ /** @property {number} - Extra cost added to A* G-score for stepping on this cell */
40
+ this.cost = 0;
41
+ /** @property {number} - A* G-score: actual cost from start to this node */
42
+ this.g = 0;
43
+ /** @property {number} - A* F-score: G + heuristic */
44
+ this.f = 0;
45
+ /** @property {PathFinderNode|null} - Parent for path reconstruction */
46
+ this.parent = null;
47
+ /** @property {boolean} - In the A* open list */
48
+ this.isOpen = false;
49
+ /** @property {boolean} - In the A* closed list */
50
+ this.isClosed = false;
51
+ }
52
+
53
+ /** Reset per-search state (called at the start of buildNodeData). */
54
+ reset()
55
+ {
56
+ this.walkable = false;
57
+ this.cost = 0;
58
+ this.g = 0;
59
+ this.f = 0;
60
+ this.parent = null;
61
+ this.isOpen = false;
62
+ this.isClosed = false;
63
+ }
64
+
65
+ /** True if walkable and not blocked by cost. */
66
+ isClear()
67
+ {
68
+ return this.walkable && this.cost === 0;
69
+ }
70
+ }
71
+
72
+ ///////////////////////////////////////////////////////////////////////////////
73
+
74
+ /** Grid pathfinder using A* with two optional smoothing passes.
75
+ * @memberof PathFinding
76
+ * @example
77
+ * // Tile-layer driven (most common):
78
+ * const pf = new PathFinder(myTileCollisionLayer);
79
+ * const path = pf.findPath(player.pos, mousePos);
80
+ *
81
+ * // Bare grid with custom walkability:
82
+ * const pf = new PathFinder(vec2(50, 50));
83
+ * pf.isWalkable = (x, y) => myGrid[y*50 + x] === 0;
84
+ */
85
+ class PathFinder
86
+ {
87
+ /** @param {TileCollisionLayer|Vector2} source - Either a TileCollisionLayer
88
+ * (size and walkability auto-derived) or a Vector2 grid size (user
89
+ * overrides isWalkable). */
90
+ constructor(source)
91
+ {
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.
95
+ if (isVector2(source))
96
+ {
97
+ this.size = source.floor();
98
+ this.tileLayer = undefined;
99
+ }
100
+ else
101
+ {
102
+ ASSERT(source && isVector2(source.size) && typeof source.getCollisionData === 'function',
103
+ 'PathFinder requires a Vector2 size or a TileCollisionLayer');
104
+ this.size = source.size;
105
+ this.tileLayer = source;
106
+ }
107
+
108
+ // Tunables (public, freely re-assignable).
109
+ this.heuristicWeight = 1;
110
+ this.maxLoop = 500;
111
+ this.smoothPath = true;
112
+ this.debug = false;
113
+ this.debugTime = 2;
114
+
115
+ // Pre-allocate the node array — one node per tile, reused across calls.
116
+ this.nodes = new Array(this.size.x * this.size.y);
117
+ for (let y = 0; y < this.size.y; ++y)
118
+ for (let x = 0; x < this.size.x; ++x)
119
+ this.nodes[x + y * this.size.x] = new PathFinderNode(x, y);
120
+
121
+ // Scratch Vector2 reused to avoid allocations in the isWalkable hot path.
122
+ this.collisionScratch = vec2();
123
+ }
124
+
125
+ /** Default walkability: if a tile layer was provided, returns true when the
126
+ * cell has no solid collision data; otherwise returns true. Override on
127
+ * the instance or via a subclass.
128
+ * @param {number} x - Tile x
129
+ * @param {number} y - Tile y
130
+ * @returns {boolean} */
131
+ isWalkable(x, y)
132
+ {
133
+ if (!this.tileLayer) return true;
134
+ return !this.tileLayer.getCollisionData(this.collisionScratch.set(x, y));
135
+ }
136
+
137
+ /** Default extra cost for stepping on a cell. Returns 0 (free) by default.
138
+ * Override to add cost-weighted terrain (mud, swamp, etc).
139
+ * @param {number} x - Tile x
140
+ * @param {number} y - Tile y
141
+ * @returns {number} */
142
+ getCost(x, y)
143
+ {
144
+ return 0;
145
+ }
146
+
147
+ /** Get the node at tile coords, or null if out of bounds.
148
+ * @param {number} x
149
+ * @param {number} y
150
+ * @returns {PathFinderNode|null} */
151
+ getNode(x, y)
152
+ {
153
+ if (x < 0 || y < 0 || x >= this.size.x || y >= this.size.y) return null;
154
+ return this.nodes[x + y * this.size.x];
155
+ }
156
+
157
+ /** Convert a world-space position to integer tile coords (no clamping).
158
+ * @param {Vector2} worldPos
159
+ * @returns {Vector2}
160
+ * @memberof PathFinding */
161
+ worldToTile(worldPos)
162
+ {
163
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
164
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
165
+ return vec2(floor(worldPos.x - ox), floor(worldPos.y - oy));
166
+ }
167
+
168
+ /** Convert integer tile coords to the world-space center of that tile.
169
+ * @param {number} x
170
+ * @param {number} y
171
+ * @returns {Vector2}
172
+ * @memberof PathFinding */
173
+ tileToWorld(x, y)
174
+ {
175
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
176
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
177
+ return vec2(x + 0.5 + ox, y + 0.5 + oy);
178
+ }
179
+
180
+ /** Reset all nodes and re-populate walkable / cost / posWorld from the
181
+ * current isWalkable / getCost overrides. Called at the start of
182
+ * findPath; exposed so tests and tooling can drive it directly.
183
+ * @private */
184
+ buildNodeData()
185
+ {
186
+ const w = this.size.x;
187
+ const h = this.size.y;
188
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
189
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
190
+ for (let y = 0; y < h; ++y)
191
+ for (let x = 0; x < w; ++x)
192
+ {
193
+ const node = this.nodes[x + y * w];
194
+ node.reset();
195
+ const walkable = !!this.isWalkable(x, y);
196
+ const cost = walkable ? max(0, this.getCost(x, y)) : 0;
197
+ node.walkable = walkable;
198
+ node.cost = cost;
199
+ node.posWorld.set(x + 0.5 + ox, y + 0.5 + oy);
200
+
201
+ if (this.debug && this.debugTime > 0)
202
+ {
203
+ if (!walkable)
204
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, 0.25), this.debugTime);
205
+ else if (cost > 0)
206
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, min(0.2, cost * 0.05)), this.debugTime);
207
+ }
208
+ }
209
+ }
210
+
211
+ /** Core A* search loop. Expects buildNodeData() to have been called first.
212
+ * Marks node.parent for path reconstruction. Returns true if endNode was
213
+ * reached; false on disconnected goal or maxLoop exhaustion.
214
+ * @param {PathFinderNode} startNode
215
+ * @param {PathFinderNode} endNode
216
+ * @returns {boolean}
217
+ * @private */
218
+ aStarSearch(startNode, endNode)
219
+ {
220
+ ASSERT(startNode && endNode, 'aStarSearch needs both endpoints');
221
+ ASSERT(startNode !== endNode, 'aStarSearch: start and end must differ — caller should handle trivial case');
222
+ ASSERT(startNode.walkable && endNode.walkable, 'aStarSearch: endpoints must be walkable');
223
+
224
+ const openList = [startNode];
225
+ startNode.isOpen = true;
226
+ let loopCount = 0;
227
+
228
+ while (openList.length > 0)
229
+ {
230
+ // Find the open node with the smallest f score (linear scan).
231
+ // Same as the C++ — fine up to a few thousand nodes.
232
+ let bestIndex = 0;
233
+ let bestF = openList[0].f;
234
+ for (let i = 1; i < openList.length; ++i)
235
+ {
236
+ if (openList[i].f < bestF)
237
+ {
238
+ bestF = openList[i].f;
239
+ bestIndex = i;
240
+ }
241
+ }
242
+ const current = openList[bestIndex];
243
+
244
+ if (current === endNode) break;
245
+ if (++loopCount > this.maxLoop) break;
246
+
247
+ // Move current from open to closed.
248
+ current.isOpen = false;
249
+ openList.splice(bestIndex, 1);
250
+ current.isClosed = true;
251
+
252
+ if (this.debug && this.debugTime > 0)
253
+ debugRect(current.posWorld, PATHFINDER_TILE_VEC, rgb(1, 1, 1, 0.05), this.debugTime);
254
+
255
+ // Expand all 8 neighbors.
256
+ for (let dy = -1; dy <= 1; ++dy)
257
+ for (let dx = -1; dx <= 1; ++dx)
258
+ {
259
+ if (dx === 0 && dy === 0) continue;
260
+ const neighbor = this.getNode(current.pos.x + dx, current.pos.y + dy);
261
+ if (!neighbor || !neighbor.walkable || neighbor.isClosed) continue;
262
+
263
+ let stepCost = 1;
264
+ if (dx !== 0 && dy !== 0)
265
+ {
266
+ // Diagonal step: refuse if either cardinal neighbor is
267
+ // blocked or has cost. Prevents cutting through corners.
268
+ const card1 = this.getNode(current.pos.x + dx, current.pos.y);
269
+ if (!card1 || card1.cost > 0 || !card1.walkable) continue;
270
+ const card2 = this.getNode(current.pos.x, current.pos.y + dy);
271
+ if (!card2 || card2.cost > 0 || !card2.walkable) continue;
272
+ stepCost = PATHFINDER_DIAGONAL_COST;
273
+ }
274
+
275
+ const tentativeG = current.g + stepCost + neighbor.cost;
276
+ if (!neighbor.isOpen)
277
+ {
278
+ neighbor.isOpen = true;
279
+ openList.push(neighbor);
280
+ }
281
+ else if (tentativeG >= neighbor.g)
282
+ {
283
+ continue;
284
+ }
285
+
286
+ // Best path so far through neighbor — record it.
287
+ neighbor.parent = current;
288
+ neighbor.g = tentativeG;
289
+ const gdx = endNode.pos.x - neighbor.pos.x;
290
+ const gdy = endNode.pos.y - neighbor.pos.y;
291
+ neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
292
+ }
293
+ }
294
+
295
+ return endNode.parent !== null;
296
+ }
297
+
298
+ /** Find the clear (walkable, zero-cost) node closest to the given world
299
+ * position. Spirals outward in expanding boxes until a clear node is
300
+ * found or the search range is exhausted. Useful for snapping a click
301
+ * or NPC spawn position to the nearest open tile.
302
+ *
303
+ * By default, calls `buildNodeData()` first so it works correctly on a
304
+ * fresh PathFinder. If you're calling it many times in a row with
305
+ * unchanged walkability, pass `rebuild=false` and call `buildNodeData()`
306
+ * once externally to avoid redundant work.
307
+ * @param {Vector2} worldPos
308
+ * @param {number} [searchRange=10] - Max box-radius in tiles
309
+ * @param {boolean} [rebuild=true] - Whether to call buildNodeData first
310
+ * @returns {PathFinderNode|null}
311
+ * @memberof PathFinding */
312
+ getNearestClearNode(worldPos, searchRange = 10, rebuild = true)
313
+ {
314
+ ASSERT(isVector2(worldPos), 'worldPos must be a Vector2');
315
+ if (rebuild) this.buildNodeData();
316
+
317
+ // Inline worldToTile to avoid a Vector2 allocation per call.
318
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
319
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
320
+ const centerX = floor(worldPos.x - ox);
321
+ const centerY = floor(worldPos.y - oy);
322
+
323
+ for (let offset = 0; offset <= searchRange; ++offset)
324
+ {
325
+ let nearest = null;
326
+ let nearestDistSq = 0;
327
+
328
+ for (let dy = -offset; dy <= offset; ++dy)
329
+ for (let dx = -offset; dx <= offset; ++dx)
330
+ {
331
+ // Only scan the perimeter of the current ring (skip the
332
+ // interior we've already searched in earlier iterations).
333
+ if (offset > 0 && abs(dx) !== offset && abs(dy) !== offset)
334
+ continue;
335
+
336
+ const node = this.getNode(centerX + dx, centerY + dy);
337
+ if (!node || !node.isClear()) continue;
338
+
339
+ const ddx = node.posWorld.x - worldPos.x;
340
+ const ddy = node.posWorld.y - worldPos.y;
341
+ const distSq = ddx * ddx + ddy * ddy;
342
+ if (!nearest || distSq < nearestDistSq)
343
+ {
344
+ nearest = node;
345
+ nearestDistSq = distSq;
346
+ }
347
+ }
348
+ if (nearest) return nearest;
349
+ }
350
+ return null;
351
+ }
352
+
353
+ /** Smooth a node path by removing redundant turns and tightening corners
354
+ * where a grid-aligned diagonal is clear. Modifies the path in place.
355
+ * Stays on the grid — does not introduce off-tile-center points.
356
+ * Port of ShortenPath() in pathFinding.cpp.
357
+ * @param {PathFinderNode[]} path
358
+ * @private */
359
+ smoothPathCorners(path)
360
+ {
361
+ if (path.length <= 2) return;
362
+
363
+ let i = 1;
364
+ while (i < path.length - 1)
365
+ {
366
+ const prev = path[i - 1];
367
+ const node = path[i];
368
+ const next = path[i + 1];
369
+
370
+ const dx = next.pos.x - prev.pos.x;
371
+ const dy = next.pos.y - prev.pos.y;
372
+ const lenSq = dx * dx + dy * dy;
373
+
374
+ // dx,dy is the prev-to-current step direction; needed for the
375
+ // 135° "mostly vertical/horizontal" disambiguation.
376
+ const stepDx = node.pos.x - prev.pos.x;
377
+ const stepDy = node.pos.y - prev.pos.y;
378
+ const stepDxNext = next.pos.x - node.pos.x;
379
+ const stepDyNext = next.pos.y - node.pos.y;
380
+
381
+ if (lenSq === 1)
382
+ {
383
+ // 45° angle — middle node is off the straight line. Drop it.
384
+ if (this.debug && this.debugTime > 0)
385
+ debugCircle(node.posWorld, 0.3, rgb(0.5, 0, 0.5, 0.5), this.debugTime);
386
+ path.splice(i, 1);
387
+ i = max(1, i - 1);
388
+ continue;
389
+ }
390
+ else if (lenSq === 2)
391
+ {
392
+ // 90° corner. Check the alternative-diagonal cell.
393
+ if (this.debug && this.debugTime > 0)
394
+ debugCircle(node.posWorld, 0.3, rgb(1, 0, 0, 0.5), this.debugTime);
395
+
396
+ let sx, sy;
397
+ if (prev.pos.y === node.pos.y && next.pos.x === node.pos.x)
398
+ { sx = prev.pos.x; sy = next.pos.y; }
399
+ else
400
+ { sx = next.pos.x; sy = prev.pos.y; }
401
+
402
+ const shortcut = this.getNode(sx, sy);
403
+ if (shortcut && shortcut.isClear())
404
+ {
405
+ path.splice(i, 1);
406
+ i = max(1, i - 1);
407
+ continue;
408
+ }
409
+ }
410
+ else if (lenSq === 5)
411
+ {
412
+ // 135° angle (a knight's-move offset). Try to relocate the
413
+ // middle node to whichever of two candidate cells is closer
414
+ // to prev-of-prev, and only if the corner cut is also clear.
415
+ if (this.debug && this.debugTime > 0)
416
+ debugCircle(node.posWorld, 0.3, rgb(1, 1, 0, 0.5), this.debugTime);
417
+
418
+ const prevPrev = i >= 2 ? path[i - 2] : prev;
419
+ let s1x, s1y, s2x, s2y;
420
+ if (stepDx === 0 || stepDxNext === 0)
421
+ {
422
+ // mostly vertical
423
+ s1x = next.pos.x; s1y = node.pos.y;
424
+ s2x = prev.pos.x; s2y = node.pos.y;
425
+ }
426
+ else
427
+ {
428
+ // mostly horizontal
429
+ s1x = node.pos.x; s1y = next.pos.y;
430
+ s2x = node.pos.x; s2y = prev.pos.y;
431
+ }
432
+ const dd1x = s1x - prevPrev.pos.x;
433
+ const dd1y = s1y - prevPrev.pos.y;
434
+ const dd2x = s2x - prevPrev.pos.x;
435
+ const dd2y = s2y - prevPrev.pos.y;
436
+ const dist1Sq = dd1x * dd1x + dd1y * dd1y;
437
+ const dist2Sq = dd2x * dd2x + dd2y * dd2y;
438
+ const sx = dist1Sq < dist2Sq ? s1x : s1x === s2x && s1y === s2y ? s1x : s2x;
439
+ const sy = dist1Sq < dist2Sq ? s1y : s1x === s2x && s1y === s2y ? s1y : s2y;
440
+
441
+ const shortcut = this.getNode(sx, sy);
442
+ if (shortcut && shortcut !== node && shortcut.isClear())
443
+ {
444
+ // Also check the cut-corner cell is clear.
445
+ const ccx = next.pos.x + s2x - s1x;
446
+ const ccy = next.pos.y + s2y - s1y;
447
+ const cutCorner = this.getNode(ccx, ccy);
448
+ if (cutCorner && cutCorner.isClear())
449
+ {
450
+ path[i] = shortcut;
451
+ i = max(1, i - 1);
452
+ continue;
453
+ }
454
+ }
455
+ }
456
+ else if (lenSq === 4 || lenSq === 8)
457
+ {
458
+ // Straight line or a 1-cell bump.
459
+ if (this.debug && this.debugTime > 0)
460
+ debugCircle(node.posWorld, 0.3, rgb(0, 1, 0, 0.5), this.debugTime);
461
+
462
+ if (stepDx === stepDxNext && stepDy === stepDyNext)
463
+ {
464
+ // Truly straight — nothing to do, advance.
465
+ ++i;
466
+ continue;
467
+ }
468
+ else
469
+ {
470
+ // Bump — try to flatten via the in-line cell.
471
+ let sx, sy;
472
+ if (prev.pos.y === next.pos.y)
473
+ { sx = node.pos.x; sy = prev.pos.y; }
474
+ else
475
+ { sx = prev.pos.x; sy = node.pos.y; }
476
+ const shortcut = this.getNode(sx, sy);
477
+ if (shortcut && shortcut.isClear())
478
+ {
479
+ path[i] = shortcut;
480
+ i = max(1, i - 1);
481
+ continue;
482
+ }
483
+ }
484
+ }
485
+
486
+ ++i;
487
+ }
488
+ }
489
+
490
+ /** Smooth a node path via line-of-sight ("string pulling"). Walks the
491
+ * input path collapsing runs of nodes into straight segments whenever
492
+ * isLineClear permits, so the result can leave grid centers and cut
493
+ * cleanly across open spaces.
494
+ *
495
+ * Bails (leaves the path unchanged) if any node has nonzero cost — a
496
+ * straight geometric shortcut can't be trusted to be the lowest-cost
497
+ * route when cost-weighted terrain is in play.
498
+ *
499
+ * Port of ShortenPath2() in pathFinding.cpp.
500
+ * @param {PathFinderNode[]} path
501
+ * @private */
502
+ smoothPathStringPull(path)
503
+ {
504
+ if (path.length <= 2) return;
505
+ for (const n of path)
506
+ {
507
+ if (!n.isClear()) return;
508
+ }
509
+
510
+ const original = path.slice();
511
+ path.length = 0;
512
+ path.push(original[0]);
513
+ let searchIndex = 0;
514
+
515
+ for (let i = 1; i < original.length; ++i)
516
+ {
517
+ const node = original[i];
518
+
519
+ // Skip if node is collinear with the search-window start and the
520
+ // previous node — it adds no information. Note: a == b is the
521
+ // degenerate i=1, searchIndex=0 case; skip the test then.
522
+ {
523
+ const a = original[searchIndex];
524
+ const b = original[i - 1];
525
+ if (a !== b)
526
+ {
527
+ const cross =
528
+ (b.pos.x - a.pos.x) * (node.pos.y - a.pos.y) -
529
+ (b.pos.y - a.pos.y) * (node.pos.x - a.pos.x);
530
+ if (cross === 0) continue;
531
+ }
532
+ }
533
+
534
+ if (!this.isLineClear(node.pos, path[path.length - 1].pos))
535
+ {
536
+ // Look ahead — if any later node has a clear shot to the
537
+ // back of our new path, skip this node and try later.
538
+ let foundClearAfter = false;
539
+ for (let j = i + 1; j < original.length; ++j)
540
+ {
541
+ if (this.isLineClear(original[j].pos, path[path.length - 1].pos))
542
+ {
543
+ foundClearAfter = true;
544
+ break;
545
+ }
546
+ }
547
+ if (foundClearAfter)
548
+ {
549
+ if (this.debug && this.debugTime > 0)
550
+ debugLine(node.posWorld, path[path.length - 1].posWorld, rgb(0, 0, 1, 0.3), 0.02, this.debugTime);
551
+ continue;
552
+ }
553
+
554
+ // No clear line ahead — fall back to the last waypoint we did
555
+ // have a clear line to. searchIndex tracks our scan position.
556
+ for (; searchIndex < original.length; ++searchIndex)
557
+ {
558
+ const cand = original[searchIndex];
559
+ if (this.isLineClear(node.pos, cand.pos))
560
+ {
561
+ path.push(cand);
562
+ i = searchIndex;
563
+ break;
564
+ }
565
+ }
566
+ ASSERT(searchIndex < original.length, 'smoothPathStringPull: ran out of candidates');
567
+ }
568
+ }
569
+
570
+ path.push(original[original.length - 1]);
571
+ }
572
+
573
+ /** Lookup helper: true when the node at tile coords (x, y) is in-bounds
574
+ * and clear (walkable, zero-cost). Used by isLineClear's hot path.
575
+ * @param {number} x
576
+ * @param {number} y
577
+ * @returns {boolean}
578
+ * @private */
579
+ isNodeClear(x, y)
580
+ {
581
+ const n = this.getNode(x, y);
582
+ return n !== null && n.isClear();
583
+ }
584
+
585
+ /** Check that the line between two tile-coord endpoints stays entirely
586
+ * inside walkable, zero-cost cells. Stricter than just sampling along
587
+ * the line — it also checks the diagonal-corner-adjacent cells so the
588
+ * line can never "scrape past" a wall corner.
589
+ *
590
+ * Both endpoints must themselves be clear (asserted in debug). Port of
591
+ * CheckLine() in pathFinding.cpp.
592
+ * @param {Vector2} startPos - Tile coords
593
+ * @param {Vector2} endPos - Tile coords
594
+ * @returns {boolean}
595
+ * @private */
596
+ isLineClear(startPos, endPos)
597
+ {
598
+ ASSERT(isVector2(startPos) && isVector2(endPos), 'isLineClear needs Vector2 endpoints');
599
+ ASSERT(this.isNodeClear(startPos.x, startPos.y) && this.isNodeClear(endPos.x, endPos.y),
600
+ 'isLineClear endpoints must be in-bounds and clear');
601
+
602
+ const dx = endPos.x - startPos.x;
603
+ const dy = endPos.y - startPos.y;
604
+ const adx = abs(dx);
605
+ const ady = abs(dy);
606
+ const sx = sign(dx);
607
+ const sy = sign(dy);
608
+ let x = startPos.x;
609
+ let y = startPos.y;
610
+
611
+ if (ady === adx)
612
+ {
613
+ // Pure diagonal.
614
+ while (x !== endPos.x)
615
+ {
616
+ if (x !== startPos.x)
617
+ {
618
+ if (!this.isNodeClear(x, y)) return false;
619
+ if (!this.isNodeClear(x, y - sy)) return false;
620
+ }
621
+ if (!this.isNodeClear(x, y + sy)) return false;
622
+ x += sx;
623
+ y += sy;
624
+ }
625
+ if (!this.isNodeClear(endPos.x, endPos.y - sy)) return false;
626
+ }
627
+ else if (ady < adx)
628
+ {
629
+ // Mostly horizontal.
630
+ if (dy === 0)
631
+ {
632
+ // Purely horizontal.
633
+ x += sx;
634
+ while (x !== endPos.x)
635
+ {
636
+ if (!this.isNodeClear(x, y)) return false;
637
+ x += sx;
638
+ }
639
+ }
640
+ else
641
+ {
642
+ let lastY = startPos.y;
643
+ while (x !== endPos.x)
644
+ {
645
+ y = startPos.y + Math.trunc((dy * (x - startPos.x)) / dx);
646
+ if (lastY !== y)
647
+ {
648
+ if (!this.isNodeClear(x - sx, y + sy)) return false;
649
+ if (!this.isNodeClear(x, y - sy)) return false;
650
+ }
651
+ lastY = y;
652
+ if (x !== startPos.x)
653
+ {
654
+ if (!this.isNodeClear(x, y)) return false;
655
+ }
656
+ y += sy;
657
+ if (!this.isNodeClear(x, y)) return false;
658
+ x += sx;
659
+ }
660
+ const finalY = endPos.y - sy;
661
+ if (!this.isNodeClear(endPos.x, finalY)) return false;
662
+ }
663
+ }
664
+ else
665
+ {
666
+ // Mostly vertical.
667
+ if (dx === 0)
668
+ {
669
+ y += sy;
670
+ while (y !== endPos.y)
671
+ {
672
+ if (!this.isNodeClear(x, y)) return false;
673
+ y += sy;
674
+ }
675
+ }
676
+ else
677
+ {
678
+ let lastX = startPos.x;
679
+ while (y !== endPos.y)
680
+ {
681
+ x = startPos.x + Math.trunc((dx * (y - startPos.y)) / dy);
682
+ if (lastX !== x)
683
+ {
684
+ if (!this.isNodeClear(x + sx, y - sy)) return false;
685
+ if (!this.isNodeClear(x - sx, y)) return false;
686
+ }
687
+ lastX = x;
688
+ if (y !== startPos.y)
689
+ {
690
+ if (!this.isNodeClear(x, y)) return false;
691
+ }
692
+ x += sx;
693
+ if (!this.isNodeClear(x, y)) return false;
694
+ y += sy;
695
+ }
696
+ const finalX = endPos.x - sx;
697
+ if (!this.isNodeClear(finalX, endPos.y)) return false;
698
+ }
699
+ }
700
+ return true;
701
+ }
702
+
703
+ /** Find a path from startPos to endPos in world space. Returns an array
704
+ * of world-space Vector2 points; empty array if no path exists.
705
+ *
706
+ * Start and end are snapped to the nearest walkable tile via
707
+ * getNearestClearNode. Intermediate points are tile centers unless the
708
+ * string-pulling smoothing pass moves them off-grid.
709
+ * @param {Vector2} startPos - World-space start
710
+ * @param {Vector2} endPos - World-space end
711
+ * @returns {Vector2[]}
712
+ * @memberof PathFinding */
713
+ findPath(startPos, endPos)
714
+ {
715
+ ASSERT(isVector2(startPos) && isVector2(endPos), 'findPath needs Vector2 endpoints');
716
+
717
+ this.buildNodeData();
718
+
719
+ // rebuild=false because we just built — avoid redundant work per snap.
720
+ const startNode = this.getNearestClearNode(startPos, 10, false);
721
+ const endNode = this.getNearestClearNode(endPos, 10, false);
722
+ if (!startNode || !endNode) return [];
723
+
724
+ // Trivial case: start and end snapped to the same tile.
725
+ if (startNode === endNode) return [startNode.posWorld.copy()];
726
+
727
+ if (!this.aStarSearch(startNode, endNode)) return [];
728
+
729
+ // Walk back from endNode via parent pointers, then reverse — cheaper
730
+ // than unshifting on every step.
731
+ const nodePath = [];
732
+ for (let n = endNode; n; n = n.parent)
733
+ nodePath.push(n);
734
+ nodePath.reverse();
735
+
736
+ if (this.smoothPath)
737
+ {
738
+ this.smoothPathCorners(nodePath);
739
+ this.smoothPathStringPull(nodePath);
740
+ }
741
+
742
+ // Convert to world-space Vector2 path. Return copies, not live node
743
+ // references — callers shouldn't be able to mutate the grid.
744
+ const result = nodePath.map(n => n.posWorld.copy());
745
+
746
+ if (this.debug && this.debugTime > 0 && result.length > 0)
747
+ {
748
+ for (let i = 1; i < result.length; ++i)
749
+ debugLine(result[i - 1], result[i], RED, 0.1, this.debugTime);
750
+ for (const p of result)
751
+ debugCircle(p, 0.5, rgb(1, 0, 0, 0.3), this.debugTime);
752
+ debugCircle(result[0], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
753
+ debugCircle(result[result.length - 1], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
754
+ }
755
+
756
+ return result;
757
+ }
758
+ }