Design Patterns and Video Games

2D Strategy Game (17): Battle

We add unit attacks, new frames, and turn mechanisms.

This post is part of the 2D Strategy Game series

In the following video, we can see an example of a battle with three knights versus three pikemen and archers:

Game state

We upgrade the game state with new features:

Game state

Attributes:

We create getters and setters for those attributes as usual. For the setter of playerId, we have an interesting case:

@playerId.setter
def playerId(self, playerId: int):
    assert playerId in self.__playerIds
    self.__playerId = playerId

Indeed, we check that the new player id is in the list: in the other case, it would lead to silent bugs. This example shows an actual case where private members with getters/setters are more powerful than simple attributes!

Observer: We make the GameState listenable/observable to connect it to the User Interface without introducing a dependency as we did with the Layer class. We call the notifyXXX() methods in commands, and some UI components register with GameState and implement the methods of IGameStateListener they need.

Attack unit command

We create a new AttackUnitCommand class for handling one unit attacking another:

Attack unit command

We initialize the command with the coordinates of the two units in cell and targetCell:

def __init__(self, cell: Tuple[int, int], targetCell: Tuple[int, int]):
    self.__cell = cell
    self.__targetCell = targetCell
    self.__damage = 0

We use coordinates instead of references to units to prevent errors. Otherwise, we could have unexpected behavior if another operation deletes the units. Furthermore, the command would keep track of the object since Python keeps objects as long as they are a reference to it. If we use cell coordinates, the commands can not execute in the worst case, but there is no bug or crash. Furthermore, it is better not to use references in commands constructor data if we want to serialize them later, for instance, to record a replay or handle rollback.

We use the third attribute damage to store the damage computed in the check() method and use it in the execute() method. It avoids doing the same computation twice and ensures consistency between the two methods.

The check() method: it performs obvious checks, like checking that the cells and units exist, the main unit belongs to the current player, the other to an enemy player, etc.

It also uses the unit properties to know what we can do and compute points. As a reminder, this system allows defining the behavior of units using a collection of properties we associate with values. For instance, if a unit does not have the ACTION_POINTS property, it means that it can not do any action:

if not unit.hasProperty(UnitProperty.ACTION_POINTS):
    return False

Then, if it has the action property but no more associated points, it can not attack:

actionPoints = unit.getProperty(UnitProperty.ACTION_POINTS)
if actionPoints == 0:
    return False

At the end of the method, we compute the damage done and return True if it is higher than zero:

self.__damage = 0
dist = vectorDistI(self.__cell, self.__targetCell)
for attack, properties in attackUnitProperties.items():
    range = unit.getProperty(properties["range"], 1)
    if dist > range or (dist == 1 and range > 1):
        continue
    damage = unit.getProperty(attack, 0)
    damage -= targetUnit.getProperty(properties["defense"], 0)
    if damage > self.__damage:
        self.__damage = damage
if self.__damage <= 0:
    return False

Line 2 computes the Euclidean distance between the two units.

Line 3 iterates through all the attack-related properties, like UnitProperty.MELEE_ATTACK (we define attackUnitProperties in UnitProperty.py). Each case contains the range and the defense properties associated with the attack. For instance, for melee, it is UnitProperty.MELEE_RANGE and UnitProperty.MELEE_DEFENSE. This trick avoids a long copy/paste of each case and eases adding new attack properties. For instance, if we want to create a magic attack, we only have to update the properties in UnitProperty.py and do not need to touch the command code.

Line 4 gets the range of the current attack. When the unit does not have this property, the getProperty() returns its second argument: in this line, it is one. In other words, we assume that all units have a default range of one cell.

Lines 5-6 check that the distance between units is in the range (first condition). We also consider that we can not use high-range melee attacks. For instance, an archer can not use his/her bow in close combat.

Lines 7-8 compute the damage points: the attack points minus the defense points. In both cases, we assume that the default is zero, which automatically discards attacks a unit does not have.

Lines 9-10 save the damage if it is higher than the one we computed since then.

Lines 11-12 return False if the unit can not do any damage.

The execute() method: we execute it only if the check() returns True. We first update the unit action points:

unit = unitsLayer.getUnit(self.__cell)
actionPoints = unit.getProperty(UnitProperty.ACTION_POINTS)
unit.setProperty(UnitProperty.ACTION_POINTS, actionPoints - 1)
state.notifyUnitChanged(self.__cell, unit)

The last line notifies all observers that this unit has changed, like the unit frame we present in the following sections.

Then, we compute the life points of the damaged unit and update the state depending on cases:

targetUnit = unitsLayer.getUnit(self.__targetCell)
targetLifePoints = targetUnit.getProperty(UnitProperty.LIFE_POINTS)
targetLifePoints = targetLifePoints - self.__damage
if targetLifePoints > 0:
    targetUnit.setProperty(UnitProperty.LIFE_POINTS, targetLifePoints)
    state.notifyUnitChanged(self.__targetCell, targetUnit)
elif targetUnit.unitClass == UnitClass.KNIGHT:
    targetUnit = Unit(UnitClass.SWORDSMAN, targetUnit.playerId)
    unitsLayer.setUnit(self.__targetCell, CellValue.UNITS_UNIT, targetUnit)
    unitsLayer.notifyCellChanged(self.__targetCell)
    state.notifyUnitChanged(self.__targetCell, targetUnit)
else:
    unitsLayer.setUnit(self.__targetCell, CellValue.NONE, None)
    unitsLayer.notifyCellChanged(self.__targetCell)
    state.notifyUnitDied(self.__targetCell)

If the unit still has life points, we update the related properties and notify observers (lines 4-6).

If the unit is a knight, we do not delete it but replace it with a swordsman (lines 7-11). It is a game design choice: we consider that the knight falls from its mount but is still able to fight. We could choose other game rules! As in the previous case, we tell anyone listening that the unit has changed (line 11) but also that the units layer has changed (line 10).

The last case removes the unit (lines 12-15). The state notifies the death, and we will use it to display a grave where the unit was.

User interface

We improve the user interface with new features:

User interface

Accessible cells: When the player selects a unit, we shadow all cells it can not reach. In the example above, the knight can ride to all non-shadowed cells since it still has 16 move points. To implement this, we use the same trick we used to display the path numbers: create a new layer dedicated to cell shadows. It is also a UI layer, so nothing goes to the state package, and we code everything in the ui package.

We create a new ShadowLayer class and ShadowValue enum to store the cell with a shadow. The ShadowLayer is a child class of Layer (like all layers), and ShadowValue defines the possible cell values:

class ShadowValue(IntEnum):
    NONE = 0
    SHADOW_LIGHT = 1
    SHADOW_MEDIUM = 2
    SHADOW_HIGH = 3

We use a tileset with four tiles, each one corresponding to the four possible values. The shadow tiles are black tiles with a non-opaque alpha value. As for all other layers, we declare the tileset in a ShadowTiledef class, create a new dedicated UI, etc. Then, when we set a value in the shadow layer, the corresponding cell becomes shadowed on the screen.

In the ShadowLayer class, we add a showPaths() method that shadow all cells but the ones with a cost less or equal to a maximum value:

def showPaths(self, distanceMap: DistanceMap, maxCost: int):
    x, y = (distanceMap.map <= maxCost).nonzero()
    if len(x) < 2:
        self.fill(ShadowValue.NONE)
    else:
        ax1, ay1, ax2, ay2 = distanceMap.area
        x += ax1
        y += ay1
        self.fill(ShadowValue.SHADOW_LIGHT)
        self.cells[x, y] = ShadowValue.NONE

We use a distance map we introduced in the previous post, which contains the cost to reach cells in a given area. We first compute the cell coordinates with a cost lower than the maximum (line 2). The map is a Numpy array: the comparison with a value map <= maxCost returns a boolean Numpy array with True when the cost is low and False otherwise. The following call to the nonzero() method returns two Numpy arrays: one with the x coordinates of cells with a True value and another with the y coordinates of the same cells.

Hence, the length of x and y arrays is the number of cells the unit can reach. If it is zero or one, the unit can not move: we display no shadow (lines 3-4). In the other case, we shift these coordinates to convert them to world coordinates (lines 6-8). Then, we fill the layer with shadow (line 9) but not the one with a low cost (line 10).

