Design Patterns and Video Games

2D Strategy Game (5): Layers

We add two more layers to the world: one for impassable items (mountains, rivers, etc.) and one for objects (trees, houses, etc.):

This post is part of the 2D Strategy Game series

Game state

We create a new Layer class with data for a layer and change the World class with a list of layers. We also introduce a new CellValue enumeration that defines the codes for each cell type:

Game state

Package hierarchy

We organize all classes in a hierarchy of packages. The base package core contains what is related to game data and processing (constants, state, and later logic). The constants package contains definitions, like the id for each cell type. The state package contains the game data, like the world.

For each package, we create a directory and a "__init__.py" file:

Game state package files

To import a class from a sub-package, we use import paths similar to directory paths, where we remplace slashs with dots. For instance, core\state\World becomes core.state.World:

from core.state.World import World

We can simplify these imports with some content in "__init__.py" files. For instance, for the core.state classes, we can add the following lines:

# Content of "core\state\__init__.py"
from .Layer import Layer
from .World import World

Python executes the "__init__.py" file when we import the corresponding package. Thanks to these lines, we import the classes: for example, World now refers to the class and no more to the module/file.

With this trick, we can import the World class with the following syntax:

from core.state import World

This improvement does not work in all cases; sometimes, the import of a package introduces circular dependencies. A simple rule that works most often is to only use those shortened imports from outside the current base package.

World data

Layers creation: the Layer class is like the previous World class: we store all cell values in a 2D array. The new World class contains a dictionary of these layers, which we define in the constructor:

self.__layers = {
    "ground": Layer(width, height, CellValue.GROUND_SEA),
    "impassable": Layer(width, height, CellValue.NONE),
    "objects": Layer(width, height, CellValue.NONE),
}

We choose a container rather than many attributes because it simplifies the code that processes all layers. Furthermore, we won't have to update this code in many cases: it will work whatever the number of layers.

Get one layer: the getLayer() returns one of these layers:

def getLayer(self, name: str) -> Layer:
    if name not in self.__layers:
        raise ValueError(f"No layer {name}")
    return self.__layers[name]

Note the not in operator that checks if the layer name is not in the dictionary. If that is the case, we raise an exception.

In some cases, it is more convenient to have attributes. We can simulate attributes using properties:

@property
def ground(self) -> Layer:
    return self.__layers["ground"]

It is transparent for users of the World class: whatever the internal format of layer variables, it works the same.

Get all layers: we also add two new properties that return the list of layer names and the list of layers:

@property
def layerNames(self) -> List[str]:
    return list(self.__layers.keys())

@property
def layers(self) -> List[Layer]:
    return list(self.__layers.values())

We use the keys() method of the layers dictionary to return its keys and the values() method to return its items. These methods return a view (Iterable), which is bound to the dictionary. If we change the dictionary, the view will also change.

Returning view is not obvious for the class user: it is more natural to expect a list that will not change. Furthermore, in a multithreaded context, it can lead to unexpected behavior. That is the reason why we build a list, for instance: list(dict.keys()). This list will not change if the dictionary changes and the class user can also modify it safely.

Cell values

We previously stored possible cell values in variables like LAYER_GROUND_xxx. It saves us from memorizing all these values and eases their update. We improve this representation with an enumeration:

from enum import IntEnum
class CellValue(IntEnum):
    NONE = 0
    GROUND_SEA = 101
    GROUND_EARTH = 102
    IMPASSABLE_RIVER = 201
    IMPASSABLE_POND = 202
    IMPASSABLE_MOUNTAIN = 203
    OBJECTS_SIGN = 301
    OBJECTS_HILL = 302
    ...

The first advantage is to simplify imports: a single from core.constants import CellValue imports all values. The second is defining a type for values, so typing and mypy can warm us if we use a wrong value. We use this class type as any other, for instance, in the setValue() method of the Layer class:

class Layer:
    def setValue(self, x: int, y: int, value: CellValue):
        ...

Note that we don't use a generic enumeration but an IntEnum integer enumeration that only contains integers. It enforces the checks and allows comparison with any integer-based types.

We also define a dictionary with the range of cell values for each layer type. This data also ease the implementation of the following processing:

CellValueRanges = {
    "ground": (101, 103),
    "impassable": (201, 204),
    "objects": (301, 311)
}

Tile and tilesets

We expand the Theme class and introduce a new Tileset class that contains tile data for a layer type:

Theme classes files

The job of these classes is to provide data we can use during the rendering: a tileset surface and tile rectangles. This data is not available before runtime: for the surface, we have an image file name, and for the rectangles, we have cell coordinates.

Tile definitions

Raw definitions: we first gather the basic definitions in a single variable. We store this variable in the "Theme.py" file, but we could load the same data from a JSON or YAML file:

tilesDefs = {
    "ground": {
        "imageFile": "toen/ground.png",
        "tileSize": (16, 16),
        "tiles": {
            CellValue.GROUND_SEA: (4, 7),
            CellValue.GROUND_EARTH: (2, 7),
        }
    },
    "impassable": {
        "imageFile": "toen/impassable.png",
        "tileSize": (16, 16),
        "tiles": {
            CellValue.IMPASSABLE_RIVER: (0, 1),
            CellValue.IMPASSABLE_POND: (1, 0),
            CellValue.IMPASSABLE_MOUNTAIN: (4, 0),
        }
    },
    ...
}

Now our goal is to turn this data into objects we can use during the rendering. For instance, we need Pygame rectangles to use surface blitting.

Build Tileset instances: in the constructor of the Theme class, we build the tilesets dictionary of Tileset with this data:

