Creating Simple Group Steering Behavior for Your Games

There are a multitude of resources out there that discuss game entity behavior, and even a library for C++ - Open Steer - that implements many steering and group behaviors for game entities. However, a simple game calls for a simple implementation. ;)

In this tutorial, I'll expose how I designed my wave of enemy ships for N.A.S.I.C.. To understand what we are trying to accomplish here, remember that game Space Invaders? Well, in Space Invaders, the enemies behave collectively - basically, the algorithm is move left, drop down one level, move right, drop down one level, rinse and repeat. Additionally, the alien ships appear to fire at you from random enemies within the hive. My implementation is similar to that, with a couple departures from the theme. Here is the definition of the enemyWave class:


#ifndef ENEMYWAVE_HPP
#define ENEMYWAVE_HPP

#include <enemy.hpp>
#include <ammo.hpp>
#include <player.hpp>
#include <interpolate.hpp>

namespace nasic
{
    //forward declaration of nasic::player
    //to avoid circular references (player includes enemyWave)
    class player;

    class enemyWave : public sf::Drawable, public sf::Transformable
    {
    public:
        enemyWave();
        ~enemyWave();

        void init(sf::RenderWindow& window, int rows, int columns, float scale);
        bool isEmpty(){return m_enemies.size() <= 0;};
        bool initStatus(){return m_enemyInitStatus;};
        void setInitStatus(bool b){m_enemyInitStatus = b;};
        void updateEnemies(sf::Time dt, float scale);
        void initProjectiles(sf::Time dt, float scale);

        sf::Uint32 checkPlayerCollisions(sf::RenderWindow& window, nasic::player& hero, float scale, sf::Sprite& explosion, sf::Sound& explode, sf::SoundBuffer& buffer, thor::Animator<sf::Sprite, std::string>& explosionAnim, std::string animation);
        void debugClearEnemies(){m_enemies.clear();};
        void purgeEnemies();

        void animate(sf::Time dt);
        void draw(sf::RenderTarget& target, sf::RenderStates states) const;

        enum hitList
        {
            miss = 0,
            agravu = 1,
            delsiriak = 2,
            gluorn = 3,
            rhiians = 4,
        };

    private:
        void updateProjectiles(sf::Time dt);

    public:
        std::list<nasic::enemy> m_enemies;//to give player class access for collision detection

    private:

        nasic::enemy* m_enemy;

        float m_offset;
        int m_dir;
        bool m_enemyInitStatus;

        nasic::ammo* m_ammoPtr;
        std::list<nasic::ammo> m_enemyAmmo;
        std::list<nasic::ammo>::iterator m_eAmmoIt;

        sf::Time m_enemyMoveFrames;
        sf::Time m_enemyFireFrames;
    };
}

#endif // ENEMYWAVE_HPP

First of all, there is a forward declaration to the "player" class. This is a necessary evil in my design (or lack thereof, in this case), because both the player class and the enemyWave class #include each other. Because I chose not to decouple collision detection from the game entities themselves, an interesting choice by the way, a forward declaration is necessary. Failing to use a forward declaration here results in the compiler whining that the player class is undefined. If your game is complicated at all, you will definitely want to have a separate class for collision detection. And really, for me, it was just a matter of laziness, and I would likely have to write another article to explain what was happening - and I just don't have the time.

In another fit of laziness, I made the enemy list (std::list<nasic::enemy> m_enemies) a public data member - in practice you would want to offer the user of this class an interface instead of handing over what should be *private* data, willy-nilly. There are also two data members that are pointers, this can get messy if you are not careful and is generally not an accepted practice without a copy constructor and assignment operator defined. See this article and this flame war for more details about "The Rule of 3" and RAII, respectively. However, I did take care to delete the pointers in the destructor of enemyWave. At least I did you a solid there. ;)

There is an init(sf::RenderWindow& window, int rows, int columns, float scale) method which builds the enemy grid based on the number given for rows and columns. Think of the enemy grid as a spreadsheet, if that helps, where each cell is an enemy of a specific type. You'll notice in the implementation of this method below, that the data are pushed into a std::list container via a 2-D array. This is so that we can position the enemies on the screen in a manner that is easy to understand. For each row, where row is denoted as "i", there are enemies of a specific type (agravu, delsiriak, gluorn, rhiians - indicated in the enemyType enum in enemy.hpp). For each column, denoted as "j" in the implementation, a unique starting position is given to each enemy. The enemy pointer is then dereferenced as it is pushed into the std::list of enemies. Voila, the enemy grid has been initialized. There is also a method in the enemy class (see enemy.hpp in the full source for N.A.S.I.C.) that sets the enemy's initial position. When I initially set out to code the behavior for the enemyWave class, I had planned on doing some Galaga-style motion, where the enemy pops out of the grid, flies at the player on a predetermined path, and then returns to its original place in formation. Time did not permit me to hack that together, so this method is merely an artifact of my broken dreams of including a Galaga tribute *sigh*. Perhaps I'll do this another time in a separate tutorial.

