Top Down Shoot Em Up Mechanics - Part 1

 

So, everyone has played these types of games right? Anything made by Eugene Jarvis during his tenure at Williams Electronics in the 80's is a pretty good example (think Smash TV, Robotron, etc.). The cornerstone of this genre is hoards of enemies that follow you around and try to pulverize you. Usually these games have a bullet hell component to them as well, making your chances of survival nil, and securing your allowance which was most likely liquidated to quarters in the first place.

At the end of the series, I'll post the entire source repository of the complete game along with the resources. First, we'll tackle a fairly easy mechanic - the hoards that follow.

Whether it's zombies, robots, legions of obsessed fans, whatever you imagine, you'll need to model the experience in code. As you might have thought, it is fairly simple to acheive, in a naive sense. You will basically only need two pieces of data to make the calculations - the enemy position and the player position - and then it is just a *pinch* of math for the rest. Here is what the footage looks like:

First, you will need to compute the hypotenuse of the triangle representing the distance vector between the enemy and the player using Pythagoras' Theorem - which will give you the length of the vector. Assuming the enemy is located at 0,0 (in cartesian coordinates) and the player is located at 100,100 - the length will be the square root of 100^2 + 100^2 or ~141. You will then need to take what is called the "unit vector" of the target position (in this case the player) minus the enemy position. Put simply, the unit vector is a vector of length 1 - in other words, if you were to take the length of the resulting vector it would be 1. This will be used to determine the direction in both x and y coordinates the enemy needs to travel in order to reach its goal. In this case the unit vector will yeild a result of ~.71,.71, meaning the enemy must travel positively on both the x and y axes. A quick check of the length (square root of .71 + .71) gives you a result of ~1, it is indeed a unit vector. If my explanation is just not getting through, try watching this video for a great explanation.

Codewise, we'll need a few utility functions for vector length, unit vector, and also a function to convert radians to degrees, since SFML setRotation() methods work with degrees. Since I've read the SFML Game Development book, I knew that these functions have already been written, so I snagged them from the SFML github repo for this tutorial:


float toDegree(float radian)
{
    return 180.f / 3.141592653589793238462643383f * radian;
}

float length(sf::Vector2f vector)
{
    return std::sqrt(vector.x * vector.x + vector.y * vector.y);
}

sf::Vector2f unitVector(sf::Vector2f vector)
{
    assert(vector != sf::Vector2f(0.f, 0.f));
    return vector / length(vector);
}

Most of this is boilerplate, just set up for the window and a couple rectangles we'll use as the player and the enemy to keep everything simple:


#include <iostream>
#include <SFML/Graphics.hpp>
#include <GeneralUtilities.hpp>

sf::Vector2f direction(sf::RectangleShape& follower, sf::Vector2f target);

int main()
{
    sf::VideoMode desktop = sf::VideoMode::getDesktopMode();
    sf::RenderWindow window(desktop, "Follow me!!", sf::Style::Fullscreen);
    sf::Event e;

    sf::RectangleShape player;
    player.setFillColor(sf::Color(200,0,0,255));
    player.setSize(sf::Vector2f(50.f,50.f));
    player.setPosition(window.getSize().x/2.f, window.getSize().y - player.getLocalBounds().height);

    sf::RectangleShape enemy;
    enemy.setFillColor(sf::Color(0,200,0,255));
    enemy.setSize(sf::Vector2f(50.f,50.f));
    enemy.setPosition(0.f, enemy.getLocalBounds().height);

    sf::Font myfont;
    if(!myfont.loadFromFile("resources/fonts/contb.ttf"))
    {
        std::cerr<<"Could not load font"<<std::endl;
    }

    sf::Text status("Chasing....", myfont);
    status.setPosition(window.getSize().x/2.f, 0.f);
    status.setCharacterSize(32);
    status.setColor(sf::Color(200,100,0,255));

    sf::Time timePerFrame = sf::seconds(1.f/60.f);
    sf::Clock clock;
    sf::Time timeElapsed = sf::Time::Zero;
    sf::Time duration = sf::Time::Zero;

There are some time and clock variables so that we can fix the timestep to 60 fps. The event loop is not exciting, just some key press events for player motion:


while(window.isOpen())
    {
        while(window.pollEvent(e))
        {
            if(e.type == sf::Event::Closed)
            {
                window.close();
                return 0;
            }

            if(e.type == sf::Event::KeyPressed)
            {
                switch(e.key.code)
                {
                case sf::Keyboard::Escape:
                    {
                        window.close();
                        return 0;
                    }
                    break;

                case sf::Keyboard::Left:
                {
                    player.move(-10.f,0.f);
                }
                break;

                case sf::Keyboard::Right:
                {
                    player.move(10.f,0.f);
                }
                break;

                case sf::Keyboard::Up:
                {
                    player.move(0.f,-10.f);
                }
                break;

                case sf::Keyboard::Down:
                {
                    player.move(0.f,10.f);
                }
                break;

                default:
                    break;
                }
            }
        }

After the event loop is where most of the action is. Here are the main updates:


timeElapsed += clock.restart();
        while (timeElapsed > timePerFrame)
        {
            timeElapsed -= timePerFrame;

            //update position of follower

            sf::Vector2f target = direction(enemy,player.getPosition());
            sf::Vector2f velocity = unitVector(sf::Vector2f(timePerFrame.asSeconds() + target.x, timePerFrame.asSeconds() + target.y));
            float angle = std::atan2(velocity.y, velocity.x);

            velocity *= 2.f;

            if(enemy.getPosition().y >= player.getPosition().y
               && enemy.getPosition().y < player.getPosition().y + player.getLocalBounds().height
               && enemy.getPosition().x >= player.getPosition().x
               && enemy.getPosition().x < player.getPosition().x + player.getLocalBounds().width)
                status.setString("Gotcha!");
            else
            {
                status.setString("Chasing....");
                enemy.move(velocity);
                enemy.setRotation(toDegree(angle));
            }
        }

        window.clear();
        window.draw(player);
        window.draw(enemy);
        window.draw(status);
        window.display();
    }
    return 0;
}

The first part of interest is the computation for the "target" vector. The target is computed based on the "direction" function, which simply returns a positive or negative unit vector (vector of length 1) to guide the enemy in the direction of the target vector supplied:


sf::Vector2f direction(sf::RectangleShape& follower, sf::Vector2f target)
{
    return unitVector(sf::Vector2f(target - follower.getPosition()));
}

The next segment of code computes the velocity, which is the unit vector of the frame time (1/60 or 60 fps) plus the target vector. This will guide the enemy by supplying a positive or negative value based on the target's location. We can then multiply the resulting vector by any scalar value (in this case multiplying by 2 is sufficient) and update the velocity accordingly. For this particular application, we only do updates to the enemy if it is not already intersecting the player. If it is not, then we supply the rectangle representing the enemy with the velocity vector.

Also of note, is the rotation update. This requires a bit more explanation - some trigonometry is required. As you can see above, we used angle = std::atan2(velocity.y, velocity.x) to get the angle the enemy should rotate to in order to face towards its target. We know from geometry that the tangent of a right triangle is the slope or opposite/adjacent. So, basically, when we take the arctan of our velocity coordinates, the question is, what angle gives us the slope of the coordinates supplied? Then we feed that resulting angle to the setRotation method of the enemy. Easy as pie. For a great explanation of arctan, check out this video.

For a better look at the numbers in real time, just download the code and print the outputs. Enjoy!