Developing a Jigsaw Puzzle Game Part 9: Improvements

With the addition of win conditions, support for multiple puzzles, and the ability to rotate pieces, this basic jigsaw puzzle game is almost done.

Scaling Support

However, it would be a nice effect if the pieces scaled up slightly when they were being moved, as if they were being lifted towards the screen. It would make it clear to the player which piece they are currently interacting with, useful when there’s a pile of overlapping pieces.

SpriteKit comes with a robust set of convenience actions that would make this very easy to apply but I’m all about the ECS so I’ll do it the hard way instead.

I want the animations to be interruptible. If the user touches a piece, it should start scaling up. If they then let go before that scaling up animation is complete, a new scale down animation should immediately begin from the scale that the piece reached when the gesture ended.

In order to calculate the correct scale every frame, I’ll need to keep track of a bunch of different properties:

  1. The starting scale
  2. The difference between the starting scale and the target scale
  3. The total duration, calculated by dividing the difference by a scale amount per second property
  4. The elapsed time

Adding a Scale Component

I’ll create a new component in the Shared folder called ScaleComponent.swift and make sure that it targets both the macOS and iOS versions of the game, then replace the contents with:

import GameplayKit
import SpriteKit
class ScaleComponent : GKComponent {
	// 1. 
	let scaleAmountPerSecond : CGFloat = 2
	// 2. 
	var targetScale : CGFloat = 1 {
		didSet {
			originalScale = currentScale
			difference = targetScale - currentScale
			duration = TimeInterval(abs(difference) / scaleAmountPerSecond)
			currentTime = duration
		}
	}
	// 3.
	var originalScale : CGFloat = 1
	// 4.
	var difference : CGFloat = 0
	// 5.
	var currentTime : TimeInterval = 0
	var duration : TimeInterval = 0
	// 6.
	var currentScale : CGFloat = 1
}
  1. This is a fixed property that defines how quickly a piece should scale up or down. In this case it is set to double the size of the sprite every second.
  2. The targetScale property is what the other systems in the game should use when they want to animate the scale of an entity. It can be changed at any point and, as soon as it is changed, a new animation should begin (effectively discarding the current one). It does this by setting the originalScale, the difference, the duration, and the currentTime properties immediately.
  3. The originalScale is set to whatever the currentScale was at the time the animation was triggered (i.e. it is the starting scale of the animation). This will remain constant throughout the animation.
  4. The difference property stores the difference between the starting scale and target scale and is used throughout the animation to work out how much additional scale should be added to the original scale at any given moment. It remains constant throughout the animation
  5. The currentTime is used to keep track of how much time has elapsed. It starts out as the same as the duration and is decremented by the delta time during the animation. The total duration remains constant, and is needed to normalise the current time to give a percentage of the animation completed, which can then be used with the difference property to calculate how much additional scale should be added to the originalScale to give the currentScale.
  6. The currentScale is constantly updated throughout the animation and this is the value that is applied to the sprite each frame.

If I wanted to make different entities have different scaling speeds, I could make the scaleAmountPerSecond into a variable and maybe add an initialiser but for this game I want all pieces to behave in the same way.

In GameScene.swift, under let rotationComponent = RotationComponent(currentRotation: randomRotation), I’ll add this new component to each piece:

let scaleComponent = ScaleComponent()
puzzlePiece.addComponent(scaleComponent)

Applying the Scale

The Render system is the system where transform properties should be applied. In SpriteComponent+Render.swift, I’ll add the following to the bottom of update(deltaTime:):

// 1. 
if let hasScale = entity?.component(ofType: ScaleComponent.self), hasScale.currentScale != hasScale.targetScale {
	// 2. 
	if hasScale.currentTime > 0 {
		// 3.
		hasScale.currentTime -= seconds
		// 4.
		let factor = CGFloat((hasScale.duration - hasScale.currentTime) / hasScale.duration)
		// 5.
		hasScale.currentScale = hasScale.originalScale + (hasScale.difference * factor)
	} else {
		// 6.
		hasScale.currentScale = hasScale.targetScale
	}
	// 7.
	self.sprite.setScale(hasScale.currentScale)
}

  1. First make sure that the entity has a scale component. There should also be a difference between the current scale and the target scale. If there isn’t, then no work needs to be done.
  2. The currentTime property counts down from the total duration. If it is at 0 or below, then the animation has ended.
  3. Reduce the currentTime by the delta time between this frame and the last frame.
  4. Normalise the duration to get a value between 0 and 1 that defines how much of the animation has elapsed.
  5. Multiply this normalised duration by the difference between the original scale and the target scale to get the amount changed for this frame, then add this to the original scale to get the absolute value of the current scale. Set this to the scale component’s currentScale property.
  6. Alternatively, the currentTime is at 0 or below meaning the animation has ended. If this is the case, then make the currentScale equal to the targetScale. This ensures that the comparison at the start of the if let statement will fail next time around.
  7. Whatever happens in this statement (whether the animation has completed or not), the new scale needs to be applied to the sprite.

