navmesh
Version:
A library for fast pathfinding using navigation meshes in JS
342 lines (305 loc) • 13.2 kB
text/typescript
import jsastar from "javascript-astar";
import NavPoly from "./navpoly";
import NavGraph from "./navgraph";
import Channel from "./channel";
import { angleDifference, areCollinear, clamp, distanceSquared, projectPointToEdge } from "./utils";
import Vector2 from "./math/vector-2";
import Line from "./math/line";
import Polygon from "./math/polygon";
import { Point, PolyPoints } from "./common-types";
/**
* The `NavMesh` class is the workhorse that represents a navigation mesh built from a series of
* polygons. Once built, the mesh can be asked for a path from one point to another point. Some
* internal terminology usage:
* - neighbor: a polygon that shares part of an edge with another polygon
* - portal: when two neighbor's have edges that overlap, the portal is the overlapping line segment
* - channel: the path of polygons from starting point to end point
* - pull the string: run the funnel algorithm on the channel so that the path hugs the edges of the
* channel. Equivalent to having a string snaking through a hallway and then pulling it taut.
*/
export default class NavMesh {
private meshShrinkAmount: number;
private navPolygons: NavPoly[];
private graph: NavGraph;
/**
* @param meshPolygonPoints Array where each element is an array of point-like objects that
* defines a polygon.
* @param meshShrinkAmount The amount (in pixels) that the navmesh has been shrunk around
* obstacles (a.k.a the amount obstacles have been expanded).
*/
public constructor(meshPolygonPoints: PolyPoints[], meshShrinkAmount = 0) {
this.meshShrinkAmount = meshShrinkAmount;
// Convert the PolyPoints[] into NavPoly instances.
const newPolys = meshPolygonPoints.map((polyPoints) => {
const vectors = polyPoints.map((p) => new Vector2(p.x, p.y));
return new Polygon(vectors);
});
this.navPolygons = newPolys.map((polygon, i) => new NavPoly(i, polygon));
this.calculateNeighbors();
// Astar graph of connections between polygons
this.graph = new NavGraph(this.navPolygons);
}
/**
* Get the NavPolys that are in this navmesh.
*/
public getPolygons() {
return this.navPolygons;
}
/**
* Cleanup method to remove references.
*/
public destroy() {
this.graph.destroy();
for (const poly of this.navPolygons) poly.destroy();
this.navPolygons = [];
}
/**
* Find if the given point is within any of the polygons in the mesh.
* @param point
*/
public isPointInMesh(point: Point) {
return this.navPolygons.some((navPoly) => navPoly.contains(point));
}
/**
* Find the closest point in the mesh to the given point. If the point is already in the mesh,
* this will give you that point. If the point is outside of the mesh, this will attempt to
* project this point into the mesh (up to the given maxAllowableDist). This returns an object
* with:
* - distance - from the given point to the mesh
* - polygon - the one the point is closest to, or null
* - point - the point inside the mesh, or null
* @param point
* @param maxAllowableDist
*/
public findClosestMeshPoint(point: Vector2, maxAllowableDist: number = Number.POSITIVE_INFINITY) {
let minDistance = maxAllowableDist;
let closestPoly: NavPoly | null = null;
let pointOnClosestPoly: Point | null = null;
for (const navPoly of this.navPolygons) {
// If we are inside a poly, we've got the closest.
if (navPoly.contains(point)) {
minDistance = 0;
closestPoly = navPoly;
pointOnClosestPoly = point;
break;
}
// Is the poly close enough to warrant a more accurate check? Point is definitely outside of
// the polygon. Distance - Radius is the smallest possible distance to an edge of the poly.
// This will underestimate distance, but that's perfectly fine.
const r = navPoly.boundingRadius;
const d = navPoly.centroid.distance(point);
if (d - r < minDistance) {
const result = this.projectPointToPolygon(point, navPoly);
if (result.distance < minDistance) {
minDistance = result.distance;
closestPoly = navPoly;
pointOnClosestPoly = result.point;
}
}
}
return { distance: minDistance, polygon: closestPoly, point: pointOnClosestPoly };
}
/**
* Find a path from the start point to the end point using this nav mesh.
* @param {object} startPoint A point-like object in the form {x, y}
* @param {object} endPoint A point-like object in the form {x, y}
* @returns {Vector2[]|null} An array of points if a path is found, or null if no path
*/
public findPath(startPoint: Point, endPoint: Point) {
let startPoly = null;
let endPoly = null;
let startDistance = Number.MAX_VALUE;
let endDistance = Number.MAX_VALUE;
let d, r;
const startVector = new Vector2(startPoint.x, startPoint.y);
const endVector = new Vector2(endPoint.x, endPoint.y);
// Find the closest poly for the starting and ending point
for (const navPoly of this.navPolygons) {
r = navPoly.boundingRadius;
// Start
d = navPoly.centroid.distance(startVector);
if (d <= startDistance && d <= r && navPoly.contains(startVector)) {
startPoly = navPoly;
startDistance = d;
}
// End
d = navPoly.centroid.distance(endVector);
if (d <= endDistance && d <= r && navPoly.contains(endVector)) {
endPoly = navPoly;
endDistance = d;
}
}
// If the end point wasn't inside a polygon, run a more liberal check that allows a point
// to be within meshShrinkAmount radius of a polygon
if (!endPoly && this.meshShrinkAmount > 0) {
for (const navPoly of this.navPolygons) {
r = navPoly.boundingRadius + this.meshShrinkAmount;
d = navPoly.centroid.distance(endVector);
if (d <= r) {
const { distance } = this.projectPointToPolygon(endVector, navPoly);
if (distance <= this.meshShrinkAmount && distance < endDistance) {
endPoly = navPoly;
endDistance = distance;
}
}
}
}
// No matching polygons locations for the end, so no path found
// because start point is valid normally, check end point first
if (!endPoly) return null;
// Same check as above, but for the start point
if (!startPoly && this.meshShrinkAmount > 0) {
for (const navPoly of this.navPolygons) {
// Check if point is within bounding circle to avoid extra projection calculations
r = navPoly.boundingRadius + this.meshShrinkAmount;
d = navPoly.centroid.distance(startVector);
if (d <= r) {
// Check if projected point is within range of a polgyon and is closer than the
// previous point
const { distance } = this.projectPointToPolygon(startVector, navPoly);
if (distance <= this.meshShrinkAmount && distance < startDistance) {
startPoly = navPoly;
startDistance = distance;
}
}
}
}
// No matching polygons locations for the start, so no path found
if (!startPoly) return null;
// If the start and end polygons are the same, return a direct path
if (startPoly === endPoly) return [startVector, endVector];
// Search!
const astarPath = jsastar.astar.search(this.graph, startPoly, endPoly, {
heuristic: this.graph.navHeuristic,
});
// While the start and end polygons may be valid, no path between them
if (astarPath.length === 0) return null;
// jsastar drops the first point from the path, but the funnel algorithm needs it
astarPath.unshift(startPoly);
// We have a path, so now time for the funnel algorithm
const channel = new Channel();
channel.push(startVector);
for (let i = 0; i < astarPath.length - 1; i++) {
const navPolygon = astarPath[i];
const nextNavPolygon = astarPath[i + 1];
// Find the portal
let portal = null;
for (let i = 0; i < navPolygon.neighbors.length; i++) {
if (navPolygon.neighbors[i].id === nextNavPolygon.id) {
portal = navPolygon.portals[i];
}
}
if (!portal) throw new Error("Path was supposed to be found, but portal is missing!");
// Push the portal vertices into the channel
channel.push(portal.start, portal.end);
}
channel.push(endVector);
// Pull a string along the channel to run the funnel
channel.stringPull();
// Clone path, excluding duplicates
let lastPoint = null;
const phaserPath = [];
for (const p of channel.path) {
const newPoint = p.clone();
if (!lastPoint || !newPoint.equals(lastPoint)) phaserPath.push(newPoint);
lastPoint = newPoint;
}
return phaserPath;
}
private calculateNeighbors() {
// Fill out the neighbor information for each navpoly
for (let i = 0; i < this.navPolygons.length; i++) {
const navPoly = this.navPolygons[i];
for (let j = i + 1; j < this.navPolygons.length; j++) {
const otherNavPoly = this.navPolygons[j];
// Check if the other navpoly is within range to touch
const d = navPoly.centroid.distance(otherNavPoly.centroid);
if (d > navPoly.boundingRadius + otherNavPoly.boundingRadius) continue;
// The are in range, so check each edge pairing
for (const edge of navPoly.edges) {
for (const otherEdge of otherNavPoly.edges) {
// If edges aren't collinear, not an option for connecting navpolys
if (!areCollinear(edge, otherEdge)) continue;
// If they are collinear, check if they overlap
const overlap = this.getSegmentOverlap(edge, otherEdge);
if (!overlap) continue;
// Connections are symmetric!
navPoly.neighbors.push(otherNavPoly);
otherNavPoly.neighbors.push(navPoly);
// Calculate the portal between the two polygons - this needs to be in
// counter-clockwise order, relative to each polygon
const [p1, p2] = overlap;
let edgeStartAngle = navPoly.centroid.angle(edge.start);
let a1 = navPoly.centroid.angle(overlap[0]);
let a2 = navPoly.centroid.angle(overlap[1]);
let d1 = angleDifference(edgeStartAngle, a1);
let d2 = angleDifference(edgeStartAngle, a2);
if (d1 < d2) {
navPoly.portals.push(new Line(p1.x, p1.y, p2.x, p2.y));
} else {
navPoly.portals.push(new Line(p2.x, p2.y, p1.x, p1.y));
}
edgeStartAngle = otherNavPoly.centroid.angle(otherEdge.start);
a1 = otherNavPoly.centroid.angle(overlap[0]);
a2 = otherNavPoly.centroid.angle(overlap[1]);
d1 = angleDifference(edgeStartAngle, a1);
d2 = angleDifference(edgeStartAngle, a2);
if (d1 < d2) {
otherNavPoly.portals.push(new Line(p1.x, p1.y, p2.x, p2.y));
} else {
otherNavPoly.portals.push(new Line(p2.x, p2.y, p1.x, p1.y));
}
// Two convex polygons shouldn't be connected more than once! (Unless
// there are unnecessary vertices...)
}
}
}
}
}
// Check two collinear line segments to see if they overlap by sorting the points.
// Algorithm source: http://stackoverflow.com/a/17152247
private getSegmentOverlap(line1: Line, line2: Line) {
const points = [
{ line: line1, point: line1.start },
{ line: line1, point: line1.end },
{ line: line2, point: line2.start },
{ line: line2, point: line2.end },
];
points.sort(function (a, b) {
if (a.point.x < b.point.x) return -1;
else if (a.point.x > b.point.x) return 1;
else {
if (a.point.y < b.point.y) return -1;
else if (a.point.y > b.point.y) return 1;
else return 0;
}
});
// If the first two points in the array come from the same line, no overlap
const noOverlap = points[0].line === points[1].line;
// If the two middle points in the array are the same coordinates, then there is a
// single point of overlap.
const singlePointOverlap = points[1].point.equals(points[2].point);
if (noOverlap || singlePointOverlap) return null;
else return [points[1].point, points[2].point];
}
/**
* Project a point onto a polygon in the shortest distance possible.
*
* @param {Phaser.Point} point The point to project
* @param {NavPoly} navPoly The navigation polygon to test against
* @returns {{point: Phaser.Point, distance: number}}
*/
private projectPointToPolygon(point: Vector2, navPoly: NavPoly) {
let closestProjection = null;
let closestDistance = Number.MAX_VALUE;
for (const edge of navPoly.edges) {
const projectedPoint = projectPointToEdge(point, edge);
const d = point.distance(projectedPoint);
if (closestProjection === null || d < closestDistance) {
closestDistance = d;
closestProjection = projectedPoint;
}
}
return { point: closestProjection, distance: closestDistance };
}
}