Advertisement

Make a Neon Vector Shooter for iOS: More Gameplay

by

This Cyber Monday Tuts+ courses will be reduced to just $3 (usually $15). Don't miss out.

This post is part of a series called Cross-Platform Vector Shooter: iOS.
Make a Neon Vector Shooter for iOS: First Steps

In this series of tutorials, I'll show you how to make a Geometry Wars-inspired twin-stick shooter, with neon graphics, crazy particle effects, and awesome music, for iOS using C++ and OpenGL ES 2.0. So far, we've set up the basic gameplay; now, we'll add enemies and a scoring system.

Overview

In this part we will build upon the previous tutorial by adding enemies, collision detection, and scoring.

Here are the new features in action:


Warning: Loud!

We will add the following new classes to handle this:

  • Enemy
  • EnemySpawner: Responsible for creating enemies and gradually increasing the game's difficulty.
  • PlayerStatus: Tracks the player's score, high score, and lives.

You may have noticed that there are two types of enemies in the video, but there's only one Enemy class. We could derive subclasses from Enemy for each enemy type. The original XNA version of the game didn't, because of the following drawbacks:

  • They add more boilerplate code.
  • They can increase the complexity of the code and make it harder to understand. The state and functionality of an object becomes spread out over its entire inheritance chain.
  • They aren't very flexible—you can't share pieces of functionality between different branches of the inheritance tree if that functionality isn't in the base class. For example, consider making two classes, Mammal and Bird, which both derive from Animal. The Bird class has a Fly() method. Then you decide to add a Bat class that derives from Mammal and can also fly. To share this functionality using only inheritance you would have to move the Fly() method to the Animal class where it doesn't belong. In addition, you can't remove methods from derived classes, so if you made a Penguin class that derived from Bird, it would also have to have a Fly() method.

For this tutorial, we will side with the original XNA version and favor composition over inheritance for implementing the different types of enemies. We will do this by creating various reusable behaviors that we can add to enemies. We can then easily mix and match behaviors when we create new types of enemies. For example, if we already had a FollowPlayer behavior and a DodgeBullet behavior, we could make a new enemy that does both simply by adding both behaviors.

Enemies

Enemies will have a few additional properties over entities. In order to give the player some time to react, we'll make enemies gradually fade in before they become active and dangerous.

Let's code the basic structure of the Enemy class:

class Enemy
:   public Entity
{
public:
    enum Behavior
    {
        kFollow = 0,
        kMoveRandom,
    };

protected:
    std::list<Behavior> mBehaviors;
    float               mRandomDirection;
    int                 mRandomState;
    int                 mPointValue;
    int                 mTimeUntilStart;

protected:
    void AddBehaviour(Behavior b);
    void ApplyBehaviours();

public:
    Enemy(tTexture* image, const tVector2f& position);

    void update();

    bool getIsActive();
    int getPointValue();

    static Enemy* createSeeker(const tVector2f& position);
    static Enemy* createWanderer(const tVector2f& position);

    void handleCollision(Enemy* other);
    void wasShot();

    bool followPlayer(float acceleration);
    bool moveRandomly();
};

Enemy::Enemy(tTexture* image, const tVector2f& position)
:   mPointValue(1),
mTimeUntilStart(60)
{
    mImage = image;
    mPosition = position;
    mRadius = image->getSurfaceSize().width / 2.0f;
    mColor = tColor4f(0,0,0,0);
    mKind = kEnemy;
}

void Enemy::update()
{
    if (mTimeUntilStart <= 0)
    {
        ApplyBehaviours();
    }
    else
    {
        mTimeUntilStart--;
        mColor = tColor4f(1,1,1,1) * (1.0f - (float)mTimeUntilStart / 60.0f);
    }

    mPosition += mVelocity;
    mPosition = tVector2f(tMath::clamp(mPosition.x, getSize().width / 2.0f, GameRoot::getInstance()->getViewportSize().width - getSize().width / 2.0f),
                          tMath::clamp(mPosition.y, getSize().height / 2.0f, GameRoot::getInstance()->getViewportSize().height - getSize().height / 2.0f));

    mVelocity *= 0.8f;
}

