Quantcast
Channel: Yoke Harn » Product
Viewing all articles
Browse latest Browse all 7

Create Flappy Bird Game in 3 Days – Day 2, Part 2

$
0
0

Why are some of the images in this post went missing?

This is a final part of Day 2 of the 3 Days of the series. Check out the previous post here.

Hour 5 : Obstacle, Point

We will create the obstacle block in this hour. Create a new class Obstacle like the following.

#import <SpriteKit/SpriteKit.h>

@interface Obstacle : SKNode

-(void)buildObstacle:(SKTextureAtlas*)atlas;
-(void)randomHeight;
-(CGFloat)getObstacleWidth;
@end
#import "Obstacle.h"
#import "MyScene.h"
#import "SKPhysicsBody+AnchorFix.h"
#import "GameUtil.h"

@implementation Obstacle {
    SKSpriteNode *upperBlock;
    SKSpriteNode *lowerBlock;
}

-(void)buildObstacle:(SKTextureAtlas*)atlas {
}

-(void)randomHeight {
}

-(CGFloat) getObstacleWidth {
    return upperBlock.frame.size.width;
}

@end

Put the following in buildObstacle method.

// lower block
lowerBlock = [SKSpriteNode spriteNodeWithTexture:[atlas textureNamed:@"block"]];
lowerBlock.anchorPoint = CGPointMake(0.5, 1.0);
lowerBlock.name = @"block";
[self addChild:lowerBlock];

// upper block
upperBlock = [SKSpriteNode spriteNodeWithTexture:[atlas textureNamed:@"block"]];
upperBlock.anchorPoint = CGPointMake(0.5, 0.0);
upperBlock.name = @"block";
[self addChild:upperBlock];

// add invisible node to collect point
SKNode *point = [[SKNode alloc] init];

CGPoint anchorPoint = CGPointMake(0.5, 0.0);
point.position = [GameUtil convertPoint:CGPointMake(0, 0)];

point.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:CGSizeMake(lowerBlock.frame.size.width/2, [UIScreen mainScreen].bounds.size.height) withAnchorPoint:anchorPoint];
point.physicsBody.dynamic = false;
point.physicsBody.categoryBitMask = pointCategory;
point.physicsBody.contactTestBitMask = playerCategory;
[self addChild:point];

[self randomHeight];

Before we continue, you need to understand a little about anchor point in SpriteKit.

This is an excerpt from SpriteKit Programming Guide: A sprite node’s anchorPoint property determines which point in the frame is positioned at the sprite’s position. Anchor points are specified in the unit coordinate system, shown in below figure. The unit coordinate system places the origin at the bottom left corner of the frame and (1,1) at the top right corner of the frame. A sprite’s anchor point defaults to (0.5,0.5), which corresponds to the center of the frame.

You see, by default the anchor point is in the center of the node. That’s OK as long as you know your node length, the position where you want to be and you can try and guess by executing your code on device to get the best position.

But in our case, the block has to be position randomly in vertical and have to take into account of the node height (different for iPhone and iPad) to get the half point and minus the screen offset (different for 3.5 inch, 4 inch and iPad) etc etc. The calculation is getting complicated.

So to make it easier and maintainable, we are going to set the anchor point for lower block to be middle top (0.5, 1), and upper block to be middle bottom (0.5, 0). I’ll show you how easy the calculation later if we going this way.

We add an invisible node in the middle of the gap of the upper block and lower block, as the point block. This will help to detect if the bird have fly in the gap and will notify the system that it should collecting the point.

Next, we add the following in the randomHeight method.

int lowerBound = 70;
int upperBound = 480 - 140; // use iphone 3.5 inch as base calculation
int gap = 120;

int randomMidPoint = lowerBound + arc4random() % (upperBound - lowerBound);

// lower block
lowerBlock.position = [GameUtil convertPoint:CGPointMake(0, randomMidPoint)];
//bg.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:bg.frame.size];
lowerBlock.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:lowerBlock.frame.size withAnchorPoint:lowerBlock.anchorPoint];
lowerBlock.physicsBody.categoryBitMask = obstacleCategory;
lowerBlock.physicsBody.dynamic = false;
lowerBlock.physicsBody.collisionBitMask = playerCategory;
lowerBlock.physicsBody.contactTestBitMask = playerCategory;

// upper block
upperBlock.position = [GameUtil convertPoint:CGPointMake(0, randomMidPoint + gap)];
upperBlock.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:upperBlock.frame.size withAnchorPoint:upperBlock.anchorPoint];
upperBlock.physicsBody.categoryBitMask = obstacleCategory;
upperBlock.physicsBody.dynamic = false;
upperBlock.physicsBody.collisionBitMask = playerCategory;
upperBlock.physicsBody.contactTestBitMask = playerCategory;

Remember that the coordinate position is originated in bottom left? We set the minimum lower block Y-coordinate to be 70, and the maximum block Y-coordinate to be 360 (140 point from above the screen for 3.5 inch device). Each time we invoke the randomHeight method, it will give the new position for the lower block height.

You see how easy the calculation of the position of the block now that we use anchor point?

Right now, your code will have error on this piece of code.

[SKPhysicsBody bodyWithRectangleOfSize:lowerBlock.frame.size withAnchorPoint:lowerBlock.anchorPoint];

That’s because this method does not exist in the SDK. Normally, we would use the below to define the body of the block.

[SKPhysicsBody bodyWithRectangleOfSize:lowerBlock.frame.size];

But, this method will create a body that is position at the default anchor point, which is at the center. The physics body will still remain the same place if we change our anchor point to other value.

When we change the anchor point and set the position, we only change the position of the display element of the node, not the physics body!

So to address this problem, we need to create physics body that will take into consideration of the anchor point adjustment.

Create a new category class like below and import it in the Obstacle.m later.

#import <SpriteKit/SpriteKit.h>

@interface SKPhysicsBody (AnchorFix)
+ (CGPathRef)pathForRectangleOfSize:(CGSize)size withAnchorPoint:(CGPoint)anchor;
+ (SKPhysicsBody *)bodyWithRectangleOfSize:(CGSize)size withAnchorPoint:(CGPoint)anchor;
@end
#import "SKPhysicsBody+AnchorFix.h"

@implementation SKPhysicsBody (AnchorFix)
+ (CGPathRef)pathForRectangleOfSize:(CGSize)size withAnchorPoint:(CGPoint)anchor {
    CGPathRef path = CGPathCreateWithRect( CGRectMake(-size.width * anchor.x, -size.height * anchor.y,
                                                      size.width,   size.height), nil);
    return path;
}

+ (SKPhysicsBody *)bodyWithRectangleOfSize:(CGSize)size withAnchorPoint:(CGPoint)anchor {
    CGPathRef path = [self pathForRectangleOfSize:size withAnchorPoint:anchor];
    return [self bodyWithPolygonFromPath:path];
}
@end

In next hour, we are going to putting all the code together to make the bird fly and crash the bird onto the block!

Hour 6 : Putting Bird and Obstacle Together

Add the following in the MyScene.m

-(void)initializeObstacle {
    for (int i = 0; i < 4; i++) {
        Obstacle *bg = [[Obstacle alloc] init];
        [bg buildObstacle:imageAtlas];
        bg.position = CGPointMake(i * ([bg getObstacleWidth]*3) + [bg getObstacleWidth]*13, 0);

        bg.name = @"obstacle";
        [self addChild:bg];
    }
}

This will create four block, with each block separated about 3 block width, and put them 13 block away from the screen to the right.

We need to animate the block to move in sync with the land animation. Overwrite the moveBG method of the MyScene to the following.

