MaxCodes.info - Basic Shooting Mechanics

Shooting Goblins for Fun and Profit

Basic Shooting Mechanics in a 2D Environment


        Hello everyone, and welcome! I'm going to talk about an idea ive been kicking around for a little while -
enabling characters to be able to shoot, or in more general terms, launch objects at other object. I phrased
that fairly ambigulously on purpose, because what i'm going to show can be taken and applied to every thing
from side scrolling or top down shooter games, to launching arrows in a Rogue Like, heck, you could even use
it to code Pong. Point being, its widely applicaple, its a fun and "neat" feature to add to any game, and its
fairly simple to implement. For this tutorial the mechanics are assuming a grid based game.

I'm using the bearlibterminal for purposes of handling input and output, but the concepts shown are easily
adapted to NCURSES or what ever library you're most comfortable with. The only parts of the code that would
change is you handle input from the keyboard and how you represent objects on the screen.

Tracking Direction, or Inventing The Compass.

For the purposes of this example We'll create a character class that will have four basic member functions:
shoot(), move(), turnInPlace(), and render(). We'll also be using the same Point struct i've used in other examples since
this example is for grid based games. So without further ado, lets get to it!

                        struct Point {
                            int x, y;
                            bool blocks;
                            bool populated;
                            bool operator==(const Point& other) const {
                                return x == other.x && y == other.y;
                            }
                            bool operator!=(const Point& other) const {
                                return x != other.x || y != other.y;
                            }
                        };

                        class ent {
                            public:
                              std::unordered_map facing;
                              Point pos;
                              char faces;
                              char ch[2];
                              void move(World*, int x, int y);
                              void render();
                              void turnInPlace(bool dir);
                              bullet* shoot();
                              ent(int x, int y, int id, char ch);
                              ent();
                            };
                            
                    

To enable our character to shoot at items, the very first thing we need to know is "What direction is our character facing?"
I went back to my Langton's Ants project, because this issue was addressed for the algorithm which
decided the Ant's next move. The idea i hit upon used STL's unsorted_map. To keep things simple, our character can only shoot
in their four cardinal directions, for a grid based game this is fine, and it also lends nicely to the keys for an asosciative
array: N, S, E, W. by mapping each of these keys to a Point{dx,dy} we're left with a simple way of assigning which direction
are character is facing. I used the characters constructor for populating the unsorted_map:


                        ent::ent(int x, int y, int id, char ch)
                        {
 
                            facing['N'] = {0,-1};
                            facing['S'] = {0, 1};
                            facing['E'] = {1,0};
                            facing['W'] = {-1,0}; 
                            this->pos.x = x;
                            this->pos.y = y;
                            this->ch = ch;
                            this->faces = 'N';
                        }
                        
                    

Keeping Direction Current


Our next task is properly assigning our characters direction and updating it when direction changes. One way to accomplish
this is to make whatever direction our character is moving in be the direction that they are facing. It's a good place to start but
its not without its shortcomings which we shall discuss, but its as good a place as any to start lets take a look at how our characters
movements are controlled to get an idea of how this can be accomplished. Inside of our gameloop there is undoubtly a function for interpreting
key presses, and we'll be using the directional arrows for controlling our characters movements, part of what makes this work is having
our gameloop be non-blocking meaning the loop doesnt stop to wait for the player to make a turn. With that in mind our game loop
should look like this:

                        while(true)
                        {
                            terminal_clear();
                            if (terminal_has_input())
                            {
  
                                keypress=terminal_read();
                                switch (keypress)
                                {
                                    case TK_UP: player->move(Map,0,-1); break;
                                    case TK_DOWN: player->move(Map,0,1); break;
                                    case TK_LEFT: player->move(Map,-1,0); break;
                                    case TK_RIGHT: player->move(Map,1,0); break;
                                    default: break;
                                }
                            }
                            render(Map);
                            player->render();
                            terminal_refresh();
                        }             
                    