void Enemy::wasShot()
{
    mIsExpired = true;

    PlayerStatus::getInstance()->addPoints(mPointValue);
    PlayerStatus::getInstance()->increaseMultiplier();

    tSound* temp = Sound::getInstance()->getExplosion();

    if (!temp->isPlaying())
    {
        temp->play(0, 1);
    }
}

This code will make enemies fade in for 60 frames and will allow their velocity to function. Multiplying the velocity by 0.8 fakes a friction-like effect. If we make enemies accelerate at a constant rate, this friction will cause them to smoothly approach a maximum speed. The simplicity and smoothness of this type of friction is nice, but you may want to use a different formula depending on the effect you want.

The wasShot() method will be called when the enemy gets shot. We'll add more to it later in the series.

We want different types of enemies to behave differently; we'll accomplish this by assigning behaviors. A behavior will use some custom function that runs each frame to control the enemy.

The original XNA version of Shape Blaster used a special language feature from C# to automate the behaviors. Without going into too much detail (since we won't be using them), the end result was that the C# runtime would call the behavior methods every frame without having to explicitly say so.

Since this language feature doesn't exist in either C or C++, we'll will have to explicitly call the behaviors ourselves. Though this requires a little more code, the side benefit is we'll know exactly when our behaviors are updated and thus gives us finer control.

Our simplest behavior will be the followPlayer() behavior shown below:

bool Enemy::followPlayer(float acceleration)
{
    if (!PlayerShip::getInstance()->getIsDead())
    {
        tVector2f temp = (PlayerShip::getInstance()->getPosition() - mPosition);
        temp = temp * (acceleration / temp.length());
        mVelocity += temp;
    }

    if (mVelocity != tVector2f(0,0))
    {
        mOrientation = atan2f(mVelocity.y, mVelocity.x);
    }

    return true;
}