- (void)moveBg {
    if (!gameover) {
        float velocity = -BG_VELOCITY;

        [super moveBg];
        [self enumerateChildNodesWithName:@"obstacle" usingBlock: ^(SKNode *node, BOOL *stop)
         {
             Obstacle * bg = (Obstacle *) node;
             CGPoint bgVelocity = CGPointMake(velocity, 0);
             CGPoint amtToMove = CGPointMultiplyScalar(bgVelocity,[self getDt]);
             bg.position = CGPointAdd(bg.position, amtToMove);

             //Checks if bg node is completely scrolled of the screen, if yes then put it at the end of the other node
             if (bg.position.x <= -[bg getObstacleWidth])  {

                  bg.position = CGPointMake(bg.position.x + 4*([bg getObstacleWidth]*3), bg.position.y);
                 [bg randomHeight];
             }
         }];
    }
}

The code is about the same as the land animation in the parent class, except that it will only move when the game is still on.

Since we are reusing the block object, we need to call the randomHeight method to re-position the block gap so that it will appear like a new block each time we swap the block to the back.

Next, add the following code to initiate the game.

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    if (!gameover) {

        // before game begin
        if (!player.physicsBody.dynamic) {
            [self initializeObstacle];

            // hide instruction
            [instructionNode runAction:[SKAction fadeOutWithDuration:0.3f]];
        }
        player.physicsBody.dynamic = YES;
        [player runAction:actionMoveUp];
    }
}

What this method do is when the first tap occurred,

  • Init the obstacle block
  • Hide the instruction node

For subsequent tap, it will set the body of the bird to dynamic so that it will response to the gravity, and apply the impulse to the bird to make it jump.

Next, we will handle the collission of the bird. Add the following in the MyScene.m

-(void)didBeginContact:(SKPhysicsContact *)contact {

    if (contact.bodyA.categoryBitMask == pointCategory || contact.bodyB.categoryBitMask == pointCategory)
    {
        if (!gameover) {
            // increase score
            totalScore++;
        }
    } else {

        if (!gameover) {

            // shake screen
            UIView *myview = self.scene.view;
            CABasicAnimation *animation =
            [CABasicAnimation animationWithKeyPath:@"position"];
            [animation setDuration:0.05];
            [animation setRepeatCount:4];
            [animation setAutoreverses:YES];
            [animation setFromValue:[NSValue valueWithCGPoint:
                                     CGPointMake([myview center].x - 5.0f, [myview center].y - 5.0f)]];
            [animation setToValue:[NSValue valueWithCGPoint:
                                   CGPointMake([myview center].x + 5.0f, [myview center].y + 5.0f)]];
            [[myview layer] addAnimation:animation forKey:@"position"];

            gameover = true;
        }
    }

}

We check if one of the contact body is of point category, it’s actually touching the  gap inside and we increment the totalpoint value, otherwise, it should end the game. We’ve added the shake screen animation and set the gameover flag to true.

Now try to run your game. You should be able to start playing the game for the first time!

In next hour, we are going to display current score and store your best score.

Hour 7 : Score

SpriteKit has the SKLabelNode that can handle displaying of text in game scene. However, it will only handle flat text and can’t do much to do a fancy text.

Hence I’ve build a simple class to display the score using image.

Add the following in your project.

#import <SpriteKit/SpriteKit.h>

@interface TexturedFont : SKNode

@property (nonatomic, retain) NSMutableArray *numberFrames;
@property (nonatomic, retain) NSMutableArray *numberNodes;

+(id)initWithTextureAtlas:(SKTextureAtlas*)fontAtlas;
-(void)runningFrom:(int)from to:(int)to completion:(dispatch_block_t)block;;
-(void)setNumber:(int)number;
-(void)setRightAlign;
-(void)setLeftAlign;
-(void)setCenterAlign;
@end

#import "TexturedFont.h"

@implementation TexturedFont {
    int positionFlag; // 1=center, 2=left, 3=right
    int currentNumber;
}

+(id) initWithTextureAtlas:(SKTextureAtlas *)fontAtlas {

    NSMutableArray *tempFrames = [NSMutableArray array];

    for (int i=0; i < 10; i++) {
        NSString *textureName = [NSString stringWithFormat:@"%d", i];
        SKTexture *temp = [fontAtlas textureNamed:textureName];
        [tempFrames addObject:temp];
    }

    TexturedFont *label = [[TexturedFont alloc] init];
    label.numberFrames = tempFrames;

    [label setCenterAlign];

    label.numberNodes = [NSMutableArray array];

    return label;
}

