Instantly share code, notes, and snippets.
Created
October 12, 2016 19:49
-
Star
1
(1)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
Save veeneck/4abf94e09ee750f206d56fa44a8e09de to your computer and use it in GitHub Desktop.
GameplayKit Pathfinding
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// PKNavmesh.swift | |
// PathKit | |
// | |
// Created by Ryan Campbell on 9/12/16. | |
// Copyright © 2016 Phalanx Studios. All rights reserved. | |
// | |
import SpriteKit | |
import GameplayKit | |
#if os(iOS) | |
import Particleboard | |
#elseif os(OSX) | |
import ParticleboardOS | |
#endif | |
/** | |
Extension of GKMeshGraph that has built in utility methods for pathfinding. For example, if the desired point is inside of an obstacle, you can optionally find the | |
closest point on the graph. | |
*/ | |
public class PKNavmesh : GKMeshGraph<GKGraphNode2D> { | |
public var meshObstacles = [PKMeshObstacle]() | |
public var collider : UInt32 = 0 | |
public var buffer : Float = 0 | |
// MARK: Initializing a Navmesh | |
public init(meshObstacles:[PKMeshObstacle], obstacles:[GKPolygonObstacle], scene:SKScene, bgLayer:SKNode, bufferRadius:Float, collider:UInt32, size:CGSize) { | |
/// Add / subtract 250 from map size so that obstacle buffers off screen to trigger gameplaykit bug | |
super.init(bufferRadius: bufferRadius, minCoordinate: float2(x:-250, y:-250), maxCoordinate: float2(x:Float(size.width) + 250, y:Float(size.height) + 250)) | |
self.buffer = bufferRadius | |
self.meshObstacles = meshObstacles | |
self.collider = collider | |
self.triangulationMode = [.vertices] | |
self.addObstacles(obstacles) | |
self.triangulate() | |
self.removeNodesWithNoSceneConnection(scene, layer: bgLayer) | |
self.debugOverlay(scene: scene) | |
///self.debugTriangles(scene: scene) | |
} | |
override init(bufferRadius: Float, minCoordinate min: vector_float2, maxCoordinate max: vector_float2) { | |
super.init(bufferRadius: bufferRadius, minCoordinate: min, maxCoordinate: max) | |
} | |
override init(bufferRadius: Float, minCoordinate min: vector_float2, maxCoordinate max: vector_float2, nodeClass:AnyClass) { | |
super.init(bufferRadius: bufferRadius, minCoordinate: min, maxCoordinate: max, nodeClass:nodeClass) | |
} | |
required public init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
/// Standard helper to load a plist file | |
class func loadPListData(_ file:String) -> NSDictionary { | |
let lookup = "\(file)_obstacles" | |
let path = Bundle.main.path(forResource: lookup, ofType: "plist") | |
let pListData = NSDictionary(contentsOfFile:path!) | |
return pListData! | |
} | |
/// The mesh graph is slightly bigger than the scene, so nodes can connect behind obstacles off scene. This allows units to walk behind obstacles | |
/// if it is the shortest path. Removing off scene nodes causes hard error in GameplayKit. Instead, we'll remove any connections from one off scene node to another. | |
/// This keeps the nodes in place, but units will no longer try to walk behind the scene | |
func removeNodesWithNoSceneConnection(_ scene:SKScene, layer:SKNode) { | |
var nodesOffScene = [GKGraphNode2D]() | |
/// Loop each node | |
for node in self.nodes as! [GKGraphNode2D] { | |
/// If node is already off scene, we want to check it | |
if(!layer.contains(CGPoint(node.position))) { | |
/// Since it is offscene, remove connection to other off scene nodes | |
for connection in node.connectedNodes { | |
let connectNode = connection as! GKGraphNode2D | |
if(!layer.contains(CGPoint(connectNode.position))) { | |
node.removeConnections(to: [connection], bidirectional: true) | |
} | |
} | |
} | |
} | |
} | |
// MARK: Pathfinding | |
/// Add a start and end node to the graph. Find the path. Remove the start and end node. Trickery needed with buffer radius issues. | |
/// NOTE: We need to allow movement inside buffer region because squad shuodl be able to end up anywhere. The buffer in our case is only so that the squad will take | |
/// wide angles when rounding corners, but should not affect start and end point. | |
public func findPath(_ start:CGPoint, end:CGPoint, ignoringObstacles:[GKPolygonObstacle] = [], radius:Float, scene:SKScene, size:Float = 32) -> (path:GKPath, nodes:[GKGraphNode2D])? { | |
logged("Finding path from \(start) to \(end)", file: #file, level: .Debug, newline: false) | |
let startNode = GKGraphNode2D(point:float2(start)) | |
let endNode = GKGraphNode2D(point:float2(end)) | |
/// Start Node | |
/// If point is in buffer region, force connection to closest node | |
if self.getBuffersContainingPoint(point: float2(start), bufferSize: self.buffer).count > 0 { | |
logged("trying to force start node", file: #file, level: .Debug, newline:false) | |
self.connectToLowestCostNodeAvoidingObstacles(node: startNode, scene:scene) | |
} | |
/// else, let the gknavmesh connect it to every node nearby | |
else { | |
logged("trying to add start node", file: #file, level: .Debug, newline:false) | |
self.connectToGraph(node: startNode) | |
} | |
logged("Start node added", file: #file, level: .Debug, newline:false) | |
/// End Node | |
/// If point is in buffer region, force connection to closest node | |
if self.getBuffersContainingPoint(point: float2(end), bufferSize: self.buffer).count > 0 { | |
logged("trying to force end node", file: #file, level: .Debug, newline:false) | |
self.connectToLowestCostNodeAvoidingObstacles(node: endNode, scene:scene) | |
} | |
/// else, let the gknavmesh connect it to every node nearby | |
else { | |
logged("trying to add end node", file: #file, level: .Debug, newline:false) | |
self.connectToGraph(node: endNode) | |
} | |
logged("End node added", file: #file, level: .Debug, newline:false) | |
/// If there is a connection with more than two points, return the path smoothed result | |
if(!startNode.connectedNodes.isEmpty && !endNode.connectedNodes.isEmpty) { | |
self.debugOverlay(scene: scene) | |
let pathNodes = self.findPath(from: startNode, to: endNode) as! [GKGraphNode2D] | |
if pathNodes.count < 2 { | |
logged("Path not found :(", file: #file, level: .Error, newline:false) | |
self.remove([startNode, endNode]) | |
return nil | |
} | |
else { | |
let optimizedNodes = self.optimizeWaypoints(startNode: startNode, points: pathNodes, scene: scene) | |
let path = GKPath(graphNodes: optimizedNodes, radius: radius) | |
self.remove([startNode, endNode]) | |
logged("Path found!", file: #file, level: .Debug, newline:false) | |
self.debugPath(pathNodes: pathNodes, optimizedNodes:optimizedNodes, scene: scene) | |
return (path, optimizedNodes) | |
} | |
} | |
self.remove([startNode, endNode]) | |
logged("Path not found -- start or end node have no connections", file: #file, level: .Error, newline:false) | |
return nil | |
} | |
// MARK: Utility | |
/// Determines if a point lies on an obstacle. If this doesn't perform well, we can loop through the MeshObstacles and call | |
/// containsPoint() on each one. Expects world point | |
public func pointIsValid(_ point:float2) -> Bool { | |
for mesh in self.meshObstacles { | |
if mesh.containsPoint(CGPoint(point)) { | |
return false | |
} | |
} | |
return true | |
} | |
/// Builds a list of all buffer regions of obstacles containing a point. Used for ignoreBufferRadius when adding a GKGraphNode to the mesh. | |
func getBuffersContainingPoint(point:float2, bufferSize:Float) -> [GKPolygonObstacle] { | |
var ret = [GKPolygonObstacle]() | |
for mesh in self.meshObstacles { | |
if mesh.containsPointInBuffer(point: CGPoint(point), bufferRadius: CGFloat(bufferSize)) { | |
ret.append(mesh.obstacle) | |
} | |
} | |
return ret | |
} | |
/// GameplayKit has bugs where clicking in the coordinates of one GKTriangle will connect to nodes from another triangle. | |
/// This is a utility method to help override that. | |
func triangleAtPoint(point:CGPoint) -> GKTriangle? { | |
for i in 1..<self.triangleCount { | |
let triangle = self.triangle(at: i) | |
if triangle.containsPoint(point: point) { | |
return triangle | |
} | |
} | |
return nil | |
} | |
/// Once we have a triangle, we have the 3 corners. This utility method can help locate the actual GKGraphNode2D that lives at those corners. | |
func nodeAtPoint(point:float2) -> GKGraphNode2D? { | |
for node in self.nodes! { | |
let node2d = node as! GKGraphNode2D | |
if node2d.position == point { | |
return node2d | |
} | |
} | |
return nil | |
} | |
/// GameplayKit has bugs where clicking in the coordinates of one GKTriangle will connect to nodes from another triangle. | |
/// This will manually connect a new node to the 3 corners of the triangle it sits in. | |
func connectNodeToTriangle(node:GKGraphNode2D, triangle:GKTriangle) -> Bool { | |
var nodesToConnect = [GKGraphNode2D]() | |
/// See if a node lives at each triangle corner position. | |
if let point2d = self.nodeAtPoint(point: float2(x:triangle.points.0.x, y:triangle.points.0.y)) { | |
nodesToConnect.append(point2d) | |
} | |
if let point2d = self.nodeAtPoint(point: float2(x:triangle.points.1.x, y:triangle.points.1.y)) { | |
nodesToConnect.append(point2d) | |
} | |
if let point2d = self.nodeAtPoint(point: float2(x:triangle.points.2.x, y:triangle.points.2.y)) { | |
nodesToConnect.append(point2d) | |
} | |
if nodesToConnect.count > 0 { | |
self.add([node]) | |
node.addConnections(to: nodesToConnect, bidirectional: true) | |
return true | |
} | |
else { | |
return false | |
} | |
} | |
/// GameplayKit has bugs where clicking in the coordinates of one GKTriangle will connect to nodes from another triangle. | |
/// FInd the triangle a new node sits in and connect to the 3 corners. | |
func connectToGraph(node:GKGraphNode2D) -> Bool { | |
if let triangle = self.triangleAtPoint(point: CGPoint(float2(x:node.position.x, y:node.position.y))) { | |
return self.connectNodeToTriangle(node: node, triangle: triangle) | |
} | |
else { | |
return false | |
} | |
} | |
/// If the point is inside a buffer region, we have to manually connect to the lowest cost node while avoiding obstacles. | |
func connectToLowestCostNodeAvoidingObstacles(node:GKGraphNode2D, scene:SKScene) -> Bool { | |
/// Add the node to the graph | |
self.add([node]) | |
/// Find the lowest cost to nearby nodes. Store in array with best cost as first index. | |
var bestCost : Float = -1 | |
var bestNodes = [GKGraphNode2D]() | |
for point in self.nodes! { | |
if point != node { | |
let cost = node.estimatedCost(to: point) | |
if cost < bestCost || bestCost == -1 { | |
bestCost = cost | |
bestNodes.insert(point as! GKGraphNode2D, at: 0) | |
} | |
} | |
} | |
/// Assuming we found nearby nodes, connect to the first node possible that does not pass through a physics environment object. | |
if bestNodes.count > 0 { | |
for best in bestNodes { | |
if self.hasClearPathThroughEnvironment(start: node, end: best, scene: scene) { | |
node.addConnections(to: [best], bidirectional: true) | |
return true | |
} | |
} | |
} | |
return false | |
} | |
// MARK: Path Smoothing | |
/// Given an array of waypoints, check line of sight on each connecting point to find the most | |
/// efficient walking path | |
func optimizeWaypoints(startNode:GKGraphNode2D, points:[GKGraphNode2D], scene:SKScene) -> [GKGraphNode2D] { | |
var optimizedPath = [GKGraphNode2D]() | |
var origin = startNode | |
var checkpoint = origin | |
var checkpointIndex = 0 | |
/// Keep looping until we've arrived at final point | |
while optimizedPath.last != points.last { | |
/// Checkpoint is last checked node ... have to see how far we can skip ahead | |
for index in checkpointIndex ..< points.count { | |
/// Set our new start and end based on where we are in the iteration | |
let start = origin | |
let end = points[index] | |
logged("Checking \(start) to \(end)", file: #file, level: .Debug, newline: false) | |
/// If there is no clear path, we have to add this node and then start testing next node | |
if(self.lineSegmentsHitBuffer(start: start, end: end) || | |
!self.hasClearPathThroughEnvironment(start: start, end: end, scene: scene)) { | |
logged("No clear path. Adding waypoint at \(checkpoint)\n", file: #file, level: .Debug, newline: true) | |
/// Add the checkpoint to our new path | |
///if(checkpoint != origin) { | |
optimizedPath.append(checkpoint) | |
///} | |
/// Set the checkpoint as the new origin to scan from | |
origin = checkpoint | |
/// Reset the loop index to that of the current checkpoint | |
checkpointIndex = index | |
/// Reset the next checkpoint to look at to the reflected index | |
checkpoint = points[index] | |
break | |
} | |
/// Otherwise, make check point the next node | |
checkpoint = points[index] | |
/// If we've reached the last point (destination), append it which will end the loop | |
if(index == points.count - 1) { | |
optimizedPath.append(points[index]) | |
} | |
} | |
} | |
/** | |
Always add in the start point in case algorithm above doesn't think it is needed. Two examples of why: | |
1. If the point is in the same triangle that it started in, the start point will never get added based on logic of loop above. | |
2. Gameplaykit will walk to the closest point of the path, and then continue down the path. So, if no start point added, unit will walk to an unpredictable start point. | |
*/ | |
if optimizedPath.first != points[0] { | |
optimizedPath.insert(points[0], at: 0) | |
} | |
return optimizedPath | |
} | |
/// Checks against objects registered as physics objects to see if they would be intersected by the line. | |
/// Only checks Environemnt obstacles. | |
func hasClearPathThroughEnvironment(start:GKGraphNode2D, end:GKGraphNode2D, scene:SKScene) -> Bool { | |
var ret = true | |
let startPos = CGPoint(x:CGFloat(start.position.x), y:CGFloat(start.position.y)) | |
let endPos = CGPoint(x:CGFloat(end.position.x), y:CGFloat(end.position.y)) | |
scene.physicsWorld.enumerateBodies(alongRayStart: startPos, end:endPos) { | |
body, point, normal, stop in | |
if body.categoryBitMask == self.collider { | |
ret = false | |
logged("Line segment hit physics environment", file: #file, level: .Debug, newline: false) | |
return | |
} | |
} | |
return ret | |
} | |
/// Checks against objects registered as physics objects to see if they would be intersected by the line. | |
/// Checks all obstacles | |
func hasClearPath(start:GKGraphNode2D, end:GKGraphNode2D, scene:SKScene) -> Bool { | |
var ret = true | |
let startPos = CGPoint(x:CGFloat(start.position.x), y:CGFloat(start.position.y)) | |
let endPos = CGPoint(x:CGFloat(end.position.x), y:CGFloat(end.position.y)) | |
scene.physicsWorld.enumerateBodies(alongRayStart: startPos, end:endPos) { | |
body, point, normal, stop in | |
ret = false | |
} | |
return ret | |
} | |
/// This is a quick and dirty check to sample 10 points along a line to see if they live inside of the buffer region of an obstacle. | |
/// Goal is for this to be quicker than physics, and be accurate a majority of the time. A few outliers will not break pathfinding. | |
/// Also fixes issue when rounding corners and physics environment not hit from center of agent, but buffer would be. | |
func lineSegmentsHitBuffer(start:GKGraphNode2D, end:GKGraphNode2D) -> Bool { | |
let startPos = CGPoint(x:CGFloat(start.position.x), y:CGFloat(start.position.y)) | |
let endPos = CGPoint(x:CGFloat(end.position.x), y:CGFloat(end.position.y)) | |
/// We want to inset the buffer a bit so that points along the edge of the buffer don't trigger this | |
var testBuffer = self.buffer | |
if testBuffer > 1 { | |
testBuffer = testBuffer - 1 | |
} | |
/// If start point or end point is in buffer (i.e: unit is in a corner / going to a corner) ignore this check | |
let startBuffers = self.getBuffersContainingPoint(point: float2(startPos), bufferSize:testBuffer) | |
let endBuffers = self.getBuffersContainingPoint(point: float2(endPos), bufferSize:testBuffer) | |
/*if startBuffers.count > 0 || endBuffers.count > 0 { | |
return false | |
}*/ | |
/// Find out vector | |
var vector = endPos - startPos | |
/// Get the length so that we can divide into equal points along the line. | |
let length = vector.length() | |
if length > 0 { | |
/// 7 equal points, and we'll ignore the start and end point | |
let pointDistance = length / 10 | |
/// Normalize the vector, so now it's just a direction | |
vector.normalize() | |
/// Loop to generate points along the line | |
for i in 1 ... 8 { | |
/// Take vector times equal point distance to generate a point i increments along the line | |
let testPoint = startPos + (vector * (pointDistance * CGFloat(i))) | |
/// See if the point exists in any of the buffers. | |
let buffers = self.getBuffersContainingPoint(point: float2(testPoint), bufferSize:testBuffer) | |
if buffers.count != 0 { | |
for buffer in buffers { | |
if startBuffers.contains(buffer) || endBuffers.contains(buffer) { | |
/// Ignore this buffer because unit may be stuck in a corner | |
} | |
else { | |
/// Intersecting a random buffer, which should trigger a waypoint drop | |
logged("Line segment hit buffer", file: #file, level: .Debug, newline: false) | |
return true | |
} | |
} | |
} | |
} | |
} | |
return false | |
} | |
// MARK: Debug | |
/// Will automatically turn on debugging information if Debug.Navigaiton.enabled is set to true. | |
func debugOverlay(scene:SKScene) { | |
if PKAtlas.DEBUG == true { | |
if let debug = scene.childNode(withName: "PermanentDebugLayer") { | |
debug.enumerateChildNodes(withName: "navmeshDebug") { node, stop in | |
node.removeFromParent() | |
} | |
for node in self.nodes as! [GKGraphNode2D] { | |
let point = node.position | |
let shapeTexture = SKSpriteNode(texture: SKTexture(imageNamed: "yellow_circle")) | |
shapeTexture.zPosition = 1001 | |
shapeTexture.position = debug.convert(CGPoint(point), from:scene) | |
shapeTexture.setScale(0.4) | |
shapeTexture.name = "navmeshDebug" | |
debug.addChild(shapeTexture) | |
for destination in node.connectedNodes as! [GKGraphNode2D] { | |
let start = debug.convert(CGPoint(node.position), from: scene) | |
let end = debug.convert(CGPoint(destination.position), from: scene) | |
let points = [start, end] | |
let shapeNode = SKShapeNode(points: UnsafeMutablePointer<CGPoint>(mutating: points), count: 2) | |
shapeNode.strokeColor = SKColor(white: 1.0, alpha: 0.5) | |
shapeNode.lineWidth = 5.0 | |
shapeNode.zPosition = 1000 | |
shapeNode.name = "navmeshDebug" | |
debug.addChild(shapeNode) | |
} | |
if node.connectedNodes.isEmpty { | |
shapeTexture.color = SKColor.orange | |
shapeTexture.colorBlendFactor = 1 | |
shapeTexture.alpha = 0.2 | |
} | |
if(!scene.childNode(withName: "World")!.contains(CGPoint(node.position))) { | |
shapeTexture.color = SKColor.red | |
shapeTexture.colorBlendFactor = 1 | |
shapeTexture.alpha = 1 | |
} | |
} | |
} | |
} | |
} | |
/// Will automatically turn on debugging information if Debug.Pathfinding.enabled is set to true. | |
func debugPath(pathNodes:[GKGraphNode2D], optimizedNodes:[GKGraphNode2D], scene:SKScene) { | |
if PKAtlas.DEBUG == true { | |
if let debug = scene.childNode(withName: "PermanentDebugLayer") { | |
// remove last debug informaiton | |
debug.enumerateChildNodes(withName: "pathfinding") { node, stop in | |
node.removeFromParent() | |
} | |
for node in pathNodes { | |
var shapeTexture1 = SKSpriteNode(imageNamed: "red_cross") | |
shapeTexture1.name = "pathfinding" | |
shapeTexture1.xScale = 4 | |
shapeTexture1.yScale = 4 | |
shapeTexture1.zPosition = 1010 | |
shapeTexture1.position = debug.convert(CGPoint(x:CGFloat(node.position.x), y:CGFloat(node.position.y)), from:scene) | |
debug.addChild(shapeTexture1) | |
} | |
for point in optimizedNodes { | |
var shapeTexture1 = SKSpriteNode(imageNamed: "green_cross") | |
shapeTexture1.name = "pathfinding" | |
shapeTexture1.xScale = 4 | |
shapeTexture1.yScale = 4 | |
shapeTexture1.zPosition = 1011 | |
shapeTexture1.position = debug.convert(CGPoint(x:CGFloat(point.position.x), y:CGFloat(point.position.y)), from:scene) | |
debug.addChild(shapeTexture1) | |
} | |
} | |
} | |
} | |
func debugTriangles(scene:SKScene) { | |
if PKAtlas.DEBUG == true { | |
if let debug = scene.childNode(withName: "PermanentDebugLayer") { | |
print(self.triangleCount) | |
print(triangle(at: 1)) | |
for i in 1..<self.triangleCount { | |
let triangle = self.triangle(at: i) | |
let path = CGMutablePath() | |
let points = triangle.points | |
path.move(to: CGPoint(x:CGFloat(points.0.x), y:CGFloat(points.0.y))) | |
path.addLine(to: CGPoint(x:CGFloat(points.1.x), y:CGFloat(points.1.y))) | |
path.addLine(to: CGPoint(x:CGFloat(points.2.x), y:CGFloat(points.2.y))) | |
path.closeSubpath() | |
let outline = SKShapeNode(path: path) | |
outline.zPosition = 1001 | |
outline.lineWidth = 1 | |
outline.fillColor = SKColor.yellow | |
outline.alpha = 0.5 | |
debug.addChild(outline) | |
} | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment