Update note: This tutorial was update to Swift and Sprite Kit by Gabriel Hauber. The original Cocos2D tutorial was written by Johann Fradj.
In this tutorial, you’ll learn how to add the A* Pathfinding algorithm into a simple Sprite Kit game. A* lets you calculate a navigable path between two points, and is especially useful for 2D tile-based games such as CatMaze, which you’ll work on in this tutorial.
If you’re not familiar with the A* algorithm itself, you should read Introduction to A* Pathfinding first for all the details on how it works. This tutorial will cover the implementation of the algorithm into an existing Sprite Kit game.
To go through this tutorial, it’s helpful if you have prior knowledge of the Sprite Kit framework on iOS or OS X. If you need a refresher there, check out our Sprite Kit Tutorials to take you all the way from core concepts to complete games!
Find the shortest path to your keyboard, and begin! :]
Getting Started
Start by downloading the starter project. It’s a simple Sprite Kit based game configured with both iOS and OS X targets. Build and run the project for your platform of choice. If you run the OS X version, you should see the following:
In this game, you take the role of a cat thief trying to make your way through a dungeon guarded by dangerous dogs. If you try to walk through a dog they will eat you – unless you can bribe them with a bone! You need to traverse the dungeon in the correct order so you have enough bones when you need them to get through a dog blocking your way as you make your way to the exit.
Note that the cat can only move vertically or horizontally (not diagonally), and will move from one tile center to another. Each tile can be either walkable or unwalkable.
Try out the game, and see if you can reach the exit! When you tap or click somewhere on the map, the cat will jump to an adjacent tile in the direction of your tap. (On the OS X version you can also use the cursor keys to move around).
Cat Maze and A* Overview
In this tutorial you will modify this so that the cat will automatically move all the way to the tile you tapped using the most efficient route, much like many RPGs or point-and-click adventure games.
Open Cat.swift and take a look at the implementation of moveToward(_:)
. You will see that it calls moveInDirection(_:)
with a direction based on the cat’s current position and the target position.
You will change this so that instead of moving one tile at a time, the cat will find the shortest path to the target, and make its way along that path.
First, replace the moveToward(_:)
implementation with the following:
func moveToward(target: CGPoint) { let toTileCoord = gameScene.tileMap.tileCoordForPosition(target) moveTo(toTileCoord) } |
Build and run the game now and make a move; the cat will teleport to the selected location. Well, that’s the end result you’re looking for so congratulations! ;]
The actual move algorithm is in moveTo(_:)
, which is where you’ll to calculate the shortest path using the A* algorithm, and make the cat follow it.
A Reusable Pathfinder
The A* pathfinding algorithm is the same whether it is finding the best path for a cat in a maze full of dogs, or for a warrior in a dungeon full of monsters. Because of this, the pathfinder you create in this tutorial will be reusable in other projects.
The pathfinding algorithm is going to need to know the following pieces of information from the game:
- Given a map location, what map locations next to it are valid locations to move to?
- What is the movement cost between two map locations?
In the project navigator, right-click on the Shared group, and select New File…. Choose the iOS\Source\Swift File template and click Next. Name the file AStarPathfinder and select the Shared directory as the location in which to create the file. Ensure both CatMaze and CatMaze Mac targets are selected and click Create.
Add the following code to the file you just created:
protocol PathfinderDataSource: NSObjectProtocol { func walkableAdjacentTilesCoordsForTileCoord(tileCoord: TileCoord) -> [TileCoord] func costToMoveFromTileCoord(fromTileCoord: TileCoord, toAdjacentTileCoord toTileCoord: TileCoord) -> Int } /** A pathfinder based on the A* algorithm to find the shortest path between two locations */ class AStarPathfinder { weak var dataSource: PathfinderDataSource? func shortestPathFromTileCoord(fromTileCoord: TileCoord, toTileCoord: TileCoord) -> [TileCoord]? { // placeholder: move immediately to the destination coordinate return [toTileCoord] } } |
The PathfinderDataSource
protocol describes the two requirements listed above: available (walkable) adjacent tiles, and the movement cost. This will be used by the AStarPathfinder
, which you’ll fill out with the algorithm later.
Setting up the Game to Use the Pathfinder
In order to use the pathfinder, the Cat
object will need to create an instance of it. But what is a good candidate for the pathfinder’s data source?
You have two choices:
- The
GameScene
class. It knows about the map, and so is a candidate for supplying information such as movement costs and what tiles are walkable, and so on. - The
Cat
class, but why would you choose this? Imagine a game which has multiple moveable character types, each of which has its own rules as to what is a walkable tile and movement costs. For example, a Ghost character may be able to move through walls, but it costs more to do so.
Because you are such a fan of good design, you choose the second option :]
Open Cat.swift and add the following class extension to the bottom of the file so it conforms to PathfinderDataSource
:
extension Cat: PathfinderDataSource { func walkableAdjacentTilesCoordsForTileCoord(tileCoord: TileCoord) -> [TileCoord] { let adjacentTiles = [tileCoord.top, tileCoord.left, tileCoord.bottom, tileCoord.right] return adjacentTiles.filter { self.gameScene.isWalkableTileForTileCoord($0) } } func costToMoveFromTileCoord(fromTileCoord: TileCoord, toAdjacentTileCoord toTileCoord: TileCoord) -> Int { return 1 } } |
As you can see, finding the adjacent tiles is really simple: create an array with the tile coordinates around the given tile coordinate, then filter the array to return only walkable tiles.
Because you can’t move diagonally and because terrain is just walkable or unwalkable the cost is always the same. In your other apps, perhaps diagonal movement costs more or there are different terrain types such as swamps, hills, etc.
Now that you’ve implemented the pathfinder’s data source, it’s time to create a pathfinder instance. Add the following properties to the Cat
class:
let pathfinder = AStarPathfinder() var shortestPath: [TileCoord]? |
In the initializer, set up the cat as the pathfinder’s data source immediately after the call to super.init()
:
pathfinder.dataSource = self |
In moveTo(_:)
, find the two lines of code that update the cat’s position and state:
position = gameScene.tileMap.positionForTileCoord(toTileCoord) updateState() |
Replace those two lines with the following:
shortestPath = pathfinder.shortestPathFromTileCoord(fromTileCoord, toTileCoord: toTileCoord) |
Once you fill out the pathfinding algorithm, this property will store the list of steps needed to get from point A to point B.
Creating the ShortestPathStep Class
In order to calculate the shortest path using the A* algorithm, you need to know for each of the path’s steps:
- Location
- F, G and H scores
- parent step (so you can trace back along its length from end to beginning)
You’ll capture all this information in a private class called ShortestPathStep
.
Add the following code to the top of AStarPathfinder.swift:
/** A single step on the computed path; used by the A* pathfinding algorithm */ private class ShortestPathStep: Hashable { let position: TileCoord var parent: ShortestPathStep? var gScore = 0 var hScore = 0 var fScore: Int { return gScore + hScore } var hashValue: Int { return position.col.hashValue + position.row.hashValue } init(position: TileCoord) { self.position = position } func setParent(parent: ShortestPathStep, withMoveCost moveCost: Int) { // The G score is equal to the parent G score + the cost to move from the parent to it self.parent = parent self.gScore = parent.gScore + moveCost } } private func ==(lhs: ShortestPathStep, rhs: ShortestPathStep) -> Bool { return lhs.position == rhs.position } extension ShortestPathStep: Printable { var description: String { return "pos=\(position) g=\(gScore) h=\(hScore) f=\(fScore)" } } |
As you can see, this is a very simple class that keeps track of the following:
- The step’s tile coordinate
- The G score (the movement cost from the start to the step’s position)
- The H score (the estimated number of tiles between the current position and destination)
- The step before this step in the path (the parent)
- The F score, that is, the score for this tile (calculated by adding G + H).
The class also conforms to the Equatable
protocol: two steps are equal if they refer to the same location, regardless of their G or H scores.
Finally, it is also Printable
for the purposes of human-friendly debug messages.
Implementing the A* Algorithm
Now the bootstrapping is over and it’s time to write the code to calculate the optimal path! First, add the following helper methods to the AStarPathfinder
class:
private func insertStep(step: ShortestPathStep, inout inOpenSteps openSteps: [ShortestPathStep]) { openSteps.append(step) openSteps.sort { $0.fScore <= $1.fScore } } func hScoreFromCoord(fromCoord: TileCoord, toCoord: TileCoord) -> Int { return abs(toCoord.col - fromCoord.col) + abs(toCoord.row - fromCoord.row) } |
The first method insertStep(_:inOpenSteps:)
inserts a ShortestPathStep
into the open list at the appropriate position ordered by F score. Note that it modifies the array in-place and is passed in as an inout
parameter.
The second method computes the H score for a square according to the Manhattan (or “city block”) method, which calculates the total number of steps moved horizontally and vertically to reach the final desired step from the current step, ignoring any obstacles that may be in the way.
With these helper methods in place, you now have everything you need to implement the pathfinding algorithm itself.
Delete the current placeholder code in shortestPathFromTileCoord(_:toTileCoord:)
and replace it with the following:
// 1 if self.dataSource == nil { return nil } let dataSource = self.dataSource! // 2 var closedSteps = Set<ShortestPathStep>() var openSteps = [ShortestPathStep(position: fromTileCoord)] while !openSteps.isEmpty { // 3 let currentStep = openSteps.removeAtIndex(0) closedSteps.insert(currentStep) // 4 if currentStep.position == toTileCoord { println("PATH FOUND : ") var step: ShortestPathStep? = currentStep while step != nil { println(step!) step = step!.parent } return [] } // 5 let adjacentTiles = dataSource.walkableAdjacentTilesCoordsForTileCoord(currentStep.position) for tile in adjacentTiles { // 6 let step = ShortestPathStep(position: tile) if closedSteps.contains(step) { continue } let moveCost = dataSource.costToMoveFromTileCoord(currentStep.position, toAdjacentTileCoord: step.position) if let existingIndex = find(openSteps, step) { // 7 let step = openSteps[existingIndex] if currentStep.gScore + moveCost < step.gScore { step.setParent(currentStep, withMoveCost: moveCost) openSteps.removeAtIndex(existingIndex) insertStep(step, inOpenSteps: &openSteps) } } else { // 8 step.setParent(currentStep, withMoveCost: moveCost) step.hScore = hScoreFromCoord(step.position, toCoord: toTileCoord) insertStep(step, inOpenSteps: &openSteps) } } } return nil |
This is an important method, so let’s take it section by section:
- If there’s no valid data source then you can exit early. If there is one, you set up a shadowed local variable to unwrap it.
- Set up the data structures to keep track of the steps. The open steps list starts with the initial position.
- Remove the lowest F cost step from the open list and add it to the closed list. Because the list is ordered, the first step is always the one with the lowest F cost.
- If the current step is the destination, you’re done! For now, you’re just logging the path out to the console.
- Get the adjacent tiles coordinates of the current step and begin looping through them.
- Get the step and check that it isn’t already in the closed list. If not, calculate the movement cost.
- If the step is in the open list, then grab that version of the step. If the current step and movement score is better than the old score, then replace the step’s existing parent with the current step.
- If the step isn’t in the open list then compute the H score and add it.
Build and run to try it out! If you touch the tile shown below:
You should see this in the console:
pos=[col=22 row=3] g=9 h=0 f=9 pos=[col=21 row=3] g=8 h=1 f=9 pos=[col=20 row=3] g=7 h=2 f=9 pos=[col=20 row=2] g=6 h=3 f=9 pos=[col=20 row=1] g=5 h=4 f=9 pos=[col=21 row=1] g=4 h=3 f=7 pos=[col=22 row=1] g=3 h=2 f=5 pos=[col=23 row=1] g=2 h=3 f=5 pos=[col=24 row=1] g=1 h=4 f=5 pos=[col=24 row=0] g=0 h=0 f=0 |
Remember the path is built backwards, so you have to read from bottom to top to see what path the algorithm has chosen. Try to match these up to the tiles in the maze so you can see that it really is the shortest path!
Following the Yellow Brick Path
Now that the path is calculated, you just have to make the cat follow it. The cat will have to remember the whole path, and then follow it step by step.
Open Cat.swift and find moveTo(_:)
. Add the following code to the end of the method, after the line that sets shortestPath
:
if let shortestPath = shortestPath { for tileCoord in shortestPath { println("Step: \(tileCoord)") } } |
The pathfinder isn’t actually yet returning the path, so switch to AStarPathfinder.swift. Remember that when the algorithm finishes, it has a path from the final step back to the beginning. This needs to be reversed, and returned to the caller as an array of TileCoord
s instead of ShortestPathStep
s.
Add the following helper method to the AStarPathfinder
class:
private func convertStepsToShortestPath(lastStep: ShortestPathStep) -> [TileCoord] { var shortestPath = [TileCoord]() var currentStep = lastStep while let parent = currentStep.parent { // if parent is nil, then it is our starting step, so don't include it shortestPath.insert(currentStep.position, atIndex: 0) currentStep = parent } return shortestPath } |
You’re reversing the array by inserting each step’s parent at the beginning of an array until the beginning step is reached.
Inside shortestPathFromTileCoord(_:toTileCoord:)
, find the block of code inside the if currentStep.position == toTileCoord {
statement that logs out the path. Replace it with the following code:
return convertStepsToShortestPath(currentStep) |
This will run your helper method to put the steps in the correct order and return that path.
Build and run. If you try moving the cat, you should see this in the console:
Step: [col=24 row=1] Step: [col=23 row=1] Step: [col=22 row=1] Step: [col=21 row=1] Step: [col=20 row=1] Step: [col=20 row=2] Step: [col=20 row=3] Step: [col=21 row=3] Step: [col=22 row=3] |
Yes! Now you have tile coordinates ordered from start to finish (instead of reversed), nicely stored in an array for you to use.
Getting the Cat on the Path
The last thing to do is to go through the shortestPath
array and animate the cat to follow the path.
Add the following method to the Cat
class:
func popStepAndAnimate() { if shortestPath == nil || shortestPath!.isEmpty { // done moving, so stop animating and reset to "rest" state (facing down) removeActionForKey("catWalk") texture = SKTexture(imageNamed: "CatDown1") return } // get the next step to move to and remove it from the shortestPath let nextTileCoord = shortestPath!.removeAtIndex(0) println(nextTileCoord) // determine the direction the cat is facing in order to animate it appropriately let currentTileCoord = gameScene.tileMap.tileCoordForPosition(position) // make sure the cat is facing in the right direction for its movement let diff = nextTileCoord - currentTileCoord if abs(diff.col) > abs(diff.row) { if diff.col > 0 { runAnimation(facingRightAnimation, withKey: "catWalk") } else { runAnimation(facingLeftAnimation, withKey: "catWalk") } } else { if diff.row > 0 { runAnimation(facingForwardAnimation, withKey: "catWalk") } else { runAnimation(facingBackAnimation, withKey: "catWalk") } } runAction(SKAction.moveTo(gameScene.tileMap.positionForTileCoord(nextTileCoord), duration: 0.4), completion: { let gameOver = self.updateState() if !gameOver { self.popStepAndAnimate() } }) } |
This method pops one step off the array and animates the movement of the cat to that position. The cat has a walking animation too, so the method will start and stop that as appropriate in addition to ensuring the cat is facing in the correct direction.
At the end of the method inside the runAction(_:duration:completion:)
call, you need to update the game’s state to check for dogs, bones, etc. and then schedule another call to popStepAndAnimate()
if there’s another step along the path.
Finally, change the code in moveToward(_:)
to call popStepAndAnimate()
instead of printing out the steps:
if let shortestPath = shortestPath { popStepAndAnimate() } |
Build and run, and. . .
The cat automatically moves to the final destination that you touch, collects bones and vanquishes menacing dogs! :]
Note: As you play the game, you’ll see that if you select a new location before it has reached the previous one, the cat moves strangely. That’s because the current movement path is interrupted and another one started. Since this tutorial isn’t focused on gameplay, we’ll gloss over this for now although the final project download at the end of this tutorial has this issue fixed – in Cat.swift look for references to currentStepAction
and pendingMove
.
Congratulations! You have now implemented A* pathfinding in a simple Sprite Kit game from scratch! :]
Bonus: Diagonal Movement
What if you also want to let the cat move diagonally?
You just have to update two functions:
walkableAdjacentTilesCoordForTileCoord(_:)
: include the diagonal tiles as well.costToMoveFromTileCoord(_:toAdjacentTileCoord:)
: calculate an appropriate movement cost for diagonal movement.
You might wonder how you should compute the cost for diagonal movement. This is actually quite easy with some simple math!
The cat is moving from the center of a tile to another, and because the tiles are squares, A, B and C form a right-angled triangle as in the diagram below:
According to the Pythagorean Theorem, C² = A² + B², so:
C = √(A² + B²) with A = B = 1 (The movement cost to move from a square to another = G cost) C = √(2) C ≈ 1.41
So the cost to move diagonally is 1.41. In comparison, moving left then up costs 2, so diagonal movement is clearly better!
Now, computing with integers is more efficient than floats, so instead of using floats to represent the cost of a diagonal move, you can simply multiply the costs by 10 and round them. So horizontal and vertical moves will cost 10, and diagonal moves 14.
Time to try this out! First replace costToMoveFromTileCoord(_:toAdjacentTileCoord:)
in Cat.swift:
func costToMoveFromTileCoord(fromTileCoord: TileCoord, toAdjacentTileCoord toTileCoord: TileCoord) -> Int { return (fromTileCoord.col != toTileCoord.col) && (fromTileCoord.row != toTileCoord.row) ? 14 : 10 } |
In order to add the diagonals to the walkable adjacent tiles, first add some new helper properties to the TileCoord
class in TileMap.swift:
/** coordinate top-left of self */ var topLeft: TileCoord { return TileCoord(col: col - 1, row: row - 1) } /** coordinate top-right of self */ var topRight: TileCoord { return TileCoord(col: col + 1, row: row - 1) } /** coordinate bottom-left of self */ var bottomLeft: TileCoord { return TileCoord(col: col - 1, row: row + 1) } /** coordinate bottom-right of self */ var bottomRight: TileCoord { return TileCoord(col: col + 1, row: row + 1) } |
Back in Cat.swift, modify walkableAdjacentTilesForTileCoord(_:)
to return the diagonally adjacent squares:
func walkableAdjacentTilesCoordsForTileCoord(tileCoord: TileCoord) -> [TileCoord] { var canMoveUp = gameScene.isWalkableTileForTileCoord(tileCoord.top) var canMoveLeft = gameScene.isWalkableTileForTileCoord(tileCoord.left) var canMoveDown = gameScene.isWalkableTileForTileCoord(tileCoord.bottom) var canMoveRight = gameScene.isWalkableTileForTileCoord(tileCoord.right) var walkableCoords = [TileCoord]() if canMoveUp { walkableCoords.append(tileCoord.top) } if canMoveLeft { walkableCoords.append(tileCoord.left) } if canMoveDown { walkableCoords.append(tileCoord.bottom) } if canMoveRight { walkableCoords.append(tileCoord.right) } // now the diagonals if canMoveUp && canMoveLeft && gameScene.isWalkableTileForTileCoord(tileCoord.topLeft) { walkableCoords.append(tileCoord.topLeft) } if canMoveDown && canMoveLeft && gameScene.isWalkableTileForTileCoord(tileCoord.bottomLeft) { walkableCoords.append(tileCoord.bottomLeft) } if canMoveUp && canMoveRight && gameScene.isWalkableTileForTileCoord(tileCoord.topRight) { walkableCoords.append(tileCoord.topRight) } if canMoveDown && canMoveRight && gameScene.isWalkableTileForTileCoord(tileCoord.bottomRight) { walkableCoords.append(tileCoord.bottomRight) } return walkableCoords } |
Note that the code to add the diagonals is a bit more complicated than for horizontal/vertical movement. Why is this?
The code enforces the rule that if the cat is to move, say, diagonally to the tile to the bottom-left, it must also be able to move in both the down and left directions. This prevents the cat from walking through walls. The following diagram illustrates this:
In the diagram,
- O = Origin
- T = Top
- B = Bottom
- L = Left
- R = Right
- TL = Top – Left
- …
Take for example the case shown in the top left of the above image.
The cat wants to go from the origin (O) to the bottom left diagonal square. If there is a wall to the left or below (or both), then moving diagonally would cut through the corner of a wall (or two). So the bottom-left direction is open only if there is no wall to the left or below.
Tip: You can simulate different type of terrain by updating costToMoveFromTileCoord(_:toAdjacentTileCoord:)
to take the terrain type into consideration. Lowering the cost will result in the cat preferring those squares; increasing it will cause the cat to tend to avoid the squares if a better route is available.
Build and run your project. Verify that the cat does, indeed, take the diagonal when it can.
A challenge
How would you give the cat automatic dog-avoidance behaviour? Rather than having to manually navigate the cat around a dog to get to a bone, what could you do to have the cat automatically choose the dog-free route if one is available?
Where to Go From Here?
Download the final project with all of the code from the tutorial (including diagonal movements and dog avoidance behaviour).
Congratulations, you now know the basics of the A* algorithm and how to implement it! You should be able to:
- Implement the A* algorithm in your own game
- Refine it as necessary (by allowing different kind of terrain, better heuristics, etc…) and optimize it
A great place to go to for more information on the A* algorithm is Amit’s A* Pages.
If you have any questions or comments about this tutorial, please join the forum discussion below!
The post How To Implement A* Pathfinding with Swift appeared first on Ray Wenderlich.