How to Make a Game Like Candy Crush with Swift Tutorial: Part 2

How to Make a Game Like Candy Crush with Swift Tutorial: Part 2
Learn to make a tasty match-3 game in the new Swift language

Learn to make a tasty match-3 game in the new Swift language

Welcome back to our how to make a game like Candy Crush with Swift tutorial series!

This is the second part of a two-part series that teaches you how to make a match-3 game like Candy Crush Saga or Bejeweled. Your game is called Cookie Crunch Adventure and it’s delicious!

In the first part of the tutorial, you loaded a level shape from a JSON file, placed cookie sprites on the screen and implemented the logic for detecting swipes and swapping cookies.

In this second and final part, you’ll implement the rest of the game rules, add loads of animations and polish Cookie Crunch Adventure to a top-10-quality shine. I’m getting hungry just thinking about it!

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 crunch some cookies!

Getting Started

Everything you’ve worked on so far has been to allow the player to swap cookies. Next, your game needs to process the results of the swaps.

Swaps always lead to a chain of three or more matching cookies. The next thing to do is to remove those matching cookies from the screen and reward the player with some points.

This is the sequence of events:

Game flow

You’ve already done the first three steps: filling the level with cookies, calculating possible swaps and waiting for the player to make a swap. In this part of the Swift tutorial, you’ll implement the remaining steps.

Finding the Chains

At this point in the game flow, the player has made her move and swapped two cookies. Because the game only lets the player make a swap if it will result in a chain of three or more cookies of the same type, you know there is now at least one chain—but there could be additional chains, as well.

Before you can remove the matching cookies from the level, you first need to find all the chains. That’s what you’ll do in this section.

First, make a class that describes a chain. Go to File\New\File…, choose the iOS\Source\Swift File template and click Next. Name the file Chain.swift and click Create.

Replace the contents of Chain.swift with this:

class Chain: Hashable, Printable {
  var cookies = Array<Cookie>()  // private
 
  enum ChainType: Printable {
    case Horizontal
    case Vertical
 
    var description: String {
      switch self {
      case .Horizontal: return "Horizontal"
      case .Vertical: return "Vertical"
      }
    }
  }
 
  var chainType: ChainType
 
  init(chainType: ChainType) {
    self.chainType = chainType
  }
 
  func addCookie(cookie: Cookie) {
    cookies.append(cookie)
  }
 
  func firstCookie() -> Cookie {
    return cookies[0]
  }
 
  func lastCookie() -> Cookie {
    return cookies[cookies.count - 1]
  }
 
  var length: Int {
    return cookies.count
  }
 
  var description: String {
    return "type:\(chainType) cookies:\(cookies)"
  }
 
  var hashValue: Int {
    return reduce(cookies, 0) { $0.hashValue ^ $1.hashValue }
  }
}
 
func ==(lhs: Chain, rhs: Chain) -> Bool {
  return lhs.cookies == rhs.cookies
}

A chain has a list of cookie objects and a type: It’s either horizontal (a row of cookies) or vertical (a column). The type is defined as an enum; it is nested inside the Chain class because these two things are tightly coupled. If you feel adventurous, you can also add more complex chain types, such as L- and T-shapes.

There is a reason you’re using an array here to store the cookie objects and not a Set: It’s convenient to remember the order of the cookie objects so that you know which cookies are at the ends of the chain. This makes it easier to combine multiple chains into a single one to detect those L- or T-shapes.

Note: The chain implements Hashable so it can be placed inside a set. The code for hashValue may look strange but it simply performs an exclusive-or on the hash values of all the cookies in the chain. The reduce() function is one of Swift’s more advanced functional programming features.

To start putting these new chain objects to good use, open Level.swift. You’re going to add a method named removeMatches(), but before you get to that, you need a couple of helper methods to do the heavy lifting of finding chains.

To find a chain, you’ll need a pair of for loops that step through each square of the level grid.

Finding chains

While stepping through the cookies in a row horizontally, you want to find the first cookie that starts a chain.

You know a cookie begins a chain if at least the next two cookies on its right are of the same type. Then you skip over all the cookies that have that same type until you find one that breaks the chain. You repeat this until you’ve looked at all the possibilities.

Add this method to Level.swift to scan for horizontal cookie matches:

func detectHorizontalMatches() -> Set<Chain> {
  // 1
  let set = Set<Chain>()
  // 2
  for row in 0..NumRows {
    for var column = 0; column < NumColumns - 2 ; {
      // 3
      if let cookie = cookies[column, row] {
        let matchType = cookie.cookieType
        // 4
        if cookies[column + 1, row]?.cookieType == matchType &&
           cookies[column + 2, row]?.cookieType == matchType {
          // 5
          let chain = Chain(chainType: .Horizontal)
          do {
            chain.addCookie(cookies[column, row]!)
            ++column
          }
          while column < NumColumns && cookies[column, row]?.cookieType == matchType
 
          set.addElement(chain)
          continue
        }
      }
      // 6
      ++column
    }
  }
  return set
}

