Demolition Page 2 - Extending our playground with GKGridGraph

GKGridGraph Solid Objects Removed.png

We continue our playground exploration of GKGameplayKit, SpriteKit and Swift 2.0 by actually creating some player objects and helping them navigate their way around asking GKGridGraph's path finding abilities. If you didn't read the first page, now's the time to catch up.

Remember, I split the documentation up between the playground and the blog, so keep playing with both. 

The Problem

We have a map, but no way of getting around it. We would have to spend a lot of time looking at each square, figuring out if the square above is the right kind to let us walk in it... and on and on. Luckily GameplayKit provides some classes focused on solving this, and we are going to look at the one the is just right for a 2D game. 

We are going to add our GKGridGraph to the LevelScene object [1]. There are a couple of choices for constructing it, but we are going to start with a mode that will not only create a grid of squares, but fill them with nodes (places the player can be) that are correctly connected to each other. 

    gridGraph = GKGridGraph(fromGridStartingAt: int2(0,0), width: Int32(levelData.gridWidth), height: Int32(levelData.gridHeight), diagonalsAllowed: false)

It's quite simple, we provide the starting *grid* location, the *grid* width and height, and finally tell it if players are allowed to move diagonally or not. In our case, not. I have added some code to provide an overlay on our map to highlight the nodes (white dots) and draw arrows to nodes you can move to. This will help us see the impact of the changes we make. If we didn't nothing else we would see: 