This simply makes the enemy accelerate towards the player at a constant rate. The friction we added earlier will ensure it eventually tops out at some max speed (five pixels per frame when acceleration is one unit, since \(0.8 \times 5 + 1 = 5\).

Let's add the scaffolding needed to make behaviors work. Enemies need to store their behaviors, so we'll add a variable to the Enemy class:

std::list<Behavior> mBehaviors;

mBehaviors is a std::list containing all active behaviors. Each frame we'll go through all the behaviors the enemy has and call the behavior function based on the behavior type. If the behavior method returns false, it means that the behavior has completed, so we should remove it from the list.

We'll add the following methods to the Enemy class:

void Enemy::AddBehaviour(Behavior b)
{
    mBehaviors.push_back(b);
}

void Enemy::ApplyBehaviours()
{
    std::list<Behavior>::iterator iter, iterNext;

    iter = mBehaviors.begin();
    iterNext = iter;

    while (iter != mBehaviors.end())
    {
        iterNext++;

        bool result = false;

        switch (*iter)
        {
            case kFollow: result = followPlayer(0.9f); break;
            case kMoveRandom: result = moveRandomly(); break;
        }

        if (!result)
        {
            mBehaviors.erase(iter);
        }

        iter = iterNext;
    }
}

And we'll modify the update() method to call ApplyBehaviours():

if (mTimeUntilStart <= 0)
{
    ApplyBehaviours();
}

Now we can make a static method to create seeking enemies. All we have to do is pick the image we want and add the followPlayer() behavior:

Enemy* Enemy::createSeeker(const tVector2f& position)
{
    Enemy* enemy = new Enemy(Art::getInstance()->getSeeker(), position);

    enemy->AddBehaviour(kFollow);
    enemy->mPointValue = 2;

    return enemy;
}

To make an enemy that moves randomly, we'll have it choose a direction and then make small random adjustments to that direction. However, if we adjust the direction every frame, the movement will be jittery, so we'll only adjust the direction periodically. If the enemy runs into the edge of the screen, we'll have it choose a new random direction that points away from the wall.

bool Enemy::moveRandomly()
{
    if (mRandomState == 0)
    {
        mRandomDirection += tMath::random() * 0.2f - 0.1f;
    }

    mVelocity += 0.4f * tVector2f(cosf(mRandomDirection), sinf(mRandomDirection));
    mOrientation -= 0.05f;

    tRectf bounds        = tRectf(0,0, GameRoot::getInstance()->getViewportSize());
    bounds.location.x   -= -mImage->getSurfaceSize().width  / 2.0f - 1.0f;
    bounds.location.y   -= -mImage->getSurfaceSize().height / 2.0f - 1.0f;
    bounds.size.width   += 2.0f * (-mImage->getSurfaceSize().width  / 2.0f - 1.0f);
    bounds.size.height  += 2.0f * (-mImage->getSurfaceSize().height / 2.0f - 1.0f);

    if (!bounds.contains(tPoint2f((int32_t)mPosition.x, (int32_t)mPosition.y)))
    {
        tVector2f temp  = tVector2f(GameRoot::getInstance()->getViewportSize().x, GameRoot::getInstance()->getViewportSize().y) / 2.0f;
        temp -= mPosition;
        mRandomDirection = atan2f(temp.y, temp.x) + tMath::random() * tMath::PI - tMath::PI / 2.0f;
    }

    mRandomState = (mRandomState + 1) % 6;

    return true;
}

We can now make a factory method for creating wandering enemies, much like we did for the seeker:

Enemy* Enemy::createWanderer(const tVector2f& position)
{
    Enemy* enemy = new Enemy(Art::getInstance()->getWanderer(), position);

    enemy->mRandomDirection = tMath::random() * tMath::PI * 2.0f;
    enemy->mRandomState = 0;
    enemy->AddBehaviour(kMoveRandom);

    return enemy;
}

Collision Detection

For collision detection, we'll model the player's ship, the enemies, and the bullets as circles. Circular collision detection is nice because it's simple, it's fast, and it doesn't change when the objects rotate. If you recall, the Entity class has a radius and a position (the position refers to the center of the entity)—this is all we need for circular collision detection.

Testing each entity against all other entities that could potentially collide can be very slow if you have a large number of entities. There are many techniques you can use to speed up broad phase collision detection, like quadtrees, sweep and prune, and BSP trees. However, for now, we will only have a few dozen entities on screen at a time, so we won't worry about these more complex techniques. We can always add them later if we need them.

In Shape Blaster, not every entity can collide with every other type of entity. Bullets and the player's ship can collide only with enemies. Enemies can also collide with other enemies; this will prevent them from overlapping.

To deal with these different types of collisions, we will add two new lists to the EntityManager to keep track of bullets and enemies. Whenever we add an entity to the EntityManager, we'll want to add it to the appropriate list, so we'll make a private addEntity() method to do so. We'll also be sure to remove any expired entities from all the lists each frame.

std::list<Enemy*>       mEnemies;
std::list<Bullet*>      mBullets;

void EntityManager::addEntity(Entity* entity)
{
    mEntities.push_back(entity);

    switch (entity->getKind())
    {
        case Entity::kBullet:       mBullets.push_back((Bullet*)entity); break;
        case Entity::kEnemy:        mEnemies.push_back((Enemy*)entity); break;

        default: break;
    }
}

// ...
// in Update()
for(std::list<Bullet*>::iterator iter = mBullets.begin(); iter != mBullets.end(); iter++)
{
    if ((*iter)->isExpired())
    {
        delete *iter;
        *iter = NULL;
    }
}
mBullets.remove(NULL);
for(std::list<Enemy*>::iterator iter = mEnemies.begin(); iter != mEnemies.end(); iter++)
{
    if ((*iter)->isExpired())
    {
        delete *iter;
        *iter = NULL;
    }
}
mEnemies.remove(NULL);

Replace the calls to entity.add() in EntityManager.add() and EntityManager.update() with calls to addEntity().

Now let's add a method that will determine whether two entities are colliding:

bool EntityManager::isColliding(Entity* a, Entity* b)
{
    float radius = a->getRadius() + b->getRadius();
    return !a->isExpired() && !b->isExpired() && a->getPosition().distanceSquared(b->getPosition()) < radius * radius;
}

To determine whether two circles overlap, simply check whether the distance between them is less than the sum of their radii. Our method optimizes this slightly by checking if the square of the distance is less than the square of the sum of the radii. Remember that it's a bit faster to compute the distance squared than the actual distance.

Different things will happen depending on which two objects collide. If two enemies collide, we want them to push each other away; if a bullet hits an enemy, the bullet and the enemy should both be destroyed; if the player touches an enemy, the player should die and the level should reset.

We'll add a handleCollision() method to the Enemy class to handle collisions between enemies:

void Enemy::handleCollision(Enemy* other)
{
    tVector2f d = mPosition - other->mPosition;

    mVelocity += 10.0f * d / (d.lengthSquared() + 1.0f);
}

This method will push the current enemy away from the other enemy. The closer they are, the harder it will be pushed, because the magnitude of (d / d.LengthSquared()) is just one over the distance.

Respawning the Player

Next, we need a method to handle the player's ship getting killed. When this happens, the player's ship will disappear for a short time before respawning.

We start by adding two new members to PlayerShip:

int mFramesUntilRespawn;

bool PlayerShip::getIsDead()
{
    return mFramesUntilRespawn > 0;
}

At the very beginning of PlayerShip::update(), add the following:

if (getIsDead())
{
    mFramesUntilRespawn--;
}

And we override draw() as shown:

void PlayerShip::draw(tSpriteBatch* spriteBatch)
{
    if (!getIsDead())
    {
        Entity::draw(spriteBatch);
    }
}

Finally, we add a kill() method to PlayerShip:

void PlayerShip::kill()
{
    mFramesUntilRespawn = 60;
}

Now that all the pieces are in place, we'll add a method to the EntityManager that goes through all the entities and checks for collisions:

void EntityManager::handleCollisions()
{
    for (std::list<Enemy*>::iterator i = mEnemies.begin(); i != mEnemies.end(); i++)
    {
        for (std::list<Enemy*>::iterator j = mEnemies.begin(); j != mEnemies.end(); j++)
        {
            if (isColliding(*i, *j))
            {
                (*i)->handleCollision(*j);
                (*j)->handleCollision(*i);
            }
        }
    }

    // handle collisions between bullets and enemies
    for (std::list<Enemy*>::iterator i = mEnemies.begin(); i != mEnemies.end(); i++)
    {
        for (std::list<Bullet*>::iterator j = mBullets.begin(); j != mBullets.end(); j++)
        {
            if (isColliding(*i, *j))
            {
                (*i)->wasShot();
                (*j)->setExpired();
            }
        }
    }

    // handle collisions between the player and enemies
    for (std::list<Enemy*>::iterator i = mEnemies.begin(); i != mEnemies.end(); i++)
    {
        if ((*i)->getIsActive() && isColliding(PlayerShip::getInstance(), *i))
        {
            PlayerShip::getInstance()->kill();

            for (std::list<Enemy*>::iterator j = mEnemies.begin(); j != mEnemies.end(); j++)
            {
                (*j)->wasShot();
            }
            
            EnemySpawner::getInstance()->reset();
            break;
        }
    }
}

Call this method from update() immediately after setting mIsUpdating to true.

Enemy Spawner

The last thing to do is make the EnemySpawner class, which is responsible for creating enemies. We want the game to start off easy and get harder, so the EnemySpawner will create enemies at an increasing rate as time progresses. When the player dies, we'll reset the EnemySpawner to its initial difficulty.

class EnemySpawner
: public tSingleton<EnemySpawner>
{
protected:
    float mInverseSpawnChance;

protected:
    tVector2f GetSpawnPosition();

protected:
    EnemySpawner();

public:
    void update();
    void reset();

    friend class tSingleton<EnemySpawner>;
};

void EnemySpawner::update()
{
    if (!PlayerShip::getInstance()->getIsDead() && EntityManager::getInstance()->getCount() < 200)
    {
        if (int32_t(tMath::random() * mInverseSpawnChance) == 0)
        {
            EntityManager::getInstance()->add(Enemy::createSeeker(GetSpawnPosition()));
        }

        if (int32_t(tMath::random() * mInverseSpawnChance) == 0)
        {
            EntityManager::getInstance()->add(Enemy::createWanderer(GetSpawnPosition()));
        }
    }

    if (mInverseSpawnChance > 30)
    {
        mInverseSpawnChance -= 0.005f;
    }
}

tVector2f EnemySpawner::GetSpawnPosition()
{
    tVector2f pos;
    do
    {
        pos = tVector2f(tMath::random() * GameRoot::getInstance()->getViewportSize().width,
                        tMath::random() * GameRoot::getInstance()->getViewportSize().height);
    }
    while (pos.distanceSquared(PlayerShip::getInstance()->getPosition()) < 250 * 250);

    return pos;
}

void EnemySpawner::reset()
{
    mInverseSpawnChance = 90;
}

Each frame, there is a one in mInverseSpawnChance of generating each type of enemy. The chance of spawning an enemy gradually increases until it reaches a maximum of one in twenty. Enemies are always created at least 250 pixels away from the player.

Be careful about the while loop in GetSpawnPosition(). It will work efficiently as long as the area in which enemies can spawn is bigger than the area where they can't spawn. However, if you make the forbidden area too large, you will get an infinite loop.

Call EnemySpawner::update() from GameRoot::onRedrawView() and call EnemySpawner::reset() when the player is killed.

Score and Lives

  • In Shape Blaster, you begin with four lives, and will gain an additional life every 2,000 points.
  • You receive points for destroying enemies, with different types of enemies being worth different amounts of points.
  • Each enemy destroyed also increases your score multiplier by one.
  • If you don't kill any enemies within a short amount of time, your multiplier will be reset.
  • The total amount of points received from each enemy you destroy is the number of points the enemy is worth, multiplied by your current multiplier.
  • If you lose all your lives, the game is over and you start a new game with your score reset to zero.

To handle all this, we will make a static class called PlayerStatus:

class PlayerStatus
: public tSingleton<PlayerStatus>
{
protected:
    static const float kMultiplierExpiryTime;
    static const int   kMaxMultiplier;
    static const std::string kHighScoreFilename;

    float   mMultiplierTimeLeft;
    int     mLives;
    int     mScore;
    int     mHighScore;
    int     mMultiplier;
    int     mScoreForExtraLife;
    uint32_t mLastTime;

protected:
    int     LoadHighScore();
    void    SaveHighScore(int score);

protected:
    PlayerStatus();

public:
    void    reset();
    void    update();
    void    addPoints(int basePoints);
    void    increaseMultiplier();
    void    resetMultiplier();
    void    removeLife();

    int     getLives() const;
    int     getScore() const;
    int     getHighScore() const;
    int     getMultiplier() const;
    bool    getIsGameOver() const;

    friend class tSingleton<PlayerStatus>;
};

PlayerStatus::PlayerStatus()
{
    mScore = 0;
    mHighScore = LoadHighScore();
    reset();

    mLastTime = tTimer::getTimeMS();
}

void    PlayerStatus::reset()
{
    if (mScore > mHighScore)
    {
        mHighScore = mScore;
        SaveHighScore(mHighScore);
    }

    mScore = 0;
    mMultiplier = 1;
    mLives = 4;
    mScoreForExtraLife = 2000;
    mMultiplierTimeLeft = 0;
}

void    PlayerStatus::update()
{
    if (mMultiplier > 1)
    {
        mMultiplierTimeLeft -= float(tTimer::getTimeMS() - mLastTime) / 1000.0f;

        if (mMultiplierTimeLeft <= 0)
        {
            mMultiplierTimeLeft = kMultiplierExpiryTime;
            resetMultiplier();
        }
    }

    mLastTime = tTimer::getTimeMS();
}

void    PlayerStatus::addPoints(int basePoints)
{
    if (!PlayerShip::getInstance()->getIsDead())
    {
        mScore += basePoints * mMultiplier;
        while (mScore >= mScoreForExtraLife)
        {
            mScoreForExtraLife += 2000;
            mLives++;
        }
    }
}

void    PlayerStatus::increaseMultiplier()
{
    if (!PlayerShip::getInstance()->getIsDead())
    {
        mMultiplierTimeLeft = kMultiplierExpiryTime;

        if (mMultiplier < kMaxMultiplier)
        {
            mMultiplier++;
        }
    }
}

void    PlayerStatus::resetMultiplier()
{
    mMultiplier = 1;
}

void    PlayerStatus::removeLife()
{
    mLives--;
}

Call PlayerStatus::update() from GameRoot::onRedrawView() when the game is not paused.

Next, we want to display your score, lives, and multiplier on screen. To do this we'll need to add a tSpriteFont in the Content project and a corresponding variable in the Art class, which we will name Font. Load the font in Art's constructor as we did with the textures.

font_invert

Note: The font we're using is actually an image rather than something like a TrueType font file. Image-based fonts were how classic arcade games and consoles printed text on screen, and even now some current generation games still use the technique. One benefit we gain from this is that we'll end up using the same techniques to draw text on screen as we do other sprites.

Modify the end of GameRoot::onRedrawView() where the cursor is drawn, as shown below:

char buf[80];

sprintf(buf, "Lives: %d", PlayerStatus::getInstance()->getLives());
mSpriteBatch->drawString(1, Art::getInstance()->getFont(), buf, tPoint2f(5,5), tColor4f(1,1,1,1),
                        0, tPoint2f(0,0), tVector2f(kScale));

sprintf(buf, "Score: %d", PlayerStatus::getInstance()->getScore());
DrawRightAlignedString(buf, 5);

sprintf(buf, "Multiplier: %d", PlayerStatus::getInstance()->getMultiplier());
DrawRightAlignedString(buf, 35);

mSpriteBatch->draw(0, Art::getInstance()->getPointer(), Input::getInstance()->getMousePosition(), tOptional<tRectf>());

DrawRightAlignedString() is a helper method for drawing text aligned on the right side of the screen. Add it to GameRoot by adding the code below:

#define kScale 3.0f

void GameRoot::DrawRightAlignedString(const std::string& str, int32_t y)
{
    int32_t textWidth = int32_t(Art::getInstance()->getFont().getTextSize(str).width * kScale);
    mSpriteBatch->drawString(1, Art::getInstance()->getFont(), str, tPoint2f(mViewportSize.width - textWidth - 5, y), tColor4f(1,1,1,1),
                            0, tPoint2f(0,0), tVector2f(kScale));
}

Now your lives, score, and multiplier should display on screen. However, we still need to modify these values in response to game events. Add a property called mPointValue to the Enemy class.

int Enemy::getPointValue()
{
    return mPointValue;
}

Set the point value for different enemies to something you feel is appropriate. I've made the wandering enemies worth one point, and the seeking enemies worth two points.

Next, add the following two lines to Enemy::wasShot() to increase the player's score and multiplier:

PlayerStatus::getInstance()->addPoints(mPointValue);
PlayerStatus::getInstance()->increaseMultiplier();

Call PlayerStatus::removeLife() in PlayerShip::kill(). If the player loses all their lives, call PlayerStatus::reset() to reset their score and lives at the start of a new game.

High Scores

Let's add the ability for the game to track your best score. We want this score to persist across plays so we'll save it to a file. We'll keep it really simple and save the high score as a single plain-text number in a file (this will be in the App's "Application Support" directory, which is a fancy name for the "preferences" directory.)

