Design Patterns and Video Games

Discover Python and Patterns (21): Bullets

It is time to shoot with our tank and destroy the towers! We have all we need: a state to represent bullets, commands to update them, layers for the rendering, and a UI to manage all of these.

This post is part of the Discover Python and Patterns series

Objective

At the end of this post, the tank and the towers can shoot bullets. If a bullet hits a unit, it loses its weapon:

Current architecture

Before adding this new feature to our program, let's have a look at its current structure.

The game state

The game state represents all the items in our game:

The GameState class defines the size of the world (worldSize), stores the items in the background (ground), the items in the foreground (walls), and all the units (units).

The Unit class allows us to represent the two types of units in our game: tanks and towers. Each of them has a current location in the world (position), tile coordinates in the tileset (tile), a view angle (orientation), and the coordinates of a cell their weapon currently targets (weaponTarget).

Note that the purpose of these classes is to store game data, and not to update it in any way. As a result, we currently have no method in these classes. We could add some, but only for convenience, for instance, to search for something in the game state. Behind this design, there is still the idea of dividing the problems into sub-problems: game state stores data and commands update it.

Commands

Commands store the elements required for updating the game state, and are also able to execute these updates:

The MoveCommand class stores a move for one unit, and apply it (if possible). The TargetCommand class stores an update of the cell targetted by one unit, and apply it.

We can compare commands to a cooker in a one-person restaurant. The cooker comes to your table and asks what you want to eat. He writes it down on some piece of paper and goes back to the kitchen. Once the meals are ready, he serves them. Obviously, the service would be better if there are waiters; this is a typical improvement in Command patterns implementations. It is not our case in our current design; I'll present it in a future post.

Rendering

A hierarchy of layer classes renders the game state:

The Layer base class contains the shared data and functionalities required by all child classes: the size of cells in pixels (cellSize), the tileset image (texture), and a method to render a tile (renderTile). Child classes must implement the render() method.

The ArrayLayer renders a 2D array of tiles and the UnitsLayer a list of units.

User interface

Last but not least, the user interface manages all these items. Remind that it implements the Game Loop pattern:

The processInput() method parses of Pygame events (keyboard, mouse) and creates commands accordingly. It also creates commands to update non-playing items.

The update() method executes all the commands and clears the commands list.

The render() method renders all layers and tells Pygame that the rendering is done.

The run() contains the main game loop and regulates the frame rate.

Bullets

Introduce the bullets features requires the update of all parts of the program: the game state to represent them, the commands to update them, the layers to render them, and the UI to manage them.

Game state with bullets

Bullets are too different from the units, and I need to (re)introduce a class hierarchy, with a child class for units and a child class for bullets:

class GameItem():
    def __init__(self,state,position,tile):
        self.state = state
        self.status = "alive"
        self.position = position
        self.tile = tile
        self.orientation = 0    

class Unit(GameItem):
    def __init__(self,state,position,tile):
        super().__init__(state,position,tile)
        self.weaponTarget = Vector2(0,0)
        self.lastBulletEpoch = -100

class Bullet(GameItem):
    def __init__(self,state,unit):
        super().__init__(state,unit.position,Vector2(2,1))
        self.unit = unit
        self.startPosition = unit.position
        self.endPosition = unit.weaponTarget

The GameItem base class contains the shared attributes: a reference to the game state, the current status of the item ("alive" or "destroyed"), its cell position, its tile coordinates, and its orientation.

The Unit child class contains the coordinates of the cell targeted by its weapon.

The Bullet child class contains all that we need to manage a bullet:

GameState class and game epochs

In the GameState class I add the following new attributes:

To handle game time, I don't consider real time (in seconds or minutes for instance). Since each computer can run at a different speed, depending on its power, the game may not run at the same speed on every device. A usual and highly advised approach is to consider game time, where the minimal time slot is a single update of the game state. It can have many names, I use to call them "game epochs".

If your computer is able to run the game at maximum speed, for instance 60 game updates per seconds, then you can get a perfect synchronization between game epochs and real time. In this case, a game epoch always lasts about 16 milliseconds.

