Warfare Unleashed Implementing Gameplay

0
94
23 min read

(For more resources related to this topic, see here.)

Equipping the entities

The SceneNode base class was inherited by the Entity class. Entities are the central part of this chapter. It’s all about the interaction between entities of different kinds. Before starting to implement all those interactions, it is reasonable to think about crucial properties our entities need to have.

Introducing hitpoints

Since, we are preparing our airplanes for the battlefield, we need to provide them with new specific attributes. To our class definition of Entity, we add a new member variable that memorizes the current hitpoints. Hitpoints ( HP ) are a measure for the hull integrity of an entity; the entity is destroyed as soon as the hitpoints reach or fall below zero.

In addition to the member variable, we provide member functions that allow the modification of the hitpoints. We do not provide direct write access, however, the hitpoints can be decreased (the plane is damaged) or increased (the plane is repaired). Also, a destroy() function instantly destroys the entity.

class Entity : public SceneNode
{
public:
explicit Entity(int hitpoints);
void repair(int points);
void damage(int points);
void destroy();
int getHitpoints() const;
bool isDestroyed() const;
...
private:
int mHitpoints;
...
};

The implementation is as expected: repair() adds the specified hitpoints, damage() subtracts them, and destroy() sets them to zero.

Storing entity attributes in data tables

In our game, there are already two different airplanes with different attributes. For this chapter, we introduce a third one to make the game more interesting. With an increasing amount of new aircraft types, attributes such as speed, hitpoints, used texture, or fire rate may vary strongly among them. We need to think of a way to store those properties in a central place, allowing easy access to them.

What we clearly want to avoid are case differentiations in every Aircraft method, since this makes the local logic code less readable, and spreads the attributes across different functions. Instead of if/else cascades or switch statements, we can store the attributes in a central table, and just access the table every time we need an attribute.

Let’s define the type of such a table entry in the case of an airplane. We choose the simplest way, and have a structure AircraftData with all members public. This type is defined in the file DataTables.hpp.

struct AircraftData
{
int hitpoints;
float speed;
Textures::ID texture;
};

While AircraftData is a single table entry, the whole table is represented as a sequence of entries, namely std::vector<AircraftData>.

Next, we write a function that initializes the table for different aircraft types. We begin to define a vector of the correct size (Aircraft::TypeCount is the last enumerator of the enum Aircraft::Type, it contains the number of different aircraft types). Since the enumerators are consecutive and begin at zero, we can use them as indices in our STL container. We thus initialize all the attributes for different airplanes, and eventually return the filled table.

std::vector<AircraftData> initializeAircraftData()
{
std::vector<AircraftData> data(Aircraft::TypeCount);
data[Aircraft::Eagle].hitpoints = 100;
data[Aircraft::Eagle].speed = 200.f;
data[Aircraft::Eagle].texture = Textures::Eagle;
data[Aircraft::Raptor].hitpoints = 20;
data[Aircraft::Raptor].speed = 80.f;
data[Aircraft::Raptor].texture = Textures::Raptor;
...
return data;
}

The global function initializeAircraftData() is declared in DataTables.hpp and defined in DataTables.cpp. It is used inside Aircraft.cpp, to initialize a global constant Table. This constant is declared locally in the .cpp file, so only the Aircraft internals can access it. In order to avoid name collisions in other files, we use an anonymous namespace.

namespace
{
const std::vector<AircraftData> Table = initializeAircraftData();
}

Inside the Aircraft methods, we can access a constant attribute of the own plane type using the member variable mType as index. For example, Table[mType].hitpoints denotes the maximal hitpoints of the current aircraft.

Data tables are only the first step of storing gameplay constants. For more flexibility, and to avoid recompiling the application, you can also store these constants externally, for example, in a simple text file or using a specific file format. The application initially loads these files, parses the values, and fills the data tables accordingly.

Nowadays, it is very common to load gameplay information from external resources. There are text-based formats such as YAML or XML, as well as, many application-specific text and binary formats. There are also well-known C++ libraries such as Boost.Serialize (www.boost. org) that help with loading and saving data structures from C++.