Here’s how this method works, step by step:

  1. You create a new set to hold the horizontal chains (Chain objects). Later, you’ll remove the cookies in these chains from the playing field.
  2. You loop through the rows and columns. Note that you don’t need to look at the last two columns because these cookies can never begin a new chain. Also notice that the inner for loop does not increment its loop counter; the incrementing happens conditionally inside the loop body.
  3. You skip over any gaps in the level design.
  4. You check whether the next two columns have the same cookie type. Normally you have to be careful not to step outside the bounds of the array when doing something like cookies[column + 2, row], but here that can’t go wrong. That’s why the for loop only goes up to NumColumns - 2. Also note the use of optional chaining with the question mark.
  5. At this point, there is a chain of at least three cookies but potentially there are more. This steps through all the matching cookies until it finds a cookie that breaks the chain or it reaches the end of the grid. Then it adds all the matching cookies to a new Chain object. You increment column for each match.
  6. If the next two cookies don’t match the current one or if there is an empty tile, then there is no chain, so you skip over the cookie.

Note: If there’s a gap in the grid, the use of optional chaining — the question mark after cookies[column, row]? — makes sure the while loop terminates at that point. So the logic above also works on levels with empty squares. Neat!

Next, add this method to scan for vertical cookie matches:

func detectVerticalMatches() -> Set<Chain> {
  let set = Set<Chain>()
 
  for column in 0..NumColumns {
    for var row = 0; row < NumRows - 2; {
      if let cookie = cookies[column, row] {
        let matchType = cookie.cookieType
 
        if cookies[column, row + 1]?.cookieType == matchType &&
           cookies[column, row + 2]?.cookieType == matchType {
 
          let chain = Chain(chainType: .Vertical)
          do {
            chain.addCookie(cookies[column, row]!)
            ++row
          }
          while row < NumRows && cookies[column, row]?.cookieType == matchType
 
          set.addElement(chain)
          continue
        }
      }
      ++row
    }
  }
  return set
}

The vertical version has the same kind of logic, but loops by column in the outer for loop and by row in the inner loop.

You may wonder why you don’t immediately remove the cookies from the level as soon as you detect that they’re part of a chain. The reason is that a cookie may be part of two chains at the same time: one horizontal and one vertical. So you don’t want to remove it until you’ve checked both the horizontal and vertical options.

Now that the two match detectors are ready, add the implementation for removeMatches():

func removeMatches() -> Set<Chain> {
  let horizontalChains = detectHorizontalMatches()
  let verticalChains = detectVerticalMatches()
 
  println("Horizontal matches: \(horizontalChains)")
  println("Vertical matches: \(verticalChains)")
 
  return horizontalChains.unionSet(verticalChains)
}

This method calls the two helper methods and then combines their results into a single set. Later, you’ll add more logic to this method but for now you’re only interested in finding the matches and returning the set.

You still need to call removeMatches() from somewhere and that somewhere is GameViewController.swift. Add this helper method:

func handleMatches() {
  let chains = level.removeMatches()
  // TODO: do something with the chains set
}

Later, you’ll fill out this method with code to remove cookie chains and drop other cookies into the empty tiles.

In handleSwipe(), change the call to scene.animateSwap() to this:

scene.animateSwap(swap, completion: handleMatches)

Recall that in Swift a closure and a function are really the same thing, so instead of passing a closure block to animateSwap(), you can also give it the name of a function.

Build and run, and swap two cookies to make a chain. You should now see something like this in Xcode’s debug pane:

List of matches

Removing the Chains

Level’s method is called “removeMatches”, but so far it only detects the matching chains. Now you’re going to remove those cookies from the game with a nice animation.

First, you need to update the data model—that is, remove the Cookie objects from the array for the 2-D grid. When that’s done, you can tell GameScene to animate the sprites for these cookies out of existence.

eatcookies

Removing the cookies from the model is simple enough. Add the following method to Level.swift:

func removeCookies(chains: Set<Chain>) {
  for chain in chains {
    for cookie in chain.cookies {
      cookies[cookie.column, cookie.row] = nil
    }
  }
}

Each chain has a list of cookie objects and each cookie knows its column and row in the grid, so you simply set that element in the array to nil to remove the cookie object from the data model.

Note: At this point, the Chain object is the only owner of the Cookie object. When the chain gets deallocated, so will these cookie objects.

In removeMatches(), replace the println() statements with the following:

removeCookies(horizontalChains)
removeCookies(verticalChains)

That takes care of the data model. Now switch to GameScene.swift and add the following method:

func animateMatchedCookies(chains: Set<Chain>, completion: () -> ()) {
  for chain in chains {
    for cookie in chain.cookies {
      if let sprite = cookie.sprite {
        if sprite.actionForKey("removing") == nil {        
          let scaleAction = SKAction.scaleTo(0.1, duration: 0.3)
          scaleAction.timingMode = .EaseOut
          sprite.runAction(SKAction.sequence([scaleAction, SKAction.removeFromParent()]),
                           withKey:"removing")
        }
      }
    }
  }
  runAction(matchSound)
  runAction(SKAction.waitForDuration(0.3), completion: completion)
}

This loops through all the chains and all the cookies in each chain, and then triggers the animations.

Because the same Cookie could be part of two chains (one horizontal and one vertical), you need to make sure to add only one animation to the sprite, not two. That’s why the action is added to the sprite under the key “removing”. If such an action already exists, you shouldn’t add a new animation to the sprite.

When the shrinking animation is done, the sprite is removed from the cookie layer. The waitForDuration() action at the end of the method ensures that the rest of the game will only continue after the animations finish.

Open GameViewController.swift and change handleMatches() to call this new animation:

func handleMatches() {
  let chains = level.removeMatches()
 
  scene.animateMatchedCookies(chains) {
    self.view.userInteractionEnabled = true
  }
}

Try it out. Build and run, and make some matches.

Match animation

