Design Patterns and Video Games

Discover Python and Patterns (24): Load levels

In the previous post, we created a new level using Tiled. I show you how to load it in our game.

This post is part of the Discover Python and Patterns series

Level 2 in action

You can see the game with the level in the "level2.tmx" file:

Requirement

To parse the .tmx file created by Tiled, I use a free library called tmx. You can install it using pip: open an Anaconda prompt (as we did for Pygame), and run:

pip install tmx

At the beginning of our program, add the following line to import it:

import tmx

Load level command

We could load the level thanks to a new method in the GameState class or a new independent function. Instead, I propose to create a new command LoadLevelCommand, child of the Command base class:

class LoadLevelCommand(Command)       :
    def __init__(self,ui,fileName):
        self.ui = ui
        self.fileName = fileName
    ...

It works as other commands: you add it to the command list, and the next game state update will execute it.

The first benefit of this approach is that we create a separate process for loading a level, and don't add another feature to the game state. It is still the same idea we saw many times in this series: divide and conquer!

The second benefit is that we can take advantage of the Command pattern features. We can better control the execution flow thanks to this pattern. For instance, an event in the game could trigger the loading of a new level. Thanks to this approach, it can be executed at the right moment, avoiding unexpected behaviors.

In the constructor below, we define two attributes:

Load the level file

The run() method of the LoadLevelCommand class starts the loading of the level:

def run(self):
    if not os.path.exists(self.fileName):
        raise RuntimeError("No file {}".format(self.fileName))
    tileMap = tmx.TileMap.load(self.fileName)
    ...

Lines 2 checks that the file exists. If it is not the case, we raise an exception (line 3), telling that the file does not exist. The exists() function of the os.path standard library returns True if the file exists, False otherwise. Don't forget to import os to get access to this function.

In the exception message, you can recognize the string formatting syntax, where the braces {} are replaced by the content of the argument in the following format() method. Now we saw the class syntax, we can see the method call of the instance "No file{}" of the Python string class. It is easier to understand if we introduce intermediate variables:

formatString = "No file{}"
msg = formatString.format(self.fileName)
raise RuntimeError(msg)

Line 3 calls the load() static method of the tmx.TileMap class:

tileMap = tmx.TileMap.load(self.fileName)

You can call static methods without an instance, like functions. Note that you need to use the name of the class in place of the instance. In this example, the class is tmx.TileMap. If it were MyClass, a call to a static method myMethod() would be MyClass.myMethod().

The returned value is an instance of the tmx.TileMap class. You can find a detailed description of this class here: http://python-tmx.nongnu.org/doc/.

Check, check, check!

Before starting the description of the loading procedure, I want to emphasize an essential aspect: the need for checks!

Loading an external file is always risky. Even if you are loading a file that you created with the software you own, there is still a risk that something happens. For instance, if your software updates itself, and introduce changes in the file format, you could have to face unexpected issues. It is even more problematic when other members of your team create these files. And finally, if you allow players to create their levels, many cases can happen.

It is the reason why you should add as many checks as you can, including checks that seem obvious. If something wrong happens, it will warn the player that the file format is not supported. Maybe the cause can be understandable for the player, and (s)he will be able to correct the level. For instance, if the format expects five layers, and the player creates a level with six layers, (s)he can correct it. For all other warnings, it will help you and members of your team in a better way than a crash or unexpected behavior. For the latter case, I have experimented with these cases, and you can trust me: loading a malformed file that silently leads to errors later in the application is a nightmare! If only I got some messages that tell me that some values of my file are incorrect!

Decode the tmx TiledMap instance

I continue the description of the run() method. The next lines check that the orientation of the map is orthogonal:

if tileMap.orientation != "orthogonal":
    raise RuntimeError("Error in {}: invalid orientation".format(self.fileName))

We can read most map properties using attributes of the tmx.TiledMap class, like orientation.

As described in the last post, we need maps with five layers:

if len(tileMap.layers) != 5:
    raise RuntimeError("Error in {}: 5 layers are expected".format(self.fileName))

The layers attribute of the tmx.TiledMap class is a list of tmx.Layer class instances.

We create a state variable that refers to the game state (to reduce the length of following lines) and set the world size:

state = self.ui.gameState
state.worldSize = Vector2(tileMap.width,tileMap.height)    

The next lines decode the first layer in the map (tileMap.layers[0]) and set the corresponding items in the game state and the UI accordingly:

tileset, array = self.decodeArrayLayer(tileMap,tileMap.layers[0])
cellSize = Vector2(tileset.tilewidth,tileset.tileheight)
state.ground[:] = array
imageFile = tileset.image.source
self.ui.layers[0].setTileset(cellSize,imageFile)

The decodeArrayLayer() is a method of the LoadLevelCommand class that returns the corresponding tmx.Tileset and the array of tile coordinates (as used in the game state).

I created the decodeArrayLayer() method because I don't want to type twice the same procedure. If you wonder when to create methods, here are two golden rules:

The following lines work the same, except that we decode the second layers:

