Now that this simple jigsaw puzzle game has some (unnecessarily) advanced architecture, it’s time to take advantage of it to start making this “game” into a game.
Identifying Entities in the Scene
I have all of these sprites on screen representing puzzle pieces and it’s these sprites that the player directly interacts with.
Right now there’s no easy way to identify which entity any given sprite belongs to. This is a problem because ultimately the sprite is a representation of the properties of an entity’s components. In other words, the sprite is one leaf on this giant tree that is an entity.
Thankfully, SKNode
instances have an optional entity
property that can be set and used to identify the entity that the sprite belongs to.
(Whew, dodged that bullet.)
In SpriteComponent.swift
, I’ll add the following under the initialiser.
override func didAddToEntity() { self.sprite.entity = self.entity }
Now, when the component is attached to an entity, it will add a reference to that entity to the sprite itself.
Identifying Sprites
Puzzle pieces can overlap so now I need a way to identify which nodes are under a given point, then get the one that is highest in the stack.
In GameScene.swift
, I’ll add the following helper method before the closing brace:
// 1. func topNode( at point : CGPoint ) -> SKNode? { // 2. var topMostNode : SKNode? = nil // 3. let nodes = self.nodes(at: point).filter() { $0.entity != nil } for node in nodes { // 4. if topMostNode == nil { topMostNode = node continue } // 5. if topMostNode!.zPosition < node.zPosition { topMostNode = node } } return topMostNode }
- This method takes a
CGPoint
and attempts to find the topmost node (as defined by its z-position), returning nil if it’s unsuccessful. - This optional SKNode variable called
topMostNode
will be set it to nil in case there are no nodes at the current point, and only be given a value if a node is found. - I’ll ask the scene for all the nodes at the current point, then apply a filter to only get those nodes that have an entity attached to them. I’ll then loop through all that match this condition.
- If
topMostNode
is nil, then the current node in the loop is the first node in the array and by default must be the topmost node so far. Set it totopMostNode
and continue on to the next one. - If I reach this point, then I know that
topMostNode
has been set at least once and I can safely force unwrap it. I then need to compare it’szPosition
with the current node’szPosition
and, if the current node’s is higher, set that node to be thetopMostNode
.
Handling Input
I have to think carefully about how I want to manage interaction. The input methods on iOS and macOS are, of course, different but the handling of the input will be identical.
For example, clicking and dragging a piece with a mouse will produce the same result on screen as touching a piece and moving it with a finger.
SpriteKit is set up so that any subclasses of SKScene
are part of the responder chain for touch events on iOS or mouse events on macOS. My initial thought was to tease out these input events into separate components or systems but, like many things in Apple Development World, fighting against established paradigms is a shortcut to migraines.
Instead I came up with a compromise: the most that any SKScene
subclass should do is standardise the input data. It should then pass this standardised data to an interaction system for processing.
This will help decouple the actual game logic (which will be identical across platforms) from the data collection (which is different across platforms). It would also respect the role of systems and help keep the main scene as lean as possible.
Receiving the Input—Mouse
In GameScene.swift
, at the start of didMove(to:)
, I’ll add the following line:
self.setupInteractionHandlers()
This method will give both macOS and iOS the opportunity to set up any platform-specific interaction functionality before the game has started running.
I’ll create a new file called GameScene+mouse.swift
. This time, I’ll make sure that it’s only included in the macOS target, then add it to the Jigsaw (macOS)
group and replace the contents with:
import AppKit extension GameScene { // 1. func setupInteractionHandlers() { } // 2. override func mouseDown(with event: NSEvent) { } override func mouseDragged(with event: NSEvent) { } override func mouseUp(with event: NSEvent) { } }
- There’s nothing that I actually need to set up for macOS, but this method is required for conformance.
- These are the method stubs for all the mouse events that I currently want to keep track of.
Receiving the Input—Touch
In the Jigsaw (iOS)
group, I’ll create another Swift file called GameScene+touch.swift
and make sure that it’s only included in the iOS target.
I’ll then replace the contents with the following:
import UIKit extension GameScene { // 1. func setupInteractionHandlers() { let panRecogniser = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) // 2. panRecogniser.maximumNumberOfTouches = 1 self.view?.addGestureRecognizer(panRecogniser) } // 3. @objc func handlePan(_ recogniser : UIPanGestureRecognizer ) { } }
- It’s easy to use iOS gesture recognisers in SpriteKit games and they provide a higher level of abstraction for input, making them easier to work with than the
touches<Status>(_:with:)
methods. I’ll start by adding a pan gesture to handle piece movement. - If iOS detects multiple fingers during a pan gesture, it will average the position between them which could cause the puzzle piece to flick around if the player adds additional fingers to the gesture while it’s in progress. Setting this property forces iOS to only recognise the first touch and ignore later, additional touches.
- A stub method for handling the actual recognition—the
@objc
is required as it needs to be exposed to Objective C.
Creating the Interaction Component
Next, in the Shared
group, I’ll create a file called InteractionComponent.swift
and make sure that it’s included with both the iOS and macOS targets.
I’ll replace the contents with the following:
// 1. enum ActionState : Equatable { case began case changed case ended } // 1. enum Action : Equatable { case none case move(ActionState, CGPoint?) } class InteractionComponent : GKComponent { // 1. var state : Action = .none }
- This enum defines the range of possible states actions can be in. Discrete actions like taps or single clicks might not need to record changing states, but for something like move it’s useful to know when the interaction began, get an update each time it changes, and get notified when it ends. I’ll also make it
Equatable
for some easy comparisons later. - This enum defines the range of allowed actions for interaction. Right now, the input is going to be limited to moving pieces. Defining the actions like this allows me to define whether or not that action is continuous (and therefore needs to record its state over time). It also defines what type the input data should be normalised to when it’s received. Moving pieces is a continuous interaction and the type of data I’m interested in is the location (as a
CGPoint
) of the finger/cursor as it moves across the screen. - Other systems can update this property at any time to inform the interaction system that an entity has been interacted with.
I need to add this component to each of the puzzle entities. Back in GameScene.swift
, in the for loop of didMove(to:)
, under let positionComponent = PositionComponent(currentPosition: piece.position, targetPosition: piece.position)
, I’ll add the following:
let interactionComponent = InteractionComponent() puzzlePiece.addComponent(interactionComponent)
Keeping Track of the Pieces
Using my topNode(at:)
method, I could just query the scene to get the current node under the pointer/touch location every time the input is updated. This assumes that it’s the same puzzle piece throughout the operation but this assumption is problematic.
Users can move their fingers or mouses pretty fast. If on the next frame, their finger or mouse is outside the bounds of the piece they were moving but inside the bounds of another piece, then that other piece will suddenly start getting the interaction updates.
Instead, I’ll add a property to GameScene.swift
that keeps track of an entity once it starts being interacted with and then only update that entity with the new information until the interaction ends.
At the top of GameScene.swift
, under var puzzle : Puzzle!
, I’ll add the following:
var entityBeingInteractedWith : GKEntity?
Handling Mouse Events
In GameScene+mouse.swift
, I’ll replace the method stubs with:
override func mouseDown(with event: NSEvent) { // 1. let point = event.location(in: self) guard let hasEntity = self.topNode(at: point)?.entity else { return } // 2. self.entityBeingInteractedWith = hasEntity // 3. self.entityBeingInteractedWith?.component(ofType: InteractionComponent.self)?.state = .move(.began, point) } override func mouseDragged(with event: NSEvent) { // 4. let point = event.location(in: self) self.entityBeingInteractedWith?.component(ofType: InteractionComponent.self)?.state = .move(.changed, point) } override func mouseUp(with event: NSEvent) { // 5. let point = event.location(in: self) self.entityBeingInteractedWith?.component(ofType: InteractionComponent.self)?.state = .move(.ended, point) self.entityBeingInteractedWith = nil }
- This method fires when the mouse button is first pressed down. I get the location and make sure that there is a node with an entity attached there. If not, there’s no point in continuing
- If there is a node with an entity, then I set my temporary property. This is the entity I’ll keep track of for the duration of the interaction.
- If this entity has an interaction component, I’ll update it to let it know that its entity is involved in a movement.
- I’ll get the current location of the mouse cursor and use optional chaining to get the entity being interacted with (if there is one) and update its interaction component (if it has one) to let it know that it should change position.
- Finally, when the mouse button is released, I’ll update the entity’s interaction component once again, letting it know that the action has finished, then release the entity I was keeping track of.
Handling Touch Events
Similarly, in GameScene+touch.swift
, I’ll replace handlePan(_:)
with:
@objc func handlePan(_ recogniser : UIPanGestureRecognizer ) { // 1. let point = self.scene?.convertPoint(fromView: recogniser.location(in: self.view)) ?? .zero // 2. if recogniser.state == .began { self.entityBeingInteractedWith = self.topNode(at: point)?.entity } // 3. guard let hasEntity = self.entityBeingInteractedWith else { return } // 4. guard recogniser.numberOfTouches <= 1 else { self.entityBeingInteractedWith = nil hasEntity.component(ofType: InteractionComponent.self)?.state = .none return }// 5. switch recogniser.state { case .began: hasEntity.component(ofType: InteractionComponent.self)?.state = .move(.began, point) case .changed: hasEntity.component(ofType: InteractionComponent.self)?.state = .move(.changed, point) case .ended, .cancelled, .failed: hasEntity.component(ofType: InteractionComponent.self)?.state = .move(.ended, point) self.entityBeingInteractedWith = nil default: break }
}
- Because the recogniser was added to the view not the scene, the y-coordinates are currently flipped. This means that the point of the touch has to be converted from UIView coordinates to SpriteKit coordinates.
- If the recogniser has just begun, then I need to see if there’s a valid entity at the start location of the interaction
- If I still don’t have an entity at this point, then there’s nothing that needs to be done so I can exit early.
- Introducing a second touch and then releasing the first while continually moving the fingers can cause pieces to snap to random locations. In order to stop this, I’ll make sure there’s only a maximum of one touch being recognised (an inequality test is used as, when the pan gesture ends, there will be 0 touches) and, if not, I’ll cancel the whole operation (this isn’t the greatest player experience, but it’s the one with the least complications).
- I’ll switch on the current recogniser state and update the component, nilling out the
entityBeingInteractedWith
property if the recogniser stops recognising for any reason.Updating the Scene
Almost there!
With all this in place, I can now use the interaction component’s data to update the sprites on screen. I am, however, about to override
update(deltaTime:)
, which means creating a new system.In the
Shared
group, I’ll add a new file calledInteractionComponent+Interact.swift
and make sure that it’s included in both the macOS and iOS targets. Then I’ll add the following:extension InteractionComponent { override func update(deltaTime seconds: TimeInterval) { // 1. switch state { case .none: break case .move(let state, let point): self.handleMove(state: state, point: point) } } func handleMove( state : ActionState, point : CGPoint? ) { // 2. guard let positionComponent = entity?.component(ofType: PositionComponent.self) else { return } // 3. if let hasPoint = point { positionComponent.currentPosition = hasPoint } // 4. switch state { case .ended: self.state = .none default: break } } }
- I switch on the current state of interaction and, if we’re in a move situation, pass on the point and the current state of the move to another method. If I add additional interactions then I can add to this switch statement and call another method.
- If this entity doesn’t have a position component, then nothing can be done so I can exit and move on. No muss, no fuss.
- The CGPoint being passed in is an optional. In certain situations, I might want to inform the component that the interaction has ended but have it use the position prior to the final interaction which I can do by passing nil as the point in the enum. If I do have a point, however, it’ll get applied to the position component here.
- Finally, when the action is ended, I’ll set
state
to.none
to prevent any further unnecessary calls to this method.Fixing Bugs
If I build and run on any of the platforms, everything works as expected! I can click or touch pieces and move them around.
However, there’s a subtle bug here: when I first click or touch a piece, its centre jumps to where my finger or mouse cursor is.
It’s not the worst thing in the world, but it is a bit janky.
To fix it, I’m going to add some helper extension to CGPoint. I’ll start by adding a file to the
Shared
group calledCGPoint+math.swift
then add the following:import SpriteKit extension CGPoint { public static func - (lhs : CGPoint, rhs : CGPoint ) -> CGPoint { return CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y) } public static func + (lhs : CGPoint, rhs : CGPoint ) -> CGPoint { return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y) } }CGPoint now supports the plus and minus signs.
My initial idea was to calculate the offset when the state of the interaction is set to
began
(as an interaction only begins once).There’s a problem with this, though: the resolution of events is higher than the frame rate of the game. In other words, multiple mouse or touch events can happen before the
update(deltaTime:)
method of the interaction system is called.What this means is that, by the time
handleMove(state:point:)
is called, thestate
property could have already changed a couple of times, meaning that it’s no longer at thebegan
state but has moved on tochanged
.Instead, what I need to do is capture the fact that state was set to
began
at some point between updates.I’ll do this by replacing the current property declarations of the interaction component with these:
class InteractionComponent : GKComponent { // 1. var didBegin : Bool = false var state : Action = .none { // 2. didSet { switch state { case .move(let state, _): if actionState == .began { self.didBegin = true } default: break } } } var targetEntity : GKEntity? // 3. var offset : CGPoint = .zero }
- This flag will be set every time a new interaction has begun.
- When the
state
property is set, I can drill down into the action and see if theActionState
is set to.began
. If so, I’ll set thedidBegin
flag.- I need to keep track of the new offset of the touch location in the piece and the piece’s centre.
Finally, back in
InteractionComponent+Interact.swift
, I’ll change thehandleMove(state:point:)
to:func handleMove( state : ActionState, point : CGPoint? ) { guard let positionComponent = entity?.component(ofType: PositionComponent.self) else { return } // 1. if self.didBegin { if let hasPoint = point { offset = positionComponent.currentPosition - hasPoint } self.didBegin = false } if let hasPoint = point { // 2. positionComponent.currentPosition = hasPoint + offset } switch state { case .ended: // 3. self.state = .none offset = .zero default: break } }
- If the interaction began at any point between the last frame update and this one, then calculate the offset and store it in the property, remembering to set the
didBegin
to false to prevent this from firing again.- Make sure to apply the offset when calculating the new position of the piece. This will ensure that the piece follows the player’s finger exactly where they touched it.
- Finally, reset the offset to 0 when the interaction finishes.
The user can click or touch any point within a piece’s bounds and it will move realistically around the scene.
As a bonus, with this interaction system I can now easily create puzzles that have pieces already fixed in place (e.g. for an “easy” mode, or for a hint system). All I would need to do is set their current position to their final position and remove their interaction component to stop them responding to events—bonus functionality for very little work!
Next up, randomising their start position and adding a snapping system!