Now that I have a game where pieces are randomly placed and the player can drag them into position to win the game, I want to make things more difficult.
When the game begins, as well as being randomly positioned, the pieces will be randomly rotated.
The snapping that indicates correct placement will then only take effect if the piece is within the threshold for distance AND within the threshold for rotation.
The correct rotation will be 0°. So long as players are within +/- 20° of this then the snapping will take over and update both the position component and the rotation component to be their target values.
Thinking in Circles
I think better in degrees than radians, so I’ll add a couple of convenience methods to CGFloat
.
In the Shared
folder, I’ll create a new file called CGFloat+rotation.swift
:
import SpriteKit extension CGFloat { // 1. func toDegrees() -> CGFloat { return ( self / CGFloat.pi ) * 180 } // 2. func toRads() -> CGFloat { return ( self / 180 ) * CGFloat.pi } }
- Adding it as a function like this allows me to take any radian value and convert it to degrees by using a chained function. The cool thing is that I can do this on straight numbers as well, for example:
let degreeAngle = 2.435.toDegrees()
- There will be situations where I want to go back the other way.
Adding a Rotation Component
The rotation of a piece is a distinct attribute and deserves its own component. I’ll create a new file called RotationComponent.swift
and add it to the Shared
folder, making sure that both the macOS and iOS targets are selected:
import GameplayKit import SpriteKit class RotationComponent : GKComponent { // 1. var currentRotation : CGFloat // 2. init( currentRotation : CGFloat ) { self.currentRotation = currentRotation super.init() } required init?(coder aDecoder: NSCoder) { fatalError("Not implmented") } }
- The
currentRotation
is a value in radians that can be changed by any of the systems. The target rotation will always be 0. - In order to support the randomised rotation of pieces at the start of a game, the component should be initialised with a rotation value.
Applying the Rotation Component
To apply the current rotation to the sprite, I’ll use the render system. This gives other systems a chance to update the component first before the final value is applied.
In SpriteComponent+Render.swift
I’ll add the following at the bottom of update(deltaTime:)
:
if let hasRotation = entity?.component(ofType: RotationComponent.self) { self.sprite.zRotation = hasRotation.currentRotation }
Adding Interaction
In InteractionComponent.swift
, I’ll add a new action so that the enum now looks like this:
enum Action : Equatable { case none case move(ActionState, CGPoint?) case rotate(ActionState, CGFloat) }
I then need to handle this new action. In InteractionComponent+Interact.swift
, I’ll define a new method
func handleRotation( state : ActionState, rotation : CGFloat ) { // 1. guard let rotationComponent = entity?.component(ofType: RotationComponent.self) else { return } switch state { // 2. case .ended: self.state = .none // 3. default: rotationComponent.currentRotation = rotation } }
- If this entity doesn’t have a rotation component, there’s no point in continuing.
- If the state has ended, then set the action back to
.none
to make the piece eligible for snapping again. - Finally, for all other states, apply the rotation. The rotation value passed to this method will need to be the absolute rotation of the current piece. In other words, the change from frame to frame is not important, only the total amount of rotation that has occurred since the start of the gesture.
Interaction: iOS
UIKit has a convenient rotation gesture handler that makes it easy to detect rotations.
In `GameScene+touch.swift, I’ll add a new method:
@objc func handleRotation( _ recogniser : UIRotationGestureRecognizer) { // 1. let point = self.scene?.convertPoint(fromView: recogniser.location(in: self.view)) ?? .zero if recogniser.state == .began { // 2. self.entityBeingInteractedWith = self.topNode(at: point)?.entity } // 3. guard let hasEntity = self.entityBeingInteractedWith else { return } // 4. let rotation = recogniser.rotation * -1 // 5. switch recogniser.state { case .began: hasEntity.component(ofType: InteractionComponent.self)?.state = .rotate(.began, rotation) case .changed: hasEntity.component(ofType: InteractionComponent.self)?.state = .rotate(.changed, rotation) case .ended, .cancelled, .failed: hasEntity.component(ofType: InteractionComponent.self)?.state = .rotate(.ended, rotation) default: break } }
- The gesture recogniser is linked to the
SKView
(not theSKScene
), so it first needs to be converted into SpriteKit coordinate space. This gives me the point around which the gesture is taking place. - If this is the start of the interaction then, using my previously defined convenience method, I’ll get the entity belonging to the top node at that point.
- If there is no entity, bail out early.
- I multiply the rotation by -1 to reverse it, otherwise the rotation of the piece goes in the opposite direction of the gesture. I believe this is to do with the reversal of the y-axis between UIView and SpriteKit.
- Then I switch on the recogniser state and update the interaction component. The UIKit gesture recognisers supply the rotation as an absolute value starting from 0—i.e. with each update of the gesture, the total amount of rotation that has been applied is given. It’s possible to reset this with every update by updating a property on the recogniser, but in this case it’s what the interaction component is expecting so this works just fine (well, almost, but we’ll get to that).
Finally, I’ll add this gesture recogniser to the setupInteractionHandlers()
method:
let rotationRecogniser = UIRotationGestureRecognizer(target: self, action: #selector(handleRotation(_:))) self.view?.addGestureRecognizer(rotationRecogniser)
Interaction: macOS
The Mac is more complicated. There are NSGestureRecognisers
that do include rotation, but this assumes the use of a trackpad. Desktop Mac users might have a mouse and no trackpad and those that do have a trackpad can trigger the same input events on it as if it was a mouse. It’s therefore better to work on the assumption that players will be using a mouse.
The solution I came up with was moving the mouse left or right while the right mouse button is pressed would act as a rotation gesture.
In order for this to work correctly, though, I need to keep track of the distance the mouse had moved since the start of the event. The interaction component is expecting an absolute value and it will also feel more ‘correct’. As you drag the mouse towards and away from the piece, the rotation increases, returning to its starting point if you then move the mouse back.
I need to keep track of the starting position of the event which I can do with a property. However, extensions can’t have properties so I’ll need to add it to the shared GameScene.swift
file.
var startingPosition : CGPoint = .zero
I don’t love this—a property that is specific to the Mac now appears in a file meant for both platforms—but it gets the job done.
Next, in GameScene+mouse.swift
, I’ll add the following:
// 1. override func rightMouseDown(with event: NSEvent) { // 2. let point = event.location(in: self) guard let hasEntity = self.topNode(at: point)?.entity else { return } // 3. self.entityBeingInteractedWith = hasEntity self.entityBeingInteractedWith?.component(ofType: InteractionComponent.self)?.state = .rotate(.began, 0) // 4. self.startingPosition = point } override func rightMouseDragged(with event: NSEvent) { // 5. let difference = self.startingPosition - event.location(in: self) // 6. var rotation = difference.x // 7. if abs(difference.x) < 2 { rotation = 0 } // 8. self.entityBeingInteractedWith?.component(ofType: InteractionComponent.self)?.state = .rotate(.changed, rotation.toRads()) } override func rightMouseUp(with event: NSEvent) { // 9. self.entityBeingInteractedWith?.component(ofType: InteractionComponent.self)?.state = .rotate(.ended, 0) }
This is a lot, so let’s break it down:
- I can override the built in methods for responding to the right mouse button which are the same as for the left mouse button. Thank God Apple has embraced multi-button mice!
- I’ll get the location of the cursor when the right mouse button is first clicked and make sure there is a piece with an entity attached underneath it. Otherwise, I can exit early.
- If there’s an entity at this point, set it to the property used to keep track of entities throughout an interaction.
- This time, however, I also need to keep track of the starting position.
- As the interaction continues, I need to get the total distance moved between the current update and the starting position. I can use my convenient
CGPoint
extensions for this (see the movement post). - As I only want left and right movements to count towards rotations, I’m only interested in the x-values. I started developing this on the basis that a single point moved on the x-direction would be equivalent to a single degree of rotation, planning to adjust this if necessary. However, this felt pretty good out of the gate so I left it at that.
- If the absolute difference is less than 2 (a number I came up with through trial and error), then I want to treat the rotation as 0. Modern mouses are so sensitive that even slight movements might register and cause the piece to slowly rotate even though the player thinks they’re not moving it at all.
- I can now update the state of the interaction component, remembering to convert the distance to radians.
- Finally, when the mouse button is released, I can update the state to let the interaction component know it has ended. Passing 0 as the rotation amount here doesn’t matter, as the interaction component doesn’t apply the rotation when the state is ended. This is…not great—the API of the interaction system does not make this fact clear in any way—and it is only done this way as a hack fix for an issue with the iOS gesture recognisers.
Clicking and dragging with the right mouse button now causes the pieces to rotate:
Handling Snapping
In SnappingComponent.swift
I’ll add a new property that defines (in +/- degrees) how close the piece can get to 0º before it can be considered correct.
let rotationTolerance : CGFloat = 20
This is a generous tolerance: The player can be anywhere under 20º or over 340º and (provided the position is also within range) the system will consider this correct. However, given that the original puzzle this was based on only had the four compass directions as discrete rotation options, it’s better to err on the side of “close enough” rather than making it too fiddly.
I need to make a slight change to the snapping system. Previously, it was set up to check for a single attribute (position) before applying the snap. I’ll replace the following lines:
if hyp < self.positionTolerance { positionComponent.currentPosition = positionComponent.targetPosition }
With:
// 1. var shouldSnap = true // 2. if hyp > self.positionTolerance { shouldSnap = false } if let hasRotation = entity?.component(ofType: RotationComponent.self) { // 3. let inDegrees = abs(hasRotation.currentRotation.toDegrees()) // 4. if inDegrees > rotationTolerance && inDegrees < (360 - rotationTolerance) { shouldSnap = false } } if shouldSnap { // 5. positionComponent.currentPosition = positionComponent.targetPosition entity?.component(ofType: RotationComponent.self)?.currentRotation = 0 }
- I’ll start by assuming that I’m going to snap the piece into position.
- Then I’ll see if the hypotenuse is larger than the position tolerance. If it is, it means that the piece is too far away for snapping and so it should fail whatever happens with rotation.
- If the entity has a rotation component, then I’ll convert the current rotation (which is in radians) to degrees and get its absolute value. Rotations can be negative based on which way they were last turned.
- If the degree value is greater than the tolerance (20º in this case) and it’s less than a full turn (360º) minus the tolerance (i.e. its rotation is less than 340º), then it’s outside the snapping range and, regardless of its position, the snap should fail.
- Only if it passes the two tests above should the current position be set to the target position and the rotation (if there is a rotation component) be set to 0. This will give the snapping effect.
Randomising the Starting Rotation
In GameScene.swift
, under let snappingComponent = SnappingComponent()
, I’ll add:
// 1. let rotationRandomiser = GKRandomDistribution(lowestValue: 0, highestValue: 359) // 2. let randomRotation = CGFloat(rotationRandomiser.nextInt()).toRads() // 3. let rotationComponent = RotationComponent(currentRotation: randomRotation) puzzlePiece.addComponent(rotationComponent)
- This sets up a random distribution between 0 and 359 (representing degrees) using GameplayKit’s convenient random distribution class.
- The value that I get from the random distribution is an integer and so first needs to be cast as a
CGFloat
. Once that’s done, I can use my convenience method to convert it to the expected radians. - I initialise the rotation component with this random rotation and add the component to the entity.
Fixing the Bugs
Applying the absolute value works fine except for one thing: it always assumes that the piece is starting without any rotation applied. Having now added a random rotation to the start, this is no longer true (not to mention that it was never true for pieces that had been previously rotated by the player).
To fix this, I need to add on any existing rotation to the new rotation as it comes in. However, this existing rotation needs to act like a constant value throughout the interaction which means I need to calculate it when it begins and then apply the results of this calculation throughout.
If this seems familiar, then it’s the exact same issue I faced when initiating the movement of pieces off-centre, which means the solution is the same.
First, a new property in Interaction.swfit
:
var rotationOffset : CGFloat = 0
Like the position offset issue, the rotation state may update more than once in between frames, meaning that the .began
state might get lost. In order to fix this, I’ll set the same didBegin
flag as I used for the position offset. I’ll replace state
property in the Interaction.swift
file with this:
var state : Action = .none { didSet { switch state { // 1. case .move(let actionState, _), .rotate(let actionState, _): if actionState == .began { self.didBegin = true } default: break } } }
- The only real difference here is that it now checks for a
rotate
action. The rest of it works the same as for the position solution: if theactionState
of any of these two actions is.began
, then set the flag.
Then, in InteractionComponent+Interact.swift
, I’ll do a check for this flag in the handleRotation( state : ActionState, rotation : CGFloat )
method and calculate the offset:
if self.didBegin { rotationOffset = rotationComponent.currentRotation - rotation self.didBegin = false }
Finally, this offset needs to be applied to every update. I’ll replace the rotationComponent.currentRotation = rotation
with:
rotationComponent.currentRotation = rotation + rotationOffset
And we are almost there! There are a few things I want to fix and a few improvements I want to make in the next post and then this thing will be done!