@excaliburjs/plugin-pathfinding
Version:
excalibur-pathfinding provides ability to use A* and Dijkstra's Algorithm for pathfinding and with Excalibur.js
491 lines (424 loc) • 16.6 kB
text/typescript
import { Loadable, Tile, TileMap } from "excalibur";
export type GraphNode = {
id: number | string;
value?: any;
};
export type aStarNode = {
id: number | string;
collider: boolean;
gCost: number;
hCost: number;
fCost: number;
x: number;
y: number;
checked: boolean;
parent: aStarNode | null;
};
export type Edge = {
name: string;
from: GraphNode;
to: GraphNode;
value?: number;
};
export type GraphTile = {
name?: string;
index?: number;
coordinates?: { x: number; y: number };
data?: any;
collider?: boolean;
};
export type GraphTileMap = {
name: string;
tiles: Array<GraphTile>;
rows: number;
cols: number;
};
export class ExcaliburGraph {
duration: number = 0;
starttime: number = 0;
endtime: number = 0;
key: boolean = false;
nodes: Map<string, GraphNode> = new Map();
edges: Map<string, Edge> = new Map();
addNode(node: GraphNode) {
this.nodes.set(typeof node.id === "number" ? node.id.toString() : node.id, node);
}
addEdge(edge: Edge, bidirectional = false) {
this.edges.set(edge.name, edge);
if (bidirectional) {
let newEdge = { name: `${edge.name}_reverse`, from: edge.to, to: edge.from };
if (edge.value) Object.assign(newEdge, { value: edge.value });
this.addEdge(newEdge);
}
}
resetGraph() {
this.nodes = new Map();
this.edges = new Map();
}
addTileMap(tilemap: GraphTileMap, diagonal = false) {
tilemap.tiles.forEach((tile, index) => {
//tile.collider === true ? this.addNode({ name: `${index}`, value: 1 }) : this.addNode({ name: `${index}`, value: 0 });
if (!tile.collider) {
this.addNode({ id: `${index}`, value: 0 });
}
});
//loop over tilemap and find each tile's neighbors
//add edges to the adjacency list
tilemap.tiles.forEach((tile, index) => {
//get neighbors
if (tilemap.tiles[index].collider) return;
type Neighbors = {
index: number;
diagonal: boolean;
};
let neighbors: Neighbors[] = [];
if (tilemap.tiles[index - 1] && index % tilemap.cols != 0) neighbors.push({ index: index - 1, diagonal: false });
if (tilemap.tiles[index + 1] && index % tilemap.cols != tilemap.cols - 1) neighbors.push({ index: index + 1, diagonal: false });
if (tilemap.tiles[index - tilemap.cols]) neighbors.push({ index: index - tilemap.cols, diagonal: false });
if (tilemap.tiles[index + tilemap.cols]) neighbors.push({ index: index + tilemap.cols, diagonal: false });
if (diagonal) {
if (tilemap.tiles[index - tilemap.cols - 1] && index % tilemap.cols != 0)
neighbors.push({ index: index - tilemap.cols - 1, diagonal: true });
if (tilemap.tiles[index - tilemap.cols + 1] && index % tilemap.cols != tilemap.cols - 1)
neighbors.push({ index: index - tilemap.cols + 1, diagonal: true });
if (tilemap.tiles[index + tilemap.cols - 1] && index % tilemap.cols != 0)
neighbors.push({ index: index + tilemap.cols - 1, diagonal: true });
if (tilemap.tiles[index + tilemap.cols + 1] && index % tilemap.cols != tilemap.cols - 1)
neighbors.push({ index: index + tilemap.cols + 1, diagonal: true });
}
//check value for 1
neighbors.forEach(neighbor => {
if (tilemap.tiles[neighbor.index].collider != true) {
this.addEdge({
name: `${index}_${neighbor.index}`,
from: this.nodes.get(`${index}`)!,
to: this.nodes.get(`${neighbor.index}`)!,
value: neighbor.diagonal ? 1.41 : 1,
});
}
});
});
}
getNodes() {
return this.nodes;
}
getEdges() {
return this.edges;
}
getAdjacentNodes(node: GraphNode): GraphNode[] {
const adjacentNodes = this.getAdjacentEdges(node).map(edge => edge.to);
const uniqueNames = Array.from(new Set(adjacentNodes.map(node => node.id)));
return uniqueNames.map(name => this.nodes.get(typeof name === "number" ? name.toString() : name)!);
}
getAdjacentEdges(node: GraphNode) {
return [...this.edges.values()].filter(edge => edge.from === node).map(edge => edge);
}
getAdjacentEdgesTo(node: GraphNode) {
return [...this.edges.values()].filter(edge => edge.to === node).map(edge => edge);
}
bfs(startnode: GraphNode, endnode: GraphNode): boolean {
this.starttime = performance.now();
const queue = [startnode];
const visited = new Set();
while (queue.length > 0) {
const current = queue.shift()!;
if (!visited.has(current)) {
visited.add(current);
queue.push(...this.getAdjacentNodes(current));
}
if (current === endnode) {
this.endtime = performance.now() - this.starttime;
this.duration = this.endtime;
return true;
}
}
this.endtime = performance.now() - this.starttime;
this.duration = this.endtime;
return false;
}
dfs(startnode: GraphNode, endnode: GraphNode, visited = new Set()): boolean {
this.starttime = performance.now();
const stack = [startnode];
visited.add(startnode);
const adjacentNodes = this.getAdjacentNodes(startnode);
for (const node of adjacentNodes) {
if (node === endnode) {
return true;
}
if (!visited.has(node)) {
if (this.dfs(node, endnode, visited)) {
this.endtime = performance.now() - this.starttime;
this.duration = this.endtime;
return true;
}
}
}
this.endtime = performance.now() - this.starttime;
this.duration = this.endtime;
return false;
}
dijkstra(sourcenode: GraphNode): Array<{ node: GraphNode; distance: number; previous: GraphNode | null }> {
this.starttime = performance.now();
const visited: GraphNode[] = [];
const unvisited: GraphNode[] = [];
const resultArray: Array<{ node: GraphNode; distance: number; previous: GraphNode | null }> = [];
//fill unvisited
this.nodes.forEach(node => unvisited.push(node));
//fill resultArray
this.nodes.forEach(node => resultArray.push({ node, distance: Infinity, previous: null }));
//start with starting node
//add starting node to result array
const startingNodeIndex = resultArray.findIndex(node => node.node === sourcenode);
if (startingNodeIndex === -1) return [];
resultArray[startingNodeIndex].distance = 0;
visited.push(sourcenode);
unvisited.splice(unvisited.indexOf(sourcenode), 1);
let current = sourcenode;
let currentEdges = this.getAdjacentEdges(current);
//update result array with distances, which is edge values
for (let i = 0; i < currentEdges.length; i++) {
const edge = currentEdges[i];
const index = resultArray.findIndex(node => node.node === edge.to);
if (startingNodeIndex === -1) return [];
resultArray[index].distance = edge.value as number;
resultArray[index].previous = current;
}
while (unvisited.length > 0) {
//get list of unvisited available nodes
let listOfAvailableNodes: GraphNode[] = [];
let listofAvailableEntries: Array<{ node: GraphNode; distance: number; previous: GraphNode | null }> = [];
listofAvailableEntries = resultArray.filter(node => unvisited.includes(node.node));
listOfAvailableNodes = listofAvailableEntries.map(node => node.node);
//loop through available nodes and find lowest distance to sourcenode
let lowestDistance = Infinity;
let lowestDistanceIndex = -1;
if (listOfAvailableNodes.length > 0) {
for (let i = 0; i < listOfAvailableNodes.length; i++) {
const unVisitedNode = listOfAvailableNodes[i];
let index = resultArray.findIndex(node => node.node === unVisitedNode);
if (resultArray[index].distance < lowestDistance) {
lowestDistance = resultArray[index].distance;
lowestDistanceIndex = index;
}
}
} else {
//manage exception
//choose node from unvisited list that has lowest distance to source node
lowestDistance = Infinity;
lowestDistanceIndex = -1;
for (let i = 0; i < unvisited.length; i++) {
const unVisitedNode = unvisited[i];
let index = resultArray.findIndex(node => node.node === unVisitedNode);
if (resultArray[index].distance < lowestDistance) {
lowestDistance = resultArray[index].distance;
lowestDistanceIndex = index;
}
}
}
// add test for -1 index representing unreachable node
if (lowestDistanceIndex === -1) {
break;
}
current = resultArray[lowestDistanceIndex].node;
currentEdges = this.getAdjacentEdges(current);
//remove visited from currentEdges
currentEdges = currentEdges.filter(edge => {
return !visited.includes(edge.from) && !visited.includes(edge.to);
});
visited.push(current);
unvisited.splice(unvisited.indexOf(current), 1);
//update result array with distances, which is edge values
for (let i = 0; i < currentEdges.length; i++) {
const edge = currentEdges[i];
const index = resultArray.findIndex(node => node.node === edge.to);
//update cumulative distances
const previousIndex = resultArray.findIndex(node => node.node === edge.from);
const previousDistance = resultArray[previousIndex].distance;
const cumDistance = (previousDistance + edge.value!) as number;
if (cumDistance < resultArray[index].distance) {
resultArray[index].distance = cumDistance;
resultArray[index].previous = current;
}
}
}
this.endtime = performance.now() - this.starttime;
this.duration = this.endtime;
return resultArray;
}
shortestPath(startnode: GraphNode, endnode: GraphNode): GraphNode[] {
let dAnalysis = this.dijkstra(startnode);
//iterate through dAnalysis to plot shortest path to endnode
let path: GraphNode[] = [];
let current: GraphNode | null | undefined = endnode;
while (current != null) {
path.push(current);
current = dAnalysis.find(node => node.node == current)?.previous;
if (current == null) {
break;
}
}
path.reverse();
return path;
}
}
export class ExcaliburAStar {
tilemap = {
cols: 0,
rows: 0,
};
grid: aStarNode[] = [];
currentNode: aStarNode | null = null;
currentIndex = 0;
checkedNodes: aStarNode[] = [];
openNodes: aStarNode[] = [];
startnode: aStarNode | null = null;
endnode: aStarNode | null = null;
goalReached: boolean = false;
path: aStarNode[] = [];
duration: number = 0;
starttime: number = 0;
endtime: number = 0;
constructor(tilemap: TileMap | GraphTileMap) {
this.tilemap.cols = tilemap instanceof TileMap ? tilemap.columns : tilemap.cols;
this.tilemap.rows = tilemap.rows;
let tileIndex = 0;
for (const tile of tilemap.tiles) {
if (tilemap instanceof TileMap) {
this.grid.push({
id: tileIndex,
collider: (tile as Tile).solid ? true : false,
gCost: 0,
hCost: 0,
fCost: 0,
x: tileIndex % this.tilemap.cols,
y: Math.floor(tileIndex / this.tilemap.cols),
checked: false,
parent: null,
});
tileIndex++;
} else {
this.grid.push({
id: tileIndex,
collider: (tile as GraphTile).collider ? true : false,
gCost: 0,
hCost: 0,
fCost: 0,
x: tileIndex % this.tilemap.cols,
y: Math.floor(tileIndex / this.tilemap.cols),
checked: false,
parent: null,
});
tileIndex++;
}
}
}
setCost() {
if (this.startnode === null || this.endnode === null) return;
if (this.grid.length === 0) return;
for (const tile of this.grid) {
tile.gCost = this.getGCost(tile, this.startnode);
tile.hCost = this.getHCost(tile, this.endnode);
tile.fCost = this.getFCost(tile);
}
}
astar(sourcenode: aStarNode, endnode: aStarNode, diagonal = false): aStarNode[] {
this.starttime = performance.now();
this.startnode = sourcenode;
this.endnode = endnode;
this.goalReached = false;
this.checkedNodes = [];
this.openNodes = [];
this.setCost();
this.openNodes.push(this.startnode);
while (this.openNodes.length > 0) {
this.currentNode = this.openNodes[0];
this.currentIndex = 0;
// get lowest fCost in openNodes
for (const node of this.openNodes) {
if (node.fCost < this.currentNode.fCost) {
this.currentNode = node;
this.currentIndex = this.openNodes.indexOf(node);
}
}
//remove current node from openNodes
this.openNodes.splice(this.currentIndex, 1);
//add to checked
this.checkedNodes.push(this.currentNode);
if (this.currentNode === this.endnode) {
this.path = [];
do {
this.path.push(this.currentNode as aStarNode);
this.currentNode = (this.currentNode as aStarNode).parent;
} while (this.currentNode !== this.startnode);
this.path = this.path.reverse();
this.goalReached = true;
this.endtime = performance.now();
this.duration = this.endtime - this.starttime;
return this.path;
}
const neighbors = this.getNeighbors(this.currentNode, diagonal);
for (const neighbor of neighbors) {
if (!this.checkedNodes.includes(neighbor)) {
if (!this.openNodes.includes(neighbor) && !neighbor.collider) {
neighbor.parent = this.currentNode;
this.openNodes.push(neighbor);
}
}
}
}
this.endtime = performance.now();
this.duration = this.endtime - this.starttime;
return [];
}
getNeighbors(node: aStarNode, diagonal: boolean): aStarNode[] {
const neighbors: aStarNode[] = [];
const index: number = typeof node.id === "string" ? parseInt(node.id) : node.id;
//get neighbors
//left/right tiles
if (this.grid[index - 1] && index % this.tilemap.cols != 0) neighbors.push(this.grid[index - 1]);
if (this.grid[index + 1] && index % this.tilemap.cols != this.tilemap.cols - 1) neighbors.push(this.grid[index + 1]);
//up/down tiles
if (this.grid[index - this.tilemap.cols]) neighbors.push(this.grid[index - this.tilemap.cols]);
if (this.grid[index + this.tilemap.cols]) neighbors.push(this.grid[index + this.tilemap.cols]);
//if diagonal
if (diagonal) {
if (this.grid[index - this.tilemap.cols - 1] && index % this.tilemap.cols != 0)
neighbors.push(this.grid[index - this.tilemap.cols - 1]);
if (this.grid[index - this.tilemap.cols + 1] && index % this.tilemap.cols != this.tilemap.cols - 1)
neighbors.push(this.grid[index - this.tilemap.cols + 1]);
if (this.grid[index + this.tilemap.cols - 1] && index % this.tilemap.cols != 0)
neighbors.push(this.grid[index + this.tilemap.cols - 1]);
if (this.grid[index + this.tilemap.cols + 1] && index % this.tilemap.cols != this.tilemap.cols - 1)
neighbors.push(this.grid[index + this.tilemap.cols + 1]);
}
return neighbors;
}
getNodeByIndex(index: number): aStarNode {
return this.grid[index];
}
getNodeByCoord(x: number, y: number): aStarNode {
return this.grid[y * this.tilemap.cols + x];
}
getGCost(node: aStarNode, startnode: aStarNode): number {
return Math.abs(node.x - startnode.x) + Math.abs(node.y - startnode.y);
}
getHCost(node: aStarNode, endnode: aStarNode): number {
return Math.abs(node.x - endnode.x) + Math.abs(node.y - endnode.y);
}
getFCost(node: aStarNode): number {
return node.gCost + node.hCost;
}
resetGrid() {
for (const tile of this.grid) {
tile.checked = false;
tile.gCost = 0;
tile.hCost = 0;
tile.fCost = 0;
}
this.checkedNodes = [];
this.startnode = null;
this.endnode = null;
this.goalReached = false;
this.duration = 0;
}
}