However, there is no garanties that every computer can run updates 60 times per seconds. There is high chance that your computer does not updates at this rate, mainly because we are using Pygame in a simple way, and the rendering is very slow (I'll show how to correct that in the next post).

Whatever the number of game or frames updates, the gameplay must be always the same. In order to get such a result, reasonning in game time (or epochs) is one of the best way to achieve it. Considering our bullet delay, we only allow another shot by the same unit after bulletDelay epochs. It means that, slow or fast computer, every game items (units or bullets) moved at least by bulletDelay steps. No one will be able to shoot more bullets because he has a faster computer!

Bullets commands

To update bullets, I create three commands:

ShootCommand

The ShootCommnand class stores a reference to the game state and to a unit that shoots:

class ShootCommand(Command):
    def __init__(self,state,unit):
        self.state = state
        self.unit = unit

    def run(self):
        if self.unit.status != "alive":
            return
        if self.state.epoch-self.unit.lastBulletEpoch < self.state.bulletDelay:
            return
        self.unit.lastBulletEpoch = self.state.epoch
        self.state.bullets.append(Bullet(self.state,self.unit))

A command may not always lead to a game update. In this case, if the unit is not "alive" (lines 7-8), then nothing happens. It is the same if the unit already shot recently (lines 9-10). If everything is fine, we record the last game time (a.k.a. epoch) the unit shot (line 11), and we add a new bullet to the list of bullets (line 12).

MoveBulletCommand

The MoveBulletCommand class handles the movement of a bullet:

class MoveBulletCommand(Command):
    def __init__(self,state,bullet):
        self.state = state
        self.bullet = bullet

    def run(self):
        direction = (self.bullet.endPosition - self.bullet.startPosition).normalize()
        newPos = self.bullet.position + self.state.bulletSpeed * direction
        newCenterPos = newPos + Vector2(0.5,0.5)
        # If the bullet goes outside the world, destroy it
        if not self.state.isInside(newPos):
            self.bullet.status = "destroyed"
            return
        # If the bullet goes towards the target cell, destroy it
        if ((direction.x > 0 and newPos.x >= self.bullet.endPosition.x) \
        or (direction.x < 0 and newPos.x <= self.bullet.endPosition.x)) \
        and ((direction.y >= 0 and newPos.y >= self.bullet.endPosition.y) \
        or (direction.y < 0 and newPos.y <= self.bullet.endPosition.y)):
            self.bullet.status = "destroyed"
            return
        # If the bullet is outside the allowed range, destroy it
        if newPos.distance_to(self.bullet.startPosition) >= self.state.bulletRange:  
            self.bullet.status = "destroyed"
            return
        # If the bullet hits a unit, destroy the bullet and the unit 
        unit = self.state.findLiveUnit(newCenterPos)
        if not unit is None and unit != self.bullet.unit:
            self.bullet.status = "destroyed"
            unit.status = "destroyed"
            return
        # Nothing happends, continue bullet trajectory
        self.bullet.position = newPos

We first compute the direction in which the bullet goes to (line 7):

direction = (self.bullet.endPosition -self.bullet.startPosition).normalize()

The direction is the difference between the end and the start positions of the bullet. This direction is normalized (e.g., the norm of the vector is 1), so the distance between the end and the start does not change the speed of the bullet.

Then, we compute the next position of bullet sprite and the position of the center of this sprite (lines 8-9):

newPos = self.bullet.position + self.state.bulletSpeed * direction
newCenterPos = newPos + Vector2(0.5,0.5)

The next position of the bullet is the current one plus the direction multiplied by the speed of bullets. This position is the top left corner of the bullet tile, with the bullet drawn at its center. We compute this centered position to compute collisions because the player sees it at this specific location.

Lines 11-13 check that the next bullet position is still inside the world. If it not the case, the bullet status becomes "destroyed" and we leave the method. I added the isInside() method in the GameState class to test if a position is inside the world. I am used to creating such convenience methods to write clear code:

if not self.state.isInside(newPos):
    self.bullet.status = "destroyed"
    return

Lines 15-20 test if the bullet reaches its final destination. It is a bit tricky because it depends on the direction of the bullet. For instance, if it goes to the right (direction.x >= 0), then the trajectory is over if the x coordinate goes beyond the x coordinates of the end position(newPos.x >= self.bullet.endPosition.x):

if ((direction.x >= 0 and newPos.x >= self.bullet.endPosition.x) \
or (direction.x < 0 and newPos.x <= self.bullet.endPosition.x)) \
and ((direction.y >= 0 and newPos.y >= self.bullet.endPosition.y) \
or (direction.y < 0 and newPos.y <= self.bullet.endPosition.y)):
    self.bullet.status = "destroyed"
    return 

Lines 22-24 check that the next bullet position is not out of range. If it is not the case, the bullet becomes "destroyed":

if newPos.distance_to(self.bullet.startPosition) > self.state.bulletRange:  
    self.bullet.status = "destroyed"
    return

Lines 26-31 test if a unit collides with the bullet. This test is performed by a new findLiveUnit() in the GameState class that looks for the first unit at some position with an "alive" status. If we find a unit, and if this unit is not the one that created this bullet, then the unit and the bullet becomes "destroyed":

unit = self.state.findLiveUnit(newCenterPos)
if not unit is None and unit != self.bullet.unit:
    self.bullet.status = "destroyed"
    unit.status = "destroyed"
    return

Finally, if all tests passed, we can update the position of the bullet (line 32):

self.bullet.position = newPos

DeleteDestroyedCommand

The DeleteDestroyedCommand deletes all game items in a list with a status different from "alive":

class DeleteDestroyedCommand(Command)       :
    def __init__(self,itemList):
        self.itemList = itemList

    def run(self):
        newList = [ item for item in self.itemList if item.status == "alive" ]
        self.itemList[:] = newList  

We use this command to delete all bullets with a "destroyed" status in the bullets list of the GameState class.

I create this command because I don't want to change the bullets list in other commands. Removing elements in a list is always risky because it changes the index of items. It can also lead to unexpected behavior when you iterate through it. And last but not least, it is a nightmare when you do multi-threading. Once again, for this simple program, we could remove destroyed bullets in the move bullet command. As for all previous cases, I am here to show the best practices, and to save you hours or even days of bug searching!

About the implementation of this removal, you can see that I first create a new list of items where the status attribute is "alive":

newList = [ item for item in self.itemList if item.status == "alive" ]

This syntax is very compact and equivalent to the following one:

newList = []
for item in self.itemList:
    if item.status == "alive":
        newList.append(item)

Line 7 also introduces a new syntax with the two dots in brackets [:]:

self.itemList[:] = newList

To update the itemList attribute, you could think about the following syntax:

self.itemList = newList

Using this second syntax, it updates the content of the itemList attribute. However, this attribute contains a reference to a list; it is not a list. So, without the [:], the itemList attribute references a new list, and the one it was previously referring to is not changed.

With the [:], we ask for a copy of all items in the list referenced by newList into the list referenced by self.itemList.

If these references are not clear, don't worry. It is a difficult topic for many new programmers (and actually many programmers still don't understand them and sometimes don't even know that they exist...)

Bullets layers

We need a new Layer child class to render the bullets:

class BulletsLayer(Layer):
    def __init__(self,ui,imageFile,gameState,bullets):
        super().__init__(ui,imageFile)
        self.gameState = gameState
        self.bullets = bullets

    def render(self,surface):
        for bullet in self.bullets:
            if bullet.status == "alive":
                self.renderTile(surface,bullet.position,bullet.tile,bullet.orientation)

This layer is similar to the UnitsLayer, except that we only render one tile.

About the UnitsLayer class, I updated it to render only units with an "alive" status.

User Interface

The main change in the UserInterface is in the processInput() method, where we create the commands:

def processInput(self):
    # Pygame events (close, keyboard and mouse click)
    ...

    # Keyboard controls the moves of the player's unit
    ...

    # Mouse controls the target of the player's unit
    ...

    # Other units always target the player's unit and shoot if close enough
    for unit in self.gameState.units:
        if unit != self.playerUnit:
            command = TargetCommand(self.gameState,unit,self.playerUnit.position)
            self.commands.append(command)
            distance = unit.position.distance_to(self.playerUnit.position)
            if distance <= self.gameState.bulletRange:
                self.commands.append(ShootCommand(self.gameState,unit))

    # Shoot if left mouse was clicked
    if mouseClicked:
        self.commands.append(
            ShootCommand(self.gameState,self.playerUnit)
        )

    # Bullets automatic movement
    for bullet in self.gameState.bullets:
        self.commands.append(
            MoveBulletCommand(self.gameState,bullet)
        )

    # Delete any destroyed bullet
    self.commands.append(
        DeleteDestroyedCommand(self.gameState.bullets)
    )

Non-playing unit shoots

Lines 12-18 updates non-playing units. Their weapon always targets the player (lines 14-15), as before. The new lines 16-18 add a new shoot command if the player is in the range:

distance = unit.position.distance_to(self.playerUnit.position)
if distance <= self.gameState.bulletRange:
    self.commands.append(ShootCommand(self.gameState,unit))

The distance variable contains the distance between the position of the non-playing unit and the one of the player. unit.position is an instance of the Pygame Vector2 class. This class has a distance_to method that computes a Euclidean distance between the instance and the vector in the method argument.

Player shoots

Lines 21-24 adds a new shoot command for the player if he clicks the left mouse button. Remind that a command does not necessarily lead to update. For instance, if the player is dead or if he shot recently, no bullet is created:

if mouseClicked:
    self.commands.append(
        ShootCommand(self.gameState,self.playerUnit)
    )

Bullets movement

Lines 27-30 add one move command for each bullet in the list:

for bullet in self.gameState.bullets:
    self.commands.append(
        MoveBulletCommand(self.gameState,bullet)
    )

Note that I add the command in the order of the list. We could change this, for instance, first move the player bullets before the others. You can see here the benefits of this pattern: execution order and implementation are entirely separated. We can change one without worrying about the other.

Delete bullets

Lines 33-35 add the command that removes all bullets with a "destroyed" status in the bullets list of the game state:

self.commands.append(
    DeleteDestroyedCommand(self.gameState.bullets)
)

This command is the last one to be executed (it is the last one in the list of commands) when every update of the current epoch has been executed. As a result, we take no risk, and no unexpected behavior can happen.

Final program

I also did other changes I don't describe. They are minor changes (like adding a bullet layer in the layers list), and I think you can easily understand them if you have a look at the code.

Download code and assets

In the next post, we'll add explosions. I also show how to speed up the rendering to get 60 frames per second.