-(void)setCenterAlign {
    positionFlag = 1;
}

-(void)setLeftAlign {
    positionFlag = 2;
}

-(void)setRightAlign {
    positionFlag = 3;
}

-(void)runningFrom:(int)from to:(int)to completion:(dispatch_block_t)block{
    [self setNumber:from];
    [self runAction:[SKAction repeatAction:[SKAction sequence:@[
                        [SKAction waitForDuration:0.1f],
                        [SKAction runBlock:^{
                            [self incrementNumber];
                        }]
                        ]] count:(to-from)]completion:^{
        block();
    }];
}

-(void) incrementNumber {
    [self setNumber:++currentNumber];
}

-(void)setNumber:(int)number {
    currentNumber = number;
    //[self removeChildrenInArray:self.numberNodes];
    [self removeAllChildren];
    self.numberNodes = [NSMutableArray array];

    NSMutableArray *numberArray = [self getNumberArray:number];

    CGFloat totalWidth = 0;
    CGFloat offset = 0;

    for (int i = 0; i < [numberArray count]; i++) {
        SKSpriteNode *node = [SKSpriteNode spriteNodeWithTexture:[self getTexture:[numberArray[i] intValue]]];

        node.position = CGPointMake(offset, 0);
        node.anchorPoint = CGPointMake(0, 0.5);
        [self.numberNodes addObject:node];

        totalWidth += node.frame.size.width;
        offset += node.frame.size.width;
    }

    // rearrange position, handle left, right and center align
    for (SKSpriteNode *node in self.numberNodes) {
        if (positionFlag == 1) {
            node.position = CGPointMake(node.position.x - (totalWidth/2), 0);
        }
        if (positionFlag == 2) {
            // already left
        }
        if (positionFlag == 3) {
            node.position = CGPointMake(node.position.x - totalWidth, 0);
        }
    }

    // add all to view
    for (SKSpriteNode *node in self.numberNodes) {
        [self addChild:node];
    }
}

-(SKTexture*) getTexture:(int)number {
    return self.numberFrames[number];
}

-(NSMutableArray*)getNumberArray:(int)number {
    NSString *temp = [NSString stringWithFormat:@"%d", number];

    NSMutableArray *tempArray = [NSMutableArray array];
    for (int i = 0; i < [temp length]; i++) {

        NSString *tempInt = [temp substringWithRange:NSMakeRange(i, 1)];
        [tempArray addObject:tempInt];
    }

    return tempArray;
}
@end

Now, add the following in MyScene.m.

// local variable
TexturedFont *scoreTextureFont;

// init method
SKTextureAtlas *bigFontAtlas = [self textureAtlasNamed:@"bignumber" share:TRUE];
scoreTextureFont = [TexturedFont initWithTextureAtlas:bigFontAtlas];
scoreTextureFont.position = [GameUtil convertPoint:CGPointMake(MID_POINT_X, 420)];
scoreTextureFont.zPosition = 10;
[scoreTextureFont setNumber:0];
[self addChild:scoreTextureFont];

Update the score whenever the bird collect the point in didBeginContact method.

[scoreTextureFont setNumber:totalScore];

We need to store the top score whenever the current score is higher than the previous top. Add the following in the GameUtil.

+(int) getTopScore {
    NSInteger highScore = [[NSUserDefaults standardUserDefaults] integerForKey:@"topscore"];
    return highScore;
}

+(void) saveScore:(int)score {
    if ([GameUtil getTopScore] &lt; score) {
        [[NSUserDefaults standardUserDefaults]
         setInteger:score forKey:@"topscore"];
    }
}

In next hour, we are going to retrieve the score and save the score whenever the game is over, and display the scoreboard and coin.

Hour 8 : Game Over, Scoreboard, Coin, Button

Create a new class GameStat that will hold the score, top score and coin information.

#import &lt;SpriteKit/SpriteKit.h&gt;