The next method of interest is nasic::enemyWave::updateEnemies(sf::Time dt, float scale), which handles the motion of the enemies and their corresponding projectiles. It is pretty easy to understand. First, you must make sure the enemy list contains enemies before you perform an operation on them - so in the level.cpp, there will be a call to the isEmpty() method, which returns a boolean based on the size of the enemy list. As long as it is not empty, a call to updateEnemies(sf::Time dt, float scale) in your game loop results in a grid of moving enemies that fire downward from random enemies in the grid. I used the interpolate::backEaseOut to simulate a sort of sudden but floaty and life-like quality to the enemy grid. You'll notice the only  magic in here is the interpolated movement guided by the m_enemyFrames timekeeping, which resets every 5 seconds to perform the motion. Multiplying m_dir by -1 every time the time resets to zero allows the motion of the grid to change from left to right. Finally, there is a call at the bottom to updateProjectiles(dt), which simply updates the motion of the shots being fired from random enemies in the grid. As you can probably guess, they aren't the smartest bunch, but you'd be surprised how much mileage you might get out of simple automatons with a hint of random behavior.

The next block of code to look at is the nasic::enemyWave::initProjectiles(sf::Time dt, float scale) function. This block does a couple things that are fun - it selects a random enemy from the list every second (rand() is seeded in the level.cpp file, by the way), does a switch-case on the random enemy based on its type (randEnemyIt->getType(), which returns an sf::Uint32 value corresponding to an enum type in enemy.hpp), and pushes back some ammo based on the random enemy's location and type. The types are somewhat arbitrary (agravu, delsiriak, gluorn, rhiians) and were created with some random alien name generator I found out there on the internets. But based on the type, a different kind of ammo is pushed to the enemyWave ammo list. After all that, the frame count that will determine when the ammo will be fired is reset to zero (m_enemyFireFrames). If think back and remember from the last paragraph, there was a call to updateAmmo(dt) in the nasic::enemyWave::updateEnemies(sf::Time dt, float scale) function. If you look at the code in this function, you will see that all it does is check the ammo list to see if it contains ammo, then iterates throught the list and calls the fire(dt) function on each element in the list - which happens to be one every second.

The nasic::enemyWave::animate(sf::Time dt) and nasic::enemyWave::draw(sf::RenderTarget& target, sf::RenderStates states) functions are pretty boring - just iterate the enemy list container and call the enemy::animate(sf::Time dt) function on each element. The draw function you just need to remember to use std::list<nasic::enemy>::const_iterator and std::list<nasic::ammo>::const_iterator, because when you inherit from sf::Drawable it expects your drawables to be const.

The messiest part is the sf::Uint32 nasic::enemyWave::checkPlayerCollisions method. I'm not even going to discuss that here because I'm saving this for part 5 of the main N.A.S.I.C. tutorial series. It will make more sense in that context, seeing as how it will check for collisions between the enemy fire and the player and return a value corresponding to the enemy type to deal damage to the player.

If you really want to see the enemy wave in action without compiling the project, just watch this video and fast forward to the good parts. ;)



I think I've explained a good bit of it, and I'll finish in the 5th installment of the N.A.S.I.C. tutorial series, but for now here is the class definition in full:


#include <enemyWave.hpp>

nasic::enemyWave::enemyWave()
{

}

nasic::enemyWave::~enemyWave()
{
    //delete the member pointers on destruction of the enemy wave
    delete m_enemy;
    delete m_ammoPtr;
}