Add the following to PlayerStatus:

const std::string PlayerStatus::kHighScoreFilename("highscore.txt");

void CreatePathIfNonExistant2(const std::string& newPath)
{
    @autoreleasepool
    {
        // Create the path if it doesn't exist
        NSError *error;
        [[NSFileManager defaultManager]
         createDirectoryAtPath:[NSString stringWithUTF8String:newPath.c_str()]
         withIntermediateDirectories:YES
         attributes:nil
         error:&error];
    }
}

CreatePathIfNonExistant2() is a function I've made that will create a directory on the iOS device if it doesn't already exist. Since our preference path will not exist initially, we'll have to create it the first time.

std::string GetExecutableName2()
{
    return [[[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleExecutable"] UTF8String];
}

GetExecutableName2() returns the name of the executable. We'll use the name of the application as part of the preference path. We'll use this function instead of hard-coding the name of the executable, so that we can just re-use this code for other applications unchanged.

std::string GetPreferencePath2(const std::string& file)
{
    std::string result = std::string([[NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES) objectAtIndex:0] UTF8String]) + "/" +
    GetExecutableName2() + "/";
    CreatePathIfNonExistant2(result);

    return result + file;
}

GetPreferencePath2() returns the full string version name of the preference path, and creates the path if it doesn't already exist.

int     PlayerStatus::LoadHighScore()
{
    int score = 0;

    std::string fstring;

    if ([[NSFileManager defaultManager] fileExistsAtPath:[NSString stringWithUTF8String:GetPreferencePath2(kHighScoreFilename).c_str()]])
    {
        fstring = [[NSString stringWithContentsOfFile:[NSString stringWithUTF8String:GetPreferencePath2(kHighScoreFilename).c_str()]
                                                        encoding:NSUTF8StringEncoding error:nil] UTF8String];
        if (!fstring.empty())
        {
            sscanf(fstring.c_str(), "%d", &score);
        }
    }

    return score;
}

void    PlayerStatus::SaveHighScore(int score)
{
    char buf[20];
    sprintf(buf, "%d", score);

    [[NSString stringWithUTF8String:buf] writeToFile:[NSString stringWithUTF8String:GetPreferencePath2(kHighScoreFilename).c_str()] atomically:YES
                                                                           encoding:NSUTF8StringEncoding error:nil];
}

The LoadHighScore() method first checks that the high score file exists, and then returns what's in the file as an integer. It's unlikely that the score will be invalid unless as the user is generally unable to change files manually from within iOS, but if it the score ends up being a non-number, the score will just end up being zero.

We want to load the high score when the game starts up, and save it when the player gets a new high score. We'll modify the static constructor and reset() methods in PlayerStatus to do so. We'll also add a helper member, mIsGameOver, which we'll use in a moment.

bool    PlayerStatus::getIsGameOver() const
{
    return mLives == 0;
}

PlayerStatus::PlayerStatus()
{
    mScore = 0;
    mHighScore = LoadHighScore();
    reset();

    mLastTime = tTimer::getTimeMS();
}

void    PlayerStatus::reset()
{
    if (mScore > mHighScore)
    {
        mHighScore = mScore;
        SaveHighScore(mHighScore);
    }

    mScore = 0;
    mMultiplier = 1;
    mLives = 4;
    mScoreForExtraLife = 2000;
    mMultiplierTimeLeft = 0;
}

That takes care of tracking the high score. Now we need to display it. We'll add the following code to GameRoot::onRedrawView() in the same SpriteBatch block where the other text is drawn:

if (PlayerStatus::getInstance()->getIsGameOver())
{
    sprintf(buf, "Game Over\nYour Score: %d\nHigh Score: %d",
            PlayerStatus::getInstance()->getScore(),
            PlayerStatus::getInstance()->getHighScore());

    tDimension2f textSize = Art::getInstance()->getFont().getTextSize(buf);
    mSpriteBatch->drawString(1, Art::getInstance()->getFont(), buf, (mViewportSize - textSize) / 2, tColor4f(1,1,1,1), 0, tPoint2f(0,0), tVector2f(kScale));
}

This will make it display your score and high score on game over, centered in the screen.

As a final adjustment, we'll increase the time before the ship respawns on game over to give the player time to see their score. Modify PlayerShip::kill() by setting the respawn time to 300 frames (five seconds) if the player is out of lives.

void PlayerShip::kill()
{
    PlayerStatus::getInstance()->removeLife();
    mFramesUntilRespawn = PlayerStatus::getInstance()->getIsGameOver() ? 300 : 120;
}

The game is now ready to play. It may not look like much, but it has all the basic mechanics implemented. In future tutorials we will add particle effects and a background grid to spice it up. But right now, let's quickly add some sound and music to make it more interesting.

Sound and Music

Playing sound and music is fairly simple in iOS. First, let's add our sound effects and music to the content pipeline.

First, we make a static helper class for the sounds. Note that the game's Sound Management class is called Sound, but our Utility library's sound class is called tSound.

class Sound
: public tSingleton<Sound>
{
protected:
    tSound* mMusic;
    std::vector<tSound*> mExplosions;
    std::vector<tSound*> mShots;
    std::vector<tSound*> mSpawns;

protected:
    Sound();

public:
    tSound* getMusic() const;
    tSound* getExplosion() const;
    tSound* getShot() const;
    tSound* getSpawn() const;

    friend class tSingleton<Sound>;
};

Sound::Sound()
{
    char buf[80];
    mMusic = new tSound("music.mp3");

    for (int i = 1; i <= 8; i++)
    {
        sprintf(buf, "explosion-0%d.wav", i);
        mExplosions.push_back(new tSound(buf));

        if (i <= 4)
        {
            sprintf(buf, "shoot-0%d.wav", i);
            mShots.push_back(new tSound(buf));
        }

        sprintf(buf, "spawn-0%d.wav", i);
        mSpawns.push_back(new tSound(buf));
    }
}

Since we have multiple variations of each sound, the Explosion, Shot, and Spawn properties will pick a sound at random among the variants.

Call Sound's constructor in GameRoot::onInitView(). To play the music, add the following line at the end of GameRoot::onInitView().

Sound::getInstance()->getMusic()->play(0, (uint32_t)-1);

To play sounds in, you can simply call the play() method on a SoundEffect. This method also provides an overload that allows you to start the sound after a certain duration of time, and adjust the number of times the sound loops. We'll use -1 for the music as that is equivalent to "looping forever."

To trigger the sound effect for shooting, add the following line in PlayerShip::update(), inside the if statement where the bullets are created:

tSound* curShot = Sound::getInstance()->getShot();

if (!curShot->isPlaying())
{
    curShot->play(0, 1);
}

Likewise, trigger an explosion sound effect each time an enemy is destroyed by adding the following to Enemy::wasShot():

tSound* temp = Sound::getInstance()->getExplosion();

if (!temp->isPlaying())
{
    temp->play(0, 1);
}

You now have sound and music in your game. Easy, isn't it?

Conclusion

That wraps up the basic gameplay mechanics. In the next tutorial, we'll add a virtual gamepad on-screen so that we can move and shoot at the same time.

Advertisement