GKGridGraph is really exactly the same as its parent, GKGraph. It is made up of node (GKGridGraphNode's in the case of GKGridGraph, but even they are just sub-classes of GKGraphNodes) that know which other nodes (or places) you can get to from there. GKGridGraph then provides a way of getting the right node for a specific grid location: 

let nodeToRemove = self.gridGraph.nodeAtGridPosition(int2(gridX.int32,gridY.int32))

The above code will get us a a node at gridX,gridY. Note that we pass in the pair of coordinates as an int2, which you may not have seen before (I've also added some other functions [2]). We'll come back around to these later but just for now these types enable Apple to be extremely efficient in processing them on the CPU or GPU. So, no we get a node (if there is one at this location), and we could have a look at the other nodes it is connected to. 

In fact this is a good point to get a node, we want to remove nodes from the graph if they are of type .Solid or .Void as the player will never be able to move to those squares. One of the nice things about GKGridGraph is that it will automatically keep the graph updated when we remove those nodes. Here's the code that runs in the block that creates the sprites

switch tile{
case .Solid,.Void:
    let nodeToRemove = self.gridGraph.nodeAtGridPosition(int2(gridX.int32,gridY.int32))
    self.gridGraph.removeNodes([nodeToRemove!])

default:
    break
}

When we do that we immediately get a graph of nodes that makes a bit more sense

See how now you can't move into the walls? When we want to make the players move we will want GamplayKit to use these connections to find the paths from one point to another. But we aren't done yet... Remember we also randomly add destructible blocks? We need to remove those nodes from the graph too.

public func randomlyAddDestructable(levelData:LevelData, tile:MapSquare, gridX:Int,gridY:Int){
    if arc4random() % 100 < 20{
        if self.levelData[gridX,gridY] == .Ground{
            //Looks like a bug. Subscripts must be explicitly unwrapped
            self.levelData![gridX,gridY] = MapSquare.Destructable
            if let nodeToRemove = self.gridGraph.nodeAtGridPosition(int2(gridX.int32,gridY.int32)){
                gridGraph.removeNodes([nodeToRemove])
            }
        }
    }
}

Which gives us our final graph

Making things move

We need a "something" to navigate around this grid that we have created. We'll need to tweak the kind of Sprite we create when find a player MapSquare's spriteForSquare

switch self {
case .Player1, .Player2, .Player3, .Player4:
    sprite = PlayerSprite(color: color, size: CGSize(width: level.squareSize, height: level.squareSize))
default:
    sprite = SKSpriteNode(color: color, size: CGSize(width: level.squareSize, height: level.squareSize))
}

PlayerSprite is special in that it implements the UpdateableLevelEntity protocol, which LevelScene will use to know that the object wants to be updated when the level updates [3]

public override func update(currentTime: NSTimeInterval) {

    if let arena = childNodeWithName("Arena"){
        for child in arena.children{
            if let updateable = child as? UpdateableLevelEntity{
                updateable.update(currentTime, inLevel: self)
            }
        }
    }
}

It's in this update() method we want our players to plan a route, so let's have a look at that.

class PlayerSprite : SKSpriteNode,UpdateableLevelEntity {
    var route = [GKGridGraphNode]()
    
    func update(currentTime:NSTimeInterval, inLevel:LevelScene){
        return
        //Plan a route if there is no current moves left in the 
        //route and we aren't currently moving
        if route.count == 0 && !hasActions(){
            let currentNode = inLevel.gridGraph.nodeAtGridPosition(position.gridPoint(inLevel.levelData.squareSize.int32))
            
            //Not a great idea if the grid ever gets full!!
            repeat{
                let rx = Int32(Int(arc4random()) % inLevel.levelData.gridWidth)
                let ry = Int32(Int(arc4random()) % inLevel.levelData.gridHeight)
                
                if let destinationNode = inLevel.gridGraph.nodeAtGridPosition(int2(rx,ry)){
                    if let path = inLevel.gridGraph.findPathFromNode(currentNode!, toNode: destinationNode) as? [GKGridGraphNode]{
                        route = path
                    } else {
                        fatalError("Got a very unexpected output from findpath")
                    }
                }
                
                if route.count > 0 {
                    route.removeAtIndex(0)
                }
            } while route.count == 0
        }
        
        if !hasActions(){
            let currentPosition = position.gridPoint(inLevel.levelData.squareSize.int32)
            
            let nextNode = route.removeAtIndex(0)
            
            let dx = nextNode.gridPosition.x - currentPosition.x
            let dy = nextNode.gridPosition.y - currentPosition.y
            
            runAction(SKAction.moveByX(CGFloat(dx.int * inLevel.levelData.squareSize), y: CGFloat(dy.int * inLevel.levelData.squareSize), duration: 0.2))
            
        }
    }
}

The PlayerSprite stores a route (the memory of where it's heading). The logic is very simple: 

  • If you don't have any locations in your route, and you aren't moving, then plan a new route to a random square
  • Otherwise, if you have a route and aren't moving, take the next location in the route off the array and run an action to move there. 

We want to focus on the route planning part. GameplayKit makes this incredibly simple. 

if let destinationNode = inLevel.gridGraph.nodeAtGridPosition(int2(rx,ry)){
    if let path = inLevel.gridGraph.findPathFromNode(currentNode!, toNode: destinationNode) as? [GKGridGraphNode]{
        route = path
    } else {
        fatalError("Got a very unexpected output from findpath")
    }
}

We take our randomly generated map location, and then use findPathFromNode (our current position) to the destination node we got from nodeAtGridPosition. It really is that simple. GameplayKit returns an array of all the squares from our current location to the destination location. We can then just pull each off and run a SpriteKit action that will move us to that next node

let nextNode = route.removeAtIndex(0)

let dx = nextNode.gridPosition.x - currentPosition.x
let dy = nextNode.gridPosition.y - currentPosition.y

runAction(SKAction.moveByX(CGFloat(dx.int * inLevel.levelData.squareSize), y: CGFloat(dy.int * inLevel.levelData.squareSize), duration: 0.2))

The result is... some players moving around the map!

 

That's it for this instalment, don't forget to download and run the playground and most importantly, play with it!


  1. Have a look in the Sources folder, code that we aren't focusing on in the blog we be stashed there to keep the main page simple. 
  2. I've just added some extensions to Int, Int32 and CGFloat to make it easy to transform between the types GameplayKit wants to use, and the types SpriteKit wants to use. You can find them in SpriteKitExtensions.swift in sources
  3. For those who have dug into the GameplayKit videos... You will see somethings each week that may not look like they exploit GameplayKit's features... hang in there. Firstly I don't want to overwhelm beginners with everthing, and secondly, we are going to come around and fix the problems that occur when you *don't* use a GameplayKit-like approach later on.