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
At the end of this post, the tank and the towers can shoot bullets. If a bullet hits a unit, it loses its weapon:
Before adding this new feature to our program, let's have a look at its current structure.
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 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.
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.
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.
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.
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:
unit
);startPosition
). It is initialized at the current unit position;endPosition
). It is initialized at the current unit target.In the GameState
class I add the following new attributes:
bullets
: a list of current bullets;bulletSpeed
: a float number that defines the speed of bullets;bulletRange
: a float number that defines the range of bullets;bulletDelay
: a float number that defines the minimum number of game epochs between two shots;epochs
: current game epoch.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!
To update bullets, I create three commands:
ShootCommand
to create a new bullet;MoveBulletCommand
to move a bullet and act accordingly;DeleteDestroyedCommand
to delete game items in a list with a "destroyed" status.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).
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
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...)
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.
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)
)
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.
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)
)
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.
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.
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.
In the next post, we'll add explosions. I also show how to speed up the rendering to get 60 frames per second.