Quantcast
Channel: Kodeco | High quality programming tutorials: iOS, Android, Swift, Kotlin, Unity, and more
Viewing all articles
Browse latest Browse all 4370

How to Make a Game Like Candy Crush with SpriteKit and Swift: Part 3

$
0
0

Update note: This SpriteKit tutorial has been updated for Xcode 9.3 and Swift 4.1 by Kevin Colligan. The original tutorial was written by Matthijs Hollemans and subsequently updated by Morten Faarkrog.

How to Make a Game Like Candy Crush with SpriteKit and Swift: Part 3

Welcome back to our “How to Make a Game Like Candy Crush” tutorial with SpriteKit and Swift series.

  • In the first part, you built the game foundation.
  • In the second part, you worked on swipes and swapping cookies.
  • (You’re here) In the third part, you’ll find and remove chains, refill the grid and keep score.

This tutorial picks up where you left off in the last part. Use the Download Materials button at the top or bottom of this tutorial to download the starter project if you need it.

Getting Started

You’ve done everything needed to allow the player to swap cookies. 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 points.

This is the sequence of events:

Finding the Chains

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. But a single swipe could create multiple chains. To find them all, you’ll 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:

class Chain: Hashable, CustomStringConvertible {
  var cookies: [Cookie] = []

  enum ChainType: CustomStringConvertible {
    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 add(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 cookies.reduce (0) { $0.hashValue ^ $1.hashValue }
  }

  static 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 Chain because these two things are tightly coupled. You can also add more complex chain types, such as L- and T-shapes.

You’re using an array to store cookies and not a set because you need to know their order. This makes it easier to combine multiple chains into a single one — or to detect L- or T-shapes.

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.

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 cookies of the same type until you find one that breaks the chain. You repeat this until you’ve looked at all possibilities.

Add this method to scan for horizontal chains:

private func detectHorizontalMatches() -> Set<Chain> {
  // 1
  var set: Set<Chain> = []
  // 2
  for row in 0..<numRows {
    var column = 0
    while 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)
          repeat {
            chain.add(cookie: cookies[column, row]!)
            column += 1
          } while column < numColumns && cookies[column, row]?.cookieType == matchType

          set.insert(chain)
          continue
        }
      }
      // 6
      column += 1
    }
  }
  return set
}

Here’s how this method works:

  1. You create a new set to hold the horizontal chains. Later, you’ll remove the cookies in these chains from the playing field.
  2. You loop through the rows and columns. You don’t need to look at the last two columns because these cookies can never begin a new chain.
  3. You skip over any gaps in the level design.
  4. You check whether the next two columns have the same cookie type.
  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 the end of the grid. Then, it adds all the matching cookies to a new Chain.
  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.

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

private func detectVerticalMatches() -> Set<Chain> {
  var set: Set<Chain> = []

  for column in 0..<numColumns {
    var row = 0
    while 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)
          repeat {
            chain.add(cookie: cookies[column, row]!)
            row += 1
          } while row < numRows && cookies[column, row]?.cookieType == matchType

          set.insert(chain)
          continue
        }
      }
      row += 1
    }
  }
  return set
}

The vertical version has the same kind of logic, but loops first by column and then by row.

You don’t immediately remove the cookies from the level as soon as you detect that they’re part of a chain because they may be part of two chains at the same time.

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

