Developing A Jigsaw Puzzle Game Part 8: Rotation

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
	}
}
  1. 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()
  2. 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")
	}
}
  1. The currentRotation is a value in radians that can be changed by any of the systems. The target rotation will always be 0.
  2. 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
 	}
}
  1. If this entity doesn’t have a rotation component, there’s no point in continuing.
  2. If the state has ended, then set the action back to .none to make the piece eligible for snapping again.
  3. 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
	}
}
  1. The gesture recogniser is linked to the SKView (not the SKScene), so it first needs to be converted into SpriteKit coordinate space. This gives me the point around which the gesture is taking place.
  2. 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.
  3. If there is no entity, bail out early.
  4. 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.
  5. 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)
Animated gif showing the effects of using a 2 finger rotation gesture on iOS. It turns a purple puzzle piece.

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:

  1. 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!
  2. 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.
  3. If there’s an entity at this point, set it to the property used to keep track of entities throughout an interaction.
  4. This time, however, I also need to keep track of the starting position.
  5. 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).
  6. 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.
  7. 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.
  8. I can now update the state of the interaction component, remembering to convert the distance to radians.
  9. 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:

Animated gif showing the effects of pressing the right mouse button and dragging left or right on macOS. It turns a purple puzzle piece.

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
}
  1. I’ll start by assuming that I’m going to snap the piece into position.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
Animated gif showing the effects of pressing the right mouse button and dragging left or right on macOS. It turns a purple puzzle piece and, when it's turned to the correct orientation, it snaps into place.
This piece is close to the correct position but only after it's rotated to the correct orientation does the snapping take place.

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)
  1. This sets up a random distribution between 0 and 359 (representing degrees) using GameplayKit’s convenient random distribution class.
  2. 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.
  3. I initialise the rotation component with this random rotation and add the component to the entity.
Pieces of a cut-up purple film strip are scattered across a dark grey background and randomly rotated

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).

Animated gif showing the effects of pressing the right mouse button and dragging left or right on macOS. It turns a purple puzzle piece, in this case it incorrectly resets the piece to 0 degrees before applying the rotation.
The piece snaps back to a 0º rotation at the start of each interaction.

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
		}
	}
}
  1. 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 the actionState 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
Animated gif showing the effects of pressing the right mouse button and dragging left or right on macOS. It turns a purple puzzle piece, correctly rotating it from its start position.

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!