Design Patterns and Video Games

2D Strategy Game (10): Random tiles

In this post, we see a core concept in design: data view. The key idea is to distinguish data content from its visual representation.

This post is part of the 2D Strategy Game series

We always show a world cell of one kind in previous programs with the same tile. For instance, we always use a tile for ground cells and another for sea cells. However, the world can be more beautiful if we use different tiles for each ground cell. For instance, some ground cells can have grass, and others don't. The following video illustrates it: the player can enable or disable automatic tiles with F1 and F2 keys:

Data view

Many programmers mix up data and its visual representation. For instance, if they have to add more tiles for ground cells, they create new cell types, one for each new ground tile. This approach is relevant if each new type brings a new game mechanism (like walk speed). However, in the opposite case, where all ground cells types have the same properties, it adds unnecessary complexity to the game logic. For instance, all ground-related functions will have to check if a cell is one of the ground types. In these examples, we face simple problems, but this approach can lead to complex issues in actual games or applications.

That is why good designers try to simplify as much as possible game data with only the content required by game logic. Note that it is also true for general applications, for which we speak of business data/objects and business logic. So then, the UI team creates as many visual representations as required without changing data and even knowing what is inside the data (they only see the data API).

Layer components

First of all, we need a specific component class for each layer since, in each case, the rendering process can be different:

Layer components

We create a first version of the render() method in each class that works as before:

def render(self, surface: Surface):
    super().render(surface)
    tileset = self.tileset.surface
    tilesRect = self.tileset.getTilesRect()
    tileWidth, tileHeight = self.tileset.tileSize
    for y in range(self.layer.height):
        for x in range(self.layer.width):
            value = self.layer[x, y]
            if value == CellValue.NONE:
                continue
            tileCoords = (x * tileWidth, y * tileHeight)
            # Select tile and blit to surface 
            tileRect = tilesRect[value]
            surface.blit(tileset, tileCoords, tileRect)

We create instances of these classes in the constructor of the WorldComponent class, depending on layer names found in the world.
We could use many if and elif statements to create each layer component dynamically. A better approach consists in using a Factory, a class that handles these creations, generally using a dictionary:

class LayerComponentFactory:
    def __init__(self, theme: Theme, world: World):
        self.__name2layer = {
            "ground": lambda: GroundComponent(theme, world),
            "impassable": lambda: ImpassableComponent(theme, world),
            "objects": lambda: ObjectsComponent(theme, world)
        }
    def create(self, name: str) -> LayerComponent:
        if name not in self.__name2layer:
            raise ValueError(f"Invalid layer '{name}'")
        return self.__name2layer[name]()

The main job of the factory is in the create() method: given a name, it returns an instance of the corresponding layer component class, for example:

factory = LayerComponentFactory(theme, world)
component = factory.create("ground")  # returns a GroundComponent

It is easy to declare a new layer class using the factory: we add a new item to the name2layer dictionary. Then, any code that uses the factory can instantiate the new class with no change.

Ground tile definitions

We have a single ground value in the game state, and we want to select one of the four tiles during the rendering randomly:

Random tiles

We improve the tile definitions so that each value can have several tile coordinates:

tilesDefs = {
    "ground": {
        "imageFile": "toen/ground.png",
        "tileSize": (16, 16),
        "tiles": {
            CellValue.GROUND_SEA: [(4, 7), (5, 7), (6, 7), (7, 7)],
            CellValue.GROUND_EARTH: [(0, 7), (1, 7), (2, 7), (3, 7)],
        }
    },
    ...

We parse these definitions to compute the rectangle in the tileset image, and store them in the tilesRects attribute of the Tileset class. It is a dictionary that maps cell values/ids (integers) to a list of Pygame rectangles:

self.__tilesRects: Dict[int, List[Rect]] = {}

The addTile() method builds a rectangle from the cell coordinates (found in tilesDefs above), and add it to tileRects:

def addTile(self, value: int, coords: Tuple[int, int]):
    if value not in self.__tilesRects:
        self.__tilesRects[value] = []
    self.__tilesRects[value].append(Rect(
        coords[0] * self.__tileSize[0],
        coords[1] * self.__tileSize[1],
        self.__tileSize[0], self.__tileSize[1]
    ))

When there are still no tiles for a value (line 2), we create a new array (line 3). Then, we can use the append() method since, after these lines, there is always an array for the current value.

The addTiles() method adds all the definitions of a cell value:

def addTiles(self, tilesDefs: Dict[int, 
        Union[List[Tuple[int, int]], Tuple[int, int]]]):
    for value, coords in tilesDefs.items():
        if type(coords) == list:
            for coord in coords:
                self.addTile(value, coord)
        elif type(coords) == tuple:
            self.addTile(value, coords)
        else:
            raise ValueError(f"Invalid coordinates {coords}")

This method is interesting because it can handle definitions either made of one pair of coordinates (like (0,2)) or an array (like [(4,1), (6,9)]). In the type declaration of tilesDefs, we use the Union operator from the typing package. This example shows that the dictionary can contain a tuple of integers or a list of tuples of integers.

We loop over all (key,value) pairs in the tilesDefs dictionary using its items() method. Then, we process the different possible types of coordinates. If it is a list (line 4), we iterate through all its items (lines 5-6). If it is a tuple (line 7), we can directly call addTile(). Finally, we raise an exception if the type is invalid: it should never happen, but if it does, debugging is easier with such messages.

Random values

The selection of tiles to render must be static: otherwise, it can change at every frame! So, in the constructor of LayerComponent, we create a random value for each cell of the layer:

random.seed(name)
self.__noise = []
width, height = self.__layer.size
for y in range(height):
    row = []
    for x in range(width):
        row.append(random.randint(0, 100000))
    self.__noise.append(row)

We always use the same random seed: the layer's name (line 1). Thus, the random values are always the same and are different from one layer to another.

We store the 2D array in the noise private attribute (line 2). Then, we build it with an array of arrays. Each row is a 1D array of random values (lines 5-7). Finally, we add these rows to the main array (line 8).

Rendering

At the end of the body of the render() method of the GroundComponent class, we draw one of the available tiles:

rects = tilesRects[value]
rectIndex = self.noise[y][x] % len(rects)
tileRect = rects[rectIndex]
surface.blit(tileset, tileCoords, tileRect)

The tilesRects variable points to the dictionary we built in the Tileset class. As a result, rects is the list of possible tiles for the current cell to draw. Line 2 selects one of these tiles using the noise 2D array. Since the values in this array are too large, we compute the division remainder to get an index between 0 and the number of possible tiles minus one.

Final program

Download code and assets

In the next post, we show how to autotically select road and border tiles.