
In this epic tutorial you’ll learn how to make a tasty match-3 game like Candy Crush with SpriteKit and Swift. Now updated for Xcode 7.3 and Swift 2.2.
Update note: This SpriteKit tutorial has been updated for Xcode 7.3 and Swift 2.2 by Morten Faarkrog. The original tutorial was written by Matthijs Hollemans.
Welcome back to our “How to Make a Game Like Candy Crush” tutorial with SpriteKit and Swift series. Your game is called Cookie Crunch Adventure and it’s delicious!
- In the first part, you put some of the foundation in place. You setup the gameplay view, the sprites, and the logic for loading levels.
- (You’re here) The second part will continue expanding on the foundation of the game. You’ll focus on detecting swipes and swapping cookies, and you’ll also create some nice visual effects in the process.
- In the third part, you’ll work on finding and removing chains and refilling the level with new yummy cookies after successful swipes.
- Finally, in the fourth part, you’ll complete the gameplay by adding support for scoring points, winning and losing, shuffling the cookies, and more.
This Swift tutorial picks up where you left off in the last part. If you don’t have it already, here is the project with all of the source code up to this point. You also need a copy of the resources zip (this is the same file from Part One).
Let’s get cookie-ing!
Add Swipe Gestures
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.
You first need to add a new convertPoint()
method. 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.
Now add the touchesBegan()
method:
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) { // 1 guard let touch = touches.first else { return } 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, if any, 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.
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: Set<UITouch>, withEvent event: UIEvent?) { // 1 guard swipeFromColumn != nil else { return } // 2 guard let touch = touches.first else { return } 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
isnil
, 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 usingswipeFromColumn
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 ofhorzDelta
orvertDelta
will be set). - The method only performs the swap if the player swiped out of the old square.
- By setting
swipeFromColumn
back tonil
, 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 guard toColumn >= 0 && toColumn < NumColumns else { return } guard toRow >= 0 && toRow < NumRows else { return } // 3 if let toCookie = level.cookieAtColumn(toColumn, row: toRow), let fromCookie = level.cookieAtColumn(swipeFromColumn!, row: swipeFromRow!) { // 4 print("*** 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
ortoRow
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: Set<UITouch>, withEvent event: UIEvent?) { swipeFromColumn = nil swipeFromRow = nil } override func touchesCancelled(touches: Set<UITouch>?, withEvent event: UIEvent?) { if let touches = touches { 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 type, Swap
. This is another model object 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:
struct Swap: CustomStringConvertible { let cookieA: Cookie let 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 print()
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!
Note: You may be wondering why Cookie
is a class but Swap
is a struct. In Swift a struct is a value type, while a class is a reference type. That means structs get copied when you pass them around, but for classes only a reference is passed around. (There are other differences too; for example, you can’t use inheritance with structs.)
The reason you can make Swap
, and also Set
and Array2D
, into structs is that these objects do not have an “identity”. A Swap
that links to cookie X and cookie Y is identical to another Swap
instance that links to cookie X and cookie Y, even though these two instances each take up their own space in memory. So these two Swap
instances are interchangeable, which is why they don’t have an identity. Likewise for Array2D
and Set
. Swift’s struct fits better with this sort of thing than class.
A Cookie
, on the other hand, is a uniquely identifiable thing. You want to have a reference to it, so different parts of the app all work on the same object instead of different copies. That’s why class makes more sense for Cookie
than struct.
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 = CGSize(width: TileWidth, height: TileHeight) 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.
What remains, is 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:
hideSelectionIndicator() |
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 light up 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 matches after the user swaps two cookies or after new cookies falls 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 exist on the board. 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 repeat { 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:
repeat { 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.
Track Allowable Swaps
You only want to 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.
Alternatively, 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, when the player takes too long, 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:
private var possibleSwaps = Set<Swap>() |
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 struct declaration:
struct Swap: CustomStringConvertible, Hashable { |
Then add the hashValue
property inside the struct:
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 struct:
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> repeat { set = createInitialCookies() detectPossibleSwaps() print("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:
private func hasChainAtColumn(column: Int, row: Int) -> Bool { let cookieType = cookies[column, row]!.cookieType // Horizontal chain check var horzLength = 1 // Left var i = column - 1 while i >= 0 && cookies[i, row]?.cookieType == cookieType { i -= 1 horzLength += 1 } // Right i = column + 1 while i < NumColumns && cookies[i, row]?.cookieType == cookieType { i += 1 horzLength += 1 } if horzLength >= 3 { return true } // Vertical chain check var vertLength = 1 // Down i = row - 1 while i >= 0 && cookies[column, i]?.cookieType == cookieType { i -= 1 vertLength += 1 } // Up i = row + 1 while i < NumRows && cookies[column, i]?.cookieType == cookieType { i += 1 vertLength += 1 } return vertLength >= 3 } |
A chain is three or more consecutive cookies of the same type in a row or column.
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.
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 ofpossibleSwaps
. - 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.insert(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.insert(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, . . . |
Block Unwanted Swaps
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.contains(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.contains(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. Replace the existing handleSwipe()
function with the following:
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:
Add Sound Efffects
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()
runAction(swapSound) |
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 the sample project with all of the code from the Swift tutorial up to this point.
Good job on finishing the second part of this four-part tutorial series. There’s still some way to go, but you’ve done a really great job laying down the foundation for your game. You surely deserve another cookie for making it halfway!
In the next part, you’ll work on finding and removing chains and refilling the level with new yummy cookies after successful swipes. It’ll be fun, we promise.
While you have a well-deserved break, take a moment to let us hear from you in the forums :]
Credits: Free game art from Game Art Guppy. The music is by Kevin MacLeod. The sound effects are based on samples from freesound.org.
Portions of the source code were inspired by Gabriel Nica‘s Swift port of the game.
The post How to Make a Game Like Candy Crush with SpriteKit and Swift: Part 2 appeared first on Ray Wenderlich.