Note: You don’t want the player to be able to tap or swipe on anything while the chain removal animations are happening. That’s why you disable userInteractionEnabled as the first thing in the swipe handler and enable it again once all the animations are done.

Dropping Cookies Into Empty Tiles

Removing the cookie chains leaves holes in the grid. Other cookies should now fall down to fill up those holes. Again, you’ll tackle this in two steps:

  1. Update the model.
  2. Animate the sprites.

Add this new method to Level.swift:

func fillHoles() -> Array<Array<Cookie>> {
  var columns = Array<Array<Cookie>>()
  // 1
  for column in 0..NumColumns {
    var array = Array<Cookie>()
    for row in 0..NumRows {
      // 2
      if tiles[column, row] != nil && cookies[column, row] == nil {
        // 3
        for lookup in (row + 1)..NumRows {
          if let cookie = cookies[column, lookup] {
            // 4
            cookies[column, lookup] = nil
            cookies[column, row] = cookie
            cookie.row = row
            // 5
            array.append(cookie)
            // 6
            break
          }
        }
      }
    }
    // 7
    if !array.isEmpty {
      columns.append(array)
    }
  }
  return columns
}

This method detects where there are empty tiles and shifts any cookies down to fill up those tiles. It starts at the bottom and scans upward. If it finds a square that should have a cookie but doesn’t, then it finds the nearest cookie above it and moves this cookie to the empty tile.

Filling holes

Here is how it all works, step by step:

  1. You loop through the rows, from bottom to top.
  2. If there’s a tile at a position but no cookie, then there’s a hole. Remember that the tiles array describes the shape of the level.
  3. You scan upward to find the cookie that sits directly above the hole. Note that the hole may be bigger than one square (for example, if this was a vertical chain) and that there may be holes in the grid shape, as well.
  4. If you find another cookie, move that cookie to the hole. This effectively moves the cookie down.
  5. You add the cookie to the array. Each column gets its own array and cookies that are lower on the screen are first in the array. It’s important to keep this order intact, so the animation code can apply the correct delay. The farther up the piece is, the bigger the delay before the animation starts.
  6. Once you’ve found a cookie, you don’t need to scan up any farther so you break out of the inner loop.
  7. If a column does not have any holes, then there’s no point in adding it to the final array.

At the end, the method returns an array containing all the cookies that have been moved down, organized by column.

Note: The return type of fillHoles() is Array<Array<Cookie>>, or an array-of-array-of-cookies. You can also write this as Cookie[][].

You’ve already updated the data model for these cookies with the new positions, but the sprites need to catch up. GameScene will animate the sprites and GameViewController is the in-between object to coordinate between the the model (Level ) and the view (GameScene).

Switch to GameScene.swift and add a new animation method:

func animateFallingCookies(columns: Array<Array<Cookie>>, completion: () -> ()) {
  // 1
  var longestDuration: NSTimeInterval = 0
  for array in columns {
    for (idx, cookie) in enumerate(array) {
      let newPosition = pointForColumn(cookie.column, row: cookie.row)
      // 2
      let delay = 0.05 + 0.15*NSTimeInterval(idx)
      // 3
      let sprite = cookie.sprite!
      let duration = NSTimeInterval(((sprite.position.y - newPosition.y) / TileHeight) * 0.1)
      // 4
      longestDuration = max(longestDuration, duration + delay)
      // 5
      let moveAction = SKAction.moveTo(newPosition, duration: duration)
      moveAction.timingMode = .EaseOut
      sprite.runAction(
        SKAction.sequence([
          SKAction.waitForDuration(delay),
          SKAction.group([moveAction, fallingCookieSound])]))
      }
  }
  // 6
  runAction(SKAction.waitForDuration(longestDuration), completion: completion)
}

Here’s how this works:

  1. As with the other animation methods, you should only call the completion block after all the animations are finished. Because the number of falling cookies may vary, you can’t hardcode this total duration but instead have to compute it.
  2. The higher up the cookie is, the bigger the delay on the animation. That looks more dynamic than dropping all the cookies at the same time. This calculation works because fillHoles() guarantees that lower cookies are first in the array.
  3. Likewise, the duration of the animation is based on how far the cookie has to fall (0.1 seconds per tile). You can tweak these numbers to change the feel of the animation.
  4. You calculate which animation is the longest. This is the time the game has to wait before it may continue.
  5. You perform the animation, which consists of a delay, a movement and a sound effect.
  6. You wait until all the cookies have fallen down before allowing the gameplay to continue.

Now you can tie it all together. Open GameViewController.swift. Replace the contents of handleMatches() with the following:

func handleMatches() {
  let chains = level.removeMatches()
  scene.animateMatchedCookies(chains) {
    let columns = self.level.fillHoles()
    self.scene.animateFallingCookies(columns) {
      self.view.userInteractionEnabled = true
    }
  }
}

This now calls fillHoles() to update the model, which returns the array that describes the fallen cookies and then passes that array onto the scene so it can animate the sprites to their new positions.

Note: To access a property or call a method in Objective-C you always had to use self. In Swift you don’t have to do this, except inside closures. That’s why inside handleMatches() you see self a lot. Swift insists on this to make it clear that the closure actually captures the value of self with a strong reference. In fact, if you don’t specify self inside a closure, the Swift compiler will give an error message.

Try it out!

Falling animation

It’s raining cookies! Notice that the cookies even fall properly across gaps in the level design.

Adding New Cookies