One possibility that has recently gained popularity consists of using script languages, most notably Lua (www.lua.org), in addition to C++. This has the advantage that not only constant data, but dynamic functionality can be outsourced and loaded during runtime.

Displaying text

We would like to add some text on the display, for example, to show the hitpoints or ammunition of different entities. Since this text information is supposed to be shown next to the entity, it stands to reason to attach it to the corresponding scene node. We therefore, create a TextNode class which inherits SceneNode as shown in the following code:

class TextNode : public SceneNode
{
public:
explicit TextNode(const FontHolder& fonts,
const std::string& text);
void setString(const std::string& text);
private:
virtual void drawCurrent(sf::RenderTarget& target,
sf::RenderStates states) const;
private:
sf::Text mText;
};

The implementation of the functions is not complicated. The SFML class sf::Text provides most of what we need. In the TextNode constructor, we retrieve the font from the resource holder and assign it to the text.

TextNode::TextNode(const FontHolder& fonts, const std::string& text)
{
mText.setFont(fonts.get(Fonts::Main));
mText.setCharacterSize(20);
setString(text);
}

The function to draw the text nodes just forwards the call to the SFML render target, as you know it from sprites.

void TextNode::drawCurrent(sf::RenderTarget& target, sf::RenderStates
states) const
{
target.draw(mText, states);
}

For the interface, mainly the following method is interesting. It assigns a new string to the text node, and automatically adapts to its size. centerOrigin() is a utility function we wrote; it sets the object’s origin to its center, which simplifies positioning a lot.

void TextNode::setString(const std::string& text)
{
mText.setString(text);
centerOrigin(mText);
}

In the Aircraft constructor, we create a text node and attach it to the aircraft itself. We keep a pointer mHealthDisplay as a member variable and let it point to the attached node.

std::unique_ptr<TextNode> healthDisplay(new TextNode(fonts, ""));
mHealthDisplay = healthDisplay.get();
attachChild(std::move(healthDisplay));

In the method Aircraft::update(), we check for the current hitpoints, and convert them to a string, using our custom toString() function. The text node’s string and relative position are set. Additionally, we set the text node’s rotation to the negative aircraft rotation, which compensates the rotation in total. We do this in order to have the text always upright, independent of the aircraft’s orientation.

mHealthDisplay->setString(toString(getHitpoints()) + " HP");
mHealthDisplay->setPosition(0.f, 50.f);
mHealthDisplay->setRotation(-getRotation());

Creating enemies

Enemies are other instances of the Aircraft class. They appear at the top of the screen and move downwards, until they fly past the bottom of the screen. Most properties are the same for the player and enemies, so we only explain the new aircraft functionality.

Movement patterns

By default, enemies fly downwards in a straight line. But it would be nice if different enemies moved differently, giving the feeling of a very basic artificial intelligence ( AI ). Thus, we introduce specific movement patterns. Such a pattern can be described as a sequence of directions to which the enemy airplane heads. A direction consists of an angle and a distance.

struct Direction
{
Direction(float angle, float distance);
float angle;
float distance;
};

Our data table for aircraft gets a new entry for the sequence of directions as shown in following code:

struct AircraftData
{
int hitpoints;
float speed;
Textures::ID texture;
std::vector<Direction> directions;
};

Let’s implement a zigzag movement pattern for the Raptor plane. First, it steers for 80 units in 45 degrees direction. Then, the angle changes to -45 degrees, and the plane traverses 160 units back. Last, it moves again 80 units in +45 degrees direction, until it arrives at its original x position.

data[Aircraft::Raptor].directions.push_back(Direction( 45, 80));
data[Aircraft::Raptor].directions.push_back(Direction(-45, 160));
data[Aircraft::Raptor].directions.push_back(Direction( 45, 80));

For the Avenger plane, we use a slightly more complex pattern: it is essentially a zigzag, but between the two diagonal movements, the plane moves straight for 50 units.

