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
We create a new World
class in a new state
package:
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.
The state
package is a directory with a __init__.py
file. In Pycharm, it looks like that:
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.
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.
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)
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.
We create a new UserInterface
class in a new ui
package:
This class has the following attributes:
world
: the game state;window
: the Pygame surface corresponding to window/screen;tileset
: a Pygame surface with tiles;tileWidth
and tileHeight
: size of tiles;tiles
: the coordinates of tiles in the tileset;clock
: a Pygame clock to limit the frame rate.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.
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:
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 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.
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:
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 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.
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.
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:
In the next post, we handle mouse events.