There’s one more thing to do to complete the game loop. Falling cookies leave their own holes at the top of each column.

Holes at top

You need to top up these columns with new cookies. Add a new method to Level.swift:

func topUpCookies() -> Array<Array<Cookie>> {
  var columns = Array<Array<Cookie>>()
  var cookieType: CookieType = .Unknown
 
  for column in 0..NumColumns {
    var array = Array<Cookie>()
    // 1
    for var row = NumRows - 1; row >= 0 && cookies[column, row] == nil; --row {
      // 2
      if tiles[column, row] != nil {
        // 3
        var newCookieType: CookieType
        do {
          newCookieType = CookieType.random()
        } while newCookieType == cookieType
        cookieType = newCookieType
        // 4
        let cookie = Cookie(column: column, row: row, cookieType: cookieType)
        cookies[column, row] = cookie
        array.append(cookie)
      }
    }
    // 5
    if !array.isEmpty {
      columns.append(array)
    }
  }
  return columns
}

Where necessary, this adds new cookies to fill the columns to the top. It returns an array with the new Cookie objects for each column that had empty tiles.

If a column has X empty tiles, then it also needs X new cookies. The holes are all at the top of the column now, so you can simply scan from the top down until you find a cookie.

Here’s how it works, step by step:

  1. You loop through the column from top to bottom. This for loop ends when cookies[column, row] is not nil—that is, when it has found a cookie.
  2. You ignore gaps in the level, because you only need to fill up grid squares that have a tile.
  3. You randomly create a new cookie type. It can’t be equal to the type of the last new cookie, to prevent too many “freebie” matches.
  4. You create the new Cookie object and add it to the array for this column.
  5. As before, if a column does not have any holes, you don’t add it to the final array.

The array that topUpCookies() returns contains a sub-array for each column that had holes. The cookie objects in these arrays are ordered from top to bottom. This is important to know for the animation method coming next.

Switch to GameScene.swift and the new animation method:

func animateNewCookies(columns: Array<Array<Cookie>>, completion: () -> ()) {
  // 1
  var longestDuration: NSTimeInterval = 0
 
  for array in columns {
    // 2
    let startRow = array[0].row + 1
 
    for (idx, cookie) in enumerate(array) {
      // 3
      let sprite = SKSpriteNode(imageNamed: cookie.cookieType.spriteName)
      sprite.position = pointForColumn(cookie.column, row: startRow)
      cookiesLayer.addChild(sprite)
      cookie.sprite = sprite
      // 4
      let delay = 0.1 + 0.2 * NSTimeInterval(array.count - idx - 1)
      // 5
      let duration = NSTimeInterval(startRow - cookie.row) * 0.1
      longestDuration = max(longestDuration, duration + delay)
      // 6
      let newPosition = pointForColumn(cookie.column, row: cookie.row)
      let moveAction = SKAction.moveTo(newPosition, duration: duration)
      moveAction.timingMode = .EaseOut
      sprite.alpha = 0
      sprite.runAction(
        SKAction.sequence([
          SKAction.waitForDuration(delay),
          SKAction.group([
            SKAction.fadeInWithDuration(0.05),
            moveAction,
            addCookieSound])
          ]))
    }
  }
  // 7
  runAction(SKAction.waitForDuration(longestDuration), completion: completion)
}

This is very similar to the “falling cookies” animation. The main difference is that the cookie objects are now in reverse order in the array, from top to bottom. Step by step, this is what the method does:

  1. The game is not allowed to continue until all the animations are complete, so you calculate the duration of the longest animation to use later in step 7.
  2. The new cookie sprite should start out just above the first tile in this column. An easy way to find the row number of this tile is to look at the row of the first cookie in the array, which is always the top-most one for this column.
  3. You create a new sprite for the cookie.
  4. The higher the cookie, the longer you make the delay, so the cookies appear to fall after one another.
  5. You calculate the animation’s duration based on far the cookie has to fall.
  6. You animate the sprite falling down and fading in. This makes the cookies appear less abruptly out of thin air at the top of the grid.
  7. You wait until the animations are done before continuing the game.

Finally, in GameViewController.swift, replace the chain of completion blocks in handleMatches() with the following:

func handleMatches() {
  let chains = level.removeMatches()
  scene.animateMatchedCookies(chains) {
    let columns = self.level.fillHoles()
    self.scene.animateFallingCookies(columns) {
      let columns = self.level.topUpCookies()
      self.scene.animateNewCookies(columns) {
        self.view.userInteractionEnabled = true
      }
    }
  }
}

Try it out!

Adding new cookies

A Cascade of Cookies

You may have noticed a couple of oddities after playing for a while. When the cookies fall down to fill up the holes and new cookies drop from the top, these actions sometimes create new chains of three or more. But what happens then?

You also need to remove these matching chains and ensure other cookies take their place. This cycle should continue until there are no matches left on the board. Only then should the game give control back to the player.

Handling these possible cascades may sound like a tricky problem, but you’ve already written all the code to do it! You just have to call handleMatches() again and again and again until there are no more chains.

In GameViewController.swift, inside handleMatches(), change the line that sets userInteractionEnabled to:

self.handleMatches()

Yep, you’re seeing that right: handleMatches() calls itself. This is called recursion and it’s a powerful programming technique. There’s only one thing you need to watch out for with recursion: At some point, you need to stop it, or the app will go into an infinite loop and eventually crash.

For that reason, add the following to the top of handleMatches(), right after the line that calls removeMatches() on the level:

