Looking at GameplayKit

Introduction

This article explores the use of Entity Component System (ECS), Pathfinding and State Machine components in the GameplayKit Framework. I don’t make any claims of expertise on the topic of GameplayKit, other than I’m enjoying exploring an interesting topic that takes me through the fundamentals of Computer Science.

Entity Component System

I’ve done some research on the origins of ECS and found implementations that focused on Entities, Components and Systems – three distinct parts, where the Entity is just a container, components are just data and the system combines them with logic. So my challenge was how to blend this into the object oriented world of SpriteKit. I’ve focused on components that participate in the run loop e.g. a move component and a health component that are constantly being invoked in an update function. I skipped the typical sprite component because it seems a bit of an over kill, but I might revisit that again. From my research I found the idea that components can simply be used to tag an entity and don’t need to be active. The break through in understanding for me was to make the system as a separate class e.g. MoveSystem inheriting from GKComponentSystem that is invoked from the scene e.g. in response to the user tapping. The MoveSystem takes source and destination coordinates and prepares a sequence of SKActions then passes these on to the appropriate MoveComponent which is then invoked on the next iteration of the run loop.

// ComponentSystem.swift (protocol)
protocol ComponentSystem {
    associatedtype Input
    var delegate: GamePlayScene! { get set }
    func queue(_ input: Input, for entity: VisualEntity)
}

// MoveSystem.swift (implementation)
class MoveSystem: GKComponentSystem<GKComponent>, ComponentSystem {
    func queue(_ input: Move, for entity: VisualEntity) {
        var sequence = [SKAction]()
        let healthSystem = self.system(ofType: HealthSystem.self, from: self.delegate.componentSystems!)!

        for node in input.path {
            let location = self.map.centerOfTile(atColumn: Int(node.gridPosition.x), row: Int(node.gridPosition.y))
            let action = SKAction.move(to: location, duration: 1)
            let completionHandler = SKAction.run({
                healthSystem.queue(Health(data: Int(node.costToEnter)), for: entity)
            })

            sequence += [action, completionHandler]
        }

        entity.stateMachine.enter(VisualEntityPendingMove.self)
        sequence.insert(SKAction.run({ entity.stateMachine.enter(VisualEntityMoving.self) }), at: 0) // Add at beginning
        sequence.append(SKAction.run({ entity.stateMachine.enter(VisualEntityIdle.self) })) // Add at end
            
        if let entity = entity as? GKEntity {
            let moveComponent = entity.component(ofType: MoveComponent.self)
            moveComponent?.sequence = sequence
        }
    }
}

Pathfinding

SKTilemapNode supports hexagonal tiles, but unfortunately GameplayKit doesn’t currently provide a graph designed specifically for hexagonal tile path finding. Fortunately though, it’s not too difficult to roll our own custom hexagonal path finding graph that inherits the core functionality of GKGraph. We just need to customise the connection of nodes in the graph, allowing for the fact that hexagonal tiles have 6 edges that can be traversed and have tile columns that are offset depending on parity.

// HexGraph.swift
class HexGraph<NodeType : HexGraphNode> : GKGraph {
    func connectAdjacentNodes() {
        let evenParityKey = "Even"
        let oddParityKey = "Odd"
        let A = "A"
        let B = "B"
        let C = "C"
        let D = "D"
        let E = "E"

        var neighbourParity = [
            evenParityKey: [
                A: vector_int2(x: -1, y: 1),
                B: vector_int2(x: 0, y: 1),
                C: vector_int2(x: 1, y: 0),
                D: vector_int2(x: -1, y: 0),
                E: vector_int2(x: -1, y: -1)
            ],
            oddParityKey: [
                A: vector_int2(x: 0, y: 1),
                B: vector_int2(x: 1, y: 1),
                C: vector_int2(x: 1, y: 0),
                D: vector_int2(x: 1, y: -1),
                E: vector_int2(x: 0, y: -1)
            ]
        ]

        for node in self.nodes as! [NodeType] {
            let nodePosition = node.gridPosition!
            let parity = nodePosition.y & 1 // Derive parity from Y coordinate e.g. (0,3) (1,3) (2,3) represents an odd row (3)
            let neighbourOffsets = (parity == 0 ? neighbourParity[evenParityKey] : neighbourParity[oddParityKey])!
            var neighbourNodes = [NodeType]()
            var neighbourPosition: vector_int2

            for offset in neighbourOffsets.values {
                neighbourPosition = vector_int2(x: nodePosition.x + offset.x, y: nodePosition.y + offset.y)
                if exists(atGridPosition: neighbourPosition) {
                    neighbourNodes.append(self.node(atGridPosition: neighbourPosition))
                }
            }

            node.addConnections(to: neighbourNodes, bidirectional: true)
        }
    }
}

State Machine

I’ve looked briefly at GKStateMachine, just using the constraint function canEnterState as a way to prevent user touches from invoking the Move System while the an entity is moving. I would prefer to encapsulate the state machine inside of the entity, but for now stateMachine is a public property.

if !self.roverEntity.stateMachine.canEnterState(VisualEntityPendingMove.self) {
    return
}

Summary

We’ve had a brief look at GameplayKit, specifically how we might approach implementing GKComponentSystem in SpriteKit, how to perform path finding with an SKTilemapNode that uses hexagonal tiles and seen a simple state machine control user interactions.

References

  1. Source code for the example project is available on GitHub
  2. Understanding Component Entity Systems
  3. Hexagonal Grids – Neighbours
  4. About GameplayKit

7 responses to “Looking at GameplayKit”

  1. This is really excellent. Thanks for sharing it. One thing I notice is that your pathfinding does not use the cost to enter a hex. In the Xcode print it says “GKGraphNode: Using default costToNode”. I looked around but could not find a way to turn on the cost to enter functionality. Do you know how to do this?

    Andy

    • If you put a breakpoint in the cost(to node:) function you’ll see it does actually get called. The console output is from here, since I call super.cost(to: node) + self.costToEnter to determine the result.

  2. Thanks for your fast reply. You are right, it does work. The problem was that I was not entering the cost to enter properly for the tiles. Thanks again for the great help your post is to me.

  3. Hi. One last question, do you know if it is possible to turn off the console report of “GKGraphNode: Using default costToNode”. I am using pathfinding with cost to enter to highlight the possible hexes in range of the unit. This console report creates a ton of spam and slows down the program. Thanks again for your help.

  4. Hi Mark. You were right about removing the super.cost line. That removed the console report. One other thing I have found out that might help others using your code. I found out that if the map becomes too big, setting up the map is too slow. I fixed this by modifying the HexGraph class function node and function exists as shown in this post.

    http://stackoverflow.com/questions/43561771/swift-more-efficient-way-to-search-array-for-match-of-different-types

Leave a reply to taavtech Cancel reply