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
You can see the game with the level in the "level2.tmx" file:
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
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:
ui
: a reference to the user interface, to get access to all data (controls, display and game state);fileName
: the name of the file to loadThe 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/.
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!
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.
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):
x + y*width
. In this example, the width is the one of the map: tileMap.width
;index
into 2D: x = index % width
and y = index // width
(//
is the integer divide). In this example, we convert the 1D tile identifier (lid
) into 2D tile coordinates tileX
and tileY
.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).
In the next post, I'll start the creation of a game menu.