data[Aircraft::Avenger].directions.push_back(Direction(+45, 50));
data[Aircraft::Avenger].directions.push_back(Direction( 0, 50));
data[Aircraft::Avenger].directions.push_back(Direction(-45, 100));
data[Aircraft::Avenger].directions.push_back(Direction( 0, 50));
data[Aircraft::Avenger].directions.push_back(Direction(+45, 50));

The following figure shows the sequence of directions for both planes; the Raptor plane is located on the left, Avenger on the right:

This way of defining movement is very simple, yet it enables a lot of possibilities. You can let the planes fly in any direction (also sideward or backwards); you can even approximate curves when using small intervals.

Now, we look at the logic we have to implement to follow these movement patterns. To the Aircraft class, we add two member variables: mTravelledDistance, which denotes the distance already travelled for each direction, and mDirectionIndex, to know which direction the plane is currently taking.

First, we retrieve the aircraft’s movement pattern and store it as a reference to const named directions. We only proceed if there are movement patterns for the current type (otherwise the plane flies straight down).

void Aircraft::updateMovementPattern(sf::Time dt)
{
const std::vector<Direction>& directions
= Table[mType].directions;
if (!directions.empty())
{

Second, we check if the current direction has already been passed by the plane (that is, the travelled distance is higher than the direction’s distance). If so, the index is advanced to the next direction. The modulo operator allows a cycle; after finishing the last direction, the plane begins again with the first one.

float distanceToTravel
= directions[mDirectionIndex].distance;
if (mTravelledDistance > distanceToTravel)
{
mDirectionIndex
= (mDirectionIndex + 1) % directions.size();
mTravelledDistance = 0.f;
}

Now, we have to get a velocity vector out of the angle. First, we turn the angle by 90 degrees (by default, 0 degrees points to the right), but since our planes fly downwards, we work in a rotated coordinate system, such that we can use a minus to toggle between left/right. We also have to convert degrees to radians, using our function toRadian().

The velocity’s x component is computed using the cosine of the angle multiplied with the maximal speed; analogue for the y component, where the sine is used. Eventually, the travelled distance is updated:

float radians
= toRadian(directions[mDirectionIndex].angle + 90.f);
float vx = getMaxSpeed() * std::cos(radians);
float vy = getMaxSpeed() * std::sin(radians);
setVelocity(vx, vy);
mTravelledDistance += getMaxSpeed() * dt.asSeconds();
}
}

Note that if the distance to travel is no multiple of the aircraft speed, the plane will fly further than intended. This error is usually small, because there are many logic frames per second, and hardly noticeable, since each enemy will only be in the view for a short time.

Spawning enemies

It would be good if enemies were initially inactive, and the world created them as soon as they come closer to the player. By doing so, we do not need to process enemies that are relevant in the distant future; the scene graph can concentrate on updating and drawing active enemies.

We create a structure nested inside the World class that represents a spawn point for an enemy.

struct SpawnPoint
{
SpawnPoint(Aircraft::Type type, float x, float y);
Aircraft::Type type;
float x;
float y;
};

A member variable World::mEnemySpawnPoints of type std::vector<SpawnPoint> holds all future spawn points. As soon as an enemy position enters the battlefield, the corresponding enemy is created and inserted to the scene graph, and the spawn point is removed.

The World class member function getBattlefieldBounds(), returns sf::FloatRect to the battlefield area, similar to getViewBounds(). The battlefield area extends the view area by a small rectangle at the top, inside which new enemies spawn before they enter the view. If an enemy’s y coordinate lies below the battlefield’s top member, the enemy will be created at its spawn point. Since enemies face downwards, they are rotated by 180 degrees.

void World::spawnEnemies()
{
while (!mEnemySpawnPoints.empty()
&& mEnemySpawnPoints.back().y
> getBattlefieldBounds().top)
{
SpawnPoint spawn = mEnemySpawnPoints.back();
std::unique_ptr<Aircraft> enemy(
new Aircraft(spawn.type, mTextures, mFonts));
enemy->setPosition(spawn.x, spawn.y);
enemy->setRotation(180.f);
mSceneLayers[Air]->attachChild(std::move(enemy));
mEnemySpawnPoints.pop_back();
}
}

