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:
We upgrade the game state with new features:
turn attribute records the current turn;
playerId attribute contains the id of the current player;
playerIds list is the players in the current game. We use it to choose the next player. Furthermore, when we reach the end of the list, we know we can go to the next turn.
We create getters and setters for those attributes as usual. For the setter of
playerId, we have an interesting case:
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.
We create a new
AttackUnitCommand class for handling one unit attacking another:
We initialize the command with the coordinates of the two units in
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_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
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.
We improve the user interface with new features:
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
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.
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
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:
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):
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.
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:
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
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):
clearTarget(): there is no unit under the mouse cursor, so we remove all selection icons and numbers (in the selection layer);
updateTarget(): if there is a unit under the mouse cursor, we see what we can do given the current context and update the attributes accordingly; in the other case, we call
clearSelection(): there is no more selected unit: if any, we remove the brace around it, the shadowed cells that show the possible paths and the unit frame;
selectUnit(): we save the current cell in the
selectedUnitattribute, display a brace around this cell, creates a unit frame, compute a distance map, and shadow cells;
attackUnit(): we schedule the corresponding command.
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:
unitMoved(): we receive this event after the player clicks to move a unit to a new location: we update the selected cell to this location;
unitDamaged(): we add a scrolling text on top of the cell where the damage occurred; We use the
AnimationComponentto show this text;
unitDied(): if the mouse cursor is over this unit's cell, we update our attributes with a call to
updateTarget(). For instance, when a unit dies, we can no longer select or attack it. We also show a grave using
In the next post, we enable the loading and saving of game state.