@interface GameStat : SKSpriteNode

-(void)setup:(SKTextureAtlas*)smallFontAtlas;
-(void)populateScore:(int)score atlas:(SKTextureAtlas*)imageAtlas;

@end

#import "GameStat.h"
#import "GameUtil.h"
#import "TexturedFont.h"

@implementation GameStat {
    TexturedFont *topScore;
    TexturedFont *score;
}

-(void)setup:(SKTextureAtlas*)smallFontAtlas {

    CGFloat offsetX = 80;
    score = [TexturedFont initWithTextureAtlas:smallFontAtlas];
    score.position = [GameUtil convertPointByRatioOnly:CGPointMake(offsetX, 30)];
    [score setRightAlign];
    [score setNumber:0];

    [self addChild:score];

    topScore = [TexturedFont initWithTextureAtlas:smallFontAtlas];
    topScore.position = [GameUtil convertPointByRatioOnly:CGPointMake(offsetX, -5)];
    [topScore setRightAlign];
    [topScore setNumber:0];

    [self addChild:topScore];
}

-(void)populateScore:(int)currentScore atlas:(SKTextureAtlas *)imageAtlas{

    int previousTopScore = [GameUtil getTopScore];

    [GameUtil saveScore:currentScore];

    [score runningFrom:0 to:currentScore completion:^{
        if (previousTopScore < currentScore) {

            [topScore setNumber:currentScore];

            // display new top score
            SKSpriteNode *newRecord = [SKSpriteNode spriteNodeWithTexture:[imageAtlas textureNamed:@"newrecord"]];
            newRecord.position = [GameUtil convertPointByRatioOnly:CGPointMake(40, 13)];
            [self addChild:newRecord];

        }
    }];
    [topScore setNumber:previousTopScore];

    // display coin
    SKSpriteNode *coin = nil;

    if (currentScore >= 30) {

        coin = [SKSpriteNode spriteNodeWithTexture:[imageAtlas textureNamed:@"gold"]];
    } else if (currentScore >= 20) {

        coin = [SKSpriteNode spriteNodeWithTexture:[imageAtlas textureNamed:@"silver"]];
    } else if (currentScore >= 10) {

        coin = [SKSpriteNode spriteNodeWithTexture:[imageAtlas textureNamed:@"bronze"]];
    } else {
        //blank coin
        coin = [SKSpriteNode spriteNodeWithTexture:[imageAtlas textureNamed:@"nomedal"]];
    }

    if (coin != nil) {
        coin.position = [GameUtil convertPointByRatioOnly:CGPointMake(-35, 0)];
        [self addChild:coin];
    }
}
@end

In the setup method, we init 2 TexturedFont object that will display the top score and current game score.

In populateScore method, we did the following:

  • Get the previous top score
  • Save the current game score (which will check if the score is more than the previous top score)
  • Run the score from 0 to the current score, and upon completion, check if it’s new top score and display the “NEW” word beside it
  • Display gold, silver, bronze or no coin based on the score

Next, we are going to build game over logo, scoreboard, and some button when the game is over. Add the following method in MyScene.m.