Now, let’s insert the spawn points. addEnemy() effectively calls mEnemySpawnPoints.push_back(), and interprets the passed coordinates relative to the player’s spawn position. After inserting all spawn points, we sort them by their y coordinates. By doing so, spawnEnemies() needs to check only the elements at the end of the sequence instead of iterating through it every time.

void World::addEnemies()
{
addEnemy(Aircraft::Raptor, 0.f, 500.f);
addEnemy(Aircraft::Avenger, -70.f, 1400.f);
...
std::sort(mEnemySpawnPoints.begin(), mEnemySpawnPoints.end(),
[] (SpawnPoint lhs, SpawnPoint rhs)
{
return lhs.y < rhs.y;
});
}

Here is an example of the player facing four Avenger enemies. Above each, you see how many hitpoints it has left.

Adding projectiles

Finally, time to add what makes a game fun. Shooting down stuff is essential for our game. The code to interact with the W orld class is already defined, thanks to the actions in Player and to the existing Entity base class. All that’s left is to define the projectiles themselves.

We start with the Projectile class. We have normal machine gun bullets and homing missiles represented by the same class. This class inherits from the Entity class and is quite small, since it doesn’t have anything special that differentiates it from other entities apart from collision tests, which we will talk about later.

class Projectile : public Entity
{
public:
enum Type
{
AlliedBullet,
EnemyBullet,
Missile,
TypeCount
};
public:
Projectile(Type type,
const TextureHolder& textures);
void guideTowards(sf::Vector2f position);
bool isGuided() const;
virtual unsigned int getCategory() const;
virtual sf::FloatRect getBoundingRect() const;
float getMaxSpeed() const;
int getDamage() const;
private:
virtual void updateCurrent(sf::Time dt,
CommandQueue& commands);
virtual void drawCurrent(sf::RenderTarget& target,
sf::RenderStates states) const;
private:
Type mType;
sf::Sprite mSprite;
sf::Vector2f mTargetDirection;
};

Nothing fun or exciting here; we add some new helper functions such as the one to guide the missile towards a target. So let’s have a quick look at the implementation. You might notice, we use the same data tables that we used in the Aircraft class to store data.

Projectile::Projectile(Type type, const TextureHolder& textures)
: Entity(1)
, mType(type)
, mSprite(textures.get(Table[type].texture))
{
centerOrigin(mSprite);
}

The constructor simply creates a sprite with the texture we want for the projectile. We will check out the guide function when we actually implement the behavior of missiles. The rest of the functions don’t hold anything particularly interesting. Draw the sprite and return a category for the commands and other data needed.

To get an overview of the class hierarchy in the scene graph, here is an inheritance diagram of the current scene node types. The data table structures which are directly related to their corresponding entities are shown at the bottom of the following diagram:

Firing bullets and missiles

So let’s try and shoot some bullets in the game. We start with adding two new actions in the Player class: Fire and LaunchMissile. We define the default key bindings for these to be the Space bar and M keys.

Player::Player()
{
// Set initial key bindings
mKeyBinding[sf::Keyboard::Left] = MoveLeft;
mKeyBinding[sf::Keyboard::Right] = MoveRight;
mKeyBinding[sf::Keyboard::Up] = MoveUp;
mKeyBinding[sf::Keyboard::Down] = MoveDown;
mKeyBinding[sf::Keyboard::Space] = Fire;
mKeyBinding[sf::Keyboard::M] = LaunchMissile;
// ...
}
void Player::initializeActions()
{
// ...
mActionBinding[Fire].action = derivedAction<Aircraft>(
std::bind(&Aircraft::fire, _1));
mActionBinding[LaunchMissile].action =derivedAction<Aircraft>(
std::bind(&Aircraft::launchMissile, _1));
}

