Design Patterns and Video Games

2D Strategy Game (15): Units

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

Unit class

We create new classes to represent a unit with all its features:

Unit properties

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!

Unit layer

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.

Layer commands

We update the commands for layer edition:

Commands for layers

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.

Units rendering

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).

UI controls

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.

Final program

Download code and assets