Design Patterns and Video Games

OpenGL 2D Facade (9): Load a level

Before handling the rendering of several layers, we need data to build them. Rather than copy and paste many ids from a Tiled map file, I propose to read them using the TMX library.

This post is part of the OpenGL 2D Facade series


This post aims to create a class able to load any layer of a Tiled map file. I extended the previous one (with a ground layer) and added a new layer with a river:

OpenGL Python Water Layer

We only see this layer in the screenshot. If we select the first one an run the program again, we can see the ground layer.

I propose to create the following class to read Tiled map files:

Python TMX level loader class



Note that I assume that each layer is using a single tileset to ease the understanding.

Also, note that the API does not depend on the TMX library: for its users, this library does not exist. If we have to use another one later, we won't have to update the code that uses the LevelLoader class.

Open the file

The constructor of the LevelLoader class reads the content of the file thanks to the TMX library:

def __init__(self, fileName: str):
    self.__fileName = fileName

    # Load map
    if not os.path.exists(fileName):
        raise RuntimeError("No file {}".format(fileName))
    self.__tileMap = TileMap.load(fileName)

    # Check main properties
    if self.__tileMap.orientation != "orthogonal":
        raise RuntimeError("Error in {}: invalid orientation".format(fileName))

Note that, if not already done, you can install the TMX library with pip (I use version 1.10; other versions could not be compatible with the code of this post):

pip install tmx==1.10

The first line of the constructor sets the fileName attribute. It is mainly for error messages, to know which file caused an error.

Lines 5-7 check that the file exists, and load it using the TMX library. In this code, we assume that we import TileMap from the tmx package; in other words, we have from tmx import TileMap at the beginning of the program.

Lines 10-11 check properties of the map. We only check the orientation of the map, but it could be safer to check other properties, especially if we want to let players create their own levels.

Size of the level

For the width and height attributes, I propose to create properties:

def width(self) -> int:
    return self.__tileMap.width

def height(self) -> int:
    return self.__tileMap.height

This approach is safer because we don't need to synchronize two true width and height attributes with the content of the tileMap attribute. Otherwise, if tileMap is updated, we would have to update width and height.

Decode a layer

The decodeLayer() method is the one that does the heavy lifting:

def decodeLayer(self, layerIndex) -> (np.ndarray, str, int, int):

We first get the desired layer using the layers attribute of the TileMap class:

if layerIndex >= len(self.__tileMap.layers):
    raise RuntimeError("Error in {}: no layer {}".format(self.fileName, layerIndex))
layer = self.__tileMap.layers[layerIndex]  # type: Layer

Note that there is another check. When you read files, and especially ones that can be created by other people, you should do as many checks as possible and return good error messages. It is to help you debug issues, but also to help others understand why their file is not correct.

Also note the python 2 typing comment: # type: Layer. It is the same as layer: Layer = ..., but it works with Python 2 and 3. It is something that seems more readable to me, but there is no golden rule; it's a question of taste.

Then, we get the tile list in the tiles attribute of the TileMap class:

tiles = layer.tiles  # type: List[LayerTile]
if len(tiles) != self.width * self.height:
    raise RuntimeError("Error in {}: invalid tiles count".format(self.fileName))

Note that is a list of LayerTile instances, also part of the TMX library, and it assumes a from tmx import LayerTile at the beginning of the program. For those who don't know it, List is a class from the typing package (from typing import List) that tells Python that the object is a list.

The following lines get and check the list of tilesets:

tilesets = self.__tileMap.tilesets  # type: List[Tileset]
if len(tilesets) == 0:
    raise RuntimeError("Error in {}: no tilesets".format(self.fileName))

The next lines try to find the tileset used by the selected layer:

gid = None
for tile in tiles:
    if tile.gid != 0:
        gid = tile.gid
if gid is None:
    tileset = tilesets[0]
    tileset = None
    for t in tilesets:
        if t.firstgid <= gid < t.firstgid + t.tilecount:
            tileset = t
    if tileset is None:
        raise RuntimeError("Error in {}: no corresponding tileset".format(self.fileName))

Tiled encodes each cell of the layer using a unique id (an integer). Each id corresponds to a tile in one of the tilesets. Then, for each tileset, Tiled defines a range of ids. These ranges can be retrieved using the firstgid and tilecount attributes of the TMX Tileset class.

Lines 1-5 pick the first tile id in the current layer. If we have one of these ids, we can find the corresponding tileset. It works because we assume that of tiles of a layer are all from the same tileset.

Lines 6-7 handle the case where the layer is empty (a zero id means no tile).

Lines 8-15 iterate through all tilesets, and if the id we picked is in the range of a tileset, then we found it.

After that, we perform some checks on the tileset that we found:

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

The last part of the decodeLayer() method creates the Numpy array with the tile coordinates of the layer:

tileCoords = np.zeros((self.width, self.height, 2), dtype=np.int32)
for y in range(self.height):
    for x in range(self.width):
        tile = layer.tiles[x + y * self.width]
        if tile.gid == 0:
            tileCoords[x, y, 0] = tileset.columns - 1
            tileCoords[x, y, 1] = tileset.columns - 1
            lid = tile.gid - tileset.firstgid
            if lid < 0 or lid >= tileset.tilecount:
                raise RuntimeError("Error in {}: invalid tile id".format(self.fileName))
            tileCoords[x, y, 0] = lid % tileset.columns
            tileCoords[x, y, 1] = lid // tileset.columns

Line 1 creates the Numpy array.

Lines 2-3 iterate through all cell coordinates (x,y) of the layer.

Line 4 gets the tile id at the current cell coordinates. Note that we have to turn a 2D coordinate into a 1D index: you can recognize the usual expression with a left to right and top to bottom order.

Lines 5-7 handle the case of an empty cell (no tile to draw). We set the coordinates at the bottom right of the tileset, assuming that there is always a fully transparent tile. It is not the most elegant way to handle this, but it is a simple and effective one, given our current implementation of the OpenGL facade.

Lines 8-13 first subtract the first id of the tileset to get a value between 0 and the number of tiles minus one (line 9). If this relative id is correct (lines 10-11), we can convert this 1D index into 2D coordinates (lines 12-13). You can recognize the inverse of a 2D to 1D conversion.

Finally, we return the tile coordinates, the image file of the tileset, and the width and height of tiles:

return tileCoords, tileset.image.source, tileset.tilewidth, tileset.tileheight

Main program update

In the previous file, we replace all content related to the building of the level data by the following lines:

levelLoader = LevelLoader("level.tmx")
levelWidth = levelLoader.width
levelHeight = levelLoader.height
level, textureImage, tileWidth, tileHeight = levelLoader.decodeLayer(1)

We call the decodeLayer() method with layer index 1, but you can try with 0 to render the ground layer.

Final program

Download code and assets

In the next post, we'll extend the facade to handle the rendering of several layers.