So when we press the keys bound to those two actions, a command will be fired which calls the aircraft’s fire() and launchMissile() functions. However, we cannot put the actual code that fires the bullet or missile in those two functions. The reason is, because if we could, we would have no concept of how much time has elapsed. We don’t want to fire a projectile for every frame. We want there to be some cool down until the next time we fire a bullet, to accomplish that we need to use the delta time passed in the aircraft’s update() function.

Instead, we mark what we want to fire by setting the Boolean flags mIsFiring or mIsLaunchingMissile to true in the Aircraft::fire() and the Aircraft::launchMissile() functions, respectively. Then we perform the actual logic in the update() function using commands. In order to make the code clearer to read, we have extracted it to its own function.

void Aircraft::checkProjectileLaunch(sf::Time dt, CommandQueue&
commands)
{
if (mIsFiring && mFireCountdown <= sf::Time::Zero)
{
commands.push(mFireCommand);
mFireCountdown += sf::seconds(1.f / (mFireRateLevel+1));
mIsFiring = false;
}
else if (mFireCountdown > sf::Time::Zero)
{
mFireCountdown -= dt;
}
if (mIsLaunchingMissile)
{
commands.push(mMissileCommand);
mIsLaunchingMissile = false;
}
}

We have a cool down for the bullets. When enough time has elapsed since the last bullet was fired, we can fire another bullet. The actual creation of the bullet is done using a command which we will look at later. After we spawn the bullet, we reset the countdown. Here, we use += instead of =; with a simple assignment, we would discard a little time remainder in each frame, generating a bigger error as time goes by. The time of the countdown is calculated using a member variable mFireCountdown in Aircraft. Like that, we can improve the aircraft’s fire rate easily. So if the fire rate level is one, then we can fire a bullet every half a second, increase it to level two, and we get every third of a second. We also have to remember to keep ticking down the countdown member, even if the user is not trying to fire. Otherwise, the countdown would get stuck when the user released the Space bar.

Next is the missile launch. We don’t need a countdown here, because in the Player class, we made the input an event-based (not real-time based) input.

bool Player::isRealtimeAction(Action action)
{
switch (action)
{
case MoveLeft:
case MoveRight:
case MoveDown:
case MoveUp:
case Fire:
return true;
default:
return false;
}
}

Since the switch statement does not identify LaunchMissile as a real-time input, the user has to release the M key before he can shoot another missile. The user wants to save his missiles for the moment he needs them.

So, let’s look at the commands that we perform, in order to actually shoot the projectiles. We define them in the constructor in order to have access to the texture holder. This shows one of the strengths of lambda expressions in C++11.

Aircraft::Aircraft(Type type, const TextureHolder& textures)
{
mFireCommand.category = Category::SceneAirLayer;
mFireCommand.action =
[this, &textures] (SceneNode& node, sf::Time)
{
createBullets(node, textures);
};
mMissileCommand.category = Category::SceneAirLayer;
mMissileCommand.action =
[this, &textures] (SceneNode& node, sf::Time)
{
createProjectile(node, Projectile::Missile, 0.f, 0.5f,
textures);
};
}

Now, we can pass the texture holder to the projectiles without any extra difficulty, and we don’t even have to keep an explicit reference to the resources. This makes the Aircraft class and our code a lot simpler, since the reference does not need to exist in the update() function.

The commands are sent to the air layer in the scene graph. This is the node where we want to create our projectiles. The missile is a bit simpler to create than bullets, that’s why we call directly Aircraft::createProjectile(). So how do we create bullets then?

void Aircraft::createBullets(SceneNode& node, const TextureHolder&
textures) const
{
Projectile::Type type = isAllied()
? Projectile::AlliedBullet : Projectile::EnemyBullet;
switch (mSpreadLevel)
{
case 1:
createProjectile(node, type, 0.0f, 0.5f, textures);
break;
case 2:
createProjectile(node, type, -0.33f, 0.33f, textures);
createProjectile(node, type, +0.33f, 0.33f, textures);
break;
case 3:
createProjectile(node, type, -0.5f, 0.33f, textures);
createProjectile(node, type, 0.0f, 0.5f, textures);
createProjectile(node, type, +0.5f, 0.33f, textures);
break;
}
}

