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
- Source code for the example project is available on GitHub
- Understanding Component Entity Systems
- Hexagonal Grids – Neighbours
- About GameplayKit
Leave a Reply