void nasic::enemyWave::init(sf::RenderWindow& window, int rows, int columns, float scale)
{
    float offset;

    //fill the grid w/ enemies
    for(int i=0; i<rows; ++i)
    {
        for(int j=0; j<columns; ++j)
        {
            switch(i)
            {
            case 0:
            {
                m_enemy = new nasic::enemy(nasic::enemy::enemyType::Rhiians, scale);
                m_enemy->setId(sf::Vector2u(i,j));
                offset = scale*50.f;
                m_enemy->setInitialPosition(sf::Vector2f(j * offset + window.getSize().x/16.f, i * offset + offset/2.f));
                m_enemy->setPosition(j * offset + window.getSize().x/16.f, i * offset + offset/2.f);
                m_enemies.push_back(*m_enemy);
            }
            break;

            case 1:
            {
                m_enemy = new nasic::enemy(nasic::enemy::enemyType::Agravu, scale);
                m_enemy->setId(sf::Vector2u(i,j));
                offset = scale*50.f;
                m_enemy->setInitialPosition(sf::Vector2f(j * offset + window.getSize().x/16.f, i * offset + offset/2.f));
                m_enemy->setPosition(j * offset + window.getSize().x/16.f, i * offset + offset/2.f);
                m_enemies.push_back(*m_enemy);
            }
            break;

            case 2:
            {
                m_enemy = new nasic::enemy(nasic::enemy::enemyType::Delsiriak, scale);
                m_enemy->setId(sf::Vector2u(i,j));
                offset = scale*50.f;
                m_enemy->setInitialPosition(sf::Vector2f(j * offset + window.getSize().x/16.f, i * offset + offset/2.f));
                m_enemy->setPosition(j * offset + window.getSize().x/16.f, i * offset + offset/5.f);
                m_enemies.push_back(*m_enemy);
            }
            break;

            case 3:
            {
                m_enemy = new nasic::enemy(nasic::enemy::enemyType::Gluorn, scale);
                m_enemy->setId(sf::Vector2u(i,j));
                offset = scale*50.f;
                m_enemy->setInitialPosition(sf::Vector2f(j * offset + window.getSize().x/16.f, i * offset + offset/2.f));
                m_enemy->setPosition(j * offset + window.getSize().x/16.f, i * offset + offset/6.f);
                m_enemies.push_back(*m_enemy);
            }
            break;

            default:
                break;
            }
        }
    }

    m_dir = 1;//set the direction of the enemy hoard to move right
    m_enemyMoveFrames = sf::Time::Zero;//reset the frames controlling movement
    m_enemyFireFrames = sf::Time::Zero;//reset the frames controlling enemy fire
    setInitStatus(true);//initialization is done!
}

void nasic::enemyWave::updateEnemies(sf::Time dt, float scale)
{
    //frame updates for enemy movement
    if(m_enemyMoveFrames.asSeconds() >= 5.f)
    {
        m_enemyMoveFrames = sf::Time::Zero;
        m_dir *= -1;//switch directions...
        //std::cout<<"direction: "<<m_dir<<std::endl;
    }

    else
    {
        m_enemyMoveFrames += dt;
    }

    //movement for enemies
    std::list<nasic::enemy>::iterator enemIt;
    for(enemIt = m_enemies.begin(); enemIt != m_enemies.end(); ++enemIt)
    {
        //perform animation/state updates
        (*enemIt).update(dt);

        //must multiply by the direction int to keep the
        //enemies oscillating back and forth
        if(m_enemyMoveFrames.asSeconds() < 5.f)
            (*enemIt).move(m_dir*((5*scale)*(interpolate::backEaseOut(m_enemyMoveFrames.asSeconds(), 0.f, 1.f, 5.f))), scale*m_enemyMoveFrames.asSeconds()*(float)m_dir/12.f);
    }

    updateProjectiles(dt);
}

void nasic::enemyWave::initProjectiles(sf::Time dt, float scale)
{
    m_enemyFireFrames += dt;

    //load up the enemy ammo vector every second
    if(m_enemyFireFrames.asSeconds() > 1.f && m_enemyAmmo.size() < 1)
    {
        int pickRandEnemy = rand()% m_enemies.size();
        std::list<nasic::enemy>::iterator randEnemyIt = m_enemies.begin();

        //advance the list iterator to a random enemy
        //in the list every second
        std::advance(randEnemyIt, pickRandEnemy);

        switch(randEnemyIt->getType())
        {
        case 0:
        {
            m_ammoPtr = new nasic::ammo(nasic::ammo::ammoType::agravu, sf::Vector2f(randEnemyIt->getPosition().x + randEnemyIt->getAABB().x/2.f, randEnemyIt->getPosition().y + randEnemyIt->getAABB().y/2.f), scale);
            m_enemyAmmo.push_back(*m_ammoPtr);
        }
        break;
        case 1:
        {
            m_ammoPtr = new nasic::ammo(nasic::ammo::ammoType::delsiriak, sf::Vector2f(randEnemyIt->getPosition().x + randEnemyIt->getAABB().x/2.f, randEnemyIt->getPosition().y + randEnemyIt->getAABB().y/2.f), scale);
            m_enemyAmmo.push_back(*m_ammoPtr);
        }
        break;
        case 2:
        {
            m_ammoPtr = new nasic::ammo(nasic::ammo::ammoType::gluorn, sf::Vector2f(randEnemyIt->getPosition().x + randEnemyIt->getAABB().x/2.f, randEnemyIt->getPosition().y + randEnemyIt->getAABB().y/2.f), scale);
            m_enemyAmmo.push_back(*m_ammoPtr);
        }
        break;
        case 3:
        {
            m_ammoPtr = new nasic::ammo(nasic::ammo::ammoType::rhiians, sf::Vector2f(randEnemyIt->getPosition().x + randEnemyIt->getAABB().x/2.f, randEnemyIt->getPosition().y + randEnemyIt->getAABB().y/2.f), scale);
            m_enemyAmmo.push_back(*m_ammoPtr);
        }
        break;
        default:
            break;
        }
        m_enemyFireFrames = sf::Time::Zero;
    }
}

