Learn to make a tasty match-3 game in the new Swift language
A little while back, I wrote an Objective-C tutorial about how to make a game like the Candy Crush Saga, which is a very popular casual match-3 game.
But I thought it would be great to make a Swift version as well, hence this post!
In this Swift tutorial, you’ll learn how to make a game like Candy Crush named Cookie Crunch Adventure. Yum, that sounds even better than candy!
In the process of going through this tutorial, you’ll get some excellent practice with Swift techniques such as enums, generics, subscripting, closures, and extensions. You’ll also learn a lot about game architecture and best practices.
This is Part One of a two-part series. In this first part, you’ll put the foundation in place: the gameplay view, the sprites and some of the logic for detecting swipes and swapping cookies.
In the second part, you’ll complete the gameplay and add the final polish to transform Cookie Crunch Adventure into a game of top-10 quality.
Note: This Swift tutorial assumes you have working knowledge of Sprite Kit and Swift. If you’re new to Sprite Kit, check out the beginner tutorials on the site or our book, iOS Games by Tutorials. For an introduction to Swift, see our Swift tutorial.
This tutorial requires Xcode 6 beta 2 or later. At the time of writing this tutorial, it is unclear to us if we are allowed to post screenshots of Xcode 6 since it is still in beta. Therefore, we are suppressing Xcode 6 screenshots in this Swift tutorial until we are sure it is OK. (Any Xcode screenshots shown are taken from the Objective-C version of this tutorial.)
Getting Started
Before you continue, download the resources for this Swift tutorial and unpack the zip file. You’ll have a folder containing all the images and sound effects you’ll need later on.
Start up Xcode 6, go to File\New\Project…, choose the iOS\Application\Game template and click Next. Fill out the options as follows:
- Product Name: CookieCrunch
- Language: Swift
- Game Technology: SpriteKit
- Devices: iPhone
Click Next, choose a folder for your project and click Create.
This is a portrait-only game, so open the Target Settings screen and in the General tab, make sure only Portrait is checked in the Device Orientation section:
To start importing the graphics files, go to the Resources folder you just downloaded and drag the Sprites.atlas folder into Xcode’s Project Navigator. Make sure Destination: Copy items if needed is checked.
You should now have a blue folder in your project:
Xcode will automatically pack the images from this folder into a texture atlas when it builds the game. Using a texture atlas as opposed to individual images will dramatically improve your game’s drawing performance.
Note: To learn more about texture atlases and performance, check out Chapter 25 in iOS Games by Tutorials, “Sprite Kit Performance: Texture Atlases”.
There are a few more images to import, but they don’t go into a texture atlas. This is because they are either large full-screen background images (which are more efficient to keep outside of the texture atlas) or images that you will later use from UIKit controls (UIKit controls cannot access images inside texture atlases).
From the Resources/Images folder, drag each of the individual images into the asset catalog:
Delete the Spaceship image from the asset catalog. This is a sample image that came with the template but you won’t need any spaceships while crunching those tasty cookies! :]
Outside of the asset catalog in the Project Navigator, delete GameScene.sks. You won’t be using Xcode’s built-in level editor for this game.
Great! It’s time to write some code. Replace the contents of GameViewController.swift with the following:
import UIKit
import SpriteKit
class GameViewController: UIViewController {
var scene: GameScene!
override func prefersStatusBarHidden() -> Bool {
return true
}
override func shouldAutorotate() -> Bool {
return true
}
override func supportedInterfaceOrientations() -> Int {
return Int(UIInterfaceOrientationMask.AllButUpsideDown.toRaw())
}
override func viewDidLoad() {
super.viewDidLoad()
// Configure the view.
let skView = view as SKView
skView.multipleTouchEnabled = false
// Create and configure the scene.
scene = GameScene(size: skView.bounds.size)
scene.scaleMode = .AspectFill
// Present the scene.
skView.presentScene(scene)
}
} |
This is mostly boilerplate code that creates the Sprite Kit scene and presents it in the SKView
.
For the final piece of setup, replace the contents of GameScene.swift with this:
import SpriteKit
class GameScene: SKScene {
init(size: CGSize) {
super.init(size: size)
anchorPoint = CGPoint(x: 0.5, y: 0.5)
let background = SKSpriteNode(imageNamed: "Background")
addChild(background)
}
} |
This loads the background image from the asset catalog and places it in the scene. Because the scene’s anchorPoint
is (0.5, 0.5), the background image will always be centered on the screen on both 3.5-inch and 4-inch devices.
Build and run to see what you’ve got so far. Excellent!
Can I Have Some Cookies?
This game’s playing field will consist of a grid, 9 columns by 9 rows. Each square of this grid can contain a cookie.
Column 0, row 0 is in the bottom-left corner of the grid. Since the point (0,0) is also at the bottom-left of the screen in Sprite Kit’s coordinate system, it makes sense to have everything else “upside down”—at least compared to the rest of UIKit. :]
Note: Wondering why Sprite Kit’s coordinate system is different than UIKit’s? This is because OpenGL ES’s coordinate system has (0, 0) at the bottom-left, and Sprite Kit is built on top of OpenGL ES.
To learn more about OpenGL ES, we have a video tutorial series for that.
To being implementing this, you need to create the class representing a cookie object. Go to File\New\File…, choose the iOS\Source\Swift File template and click Next. Name the file Cookie.swift and click Create.
Replace the contents of Cookie.swift with the following:
import SpriteKit
enum CookieType: Int {
case Unknown = 0, Croissant, Cupcake, Danish, Donut, Macaroon, SugarCookie
}
class Cookie {
var column: Int
var row: Int
let cookieType: CookieType
var sprite: SKSpriteNode?
init(column: Int, row: Int, cookieType: CookieType) {
self.column = column
self.row = row
self.cookieType = cookieType
}
} |
The column
and row
properties let Cookie
keep track of its position in the 2-D grid.
The sprite
property is optional, hence the question mark after SKSpriteNode
, because the cookie object may not always have its sprite set.
The cookieType
property describes the—wait for it—type of the cookie, which takes a value from the CookieType
enum. The type is really just a number from 1 to 6, but wrapping it in an enum allows you to work with easy-to-remember names instead of numbers.
You will deliberately not use cookie type Unknown
(value 0). This value has a special meaning, as you’ll learn toward the end of this part of the tutorial.
Each cookie type number corresponds to a sprite image:
In Swift, an enum isn’t just useful for associating symbolic names with numbers; you can also add functions and computed properties to an enum. Add the following code inside the enum CookieType
:
var spriteName: String {
let spriteNames = [
"Croissant",
"Cupcake",
"Danish",
"Donut",
"Macaroon",
"SugarCookie"]
return spriteNames[toRaw() - 1]
}
var highlightedSpriteName: String {
let highlightedSpriteNames = [
"Croissant-Highlighted",
"Cupcake-Highlighted",
"Danish-Highlighted",
"Donut-Highlighted",
"Macaroon-Highlighted",
"SugarCookie-Highlighted"]
return highlightedSpriteNames[toRaw() - 1]
} |
The spriteName
property returns the filename of the corresponding sprite image in the texture atlas. In addition to the regular cookie sprite, there is also a highlighted version that appears when the player taps on the cookie.
The spriteName
and highlightedSpriteName
properties simply look up the name for the cookie sprite in an array of strings. To find the index, you use toRaw()
to convert the enum’s current value to an integer. Recall that the first useful cookie type, Croissant
, starts at 1 but arrays are indexed starting at 0, so you need to subtract 1 to find the correct array index.
Every time a new cookie gets added to the game, it will get a random cookie type. It makes sense to add that as a function on CookieType
. Add the following to the enum as well:
static func random() -> CookieType {
return CookieType.fromRaw(Int(arc4random_uniform(6)) + 1)!
} |
This calls arc4random_uniform()
to generate a random number between 0 and 5, then adds 1 to make it a number between 1 and 6. Because Swift is very strict, the result from arc4random_uniform()
(an UInt32) must first be converted to an Int, and then fromRaw()
can convert this number into a proper CookieType
value.
Now, you may wonder why you’re not making Cookie
a subclass of SKSpriteNode
. After all, the cookie is something you want to display on the screen.
If you’re familiar with the model-view-controller (or MVC) pattern, think of Cookie
as a model object that simply describes the data for the cookie. The view is a separate object, stored in the sprite
property.
This kind of separation between data models and views is something you’ll use consistently throughout this tutorial. The MVC pattern is more common in regular apps than in games but, as you’ll see, it can help keep the code clean and flexible.
Printing Cookies
If you were to use println()
to print out a Cookie
at the moment, it wouldn’t look very nice.
What you’d like is to customize the output of when you print a cookie. You can do this by making the Cookie
conform to the Printable
protocol.
To do this, modify the declaration of Cookie
as follows:
class Cookie: Printable { |
Then add a computed property named description
:
var description: String {
return "type:\(cookieType) square:(\(column),\(row))"
} |
Now println()
will print out something helpful: the type of cookie and its column and row in the level grid. You’ll use this in practice later.
Let’s also make the CookieType
enum printable. Add the Printable
protocol to the enum definition and have the description
property return the sprite name, which is a pretty good description of the cookie type:
enum CookieType: Int, Printable {
...
var description: String {
return spriteName
}
} |
Keeping the Cookies: the 2-D Grid
Now you need something to hold that 9×9 grid of cookies. The Objective-C version of this tutorial did this,
to create a two-dimensional array of 81 elements. Then you could simply do myCookie = _cookies[3][6];
to find the cookie at column 3, row 6.
Swift arrays, however, work quite differently from plain old C arrays and the above is not possible. Fortunately, you can write your own class that acts like a 2-D array and that is just as convenient to use.
Go to File\New\File…, choose the iOS\Source\Swift File template and click Next. Name the file Array2D.swift and click Create.
Replace the contents of Array2D.swift with the following:
class Array2D<T> {
let columns: Int
let rows: Int
let array: Array<T?> // private
init(columns: Int, rows: Int) {
self.columns = columns
self.rows = rows
array = Array<T?>(count: rows*columns, repeatedValue: nil)
}
subscript(column: Int, row: Int) -> T? {
get {
return array[row*columns + column]
}
set {
array[row*columns + column] = newValue
}
}
} |
The notation Array2D<T>
means that this class is a generic; it can hold elements of any type T
. You’ll use Array2D
to store Cookie
objects, but later on in the tutorial you’ll use another Array2D
to store a different type of object, Tile
.
Array2D
‘s initializer creates a regular Swift Array
with a count of rows x columns and sets all these elements to nil. When you want a value to be nil in Swift, it needs to be declared optional, which is why the type of the array
property is Array<T?>
and not just Array<T>
.
What makes this class easy to use is that is supports subscripting. If you know the column and row numbers of a specific item, you can index the array as follows: myCookie = cookies[column, row]
. Sweet!
Get Ready, Get Set…
There is one more helper class you need to write. In Xcode 6 beta 2, Swift does not come with a native set type. A set is a collection, like an array, but it allows each element to appear only once, and it does not store the elements in any particular order.
You could use an NSSet
for this purpose, but that doesn’t take advantage of Swift’s strict type checking. Fortunately, it’s not hard to write your own.
Go to File\New\File…, choose the iOS\Source\Swift File template and click Next. Name the file Set.swift and click Create.
Replace the contents of Set.swift with the following:
class Set<T: Hashable>: Sequence, Printable {
var dictionary = Dictionary<T, Bool>() // private
func addElement(newElement: T) {
dictionary[newElement] = true
}
func removeElement(element: T) {
dictionary[element] = nil
}
func containsElement(element: T) -> Bool {
return dictionary[element] != nil
}
func allElements() -> T[] {
return Array(dictionary.keys)
}
var count: Int {
return dictionary.count
}
func unionSet(otherSet: Set<T>) -> Set<T> {
var combined = Set<T>()
for obj in dictionary.keys {
combined.dictionary[obj] = true
}
for obj in otherSet.dictionary.keys {
combined.dictionary[obj] = true
}
return combined
}
func generate() -> IndexingGenerator<Array<T>> {
return allElements().generate()
}
var description: String {
return dictionary.description
}
} |
There’s a lot of code here but it’s all quite straightforward. You can add elements to the set, remove elements, and inspect which elements are currently in the set. The unionSet()
function is used to combine two sets into a new one.
To guarantee that each element appears only once, Set
uses a dictionary. As you probably know, a dictionary stores key-value pairs, and keys must be unique. Set
stores its elements as the keys of the dictionary, not as values, guaranteeing that each element appears only once.
Set
also conforms to the Sequence
protocol and implements generate()
to return a so-called “generator” object. This allows you to use the set in for-in
loops. Very handy!
The Level Class
That’s the preliminaries out of the way. Let’s put Array2D
and Set
to use.
Go to File\New\File…, choose the iOS\Source\Swift File template and click Next. Name the file Level.swift and click Create.
Replace the contents of Level.swift with the following:
import Foundation
let NumColumns = 9
let NumRows = 9
class Level {
let cookies = Array2D<Cookie>(columns: NumColumns, rows: NumRows) // private
} |
This declares two constants for the dimensions of the level, NumColumns
and NumRows
, so you don’t have to hardcode the number 9 everywhere.
The property cookies
is the two-dimensional array that holds the Cookie
objects, 81 in total (9 rows of 9 columns). It is defined with let
, not var
, because once it’s set the value of cookies
never has to change; it will always have the same Array2D
instance.
The cookies
array is supposed to be private, so Level
needs to provide a way for others to obtain a cookie object at a specific position in the level grid. (Note: Swift currently does not support a way to mark properties as private.)
Add the code for this method to Level.swift:
func cookieAtColumn(column: Int, row: Int) -> Cookie? {
assert(column >= 0 && column < NumColumns)
assert(row >= 0 && row < NumRows)
return cookies[column, row]
} |
Using cookieAtColumn(3, row: 6)
you can ask the Level
for the cookie at column 3, row 6. Behind the scenes this asks the Array2D
for the cookie and then returns it. Note that the return type is Cookie?
, an optional, because not all grid squares will necessarily have a cookie (they may be nil).
Notice the use of assert()
to verify that the specified column and row numbers are within the valid range of 0-8.
Note: New to assert
? The idea behind assert
is you give it a condition, and if the condition fails the app will crash with a log message.
“Wait a minute,” you may think, “why would I want to crash my app on purpose?!”
Crashing your app on purpose is actually a good thing if you have a condition that you don’t expect to ever happen in your app like this one. assert
will help you because when the app crashes, the backtrace will point exactly to this unexpected condition, making it nice and easy to resolve the source of the problem.
Now to fill up that cookies
array with some cookies! Later on you will learn how to read level designs from a JSON file but for now, you’ll fill up the array yourself, just so there is something to show on the screen.
Add the following two methods to Level.swift:
func shuffle() -> Set<Cookie> {
return createInitialCookies()
}
func createInitialCookies() -> Set<Cookie> {
var set = Set<Cookie>()
// 1
for row in 0..NumRows {
for column in 0..NumColumns {
// 2
var cookieType = CookieType.random()
// 3
let cookie = Cookie(column: column, row: row, cookieType: cookieType)
cookies[column, row] = cookie
// 4
set.addElement(cookie)
}
}
return set
} |
(Don’t worry about the error messages on the lines with Set<Cookie>
. You’ll fix that in a moment.)
The shuffle
method fills up the level with random cookies. Right now it just calls createInitialCookies()
, where the real work happens. Here’s what it does, step by step:
- The method loops through the rows and columns of the 2-D array. This is something you’ll see a lot in this tutorial. Remember that column 0, row 0 is in the bottom-left corner of the 2-D grid.
- Then the method picks a random cookie type. This uses the
random()
function you added to the CookieType
enum earlier.
- Next, the method creates a new
Cookie
object and adds it to the 2-D array.
- Finally, the method adds the new
Cookie
object to a Set
. shuffle
returns this set of cookie objects to its caller.
One of the main difficulties when designing your code is deciding how the different objects will communicate with each other. In this game, you often accomplish this by passing around a collection of objects, usually a Set
or Array
.
In this case, after you create a new Level
object and call shuffle
to fill it up with cookies, the Level
replies, “Here is a set with all the new Cookie
objects I just added.” You can take that set and, for example, create new sprites for all the cookie objects it contains. In fact, that’s exactly what you’ll do in the next section.
First, however, there is that error with Set<Cookie>
. Because Set
stores its elements as dictionary keys, the objects that you put into the set must conform to the Hashable
protocol. That’s a requirement for Swift’s dictionary keys. Right now, Cookie
does not conform to Hashable
, which is why Xcode gets upset.
Switch to Cookie.swift and change the class declaration to include Hashable
:
class Cookie: Printable, Hashable { |
Add the following property inside the class:
var hashValue: Int {
return row*10 + column
} |
The Hashable
protocol requires that you add a hashValue
property to the object. This should return an Int value that is as unique as possible for your object. Its position in the 2-D grid is enough to identify each cookie, and you’ll use that to generate the hash value.
Also add the following function outside the Cookie
class:
func ==(lhs: Cookie, rhs: Cookie) -> Bool {
return lhs.column == rhs.column && lhs.row == rhs.row
} |
Whenever you add the Hashable
protocol to an object, you also need to supply the ==
comparison operator for comparing two objects of the same type.
Now the errors in Level.swift
should be gone. Press Command+B to build the app and make sure you’re no longer getting any compilation errors.
The Scene and the View Controller
In many Sprite Kit games, the “scene” is the main object for the game. In Cookie Crunch, however, you’ll make the view controller play that role.
Why? The game will include UIKit elements, such as labels, and it makes sense for the view controller to manage them. You’ll still have a scene object—GameScene
from the template—but this will only be responsible for drawing the sprites; it won’t handle any of the game logic.
Cookie Crunch will use an architecture that is very much like the model-view-controller or MVC pattern that you may know from non-game apps:
- The data model will consist of
Level
, Cookie
and a few other classes. The models will contain the data, such as the 2-D grid of cookie objects, and handle most of the gameplay logic.
- The views will be
GameScene
and the SKSpriteNodes
on the one hand, and UIViews
on the other. The views will be responsible for showing things on the screen and for handling touches on those things. The scene in particular will draw the cookie sprites and detect swipes.
- The view controller will play the same role here as in a typical MVC app: it will sit between the models and the views and coordinate the whole shebang.
All of these objects will communicate with each other, mostly by passing arrays and sets of objects to be modified. This separation will give each object only one job that it can do, totally independent of the others, which will keep the code clean and easy to manage.
Note: Putting the game data and rules in separate model objects is especially useful for unit testing. This tutorial doesn’t cover unit testing but, for a game such as this, it’s a good idea to have a comprehensive set of tests for the game rules.
If game logic and sprites are all mixed up, then it’s hard to write such tests, but in this case you can test Level
separate from the other components. This kind of testing lets you add new game rules with confidence you didn’t break any of the existing ones.
Open GameScene.swift and add the following properties to the class:
var level: Level!
let TileWidth: CGFloat = 32.0
let TileHeight: CGFloat = 36.0
let gameLayer = SKNode()
let cookiesLayer = SKNode() |
The scene has a public property to hold a reference to the current level. This variable is marked as Level!
because it will not initially have a value.
Each square of the 2-D grid measures 32 by 36 points, so you put those values into the TileWidth
and TileHeight
constants. These constants will make it easier to calculate the position of a cookie sprite.
To keep the Sprite Kit node hierarchy neatly organized, GameScene
uses several layers. The base layer is called gameLayer
. This is the container for all the other layers and it’s centered on the screen. You’ll add the cookie sprites to cookiesLayer
, which is a child of gameLayer
.
Add the following lines to init
to add the new layers. Put this after the code that creates the background node:
addChild(gameLayer)
let layerPosition = CGPoint(
x: -TileWidth * CGFloat(NumColumns) / 2,
y: -TileHeight * CGFloat(NumRows) / 2)
cookiesLayer.position = layerPosition
gameLayer.addChild(cookiesLayer) |
This adds two empty SKNode
s to the screen to act as layers. You can think of these as transparent planes you can add other nodes in.
Remember that earlier you set the anchorPoint
of the scene to (0, 0), and the position
of the scene also defaults to (0, 0). This means (0, 0) is in the center of the screen. Therefore, when you add these layers as children of the scene, the point (0, 0) in layer coordinates will also be in the center of the screen.
However, because column 0, row 0 is in the bottom-left corner of the 2-D grid, you want the positions of the sprites to be relative to the cookiesLayer
’s bottom-left corner, as well. That’s why you move the layer down and to the left by half the height and width of the grid.
Note: Because NumColumns
and NumRows
are of type Int but CGPoint
‘s x
and y
fields are of type CGFloat
, you have to convert these values by writing CGFloat(NumColumns)
. You’ll see this sort of thing a lot in Swift code.
Adding the sprites to the scene happens in addSpritesForCookies()
. Add it below:
func addSpritesForCookies(cookies: Set<Cookie>) {
for cookie in cookies {
let sprite = SKSpriteNode(imageNamed: cookie.cookieType.spriteName)
sprite.position = pointForColumn(cookie.column, row:cookie.row)
cookiesLayer.addChild(sprite)
cookie.sprite = sprite
}
}
func pointForColumn(column: Int, row: Int) -> CGPoint {
return CGPoint(
x: CGFloat(column)*TileWidth + TileWidth/2,
y: CGFloat(row)*TileHeight + TileHeight/2)
} |
addSpritesForCookies()
iterates through the set of cookies and adds a corresponding SKSpriteNode
instance to the cookie layer. This uses a helper method, pointForColumn(column:, row:)
, that converts a column and row number into a CGPoint
that is relative to the cookiesLayer
. This point represents the center of the cookie’s SKSpriteNode
.
Hop over to GameViewController.swift and add a new property to the class:
Next, add these two new methods:
func beginGame() {
shuffle()
}
func shuffle() {
let newCookies = level.shuffle()
scene.addSpritesForCookies(newCookies)
} |
beginGame()
kicks off the game by calling shuffle()
. This is where you call Level
’s shuffle()
method, which returns the Set
containing new Cookie
objects. Remember that these cookie objects are just model data; they don’t have any sprites yet. To show them on the screen, you tell GameScene
to add sprites for those cookies.
Make sure you call beginGame()
at the end of viewDidLoad
to set things in motion:
override func viewDidLoad() {
...
beginGame()
} |
The only missing piece is creating the actual Level
instance. That also happens in viewDidLoad()
. Add the following lines just before the code that presents the scene:
level = Level()
scene.level = level |
After creating the new Level
instance, you set the level
property on the scene to tie together the model and the view.
Note: The reason you declared the var level
property as Level!
, with an exclamation point, is that all properties must have a value by the time the class is initialized. But you can’t give level
a value in init()
yet; that doesn’t happen until viewDidLoad
. With the !
you tell Swift that this variable won’t have a value until later (but once it’s set, it can never become nil again).
Build and run, and you should finally see some cookies:
Loading Levels from JSON Files
Not all the levels in Candy Crush Saga have grids that are a simple square shape. You will now add support for loading level designs from JSON files. The five designs you’re going to load still use the same 9×9 grid, but they leave some of the squares blank.
Drag the Levels folder from the tutorial’s Resources folder into your Xcode project. As always, make sure Destination: Copy items if needed is checked. This folder contains five JSON files:
Click on Level_1.json to look inside. You’ll see that the contents are structured as a dictionary containing three elements: tiles
, targetScore
and moves
.
The tiles
array contains nine other arrays, one for each row of the level. If a tile has a value of 1, it can contain a cookie; a 0 means the tile is empty.
You’ll load this data in Level
, but first you need to add a new class, Tile
, to represent a single tile in the 2-D level grid. Note that a tile is different than a cookie — think of tiles as “slots”, and of cookies as the things inside the slots. I’ll discuss more about this in a bit.
Add a new Swift File to the project. Name it Tile.swift. Replace the contents of this file with:
You can leave this new class empty right now. Later on, I’ll give you some hints for how to use this class to add additional features to the game, such as “jelly” tiles.
Open Level.swift and add a new property and method:
let tiles = Array2D<Tile>(columns: NumColumns, rows: NumRows) // private
func tileAtColumn(column: Int, row: Int) -> Tile? {
assert(column >= 0 && column < NumColumns)
assert(row >= 0 && row < NumRows)
return tiles[column, row]
} |
The tiles
variable describes the structure of the level. This is very similar to the cookies
array, except now you make it an Array2D
of Tile
objects.
Whereas the cookies
array keeps track of the Cookie
objects in the level, tiles
simply describes which parts of the level grid are empty and which can contain a cookie:
Wherever tiles[a, b]
is nil
, the grid is empty and cannot contain a cookie.
Now that the instance variables for level data are in place, you can start adding the code to fill in the data. The top-level item in the JSON file is a dictionary, so it makes sense to add the code for loading the JSON file to Swift’s Dictionary
.
Go to File\New\File…, choose the iOS\Source\Swift File template and click Next. Name the file Extensions.swift and click Create.
Add the following code in Extensions.swift:
extension Dictionary {
static func loadJSONFromBundle(filename: String) -> Dictionary<String, AnyObject>? {
let path = NSBundle.mainBundle().pathForResource(filename, ofType: "json")
if !path {
println("Could not find level file: \(filename)")
return nil
}
var error: NSError?
let data: NSData? = NSData(contentsOfFile: path, options: NSDataReadingOptions(),
error: &error)
if !data {
println("Could not load level file: \(filename), error: \(error!)")
return nil
}
let dictionary: AnyObject! = NSJSONSerialization.JSONObjectWithData(data,
options: NSJSONReadingOptions(), error: &error)
if !dictionary {
println("Level file '\(filename)' is not valid JSON: \(error!)")
return nil
}
return dictionary as? Dictionary<String, AnyObject>
}
} |
Using Swift’s extension mechanism you can add new methods to existing types. Here you have added loadJSONFromBundle()
to load a JSON file from the app bundle, into a new dictionary of type Dictionary<String, AnyObject>
. This means the dictionary’s keys are always strings but the associated values can be any type of object.
The method simply loads the specified file into an NSData
object and then converts that to a Dictionary
using the NSJSONSerialization
API. This is mostly boilerplate code that you’ll find in any app that deals with JSON files.
Next, add the new init(filename:)
initializer to Level.swift:
init(filename: String) {
// 1
if let dictionary = Dictionary<String, AnyObject>.loadJSONFromBundle(filename) {
// 2
if let tilesArray: AnyObject = dictionary["tiles"] {
// 3
for (row, rowArray) in enumerate(tilesArray as Int[][]) {
// 4
let tileRow = NumRows - row - 1
// 5
for (column, value) in enumerate(rowArray) {
if value == 1 {
tiles[column, tileRow] = Tile()
}
}
}
}
}
} |
Here’s what this initializer does, step-by-step:
- Load the named file into a
Dictionary
using the loadJSONFromBundle()
helper function that you just added. Note that this function may return nil — it returns an optional — and you must use optional binding to handle this situation.
- The dictionary has an array named “tiles”. This array contains one element for each row of the level. Each of those row elements is itself an array containing the columns for that row. The type of
tilesArray
is therefore array-of-array-of-Int, or Int[][]
.
- Step through the rows using built-in
enumerate()
function, which is useful because it also returns the current row number.
- In Sprite Kit (0, 0) is at the bottom of the screen, so you have to reverse the order of the rows here. The first row you read from the JSON corresponds to the last row of the 2-D grid.
- Step through the columns in the current row. Every time it finds a 1, it creates a
Tile
object and places it into the tiles
array.
At this point, the code will compile without errors but you still need to put this new tiles
array to good use. Inside createInitialCookies()
, add an if-clause inside the two for-loops, around the code that creates the Cookie
object:
// This line is new
if tiles[column, row] != nil {
var cookieType = ...
...
set.addElement(cookie)
} |
Now the app will only create a Cookie
object if there is a tile at that spot.
One last thing remains: In GameViewController.swift’s viewDidLoad()
, replace the line that creates the level object with:
level = Level(filename: "Level_1") |
Build and run, and now you should have a non-square level shape:
Making the Tiles Visible
To make the cookie sprites stand out from the background a bit more, you can draw a slightly darker “tile” sprite behind each cookie. The texture atlas already contains an image for this (Tile.png). These new tile sprites will live on their own layer, the tilesLayer
.
To do this, first add a new private property to GameScene.swift:
let tilesLayer = SKNode() |
Then add this code to init(size:)
, right above where you add the cookiesLayer
:
tilesLayer.position = layerPosition
gameLayer.addChild(tilesLayer) |
It needs to be done first so the tiles appear behind the cookies (Sprite Kit nodes with the same zPosition
are drawn in order of how they were added).
Add the following method to GameScene.swift, as well:
func addTiles() {
for row in 0..NumRows {
for column in 0..NumColumns {
if let tile = level.tileAtColumn(column, row: row) {
let tileNode = SKSpriteNode(imageNamed: "Tile")
tileNode.position = pointForColumn(column, row: row)
tilesLayer.addChild(tileNode)
}
}
}
} |
This loops through all the rows and columns. If there is a tile at that grid square, then it creates a new tile sprite and adds it to the tiles layer.
Next, open GameViewController.swift. Add the following line to viewDidLoad()
, immediately after you set scene.level
:
Build and run, and you can clearly see where the tiles are:
You can switch to another level design by specifying a different file name in viewDidLoad()
. Simply change the filename:
parameter to “Level_2″, “Level_3″ or “Level_4″ and build and run. Does Level 3 remind you of anything? :]
Feel free to make your own designs, too! Just remember that the “tiles” array should contain nine arrays (one for each row), with nine numbers each (one for each column).
Swiping to Swap Cookies
In Cookie Crunch Adventure, you want the player to be able to swap two cookies by swiping left, right, up or down.
Detecting swipes is a job for GameScene
. If the player touches a cookie on the screen, then this might be the start of a valid swipe motion. Which cookie to swap with the touched cookie depends on the direction of the swipe.
To recognize the swipe motion, you’ll use the touchesBegan
, touchesMoved
and touchesEnded
methods from GameScene
. Even though iOS has very handy pan and swipe gesture recognizers, these don’t provide the level of accuracy and control that this game needs.
Go to GameScene.swift and add two private properties to the class:
var swipeFromColumn: Int?
var swipeFromRow: Int? |
These properties record the column and row numbers of the cookie that the player first touched when she started her swipe movement.
Initialize these two properties at the bottom of init(size:)
:
swipeFromColumn = nil
swipeFromRow = nil |
The value nil
means that these properties have invalid values. In other words, they don’t yet point at any of the cookies. This is why they are declared as optionals — Int?
instead of just Int
— because they need to be nil when the player is not swiping.
Now add a new method touchesBegan()
:
override func touchesBegan(touches: NSSet!, withEvent event: UIEvent!) {
// 1
let touch = touches.anyObject() as UITouch
let location = touch.locationInNode(cookiesLayer)
// 2
let (success, column, row) = convertPoint(location)
if success {
// 3
if let cookie = level.cookieAtColumn(column, row: row) {
// 4
swipeFromColumn = column
swipeFromRow = row
}
}
} |
Note: This method needs to be marked override
because the base class SKScene
already contains a version of touchesBegan
. This is how you tell Swift that you want it to use your own version.
The game will call touchesBegan()
whenever the user puts her finger on the screen. Here’s what the method does, step by step:
- It converts the touch location to a point relative to the
cookiesLayer
.
- Then, it finds out if the touch is inside a square on the level grid by calling a method you’ll write in a moment. If so, then this might be the start of a swipe motion. At this point, you don’t know yet whether that square contains a cookie, but at least the player put her finger somewhere inside the 9×9 grid.
- Next, the method verifies that the touch is on a cookie rather than on an empty square.
- Finally, it records the column and row where the swipe started so you can compare them later to find the direction of the swipe.
The convertPoint()
method is new. It’s the opposite of pointForColumn(column:, row:)
, so you may want to add this method right below pointForColumn()
so the two methods are nearby.
func convertPoint(point: CGPoint) -> (success: Bool, column: Int, row: Int) {
if point.x >= 0 && point.x < CGFloat(NumColumns)*TileWidth &&
point.y >= 0 && point.y < CGFloat(NumRows)*TileHeight {
return (true, Int(point.x / TileWidth), Int(point.y / TileHeight))
} else {
return (false, 0, 0) // invalid location
}
} |
This method takes a CGPoint
that is relative to the cookiesLayer
and converts it into column and row numbers. The return value of this method is a tuple with three values: 1) the boolean that indicates success or failure; 2) the column number; and 3) the row number. If the point falls outside the grid, this method returns false
for success.
So far, you have detected the start of a possible swipe motion. To perform a valid swipe, the player also has to move her finger out of the current square. It doesn’t really matter where the finger ends up—you’re only interested in the general direction of the swipe, not the exact destination.
The logic for detecting the swipe direction goes into touchesMoved()
, so add this method next:
override func touchesMoved(touches: NSSet!, withEvent event: UIEvent!) {
// 1
if swipeFromColumn == nil { return }
// 2
let touch = touches.anyObject() as UITouch
let location = touch.locationInNode(cookiesLayer)
let (success, column, row) = convertPoint(location)
if success {
// 3
var horzDelta = 0, vertDelta = 0
if column < swipeFromColumn! { // swipe left
horzDelta = -1
} else if column > swipeFromColumn! { // swipe right
horzDelta = 1
} else if row < swipeFromRow! { // swipe down
vertDelta = -1
} else if row > swipeFromRow! { // swipe up
vertDelta = 1
}
// 4
if horzDelta != 0 || vertDelta != 0 {
trySwapHorizontal(horzDelta, vertical: vertDelta)
// 5
swipeFromColumn = nil
}
}
} |
Here is what this does step by step:
- If
swipeFromColumn
is nil
, then either the swipe began outside the valid area or the game has already swapped the cookies and you need to ignore the rest of the motion. You could keep track of this in a separate boolean but using swipeFromColumn
is just as easy — that’s why you made it an optional.
- This is similar to what
touchesBegan()
does to calculate the row and column numbers currently under the player’s finger.
- Here the method figures out the direction of the player’s swipe by simply comparing the new column and row numbers to the previous ones. Note that you’re not allowing diagonal swipes (since you’re using
else if
statements, only one of horzDelta
or vertDelta
will be set).
- The method only performs the swap if the player swiped out of the old square.
- By setting
swipeFromColumn
back to nil
, the game will ignore the rest of this swipe motion.
Note: To read the actual values from swipeFromColumn
and swipeFromRow
, you have to use the exclamation point. These are optional variables, and using the !
will “unwrap” the optional. Normally you’d use optional binding to read the value of an optional but here you’re guaranteed that swipeFromRow
is not nil (you checked for that at the top of the method), so using !
is perfectly safe.
The hard work of cookie-swapping goes into a new method:
func trySwapHorizontal(horzDelta: Int, vertical vertDelta: Int) {
// 1
let toColumn = swipeFromColumn! + horzDelta
let toRow = swipeFromRow! + vertDelta
// 2
if toColumn < 0 || toColumn >= NumColumns { return }
if toRow < 0 || toRow >= NumRows { return }
// 3
if let toCookie = level.cookieAtColumn(toColumn, row: toRow) {
if let fromCookie = level.cookieAtColumn(swipeFromColumn!, row: swipeFromRow!) {
// 4
println("*** swapping \(fromCookie) with \(toCookie)")
}
}
} |
This is called “try swap” for a reason. At this point, you only know that the player swiped up, down, left or right, but you don’t yet know if there are two cookies to swap in that direction.
- You calculate the column and row numbers of the cookie to swap with.
- It is possible that the
toColumn
or toRow
is outside the 9×9 grid. This can occur when the user swipes from a cookie near the edge of the grid. The game should ignore such swipes.
- The final check is to make sure that there is actually a cookie at the new position. You can’t swap if there’s no second cookie. This happens when the user swipes into a gap where there is no tile.
- When you get here, it means everything is OK and this is a valid swap! For now, you log both cookies to the Xcode debug pane.
For completeness’s sake, you should also implement touchesEnded()
, which is called when the user lifts her finger from the screen, and touchesCancelled()
, which happens when iOS decides that it must interrupt the touch (for example, because of an incoming phone call).
Add the following:
override func touchesEnded(touches: NSSet!, withEvent event: UIEvent!) {
swipeFromColumn = nil
swipeFromRow = nil
}
override func touchesCancelled(touches: NSSet!, withEvent event: UIEvent!) {
touchesEnded(touches, withEvent: event)
} |
If the gesture ends, regardless of whether it was a valid swipe, you reset the starting column and row numbers to the special value nil
.
Great! Build and run, and try out different swaps:
Of course, you won’t see anything happen in the game yet, but at least the debug pane logs your attempts to make a valid swap.
Animating the Swaps
To describe the swapping of two cookies, you will create a new class, Swap
. This is another model class whose only purpose it is to say, “The player wants to swap cookie A with cookie B.”
Create a new Swift File named Swap.swift. Replace the contents of Swap.swift with the following:
class Swap: Printable {
var cookieA: Cookie
var cookieB: Cookie
init(cookieA: Cookie, cookieB: Cookie) {
self.cookieA = cookieA
self.cookieB = cookieB
}
var description: String {
return "swap \(cookieA) with \(cookieB)"
}
} |
Now that you have an object that can describe an attempted swap, the question becomes: Who will handle the logic of actually performing the swap? The swipe detection logic happens in GameScene
, but all the real game logic so far is in GameViewController
.
That means GameScene
must have a way to communicate back to GameViewController
that the player performed a valid swipe and that a swap must be attempted. One way to communicate is through a delegate protocol, but since this is the only message that GameScene
must send back to GameViewController
, you’ll use a closure instead.
Add the following property to the top of GameScene.swift:
var swipeHandler: ((Swap) -> ())? |
That looks scary… The type of this variable is ((Swap) -> ())?
. Because of the ->
you can tell this is a closure or function. This closure or function takes a Swap
object as its parameter and does not return anything. The question mark indicates that swipeHandler
is allowed to be nil (it is an optional).
It’s the scene’s job to handle touches. If it recognizes that the user made a swipe, it will call the closure that’s stored in the swipe handler. This is how it communicates back to the GameViewController
that a swap needs to take place.
Still in GameScene.swift, add the following code to the bottom of trySwapHorizontal(vertical:)
, replacing the println()
statement:
if let handler = swipeHandler {
let swap = Swap(cookieA: fromCookie, cookieB: toCookie)
handler(swap)
} |
This creates a new Swap
object, fills in the two cookies to be swapped and then calls the swipe handler to take care of the rest. Because swipeHandler
can be nil, you use optional binding to get a valid reference first.
GameViewController
will decide whether the swap is valid; if it is, you’ll need to animate the two cookies. Add the following method to do this in GameScene.swift:
func animateSwap(swap: Swap, completion: () -> ()) {
let spriteA = swap.cookieA.sprite!
let spriteB = swap.cookieB.sprite!
spriteA.zPosition = 100
spriteB.zPosition = 90
let Duration: NSTimeInterval = 0.3
let moveA = SKAction.moveTo(spriteB.position, duration: Duration)
moveA.timingMode = .EaseOut
spriteA.runAction(moveA, completion: completion)
let moveB = SKAction.moveTo(spriteA.position, duration: Duration)
moveB.timingMode = .EaseOut
spriteB.runAction(moveB)
} |
This is basic SKAction
animation code: You move cookie A to the position of cookie B and vice versa.
The cookie that was the origin of the swipe is in cookieA
and the animation looks best if that one appears on top, so this method adjusts the relative zPosition
of the two cookie sprites to make that happen.
After the animation completes, the action on cookieA
calls a completion block so the caller can continue doing whatever it needs to do. That’s a common pattern for this game: The game waits until an animation is complete and then it resumes.
() -> ()
is simply shorthand for a closure that returns void and takes no parameters.
Now that you’ve handled the view, there’s still the model to deal with before getting to the controller! Open Level.swift and add the following method:
func performSwap(swap: Swap) {
let columnA = swap.cookieA.column
let rowA = swap.cookieA.row
let columnB = swap.cookieB.column
let rowB = swap.cookieB.row
cookies[columnA, rowA] = swap.cookieB
swap.cookieB.column = columnA
swap.cookieB.row = rowA
cookies[columnB, rowB] = swap.cookieA
swap.cookieA.column = columnB
swap.cookieA.row = rowB
} |
This first makes temporary copies of the row and column numbers from the Cookie
objects because they get overwritten. To make the swap, it updates the cookies
array, as well as the column and row properties of the Cookie
objects, which shouldn’t go out of sync. That’s it for the data model.
Go to GameViewController.swift and add the following method:
func handleSwipe(swap: Swap) {
view.userInteractionEnabled = false
level.performSwap(swap)
scene.animateSwap(swap) {
self.view.userInteractionEnabled = true
}
} |
You first tell the level to perform the swap, which updates the data model—and then tell the scene to animate the swap, which updates the view. Over the course of this tutorial, you’ll add the rest of the gameplay logic to this function.
While the animation is happening, you don’t want the player to be able to touch anything else, so you temporarily turn off userInteractionEnabled
on the view. You turn it back on in the completion block that is passed to animateSwap()
.
Note: The above uses so-called trailing closure syntax, where the closure is written behind the function call. An alternative way to write it is as follows:
scene.animateSwap(swap, completion: {
self.view.userInteractionEnabled = true
}) |
Also add the following line to viewDidLoad()
, just before the line that presents the scene:
scene.swipeHandler = handleSwipe |
This assigns the handleSwipe()
function to GameScene
’s swipeHandler
property. Now whenever GameScene
calls swipeHandler(swap)
, it actually calls a function in GameViewController
. Freaky! This works because in Swift you can use functions and closures interchangeably.
Build and run the app. You can now swap the cookies! Also, try to make a swap across a gap—it won’t work!
Highlighting the Cookies
In Candy Crush Saga, the candy you swipe lights up for a brief moment. You can achieve this effect in Cookie Crunch Adventure by placing a highlight image on top of the sprite.
The texture atlas has highlighted versions of the cookie sprites that are brighter and more saturated. The CookieType
enum already has a function to return the name of this image: highlightedSpriteName
.
You will improve GameScene
to add this highlighted cookie on top of the existing cookie sprite. Adding it as a new sprite, as opposed to replacing the existing sprite’s texture, makes it easier to crossfade back to the original image.
In GameScene.swift, add a new private property to the class:
var selectionSprite = SKSpriteNode() |
Add the following method:
func showSelectionIndicatorForCookie(cookie: Cookie) {
if selectionSprite.parent != nil {
selectionSprite.removeFromParent()
}
if let sprite = cookie.sprite {
let texture = SKTexture(imageNamed: cookie.cookieType.highlightedSpriteName)
selectionSprite.size = texture.size()
selectionSprite.runAction(SKAction.setTexture(texture))
sprite.addChild(selectionSprite)
selectionSprite.alpha = 1.0
}
} |
This gets the name of the highlighted sprite image from the Cookie
object and puts the corresponding texture on the selection sprite. Simply setting the texture on the sprite doesn’t give it the correct size but using an SKAction
does.
You also make the selection sprite visible by setting its alpha to 1. You add the selection sprite as a child of the cookie sprite so that it moves along with the cookie sprite in the swap animation.
Add the opposite method, hideSelectionIndicator()
:
func hideSelectionIndicator() {
selectionSprite.runAction(SKAction.sequence([
SKAction.fadeOutWithDuration(0.3),
SKAction.removeFromParent()]))
} |
This method removes the selection sprite by fading it out.
It remains for you to call these methods. First, in touchesBegan()
, in the if let cookie = ...
section, add:
showSelectionIndicatorForCookie(cookie) |
And in touchesMoved()
, after the call to trySwapHorizontal()
, add:
There is one last place to call hideSelectionIndicator()
. If the user just taps on the screen rather than swipes, you want to fade out the highlighted sprite, too. Add these lines to the top of touchesEnded()
:
if selectionSprite.parent != nil && swipeFromColumn != nil {
hideSelectionIndicator()
} |
Build and run, and highlight some cookies!
A Smarter Way to Fill the Array
The purpose of this game is to make chains of three or more of the same cookie. But right now, when you run the game there may already be such chains on the screen. That’s no good—you only want there to be matches after the user swaps two cookies or after new cookies fall down the screen.
Here’s your rule: Whenever it’s the user’s turn to make a move, whether at the start of the game or at the end of a turn, no matches may be on the board already. To guarantee this is the case, you have to make the method that fills up the cookies array a bit smarter.
Go to Level.swift and find createInitialCookies()
. Replace the single line that calculates the random cookieType
with the following:
var cookieType: CookieType
do {
cookieType = CookieType.random()
}
while (column >= 2 &&
cookies[column - 1, row]?.cookieType == cookieType &&
cookies[column - 2, row]?.cookieType == cookieType)
|| (row >= 2 &&
cookies[column, row - 1]?.cookieType == cookieType &&
cookies[column, row - 2]?.cookieType == cookieType) |
Yowza! What is all this? This piece of logic picks the cookie type at random and makes sure that it never creates a chain of three or more.
In pseudo-code, it looks like this:
do {
generate a new random cookie type
}
while there are already two cookies of this type to the left
or there are already two cookies of this type below |
If the new random number causes a chain of three—because there are already two cookies of this type to the left or below—then the method tries again. The loop repeats until it finds a random number that does not create a chain of three or more. It only has to look to the left or below because there are no cookies yet on the right or above.
Try it out! Run the app and verify that there are no longer any chains in the initial state of the game.
Not All Swaps Are Allowed
You only want the let the player swap two cookies if it would result in either (or both) of these cookies making a chain of three or more.
You need to add some logic to the game to detect whether a swap results in a chain. There are two ways you could do this. The most obvious way is to check at the moment the user tries the swap.
Or—and this is what you’ll do in this tutorial—you could build a list of all possible moves after the level is shuffled. Then you only have to check if the attempted swap is in that list.
Note: Building a list also makes it easy to show a hint to the player. You’re not going to do that in this tutorial, but in Candy Crush Saga, if you don’t play for a few seconds, the game lights up a possible swap. You can implement this for yourself by picking a random item from this list of possible moves.
In Level.swift, add a new property:
var possibleSwaps = Set<Swap>() // private |
Again, you’re using a Set
here instead of an Array
because the order of the elements in this collection isn’t important. This Set
will contain Swap
objects. If the player tries to swap two cookies that are not in the set, then the game won’t accept the swap as a valid move.
Xcode warns that Swap
cannot be used in a Set
, and that’s because Swap
does not implement the Hashable
protocol yet.
Open up Swap.swift and make the following changes. First, add Hashable
to the class declaration:
class Swap: Printable, Hashable { |
Then add the hashValue
property inside the class:
var hashValue: Int {
return cookieA.hashValue ^ cookieB.hashValue
} |
This simply combines the hash values of the two cookies with the exclusive-or operator. That’s a common trick to make hash values.
And finally, add the ==
function outside of the class:
func ==(lhs: Swap, rhs: Swap) -> Bool {
return (lhs.cookieA == rhs.cookieA && lhs.cookieB == rhs.cookieB) ||
(lhs.cookieB == rhs.cookieA && lhs.cookieA == rhs.cookieB)
} |
Now you can use Swap
objects in a Set
and the compiler error should be history.
At the start of each turn, you need to detect which cookies the player can swap. You’re going to make this happen in shuffle()
. Go back to Level.swift and change the code for that method to:
func shuffle() -> Set<Cookie> {
var set: Set<Cookie>
do {
set = createInitialCookies()
detectPossibleSwaps()
println("possible swaps: \(possibleSwaps)")
}
while possibleSwaps.count == 0
return set
} |
As before, this calls createInitialCookies()
to fill up the level with random cookie objects. But then it calls a new method that you will add shortly, detectPossibleSwaps()
, to fill up the new possibleSwaps
set.
In the very rare case that you end up with a distribution of cookies that allows for no swaps at all, this loop repeats to try again. You can test this with a very small level, such as one with only 3×3 tiles. I’ve included such a level for you in the project called Level_4.json.
detectPossibleSwaps()
will use a helper method to see if a cookie is part of a chain. Add this method now:
func hasChainAtColumn(column: Int, row: Int) -> Bool {
let cookieType = cookies[column, row]!.cookieType
var horzLength = 1
for var i = column - 1; i >= 0 && cookies[i, row]?.cookieType == cookieType;
--i, ++horzLength { }
for var i = column + 1; i < NumColumns && cookies[i, row]?.cookieType == cookieType;
++i, ++horzLength { }
if horzLength >= 3 { return true }
var vertLength = 1
for var i = row - 1; i >= 0 && cookies[column, i]?.cookieType == cookieType;
--i, ++vertLength { }
for var i = row + 1; i < NumRows && cookies[column, i]?.cookieType == cookieType;
++i, ++vertLength { }
return vertLength >= 3
} |
A chain is three or more consecutive cookies of the same type in a row or column. This method may look a little strange but that’s because it stuffs a lot of the logic inside the for-statements.
Given a cookie in a particular square on the grid, this method first looks to the left. As long as it finds a cookie of the same type, it increments horzLength
and keeps going left. This is expressed succinctly in a single line of code:
for var i = column - 1; i >= 0 && cookies[i, row]?.cookieType == cookieType; --i, ++horzLength { } |
This for
loop has an empty body. That means all the logic happens inside its parameters.
for var i = column - 1; // start on the left of the current cookie
i >= 0 && // keep going while not left-most column reached
cookies[i, row]?.cookieType == cookieType; // and still the same cookie type
--i, // go to the next column on the left
++horzLength // and increment the length
{ } // do nothing inside the loop |
You can also write this out using a while statement but the for
loop allows you to fit everything on a single line. :] There are also loops for looking to the right, above and below.
Note: It’s possible that cookies[column, row]
will return nil
because of a gap in the level design, meaning there is no cookie at that location. That’s no problem because of Swift’s optional chaining. Because of the ?
operator, the loop will terminate whenever such a gap is encountered.
Now that you have this method, you can implement detectPossibleSwaps()
. Here’s how it will work at a high level:
- It will step through the rows and columns of the 2-D grid and simply swap each cookie with the one next to it, one at a time.
- If swapping these two cookies creates a chain, it will add a new
Swap
object to the list of possibleSwaps
.
- Then, it will swap these cookies back to restore the original state and continue with the next cookie until it has swapped them all.
- It will go through the above steps twice: once to check all horizontal swaps and once to check all vertical swaps.
It’s a big one, so you’ll take it in parts!
First, add the outline of the method:
func detectPossibleSwaps() {
var set = Set<Swap>()
for row in 0..NumRows {
for column in 0..NumColumns {
if let cookie = cookies[column, row] {
// TODO: detection logic goes here
}
}
}
possibleSwaps = set
} |
This is pretty simple: The method loops through the rows and columns, and for each spot, if there is a cookie rather than an empty square, it performs the detection logic. Finally, the method places the results into the possibleSwaps
property.
The detection will consist of two separate parts that do the same thing but in different directions. First you want to swap the cookie with the one on the right, and then you want to swap the cookie with the one above it. Remember, row 0 is at the bottom so you’ll work your way up.
Add the following code where it says “TODO: detection logic goes here”:
// Is it possible to swap this cookie with the one on the right?
if column < NumColumns - 1 {
// Have a cookie in this spot? If there is no tile, there is no cookie.
if let other = cookies[column + 1, row] {
// Swap them
cookies[column, row] = other
cookies[column + 1, row] = cookie
// Is either cookie now part of a chain?
if hasChainAtColumn(column + 1, row: row) ||
hasChainAtColumn(column, row: row) {
set.addElement(Swap(cookieA: cookie, cookieB: other))
}
// Swap them back
cookies[column, row] = cookie
cookies[column + 1, row] = other
}
} |
This attempts to swap the current cookie with the cookie on the right, if there is one. If this creates a chain of three or more, the code adds a new Swap
object to the set.
Now add the following code directly below the code above:
if row < NumRows - 1 {
if let other = cookies[column, row + 1] {
cookies[column, row] = other
cookies[column, row + 1] = cookie
// Is either cookie now part of a chain?
if hasChainAtColumn(column, row: row + 1) ||
hasChainAtColumn(column, row: row) {
set.addElement(Swap(cookieA: cookie, cookieB: other))
}
// Swap them back
cookies[column, row] = cookie
cookies[column, row + 1] = other
}
} |
This does exactly the same thing, but for the cookie above instead of on the right.
That should do it. In summary, this algorithm performs a swap for each pair of cookies, checks whether it results in a chain and then undoes the swap, recording every chain it finds.
Now run the app and you should see something like this in the Xcode debug pane:
possible swaps: [
swap type:SugarCookie square:(6,5) with type:Cupcake square:(7,5): true,
swap type:Croissant square:(3,3) with type:Macaroon square:(4,3): true,
swap type:Danish square:(6,0) with type:Macaroon square:(6,1): true,
swap type:Cupcake square:(6,4) with type:SugarCookie square:(6,5): true,
swap type:Croissant square:(4,2) with type:Macaroon square:(4,3): true,
. . . |
To Swap or Not to Swap…
Let’s put this list of possible moves to good use. Add the following method to Level.swift:
func isPossibleSwap(swap: Swap) -> Bool {
return possibleSwaps.containsElement(swap)
} |
This looks to see if the set of possible swaps contains the specified Swap
object. But wait a minute… when you perform a swipe, GameScene
creates a new Swap
object. How could isPossibleSwap()
possibly find that object inside its list? It may have a Swap
object that describes exactly the same move, but the actual instances in memory are different.
When you run set.containsElement(object)
, the set calls ==
on that object and all the objects it contains to determine if they match. Because you already provided an ==
operator for Swap
, this automagically works! It doesn’t matter that the Swap
objects are actually different instances; the set will find a match as long as two Swap
s can be considered equal.
Finally call the method in GameViewController.swift, inside the handleSwipe()
function:
func handleSwipe(swap: Swap) {
view.userInteractionEnabled = false
if level.isPossibleSwap(swap) {
level.performSwap(swap)
scene.animateSwap(swap) {
self.view.userInteractionEnabled = true
}
} else {
view.userInteractionEnabled = true
}
} |
Now the game will only perform the swap if it’s in the list of sanctioned swaps.
Build and run to try it out. You should only be able to make swaps if they result in a chain.
Note that after you perform a swap, the “valid swaps” list is now invalid. You’ll fix that in the next part of the series.
It’s also fun to animate attempted swaps that are invalid, so add the following method to GameScene.swift:
func animateInvalidSwap(swap: Swap, completion: () -> ()) {
let spriteA = swap.cookieA.sprite!
let spriteB = swap.cookieB.sprite!
spriteA.zPosition = 100
spriteB.zPosition = 90
let Duration: NSTimeInterval = 0.2
let moveA = SKAction.moveTo(spriteB.position, duration: Duration)
moveA.timingMode = .EaseOut
let moveB = SKAction.moveTo(spriteA.position, duration: Duration)
moveB.timingMode = .EaseOut
spriteA.runAction(SKAction.sequence([moveA, moveB]), completion: completion)
spriteB.runAction(SKAction.sequence([moveB, moveA]))
} |
This method is similar to animateSwap(swap:, completion:)
, but here it slides the cookies to their new positions and then immediately flips them back.
In GameViewController.swift, change the else-clause inside handleSwipe()
to:
} else {
scene.animateInvalidSwap(swap) {
self.view.userInteractionEnabled = true
}
} |
Now run the app and try to make a swap that won’t result in a chain:
Make Some Noise
Before wrapping up the first part of this tutorial, why don’t you go ahead and add some sound effects to the game? Open the Resources folder for this tutorial and drag the Sounds folder into Xcode.
Add new properties for these sound effects to GameScene.swift:
let swapSound = SKAction.playSoundFileNamed("Chomp.wav", waitForCompletion: false)
let invalidSwapSound = SKAction.playSoundFileNamed("Error.wav", waitForCompletion: false)
let matchSound = SKAction.playSoundFileNamed("Ka-Ching.wav", waitForCompletion: false)
let fallingCookieSound = SKAction.playSoundFileNamed("Scrape.wav", waitForCompletion: false)
let addCookieSound = SKAction.playSoundFileNamed("Drip.wav", waitForCompletion: false) |
Rather than recreate an SKAction
every time you need to play a sound, you’ll load all the sounds just once and keep re-using them.
Then add the following line to the bottom of animateSwap()
And add this line to the bottom of animateInvalidSwap()
:
runAction(invalidSwapSound) |
That’s all you need to do to make some noise. Chomp! :]
Where to Go From Here?
Here is a sample project with all of the code from the Swift tutorial up to this point.
Your game is shaping up nicely, but there’s still a way to go before it’s finished. For now, though, give yourself a cookie for making it halfway!
In the next part, you’ll implement the remaining pieces of the game flow: removing matching chains and replacing them with new cookies. You’ll also add scoring, lots of new animations and some final polish.
While you eat your cookie, take a moment to let us hear from you in the forums!
Credits: Artwork by Vicki Wenderlich. The music is by Kevin MacLeod. The sound effects are based on samples from freesound.org.
How to Make a Game Like Candy Crush with Swift Tutorial: Part 1 is a post from: Ray Wenderlich
The post How to Make a Game Like Candy Crush with Swift Tutorial: Part 1 appeared first on Ray Wenderlich.