Scaling While Moving

This scaling animation should only happen when pieces are being moved. In InteractionComponent+Interact.swift there’s the handleMove(state:point:) method that is called when a piece is moved. I’m already doing a test to see if the interaction began at any point since the last frame, so I can use this to start the animation. Under if self.didBegin { I’ll add:

entity?.component(ofType: ScaleComponent.self)?.targetScale = 1.2

Below that I test the current state of the interaction with a switch statement. In the .ended case, I’ll add:

entity?.component(ofType: ScaleComponent.self)?.targetScale = 1
An animated gif showing a piece of a purple film strip scaling up as the mouse is clicked, then scaling down as it is released
At the end of this clip, you can see what happens if I quickly press and release the mouse button—the pieces scale up slightly then back down again.

zPosition Handling

There is still a major problem with the game as it stands. If a player interacts with a piece and moves it past another piece with a higher zPosition, then the piece being moved will disappear underneath this second piece. This breaks the illusion of picking up and dropping a piece, an illusion that I just emphasised using scaling.

An animated gif showing a piece of an illustrated puzzle being lifted and moved around the scene. It sometimes hovers above other pieces and then sometimes moves underneath them in an unrealistic way.

I’m going to break the solution to this problem into two different parts.

Fixing the zPosition During Movement

When the interaction begins, I’ll store the current zPosition of the piece and then set the sprite’s zPosition to an arbitrarily large number. Then, when the interaction ends, I can set the zPosition of the sprite back to the stored value.

In SpriteComponent.swift, I’ll create a new property:

var currentZPosition : CGFloat = 0

By the time the didAddToEntity() method is called, the puzzle pieces should have been given a zPosition by the scene, so I can use this method to set the currentZPosition to the sprite’s assigned position. At the end of the didAddToEntity() method, I’ll add this line:

self.currentZPosition = self.sprite.zPosition

Note: this is a bit of a shortcut and far from ideal—it would be better for encapsulation if the intended z-position was passed to the initialiser when the sprite component was initialised. The sprite component should not have to rely on the fact that the z-position has been correctly set by the time it is added to an entity.

Then, in InteractionComponent+Interact.swift, I’ll update the handleMove(state:point:) method. In the self.didBegin { statement, under entity?.component(ofType: ScaleComponent.self)?.targetScale = 1.2, I’ll add:

entity?.component(ofType: SpriteComponent.self)?.sprite.zPosition = 1000

And in the switch statement at the bottom, in the .ended case:

if let hasSpriteComponent = entity?.component(ofType: SpriteComponent.self) {
	hasSpriteComponent.sprite.zPosition = hasSpriteComponent.currentZPosition
}

This will reset the sprite’s zPosition to what it originally was after the interaction ends.

An animated gif showing a piece of an illustrated puzzle being lifted and moved around other scattered puzzle pieces. It correctly hovers over other pieces, but when it is released it falls unrealistically below other pieces breaking the illusion that it is being lifted towards the screen,.

So moving pieces now works as intended. However, letting go of them over pieces with higher z-positions causes them to land underneath those pieces in an unrealistic way.

Fixing the z-position of Dropped Pieces

So far, I’ve been dealing exclusively with points—the interactions all happen around individual points, as does getting hold of an entity that is the target of an interaction.

However, in order for things to feel correct, I can’t just get the nodes under a single point and adjust their zPositions. This would miss pieces that should be partially obscured by the dropped piece.

Instead, I need to get all of the nodes that are under the entire piece that is being dropped, which means I need to start dealing with frames.

In the shared GameScene.swift, I’ll add this method:

// 1.
func nodes(within region : CGRect ) -> [SKNode] {
	// 2.
	var foundNodes = [SKNode]()
	for node in self.children {
		// 3.
		if node.entity == nil {
			continue
		}
		// 4. 
		if node.frame.intersects( region ) {
			foundNodes.append(node)
		}
	}
	// 5. 
	return foundNodes.sorted(by: { (node1, node2) -> Bool in
		return node1.zPosition > node2.zPosition
	})
}
  1. This method takes a CGRect that represents the dropped piece’s frame and returns an array of SKNodes that intersect with that frame.
  2. Create an empty array and then loop through every node in the scene.
  3. In order to make things more efficient, immediately ignore any node that doesn’t have an attached entity.
  4. If it does, use the intersects() method on CGRect to find out if any part of that node is within the dropped piece’s frame. If so, add it to the array.
  5. Finally, sort all of the nodes found within or touching the dropped piece’s frame by their zPosition property. This will create an array of nodes with the piece with the largest zPosition first.

Now that I can get a list of nodes under a given rect, I need to adjust all of their zPositions. Under the above method, I’ll add this method:

func fixZPosition() {
	// 1. 
	guard let hasPiece = self.entityBeingInteractedWith?.component(ofType: SpriteComponent.self) else {
		return
	}
	// 2. 
	let nodesWithinFrame = nodes(within: hasPiece.sprite.frame)
	// 3.
	guard entities.count > 1 else {
		return
	}
	// 4.
	var zPositions = nodesWithinFrame.filter() { $0.zPosition < 1000 }.map() { $0.zPosition }
	// 5.
	zPositions.append(hasPiece.currentZPosition)
	// 6.
	let sortedZPositions = zPositions.sorted() { $0 > $1 }
	// 7.
	for (idx,entity) in nodesWithinFrame.enumerated() {
		// 8.
		entity.entity?.component(ofType: SpriteComponent.self)?.sprite.zPosition = sortedZPositions[idx]
		entity.entity?.component(ofType: SpriteComponent.self)?.currentZPosition = sortedZPositions[idx]
	}
}
  1. Make sure that there is a piece being interacted with and that it has a sprite component.
  2. Call the nodes(within:) method, passing the current piece’s frame, to get an array of nodes within the edges of that frame.
  3. There needs to be at least two nodes in the array (one of them will always be the piece being dropped) or else the piece is being dropped over a completely blank section of the board and no adjustments need to take place.
  4. Filter out the current entity (its zPosition will be set to 1,000 during an interaction), then map the remaining entities to get an array of just their zPositions as CGFloats.
  5. Add back the current entity’s usual zPosition (i.e. the zPosition it has when it’s just sitting on the board, not the 1,000 that it’s currently set to).
  6. Sort the entire array of z-positions in descending order.
  7. Finally, go through the original node array (again, this includes the current piece, which will be the first item in the array) then use the index of that piece to get the value at the same index in the sortedZPositions array and apply it to both the sprite (so it takes effect immediately) and the currentZPosition (so it will take effect in future interactions).

Applying the Fix

In GameScene+touch.swift, there’s a handlePan(_ recogniser:) method that deals with pan gestures. At the bottom of this method, in the .ended case of the switch statement, I’ll add this immediately after the case declaration:

case .ended:
	self.fixZPosition()

Similarly, in GameScene+mouse.swift, there’s the mouseUp(with:) method that is called when the mouse button is released. Immediately after the opening brace, I’ll add:

self.fixZPosition()
An animated gif showing a piece of an illustrated puzzle being lifted and dropped onto a stack of other pieces. It stays on the top of the stack as it should, giving the effect that it has been dropped on the top of the stack.

This now sorts all of the zPositions within a given rect and applies them in order starting with the piece being dropped before continuing to the pieces underneath. While it assigns new zPositions to all the pieces, it still keeps their relative position in the “stack” of pieces giving the effect I want.

Font Scales

There’s one small bug remaining. When the “You Win!” text shows on a vector-based puzzle, it is twice the size than when it is shown on a raster-based puzzle.

This is because, when setting up the scene for a raster-based puzzle, the actual size of the scene is twice that of a vector-based puzzle.

A completed film strip puzzle with the words You Win! in the middle. These words are rendered smaller than on the illustrated puzzle.
A completed illustrated puzzle with the words You Win! in the middle. These words are rendered smaller than on the film strip puzzle.

In order to fix this, in GameScene, I’ll replace self.winLabel.fontSize = 100 with:

self.winLabel.fontSize = ( self.puzzle.type == "vector" ) ? 100 : 200

Now the “You Win!” label will show correctly for either of the two puzzle types.

Game Over

That’s it for this simple jigsaw puzzle game! I now have a working game engine that supports Macs, iPads, and iPhones. It can handle moving and rotating pieces, detects win conditions, and moves between an arbitrary number of puzzles.

I also have an automated workflow for generating new puzzle assets from either vector-based or raster-based Photoshop documents.

In the next post I’ll go through a review of everything I’ve learned developing this game, including the many, many things I would do differently. I’ll also briefly cover everything that I would need to do to go from here to releasing it as an actual app.