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:
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:
Attributes:
fileName
: the name of the tmx file;tileMap
: the file data as returned by the TMX library;width
, height
: size of the levelMethods:
__init__()
: opens and reads the file content;decodeLayer()
: returns the tile coordinates in the tileset, the image file of the tileset, the width of tiles, and the height of tiles.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.
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.
For the width
and height
attributes, I propose to create properties:
@property
def width(self) -> int:
return self.__tileMap.width
@property
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
.
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
break
if gid is None:
tileset = tilesets[0]
else:
tileset = None
for t in tilesets:
if t.firstgid <= gid < t.firstgid + t.tilecount:
tileset = t
break
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 tileset.image.data 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
else:
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
In the previous run.py
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.
In the next post, we'll extend the facade to handle the rendering of several layers.