tileset, array = self.decodeArrayLayer(tileMap,tileMap.layers[1])
if tileset.tilewidth != cellSize.x or tileset.tileheight != cellSize.y:
    raise RuntimeError("Error in {}: tile sizes must be the same in all layers".format(self.fileName))
state.walls[:] = array
imageFile = tileset.image.source
self.ui.layers[1].setTileset(cellSize,imageFile)

For the units layers, as described in the previous post, we use two layers: one for the tanks and another for the towers. They use another method decodeUnitsLayer() that returns a tileset and a list (instead of an array):

tanksTileset, tanks = self.decodeUnitsLayer(state,tileMap,tileMap.layers[2])
towersTileset, towers = self.decodeUnitsLayer(state,tileMap,tileMap.layers[3])
if tanksTileset != towersTileset:
    raise RuntimeError("Error in {}: tanks and towers tilesets must be the same".format(self.fileName))
if tanksTileset.tilewidth != cellSize.x or tanksTileset.tileheight != cellSize.y:
    raise RuntimeError("Error in {}: tile sizes must be the same in all layers".format(self.fileName))
state.units[:] = tanks + towers
cellSize = Vector2(tanksTileset.tilewidth,tanksTileset.tileheight)
imageFile = tanksTileset.image.source
self.ui.layers[2].setTileset(cellSize,imageFile)

All the code above doesn't exploit the splitting in two layers, we need it after, to know which units are the tanks:

self.ui.playerUnit = tanks[0]      

For now, our program only handles one player.

We use the last layer to know the tileset for bullets and explosions (we ignore the tile coordinates):

tileset, array = self.decodeArrayLayer(tileMap,tileMap.layers[4])
if tileset.tilewidth != cellSize.x or tileset.tileheight != cellSize.y:
    raise RuntimeError("Error in {}: tile sizes must be the same in all layers".format(self.fileName))
state.bullets.clear()
imageFile = tileset.image.source
self.ui.layers[3].setTileset(cellSize,imageFile)

Finally, the window size is updated:

windowSize = state.worldSize.elementwise() * cellSize
self.ui.window = pygame.display.set_mode((int(windowSize.x),int(windowSize.y))) 

At this point, we loaded the level, and the game can run.

The decodeArrayLayer() method

This method decodes a tmx.Layer instance, and returns the corresponding tileset and array of tile coordinates:

def decodeArrayLayer(self,tileMap,layer):
    tileset = self.decodeLayer(tileMap,layer)

    array = [ None ] * tileMap.height
    for y in range(tileMap.height):
        array[y] = [ None ] * tileMap.width
        for x in range(tileMap.width):
            tile = layer.tiles[x + y*tileMap.width]
            if tile.gid == 0:
                continue
            lid = tile.gid - tileset.firstgid
            if lid < 0 or lid >= tileset.tilecount:
                raise RuntimeError("Error in {}: invalid tile id".format(self.fileName))
            tileX = lid % tileset.columns
            tileY = lid // tileset.columns
            array[y][x] = Vector2(tileX,tileY)

    return tileset, array

Line 2 calls the decodeLayer() method. I created this method since both decodeArrayLayer() and decodeUnitsLayer() methods starts with the same procedure. I follow the golden rule and try not to repeat the same lines of code.

The rest of the method parses the list of tiles in the layer. A list is a one dimension array, and the output array has two dimensions. We have to convert from one to another (which is a common computation in games):

The decodeLayer() method

This last one checks the layer properties, and looks for the tileset corresponding to the layer:

def decodeLayer(self,tileMap,layer):
    if not isinstance(layer,tmx.Layer):
        raise RuntimeError("Error in {}: invalid layer type".format(self.fileName))
    if len(layer.tiles) != tileMap.width * tileMap.height:
        raise RuntimeError("Error in {}: invalid tiles count".format(self.fileName))

    # Guess which tileset is used by this layer
    gid = None
    for tile in layer.tiles:
        if tile.gid != 0:
            gid = tile.gid
            break
    if gid is None:
        if len(tileMap.tilesets) == 0:
            raise RuntimeError("Error in {}: no tilesets".format(self.fileName))
        tileset = tileMap.tilesets[0]
    else:
        tileset = None
        for t in tileMap.tilesets:
            if gid >= t.firstgid and gid < t.firstgid+t.tilecount:
                tileset = t
                break
        if tileset is None:
            raise RuntimeError("Error in {}: no corresponding tileset".format(self.fileName))

    # Check the tileset
    if tileset.columns <= 0:
        raise RuntimeError("Error in {}: invalid columns count".format(self.fileName))
    if tileset.image.data is not None:
        raise RuntimeError("Error in {}: embedded tileset image is not supported".format(self.fileName))

    return tileset

The part that tries to guess the corresponding tileset (lines 8-24) is a bit tricky. If you don't understand, it is not an issue. It is because our game uses a specific tileset for each layer, but Tiled layers can use any tileset. So, we first found the first non-zero tile global id (lines 8-12). If we found no one (line 13), then we select the first tileset (if it exists). If we found one, we search the corresponding tileset (lines 18-24).

Final program

Download code and assets

In the next post, I'll start the creation of a game menu.