if chains.count == 0 {
  beginNextTurn()
  return
}

If there are no more matches, the player gets to move again and the function exits to prevent another recursive call.

Finally, add this new beginNextTurn() method:

func beginNextTurn() {
  view.userInteractionEnabled = true
}

Try it out. If removing a chain creates another chain elsewhere, the game should now remove that chain, as well:

Cascade

There’s another problem. After a while, the game no longer seems to recognize swaps that it should consider valid. There’s a good reason for that. Can you guess what it is?

Solution Inside: Solution SelectShow
After the player makes a move, the list of possible swaps is out of date. You need to recalculate this list before letting the player move again.

The logic for this sits in Level.swift, in detectPossibleSwaps(). You need to call this method from beginNextTurn() in GameViewController.swift:

func beginNextTurn() {
  level.detectPossibleSwaps()
  view.userInteractionEnabled = true
}

Excellent! Now your game loop is complete. It has an infinite supply of cookies!

Scoring Points

In Cookie Crunch Adventure, the player’s objective is to score a certain number of points within a maximum number of swaps. Both of these values come from the JSON level file. The game should show these numbers on the screen so the player knows how well she’s doing.

First, add the following properties to GameViewController.swift:

var movesLeft: Int = 0
var score: Int = 0
 
@IBOutlet var targetLabel: UILabel
@IBOutlet var movesLabel: UILabel
@IBOutlet var scoreLabel: UILabel

The movesLeft and score variables keep track of how well the player is doing (model data), while the outlets show this on the screen (views).

Open Main.storyboard to add these labels to the view. Design the view controller to look like this:

View controller with labels

(This is an Xcode 5 screenshot but it looks similar in Xcode 6.)

Make sure to uncheck Use Auto Layout in the File inspector, the first tab on the right. In the dialog that appears, choose Disable Size Classes. This will turn the square scene into an iPhone-sized one, as in the image above.

To make the labels easier to see, give the main view a gray background color. Make the font for the labels Gill Sans Bold, size 20.0 for the number labels and 14.0 for the text labels. You may also wish to set a slight drop shadow for the labels so they are easier to see.

It looks best if you set center alignment on the number labels. Connect the three number labels to their respective outlets.

Because the target score and the maximum number of moves are stored in the JSON level file, you should load them into Level. Add the following properties to Level.swift:

let targetScore: Int!
let maximumMoves: Int!

These properties will store the values from the JSON data. They are marked with ! because it is possible that they get no value (if loading the level fails for some reason).

In Level.swift, add these two lines to the bottom of init(filename:):

init(filename: String) {
    ...
    if let tilesArray: AnyObject = dictionary["tiles"] {
      ...
      // Add these two lines:
      targetScore = (dictionary["targetScore"] as NSNumber).integerValue
      maximumMoves = (dictionary["moves"] as NSNumber).integerValue
    }
  }
}

By this point, you’ve parsed the JSON into a dictionary, so you grab the two values and store them.

Back in GameViewController.swift, add the following method:

func updateLabels() {
  targetLabel.text = NSString(format: "%ld", level.targetScore)
  movesLabel.text = NSString(format: "%ld", movesLeft)
  scoreLabel.text = NSString(format: "%ld", score)
}

You’ll call this method after every turn to update the text inside the labels.

Add the following lines to the top of beginGame(), before the call to shuffle():

movesLeft = level.maximumMoves
score = 0
updateLabels()

This resets everything to the starting values. Build and run, and your display should look like this:

Game with labels

Calculating the Score

The scoring rules are simple:

  • A 3-cookie chain is worth 60 points.
  • Each additional cookie in the chain increases the chain’s value by 60 points.

Thus, a 4-cookie chain is worth 120 points, a 5-cookie chain is worth 180 points and so on.

It’s easiest to store the score inside the Chain object, so each chain knows how many points it’s worth.

Add the following to Chain.swift:

var score: Int = 0

The score is model data, so it needs to be calculated by Level. Add the following method to Level.swift:

func calculateScores(chains: Set<Chain>) {
  // 3-chain is 60 pts, 4-chain is 120, 5-chain is 180, and so on
  for chain in chains {
    chain.score = 60 * (chain.length - 2)
  }
}

Now call this method from removeMatches(), just before the return statement:

calculateScores(horizontalChains)
calculateScores(verticalChains)

You need to call it twice because there are two sets of chain objects.

Now that the level object knows how to calculate the scores and stores them inside the Chain objects, you can update the player’s score and display it onscreen.

This happens in GameViewController.swift. Inside handleMatches(), just before the call to self.level.fillHoles(), add the following lines:

for chain in chains {
  self.score += chain.score
}
self.updateLabels()

This simply loops through the chains, adds their scores to the player’s total and then updates the labels.

Try it out. Swap a few cookies and observe your increasing score:

Score

Animating Point Values

It would be fun to show the point value of each chain with a cool little animation. In GameScene.swift, add a new method:

func animateScoreForChain(chain: Chain) {
  // Figure out what the midpoint of the chain is.
  let firstSprite = chain.firstCookie().sprite!
  let lastSprite = chain.lastCookie().sprite!
  let centerPosition = CGPoint(
    x: (firstSprite.position.x + lastSprite.position.x)/2,
    y: (firstSprite.position.y + lastSprite.position.y)/2 - 8)
 
  // Add a label for the score that slowly floats up.
  let scoreLabel = SKLabelNode(fontNamed: "GillSans-BoldItalic")
  scoreLabel.fontSize = 16
  scoreLabel.text = NSString(format: "%ld", chain.score)
  scoreLabel.position = centerPosition
  scoreLabel.zPosition = 300
  cookiesLayer.addChild(scoreLabel)
 
  let moveAction = SKAction.moveBy(CGVector(dx: 0, dy: 3), duration: 0.7)
  moveAction.timingMode = .EaseOut
  scoreLabel.runAction(SKAction.sequence([moveAction, SKAction.removeFromParent()]))
}

