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

How to Make a Game Like Candy Crush: Part 2

$
0
0
Learn to make a tasty match-3 game

Learn to make a tasty match-3 game

Welcome back to our how to make a game like Candy Crush 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 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. Now that that’s done, 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 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\Cocoa Touch\Objective-C class template and click Next. Name the class RWTChain, make it a subclass of NSObject, click Next and then Create.

Replace the contents of RWTChain.h with this:

@class RWTCookie;
 
typedef NS_ENUM(NSUInteger, ChainType) {
  ChainTypeHorizontal,
  ChainTypeVertical,
};
 
@interface RWTChain : NSObject
 
@property (strong, nonatomic, readonly) NSArray *cookies;
 
@property (assign, nonatomic) ChainType chainType;
 
- (void)addCookie:(RWTCookie *)cookie;
 
@end

A chain has a list of cookie objects and a type: It’s either horizontal (a row of cookies) or vertical (a column). If you feel adventurous, you can also add more complex chain types, such as L- and T-shapes.

It will be pretty simple to implement this class. Open RWTChain.m and replace the contents with the following:

#import "RWTChain.h"
 
@implementation RWTChain {
  NSMutableArray *_cookies;
}
 
- (void)addCookie:(RWTCookie *)cookie {
  if (_cookies == nil) {
    _cookies = [NSMutableArray array];
  }
  [_cookies addObject:cookie];
}
 
- (NSArray *)cookies {
  return _cookies;
}
 
- (NSString *)description {
  return [NSString stringWithFormat:@"type:%ld cookies:%@", (long)self.chainType, self.cookies];
}
 
@end

Notice how the header declares the cookies property as an NSArray but the implementation declares the backing instance variable as an NSMutableArray. That’s a common trick when you don’t want users of a class to modify the array; they can only read from it.

Also, there is a reason you’re using an array here to store the cookie objects and not an NSSet: 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.

To start putting these new chain objects to good use, open RWTLevel.h and add the following import:

#import "RWTChain.h"

Next, add a public method declaration:

- (NSSet *)removeMatches;

Switch over to the implementation in RWTLevel.m. Before you get to removeMatches, 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 RWTLevel.m to scan for horizontal cookie matches:

- (NSSet *)detectHorizontalMatches {
  // 1
  NSMutableSet *set = [NSMutableSet set];
 
  // 2
  for (NSInteger row = 0; row < NumRows; row++) {
    for (NSInteger column = 0; column < NumColumns - 2; ) {
 
      // 3
      if (_cookies[column][row] != nil) {
        NSUInteger matchType = _cookies[column][row].cookieType;
 
        // 4
        if (_cookies[column + 1][row].cookieType == matchType
         && _cookies[column + 2][row].cookieType == matchType) {
          // 5
          RWTChain *chain = [[RWTChain alloc] init];
          chain.chainType = ChainTypeHorizontal;
          do {
            [chain addCookie:_cookies[column][row]];
            column += 1;
          }
          while (column < NumColumns && _cookies[column][row].cookieType == matchType);
 
          [set addObject:chain];
          continue;
        }
      }
 
      // 6
      column += 1;
    }
  }
  return set;
}

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

  1. You create a new set to hold the horizontal chains (RWTChain 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.
  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 RWTChain 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, it comes up as a cookieType of 0, which will never match a real cookie. So the logic above also works if you wanted to make a level with empty squares. Neat!

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

- (NSSet *)detectVerticalMatches {
  NSMutableSet *set = [NSMutableSet set];
 
  for (NSInteger column = 0; column < NumColumns; column++) {
    for (NSInteger row = 0; row < NumRows - 2; ) {
      if (_cookies[column][row] != nil) {
        NSUInteger matchType = _cookies[column][row].cookieType;
 
        if (_cookies[column][row + 1].cookieType == matchType
         && _cookies[column][row + 2].cookieType == matchType) {
 
          RWTChain *chain = [[RWTChain alloc] init];
          chain.chainType = ChainTypeVertical;
          do {
            [chain addCookie:_cookies[column][row]];
            row += 1;
          }
          while (row < NumRows && _cookies[column][row].cookieType == matchType);
 
          [set addObject:chain];
          continue;
        }
      }
      row += 1;
    }
  }
  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:

- (NSSet *)removeMatches {
  NSSet *horizontalChains = [self detectHorizontalMatches];
  NSSet *verticalChains = [self detectVerticalMatches];
 
  NSLog(@"Horizontal matches: %@", horizontalChains);
  NSLog(@"Vertical matches: %@", verticalChains);
 
  return [horizontalChains setByAddingObjectsFromSet: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 RWTViewController.m. Add this helper method:

- (void)handleMatches {
  NSSet *chains = [self.level removeMatches];
  // TODO: do something with the set
}

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

In the swipe handler block in viewDidLoad, change the call to animateSwap:completion: to this:

[self.scene animateSwap:swap completion:^{
  [self handleMatches];
}];

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

List of matches

Removing the Chains

RWTLevel’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 RWTCookie objects from the array for the 2-D grid. When that’s done, you can tell RWTMyScene 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 RWTLevel.m:

- (void)removeCookies:(NSSet *)chains {
  for (RWTChain *chain in chains) {
    for (RWTCookie *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 RWTChain object is the only owner of the RWTCookie object. When the chain gets deallocated, so will these cookie objects.

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

[self removeCookies:horizontalChains];
[self removeCookies:verticalChains];

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

- (void)animateMatchedCookies:(NSSet *)chains completion:(dispatch_block_t)completion {
 
  for (RWTChain *chain in chains) {
    for (RWTCookie *cookie in chain.cookies) {
 
      // 1
      if (cookie.sprite != nil) {
 
        // 2
        SKAction *scaleAction = [SKAction scaleTo:0.1 duration:0.3];
        scaleAction.timingMode = SKActionTimingEaseOut;
        [cookie.sprite runAction:[SKAction sequence:@[scaleAction, [SKAction removeFromParent]]]];
 
        // 3
        cookie.sprite = nil;
      }
    }
  }
 
  [self runAction:self.matchSound];
 
  // 4
  [self runAction:[SKAction sequence:@[
    [SKAction waitForDuration:0.3],
    [SKAction runBlock:completion]
    ]]];
}

This loops through all the chains and all the cookies in each chain, and then triggers the animations. Here’s how it all works, step by step:

  1. The same RWTCookie could be part of two chains (one horizontal and one vertical), but you only want to add one animation to the sprite. This check ensures that you only animate the sprite once.
  2. You put a scaling animation on the cookie sprite to shrink its size. When the animation is done, you remove the sprite from the cookie layer.
  3. You remove the link between the RWTCookie and its sprite as soon as you’ve added the animation. This simple trick prevents the situation described in point 1.
  4. You only continue with the rest of the game after the animations finish.

Open RWTMyScene.h and add the method signature:

- (void)animateMatchedCookies:(NSSet *)chains completion:(dispatch_block_t)completion;

Open RWTViewController.m and change handleMatches to call this new animation:

- (void)handleMatches {
  NSSet *chains = [self.level removeMatches];
 
  [self.scene animateMatchedCookies:chains completion:^{
    self.view.userInteractionEnabled = YES;
  }];
}

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 method signature to RWTLevel.h:

- (NSArray *)fillHoles;

Add the implementation of fillHoles to RWTLevel.m:

- (NSArray *)fillHoles {
  NSMutableArray *columns = [NSMutableArray array];
 
  // 1
  for (NSInteger column = 0; column < NumColumns; column++) {
 
    NSMutableArray *array;
    for (NSInteger row = 0; row < NumRows; row++) {
 
      // 2
      if (_tiles[column][row] != nil && _cookies[column][row] == nil) {
 
        // 3
        for (NSInteger lookup = row + 1; lookup < NumRows; lookup++) {
          RWTCookie *cookie = _cookies[column][lookup];
          if (cookie != nil) {
            // 4
            _cookies[column][lookup] = nil;
            _cookies[column][row] = cookie;
            cookie.row = row;
 
            // 5
            if (array == nil) {
              array = [NSMutableArray array];
              [columns addObject:array];
            }
            [array addObject:cookie];
 
            // 6
            break;
          }
        }
      }
    }
  }
  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.

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. RWTMyScene will animate the sprites and RWTViewController is the in-between object to coordinate between the the model (RWTLevel ) and the view (RWTMyScene).

Open RWTMyScene.h and add this method signature:

- (void)animateFallingCookies:(NSArray *)columns completion:(dispatch_block_t)completion;

Switch to RWTMyScene.m and add the method implementation:

- (void)animateFallingCookies:(NSArray *)columns completion:(dispatch_block_t)completion {
  // 1
  __block NSTimeInterval longestDuration = 0;
 
  for (NSArray *array in columns) {
    [array enumerateObjectsUsingBlock:^(RWTCookie *cookie, NSUInteger idx, BOOL *stop) {
      CGPoint newPosition = [self pointForColumn:cookie.column row:cookie.row];
 
      // 2
      NSTimeInterval delay = 0.05 + 0.15*idx;
 
      // 3
      NSTimeInterval duration = ((cookie.sprite.position.y - newPosition.y) / TileHeight) * 0.1;
 
      // 4
      longestDuration = MAX(longestDuration, duration + delay);
 
      // 5
      SKAction *moveAction = [SKAction moveTo:newPosition duration:duration];
      moveAction.timingMode = SKActionTimingEaseOut;
      [cookie.sprite runAction:[SKAction sequence:@[
        [SKAction waitForDuration:delay],
        [SKAction group:@[moveAction, self.fallingCookieSound]]]]];
    }];
  }
 
  // 6
  [self runAction:[SKAction sequence:@[
    [SKAction waitForDuration:longestDuration],
    [SKAction runBlock: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 RWTViewController.m. Replace the contents of handleMatches with the following:

NSSet *chains = [self.level removeMatches];
[self.scene animateMatchedCookies:chains completion:^{
  NSArray *columns = [self.level fillHoles];
  [self.scene animateFallingCookies:columns completion:^{
    self.view.userInteractionEnabled = YES;
  }];
}];

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.

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 declaration to RWTLevel.h:

- (NSArray *)topUpCookies;

And add the implementation to RWTLevel.m:

- (NSArray *)topUpCookies {
  NSMutableArray *columns = [NSMutableArray array];
 
  NSUInteger cookieType = 0;
 
  for (NSInteger column = 0; column < NumColumns; column++) {
 
    NSMutableArray *array;
 
    // 1
    for (NSInteger row = NumRows - 1; row >= 0 && _cookies[column][row] == nil; row--) {
 
      // 2
      if (_tiles[column][row] != nil) {
 
        // 3
        NSUInteger newCookieType;
        do {
          newCookieType = arc4random_uniform(NumCookieTypes) + 1;
        } while (newCookieType == cookieType);
        cookieType = newCookieType;
 
        // 4
        RWTCookie *cookie = [self createCookieAtColumn:column row:row withType:cookieType];
 
        // 5
        if (array == nil) {
          array = [NSMutableArray array];
          [columns addObject:array];
        }
        [array addObject:cookie];
      }
    }
  }
  return columns;
}

Where necessary, this adds new cookies to fill the columns to the top. It returns an array with the new RWTCookie 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 RWTCookie object. This uses the createCookieAtColumn:row:withType: method that you added in Part One.
  5. You add the cookie to the array for this column. You’re lazily creating the arrays, so the allocation only happens if a column has holes.

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.

Open RWTMyScene.h and add the method signature for the new animation method:

- (void)animateNewCookies:(NSArray *)columns completion:(dispatch_block_t)completion;

Switch to RWTMyScene.m and add the implementation:

- (void)animateNewCookies:(NSArray *)columns completion:(dispatch_block_t)completion {
  // 1
  __block NSTimeInterval longestDuration = 0;
 
  for (NSArray *array in columns) {
 
    // 2
    NSInteger startRow = ((RWTCookie *)[array firstObject]).row + 1;
 
    [array enumerateObjectsUsingBlock:^(RWTCookie *cookie, NSUInteger idx, BOOL *stop) {
 
      // 3
      SKSpriteNode *sprite = [SKSpriteNode spriteNodeWithImageNamed:[cookie spriteName]];
      sprite.position = [self pointForColumn:cookie.column row:startRow];
      [self.cookiesLayer addChild:sprite];
      cookie.sprite = sprite;
 
      // 4
      NSTimeInterval delay = 0.1 + 0.2*([array count] - idx - 1);
 
      // 5
      NSTimeInterval duration = (startRow - cookie.row) * 0.1;
      longestDuration = MAX(longestDuration, duration + delay);
 
      // 6
      CGPoint newPosition = [self pointForColumn:cookie.column row:cookie.row];
      SKAction *moveAction = [SKAction moveTo:newPosition duration:duration];
      moveAction.timingMode = SKActionTimingEaseOut;
      cookie.sprite.alpha = 0;
      [cookie.sprite runAction:[SKAction sequence:@[
        [SKAction waitForDuration:delay],
        [SKAction group:@[
          [SKAction fadeInWithDuration:0.05], moveAction, self.addCookieSound]]]]];
    }];
  }
 
  // 7
  [self runAction:[SKAction sequence:@[
    [SKAction waitForDuration:longestDuration],
    [SKAction runBlock: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 RWTViewController.m, replace the chain of completion blocks in handleMatches with the following:

[self.scene animateMatchedCookies:chains completion:^{
  NSArray *columns = [self.level fillHoles];
  [self.scene animateFallingCookies:columns completion:^{
    NSArray *columns = [self.level topUpCookies];
    [self.scene animateNewCookies:columns completion:^{
      self.view.userInteractionEnabled = YES;
    }];
  }];
}];

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 RWTViewController.m, 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) {
  [self 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:

- (void)beginNextTurn {
  self.view.userInteractionEnabled = YES;
}

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>

The logic for this sits in RWTLevel.m, in detectPossibleSwaps, but this method is not public yet, so add its method signature to RWTLevel.h:

- (void)detectPossibleSwaps;

And call it from beginNextTurn in RWTViewController.m:

- (void)beginNextTurn {
  [self.level detectPossibleSwaps];
  self.view.userInteractionEnabled = YES;
}

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 RWTViewController.m, in the class extension at the top:

@property (assign, nonatomic) NSUInteger movesLeft;
@property (assign, nonatomic) NSUInteger score;
 
@property (weak, nonatomic) IBOutlet UILabel *targetLabel;
@property (weak, nonatomic) IBOutlet UILabel *movesLabel;
@property (weak, nonatomic) IBOutlet UILabel *scoreLabel;

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

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 RWTLevel. Add the following to RWTLevel.h, under the @interface line:

@property (assign, nonatomic) NSUInteger targetScore;
@property (assign, nonatomic) NSUInteger maximumMoves;

These properties will store the values from the JSON data.

In RWTLevel.m, add these two lines to the bottom of the if block in initWithFile::

self.targetScore = [dictionary[@"targetScore"] unsignedIntegerValue];
self.maximumMoves = [dictionary[@"moves"] unsignedIntegerValue];

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

Back in RWTViewController.m, add the following method:

- (void)updateLabels {
  self.targetLabel.text = [NSString stringWithFormat:@"%lu", (long)self.level.targetScore];
  self.movesLabel.text = [NSString stringWithFormat:@"%lu", (long)self.movesLeft];
  self.scoreLabel.text = [NSString stringWithFormat:@"%lu", (long)self.score];
}

You’ll call this method after every turn to update the text inside the labels. You use the %lu format specifier and cast to (long) to make this code work the same way on 32-bit and 64-bit systems.

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

self.movesLeft = self.level.maximumMoves;
self.score = 0;
[self updateLabels];

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

Game with labels

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 RWTChain object, so each chain knows how many points it’s worth.

Add the following to RWTChain.h:

@property (assign, nonatomic) NSUInteger score;

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

- (void)calculateScores:(NSSet *)chains {
  for (RWTChain *chain in chains) {
    chain.score = 60 * ([chain.cookies count] - 2);
  }
}

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

[self calculateScores:horizontalChains];
[self 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 RWTChain objects, you can update the player’s score and display it onscreen.

This happens in RWTViewController.m. Inside handleMatches, just before the call to fillHoles, add the following lines:

for (RWTChain *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 RWTMyScene.m, add a new method:

- (void)animateScoreForChain:(RWTChain *)chain {
  // Figure out what the midpoint of the chain is.
  RWTCookie *firstCookie = [chain.cookies firstObject];
  RWTCookie *lastCookie = [chain.cookies lastObject];
  CGPoint centerPosition = CGPointMake(
    (firstCookie.sprite.position.x + lastCookie.sprite.position.x)/2,
    (firstCookie.sprite.position.y + lastCookie.sprite.position.y)/2 - 8);
 
  // Add a label for the score that slowly floats up.
  SKLabelNode *scoreLabel = [SKLabelNode labelNodeWithFontNamed:@"GillSans-BoldItalic"];
  scoreLabel.fontSize = 16;
  scoreLabel.text = [NSString stringWithFormat:@"%lu", (long)chain.score];
  scoreLabel.position = centerPosition;
  scoreLabel.zPosition = 300;
  [self.cookiesLayer addChild:scoreLabel];
 
  SKAction *moveAction = [SKAction moveBy:CGVectorMake(0, 3) duration:0.7];
  moveAction.timingMode = SKActionTimingEaseOut;
  [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:completion:, in between the two for loops:

for (RWTChain *chain in chains) {
 
  // Add this line:
  [self animateScoreForChain:chain];
 
  for (RWTCookie *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.

RWTMyScene already has a method for that, preloadResources, so add the following line to it:

[SKLabelNode labelNodeWithFontNamed:@"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 RWTLevel.m, add the following private property:

@property (assign, nonatomic) NSUInteger comboMultiplier;

Update calculateScores: to:

- (void)calculateScores:(NSSet *)chains {
  for (RWTChain *chain in chains) {
    chain.score = 60 * ([chain.cookies count] - 2) * self.comboMultiplier;
    self.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 RWTLevel.m:

- (void)resetComboMultiplier {
  self.comboMultiplier = 1;
}

And add its signature to RWTLevel.h:

- (void)resetComboMultiplier;

Open RWTViewController.m and find beginGame. Add this line just before the call to shuffle:

[self.level resetComboMultiplier];

Add the same line at the top of beginNextTurn:

[self.level resetComboMultiplier];

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>

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 RWTViewController.m:

- (void)decrementMoves{
  self.movesLeft--;
  [self 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:

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

Make sure to disable Auto Layout in the File inspector, the first tab on the right. Then 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 RWTViewController.m named gameOverPanel.

@property (weak, nonatomic) IBOutlet UIImageView *gameOverPanel;

Also, add a private property for a gesture recognizer:

@property (strong, nonatomic) UITapGestureRecognizer *tapGestureRecognizer;

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

self.gameOverPanel.hidden = YES;

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

- (void)showGameOver {
  self.gameOverPanel.hidden = NO;
  self.scene.userInteractionEnabled = NO;
 
  self.tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget: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:

- (void)hideGameOver {
  [self.view removeGestureRecognizer:self.tapGestureRecognizer];
  self.tapGestureRecognizer = nil;
 
  self.gameOverPanel.hidden = YES;
  self.scene.userInteractionEnabled = YES;
 
  [self 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 (self.score >= self.level.targetScore) {
  self.gameOverPanel.image = [UIImage imageNamed:@"LevelComplete"];
  [self showGameOver];
} else if (self.movesLeft == 0) {
  self.gameOverPanel.image = [UIImage imageNamed:@"GameOver"];
  [self 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 RWTMyScene.m:

- (void)animateGameOver {
  SKAction *action = [SKAction moveBy:CGVectorMake(0, -self.size.height) duration:0.3];
  action.timingMode = SKActionTimingEaseIn;
  [self.gameLayer runAction:action];
}
 
- (void)animateBeginGame {
  self.gameLayer.hidden = NO;
 
  self.gameLayer.position = CGPointMake(0, self.size.height);
  SKAction *action = [SKAction moveBy:CGVectorMake(0, -self.size.height) duration:0.3];
  action.timingMode = SKActionTimingEaseOut;
  [self.gameLayer runAction:action];
}

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 RWTMyScene.m in initWithSize:, immediately after you create the gameLayer node:

self.gameLayer.hidden = YES;

You’re also going to call these methods from RWTViewController, so open RWTMyScene.h and add the method signatures there:

- (void)animateGameOver;
- (void)animateBeginGame;

Now open RWTViewController.m and call animateGameOver as the first thing in showGameOver:

[self.scene animateGameOver];

Finally, in RWTViewController.m’s beginGame, just before the call to shuffle, call animateBeginGame:

[self.scene animateBeginGame];

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 RWTMyScene.m to perform the cleanup:

- (void)removeAllCookieSprites {
  [self.cookiesLayer removeAllChildren];
}

Declare it in RWTMyScene.h as well:

- (void)removeAllCookieSprites;

And call it as the very first thing from shuffle inside RWTViewController.m:

[self.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 RWTViewController.m:

@property (weak, nonatomic) IBOutlet UIButton *shuffleButton;

And add an action method:

- (IBAction)shuffleButtonPressed:(id)sender {
  [self shuffle];
  [self decrementMoves];
}

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

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

self.shuffleButton.hidden = YES;

And in hideGameOver, put it back on the screen again:

self.shuffleButton.hidden = NO;

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 RWTMyScene.m, go to addSpritesForCookies: and add the following lines inside the for loop, after the existing code:

cookie.sprite.alpha = 0;
cookie.sprite.xScale = cookie.sprite.yScale = 0.5;
 
[cookie.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 RWTViewController.m:

@import AVFoundation;

This uses the new @import syntax to include the AVFoundation framework. The main advantage of @import over #import is that you don’t need to add the framework to your project separately; Xcode will add it for you automatically.

Add the following private property:

@property (strong, nonatomic) AVAudioPlayer *backgroundMusic;

Add these lines to viewDidLoad, just before beginGame:

NSURL *url = [[NSBundle mainBundle] URLForResource:@"Mining by Moonlight" withExtension:@"mp3"];
self.backgroundMusic = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:nil];
self.backgroundMusic.numberOfLoops = -1;
[self.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 RWTMyScene.m, add two new private properties:

@property (strong, nonatomic) SKCropNode *cropLayer;
@property (strong, nonatomic) SKNode *maskLayer;

In initWithSize:, add these lines below the code that creates the tilesLayer:

self.cropLayer = [SKCropNode node];
[self.gameLayer addChild:self.cropLayer];
 
self.maskLayer = [SKNode node];
self.maskLayer.position = layerPosition;
self.cropLayer.maskNode = self.maskLayer;

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

[self.gameLayer addChild:self.cookiesLayer];

With this:

[self.cropLayer addChild:self.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 self.tilesLayer with self.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

Tip: If you want to see what the mask layer looks like, add this line to initWithSize:
[self.cropLayer addChild:self.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 (NSInteger row = 0; row <= NumRows; row++) {
  for (NSInteger column = 0; column <= NumColumns; column++) {
 
    BOOL topLeft     = (column > 0) && (row < NumRows)
                                    && [self.level tileAtColumn:column - 1 row:row];
 
    BOOL bottomLeft  = (column > 0) && (row > 0)
                                    && [self.level tileAtColumn:column - 1 row:row - 1];
 
    BOOL topRight    = (column < NumColumns) && (row < NumRows)
                                             && [self.level tileAtColumn:column row:row];
 
    BOOL bottomRight = (column < NumColumns) && (row > 0)
                                             && [self.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.
    NSUInteger value = topLeft | topRight << 1 | bottomLeft << 2 | bottomRight << 3;
 
    // Values 0 (no tiles), 6 and 9 (two opposite tiles) are not drawn.
    if (value != 0 && value != 6 && value != 9) {
      NSString *name = [NSString stringWithFormat:@"Tile_%lu", (long)value];
      SKSpriteNode *tileNode = [SKSpriteNode spriteNodeWithImageNamed:name];
      CGPoint point = [self pointForColumn:column row:row];
      point.x -= TileWidth/2;
      point.y -= TileHeight/2;
      tileNode.position = point;
      [self.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>

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 tutorial, but 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 RWTTile 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: Part 2 is a post from: Ray Wenderlich

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


Viewing all articles
Browse latest Browse all 4400

Trending Articles