func removeMatches() -> Set<Chain> {
  let horizontalChains = detectHorizontalMatches()
  let verticalChains = detectVerticalMatches()

  print("Horizontal matches: \(horizontalChains)")
  print("Vertical matches: \(verticalChains)")

  return horizontalChains.union(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(), replace the call to scene.animateSwap(swap) and its completion closure with this:

scene.animate(swap, completion: handleMatches)

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

Removing Chains

Now you’re going to remove those cookies with a nice animation.

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

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

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

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

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

removeCookies(in: horizontalChains)
removeCookies(in: verticalChains)

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

func animateMatchedCookies(for chains: Set<Chain>, completion: @escaping () -> Void) {
  for chain in chains {
    for cookie in chain.cookies {
      if let sprite = cookie.sprite {
        if sprite.action(forKey: "removing") == nil {
          let scaleAction = SKAction.scale(to: 0.1, duration: 0.3)
          scaleAction.timingMode = .easeOut
          sprite.run(SKAction.sequence([scaleAction, SKAction.removeFromParent()]),
                     withKey: "removing")
        }
      }
    }
  }
  run(matchSound)
  run(SKAction.wait(forDuration: 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, you need to ensure you add only one animation to the sprite, not two. That's why the action is added using the key "removing". If such an action already exists, you won't add a new animation to the sprite.

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

Open GameViewController.swift and replace handleMatches() with the following to call this new animation:

func handleMatches() {
  let chains = level.removeMatches()

  scene.animateMatchedCookies(for: chains) {
    self.view.isUserInteractionEnabled = true
  }
}

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

Dropping Cookies Into Empty Tiles

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

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

Add this new method to Level.swift:

func fillHoles() -> [[Cookie]] {
    var columns: [[Cookie]] = []
    // 1
    for column in 0..<numColumns {
      var 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, it then finds the nearest cookie above it and moves that cookie to the empty tile.

Here is how it all works:

  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.
  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.

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(in columns: [[Cookie]], completion: @escaping () -> Void) {
  // 1
  var longestDuration: TimeInterval = 0
  for array in columns {
    for (index, cookie) in array.enumerated() {
      let newPosition = pointFor(column: cookie.column, row: cookie.row)
      // 2
      let delay = 0.05 + 0.15 * TimeInterval(index)
      // 3
      let sprite = cookie.sprite!   // sprite always exists at this point
      let duration = TimeInterval(((sprite.position.y - newPosition.y) / tileHeight) * 0.1)
      // 4
      longestDuration = max(longestDuration, duration + delay)
      // 5
      let moveAction = SKAction.move(to: newPosition, duration: duration)
      moveAction.timingMode = .easeOut
      sprite.run(
        SKAction.sequence([
          SKAction.wait(forDuration: delay),
          SKAction.group([moveAction, fallingCookieSound])]))
    }
  }

  // 6
  run(SKAction.wait(forDuration: 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 must 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 existing handleMatches() with the following:

func handleMatches() {
  let chains = level.removeMatches()
  scene.animateMatchedCookies(for: chains) {
    let columns = self.level.fillHoles()
    self.scene.animateFallingCookies(in: columns) {
      self.view.isUserInteractionEnabled = 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. Build and run to try it out!

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.

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

func topUpCookies() -> [[Cookie]] {
  var columns: [[Cookie]] = []
  var cookieType: CookieType = .unknown

  for column in 0..<numColumns {
    var array: [Cookie] = []

    // 1
    var row = numRows - 1
    while row >= 0 && cookies[column, row] == nil {
      // 2
      if tiles[column, row] != nil {
        // 3
        var newCookieType: CookieType
        repeat {
          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)
      }

      row -= 1
    }
    // 5
    if !array.isEmpty {
      columns.append(array)
    }
  }
  return columns
}

Here’s how it works:

  1. You loop through the column from top to bottom. This while 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(in columns: [[Cookie]], completion: @escaping () -> Void) {
  // 1
  var longestDuration: TimeInterval = 0

  for array in columns {
    // 2
    let startRow = array[0].row + 1

    for (index, cookie) in array.enumerated() {
      // 3
      let sprite = SKSpriteNode(imageNamed: cookie.cookieType.spriteName)
      sprite.size = CGSize(width: tileWidth, height: tileHeight)
      sprite.position = pointFor(column: cookie.column, row: startRow)
      cookiesLayer.addChild(sprite)
      cookie.sprite = sprite
      // 4
      let delay = 0.1 + 0.2 * TimeInterval(array.count - index - 1)
      // 5
      let duration = TimeInterval(startRow - cookie.row) * 0.1
      longestDuration = max(longestDuration, duration + delay)
      // 6
      let newPosition = pointFor(column: cookie.column, row: cookie.row)
      let moveAction = SKAction.move(to: newPosition, duration: duration)
      moveAction.timingMode = .easeOut
      sprite.alpha = 0
      sprite.run(
        SKAction.sequence([
          SKAction.wait(forDuration: delay),
          SKAction.group([
            SKAction.fadeIn(withDuration: 0.05),
            moveAction,
            addCookieSound])
          ]))
    }
  }
  // 7
  run(SKAction.wait(forDuration: 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. 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, once again replace handleMatches() with the following:

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

Try it out by building and running.

A Cascade of Cookies

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. You 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 isUserInteractionEnabled 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 level.removeMatches()

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 method:

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

We now have an endless supply of cookies. Build and run to see how this looks.

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.

GameViewController.swift includes all the necessary properties to hold the data, and Main.storyboard holds the views which display it.

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

var targetScore = 0
var maximumMoves = 0

Still in Level.swift, add these two lines at the end of init(filename:):

targetScore = levelData.targetScore
maximumMoves = levelData.moves

Copy the values retrieved from the JSON into the level itself.

Back in GameViewController.swift, add the following method:

func updateLabels() {
  targetLabel.text = String(format: "%ld", level.targetScore)
  movesLabel.text = String(format: "%ld", movesLeft)
  scoreLabel.text = String(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:

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 = 0

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

private func calculateScores(for 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(for: horizontalChains)
calculateScores(for: 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, so open that. 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:

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 animateScore(for 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 = String(format: "%ld", chain.score)
  scoreLabel.position = centerPosition
  scoreLabel.zPosition = 300
  cookiesLayer.addChild(scoreLabel)

  let moveAction = SKAction.move(by: CGVector(dx: 0, dy: 3), duration: 0.7)
  moveAction.timingMode = .easeOut
  scoreLabel.run(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(for:completion:), right after for chain in chains:

animateScore(for: chain)

When using SKLabelNode, SpriteKit 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.

Open GameScene.swift, and at the bottom of init(size:), add the following line:

let _ = SKLabelNode(fontNamed: "GillSans-BoldItalic")

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

Handle Combo Scenarios

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 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:

private var comboMultiplier = 0

Replace calculateScores(for:) with:

private func calculateScores(for 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 += 1
  }
}

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!

Handle Winning and Losing Scenarios

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

Create a new method in GameViewController.swift:

func decrementMoves() {
  movesLeft -= 1
  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.

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

The Look of Victory or Defeat

Main.storyboard contains an image view that you’ll update with a win or lose graphic. First, make sure the view doesn’t make an early appearance.

Open GameViewController.swift, and in viewDidLoad(), before you present the scene, make sure to hide this image view:

gameOverPanel.isHidden = true

Add the property for the tap recognizer at the top of the file:

var tapGestureRecognizer: UITapGestureRecognizer!

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

func showGameOver() {
  gameOverPanel.isHidden = false
  scene.isUserInteractionEnabled = false

  self.tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(hideGameOver))
  self.view.addGestureRecognizer(self.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:

@objc func hideGameOver() {
  view.removeGestureRecognizer(tapGestureRecognizer)
  tapGestureRecognizer = nil

  gameOverPanel.isHidden = true
  scene.isUserInteractionEnabled = 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:

Animating the Transitions

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

func animateGameOver(_ completion: @escaping () -> Void) {
  let action = SKAction.move(by: CGVector(dx: 0, dy: -size.height), duration: 0.3)
  action.timingMode = .easeIn
  gameLayer.run(action, completion: completion)
}

func animateBeginGame(_ completion: @escaping () -> Void) {
  gameLayer.isHidden = false
  gameLayer.position = CGPoint(x: 0, y: size.height)
  let action = SKAction.move(by: CGVector(dx: 0, dy: -size.height), duration: 0.3)
  action.timingMode = .easeOut
  gameLayer.run(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 addChild(gameLayer):

gameLayer.isHidden = true

Now open GameViewController.swift and replace showGameOver():

func showGameOver() {
  gameOverPanel.isHidden = false
  scene.isUserInteractionEnabled = false

  scene.animateGameOver {
    self.tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.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.

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

scene.animateBeginGame { }

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

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

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()

Build and run and your game should reset cleanly.

Manual Shuffling

There’s one more situation to manage: it may happen 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. Candy Crush Saga automatically reshuffles the cookies. But in Cookie Crunch, you’ll give that power to the player. You will allow the player to shuffle at any time by tapping a button, but it will cost the player a move.

Add the following two lines to shuffleButtonPressed(_:) in GameViewController.swift:

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.isHidden = true

Also do the same thing in viewDidLoad(), 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:

self.shuffleButton.isHidden = false

If you build and run now, you'll see the shuffle is a bit abrupt. It would look much nicer if the new cookies appeared with a cute animation. In GameScene.swift, go to addSprites(for:) 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.run(
  SKAction.sequence([
    SKAction.wait(forDuration: 0.25, withRange: 0.5),
    SKAction.group([
      SKAction.fadeIn(withDuration: 0.25),
      SKAction.scale(to: 1.0, duration: 0.25)
      ])
    ]))

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

Going to the Next Level

Wouldn't it be cool if your game automatically switched to the next level upon completing the current one? Once again, this is easy to do.

First, in Level.swift add the following global constant for keeping track of the number of levels right below numRows:

let numLevels = 4 // Excluding level 0

Next, in GameViewController.swift add the following property for keeping track of the level the user is currently playing:

var currentLevelNumber = 1

Now you need a way to know what level to use when loading your game scene. Still in GameViewController.swift replace the current viewDidLoad() method with the following:

override func viewDidLoad() {
  super.viewDidLoad()
  
  // Setup view with level 1
  setupLevel(number: currentLevelNumber)
  
  // Start the background music.
  backgroundMusic?.play()
}

And implement setupLevel(_:) as follows:

func setupLevel(number levelNumber: Int) {
  let skView = view as! SKView
  skView.isMultipleTouchEnabled = false

  // Create and configure the scene.
  scene = GameScene(size: skView.bounds.size)
  scene.scaleMode = .aspectFill

  // Setup the level.
  level = Level(filename: "Level_\(levelNumber)")
  scene.level = level

  scene.addTiles()
  scene.swipeHandler = handleSwipe

  gameOverPanel.isHidden = true
  shuffleButton.isHidden = true

  // Present the scene.
  skView.presentScene(scene)

  // Start the game.
  beginGame()
}

As you can see, this is almost the exact same code as you had in viewDidLoad() before, except for the line that setup the actual level instance. Now you choose the level number dynamically :]

Next, in decrementMoves() after the line:

gameOverPanel.image = UIImage(named: "LevelComplete")

add the following to update the current level number.

currentLevelNumber = currentLevelNumber < numLevels ? currentLevelNumber + 1 : 1

Notice that this is only called if the player actually completes the level. Rather than congratulating the player when all levels are complete, you simply go back to level 1. This way the game goes on forever!

Now there's only one last change you need to make before having implemented this awesome level-changing feature to your game. In hideGameOver() replace the line beginGame() with:

setupLevel(number: currentLevelNumber)

Build and run, and your game should now go to the next level when a user completes the current one.

Where to Go From Here?

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

You can download the completed version of the project using the Download Materials button at the top or bottom of this tutorial.

Here are ideas for other features you could add:

  • Special cookies when the player matches a certain shape. (Candy Crush Saga gives you a cookie that can clear an entire row when you match a 4-in-a-row chain.)
  • Bonus points or power-ups for L- or T-shapes
  • Jelly 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 false 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.

Credits: Free game art from Game Art Guppy. 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.

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


Viewing all articles
Browse latest Browse all 4370

Trending Articles