Unit frame: we show a new frame when the player selects a unit:

Unit frame

It displays the unit icon, name, points, and properties. We create a subframe for each case and layout them with anchors (represented by red arrows in the figure):

UI anchor system

The icon subframe is a new Icon class that renders a Pygame surface. The other subframes are instances of a new Label class that also renders a Pygame surface, except that it uses the TextRenderer class to create it from a text. It features all the text renderer can do, like adding small images and rendering multiple lines.

We never manually compute the pixel location of the subframes but use the moveRelativeTo() method of the Component class, and finally, call pack() to get the unit frame as small as possible:

self.__icon.moveRelativeTo("topLeft", self, "topLeft")
self.__titleLabel.moveRelativeTo("topLeft", self.__icon
, "topRight")
self.__pointsLabel.moveRelativeTo("topLeft", self.__titleLabel, "bottomLeft")
self.__propertiesLabel.moveRelativeTo("topLeft", self.__pointsLabel, "topRight")
self.pack()

Each call to moveRelativeTo() corresponds to one of the red arrows in the figure above. Layout is easy with this approach: for instance, if we want to put the points and the properties below the icon, we can anchor the top-left corner of the points to the bottom-left corner of the title. Try to edit the "ui\component\frame\UnitFrame.py" file to see what happens!

Turn frame: it shows the current turn and a button with an hourglass to end the current player turn. It also uses anchors to layout the inner frames.

Animations: when a unit is damaged, we display the lost points on top, scrolling to the top for a few seconds. We also display a grave when a unit dies. We render these elements with a new AnimationComponent class, a child of LayerComponent. This component is similar to the ones for selection or shadows, except that we do not use a layer. In this case, we directly utilize the LayerRenderer instance provided by LayerComponent to get the pixel location of the cells.

UI connections

Once we have our working logic and UI components, we connect everything in the PlayGameMode class. It handles the mouse moves and clicks in the world and triggers events accordingly. Hence, we introduce the following attributes and methods to implement these new features:

PlayGameMode class

State machine: we use several tricks and technics to ease the development and prevent errors. The first one puts the game mode in a given mode or state (NB: not the game state!). Then, we record this mode in the targetAction attribute, which can have the following values:

Along with the targetAction attribute, we store more information in the other ones: targetCell contains the cell coordinates of the cursor, and selectedCell the cell coordinates of the currently selected unit. We mainly set these values in the worldCellEntered() method, which the UI calls when the cursor enters a world cell. Then, the worldCellClicked() method uses the current values to update the world.

The main advantage of this approach is that we do not need to evaluate twice what we can do: one time in worldCellEntered() and a second time in worldCellClicked(). In our case, we evaluate once in worldCellEntered(), which reduces computations and ensures consistency between display and actions. For instance, when the UI shows swords, a left click leads to an attack. The only drawback is world changes: if the unit under the cursor moves, we must update the display.

Split processes: when implementing such features, we can quickly want to code everything in the two methods worldCellEntered() and worldCellClicked(). Therefore, except if there are only a few lines to add, we better split the implementation into several methods. As a result, the code becomes more readable and easier to debug. In this example, it is all the private methods (with a minus symbol in the diagram above):

Commands check: We use another trick: to know if we can move or attack a unit, we do not recode all checks in the UI. Instead, we create a command and only call the check() method! Here is an example that uses the attack command:

attackCommand = AttackUnitCommand(self.__selectedCell, cell)
if attackCommand.check(self._logic):
    self.__targetCell = cell
    self.__targetAction = "attack"
    self._selectionLayer.setValue(cell, SelectionValue.ATTACK)
    self._selectionLayer.notifyCellChanged(cell)

We create an attack command with a unit at selectedCell attacking another unit at cell (line 1). Then, we call the check() method to know if it is possible (line 2). If we can, we update the target attributes and show an attack icon (lines 3-6).

This trick will save you a lot of time, especially on large projects! We do not have to worry about the UI when we update command conditions. For instance, if we want to change the rules for attacking a unit, we only work in the attack command, and the UI automatically updates!

Events: the last methods of the PlayGameMode class update the display when something happens in the game state:

Final program

Download code and assets

In the next post, we enable the loading and saving of game state.