For projectiles, we provide different levels of fire spread in order to make the game more interesting. The player can feel that progress is made, and that his aircraft becomes more powerful as he is playing. The function calls createProjectile() just as it was done for the missile.

So how do we actually create the projectile and attach it to the scene graph?

void Aircraft::createProjectile(SceneNode& node,
Projectile::Type type, float xOffset, float yOffset,
const TextureHolder& textures) const
{
std::unique_ptr<Projectile> projectile(
new Projectile(type, textures));
sf::Vector2f offset(
xOffset * mSprite.getGlobalBounds().width,
yOffset * mSprite.getGlobalBounds().height);
sf::Vector2f velocity(0, projectile->getMaxSpeed());
float sign = isAllied() ? -1.f : +1.f;
projectile->setPosition(getWorldPosition() + offset * sign);
projectile->setVelocity(velocity * sign);
node.attachChild(std::move(projectile));
}

We create the projectile with an offset from the player and a velocity required by the projectile type. Also, depending on if this projectile is shot by an enemy or the player, we will have different directions. We do not want the enemy bullets to go upwards like the player’s bullets or the other way around.

Implementing gunfire for enemies is now a tiny step; instead of calling fire() when keys are pressed, we just call it always. We do this by adding the following code to the beginning of the checkProjectileLaunch() function:

if (!isAllied())
fire();

Now we have bullets that fly and split the sky.

Homing missiles

What would a modern aircraft be if it hadn’t got an arsenal of homing missiles? This is where we start to add intelligence to our missiles; they should be capable of seeking enemies autonomously.

Let’s first look at what we need to implement on the projectile site. For homing missiles, the functions guideTowards() and isGuided(), as well as the variable mTargetDirection are important. Their implementation looks as follows:

bool Projectile::isGuided() const
{
return mType == Missile;
}
void Projectile::guideTowards(sf::Vector2f position)
{
assert(isGuided());
mTargetDirection = unitVector(position - getWorldPosition());
}

The function unitVector() is a helper we have written. It divides a vector by its length, thus, always returns a vector of length one. The target direction is therefore a unit vector headed towards the target.

In the function updateCurrent(), we steer our missile. We change the current missile’s velocity by adding small contributions of the target direction vector to it. By doing so, the velocity vector continuously approaches the target direction, having the effect that the missile flies along a curve towards the target.

approachRate is a constant that determines, to what extent the target direction contributes to the velocity. newVelocity, which is the weighted sum of the two vectors, is scaled to the maximum speed of the missile. It is assigned to the missile’s velocity, and its angle is assigned to the missile’s rotation. We use +90 here, because the missile texture points upwards (instead of right).

void Projectile::updateCurrent(sf::Time dt,
CommandQueue& commands)
{
if (isGuided())
{
const float approachRate = 200.f;
sf::Vector2f newVelocity = unitVector(approachRate
* dt.asSeconds() * mTargetDirection + getVelocity());
newVelocity *= getMaxSpeed();
float angle = std::atan2(newVelocity.y, newVelocity.x);
setRotation(toDegree(angle) + 90.f);
setVelocity(newVelocity);
}
Entity::updateCurrent(dt, commands);
}

Note that there are many possibilities to guide a missile. Steering behaviors define a whole field of AI; they incorporate advanced mechanisms such as evasion, interception, and group behavior. Don’t hesitate to search on the internet if you’re interested.

Now, we have guided the missile to a certain position, but how to retrieve that position? We want our missile to pursuit the closest enemy. For this, we switch from Projectile to the World class, where we write a new function. First, we store all currently active (that is, already spawned and not yet destroyed) enemies in the member variable mActiveEnemies. With the command facility, this task is almost trivial:

void World::guideMissiles()
{
Command enemyCollector;
enemyCollector.category = Category::EnemyAircraft;
enemyCollector.action = derivedAction<Aircraft>(
[this] (Aircraft& enemy, sf::Time)
{
if (!enemy.isDestroyed())
mActiveEnemies.push_back(&enemy);
});

Next, we have to find the nearest enemy for each missile. We set up another command, now for projectiles, that iterates through the active enemies to find the closest one. Here, distance() is a helper function that returns the distance between the centers of two scene nodes.

Command missileGuider;
missileGuider.category = Category::AlliedProjectile;
missileGuider.action = derivedAction<Projectile>(
[this] (Projectile& missile, sf::Time)
{
// Ignore unguided bullets
if (!missile.isGuided())
return;
float minDistance = std::numeric_limits<float>::max();
Aircraft* closestEnemy = nullptr;
FOREACH(Aircraft* enemy, mActiveEnemies)
{
float enemyDistance = distance(missile, *enemy);
if (enemyDistance < minDistance)
{
closestEnemy = enemy;
minDistance = enemyDistance;
}
}

In case we found a closest enemy, we let the missile chase it.

if (closestEnemy)
missile.guideTowards(
closestEnemy->getWorldPosition());
});

After defining the second command, we push both to our queue, and reset the container of active enemies. Remember that the commands are not yet executed, they wait in the queue until they are invoked on the scene graph in World::update().

mCommandQueue.push(enemyCollector);
mCommandQueue.push(missileGuider);
mActiveEnemies.clear();
}

That’s it, now we are able to fire and forget!

The result looks as follows:

Picking up some goodies

Now we have implemented enemies and projectiles. But even if the player shot enemy airplanes down, and had exciting battles, he wouldn’t remark that his success changes anything. You want to give the player the feeling that he is progressing in the game. Usual for this game genre are power-ups that the enemies drop when they are killed. So let’s go ahead and implement that in our game.

Now this is the same story as with the projectile. Most of the things we need have already been implemented; therefore, this will be quite easy to add. What we want is only an entity that, when the player touches it, applies an effect to the player and disappears. Not much work with our current framework.

class Pickup : public Entity
{
public:
enum Type
{
HealthRefill,
MissileRefill,
FireSpread,
FireRate,
TypeCount
};
public:
Pickup(Type type,
const TextureHolder& textures);
virtual unsigned int getCategory() const;
virtual sf::FloatRect getBoundingRect() const;
void apply(Aircraft& player) const;
protected:
virtual void drawCurrent(sf::RenderTarget& target,
sf::RenderStates states) const;
private:
Type mType;
sf::Sprite mSprite;
};

So, let’s start looking at a few interesting parts. As usual, we have a data table, create a sprite and center it, so the constructor looks just as you would expect it. Let’s investigate the apply() function, and how the data table is created. In apply(), a function object stored in the table is invoked with player as argument. The initializePickupData() function initializes the function objects, using std::bind() that redirects to the Aircraft member functions.

void Pickup::apply(Aircraft& player) const
{
Table[mType].action(player);
}
std::vector<PickupData> initializePickupData()
{
std::vector<PickupData> data(Pickup::TypeCount);
data[Pickup::HealthRefill].texture = Textures::HealthRefill;
data[Pickup::HealthRefill].action
= std::bind(&Aircraft::repair, _1, 25);
data[Pickup::MissileRefill].texture = Textures::MissileRefill;
data[Pickup::MissileRefill].action
= std::bind(&Aircraft::collectMissiles, _1, 3);
data[Pickup::FireSpread].texture = Textures::FireSpread;
data[Pickup::FireSpread].action
= std::bind(&Aircraft::increaseSpread, _1);
data[Pickup::FireRate].texture = Textures::FireRate;
data[Pickup::FireRate].action
= std::bind(&Aircraft::increaseFireRate, _1);
return data;
}

The pickups call already defined functions on the player aircraft that let us modify its state. These functions may repair it, refill it with missiles, or improve its firepower. It’s nice when things just work out of the box.

That’s how the scene looks when two pickups (health and fire rate) are floating in the air. You may notice that the player’s Eagle plane shoots two bullets at once, which is the result of a previously collected fire spread pickup.

LEAVE A REPLY

Please enter your comment!
Please enter your name here