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:
Attributes:
The turn
attribute records the current turn;
The playerId
attribute contains the id of the current player;
The 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:
@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.
We create a new AttackUnitCommand
class for handling one unit attacking another:
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.
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 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:
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 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):
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 clearTarget()
;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 selectedUnit
attribute, display a brace around this cell, creates a unit frame, compute a distance map, and shadow cells;moveUnit()
and 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 AnimationComponent
to 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 AnimationComponent
.In the next post, we enable the loading and saving of game state.