This creates a new SKLabelNode with the score and places it in the center of the chain. The numbers will float up a few pixels before disappearing.

Call this new method from animateMatchedCookies(), in between the two for loops:

for chain in chains {
 
  // Add this line:
  animateScoreForChain(chain)
 
  for cookie in chain.cookies {

When using SKLabelNode, Sprite Kit needs to load the font and convert it to a texture. That only happens once, but it does create a small delay, so it’s smart to pre-load this font before the game starts in earnest.

At the bottom of GameScene‘s init(), add the following line:

SKLabelNode(fontNamed: "GillSans-BoldItalic")

Now try it out. Build and run, and score some points!

Floating score

Combos!

What makes games like Candy Crush Saga fun is the ability to make combos, or more than one match in a row.

Of course, you should reward the player for making a combo by giving her extra points. To that effect, you’ll add a combo “multiplier”, where the first chain is worth its normal score, but the second chain is worth twice its score, the third chain is worth three times its score, and so on.

In Level.swift, add the following private property:

var comboMultiplier: Int = 0  // private

Update calculateScores() to:

func calculateScores(chains: Set<Chain>) {
  // 3-chain is 60 pts, 4-chain is 120, 5-chain is 180, and so on
  for chain in chains {
    chain.score = 60 * (chain.length - 2) * comboMultiplier
    ++comboMultiplier
  }
}

The method now multiplies the chain’s score by the combo multiplier and then increments the multiplier so it’s one higher for the next chain.

You also need a method to reset this multiplier on the next turn. Add the following method to Level.swift:

func resetComboMultiplier() {
  comboMultiplier = 1
}

Open GameViewController.swift and find beginGame(). Add this line just before the call to shuffle():

level.resetComboMultiplier()

Add the same line at the top of beginNextTurn().

And now you have combos. Try it out!

Combo

Challenge: How would you detect an L-shaped chain and make it count double the value for a row?

Solution Inside: Solution SelectShow
An L-shape consists of two chains, one horizontal and one vertical, that share a corner cookie. You can loop through the set of horizontal chains and check if the chain’s first or last cookie is also present in any of the vertical chains. If so, remove those two chains and combine them into a new one, with a new ChainType.

Winning and Losing

The player only has so many moves to reach the target score. If she doesn’t, it’s game over. The logic for this isn’t difficult to add.

Create a new method in GameViewController.swift:

func decrementMoves() {
  --movesLeft
  updateLabels()
}

This simply decrements the counter keeping track of the number of moves and updates the onscreen labels.

Call it from the bottom of beginNextTurn():

decrementMoves()

Build and run to see it in action. After each swap, the game clears the matches and decreases the number of remaining moves by one.

Moves

Of course, you still need to detect when the player runs out of moves (game over!) or when she reaches the target score (success and eternal fame!), and respond accordingly.

First, though, the storyboard needs some work.

The Look of Victory or Defeat

Open Main.storyboard and drag an image view into the view. Make it 320×150 points and center it vertically.

Image view in storyboard

This image view will show either the “Game Over!” or “Level Complete!” message.

Go to the Size inspector and make the Autosizing mask for the image view look like this:

Autosizing mask image view

This will keep the image centered regardless of the screen size.

Now connect this image view to a new outlet on GameViewController.swift named gameOverPanel.

@IBOutlet var gameOverPanel: UIImageView

Also, add a property for a gesture recognizer:

var tapGestureRecognizer: UITapGestureRecognizer!

In viewDidLoad(), before you present the scene, make sure to hide this image view:

gameOverPanel.hidden = true

Now add a new method to show the game over panel:

func showGameOver() {
  gameOverPanel.hidden = false
  scene.userInteractionEnabled = false
 
  tapGestureRecognizer = UITapGestureRecognizer(target: self, action: "hideGameOver")
  view.addGestureRecognizer(tapGestureRecognizer)
}

This un-hides the image view, disables touches on the scene to prevent the player from swiping and adds a tap gesture recognizer that will restart the game.

Add one more method:

func hideGameOver() {
  view.removeGestureRecognizer(tapGestureRecognizer)
  tapGestureRecognizer = nil
 
  gameOverPanel.hidden = true
  scene.userInteractionEnabled = true
 
  beginGame()
}

This hides the game over panel again and restarts the game.

The logic that detects whether it’s time to show the game over panel goes into decrementMoves(). Add the following lines to the bottom of that method:

if score >= level.targetScore {
  gameOverPanel.image = UIImage(named: "LevelComplete")
  showGameOver()
} else if movesLeft == 0 {
  gameOverPanel.image = UIImage(named: "GameOver")
  showGameOver()
}

If the current score is greater than or equal to the target score, the player has won the game! If the number of moves remaining is 0, the player has lost the game.

In either case, the method loads the proper image into the image view and calls showGameOver() to put it on the screen.

Try it out. When you beat the game, you should see this:

Level complete

Likewise, when you run out of moves, you should see a “Game Over” message.

Animating the Transitions

It looks a bit messy with this banner on top of all those cookies, so let’s add a little animation here as well. Add these two methods to GameScene.swift:

func animateGameOver(completion: () -> ()) {
  let action = SKAction.moveBy(CGVector(dx: 0, dy: -size.height), duration: 0.3)
  action.timingMode = .EaseIn
  gameLayer.runAction(action, completion: completion)
}
 
func animateBeginGame(completion: () -> ()) {
  gameLayer.hidden = false
  gameLayer.position = CGPoint(x: 0, y: size.height)
  let action = SKAction.moveBy(CGVector(dx: 0, dy: -size.height), duration: 0.3)
  action.timingMode = .EaseOut
  gameLayer.runAction(action, completion: completion)
}

animateGameOver() animates the entire gameLayer out of the way. animateBeginGame() does the opposite and slides the gameLayer back in from the top of the screen.

The very first time the game starts, you also want to call animateBeginGame() to perform this same animation. It looks better if the game layer is hidden before that animation begins, so add the following line to GameScene.swift in init(size:), immediately after you create the gameLayer node:

gameLayer.hidden = true

Now open GameViewController.swift and call animateGameOver() in showGameOver():

func showGameOver() {
  gameOverPanel.hidden = false
  scene.userInteractionEnabled = false
 
  scene.animateGameOver() {
    self.tapGestureRecognizer = UITapGestureRecognizer(target: self, action: "hideGameOver")
    self.view.addGestureRecognizer(self.tapGestureRecognizer)
  }
}

Note that the tap gesture recognizer is now added after the animation is complete. This prevents the player from tapping while the game is still performing the animation.

Finally, in GameViewController.swift’s beginGame(), just before the call to shuffle(), call animateBeginGame():

scene.animateBeginGame() { }

The completion block for this animation is currently empty, but you’ll put something there soon.

Now when you tap after game over, the cookies should drop down the screen to their starting positions. Sweet!

Too many cookies

Whoops! Something’s not right. It appears you didn’t properly remove the old cookie sprites.

Add this new method to GameScene.swift to perform the cleanup:

func removeAllCookieSprites() {
  cookiesLayer.removeAllChildren()
}

And call it as the very first thing from shuffle() inside GameViewController.swift:

scene.removeAllCookieSprites()

That solves that! Build and run and your game should reset cleanly.

Manual Shuffling

There’s one more situation to manage: It may happen—though only rarely—that there is no way to swap any of the cookies to make a chain. In that case, the player is stuck.

There are different ways to handle this. For example, Candy Crush Saga automatically reshuffles the cookies. But in Cookie Crunch, you’ll give that power to the player. You will allow her to shuffle at any time by tapping a button, but it will cost her a move.

shufflecomic

Add an outlet property in GameViewController.swift:

@IBOutlet var shuffleButton: UIButton

And add an action method:

@IBAction func shuffleButtonPressed(AnyObject) {
  shuffle()
  decrementMoves()
}

Tapping the shuffle button costs a move, so this also calls decrementMoves().

In showGameOver(), add the following line to hide the shuffle button:

shuffleButton.hidden = true

Also do the same thing in init(size:), so the button is hidden when the game first starts.

In beginGame(), in the animation’s completion block, put the button back on the screen again:

scene.animateBeginGame() {
  self.shuffleButton.hidden = false
}

Now open Main.storyboard and add a button to the bottom of the screen:

Shuffle button storyboard

Set the title to “Shuffle” and make the button 100×36 points big. To style the button, give it the font Gill Sans Bold, 20 pt. Make the text color white with a 50% opaque black drop shadow. For the background image, choose “Button”, an image you added to the asset catalog in Part One.

Set the autosizing to make this button stick to the bottom of the screen so it will also work on 3.5-inch phones:

Autosizing shuffle button

Finally, connect the shuffleButton outlet to the button and its Touch Up Inside event to the shuffleButtonPressed: action.

Try it out!

Shuffle button in the game

Note: When shuffling a deck of cards, you take the existing cards, change their order and deal out the same cards again in a different order. In this game, however, you simply get all new—random!—cookies. Finding a distribution of the same set of cookies that allows for at least one swap is an extremely difficult computational problem, and after all, this is only a casual game.

The shuffle is a bit abrupt, so let’s make the new cookies appear with a cute animation. In GameScene.swift, go to addSpritesForCookies() and add the following lines inside the for loop, after the existing code:

// Give each cookie sprite a small, random delay. Then fade them in.
sprite.alpha = 0
sprite.xScale = 0.5
sprite.yScale = 0.5
 
sprite.runAction(
  SKAction.sequence([
    SKAction.waitForDuration(0.25, withRange: 0.5),
    SKAction.group([
      SKAction.fadeInWithDuration(0.25),
      SKAction.scaleTo(1.0, duration: 0.25)
      ])
    ]))

This gives each cookie sprite a small, random delay and then fades them into view. It looks like this:

Shuffle animation

Bring on the Muzak

Let’s give the player some smooth, relaxing music to listen to while she crunches cookies. Add this line to the top of GameViewController.swift to include the AVFoundation framework:

import AVFoundation

Also add the following property:

var backgroundMusic: AVAudioPlayer!

Add these lines to viewDidLoad(), just before the call to beginGame():

// Load and start background music.
let url = NSBundle.mainBundle().URLForResource("Mining by Moonlight", withExtension: "mp3")
backgroundMusic = AVAudioPlayer(contentsOfURL: url, error: nil)
backgroundMusic.numberOfLoops = -1
backgroundMusic.play()

This loads the background music MP3 and sets it to loop forever. That gives the game a whole lot more swing!

Drawing Better Tiles

If you compare your game closely to Candy Crush Saga, you’ll notice that the tiles are drawn slightly differently. The borders in Candy Crush look much nicer:

Border comparison

Also, if a cookie drops across a gap, your game draws it on top of the background, but candies in Candy Crush appear to fall behind the background:

Masked sprite comparison

Recreating this effect isn’t too difficult but it requires a number of new sprites. You can find these in the tutorial’s Resources in the Grid.atlas folder. Drag this folder into your Xcode project. This creates a second texture atlas with just these images.

In GameScene.swift, add two new properties:

let cropLayer = SKCropNode()
let maskLayer = SKNode()

In init(size:), add these lines below the code that creates the tilesLayer:

gameLayer.addChild(cropLayer)
 
maskLayer.position = layerPosition
cropLayer.maskNode = maskLayer

This makes two new layers: cropLayer, which is a special kind of node called an SKCropNode, and a mask layer. A crop node only draws its children where the mask contains pixels. This lets you draw the cookies only where there is a tile, but never on the background.

Replace this line:

gameLayer.addChild(cookiesLayer)

With this:

cropLayer.addChild(cookiesLayer)

Now, instead of adding the cookiesLayer directly to the gameLayer, you add it to this new cropLayer.

To fill in the mask of this crop layer, make two changes to addTiles():

  • Replace "Tile" with "MaskTile"
  • Replace tilesLayer with maskLayer

Wherever there’s a tile, the method now draws the special MaskTile sprite into the layer functioning as the SKCropNode’s mask. The MaskTile is slightly larger than the regular tile.

Build and run. Notice how the cookies get cropped when they fall through a gap:

Cookie is cropped

<div class="note"]

Tip: If you want to see what the mask layer looks like, add this line to init(size:)

cropLayer.addChild(maskLayer)

Don’t forget to remove it again when you’re done!

For the final step, add the following code to the bottom of addTiles():

for row in 0...NumRows {
  for column in 0...NumColumns {
    let topLeft     = (column > 0) && (row < NumRows) 
                                   && level.tileAtColumn(column - 1, row: row)
    let bottomLeft  = (column > 0) && (row > 0)      
                                   && level.tileAtColumn(column - 1, row: row - 1)
    let topRight    = (column < NumColumns) && (row < NumRows)
                                            && level.tileAtColumn(column, row: row)
    let bottomRight = (column < NumColumns) && (row > 0) 
                                            && level.tileAtColumn(column, row: row - 1)
 
    // The tiles are named from 0 to 15, according to the bitmask that is
    // made by combining these four values.
    let value = Int(topLeft) | Int(topRight) << 1 | Int(bottomLeft) << 2 | Int(bottomRight) << 3
 
    // Values 0 (no tiles), 6 and 9 (two opposite tiles) are not drawn.
    if value != 0 && value != 6 && value != 9 {
      let name = String(format: "Tile_%ld", value)
      let tileNode = SKSpriteNode(imageNamed: name)
      var point = pointForColumn(column, row: row)
      point.x -= TileWidth/2
      point.y -= TileHeight/2
      tileNode.position = point
      tilesLayer.addChild(tileNode)
    }
  }
}

This draws a pattern of border pieces in between the level tiles. As a challenge, try to decipher for yourself how this method works. :]

