It is time to add units to the game! Contrary to other game items, these can move and have many properties.
This post is part of the 2D Strategy Game series
We create new classes to represent a unit with all its features:
The playerId
of the Unit
class contains the unit's owner: 0=neutral, 1=blue player, 2=red player, 3=yellow player, 4=green player. The unitClass
attribute contains an id representing the class or category of the unit. As before, we use an Enum
, and more specifically, an IntEnum
to name each class id, to get a more readable code. Here, we have some medieval unit classes (bowman, knight, etc.), and adding a new one with a new attribute in UnitClass
is easy.
The properties
attribute is a way to add or remove a property to a unit dynamically. It is a dictionary that maps a property id (defined in UnitProperty
) to a value. For instance, if a unit has the HIT_POINTS
property, then it means that it has hit points. Otherwise, it could mean that this is an unbeatable unit.
It is dynamic, so if we want, we can change a property during the lifetime of a unit. For instance, if we want to add level mechanisms, we can update the unit properties when it gains a new level. It could also obtain new properties: for example, a wizard could get new spells (a spell = a property).
We create a UnitProperties
dictionary to initialize the properties of a unit given its class:
UnitProperties = {
UnitClass.WORKER: {
UnitProperty.HIT_POINTS: 0,
UnitProperty.MAX_HIT_POINTS: 10,
UnitProperty.ACTION_POINTS: 0,
UnitProperty.MAX_ACTION_POINTS: 4,
},
UnitClass.BOWMAN: {
UnitProperty.HIT_POINTS: 0,
UnitProperty.MAX_HIT_POINTS: 10,
UnitProperty.ACTION_POINTS: 0,
UnitProperty.MAX_ACTION_POINTS: 4,
UnitProperty.MELEE_ATTACK: 2,
UnitProperty.MELEE_DEFENSE: 1,
...
In the constructor of the Unit
class, we use this dictionary to initialize the unit properties:
def __init__(self, unitClass: UnitClass, playerId: int = 0):
self.__properties = copy.deepcopy(UnitProperties[unitClass])
Note the deep copy with the function from the copy
standard package! If we don't copy the properties, we can change the values in the UnitProperties
global variable! Remind that Python, like most high-level languages, copies references but not content! We use a deep copy to ensure that we copy everything. In this special case, it is not mandatory since an item of UnitProperties
has a single level. A shallow copy would be enough with the copy()
function of the copy
package. However, since there is no complexity issue with a deep copy, we prefer use the deep copy for the day we update UnitProperties
but forgot this line of code!
If we want to add units to a world layer, we can no more use values in a Numpy array: there is no more a fixed number of unit properties. Furthermore, even if the properties were always the same, it would use a lot of memory. As a result, we create a new units
attribute in the Layer
class, dedicated to units:
class Layer(Listenable[ILayerListener]):
def __init__(self, size: Tuple[int, int], dValue: CellValue):
super().__init__()
self.__size = size
self.__defaultValue = dValue
self.__cells = np.full([size[0]+2,size[1]+2],dValue,dtype=np.int32)
self.__units: Dict[Tuple[int, int], Unit] = {}
This new attribute maps a 2D location (tuple of two integers) to a unit instance. Note that Python handles this kind of dictionary key efficiently (tuples are immutable), so we can add and access units quickly, whatever their number.
However, there is a considerable drawback: we can't quickly know if there are units around a cell or, more generally, in a given area. We know we will need these features, starting with the rendering of layers.
A solution is using the cells
Numpy array for units. We consider a value UNITS_UNIT
for units defined in the CellValue
enum as we did for other world components (sea, mountains, farms, etc.). We add a new setUnit()
method in the Layer
class that adds or removes a unit in the layer:
def setUnit(self, coords: Tuple[int, int],
value: CellValue, unit: Optional[Unit] = None):
x, y = coords[0], coords[1]
assert 0 <= x < self.__size[0], f"Invalid x={x}"
assert 0 <= y < self.__size[1], f"Invalid y={y}"
if value == CellValue.NONE or unit is None:
self.__cells[x + 1, y + 1] = CellValue.NONE
if coords in self.__units:
del self.__units[coords]
else:
self.__cells[x + 1, y + 1] = value
self.__units[coords] = unit
If the value is CellValue.NONE
or the unit is None
(line 6), we perform the removal consistently. We set the value in the Numpy array to NONE
(line 7), and if there is a unit, we remove it from the units
dictionary (lines 8-9). If there is a unit to add or update (line 10), we update the array and the dictionary.
Note that we also update the setValue()
method with a new test that ensures that no one is trying to change the value of a cell with a corresponding unit. In other words, we provide that our methods are consistent: it warns users of our class in case of misusage and prevents bugs.
We update the commands for layer edition:
The SetLayerCellCommand
base class has a new unit
attribute which can reference a Unit
or be None
. The three first layers with no units ensure this attribute is always None
(in the check()
method).
The check()
method of the new SetUnitsCellCommand
class returns True
if we can add or update a unit at the given coordinates. For instance, we can't put a unit in the sea or on a mountain. Then, the execute()
method uses the setUnit()
method of the Layer
class to add or remove the unit.
We add a new UnitsComponent
class that renders the units layer. Its render()
method works as follows:
def render(self, surface: Surface):
super().render(surface)
tileset = self.tileset.surface
tilesRects = self.tileset.getTilesRects()
renderer = self.createRenderer(surface)
cellsSlice = renderer.cellsSlice
cellMinX, _, cellMinY, _ = renderer.cellsBox
cells = self.layer.cells[cellsSlice]
valid = cells == CellValue.UNITS_UNIT
for dest, value, cell in renderer.coords(valid):
cellX, cellY = cellMinX + cell[0], cellMinY + cell[1]
unit = self.layer.getUnit((cellX, cellY))
rects = tilesRects[unit.unitClass]
surface.blit(tileset, dest, rects[unit.playerId])
We get the tileset surface (line 3) and all the tile Pygame rectangles (line 4). This is a new tileset with unit tiles we define in the Theme
class. Then, we make a renderer that computes many convenient objects (line 6) and put the ones of interest in variables (lines 7-9).
We compute a Numpy boolean array with True
for cells with a unit (line 11). Then, we iterate through the cells with a unit (line 12). We see here the trick in action that allows us to get all the unit locations in an area quickly!
For each cell, we compute the absolute coordinates (line 13), get the unit (line 14) and the tile rectangle corresponding to the unit class (line 15), and finally blit the tile (line 16).
We update the UI logic to handle the creation or removal of units. We first extend the brushes with a new unit class property. For instance, in the IComponentListener
interface, we add an argument with the class of the unit to create:
def mainBrushSelected(self, layerName: str, value: Union[int, str],
unitClass: Optional[UnitClass]):
Similarly, we extend the brush variables in the EditGameMode
class and the buttons in the PaletteFrame
class.
The essential aspect of this scope is the location of the unit creation. Indeed, we must be sure that we create a new unit every time the player clicks. It means we can't make the new unit when we add a button in the PaletteFrame
class: in this case, each button creates the same unit. Visually, it looks fine because we see units at the right locations, but they all share the same properties!
The best moment to create a unit is when we create the command in the updateCell()
method of EditGameMode
:
Command = self.__logic.getSetLayerCellCommand(brushLayer)
if brushUnitClass is not None:
brushUnit = Unit(brushUnitClass, self.__state.currentPlayerId)
else:
brushUnit = None
command = Command(cell, brushValue, brushUnit, fill)
In the current case, we only need a unit class and a player id, so it is easy. However, if we have more properties or a more complex creation process, it would be complicated to ask EditGameMode
to do that: it is not its job. A first solution is to provide a lambda function instead of the unit class, for instance:
createUnit = lambda state: Unit(unitClass, state.currentPlayerId)
This way, the creation process can use need many values, it is no more the concern of EditGameMode
: it only calls the lambda, for instance, createUnit(state)
. A Builder pattern is more suitable if the creation process is complex: we embed all the creation data in an instance of a builder class, and EditGameMode
calls some build()
method that returns the unit.
In the next post, we move units using pathfinding.