This is the second part of An Introduction to GameplayKit. If you haven't yet gone through the first part, then I recommend reading that tutorial first before continuing with this one.
Introduction
In this tutorial, I am going to teach you about two more features of the GameplayKit framework you can take advantage of:
agents, goals, and behaviors
pathfinding
By utilizing agents, goals, and behaviors, we are going to
build in some basic artificial intelligence (AI) into the game that we
started in the first part of this series.
The AI will enable our red and yellow enemy dots to target and move
towards our blue player dot. We are also going to implement pathfinding
to extend on this AI to navigate around obstacles.
For this tutorial, you can use your copy of the completed project
from the first part of this series or download a fresh copy of the
source code from GitHub.
1. Agents, Goals, and Behaviors
In GameplayKit, agents, goals and behaviors are used in combination
with each other to define how different objects move in relation to each
other throughout your scene. For a single object (or SKShapeNode in our game), you begin by creating an agent, represented by the GKAgent class. However, for 2D games, like ours, we need to use the concrete GKAgent2D class.
The GKAgent class is a subclass of GKComponent. This means that your game needs to be using an entity- and component-based structure as I showed you in the first tutorial of this series.
Agents represent an object's position, size, and velocity. You then add a behavior, represented by the GKBehaviour class, to this agent. Finally, you create a set of goals, represented by the GKGoal class, and add them to the behavior object. Goals can be used to create many different gameplay elements, for example:
moving towards an agent
moving away from an agent
grouping close together with other agents
wandering around a specific position
Your behavior object monitors and calculates all of the goals that
you add to it and then relays this data back to the agent. Let's see how
this works in practice.
Open your Xcode project and navigate to PlayerNode.swift. We first need to make sure the PlayerNode class conforms to the GKAgentDelegate protocol.
class PlayerNode: SKShapeNode, GKAgentDelegate {
...
Next, add the following code block to the PlayerNode class.
var agent = GKAgent2D()
// MARK: Agent Delegate
func agentWillUpdate(agent: GKAgent) {
if let agent2D = agent as? GKAgent2D {
agent2D.position = float2(Float(position.x), Float(position.y))
}
}
func agentDidUpdate(agent: GKAgent) {
if let agent2D = agent as? GKAgent2D {
self.position = CGPoint(x: CGFloat(agent2D.position.x), y: CGFloat(agent2D.position.y))
}
}
We start by adding a property to the PlayerNode class so that we always have a reference to the current player's agent object. Next, we implement the two methods of the GKAgentDelegate
protocol. By implementing these methods, we ensure that the player dot
displayed on screen will always mirror the changes that GameplayKit
makes.
The agentWillUpdate(_:) method
is called just before GameplayKit looks through the behavior and goals
of that agent to determine where it should move. Likewise, the agentDidUpdate(_:) method is called straight after GameplayKit has completed this process.
Our implementation of these two methods ensures that the
node we see on screen reflects the changes GameplayKit makes and that
GameplayKit uses the last position of the node when performing its
calculations.
Next, open ContactNode.swift and replace the file's contents with the following implementation:
import UIKit
import SpriteKit
import GameplayKit
class ContactNode: SKShapeNode, GKAgentDelegate {
var agent = GKAgent2D()
// MARK: Agent Delegate
func agentWillUpdate(agent: GKAgent) {
if let agent2D = agent as? GKAgent2D {
agent2D.position = float2(Float(position.x), Float(position.y))
}
}
func agentDidUpdate(agent: GKAgent) {
if let agent2D = agent as? GKAgent2D {
self.position = CGPoint(x: CGFloat(agent2D.position.x), y: CGFloat(agent2D.position.y))
}
}
}
By implementing the GKAgentDelegate protocol in the ContactNode class, we allow for all of the other dots in our game to be up to date with GameplayKit as well as our player dot.
It's now time to set up the behaviors and goals. To make this work, we need to take care of three things:
Add the player node's agent to its entity and set its delegate.
Configure agents, behaviors, and goals for all of our enemy dots.
Update all of these agents at the correct time.
Firstly, open GameScene.swift and, at the end of the didMoveToView(_:) method, add the following two lines of code:
With these two lines of code, we add the agent as a component and set the agent's delegate to be the node itself.
Next, replace the implementation of the initialSpawn method with the following implementation:
func initialSpawn() {
for point in self.spawnPoints {
let respawnFactor = arc4random() % 3 // Will produce a value between 0 and 2 (inclusive)
var node: SKShapeNode? = nil
switch respawnFactor {
case 0:
node = PointsNode(circleOfRadius: 25)
node!.physicsBody = SKPhysicsBody(circleOfRadius: 25)
node!.fillColor = UIColor.greenColor()
case 1:
node = RedEnemyNode(circleOfRadius: 75)
node!.physicsBody = SKPhysicsBody(circleOfRadius: 75)
node!.fillColor = UIColor.redColor()
case 2:
node = YellowEnemyNode(circleOfRadius: 50)
node!.physicsBody = SKPhysicsBody(circleOfRadius: 50)
node!.fillColor = UIColor.yellowColor()
default:
break
}
if let entity = node?.valueForKey("entity") as? GKEntity,
let agent = node?.valueForKey("agent") as? GKAgent2D where respawnFactor != 0 {
entity.addComponent(agent)
agent.delegate = node as? ContactNode
agent.position = float2(x: Float(point.x), y: Float(point.y))
agents.append(agent)
let behavior = GKBehavior(goal: GKGoal(toSeekAgent: playerNode.agent), weight: 1.0)
agent.behavior = behavior
agent.mass = 0.01
agent.maxSpeed = 50
agent.maxAcceleration = 1000
}
node!.position = point
node!.strokeColor = UIColor.clearColor()
node!.physicsBody!.contactTestBitMask = 1
self.addChild(node!)
}
}
The most important code that we've added is located in the if statement that follows the switch statement. Let's go through this code line by line:
We first add the agent to the entity as a component and configure its delegate.
Next, we assign the agent's position and add the agent to a stored array, agents. We'll add this property to the GameScene class in a moment.
We then create a GKBehavior object with a single GKGoal to target the current player's agent. The weight
parameter in this initializer is used to determine which goals should
take precedence over others. For example, imagine that you have a goal
to target a particular agent and a goal to move away from another agent,
but you want the targeting goal to take preference. In this case, you
could give the targeting goal a weight of 1 and the moving away goal a weight of 0.5. This behavior is then assigned to the enemy node's agent.
Lastly, we configure the mass, maxSpeed, and maxAcceleration
properties of the agent. These affect how fast the objects can move and
turn. Feel free to play around with these values and see how it affects
the movement of the enemy dots.
Next, add the following two properties to the GameScene class:
var agents: [GKAgent2D] = []
var lastUpdateTime: CFTimeInterval = 0.0
The agents array will be used to keep a reference to the enemy agents in the scene. The lastUpdateTime property will be used to calculate the time that has passed since the scene was last updated.
Finally, replace the implementation of the update(_:) method of the GameScene class with the following implementation:
override func update(currentTime: CFTimeInterval) {
/* Called before each frame is rendered */
self.camera?.position = playerNode.position
if self.lastUpdateTime == 0 {
lastUpdateTime = currentTime
}
let delta = currentTime - lastUpdateTime
lastUpdateTime = currentTime
playerNode.agent.updateWithDeltaTime(delta)
for agent in agents {
agent.updateWithDeltaTime(delta)
}
}
In the update(_:) method, we calculate the time that has passed since the last scene update and then update the agents with that value.
Build and run your app, and begin moving around the scene.
You will see that the enemy dots will slowly begin moving towards you.
As
you can see, while the enemy dots do target the current player, they do
not navigate around the white barriers, instead they try to move
through them. Let's make the enemies a bit smarter with pathfinding.
2. Pathfinding
With the GameplayKit framework, you can add complex
pathfinding to your game by combining physics bodies with GameplayKit
classes and methods. For our game, we are going to set it up so that the
enemy dots will target the player dot and at the same time navigate
around obstacles.
Pathfinding in GameplayKit begins with creating a graph of your scene. This graph is a collection of individual locations, also referred to as nodes,
and connections between these locations. These connections define how a
particular object can move from one location to another. A graph can
model the available paths in your scene in one of three ways:
A continuous space containing obstacles: This graph model allows for smooth paths around obstacles from one location to another. For this model, the GKObstacleGraph class is used for the graph, the GKPolygonObstacle class for obstacles, and the GKGraphNode2D class for nodes (locations).
A simple 2D grid: In this case, valid locations can
only be those with integer coordinates. This graph model is useful when
your scene has a distinct grid layout and you do not need smooth paths.
When using this model, objects can only move horizontally or vertically
in a single direction at any one time. For this model, the GKGridGraph class is used for the graph and the GKGridGraphNode class for nodes.
A collection of locations and the connections between them:
This is the most generic graph model and is recommended for cases where
objects move between distinct spaces, but their specific location
within that space is not essential to the gameplay. For this model, the GKGraph class is used for the graph and the GKGraphNode class for nodes.
Because we want the player dot in our game to navigate around the white barriers, we are going to use the GKObstacleGraph class to create a graph of our scene. To begin, replace the spawnPoints property in the GameScene class with the following:
The spawnPoints array contains
some altered spawn locations for the purposes of this tutorial. This is
because currently GameplayKit can only calculate paths between objects
that are relatively close to each other.
Due to the large default distance between dots in this game, a couple
of new spawn points must be added to illustrate pathfinding. Note that
we also declare a graph property of type GKObstacleGraph to keep a reference to the graph we will create.
Next, add the following two lines of code at the start of the didMoveToView(_:) method:
let obstacles = SKNode.obstaclesFromNodePhysicsBodies(self.children)
graph = GKObstacleGraph(obstacles: obstacles, bufferRadius: 0.0)
In the first line, we create an array of obstacles from the physics
bodies in the scene. We then create the graph object using these
obstacles. The bufferRadius parameter in
this initializer can be used to force objects to not come within a
certain distance of these obstacles. These lines need to be added at the
start of the didMoveToView(_:) method, because the graph we create is needed by the time the initialSpawn method is called.
Finally, replace the initialSpawn method with the following implementation:
func initialSpawn() {
let endNode = GKGraphNode2D(point: float2(x: 2048.0, y: 2048.0))
self.graph.connectNodeUsingObstacles(endNode)
for point in self.spawnPoints {
let respawnFactor = arc4random() % 3 // Will produce a value between 0 and 2 (inclusive)
var node: SKShapeNode? = nil
switch respawnFactor {
case 0:
node = PointsNode(circleOfRadius: 25)
node!.physicsBody = SKPhysicsBody(circleOfRadius: 25)
node!.fillColor = UIColor.greenColor()
case 1:
node = RedEnemyNode(circleOfRadius: 75)
node!.physicsBody = SKPhysicsBody(circleOfRadius: 75)
node!.fillColor = UIColor.redColor()
case 2:
node = YellowEnemyNode(circleOfRadius: 50)
node!.physicsBody = SKPhysicsBody(circleOfRadius: 50)
node!.fillColor = UIColor.yellowColor()
default:
break
}
if let entity = node?.valueForKey("entity") as? GKEntity,
let agent = node?.valueForKey("agent") as? GKAgent2D where respawnFactor != 0 {
entity.addComponent(agent)
agent.delegate = node as? ContactNode
agent.position = float2(x: Float(point.x), y: Float(point.y))
agents.append(agent)
/*let behavior = GKBehavior(goal: GKGoal(toSeekAgent: playerNode.agent), weight: 1.0)
agent.behavior = behavior*/
/*** BEGIN PATHFINDING ***/
let startNode = GKGraphNode2D(point: agent.position)
self.graph.connectNodeUsingObstacles(startNode)
let pathNodes = self.graph.findPathFromNode(startNode, toNode: endNode) as! [GKGraphNode2D]
if !pathNodes.isEmpty {
let path = GKPath(graphNodes: pathNodes, radius: 1.0)
let followPath = GKGoal(toFollowPath: path, maxPredictionTime: 1.0, forward: true)
let stayOnPath = GKGoal(toStayOnPath: path, maxPredictionTime: 1.0)
let behavior = GKBehavior(goals: [followPath, stayOnPath])
agent.behavior = behavior
}
self.graph.removeNodes([startNode])
/*** END PATHFINDING ***/
agent.mass = 0.01
agent.maxSpeed = 50
agent.maxAcceleration = 1000
}
node!.position = point
node!.strokeColor = UIColor.clearColor()
node!.physicsBody!.contactTestBitMask = 1
self.addChild(node!)
}
self.graph.removeNodes([endNode])
}
We begin the method by creating a GKGraphNode2D
object with the default player spawn coordinates. Next, we connect this
node to the graph so that it can be used when finding paths.
Most of the initialSpawn method
remains unchanged. I have added some comments to show you where the
pathfinding portion of the code is located in the first if statement. Let's go through this code step by step:
We create another GKGraphNode2D instance and connect this to the graph.
We create a series of nodes which make up a path by calling the findPathFromNode(_:toNode:) method on our graph.
If a series of path nodes has been created successfully, we then create a path from them. The radius parameter works similar to the bufferRadius parameter from before and defines how much an object can move away from the created path.
We create two GKGoal objects, one for following the path and another for staying on the path. The maxPredictionTime
parameter allows for the goal to calculate as best it can ahead of time
whether anything is going to interrupt the object from
following/staying on that particular path.
Lastly, we create a new behavior with these two goals and assign this to the agent.
You will also notice that we remove the nodes we create from the
graph once we are finished with them. This is a good practice to follow
as it ensures that the nodes you have created do not interfere with any
other pathfinding calculations later on.
Build and run your app one last time, and you will see two
dots spawn very close to you and begin moving towards you. You may have
to run the game multiple times if they both spawn as green dots.
Important!
In this tutorial, we used GameplayKit's pathfinding feature to enable
enemy dots to target the player dot around obstacles. Note that this
was just for a practical example of pathfinding.
For an actual production game, it would be best to implement
this functionality by combining the player targeting goal from earlier
in this tutorial with an obstacle-avoiding goal created with the init(toAvoidObstacles:maxPredictionTime:) convenience method, which you can read more about in the GKGoal Class Reference.
Conclusion
In this tutorial, I showed you how you can utilize agents,
goals, and behaviors in games that have an entity-component structure.
While we only created three goals in this tutorial, there are many more
available to you, which you can read more about in the GKGoal Class Reference.
I also showed you how to implement some advanced pathfinding
in your game by creating a graph, a set of obstacles, and goals to
follow these paths.
As you can see, there is a vast amount of functionality made
available to you through the GameplayKit framework. In the third and
final part of this series, I will teach you about GameplayKit's random
value generators and how to create your own rule system to introduce
some fuzzy logic into your game.
As always, please be sure to leave your comments and feedback below.
Post a Comment