-(void)showGameover {

    SKTextureAtlas *smallFontAtlas = [self textureAtlasNamed:@"smallnumber" share:TRUE];
    // game stat
    GameStat *gamestat = [GameStat spriteNodeWithTexture:[imageAtlas textureNamed:@"scoreboard"]];
    gamestat.position = [GameUtil convertPoint:CGPointMake(MID_POINT_X, 250)];
    gamestat.alpha = 0;
    [gamestat setup:smallFontAtlas];
    [self addChild:gamestat];

    // game over logo
    SKSpriteNode *gameoverNode = [SKSpriteNode spriteNodeWithTexture:[imageAtlas textureNamed:@"gameover"]];
    gameoverNode.position = [GameUtil convertPoint:CGPointMake(MID_POINT_X, 370)];
    gameoverNode.zPosition = 10;
    gameoverNode.alpha = 0;
    [self addChild:gameoverNode];

    SKAction *gameoverAction =
    [SKAction sequence:@[
                         [SKAction waitForDuration:1.0f],
                         self.gameoverSound,
                         [SKAction group:@[
                             [SKAction fadeInWithDuration:0.5],
                             [SKAction sequence:@[
                                  [SKAction moveByX:0 y:10 duration:0.15f],
                                  [SKAction moveByX:0 y:-10 duration:0.15f]
                              ]]
            ]]
     ]];

    SKAction *commonAction = [SKAction sequence:@[[SKAction fadeInWithDuration:0.3f]]];

    [gameoverNode runAction:gameoverAction completion:^{
        [gamestat runAction:commonAction completion:^{

            // record score
            [gamestat populateScore:totalScore atlas:imageAtlas];
        }];

        // replay button
        SKSpriteNode *replayButton = [SKSpriteNode spriteNodeWithTexture:[imageAtlas textureNamed:@"replaybutton"]];
        replayButton.position = [GameUtil convertPoint:CGPointMake(MID_POINT_X, 150)];
        replayButton.name = @"replayButton";//how the node is identified later
        replayButton.zPosition = 10.0;
        replayButton.alpha = 0;

        [self addChild:replayButton];

        // menu button
        SKSpriteNode *menuButton = [SKSpriteNode spriteNodeWithTexture:[imageAtlas textureNamed:@"menubutton"]];

        menuButton.position = [GameUtil convertPoint:CGPointMake(MID_POINT_X, 100)];
        menuButton.name = @"menuButton";//how the node is identified later
        menuButton.zPosition = 10.0;
        menuButton.alpha = 0;

        [self addChild:menuButton];

        [replayButton runAction:commonAction];
        [menuButton runAction:commonAction];
    }];
}

Nothing new here, except that there’s a series of animation that tie together to display each element right after another. You should probably have a detail look into various animation available and customize your own.

Sofar, we’ve added menu button and replay button, that goes back to main menu scene, and reload the game scene again to replay.

Add the below code to handle touches of the button in touchesBegan method in MyScene.m.

UITouch *touch = [touches anyObject];
CGPoint location = [touch locationInNode:self];
SKNode *node = [self nodeAtPoint:location];

//if fire button touched, bring the rain
if ([node.name isEqualToString:@"replayButton"]) {
    // change to game scene
    //[self gotoMain];
    [self replay];
} else if ([node.name isEqualToString:@"menuButton"]) {

    [self gotoMain];
} else {
    if (!gameover) {

        // before game begin
        if (!player.physicsBody.dynamic) {
            [self initializeObstacle];

            // hide instruction
            [instructionNode runAction:[SKAction fadeOutWithDuration:0.3f]];
        }
        player.physicsBody.dynamic = YES;
        [player runAction:actionMoveUp];
    }
}

Below are the two method that will handle the button action.

-(void)gotoMain {
    SKTransition *trans = [SKTransition fadeWithDuration:1.0];

    MainMenuScene * scene = [MainMenuScene sceneWithSize:self.scene.view.frame.size];
    scene.viewController = self.viewController;
    scene.scaleMode = SKSceneScaleModeAspectFill;

    [self.scene.view presentScene:scene transition:trans];
}

-(void) replay {
    SKTransition *trans = [SKTransition fadeWithDuration:1.0];

    MyScene * scene = [MyScene sceneWithSize:self.scene.view.frame.size];
    scene.viewController = self.viewController;
    scene.scaleMode = SKSceneScaleModeAspectFill;

    [self.scene.view presentScene:scene transition:trans];
}

Finally, add this in didBeginContact method whenever the game is over.

[self showGameover];

Go ahead and run your game on your device. By right, the game is quite complete with all the basic function implemented.

Tomorrow we are going to polish up the game and add some final detail like sound, advertisement, rating etc. You can check the post here.

Let me know your thought about this tutorial.

The post Create Flappy Bird Game in 3 Days – Day 2, Part 2 appeared first on Yoke Harn.


Viewing all articles
Browse latest Browse all 7

Trending Articles