/**
* LittleJS PathFinder Plugin
* - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
* - Works directly on a TileCollisionLayer, or override isWalkable/getCost for any grid
* - Debug visualization via engine debug primitives (stripped in release builds)
* - Port of frankforce.com pathFindingBase.cpp (2018)
* @namespace PathFinding
*/
'use strict';
///////////////////////////////////////////////////////////////////////////////
// Diagonal step cost — pre-computed for the A* expansion inner loop.
const PATHFINDER_DIAGONAL_COST = Math.SQRT2;
// Shared 1x1 size vector for per-tile debugRect calls. debugRect copies the
// argument internally, so reusing one instance is safe.
const PATHFINDER_TILE_VEC = vec2(1);
///////////////////////////////////////////////////////////////////////////////
/** A single grid cell tracked by the pathfinder. Allocated once per cell at
* PathFinder construction; reset (not reallocated) at the start of every
* findPath call.
* @memberof PathFinding */
class PathFinderNode
{
/** @param {number} x - Tile x
* @param {number} y - Tile y */
constructor(x, y)
{
/** @property {Vector2} - Tile coords (integer) */
this.pos = vec2(x, y);
/** @property {Vector2} - World-space center of this tile (set by buildNodeData) */
this.posWorld = vec2();
/** @property {boolean} - True if this cell is passable (cleared each findPath call) */
this.walkable = false;
/** @property {number} - Extra cost added to A* G-score for stepping on this cell */
this.cost = 0;
/** @property {number} - A* G-score: actual cost from start to this node */
this.g = 0;
/** @property {number} - A* F-score: G + heuristic */
this.f = 0;
/** @property {PathFinderNode|null} - Parent for path reconstruction */
this.parent = null;
/** @property {boolean} - In the A* open list */
this.isOpen = false;
/** @property {boolean} - In the A* closed list */
this.isClosed = false;
}
/** Reset per-search state (called at the start of buildNodeData). */
reset()
{
this.walkable = false;
this.cost = 0;
this.g = 0;
this.f = 0;
this.parent = null;
this.isOpen = false;
this.isClosed = false;
}
/** True if walkable and not blocked by cost. */
isClear()
{
return this.walkable && this.cost === 0;
}
}
///////////////////////////////////////////////////////////////////////////////
/** Grid pathfinder using A* with two optional smoothing passes.
* @memberof PathFinding
* @example
* // Tile-layer driven (most common):
* const pf = new PathFinder(myTileCollisionLayer);
* const path = pf.findPath(player.pos, mousePos);
*
* // Bare grid with custom walkability:
* const pf = new PathFinder(vec2(50, 50));
* pf.isWalkable = (x, y) => myGrid[y*50 + x] === 0;
*/
class PathFinder
{
/** @param {TileCollisionLayer|Vector2} source - Either a TileCollisionLayer
* (size and walkability auto-derived) or a Vector2 grid size (user
* overrides isWalkable). */
constructor(source)
{
// Accept either a Vector2 size or a TileCollisionLayer (which has a .size).
// We don't import TileCollisionLayer to avoid coupling; we duck-type on
// .size + .getCollisionData.
if (isVector2(source))
{
this.size = source.floor();
this.tileLayer = undefined;
}
else
{
ASSERT(source && isVector2(source.size) && typeof source.getCollisionData === 'function',
'PathFinder requires a Vector2 size or a TileCollisionLayer');
this.size = source.size;
this.tileLayer = source;
}
// Tunables (public, freely re-assignable).
this.heuristicWeight = 1;
this.maxLoop = 500;
this.smoothPath = true;
this.debug = false;
this.debugTime = 2;
// Pre-allocate the node array — one node per tile, reused across calls.
this.nodes = new Array(this.size.x * this.size.y);
for (let y = 0; y < this.size.y; ++y)
for (let x = 0; x < this.size.x; ++x)
this.nodes[x + y * this.size.x] = new PathFinderNode(x, y);
// Scratch Vector2 reused to avoid allocations in the isWalkable hot path.
this.collisionScratch = vec2();
}
/** Default walkability: if a tile layer was provided, returns true when the
* cell has no solid collision data; otherwise returns true. Override on
* the instance or via a subclass.
* @param {number} x - Tile x
* @param {number} y - Tile y
* @returns {boolean} */
isWalkable(x, y)
{
if (!this.tileLayer) return true;
return !this.tileLayer.getCollisionData(this.collisionScratch.set(x, y));
}
/** Default extra cost for stepping on a cell. Returns 0 (free) by default.
* Override to add cost-weighted terrain (mud, swamp, etc).
* @param {number} x - Tile x
* @param {number} y - Tile y
* @returns {number} */
getCost(x, y)
{
return 0;
}
/** Get the node at tile coords, or null if out of bounds.
* @param {number} x
* @param {number} y
* @returns {PathFinderNode|null} */
getNode(x, y)
{
if (x < 0 || y < 0 || x >= this.size.x || y >= this.size.y) return null;
return this.nodes[x + y * this.size.x];
}
/** Convert a world-space position to integer tile coords (no clamping).
* @param {Vector2} worldPos
* @returns {Vector2}
* @memberof PathFinding */
worldToTile(worldPos)
{
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
return vec2(floor(worldPos.x - ox), floor(worldPos.y - oy));
}
/** Convert integer tile coords to the world-space center of that tile.
* @param {number} x
* @param {number} y
* @returns {Vector2}
* @memberof PathFinding */
tileToWorld(x, y)
{
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
return vec2(x + 0.5 + ox, y + 0.5 + oy);
}
/** Reset all nodes and re-populate walkable / cost / posWorld from the
* current isWalkable / getCost overrides. Called at the start of
* findPath; exposed so tests and tooling can drive it directly.
* @private */
buildNodeData()
{
const w = this.size.x;
const h = this.size.y;
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
for (let y = 0; y < h; ++y)
for (let x = 0; x < w; ++x)
{
const node = this.nodes[x + y * w];
node.reset();
const walkable = !!this.isWalkable(x, y);
const cost = walkable ? max(0, this.getCost(x, y)) : 0;
node.walkable = walkable;
node.cost = cost;
node.posWorld.set(x + 0.5 + ox, y + 0.5 + oy);
if (this.debug && this.debugTime > 0)
{
if (!walkable)
debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, 0.25), this.debugTime);
else if (cost > 0)
debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, min(0.2, cost * 0.05)), this.debugTime);
}
}
}
/** Core A* search loop. Expects buildNodeData() to have been called first.
* Marks node.parent for path reconstruction. Returns true if endNode was
* reached; false on disconnected goal or maxLoop exhaustion.
* @param {PathFinderNode} startNode
* @param {PathFinderNode} endNode
* @returns {boolean}
* @private */
aStarSearch(startNode, endNode)
{
ASSERT(startNode && endNode, 'aStarSearch needs both endpoints');
ASSERT(startNode !== endNode, 'aStarSearch: start and end must differ — caller should handle trivial case');
ASSERT(startNode.walkable && endNode.walkable, 'aStarSearch: endpoints must be walkable');
const openList = [startNode];
startNode.isOpen = true;
let loopCount = 0;
while (openList.length > 0)
{
// Find the open node with the smallest f score (linear scan).
// Same as the C++ — fine up to a few thousand nodes.
let bestIndex = 0;
let bestF = openList[0].f;
for (let i = 1; i < openList.length; ++i)
{
if (openList[i].f < bestF)
{
bestF = openList[i].f;
bestIndex = i;
}
}
const current = openList[bestIndex];
if (current === endNode) break;
if (++loopCount > this.maxLoop) break;
// Move current from open to closed.
current.isOpen = false;
openList.splice(bestIndex, 1);
current.isClosed = true;
if (this.debug && this.debugTime > 0)
debugRect(current.posWorld, PATHFINDER_TILE_VEC, rgb(1, 1, 1, 0.05), this.debugTime);
// Expand all 8 neighbors.
for (let dy = -1; dy <= 1; ++dy)
for (let dx = -1; dx <= 1; ++dx)
{
if (dx === 0 && dy === 0) continue;
const neighbor = this.getNode(current.pos.x + dx, current.pos.y + dy);
if (!neighbor || !neighbor.walkable || neighbor.isClosed) continue;
let stepCost = 1;
if (dx !== 0 && dy !== 0)
{
// Diagonal step: refuse if either cardinal neighbor is
// blocked or has cost. Prevents cutting through corners.
const card1 = this.getNode(current.pos.x + dx, current.pos.y);
if (!card1 || card1.cost > 0 || !card1.walkable) continue;
const card2 = this.getNode(current.pos.x, current.pos.y + dy);
if (!card2 || card2.cost > 0 || !card2.walkable) continue;
stepCost = PATHFINDER_DIAGONAL_COST;
}
const tentativeG = current.g + stepCost + neighbor.cost;
if (!neighbor.isOpen)
{
neighbor.isOpen = true;
openList.push(neighbor);
}
else if (tentativeG >= neighbor.g)
{
continue;
}
// Best path so far through neighbor — record it.
neighbor.parent = current;
neighbor.g = tentativeG;
const gdx = endNode.pos.x - neighbor.pos.x;
const gdy = endNode.pos.y - neighbor.pos.y;
neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
}
}
return endNode.parent !== null;
}
/** Find the clear (walkable, zero-cost) node closest to the given world
* position. Spirals outward in expanding boxes until a clear node is
* found or the search range is exhausted. Useful for snapping a click
* or NPC spawn position to the nearest open tile.
*
* By default, calls `buildNodeData()` first so it works correctly on a
* fresh PathFinder. If you're calling it many times in a row with
* unchanged walkability, pass `rebuild=false` and call `buildNodeData()`
* once externally to avoid redundant work.
* @param {Vector2} worldPos
* @param {number} [searchRange=10] - Max box-radius in tiles
* @param {boolean} [rebuild=true] - Whether to call buildNodeData first
* @returns {PathFinderNode|null}
* @memberof PathFinding */
getNearestClearNode(worldPos, searchRange = 10, rebuild = true)
{
ASSERT(isVector2(worldPos), 'worldPos must be a Vector2');
if (rebuild) this.buildNodeData();
// Inline worldToTile to avoid a Vector2 allocation per call.
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
const centerX = floor(worldPos.x - ox);
const centerY = floor(worldPos.y - oy);
for (let offset = 0; offset <= searchRange; ++offset)
{
let nearest = null;
let nearestDistSq = 0;
for (let dy = -offset; dy <= offset; ++dy)
for (let dx = -offset; dx <= offset; ++dx)
{
// Only scan the perimeter of the current ring (skip the
// interior we've already searched in earlier iterations).
if (offset > 0 && abs(dx) !== offset && abs(dy) !== offset)
continue;
const node = this.getNode(centerX + dx, centerY + dy);
if (!node || !node.isClear()) continue;
const ddx = node.posWorld.x - worldPos.x;
const ddy = node.posWorld.y - worldPos.y;
const distSq = ddx * ddx + ddy * ddy;
if (!nearest || distSq < nearestDistSq)
{
nearest = node;
nearestDistSq = distSq;
}
}
if (nearest) return nearest;
}
return null;
}
/** Smooth a node path by removing redundant turns and tightening corners
* where a grid-aligned diagonal is clear. Modifies the path in place.
* Stays on the grid — does not introduce off-tile-center points.
* Port of ShortenPath() in pathFinding.cpp.
* @param {PathFinderNode[]} path
* @private */
smoothPathCorners(path)
{
if (path.length <= 2) return;
let i = 1;
while (i < path.length - 1)
{
const prev = path[i - 1];
const node = path[i];
const next = path[i + 1];
const dx = next.pos.x - prev.pos.x;
const dy = next.pos.y - prev.pos.y;
const lenSq = dx * dx + dy * dy;
// dx,dy is the prev-to-current step direction; needed for the
// 135° "mostly vertical/horizontal" disambiguation.
const stepDx = node.pos.x - prev.pos.x;
const stepDy = node.pos.y - prev.pos.y;
const stepDxNext = next.pos.x - node.pos.x;
const stepDyNext = next.pos.y - node.pos.y;
if (lenSq === 1)
{
// 45° angle — middle node is off the straight line. Drop it.
if (this.debug && this.debugTime > 0)
debugCircle(node.posWorld, 0.3, rgb(0.5, 0, 0.5, 0.5), this.debugTime);
path.splice(i, 1);
i = max(1, i - 1);
continue;
}
else if (lenSq === 2)
{
// 90° corner. Check the alternative-diagonal cell.
if (this.debug && this.debugTime > 0)
debugCircle(node.posWorld, 0.3, rgb(1, 0, 0, 0.5), this.debugTime);
let sx, sy;
if (prev.pos.y === node.pos.y && next.pos.x === node.pos.x)
{ sx = prev.pos.x; sy = next.pos.y; }
else
{ sx = next.pos.x; sy = prev.pos.y; }
const shortcut = this.getNode(sx, sy);
if (shortcut && shortcut.isClear())
{
path.splice(i, 1);
i = max(1, i - 1);
continue;
}
}
else if (lenSq === 5)
{
// 135° angle (a knight's-move offset). Try to relocate the
// middle node to whichever of two candidate cells is closer
// to prev-of-prev, and only if the corner cut is also clear.
if (this.debug && this.debugTime > 0)
debugCircle(node.posWorld, 0.3, rgb(1, 1, 0, 0.5), this.debugTime);
const prevPrev = i >= 2 ? path[i - 2] : prev;
let s1x, s1y, s2x, s2y;
if (stepDx === 0 || stepDxNext === 0)
{
// mostly vertical
s1x = next.pos.x; s1y = node.pos.y;
s2x = prev.pos.x; s2y = node.pos.y;
}
else
{
// mostly horizontal
s1x = node.pos.x; s1y = next.pos.y;
s2x = node.pos.x; s2y = prev.pos.y;
}
const dd1x = s1x - prevPrev.pos.x;
const dd1y = s1y - prevPrev.pos.y;
const dd2x = s2x - prevPrev.pos.x;
const dd2y = s2y - prevPrev.pos.y;
const dist1Sq = dd1x * dd1x + dd1y * dd1y;
const dist2Sq = dd2x * dd2x + dd2y * dd2y;
const sx = dist1Sq < dist2Sq ? s1x : s1x === s2x && s1y === s2y ? s1x : s2x;
const sy = dist1Sq < dist2Sq ? s1y : s1x === s2x && s1y === s2y ? s1y : s2y;
const shortcut = this.getNode(sx, sy);
if (shortcut && shortcut !== node && shortcut.isClear())
{
// Also check the cut-corner cell is clear.
const ccx = next.pos.x + s2x - s1x;
const ccy = next.pos.y + s2y - s1y;
const cutCorner = this.getNode(ccx, ccy);
if (cutCorner && cutCorner.isClear())
{
path[i] = shortcut;
i = max(1, i - 1);
continue;
}
}
}
else if (lenSq === 4 || lenSq === 8)
{
// Straight line or a 1-cell bump.
if (this.debug && this.debugTime > 0)
debugCircle(node.posWorld, 0.3, rgb(0, 1, 0, 0.5), this.debugTime);
if (stepDx === stepDxNext && stepDy === stepDyNext)
{
// Truly straight — nothing to do, advance.
++i;
continue;
}
else
{
// Bump — try to flatten via the in-line cell.
let sx, sy;
if (prev.pos.y === next.pos.y)
{ sx = node.pos.x; sy = prev.pos.y; }
else
{ sx = prev.pos.x; sy = node.pos.y; }
const shortcut = this.getNode(sx, sy);
if (shortcut && shortcut.isClear())
{
path[i] = shortcut;
i = max(1, i - 1);
continue;
}
}
}
++i;
}
}
/** Smooth a node path via line-of-sight ("string pulling"). Walks the
* input path collapsing runs of nodes into straight segments whenever
* isLineClear permits, so the result can leave grid centers and cut
* cleanly across open spaces.
*
* Bails (leaves the path unchanged) if any node has nonzero cost — a
* straight geometric shortcut can't be trusted to be the lowest-cost
* route when cost-weighted terrain is in play.
*
* Port of ShortenPath2() in pathFinding.cpp.
* @param {PathFinderNode[]} path
* @private */
smoothPathStringPull(path)
{
if (path.length <= 2) return;
for (const n of path)
{
if (!n.isClear()) return;
}
const original = path.slice();
path.length = 0;
path.push(original[0]);
let searchIndex = 0;
for (let i = 1; i < original.length; ++i)
{
const node = original[i];
// Skip if node is collinear with the search-window start and the
// previous node — it adds no information. Note: a == b is the
// degenerate i=1, searchIndex=0 case; skip the test then.
{
const a = original[searchIndex];
const b = original[i - 1];
if (a !== b)
{
const cross =
(b.pos.x - a.pos.x) * (node.pos.y - a.pos.y) -
(b.pos.y - a.pos.y) * (node.pos.x - a.pos.x);
if (cross === 0) continue;
}
}
if (!this.isLineClear(node.pos, path[path.length - 1].pos))
{
// Look ahead — if any later node has a clear shot to the
// back of our new path, skip this node and try later.
let foundClearAfter = false;
for (let j = i + 1; j < original.length; ++j)
{
if (this.isLineClear(original[j].pos, path[path.length - 1].pos))
{
foundClearAfter = true;
break;
}
}
if (foundClearAfter)
{
if (this.debug && this.debugTime > 0)
debugLine(node.posWorld, path[path.length - 1].posWorld, rgb(0, 0, 1, 0.3), 0.02, this.debugTime);
continue;
}
// No clear line ahead — fall back to the last waypoint we did
// have a clear line to. searchIndex tracks our scan position.
for (; searchIndex < original.length; ++searchIndex)
{
const cand = original[searchIndex];
if (this.isLineClear(node.pos, cand.pos))
{
path.push(cand);
i = searchIndex;
break;
}
}
ASSERT(searchIndex < original.length, 'smoothPathStringPull: ran out of candidates');
}
}
path.push(original[original.length - 1]);
}
/** Lookup helper: true when the node at tile coords (x, y) is in-bounds
* and clear (walkable, zero-cost). Used by isLineClear's hot path.
* @param {number} x
* @param {number} y
* @returns {boolean}
* @private */
isNodeClear(x, y)
{
const n = this.getNode(x, y);
return n !== null && n.isClear();
}
/** Check that the line between two tile-coord endpoints stays entirely
* inside walkable, zero-cost cells. Stricter than just sampling along
* the line — it also checks the diagonal-corner-adjacent cells so the
* line can never "scrape past" a wall corner.
*
* Both endpoints must themselves be clear (asserted in debug). Port of
* CheckLine() in pathFinding.cpp.
* @param {Vector2} startPos - Tile coords
* @param {Vector2} endPos - Tile coords
* @returns {boolean}
* @private */
isLineClear(startPos, endPos)
{
ASSERT(isVector2(startPos) && isVector2(endPos), 'isLineClear needs Vector2 endpoints');
ASSERT(this.isNodeClear(startPos.x, startPos.y) && this.isNodeClear(endPos.x, endPos.y),
'isLineClear endpoints must be in-bounds and clear');
const dx = endPos.x - startPos.x;
const dy = endPos.y - startPos.y;
const adx = abs(dx);
const ady = abs(dy);
const sx = sign(dx);
const sy = sign(dy);
let x = startPos.x;
let y = startPos.y;
if (ady === adx)
{
// Pure diagonal.
while (x !== endPos.x)
{
if (x !== startPos.x)
{
if (!this.isNodeClear(x, y)) return false;
if (!this.isNodeClear(x, y - sy)) return false;
}
if (!this.isNodeClear(x, y + sy)) return false;
x += sx;
y += sy;
}
if (!this.isNodeClear(endPos.x, endPos.y - sy)) return false;
}
else if (ady < adx)
{
// Mostly horizontal.
if (dy === 0)
{
// Purely horizontal.
x += sx;
while (x !== endPos.x)
{
if (!this.isNodeClear(x, y)) return false;
x += sx;
}
}
else
{
let lastY = startPos.y;
while (x !== endPos.x)
{
y = startPos.y + Math.trunc((dy * (x - startPos.x)) / dx);
if (lastY !== y)
{
if (!this.isNodeClear(x - sx, y + sy)) return false;
if (!this.isNodeClear(x, y - sy)) return false;
}
lastY = y;
if (x !== startPos.x)
{
if (!this.isNodeClear(x, y)) return false;
}
y += sy;
if (!this.isNodeClear(x, y)) return false;
x += sx;
}
const finalY = endPos.y - sy;
if (!this.isNodeClear(endPos.x, finalY)) return false;
}
}
else
{
// Mostly vertical.
if (dx === 0)
{
y += sy;
while (y !== endPos.y)
{
if (!this.isNodeClear(x, y)) return false;
y += sy;
}
}
else
{
let lastX = startPos.x;
while (y !== endPos.y)
{
x = startPos.x + Math.trunc((dx * (y - startPos.y)) / dy);
if (lastX !== x)
{
if (!this.isNodeClear(x + sx, y - sy)) return false;
if (!this.isNodeClear(x - sx, y)) return false;
}
lastX = x;
if (y !== startPos.y)
{
if (!this.isNodeClear(x, y)) return false;
}
x += sx;
if (!this.isNodeClear(x, y)) return false;
y += sy;
}
const finalX = endPos.x - sx;
if (!this.isNodeClear(finalX, endPos.y)) return false;
}
}
return true;
}
/** Find a path from startPos to endPos in world space. Returns an array
* of world-space Vector2 points; empty array if no path exists.
*
* Start and end are snapped to the nearest walkable tile via
* getNearestClearNode. Intermediate points are tile centers unless the
* string-pulling smoothing pass moves them off-grid.
* @param {Vector2} startPos - World-space start
* @param {Vector2} endPos - World-space end
* @returns {Vector2[]}
* @memberof PathFinding */
findPath(startPos, endPos)
{
ASSERT(isVector2(startPos) && isVector2(endPos), 'findPath needs Vector2 endpoints');
this.buildNodeData();
// rebuild=false because we just built — avoid redundant work per snap.
const startNode = this.getNearestClearNode(startPos, 10, false);
const endNode = this.getNearestClearNode(endPos, 10, false);
if (!startNode || !endNode) return [];
// Trivial case: start and end snapped to the same tile.
if (startNode === endNode) return [startNode.posWorld.copy()];
if (!this.aStarSearch(startNode, endNode)) return [];
// Walk back from endNode via parent pointers, then reverse — cheaper
// than unshifting on every step.
const nodePath = [];
for (let n = endNode; n; n = n.parent)
nodePath.push(n);
nodePath.reverse();
if (this.smoothPath)
{
this.smoothPathCorners(nodePath);
this.smoothPathStringPull(nodePath);
}
// Convert to world-space Vector2 path. Return copies, not live node
// references — callers shouldn't be able to mutate the grid.
const result = nodePath.map(n => n.posWorld.copy());
if (this.debug && this.debugTime > 0 && result.length > 0)
{
for (let i = 1; i < result.length; ++i)
debugLine(result[i - 1], result[i], RED, 0.1, this.debugTime);
for (const p of result)
debugCircle(p, 0.5, rgb(1, 0, 0, 0.3), this.debugTime);
debugCircle(result[0], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
debugCircle(result[result.length - 1], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
}
return result;
}
}