10. Tutorial: Maze game
In this chapter we will build a maze game together, step by step. The Python we will use is quite simple: mostly just conditionals and loops. The technique of creating a tilemap is common in games and after seeing it here you should be able to incorporate it into your own projects.
10.1. Tilemap
A tilemap uses a small number of images (the tiles) and draws them many times to build a much larger game level (the map). This saves you from creating a lot of artwork and makes it very easy to change the design of the level on a whim. Here we create a maze level.
We must create three image files for the tiles: empty.png
,
wall.png
and goal.png
and save them in the mu_code/images
folder (accessible with the images
button in Mu).
They must each be 64×64 pixels. For the player we will use the built in
alien
image.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | TILE_SIZE = 64
WIDTH = TILE_SIZE * 8
HEIGHT = TILE_SIZE * 8
tiles = ['empty', 'wall', 'goal']
maze = [
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 1, 2, 0, 1],
[1, 0, 1, 0, 1, 1, 0, 1],
[1, 0, 1, 0, 0, 0, 0, 1],
[1, 0, 1, 0, 1, 0, 0, 1],
[1, 0, 1, 0, 1, 0, 0, 1],
[1, 0, 1, 0, 0, 0, 0, 1],
[1, 1, 1, 1, 1, 1, 1, 1]
]
player = Actor("alien", anchor=(0, 0), pos=(1 * TILE_SIZE, 1 * TILE_SIZE))
def draw():
screen.clear()
for row in range(len(maze)):
for column in range(len(maze[row])):
x = column * TILE_SIZE
y = row * TILE_SIZE
tile = tiles[maze[row][column]]
screen.blit(tile, (x, y))
player.draw()
|
The filenames of the tile images are stored in a list, tiles
. The
level design is stored in a list of lists, more commonly called a two
dimensional array. There are 8 rows and 8 columns in the array. If you
change the size of the array you will need to change the WIDTH
and
HEIGHT
values too. The numbers in the maze
array refers to
elements in the tiles
array. So 0
means empty
and 1
means wall
, etc.
To draw the maze we use a for
loop within another for
loop. The
outer loop iterates over the rows and the inner loop iterates over the
columns, i.e. the elements of the row.
Exercise
Create your own images empty.png, wall.png and goal.png and run the program.
Exercise
The player appears at the top left of the maze at row 1 column 1. Change the pos parameter so he appears at the bottom instead.
Exercise
Change the design of the maze by changing the numbers in the maze array.
Advanced
Make the maze bigger.
10.2. Moving the player
Add this code to the end of the program:
def on_key_down(key):
row = int(player.y / TILE_SIZE)
column = int(player.x / TILE_SIZE)
if key == keys.UP:
row = row - 1
if key == keys.DOWN:
row = row + 1
if key == keys.LEFT:
column = column - 1
if key == keys.RIGHT:
column = column + 1
player.x = column * TILE_SIZE
player.y = row * TILE_SIZE
This function will be called automatically by Pygame, like draw()
and update()
. However on_key_down()
is not called every frame;
it is only called when the player presses a key. The key that was
pressed is passed to the function in the key
parameter.
Exercise
Run the program and move the player. Are there any problems with the movement?
10.3. Restricting where the player can move
Delete the last two lines of the program and replace them with this modified version:
tile = tiles[maze[row][column]]
if tile == 'empty':
player.x = column * TILE_SIZE
player.y = row * TILE_SIZE
elif tile == 'goal':
print("Well done")
exit()
Exercise
Run the program and check that the player now only moves if the tile is empty.
Exercise
Check that the game ends when the player reaches the goal.
10.4. Animate the movement of the player
First, the alien Actor is bit too big. Draw a new image of size 64×64
pixels and save it as player.png
in the images
folder. In the
program, change the line:
player = Actor("alien", anchor=(0, 0), pos=(1 * TILE_SIZE, 1 * TILE_SIZE))
to
player = Actor("player", anchor=(0, 0), pos=(1 * TILE_SIZE, 1 * TILE_SIZE))
Next, the movement of the Actor is sudden and jerky. Luckily Pygame includes a function to do smooth movement for us automatically. Find these lines of the program:
if tile == 'empty':
player.x = column * TILE_SIZE
player.y = row * TILE_SIZE
replace them with:
if tile == 'empty':
x = column * TILE_SIZE
y = row * TILE_SIZE
animate(player, duration=0.1, pos=(x, y))
Exercise
Verify the player image has changed and moves smoothly.
10.5. Create an enemy
We will create a simple enemy that moves up and down. Add this code near
the top just above the draw()
function.
enemy = Actor("enemy", anchor=(0, 0), pos=(3 * TILE_SIZE, 6 * TILE_SIZE))
enemy.yv = -1
To make the enemy visible, add this line at the end of the draw()
function, after the player is drawn:
enemy.draw()
enemy.yv
is the velocity in the y-axis direction (up and down). Add
these lines to end of the program (still inside the on_key_down()
function) to make the enemy move and reverse velocity when it hits a
wall.
# enemy movement
row = int(enemy.y / TILE_SIZE)
column = int(enemy.x / TILE_SIZE)
row = row + enemy.yv
tile = tiles[maze[row][column]]
if not tile == 'wall':
x = column * TILE_SIZE
y = row * TILE_SIZE
animate(enemy, duration=0.1, pos=(x, y))
else:
enemy.yv = enemy.yv * -1
if enemy.colliderect(player):
print("You died")
exit()
Exercise
Verify that the enemy moves up and down and kills the player.
Advanced
Make another enemy that moves horizontally (left and right).
Advanced
The collision detection is quite lenient (i.e. buggy) because it only tests for collisions between the enemy and player when a key is pressed. Define a new function called update() and move the collisions detection there so that is called every frame.
10.6. A locked door and a key
We will add two new tiles to the game. Draw images door.png
and
key.png
and save them in images
folder.
Find the tiles
list near the top and change it to include the
new images, and modify the maze
with some number 3s and 4s where you
want to new tiles to appear. Mine looks like this:
tiles = ['empty', 'wall', 'goal', 'door', 'key']
unlock = 0
maze = [
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 1, 2, 0, 1],
[1, 0, 1, 0, 1, 1, 3, 1],
[1, 0, 1, 0, 0, 0, 0, 1],
[1, 0, 1, 0, 1, 0, 0, 1],
[1, 0, 1, 4, 1, 0, 0, 1],
[1, 0, 1, 0, 0, 0, 0, 1],
[1, 1, 1, 1, 1, 1, 1, 1]
]
At the top of the program, create a new variable to store the number of keys the player is carrying:
unlock = 0
Find the if
statement where we test for goal
:
if tile == 'goal':
print("Well done")
exit()
Modify it like this to also test for the key and door tiles. Since we are modifying a global variable inside a function we must declare it.
global unlock
if tile == 'goal':
print("Well done")
exit()
elif tile == 'key':
unlock = unlock + 1
maze[row][column] = 0 # 0 is 'empty' tile
elif tile == 'door' and unlock > 0:
unlock = unlock - 1
maze[row][column] = 0 # 0 is 'empty' tile
10.7. Finished game
Here is the finished game with all the changes included:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | TILE_SIZE = 64
WIDTH = TILE_SIZE * 8
HEIGHT = TILE_SIZE * 8
tiles = ['empty', 'wall', 'goal', 'door', 'key']
unlock = 0
maze = [
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 1, 2, 0, 1],
[1, 0, 1, 0, 1, 1, 3, 1],
[1, 0, 1, 0, 0, 0, 0, 1],
[1, 0, 1, 0, 1, 0, 0, 1],
[1, 0, 1, 4, 1, 0, 0, 1],
[1, 0, 1, 0, 0, 0, 0, 1],
[1, 1, 1, 1, 1, 1, 1, 1]
]
player = Actor("player", anchor=(0, 0), pos=(1 * TILE_SIZE, 1 * TILE_SIZE))
enemy = Actor("enemy", anchor=(0, 0), pos=(3 * TILE_SIZE, 6 * TILE_SIZE))
enemy.yv = -1
def draw():
screen.clear()
for row in range(len(maze)):
for column in range(len(maze[row])):
x = column * TILE_SIZE
y = row * TILE_SIZE
tile = tiles[maze[row][column]]
screen.blit(tile, (x, y))
player.draw()
enemy.draw()
def on_key_down(key):
# player movement
row = int(player.y / TILE_SIZE)
column = int(player.x / TILE_SIZE)
if key == keys.UP:
row = row - 1
if key == keys.DOWN:
row = row + 1
if key == keys.LEFT:
column = column - 1
if key == keys.RIGHT:
column = column + 1
tile = tiles[maze[row][column]]
if tile == 'empty':
x = column * TILE_SIZE
y = row * TILE_SIZE
animate(player, duration=0.1, pos=(x, y))
global unlock
if tile == 'goal':
print("Well done")
exit()
elif tile == 'key':
unlock = unlock + 1
maze[row][column] = 0 # 0 is 'empty' tile
elif tile == 'door' and unlock > 0:
unlock = unlock - 1
maze[row][column] = 0 # 0 is 'empty' tile
# enemy movement
row = int(enemy.y / TILE_SIZE)
column = int(enemy.x / TILE_SIZE)
row = row + enemy.yv
tile = tiles[maze[row][column]]
if not tile == 'wall':
x = column * TILE_SIZE
y = row * TILE_SIZE
animate(enemy, duration=0.1, pos=(x, y))
else:
enemy.yv = enemy.yv * -1
if enemy.colliderect(player):
print("You died")
exit()
|
10.8. Ideas for extension
However that is not the end! There are many things you could add to this game.
Show the player score.
Coins that the player collects to increase score.
Trap tiles that are difficult to see and kill the player.
Treasure chest that is unlocked with the key and increases score.
Instead of ending the game, give the player 3 lives.
Add more types of tile to the map: water, rock, brick, etc.
Change the player image depending on the direction they are moving.