Now take a look at the player->move() functions, and take a looking at the facing[] array above and something should be come apparent..
The x/y arguments passed to our players move() function are the same as the values we assigned to each direction for our facing[] array!
How fortuitous. Let's examine move() to see what our next step should be.

                    void ent::move(World* Map, int dx, int dy)
                    {
                        if (canMove(Map, this->pos.x + dx,this->pos.y + dy))
                        {
                            Map->layout[pos.x][pos.y].blocks = false;
                            Map->layout[pos.x][pos.y].populated = false;
                            this->pos.x += dx;
                            this->pos.y += dy;
                            Map->layout[pos.x][pos.y].blocks = true;
                            Map->layout[pos.x][pos.y].populated = true;
                        }
                    }

                    bool ent::canMove(World* Map, int x, int y)
                    {
                        if (Map->layout[x][y].blocks == false && Map->layout[x][y].populated == false) {
                            return true;
                        }
                        return false;
                    }
                    

Since our move function is passed a dx and dy that match our directions laid out in facing[] all we need to do is iterate over the array until
we find a matching dx/dy and assign its corresponding direction to our players "faces" value. STL's unsorted_map doesnt have the prettiest interface
for doing things this way, but it works. our new move() function should look like this:


                        void ent::move(World* Map, int dx, int dy)
                        {
                            for (auto dir : facing)  //facing is our unsorted_map
                                if (dir.second.x == dx && dir.second.y == dy)  //dir.second is the {dx,dy} Point
                                {
                                    facing = dir.first;                     //dir.first is its associated char
                                }
                            } 
                            if (canMove(Map, this->pos.x + dx,this->pos.y + dy))
                            {
                                Map->layout[pos.x][pos.y].blocks = false;
                                Map->layout[pos.x][pos.y].populated = false;
                                this->pos.x += dx;
                                this->pos.y += dy;
                                Map->layout[pos.x][pos.y].blocks = true;
                                Map->layout[pos.x][pos.y].populated = true;
                            }
                        }
                    

Turning in Place


