How to Make a Game Like Candy Crush Tutorial: OS X Port
Learn how to take an existing iOS Sprite Kit game and level it up to work on OS X too! By Gabriel Hauber.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
How to Make a Game Like Candy Crush Tutorial: OS X Port
40 mins
- Getting Started
- Add an OS X Build Target
- Surveying the work to be done
- Sprite Kit on iOS and OS X
- Platform-specific UI
- Source File Organization
- Porting UIKit to Sprite Kit
- Cross-platform event handling extension
- Cross-platform GameScene class
- Removing Dependencies on UIKit
- Labels with Shadows
- A simple Sprite Kit button
- A cross-platform controller
- Getting the game running on OS X
- OS X Finishing Touches
- Where to go from here?
Labels with Shadows
If you review the API for SKLabelNode
, you’ll notice that there is nothing there about shadows. (Incidentally, the NSLabel
component in AppKit doesn’t have the ability to add shadows, either.) Hence, if you want to keep your text readable, you’ll need to implement your own custom ShadowedLabelNode
class.
Create a new Swift file in the shared group, naming it ShadowedLabelNode.swift (make sure you add it to both iOS and OS X targets!). Replace its auto-generated contents with the following code:
import SpriteKit
class ShadowedLabelNode: SKNode {
// 1
private let label: SKLabelNode
private let shadowLabel: SKLabelNode
// 2
var text: String {
get {
return label.text
}
set {
label.text = newValue
shadowLabel.text = newValue
}
}
// 3
var verticalAlignmentMode: SKLabelVerticalAlignmentMode {
get {
return label.verticalAlignmentMode
}
set {
label.verticalAlignmentMode = newValue
shadowLabel.verticalAlignmentMode = newValue
}
}
var horizontalAlignmentMode: SKLabelHorizontalAlignmentMode {
get {
return label.horizontalAlignmentMode
}
set {
label.horizontalAlignmentMode = newValue
shadowLabel.horizontalAlignmentMode = newValue
}
}
required init(coder: NSCoder) {
fatalError("NSCoding not supported")
}
// 4
init(fontNamed fontName: String, fontSize size: CGFloat, color: SKColor, shadowColor: SKColor) {
label = SKLabelNode(fontNamed: fontName)
label.fontSize = size
label.fontColor = color
shadowLabel = SKLabelNode(fontNamed: fontName)
shadowLabel.fontSize = size
shadowLabel.fontColor = shadowColor
super.init()
shadowLabel.position = CGPoint(x: 1, y: -1)
addChild(shadowLabel)
addChild(label)
}
}
Let’s walk through this class step-by-step:
- As you can see, a shadowed label is constructed by using two labels of different colors, one offset from the other by a point in the vertical and horizontal directions.
- The
text
is a computed property that passes through to the child labels, ensuring both labels text are set correctly. - Likewise the
verticalAlignmentMode
andhorizontalAlignmentMode
properties pass through to the two labels as well. - Finally, the initializer sets up the two labels, ensuring that they are slightly offset from each other to create the shadow effect.
You could create a more comprehensive wrapper matching the SKLabelNode
API; but this is all that is needed for the Cookie Crunch game.
A simple Sprite Kit button
You’ll also need a Sprite Kit-based button to replace the UIButton
currently used in the iOS target.
In the shared group, create a new file, ButtonNode.swift, adding it to both the iOS and OS X targets. Replace its contents with the following code:
import SpriteKit
class ButtonNode: SKSpriteNode {
// 1 - action to be invoked when the button is tapped/clicked on
var action: ((ButtonNode) -> Void)?
// 2
var isSelected: Bool = false {
didSet {
alpha = isSelected ? 0.8 : 1
}
}
// MARK: - Initialisers
required init(coder: NSCoder) {
fatalError("NSCoding not supported")
}
// 3
init(texture: SKTexture) {
super.init(texture: texture, color: SKColor.whiteColor(), size: texture.size())
userInteractionEnabled = true
}
// MARK: - Cross-platform user interaction handling
// 4
override func userInteractionBegan(event: CCUIEvent) {
isSelected = true
}
// 5
override func userInteractionContinued(event: CCUIEvent) {
let location = event.locationInNode(parent)
if CGRectContainsPoint(frame, location) {
isSelected = true
} else {
isSelected = false
}
}
// 6
override func userInteractionEnded(event: CCUIEvent) {
isSelected = false
let location = event.locationInNode(parent)
if CGRectContainsPoint(frame, location) {
// 7
action?(self)
}
}
}
The class is deliberately kept nice and simple: only implementing the things absolutely needed for this game. Note the following (numbers reference the corresponding comment in the code):
- An
action
property holds a reference to the closure that will be invoked when the user taps or clicks on the button. - You will want to visually indicate when the button is being pressed. A simple way to do this is to change the alpha value when the button is selected.
- Most of the initialization is handled by the
SKSpriteNode
superclass. All you need to do is pass in a texture to use, and make sure that the node is enabled for user interaction! - When user interaction begins (either a touch down or mouse down event), the button is marked as selected.
- If, during the course of user interaction, the mouse or touch moves outside the node’s bounds, the button is no longer shown to be selected.
- If when the mouse click finishes or the user lifts their finger and the event location is within the bounds of the button, the
action
is triggered. - Note the use of optional chaining as indicated by the
?
. This indicates that nothing should happen if noaction
is set (that is, whenaction == nil
)
You are also going to need one more thing before you can begin your controller conversion in earnest. The iOS controller uses a UITapGestureRecognizer
to trigger the beginning of a new game. OS X has good gesture recognizer support as well, but in this case you will need an NSClickGestureRecognizer.
In EventHandling.swift, add the following type alias to the iOS section below the definition of CCUIEvent = UITouch
:
typealias CCTapOrClickGestureRecognizer = UITapGestureRecognizer
Similarly below the line CCUIEvent = NSEvent
:
typealias CCTapOrClickGestureRecognizer = NSClickGestureRecognizer
The APIs on these classes are similar enough that you can type alias them as you did with UITouch
and NSEvent
.
A cross-platform controller
Now it’s time to get your hands really dirty, and gut the iOS GameViewController
class to create the cross-platform controller. But before you do that, you need somewhere to put the shared controller code.
Create a new Swift file GameController.swift in the Shared group, and add it to both the iOS and OS X targets.
Replace its contents with the following:
import SpriteKit
import AVFoundation
class GameController: NSObject {
let view: SKView
// The scene draws the tiles and cookie sprites, and handles swipes.
let scene: GameScene
// 1 - levels, movesLeft, score
// 2 - labels, buttons and gesture recognizer
// 3 - backgroundMusic player
init(skView: SKView) {
view = skView
scene = GameScene(size: skView.bounds.size)
super.init()
// 4 - create and configure the scene
// 5 - create the Sprite Kit UI components
// 6 - begin the game
}
// 7 - beginGame(), shuffle(), handleSwipe(), handleMatches(), beginNextTurn(), updateLabels(), decrementMoves(), showGameOver(), hideGameOver()
}
Move the following code out of GameViewController.swift into the marked locations in GameController.swift.
- At 1, insert the declarations for
level
,movesLeft
andscore
. - At 3 (you’ll come back to 2 later), put the code for creating the
backgroundMusic
AVAudioPlayer
instance. - At 4, take everything from
viewDidLoad
from the “create and configure the scene” comment to the line that assigns the swipe handler (scene.swipeHandler = handleSwipe
) and move it into the initializer for theGameController
. Delete the duplicate assignmentscene = GameScene(size: skView.bounds.size)
(the one aftersuper.init()
). - Delete the lines that hide the
gameOverPanel
andshuffleButton
– you’ll do things slightly differently when you create the labels, buttons, etc, in a moment, below, at 5. - At 6, move the lines from
skView.presentScene(scene)
tobeginGame()
. - At 7, move the functions
beginGame()
,shuffle()
,handleSwipe()
,handleMatches()
,beginNextTurn()
,updateLabels()
,decrementMoves()
,showGameOver()
,hideGameOver()
.
The old iOS GameViewController
class should be looking a lot slimmer now! You’re not yet done gutting it, however. Delete the following lines from the GameViewController class:
// The scene draws the tiles and cookie sprites, and handles swipes.
var scene: GameScene!
@IBOutlet weak var targetLabel: UILabel!
@IBOutlet weak var movesLabel: UILabel!
@IBOutlet weak var scoreLabel: UILabel!
@IBOutlet weak var gameOverPanel: UIImageView!
@IBOutlet weak var shuffleButton: UIButton!
var tapGestureRecognizer: UITapGestureRecognizer!
You’ll deal with the shuffleButtonPressed()
method in a moment.
Before that, you’ll need a reference to the new GameController
object within the GameViewController
. Add the following property declaration to the GameViewController class:
var gameController: GameController!
Create an instance of this class at the end of viewDidLoad()
with the following code:
gameController = GameController(skView: skView)
There’s now some housekeeping to do in the iOS storyboard. Open Main.storyboard and delete all the labels, the image view and the shuffle button so the game view controller becomes a blank canvas:
The new Sprite Kit-based components will be created in the GameController class.
In GameController.swift, at the // 2
comment, paste this code:
let targetLabel = ShadowedLabelNode(fontNamed: "GillSans-Bold", fontSize: 22, color: SKColor.whiteColor(), shadowColor: SKColor.blackColor())
let movesLabel = ShadowedLabelNode(fontNamed: "GillSans-Bold", fontSize: 22, color: SKColor.whiteColor(), shadowColor: SKColor.blackColor())
let scoreLabel = ShadowedLabelNode(fontNamed: "GillSans-Bold", fontSize: 22, color: SKColor.whiteColor(), shadowColor: SKColor.blackColor())
var shuffleButton: ButtonNode!
var gameOverPanel: SKSpriteNode!
var tapOrClickGestureRecognizer: CCTapOrClickGestureRecognizer!
This code creates the three labels that display the level target, remaining moves and current score. It then declares properties for the shuffleButton
, gameOverPanel
and the tapOrClickGestureRecognizer
which will handle the rest of the user interaction.
To create the labels for target, moves and score, paste the following into GameController.swift at the // 5
comment:
let nameLabelY = scene.size.height / 2 - 30
let infoLabelY = nameLabelY - 34
let targetNameLabel = ShadowedLabelNode(fontNamed: "GillSans-Bold", fontSize: 16, color: SKColor.whiteColor(), shadowColor: SKColor.blackColor())
targetNameLabel.text = "Target:"
targetNameLabel.position = CGPoint(x: -scene.size.width / 3, y: nameLabelY)
scene.addChild(targetNameLabel)
let movesNameLabel = ShadowedLabelNode(fontNamed: "GillSans-Bold", fontSize: 16, color: SKColor.whiteColor(), shadowColor: SKColor.blackColor())
movesNameLabel.text = "Moves:"
movesNameLabel.position = CGPoint(x: 0, y: nameLabelY)
scene.addChild(movesNameLabel)
let scoreNameLabel = ShadowedLabelNode(fontNamed: "GillSans-Bold", fontSize: 16, color: SKColor.whiteColor(), shadowColor: SKColor.blackColor())
scoreNameLabel.text = "Score:"
scoreNameLabel.position = CGPoint(x: scene.size.width / 3, y: nameLabelY)
scene.addChild(scoreNameLabel)
This code first determines the y location for the name (“Target:”, “Moves:” and “Score:”) and value labels. Since the scene’s anchor point is the center, the y location is determined by adding half the scene height and then subtracting a small value, putting the labels just below the top of the view. The labels displaying the score, etc, are set to display 34 points below the heading labels.
For each label that is created, its text and position (the x position is relative to the center of the scene) are set, and the label is added to the scene.
Add the following code just below the code you just added:
targetLabel.position = CGPoint(x: -scene.size.width / 3, y: infoLabelY)
scene.addChild(targetLabel)
movesLabel.position = CGPoint(x: 0, y: infoLabelY)
scene.addChild(movesLabel)
scoreLabel.position = CGPoint(x: scene.size.width / 3, y: infoLabelY)
scene.addChild(scoreLabel)
This code sets the positions of the value labels and adds them to the scene.
To create the shuffle button, add the following code just below the code you just added:
shuffleButton = ButtonNode(texture: SKTexture(imageNamed: "Button"))
shuffleButton.position = CGPoint(x: 0, y: -scene.size.height / 2 + shuffleButton.size.height)
let nameLabel = ShadowedLabelNode(fontNamed: "GillSans-Bold", fontSize: 20, color: SKColor.whiteColor(), shadowColor: SKColor.blackColor())
nameLabel.text = "Shuffle"
nameLabel.verticalAlignmentMode = .Center
shuffleButton.addChild(nameLabel)
scene.addChild(shuffleButton)
shuffleButton.hidden = true
This creates the button node, positions it just above the bottom of the scene, and adds the text “Shuffle” by using another ShadowedLabelNode
as a child of the button. By setting center vertical alignment on the label it will be rendered properly centered on its parent button node. (By default, labels are aligned on its text’s baseline.) The button is added to the scene; but is initially hidden.
To set up the button’s action
, add the following code just below the code you just added:
shuffleButton.action = { (button) in
// shuffle button pressed!
}
Ok, what should go here? That’s right – the contents of the shuffleButtonPressed()
method from the GameViewController
class. Move the contents of that method into the shuffleButton
action closure (you will need to prefix each method call with self
as well), so it looks like this:
shuffleButton.action = { (button) in
self.shuffle()
// Pressing the shuffle button costs a move.
self.decrementMoves()
}
As it is no longer needed, delete the shuffleButtonPressed() method from the GameViewController class. That class is looking rather svelte now, don’t you think?
Ok, just the game over panel and starting a new game left to do before the iOS version of the game is running again.
Above, you changed the gameOverPanel
to be an SKSpriteNode
. Find the decrementMoves() function in the GameController
class and replace:
gameOverPanel.image = UIImage(named: "LevelComplete")
with:
gameOverPanel = SKSpriteNode(imageNamed: "LevelComplete")
Likewise, replace:
gameOverPanel.image = UIImage(named: "GameOver")
with:
gameOverPanel = SKSpriteNode(imageNamed: "GameOver")
In showGameOver()
, you need to add the gameOverPanel
to the scene instead of unhiding it. So, replace:
gameOverPanel.hidden = false
with:
scene.addChild(gameOverPanel!)
To use the cross-platform CCTapOrClickGestureRecognizer
to handle starting a new game, replace the contents of the animateGameOver() closure in showGameOver() with the following:
self.tapOrClickGestureRecognizer = CCTapOrClickGestureRecognizer(target: self, action: "hideGameOver")
self.view.addGestureRecognizer(self.tapOrClickGestureRecognizer)
In hideGameOver(), replace all references to tapGestureRecognizer
with tapOrClickGestureRecognizer. And, instead of hiding the gameOverPanel
you need to remove it from the scene and clean up by setting it to nil
. Replace:
gameOverPanel.hidden = true
With:
gameOverPanel.removeFromParent()
gameOverPanel = nil
Build and run the iOS target. If everything went well, you should be able to play the game just as before. But now you’ll be doing it entirely using Sprite Kit-based UI components!