littlejsengine 1.18.4 → 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 (177) hide show
  1. package/COPYRIGHT.txt +38 -0
  2. package/FAQ.md +633 -0
  3. package/README.md +19 -1
  4. package/dist/littlejs.d.ts +349 -54
  5. package/dist/littlejs.esm.js +1469 -448
  6. package/dist/littlejs.esm.min.js +1 -1
  7. package/dist/littlejs.js +1438 -436
  8. package/dist/littlejs.min.js +1 -1
  9. package/dist/littlejs.release.js +1438 -436
  10. package/package.json +8 -1
  11. package/plugins/box2d.js +32 -7
  12. package/{src/engineMedals.js → plugins/medalSystem.js} +248 -196
  13. package/plugins/pathFinder.js +758 -0
  14. package/plugins/pluginExport.js +19 -0
  15. package/plugins/tween.js +1 -1
  16. package/plugins/tweenSystem.js +509 -0
  17. package/plugins/uiSystem.js +150 -11
  18. package/src/engine.js +2 -141
  19. package/src/engineAudio.js +8 -5
  20. package/src/engineBuild.mjs +4 -2
  21. package/src/engineDebug.js +1 -1
  22. package/src/engineDraw.js +21 -3
  23. package/src/engineExport.js +12 -12
  24. package/src/engineInput.js +1 -1
  25. package/src/engineLogo.js +146 -0
  26. package/src/engineMath.js +10 -3
  27. package/src/engineObject.js +11 -5
  28. package/src/engineParticles.js +6 -3
  29. package/src/engineRelease.js +1 -1
  30. package/src/engineSettings.js +0 -47
  31. package/src/engineUtilities.js +7 -2
  32. package/src/engineWebGL.js +34 -7
  33. package/src/jsconfig.json +3 -1
  34. package/.github/workflows/test.yml +0 -17
  35. package/AI.md +0 -173
  36. package/CLAUDE.md +0 -1
  37. package/examples/box2d/game.js +0 -192
  38. package/examples/box2d/gameObjects.js +0 -564
  39. package/examples/box2d/index.html +0 -9
  40. package/examples/box2d/scenes.js +0 -194
  41. package/examples/box2d/tiles.png +0 -0
  42. package/examples/breakout/game.js +0 -175
  43. package/examples/breakout/gameObjects.js +0 -147
  44. package/examples/breakout/index.html +0 -8
  45. package/examples/breakout/tiles.png +0 -0
  46. package/examples/breakoutTutorial/README.md +0 -521
  47. package/examples/breakoutTutorial/game.js +0 -191
  48. package/examples/breakoutTutorial/images/1.png +0 -0
  49. package/examples/breakoutTutorial/images/10.png +0 -0
  50. package/examples/breakoutTutorial/images/11.png +0 -0
  51. package/examples/breakoutTutorial/images/2.png +0 -0
  52. package/examples/breakoutTutorial/images/3.png +0 -0
  53. package/examples/breakoutTutorial/images/4.png +0 -0
  54. package/examples/breakoutTutorial/images/5.png +0 -0
  55. package/examples/breakoutTutorial/images/6.png +0 -0
  56. package/examples/breakoutTutorial/images/7.png +0 -0
  57. package/examples/breakoutTutorial/images/8.png +0 -0
  58. package/examples/breakoutTutorial/images/9.png +0 -0
  59. package/examples/breakoutTutorial/index.html +0 -8
  60. package/examples/electron/build.mjs +0 -127
  61. package/examples/electron/electron.js +0 -35
  62. package/examples/electron/game.js +0 -54
  63. package/examples/electron/index.html +0 -13
  64. package/examples/electron/package.json +0 -5
  65. package/examples/electron/tiles.png +0 -0
  66. package/examples/empty/game.js +0 -51
  67. package/examples/empty/index.html +0 -5
  68. package/examples/empty/tiles.png +0 -0
  69. package/examples/favicon.png +0 -0
  70. package/examples/games.jpg +0 -0
  71. package/examples/htmlMenu/game.js +0 -82
  72. package/examples/htmlMenu/index.html +0 -45
  73. package/examples/htmlMenu/tiles.png +0 -0
  74. package/examples/index.html +0 -62
  75. package/examples/logo.png +0 -0
  76. package/examples/logo2.png +0 -0
  77. package/examples/module/build.mjs +0 -124
  78. package/examples/module/game.js +0 -132
  79. package/examples/module/index.html +0 -10
  80. package/examples/module/tiles.png +0 -0
  81. package/examples/particles/index.html +0 -426
  82. package/examples/particles/tiles.png +0 -0
  83. package/examples/platformer/data/gameLevelData.tmx +0 -143
  84. package/examples/platformer/data/gameLevelData.tsx +0 -4
  85. package/examples/platformer/game.js +0 -147
  86. package/examples/platformer/gameCharacter.js +0 -309
  87. package/examples/platformer/gameEffects.js +0 -278
  88. package/examples/platformer/gameLevel.js +0 -205
  89. package/examples/platformer/gameLevelData.json +0 -171
  90. package/examples/platformer/gameObjects.js +0 -378
  91. package/examples/platformer/gamePlayer.js +0 -35
  92. package/examples/platformer/index.html +0 -9
  93. package/examples/platformer/tiles.png +0 -0
  94. package/examples/platformer/tilesLevel.png +0 -0
  95. package/examples/puzzle/game.js +0 -331
  96. package/examples/puzzle/index.html +0 -8
  97. package/examples/puzzle/tiles.png +0 -0
  98. package/examples/screenshot.jpg +0 -0
  99. package/examples/shorts/animation.js +0 -18
  100. package/examples/shorts/base.html +0 -54
  101. package/examples/shorts/blending.js +0 -14
  102. package/examples/shorts/box2d.js +0 -51
  103. package/examples/shorts/box2dCar.js +0 -59
  104. package/examples/shorts/box2dPool.js +0 -107
  105. package/examples/shorts/box2dTileLayer.js +0 -48
  106. package/examples/shorts/cameraDrag.js +0 -22
  107. package/examples/shorts/clock.js +0 -21
  108. package/examples/shorts/colors.js +0 -25
  109. package/examples/shorts/debugDraw.js +0 -37
  110. package/examples/shorts/empty.js +0 -30
  111. package/examples/shorts/flappyGame.js +0 -55
  112. package/examples/shorts/fontImage.js +0 -17
  113. package/examples/shorts/fps.js +0 -90
  114. package/examples/shorts/helloWorld.js +0 -11
  115. package/examples/shorts/hillGlideGame.js +0 -63
  116. package/examples/shorts/input.js +0 -64
  117. package/examples/shorts/landerGame.js +0 -57
  118. package/examples/shorts/maze.js +0 -48
  119. package/examples/shorts/medals.js +0 -51
  120. package/examples/shorts/music.js +0 -78
  121. package/examples/shorts/musicPlayer.js +0 -137
  122. package/examples/shorts/nineSlice.js +0 -40
  123. package/examples/shorts/parallax.js +0 -72
  124. package/examples/shorts/particles.js +0 -28
  125. package/examples/shorts/piano.js +0 -44
  126. package/examples/shorts/platformer.js +0 -45
  127. package/examples/shorts/pongGame.js +0 -41
  128. package/examples/shorts/postProcess.js +0 -62
  129. package/examples/shorts/sequencer.js +0 -124
  130. package/examples/shorts/shader.js +0 -29
  131. package/examples/shorts/shapes.js +0 -21
  132. package/examples/shorts/slidingPuzzle.js +0 -52
  133. package/examples/shorts/song.mp3 +0 -0
  134. package/examples/shorts/sound.js +0 -36
  135. package/examples/shorts/spaceGame.js +0 -58
  136. package/examples/shorts/spriteAtlas.js +0 -31
  137. package/examples/shorts/starfield.js +0 -15
  138. package/examples/shorts/texture.js +0 -16
  139. package/examples/shorts/textureWrapped.js +0 -12
  140. package/examples/shorts/tileLayer.js +0 -48
  141. package/examples/shorts/tileRaycast.js +0 -39
  142. package/examples/shorts/tiles.png +0 -0
  143. package/examples/shorts/tiltedView.js +0 -63
  144. package/examples/shorts/timers.js +0 -53
  145. package/examples/shorts/topDown.js +0 -38
  146. package/examples/shorts/tween.js +0 -24
  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/shorts.js +0 -743
  151. package/examples/starter/build.bat +0 -7
  152. package/examples/starter/build.mjs +0 -126
  153. package/examples/starter/game.js +0 -137
  154. package/examples/starter/index.html +0 -35
  155. package/examples/starter/tiles.png +0 -0
  156. package/examples/stress/index.html +0 -173
  157. package/examples/style.css +0 -150
  158. package/examples/tweenSystem/game.js +0 -171
  159. package/examples/tweenSystem/index.html +0 -10
  160. package/examples/tweenSystem/tiles.png +0 -0
  161. package/examples/typescript/build.mjs +0 -60
  162. package/examples/typescript/game.js +0 -100
  163. package/examples/typescript/game.ts +0 -132
  164. package/examples/typescript/index.html +0 -10
  165. package/examples/typescript/tiles.png +0 -0
  166. package/examples/typescript/tsconfig.json +0 -17
  167. package/examples/uiSystem/game.js +0 -139
  168. package/examples/uiSystem/index.html +0 -10
  169. package/examples/uiSystem/tiles.png +0 -0
  170. package/jsconfig.json +0 -12
  171. package/plugins/desktop.ini +0 -2
  172. package/reference.md +0 -448
  173. package/test/math.test.mjs +0 -774
  174. package/test/setup.mjs +0 -22
  175. package/test/smoke.test.mjs +0 -274
  176. package/test/tween.test.mjs +0 -576
  177. 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
+ }