So now our character is facing what ever direction they're moving in. It's a step in the right direction, but one of the major issues should become
immediatly appearent. What if we dont want to move to change our direction? what if we want to run backwards? We'll handle this by using a "modifier".
holding down the shift key and hitting an arrow key is not the same input as just pressing the arrow key (obviously) so we can change the loop where we
handle input from the keyboard to check the state of the shiftkey and respond differently to Shift+Arrow keys than it does without pressing Shift.:

                        while(true)
                        {
                            terminal_clear();
                            if (terminal_has_input())
                            {
                                keypress=terminal_read();
                                if (!terminal_state(TK_SHIFT))
                                {
                                    switch (keypress)
                                    {
                                        case TK_UP: player->move(Map,0,-1); break;
                                        case TK_DOWN: player->move(Map,0,1); break;
                                        case TK_LEFT: player->move(Map,-1,0); break;
                                        case TK_RIGHT: player->move(Map,1,0); break;
                                        default: break;
                                    }
                                } else {
                                    switch(k)
                                    {
      	                                case TK_LEFT:  player->turn(false); break;
      	                                case TK_RIGHT: player->turn(true); break;
                                        default: break;
                                    }
                                }
                                render(Map);
                                player->render();
                            terminal_refresh();
                        }             
                    

Now our arrow keys produce different effects depending on if the shift key is being pressed or not. The turn() function take a boolean value as a parameter:
false for left, true for right, this dictates wheter the direction changes in a clockwise or counterclockwise direction. Lets take a look at the turn function:


                        void ent::turn(bool cw)
                        {
                            char cur = this->facing;
                            if (cw)
                            {
                                if (cur == 'N')
                                    this->facing = 'E'; 
                                if (cur == 'E')
                                    this->facing = 'S'; 
                                if (cur == 'S') 
                                    this->facing = 'W'; 
                                if (cur == 'W') 
                                    this->facing = 'N'; 
                            }
                            if (!cw)
                            {
                                if (cur == 'N')
                                    this->facing = 'W'; 
                                if (cur == 'W')
                                    this->facing = 'S'; 
                                if (cur == 'S')
                                    this->facing = 'E'; 
                                if (cur == 'E')
                                    this->facing = 'N'; 
                            }
                        }
                    

Excellent. Now while not necessary, its nice to have some kind of visual aid so the player can confirm the direction they are facing before taking an action,
and what better place than in the characters render function? One thing to remember is that the visual aids are just indicators, they are not part of the character.

                        void ent::render()
                        {
                            Point lead;
                            terminal_layer(4);
                            terminal_color("green");
                            terminal_print(pos.x, pos.y, ch);
                            lead = {pos.x + ing[facing].x, pos.y + ing[facing].y};
                            if (facing == 'N')
                                terminal_print(lead.x, lead.y, "^");
                            if (facing == 'E')
                                terminal_print(lead.x,lead.y, ">");
                            if (facing == 'W')
                                terminal_print(lead.x, lead.y, "<");
                            if (facing == 'S')
                                terminal_print(lead.x, lead.y, "v");
                        }
                    

The end result of our work thus far can be seen here:

As you can see theres no a direction indicator pointing in the direction that our character is moving. This isnt necessary to
to the overall project, but i think it gives it a nice touch.

Bullets, how do they work?


Ok, now that we have that all set up its time to get some bullets flying. When i was first designing this, at first i thought i might have the bullets be a class inheriting
from the ent class, but ultimatly i decided against it. As in life, so in videogames. Bullets are dumb. They dont require alot of information and the concept is simple.
All our bullet needs to know is the position its starting from, the direction its going, and whether or not it hit something. Our bullet's will use the same method of
determining direction as our character, via its own copy of the facing[] array. Lets have a look at the bullet class and talk about what we have and what we're going
to do with it.


                        class bullet {
                            public:
                              std::unordered_map facing;
                              char dir;
                              const char ch = '*';
                              Point pos;
                              Point impact;
                              bool go;
                              bool killshot;
                              void updatePos(World* Map);
                              void render(World* Map);
                              inline bool inBounds(Point);
                              bullet(char dir, Point spos);
                          };
                          
                          bullet::bullet(char dir, Point spos) {
                            this->facing['N'] = {0,-1};
                            this->facing['S'] = {0, 1};
                            this->facing['E'] = {1,0};
                            this->facing['W'] = {-1,0}; 
                            this->dir = dir;
                            this->go = true;
                            this->killshot = false;
                            this->pos = spos;
                          }
                    

I'll break down the properties and the member functions real quick, and then we'll get to implementing it all.


Bullet properties:

  • facing[] is the same associative array we used in the last part.

  • dir is the direction the bullet will be traveling in.
  • ch is the symbol to be displayed for the bullet when its rendered.
  • pos - is the current Point on the grid of the bullet
  • go is a bool which is set to true when its launched, and false when it either hits something or goess off screen.
  • killshot is a bool which is only set to true if it hits a target
  • impact is the point on the map where the bullet hit its target, both of these variables are for use by the game engine


Bullet member functions:

  • updatePos() - this function checks for collision with another object, be it a character, a wall, whatever, and either
    updates the current position to the next position in its path, or sets go to false upon collision
  • render() - this function draws the bullet on the screen, but its also what calls updatePos() before doing so.
  • inBounds() - its best to know if our bullet is actually on the map ;)
  • bullet() - the constructor, its supplied with the starting position, the position to travel in, and sets go to true

updatePos() and inBounds() could be folded in to one function, but it makes for cleaner looking code this way hence why its inline. Either way, their pretty simple:


                            void bullet::updatePos(World* map)
                            {
                                 Point Next;
                                 Next = {pos.x + facing[dir].x, pos.y + facing[dir].y};
                                 if (inBounds(Next)) 
                                 {
                                    if (map->layout[Next.x][Next.y].blocks)
                                    {
                                        if (map->layout[Next.x][Next.y].populated)
                                        {
                                             killshot = true;
                                             impact = Next;
                                        } 
                                        go = false;
                                    } 
                                 } else if (inBounds(Next)) {
                                    go = false; 
                                 } else {
                                    go = true;
                                    pos = Next;
                                }
                            }

                            inline bool bullet::inBounds(Point p)
                            {   
                                return p.x > 0 && p.x < 80 && p.y > 0 && p.y < 40;
                            }
                        

