Design Patterns and Video Games

2D Strategy Game (3): Ground layer

In this post, we start the design of the game state and write the first unit tests.

This post is part of the 2D Strategy Game series

Game state

Design

We create a new World class in a new state package:

First game state

The width and height attributes are integers and define the size of the world. They are immutable, meaning that we disallow any changes of their value.

The cells attribute is a list of list of integers and contains the value of each cell of the world. We only consider two values: sea and earth. Its layout is immutable, meaning that we disallow any change of its size.

The three attributes are private (see the minus symbol before their name). It means that we don't allow direct access from outside the class and control everything with methods.

The two methods are public (see the plus symbol before their name). The getValue() method returns the value of the cell at (x,y) and the setValue() method sets the value at (x,y).

If we need to change how we store the cell values one day, we will only need to update the World class (spoiler: we will!). All the code that uses it depends only on the method names and arguments, not on how we implement them.

Implementation

The state package is a directory with a __init__.py file. In Pycharm, it looks like that:

Game state files

We implement the World class in the following way:

class World:
    def __init__(self, width: int, height: int):
        self.__width = width
        self.__height = height
        self.__cells = [[0] * width] * height

    @property
    def width(self) -> int:
        return self.__width

    @property
    def height(self) -> int:
        return self.__height

    def getValue(self, x: int, y: int) -> int:
        assert 0 <= x < self.__width, f"Invalid x={x}"
        assert 0 <= y < self.__height, f"Invalid y={y}"
        return self.__cells[y][x]

    def setValue(self, x: int, y: int, value: int):
        assert 0 <= x < self.__width, f"Invalid x={x}"
        assert 0 <= y < self.__height, f"Invalid y={y}"
        self.__cells[y][x] = value

Note that we type method arguments. For instance, the __init__() method has two integer arguments width and height. It is not mandatory, but it helps a lot in finding errors. For instance, Pycharm warms when we try to copy a value with the wrong type.

Constructor

The __init__() method (lines 2-5) creates the three attributes. All of them are private: we start their names with two underscore symbols. If you try to access them, with or without the underscores, it does not work:

world = World(16, 10)
print(world.cells)  # Error, no "cells" attribute 
print(world.__cells)  # Error, no "__cells" attribute 

We can access private attributes with a complex syntax:

print(world._World__cells)  # Ok

Although we can access private attributes, we should not do it or do it with extreme care. If we made such a design, there is a good! Therefore, we never use this syntax and assume that direct access to private attributes is impossible.

Properties / getters

The width and height properties (lines 7-13) allow us to get the width and height of the world:

print(world.width, world.height)  # prints "16 10"

Since we don't implement any setter, we can't change the width and height from outside the class.

Note that we could implement these properties differently, for instance, using the size of the cells attribute:

@property
def width(self) -> int:
    return len(self.__cells[0])
@property
def height(self) -> int:
    return len(self.__cells)

Cell values

The getValue() and setValue() methods (lines 15-23) get and set (resp.) the value of a cell at coordinates (x,y). In both cases, the assert lines check that the coordinates are valid. If the test fails, Python raises an AssertionError with a message. The message is an example of Python f-String, where {expression} is replaced by the value of expression. These checks are not mandatory, but they can help a lot in case of error. The only drawback is the computational overhead which can be a problem in some cases.

The cell values are integers: we create a variable for each value, so we don't have to remember them:

LAYER_GROUND_SEA = 0
LAYER_GROUND_EARTH = 1

These variables are in the "constants.py" file in the state package.

User interface

Design

We create a new UserInterface class in a new ui package:

User interface class

This class has the following attributes:

All attributes are private: no one outside the class can access them.

The run() method contains the main game loop, and the quit() method the code that deletes all components.

Constructor

The __init__() method of the UserInterface class is:

def __init__(self, world: World):
    self.__world = world

    pygame.init()
    self.__window = pygame.display.set_mode((1024, 768), HWSURFACE | DOUBLEBUF | RESIZABLE)
    pygame.display.set_caption("2D Medieval Strategy Game with Python, http://www.patternsgameprog.com")
    pygame.display.set_icon(pygame.image.load("assets/toen/icon.png"))

    self.__tileset = pygame.image.load("assets/toen/ground.png")
    self.__tileWidth = 16
    self.__tileHeight = 16
    self.__tiles = {
        LAYER_GROUND_EARTH: (2, 7),
        LAYER_GROUND_SEA: (5, 7),
    }
    self.__clock = pygame.time.Clock()

Lines 5-8 create the window has before, except that we store objects in class attributes.

Line 11 loads the tileset. It also comes from the Toen medieval tileset:

Ground tiles

Lines 12-13 define the size of tiles.

Lines 14-17 define the location of tiles in the tileset. Coordinates are tile coordinates. For instance, the earth tile is in the third column and eighth row.

The tiles attribute is a dictionary since we use curly brackets (note: with square brackets, it would have been a list). As a result, we can consider any integer values in any order as keys, as long as there is not twice the same value. Then, the expression self.__tiles[value] returns the tile coordinates of value.

Line 18 creates a Pygame clock.

The run() method

The run() method contains a game loop similar to the previous post. We only detail the new rendering part:

def run(self):
    running = True
    while running:
        # Handle input
        ...

        # Render world on a surface
        tileWidth = self.__tileWidth
        tileHeight = self.__tileHeight
        renderWidth = self.__world.width * tileWidth
        renderHeight = self.__world.height * tileHeight
        renderSurface = Surface((renderWidth, renderHeight))
        for y in range(self.__world.height):
            for x in range(self.__world.width):
                value = self.__world.getValue(x, y)
                tile = self.__tiles[value]
                tileRect = Rect(
                    tile[0] * tileWidth, tile[1] * tileHeight,
                    tileWidth, tileHeight
                )
                tileCoords = (x * tileWidth, y * tileHeight)
                renderSurface.blit(self.__tileset, tileCoords, tileRect)

        # Scale rendering to window size
        ...

Lines 8-9 create variables with the size of the tiles to make the code easier to read. It also saves a bit of computation since Python no longer needs to reach tile size attributes.

Lines 10-11 compute the size of the rendering area (a pixel size). We want to render the whole world, so it is its size (a tile size) times the size of tiles (a pixel size).

Line 12 creates the rendering surface, and lines 13-14 iterate through all cells of the world.

Line 15 gets the cell value at (x,y), and line 16 the corresponding tile coordinates in the tileset. Note that tiles is a dictionary, and since dictionaries are implemented wish hash table in Python, the line runs very fast. Also, whatever the size of the dictionary, the execution time is the same.

Lines 17-20 create a Pygame rectangle that corresponds to the tile in the tileset, as required by the blit() method of the Pygame surface.

Line 21 is the location on the screen of the tile to render. We draw them as usual, from left to right and from top to bottom.

Line 22 is the drawing of the tile using the tileset in the tileset attribute.

Test the code

Main program

The following "main.py" file, we create a world with a rectangle of earth cells in the center and render it:

from state import World
from state.constants import LAYER_GROUND_EARTH
from ui import UserInterface

# Create a basic game state
world = World(16, 10)
for y in range(3, 7):
    for x in range(4, 12):
        world.setValue(x, y, LAYER_GROUND_EARTH)

# Create a user interface and run it
userInterface = UserInterface(world)
userInterface.run()
userInterface.quit()

Unfortunately, the rendering is not good:

Something is wrong... class

Where does the problem come from? Does the state is wrong? Or the rendering? To solve this, we can start with the creation of unit tests.

Unit tests

Unit tests are a common way to ensure that a code is working fine. The idea is simple: check that basic functions are running as excepted. In the Python standard library, there is a unittest package that allows this. With this library, we create tests with a class child of TestCase from unittest, where method names start with test. In our case, we define a new TestWorld class in a new test package:

from unittest import TestCase

class TestWorld(TestCase):

    def test_setget(self):
        world = World(14, 7)
        self.assertEqual(14, world.width)
        self.assertEqual(7, world.height)

        for y in range(world.height):
            for x in range(world.width):
                self.assertEqual(LAYER_GROUND_SEA, world.getValue(x, y))

        world.setValue(3, 4, LAYER_GROUND_EARTH)
        for y in range(world.height):
            for x in range(world.width):
                if x == 3 and y == 4:
                    self.assertEqual(LAYER_GROUND_EARTH, world.getValue(x, y))
                else:
                    self.assertEqual(LAYER_GROUND_SEA, world.getValue(x, y))

The test_setget() method checks that the getValue() and setValue() methods work fine.

Line 14 creates a new world. Lines 15-16 check that the size of this new world is as expected. The assertEqual() method of the TestCase class raises an exception if its arguments are not equal.

These tests could seem useless: we just made a world of size (14,7); how could it be different? Right now, our code is very simple. However, later, we could make significant data structure changes that could lead to failures (spoiler: we will!). These checks immediately point to them and save us a lot of debugging time.

Lines 10-12 check that all cells are sea cells.

Line 14 sets an earth cell at (3,4), and lines 15-20 that all cells but this one are sea cells.

Run the tests

We create a new file "test.py" to run the tests:

import unittest
loader = unittest.TestLoader()
tests = loader.discover('test')
testRunner = unittest.runner.TextTestRunner()
testRunner.run(tests)

Lines 2-3 create a test loader and discover test classes in the test package. Lines 4-5 run the test in the loader.

If we run this file (in Pycharm, right-click "test.py" then "Run test"), the test fails, meaning that our problem is in the implementation of the World class.

Solve the problem

The problem is due to the creation of the cell list in the constructor of the World class:

self.__cells = [[0] * width] * height

When we read this line, we can think can it creates many different rows. It is not the case: it makes a single row and repeats the reference many times. It can be easier to see it with this syntax:

row = [0] * width
self.__cells = [row] * height

Consequently, when we set a cell with self.__cells[y][x] = value, it always sets the same row, whatever the value of y.

We can solve this problem through an explicit creation of all rows:

self.__cells = []
for y in range(height):
    row = [LAYER_GROUND_SEA] * width
    self.__cells.append(row)

Thanks to this fix, the unit tests succeed, and the rendering is correct:

First rendering class

Final program

Download code and assets

In the next post, we handle mouse events.