navmesh
Version:
A library for fast pathfinding using navigation meshes in JS
366 lines (330 loc) • 12.4 kB
text/typescript
import { Point, PolyPoints } from "../common-types";
import { isTruthy } from "../utils";
import { GridMap, TileWalkableTest } from "./grid-map";
import { PointQueue } from "./point-queue";
import { RectangleHull } from "./rectangle-hull";
type CardinalDirection = "top" | "bottom" | "left" | "right";
/**
* This parses a world that is a uniform grid into convex polygons (specifically rectangles) that
* can be used for building a navmesh. This is designed mainly for parsing tilemaps into polygons.
* The functions takes a 2D array that indicates which tiles are walkable and which aren't. The
* function returns PolyPoint[] that can be used to construct a NavMesh.
*
* Notes:
* - This algorithm traverses the walkable tiles in a depth-first search, combining neighbors into
* rectangular polygons. This may not produce the best navmesh, but it doesn't require any manual
* work!
* - This assumes the world is a uniform grid. It should work for any tile size, provided that all
* tiles are the same width and height.
*
* @param map 2D array of any type.
* @param tileWidth The width of each tile in the grid.
* @param tileHeight The height of each tile in the grid.
* @param isWalkable Function that is used to test if a specific location in the map is walkable.
* Defaults to assuming "truthy" means walkable.
* @param [shrinkAmount=0] Amount to "shrink" the mesh away from the tiles. This adds more polygons
* to the generated mesh, but can be helpful for preventing agents from getting caught on edges.
* This supports values between 0 and tileWidth/tileHeight (whichever dimension is smaller).
*/
export default function buildPolysFromGridMap<TileType>(
map: TileType[][],
tileWidth: number = 1,
tileHeight: number = 1,
isWalkable: TileWalkableTest<TileType> = isTruthy,
shrinkAmount: number = 0
): PolyPoints[] {
const gridMap = new GridMap(map, isWalkable, tileWidth, tileHeight);
if (shrinkAmount >= tileWidth || shrinkAmount >= tileHeight) {
throw new Error(
`navmesh: Unsupported shrink amount ${shrinkAmount}. Must be less than tile width and height.`
);
}
let hulls: RectangleHull[] = buildInitialHulls(gridMap);
if (shrinkAmount > 0) {
hulls = shrinkHulls(hulls, gridMap, shrinkAmount);
}
return hulls.map((hull) => hull.toPoints());
}
/**
* Build up rectangular hulls from the walkable areas of a GridMap. This starts with a walkable tile
* and attempts to "grow" each of its edges to engulf its neighbors. This process repeats until the
* current hull can't engulf any neighbors.
* @param gridMap
*/
function buildInitialHulls<TileType>(gridMap: GridMap<TileType>) {
const walkableQueue = new PointQueue();
const { tileWidth, tileHeight } = gridMap;
const hulls: RectangleHull[] = [];
let currentHull;
gridMap.forEach((x, y) => {
if (gridMap.isWalkable(x, y)) walkableQueue.add({ x, y });
});
const getExtensionPoints = (hull: RectangleHull, dir: CardinalDirection) => {
const { top, left, right, bottom } = hull;
let points: Point[] = [];
if (dir === "top") {
for (let x = left; x <= right - 1; x++) points.push({ x, y: top });
} else if (dir === "bottom") {
for (let x = left; x <= right - 1; x++) points.push({ x, y: bottom });
} else if (dir === "left") {
for (let y = top; y <= bottom - 1; y++) points.push({ x: left, y });
} else if (dir === "right") {
for (let y = top; y <= bottom - 1; y++) points.push({ x: right, y });
} else {
throw new Error(`Invalid dir "${dir}" for extend`);
}
return points;
};
const extendHullInDirection = (hull: RectangleHull, dir: CardinalDirection) => {
if (dir === "top") hull.y -= 1;
else if (dir === "bottom") hull.bottom += 1;
else if (dir === "left") hull.x -= 1;
else if (dir === "right") hull.right += 1;
else throw new Error(`Invalid dir "${dir}" for extend`);
};
const attemptExtension = (hull: RectangleHull, dir: CardinalDirection) => {
const neighborPoints = getExtensionPoints(hull, dir);
const canExtend = walkableQueue.containsAllPoints(neighborPoints);
if (canExtend) {
extendHullInDirection(hull, dir);
walkableQueue.removePoints(neighborPoints);
}
return canExtend;
};
while (!walkableQueue.isEmpty()) {
// Find next colliding tile to start the algorithm.
const tile = walkableQueue.shift();
if (tile === undefined) break;
// Use tile dimensions (i.e. 1 tile wide, 1 tile tall) to simplify the checks.
currentHull = new RectangleHull(tile.x, tile.y, 1, 1);
// Check edges of bounding box to see if they can be extended.
let needsExtensionCheck = true;
while (needsExtensionCheck) {
const extendedTop = attemptExtension(currentHull, "top");
const extendedRight = attemptExtension(currentHull, "right");
const extendedLeft = attemptExtension(currentHull, "left");
const extendedBottom = attemptExtension(currentHull, "bottom");
needsExtensionCheck = extendedTop || extendedBottom || extendedLeft || extendedRight;
}
// Scale the hull up from grid dimensions to real world dimensions.
currentHull.setPosition(currentHull.x * tileWidth, currentHull.y * tileHeight);
currentHull.setSize(currentHull.width * tileWidth, currentHull.height * tileHeight);
hulls.push(currentHull);
}
return hulls;
}
// TODO: check larger than tile size. Assumes shrink <= 1 tile.
function shrinkHull<TileType>(
hull: RectangleHull,
gridMap: GridMap<TileType>,
shrinkAmount: number,
tileWidth: number,
tileHeight: number
) {
const s = shrinkAmount;
const halfWidth = tileWidth / 2;
const halfHeight = tileHeight / 2;
const { left, top, right, bottom } = hull;
const info = {
left: false,
right: false,
top: false,
bottom: false,
topLeft: gridMap.isBlockedAtWorld(left - s, top - s),
topRight: gridMap.isBlockedAtWorld(right + s, top - s),
bottomLeft: gridMap.isBlockedAtWorld(left - s, bottom + s),
bottomRight: gridMap.isBlockedAtWorld(right + s, bottom + s),
};
for (let y = top + halfHeight; y < bottom; y += halfHeight) {
if (gridMap.isBlockedAtWorld(left - s, y)) {
info.left = true;
break;
}
}
for (let y = top + halfHeight; y < bottom; y += halfHeight) {
if (gridMap.isBlockedAtWorld(right + s, y)) {
info.right = true;
break;
}
}
for (let x = left + halfWidth; x < right; x += halfWidth) {
if (gridMap.isBlockedAtWorld(x, top - shrinkAmount)) {
info.top = true;
break;
}
}
for (let x = left + halfWidth; x < right; x += halfWidth) {
if (gridMap.isBlockedAtWorld(x, bottom + shrinkAmount)) {
info.bottom = true;
break;
}
}
const shrink = {
left: info.left,
right: info.right,
top: info.top,
bottom: info.bottom,
};
if (info.topLeft && !info.left && !info.top) {
if (hull.width > hull.height) shrink.left = true;
else shrink.top = true;
}
if (info.topRight && !info.right && !info.top) {
if (hull.width > hull.height) shrink.right = true;
else shrink.top = true;
}
if (info.bottomLeft && !info.bottom && !info.left) {
if (hull.width > hull.height) shrink.left = true;
else shrink.bottom = true;
}
if (info.bottomRight && !info.bottom && !info.right) {
if (hull.width > hull.height) shrink.right = true;
else shrink.bottom = true;
}
if (shrink.left) {
hull.x += shrinkAmount;
hull.width -= shrinkAmount;
}
if (shrink.top) {
hull.y += shrinkAmount;
hull.height -= shrinkAmount;
}
if (shrink.right) {
hull.width -= shrinkAmount;
}
if (shrink.bottom) {
hull.height -= shrinkAmount;
}
return shrink;
}
function shrinkHulls<TileType>(
hulls: RectangleHull[],
gridMap: GridMap<TileType>,
shrinkAmount: number
) {
const { tileHeight, tileWidth } = gridMap;
const newHulls: RectangleHull[] = [];
const finalHulls: RectangleHull[] = [];
hulls.forEach((hull, hullIndex) => {
const th = tileHeight;
const tw = tileWidth;
const tLeft = gridMap.getGridX(hull.x);
const tTop = gridMap.getGridY(hull.y);
const tBottom = gridMap.getGridY(hull.bottom);
const tRight = gridMap.getGridX(hull.right);
const shrink = shrinkHull(hull, gridMap, shrinkAmount, tileWidth, tileHeight);
if (hull.left >= hull.right || hull.top >= hull.bottom) return;
finalHulls.push(hull);
const newVerticalHulls: RectangleHull[] = [];
const newHorizontalHulls: RectangleHull[] = [];
const addHull = (x: number, y: number, w: number, h: number) => {
const hull = new RectangleHull(x, y, w, h);
if (w > h) newHorizontalHulls.push(hull);
else newVerticalHulls.push(hull);
};
if (shrink.left) {
const x = hull.left - shrinkAmount;
let startY = tTop;
let endY = startY - 1;
for (let y = tTop; y < tBottom; y++) {
if (gridMap.isBlocked(tLeft - 1, y)) {
if (startY <= endY) {
addHull(x, startY * th, shrinkAmount, (endY - startY + 1) * th);
}
startY = y + 1;
} else {
endY = y;
}
}
if (startY <= endY) {
addHull(x, startY * th, shrinkAmount, (endY - startY + 1) * th);
}
}
if (shrink.right) {
const x = hull.right;
let startY = tTop;
let endY = startY - 1;
for (let y = tTop; y < tBottom; y++) {
if (gridMap.isBlocked(tRight, y)) {
if (startY <= endY) {
addHull(x, startY * th, shrinkAmount, (endY - startY + 1) * th);
}
startY = y + 1;
} else {
endY = y;
}
}
if (startY <= endY) {
addHull(x, startY * th, shrinkAmount, (endY - startY + 1) * th);
}
}
if (shrink.top) {
const y = hull.top - shrinkAmount;
let startX = tLeft;
let endX = startX - 1;
for (let x = tLeft; x < tRight; x++) {
if (gridMap.isBlocked(x, tTop - 1)) {
if (startX <= endX) {
addHull(startX * tw, y, (endX - startX + 1) * th, shrinkAmount);
}
startX = x + 1;
} else {
endX = x;
}
}
if (startX <= endX) {
addHull(startX * tw, y, (endX - startX + 1) * th, shrinkAmount);
}
}
if (shrink.bottom) {
const y = hull.bottom;
let startX = tLeft;
let endX = startX - 1;
for (let x = tLeft; x < tRight; x++) {
if (gridMap.isBlocked(x, tBottom)) {
if (startX <= endX) {
addHull(startX * tw, y, (endX - startX + 1) * th, shrinkAmount);
}
startX = x + 1;
} else {
endX = x;
}
}
if (startX <= endX) {
addHull(startX * tw, y, (endX - startX + 1) * th, shrinkAmount);
}
}
// Shrunk at corners when the new hulls overlap.
newHorizontalHulls.forEach((hh) => {
newVerticalHulls.forEach((vh) => {
if (hh.doesOverlap(vh)) {
const isBottomSide = hh.y > vh.y;
if (isBottomSide) vh.height -= shrinkAmount;
else vh.top += shrinkAmount;
}
});
});
[...newHorizontalHulls, ...newVerticalHulls].forEach((hull) => {
shrinkHull(hull, gridMap, shrinkAmount, tileWidth, tileHeight);
if (hull.left >= hull.right || hull.top >= hull.bottom) return;
newHulls.push(hull);
});
});
// Attempt to merge new hulls into existing hulls if possible.
for (let i = 0; i < newHulls.length; i++) {
let wasMerged = false;
// Attempt to merge into the main (shrunken) hulls first.
for (const mainHull of hulls) {
wasMerged = mainHull.attemptMergeIn(newHulls[i]);
if (wasMerged) break;
}
if (wasMerged) continue;
// Then check to see if we can merge into a later hull in newHulls.
for (let j = i + 1; j < newHulls.length; j++) {
wasMerged = newHulls[j].attemptMergeIn(newHulls[i]);
if (wasMerged) break;
}
if (!wasMerged) finalHulls.push(newHulls[i]);
}
return finalHulls;
}