Fairly straight foward. updatePos() takes its current position and adds the Point{dx,dy} value associated with its direction of travel from facing[]
It then checks if that position is not out of bounds, next it checks to see if the bullet has hit anything, if it has, it checks to see if what it hit
is a target, or a wall. If it's a valid target killshot becomes true, and the bullets position is saved to Point impact. Whether its hit a target or a wall
its hit something, so go = false, the bullets journey is over. If its out of bounds, go = false, the bullets journey is over. Otherwise the bullets position is
updated and continues on its merry way.


Every Goblins got a Gun.


Now that we have the bullets movement and stop figured out, you might be wondering how we shoot the thing in the first place. You may recall from the ent class
we laid earlier that there was a shoot() member function, but we didn't set it up yet, so lets outline that function.

                            bullet* ent::shoot()
                            {
                                return new bullet(facing, pos);
                            }
                        

If you just said to yourself "your f*%King kidding me", i wouldnt hold it against you. click. bang. shots fired.
We also need a way to call the shoot function, which brings us back to handling the keyboard input. You can choose what ever key you want to be come the trigger.
i choose the space bar. Now, the reason that simple shoot() function works is because we need to pass the object off to something to keep it alive. std::vector works
perfectly for that. Lets see the changes to the game loop:


                        std::vector bustedCaps;
                        while(true)
                        {
                            terminal_clear();
                            if (terminal_has_input())
                            {
                                keypress=terminal_read();
                                if (!terminal_state(TK_SHIFT))
                                {
                                    switch (keypress)
                                    {
                                        case TK_UP: player->move(Map,0,-1); break;
                                        case TK_DOWN: player->move(Map,0,1); break;
                                        case TK_LEFT: player->move(Map,-1,0); break;
                                        case TK_RIGHT: player->move(Map,1,0); break;
                                        case TK_SPACE: bustedCaps.push_back(player->shoot()); break;
                                        default: break;
                                    }
                                } else {
                                    switch(k)
                                    {
      	                                case TK_LEFT:  player->turn(false); break;
      	                                case TK_RIGHT: player->turn(true); break;
                                        default: break;
                                    }
                                }
                            render(Map);
                            player->render();
                            bustedCaps = bulletStatus(bustedCaps, Map);
                            terminal_refresh();
                        }

                        /************************************
                        *   This is a seperate function
                        *   outside of the game loop
                        *************************************/
                        std::vector bulletStatus(std::vector rnds, World* Map)
                        {
                            if (rnds.size() > 0)
                            {
                                int count = 0;
                                for (auto p: rnds)
                                {
                                    p->render(Map);
                                    if (!p->go)
                                    {
                                        rnds.pop_back();
                                    }
                                }
                            }
                            return rnds;
                        }
                        

As you can see the bulletStatus() function brings it full circle to call the bullets render() function:


                            void bullet::render(World* Map)
                            {
                                if (go == true)
                                {
                                    updatePos(Map);
                                    terminal_layer(4);
                                    terminal_color("flame");
                                    terminal_put(pos.x,pos.y, ch);
                                }
                            }
                        

And there you have it, the basics of implementing moving projectiles in your 2d Grid based games!! obviously i just laid out the very basics, and there is other ways to it.
but this method is simple, its expandable, and quite frankly its alot of. Check back for Part 2 where i will discuss Getting shot, causing damage, and some slightly more advanced
weaponry that can shoot around corners, etc. Until then the code for this section is up on my Github here.
I hope you enjoyed! -max




(c) 2020 Max Goren
MaxCodes.info