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.
- package/COPYRIGHT.txt +38 -0
- package/FAQ.md +633 -0
- package/LICENSE +5 -27
- package/README.md +19 -1
- package/dist/littlejs.d.ts +550 -57
- package/dist/littlejs.esm.js +2208 -492
- package/dist/littlejs.esm.min.js +1 -1
- package/dist/littlejs.js +2165 -480
- package/dist/littlejs.min.js +1 -1
- package/dist/littlejs.release.js +2166 -481
- package/package.json +8 -1
- package/plugins/box2d.js +36 -9
- package/{src/engineMedals.js → plugins/medalSystem.js} +248 -196
- package/plugins/pathFinder.js +758 -0
- package/plugins/pluginExport.js +26 -0
- package/plugins/tween.js +509 -0
- package/plugins/tweenSystem.js +509 -0
- package/plugins/uiSystem.js +151 -12
- package/src/engine.js +9 -145
- package/src/engineAudio.js +8 -5
- package/src/engineBuild.mjs +4 -1
- package/src/engineDebug.js +1 -1
- package/src/engineDraw.js +154 -5
- package/src/engineExport.js +17 -12
- package/src/engineInput.js +27 -1
- package/src/engineLogo.js +146 -0
- package/src/engineMath.js +10 -3
- package/src/engineObject.js +11 -5
- package/src/engineParticles.js +6 -3
- package/src/engineRelease.js +1 -1
- package/src/engineSettings.js +17 -47
- package/src/engineUtilities.js +7 -2
- package/src/engineWebGL.js +31 -8
- package/src/jsconfig.json +3 -1
- package/.claude/settings.local.json +0 -7
- package/.github/workflows/test.yml +0 -17
- package/AI.md +0 -172
- package/CLAUDE.md +0 -1
- package/examples/box2d/game.js +0 -192
- package/examples/box2d/gameObjects.js +0 -564
- package/examples/box2d/index.html +0 -9
- package/examples/box2d/scenes.js +0 -194
- package/examples/box2d/tiles.png +0 -0
- package/examples/breakout/game.js +0 -175
- package/examples/breakout/gameObjects.js +0 -147
- package/examples/breakout/index.html +0 -8
- package/examples/breakout/tiles.png +0 -0
- package/examples/breakoutTutorial/README.md +0 -521
- package/examples/breakoutTutorial/game.js +0 -191
- package/examples/breakoutTutorial/images/1.png +0 -0
- package/examples/breakoutTutorial/images/10.png +0 -0
- package/examples/breakoutTutorial/images/11.png +0 -0
- package/examples/breakoutTutorial/images/2.png +0 -0
- package/examples/breakoutTutorial/images/3.png +0 -0
- package/examples/breakoutTutorial/images/4.png +0 -0
- package/examples/breakoutTutorial/images/5.png +0 -0
- package/examples/breakoutTutorial/images/6.png +0 -0
- package/examples/breakoutTutorial/images/7.png +0 -0
- package/examples/breakoutTutorial/images/8.png +0 -0
- package/examples/breakoutTutorial/images/9.png +0 -0
- package/examples/breakoutTutorial/index.html +0 -8
- package/examples/electron/build.mjs +0 -127
- package/examples/electron/electron.js +0 -35
- package/examples/electron/game.js +0 -54
- package/examples/electron/index.html +0 -13
- package/examples/electron/package.json +0 -5
- package/examples/electron/tiles.png +0 -0
- package/examples/empty/game.js +0 -51
- package/examples/empty/index.html +0 -5
- package/examples/empty/tiles.png +0 -0
- package/examples/favicon.png +0 -0
- package/examples/games.jpg +0 -0
- package/examples/htmlMenu/game.js +0 -82
- package/examples/htmlMenu/index.html +0 -45
- package/examples/htmlMenu/tiles.png +0 -0
- package/examples/index.html +0 -800
- package/examples/logo.png +0 -0
- package/examples/logo2.png +0 -0
- package/examples/module/build.mjs +0 -124
- package/examples/module/game.js +0 -132
- package/examples/module/index.html +0 -10
- package/examples/module/tiles.png +0 -0
- package/examples/particles/index.html +0 -426
- package/examples/particles/tiles.png +0 -0
- package/examples/platformer/data/gameLevelData.tmx +0 -143
- package/examples/platformer/data/gameLevelData.tsx +0 -4
- package/examples/platformer/game.js +0 -147
- package/examples/platformer/gameCharacter.js +0 -309
- package/examples/platformer/gameEffects.js +0 -278
- package/examples/platformer/gameLevel.js +0 -205
- package/examples/platformer/gameLevelData.json +0 -171
- package/examples/platformer/gameObjects.js +0 -378
- package/examples/platformer/gamePlayer.js +0 -35
- package/examples/platformer/index.html +0 -9
- package/examples/platformer/tiles.png +0 -0
- package/examples/platformer/tilesLevel.png +0 -0
- package/examples/puzzle/game.js +0 -331
- package/examples/puzzle/index.html +0 -8
- package/examples/puzzle/tiles.png +0 -0
- package/examples/screenshot.jpg +0 -0
- package/examples/shorts/animation.js +0 -18
- package/examples/shorts/base.html +0 -54
- package/examples/shorts/blending.js +0 -14
- package/examples/shorts/box2d.js +0 -51
- package/examples/shorts/box2dCar.js +0 -59
- package/examples/shorts/box2dPool.js +0 -107
- package/examples/shorts/box2dTileLayer.js +0 -48
- package/examples/shorts/cameraDrag.js +0 -22
- package/examples/shorts/clock.js +0 -21
- package/examples/shorts/colors.js +0 -25
- package/examples/shorts/debugDraw.js +0 -37
- package/examples/shorts/empty.js +0 -30
- package/examples/shorts/flappyGame.js +0 -55
- package/examples/shorts/fontImage.js +0 -17
- package/examples/shorts/fps.js +0 -90
- package/examples/shorts/helloWorld.js +0 -11
- package/examples/shorts/hillGlideGame.js +0 -63
- package/examples/shorts/input.js +0 -64
- package/examples/shorts/landerGame.js +0 -57
- package/examples/shorts/maze.js +0 -48
- package/examples/shorts/medals.js +0 -51
- package/examples/shorts/music.js +0 -78
- package/examples/shorts/musicPlayer.js +0 -137
- package/examples/shorts/nineSlice.js +0 -40
- package/examples/shorts/parallax.js +0 -72
- package/examples/shorts/particles.js +0 -28
- package/examples/shorts/piano.js +0 -44
- package/examples/shorts/platformer.js +0 -45
- package/examples/shorts/pongGame.js +0 -41
- package/examples/shorts/postProcess.js +0 -62
- package/examples/shorts/sequencer.js +0 -124
- package/examples/shorts/shader.js +0 -29
- package/examples/shorts/shapes.js +0 -21
- package/examples/shorts/slidingPuzzle.js +0 -52
- package/examples/shorts/song.mp3 +0 -0
- package/examples/shorts/sound.js +0 -36
- package/examples/shorts/spaceGame.js +0 -58
- package/examples/shorts/spriteAtlas.js +0 -31
- package/examples/shorts/starfield.js +0 -15
- package/examples/shorts/texture.js +0 -16
- package/examples/shorts/tileLayer.js +0 -48
- package/examples/shorts/tileRaycast.js +0 -39
- package/examples/shorts/tiles.png +0 -0
- package/examples/shorts/tiltedView.js +0 -63
- package/examples/shorts/timers.js +0 -53
- package/examples/shorts/topDown.js +0 -38
- package/examples/shorts/uiSystem.js +0 -61
- package/examples/shorts/video.webm +0 -0
- package/examples/shorts/videoPlayer.js +0 -34
- package/examples/starter/build.bat +0 -7
- package/examples/starter/build.mjs +0 -126
- package/examples/starter/game.js +0 -137
- package/examples/starter/index.html +0 -35
- package/examples/starter/tiles.png +0 -0
- package/examples/stress/index.html +0 -173
- package/examples/style.css +0 -150
- package/examples/typescript/build.mjs +0 -60
- package/examples/typescript/game.js +0 -100
- package/examples/typescript/game.ts +0 -132
- package/examples/typescript/index.html +0 -10
- package/examples/typescript/tiles.png +0 -0
- package/examples/typescript/tsconfig.json +0 -17
- package/examples/uiSystem/game.js +0 -139
- package/examples/uiSystem/index.html +0 -10
- package/examples/uiSystem/tiles.png +0 -0
- package/jsconfig.json +0 -12
- package/plugins/desktop.ini +0 -2
- package/reference.md +0 -447
- package/test/math.test.mjs +0 -774
- package/test/setup.mjs +0 -22
- package/test/smoke.test.mjs +0 -258
- 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
|
+
}
|