Solution Inside: Solution SelectShow
Imagine dividing each tile into four quadrants. The four boolean variables indicate what kind of borders the tile has. For example, in a square level, the tile in the lower-right corner would need a background to cover the top-left only (see Tile_1.png). A tile with all neighboring tiles would get a full background (see Tile_15.png).

Build and run, and you should now have a game that looks and acts just like Candy Crush Saga!

Final game

Where to Go From Here?

Congrats for making it to the end! This has been a long but Swift tutorial, and you are coming away with all the basic building blocks for making your own match-3 games.

You can download the final Xcode project here.

Here are ideas for other features you could add:

  • Special cookies when the player matches a certain shape. For example, Candy Crush Saga gives you a cookie that can clear an entire row when you match a 4-in-a-row chain.
  • Detection of special chains, such as L- or T-shapes, that reward the player with bonus points or special power-ups.
  • Boosts, or power-ups the player can use any time she wants. For example, one boost might remove all the cookies of one type from the screen at once.
  • Jelly levels: On these levels, some tiles are covered in jelly. You have X moves to remove all the jelly. This is where the Tile class comes in handy. You can give it a BOOL jelly property and if the player matches a cookie on this tile, set the jelly property to NO to remove the jelly.
  • Hints: If the player doesn’t make a move for two seconds, light up a pair of cookies that make a valid swap.
  • Automatically move the player to the next level if she completes the current one.
  • Shuffle the cookies automatically if there are no possible moves.

As you can see, there’s still plenty to play with. Have fun!

Credits: Artwork by Vicki Wenderlich. The music is by Kevin MacLeod. The sound effects are based on samples from freesound.org.

Some of the techniques used in this source code are based on a blog post by Emanuele Feronato.

How to Make a Game Like Candy Crush with Swift Tutorial: Part 2 is a post from: Ray Wenderlich

The post How to Make a Game Like Candy Crush with Swift Tutorial: Part 2 appeared first on Ray Wenderlich.

7
Like
Save

Comments

Write a comment

*