void nasic::enemyWave::updateProjectiles(sf::Time dt)
{
    if(m_enemyAmmo.size() > 0)
    {
        for(m_eAmmoIt = m_enemyAmmo.begin(); m_eAmmoIt != m_enemyAmmo.end(); ++m_eAmmoIt)
        {
            m_eAmmoIt->fire(dt);
        }
    }
}

sf::Uint32 nasic::enemyWave::checkPlayerCollisions(sf::RenderWindow& window, nasic::player& hero, float scale, sf::Sprite& explosion, sf::Sound& explode, sf::SoundBuffer& buffer, thor::Animator<sf::Sprite, std::string>& explosionAnim, std::string animation)
{
    if(m_enemyAmmo.size() > 0)
    {
        std::list<nasic::ammo>::iterator screenAmmoIt;//check to see if enemy bullet left the screen...
        for(screenAmmoIt = m_enemyAmmo.begin(); screenAmmoIt != m_enemyAmmo.end(); ++screenAmmoIt)
        {
            if(screenAmmoIt->getPosition().y > window.getSize().y)
            {
                screenAmmoIt = m_enemyAmmo.erase(screenAmmoIt);//delete missed shots...

                return hitList::miss;
            }
        }

        std::list<nasic::ammo>::iterator ammoIt;
        for(ammoIt = m_enemyAmmo.begin(); ammoIt != m_enemyAmmo.end(); ++ammoIt)
        {
            if(ammoIt->getPosition().y >= hero.getPosition().y
                    && ammoIt->getPosition().y <= hero.getPosition().y + scale*hero.getAABB().y
                    && ammoIt->getPosition().x >= hero.getPosition().x
                    && ammoIt->getPosition().x <= hero.getPosition().x + scale*hero.getAABB().x)
            {
                //set the explosion position to the enemy location
                explosion.setPosition(ammoIt->getPosition().x, ammoIt->getPosition().y);

                //set the explosion sound buffer and play it
                explode.setBuffer(buffer);
                explode.play();

                //play the explosion animation
                explosionAnim.playAnimation(animation, false);

                ammoIt = m_enemyAmmo.erase(ammoIt);
                switch(ammoIt->getType())
                {
                case nasic::ammo::ammoType::agravu:
                {
                    return hitList::agravu;
                }
                break;

                case nasic::ammo::ammoType::delsiriak:
                {
                    return hitList::delsiriak;
                }
                break;

                case nasic::ammo::ammoType::gluorn:
                {
                    return hitList::gluorn;
                }
                break;

                case nasic::ammo::ammoType::rhiians:
                {
                    return hitList::rhiians;
                }
                break;

                default:
                    break;
                }
            }
            else
                return hitList::miss;
        }
    }
}

void nasic::enemyWave::purgeEnemies()
{
    m_enemies.clear();
    m_enemyAmmo.clear();
}

void nasic::enemyWave::animate(sf::Time dt)
{
    std::list>nasic::enemy>::iterator enemIt;
    for(enemIt = m_enemies.begin(); enemIt != m_enemies.end(); ++enemIt)
    {
        enemIt->animate(dt);
    }
}

void nasic::enemyWave::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
    states = getTransform();

    std::list<nasic::enemy>::const_iterator enemIt;
    for(enemIt = m_enemies.begin(); enemIt != m_enemies.end(); ++enemIt)
    {
        target.draw(*enemIt, states);
    }

    //draw enemy ammo
    std::list<nasic::ammo>::const_iterator eAmmoIt;
    for(eAmmoIt = m_enemyAmmo.begin(); eAmmoIt != m_enemyAmmo.end(); ++eAmmoIt)
    {
        target.draw(*eAmmoIt, states);
    }
}

As always, thank you for reading!