self.__tilesets: Dict[str, Tileset] = {}
for name, tilesDef in tilesDefs.items():
    tileset = Tileset(self, tilesDef["tileSize"], tilesDef["imageFile"])
    for value, coords in tilesDef["tiles"].items():
        tileset.addTile(value, coords)
    self.__tilesets[name] = tileset

Line 2 iterates through all elements of the tileDefs dictionary. The items() method returns an iterator that yields the key and the value of each item in the dictionary.

Line 3 creates a new instance of the Tileset class with the tile size and the image file of the tileset.

Line 4 iterates through all elements in the "tiles" items of tileDef. We are reading items of a dictionary in a dictionary. We could write tilesDefs[name]["tiles"] instead of tileDef["tiles"].

Convert cell coordinates into rectangles: the addTile() method of the Tileset class builds and stores the rectangles:

def addTile(self, value: int, coords: Tuple[int, int]):
    self.__tilesRect[value] = Rect(
            coords[0] * self.__tileSize[0],
            coords[1] * self.__tileSize[1],
            self.__tileSize[0], self.__tileSize[1]
        )

We save the rectangles but not the coordinates to save computational time. Consequently, when we call the getTileRect() method, there is no computation; we directly get a rectangle.

Tileset images

Cache surfaces: When creating a new Tileset instance, we could load the tileset image and store the surface. Instead, we cache all the images we load in the Theme class, and the Tileset instances ask for an image knowing the image file name. Using this approach, we save memory and reduce loading time when several layers use the same tileset. Furthermore, when stoping/creating a new game, but don't reload images.

In the Theme class, the getSurface() method returns the surface corresponding to an image file:

class Theme:
    def getSurface(self, imageFile: str) -> Surface:
        if imageFile not in self.__surfaces:
            fullPath = os.path.join("assets", imageFile)
            if not os.path.exists(fullPath):
                raise ValueError(f"No file '{fullPath}'")
            self.__surfaces[imageFile] =
                pygame.image.load(fullPath).convert_alpha()
        return self.__surfaces[imageFile]

The idea is to use the image file name as a key; if there is a corresponding surface in the surfaces dictionary, we return it. Otherwise, we load the surface, add it to surfaces, and return it.

Then, in the Tileset class, we ask Theme for the surface with the image file name:

class Tileset:
    def getSurface(self) -> Surface:
        return self.__theme.getSurface(self.__imageFile)

Circular imports: the Theme class imports Tileset (for the dictionary of tilesets) and the Tileset class imports Theme (to be able to ask for an image surface). Python complains if we add imports in both directions. We can solve this issue in the following way.

We enable the import of Theme in Tileset only if Python is checking types:

# In Tileset.py
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from .Theme import Theme

Remind that there is no type checking at runtime: this processing runs when we type the code in Pycham and execute mypy.

If you are using Python version 3.10 or lower, an error appears: Python does not recognize the Theme type in the argument of the constructor of Tileset. It is because Python evaluates annotations. To disable this, and consequently remove the error, add the following to the beginning of the file:

from __future__ import annotations

This line enables the behavior of Python 3.11, which no more evaluates annotations.

Render the layers

We create a new LayerComponent class in the ui package, to render layers (we only show attributes and methods of interest):

Game state

LayerComponent class

Constructor: we init a layer component with a theme, a world, and the name of a layer:

def __init__(self, theme: Theme, world: World, name: str):
    self.__layer = world.getLayer(name)
    self.__tileset = theme.getTileset(name)

We get the layer using the getLayer() method of the World class (line 2), and the corresponding tileset with the getTileset() method of the Theme class.

In both cases, we can retrieve objects thanks to the name of the layer. It shows the interest of this approach, where we store data according to keys (layer names in this example). Otherwise, we would have to repeat similar code lines with many different methods.

Rendering: the render() method is similar to the previous World method, except that we get tile data from the tileset attribute:

def render(self, surface: Surface):
    tileset = self.__tileset.getSurface()
    tileWidth, tileHeight = self.__tileset.tileSize
    tilesRect = self.__tileset.getTilesRect()
    for y in range(self.__layer.height):
        for x in range(self.__layer.width):
            value = self.__layer.getValue(x, y)
            if value == CellValue.NONE:
                continue
            tileRect = tilesRect[value]
            tileCoords = (x * tileWidth, y * tileHeight)
            surface.blit(tileset, tileCoords, tileRect)

Edit game mode

Layer components: we create a list of layers in the constructor of EditGameMode:

self.__layers = [
    LayerComponent(theme, world, name)
    for name in world.layerNames
]

If this pythonish syntax is not clear, it is equivalent to:

self.__layers = []
for name in world.layerNames:
    component = LayerComponent(theme, world, name)
    self.__layers.append(component)

The rendering of layers is straightforward:

def render(self, surface: Surface):
    for layer in self.__layers:
        layer.render(surface)

Brush layer: we defined a brushLayer attribute that defines the layer we are editing. The updateCell() private method uses it to fill a value in this layer:

def __updateCell(self, cellX: int, cellY: int, buttons: MouseButtons):
    layer = self.__world.getLayer(self.__brushLayer)
    if buttons.button1:
        if layer.getValue(cellX, cellY) != CellValue.NONE:
            return
        minValue = CellValueRanges[self.__brushLayer][0]
        maxValue = CellValueRanges[self.__brushLayer][1]
        value = random.randint(minValue, maxValue - 1)
        layer.setValue(cellX, cellY, CellValue(value))
    elif buttons.button3:
        layer.setValue(cellX, cellY, CellValue.NONE)

Lines 6-8 select a random cell value. We use the CellValueRanges dictionary defined in the CellValue module. Again, we don't use any raw values, we don't need to memorize them. Furthermore, the code is compact: we have the value range of any layer given its name. We don't need to write a long if...elif statement. Finally, this code will work even if we add or remove new layers.

Final program

Download code and assets