by Dan Duda
Requirements
- Python 3.7
- PyCharm Community (optional)
- Basic Python knowledge
Introduction
In part 4 of this tutorial series we were able to draw our maze centered on our display. Now it’s time to start making it into a playable game.
Player sprites
PyGame includes a Sprite object that we can use for our players. Sprites represent a discrete image on the screen that can move around, etc. Let’s create a new class that uses the Sprite object.
1 | class Player(pg.sprite.Sprite): |
In line 1 we define a new class called “Player” that inherits from “pg.sprite.Sprite”. In the “init” method we will require a color, starting x and y coordinates, and a radius. Our players will be represented by colored circles. Since a Sprite is actually a rectangle behind the scenes we create a rectangle that will fit the player cirlce and make it transparent on lines 11-13. On line 16 we draw our player circle onto the transparent rectangle. We then get the image and set its starting x and y position. On line 22 we create a “reset” method that we can call after a game ends to put the player back in their starting position.
Let’s add a couple color constants for our players. Player 1 will be red and player 2 will be blue.
1 | PLAYER1_COLOR = (255, 0, 0) |
In our MazeGenerator “init” method we will create instances of the Player class to represent our players. We will position player 1 in the upper left corner of the maze and player 2 in the bottom right corner. We create instances of the Player class on lines 2 and 6. Lines 4 and 10 setup some placeholder variables for the players’ sprite objects used for drawing later on.
1 | # Create players |
We will initialize our players’ sprites in the “initialize” method we added in part 4.
1 | def initialize(self): |
Let’s also add a new method called “draw_screen” that will handle drawing everything that needs to be drawn.
1 | def draw_screen(self): |
And we’ll need to call “draw_screen” from our “run_game” method. We’ll call this inside the main game loop so that the screen is updated every frame.
1 | def run_game(self): |
Also since it can take a long time to display the maze being generated we can set out SHOW_DRAW constant to False.
1 | SHOW_DRAW = False # Show the maze being created |
If we run the app now we see our two players positioned at their starting points.
Game goals
Our players should have a goal. We will mark exits for each player at the opposing player’s starting point. We will just color the wall next to the player with the opposing player’s color.
In the “draw_maze” method let’s add the following code. This will go at the bottom of the method outside of the loop.
1 | # Color the player exits |
Now we should see the colored exits.
Moving the player
To be able to move the players on the screen we now have to deal with keyboard events. We also need to figure out how to have two players use a single keyboard to play at the same time. An obvious choice for moving a player would be the arrow keys. Since we’ll have a second player I chose the WASD keys as the other control keys as they’re popular in 1st person shooters and also because they’re closer to the other side of the keyboard from the arrow keys.
One way of detecting key presses in PyGame is to use the “pg.key.get_pressed” method and that is what we will use. In our main game loop for each frame we will check if any keys are pressed and then check if they were our control keys and if so call a method to process those actions. Our “run_game” method should now look like this:
1 | def run_game(self): |
We first check if any keys were pressed and store them in “keys”. Then we check for each control key and if pressed call an action method passing in the player. We pass in the player so that we only need to create 4 action methods, one for each direction.
Now we create the actions methods.
1 | def move_up(self, player): |
We need to verify that the player can actually move in the direction they are trying to move so we first call a method called “can_move” that we will need to create. If the player is able to move then we move the player’s sprite in that direction by 1 pixel.
And now for the “can_move” method.
1 | def can_move(self, direction, player): |
This is probably the most complicated method in the game so let’s break it down. A player’s sprite can occupy either a single cell or 2 cells (for when the player is moving from one cell to another). We get the top-left corner of the first maze cell to use as an offset on lines 3 and 4. We know that each cell is a block size * 4 (3 for path and one for wall) so we store that in square. Then using the player’s rectangle position we calculate the top-left corner of the player’s position and store in p1 and the bottom-right corner in p2. We then calculate the player’s x and y maze coordinate (not pixel but grid). From the grid x and y position we use our helper method on lines 12 and 13 to get the indexes into our maze list. If the player is occupying a single cell the indexes will be the same.
On lines 15-20 we create a dictionary look up some helper methods that we will write. This is a shortcut to writing a long if,elif clause. Since we passed in the direction we can then call the helper method with a single statement on line 22. We pass the two cell indexes to whichever helper method will be called.
And the 4 new helper methods are defined as.
1 | def can_move_up(self, index1, index2): |
Based on the direction we use our bitwise operator to check if there is a pathway in that direction. If the player is between two cells then we can assume there is a pathway. If the player is trying to move between two cells we verify that it’s in the correct direction.
Run the game now and you be able to move both players around. Notice though they move very slowly. Recall that in our main game loop we are calling “draw_screen” in every iteration of the loop, every frame. Our “draw_screen” method in turn calls our “draw_maze” method. The “draw_maze” method is fairly intensive with lots of nested loops so can use a lot of CPU cycles to run.
Optimizing our drawing
To see the problem more clearly let’s check what our FPS (frames per second) are when we play our game. PyGame includes a way to easily do this. At the very top of our “run_game” method add the following line.
1 | clock = pg.time.Clock() |
Then right after we call “draw_screen” add the following lines so it looks like this.
1 | self.draw_screen() |
This will update the window caption every frame, adding on the current FPS.
Run the app again and look at the window caption. On my system I get about 20 FPS which is pretty slow especially since our game doesn’t do much.
So how can we fix this? Note that once we generate the maze for a game it doesn’t change. What we can do is save the finished maze as an image and then just redraw the image to the screen each frame instead processing the maze list every time.
We’ll need a variable to store the image of the maze in so in our “init” method we’ll add the following after we call “random.seed()”.
1 | # Store maze as image after we create it so that we just have to redraw the image on update |
And in our “generate_maze” method at the bottom outside of the loop add:
1 | # save image of completed maze |
We first draw the completed maze to the display and then use the “screen.copy()” method to save it to our “maze_image” variable.
In the “draw_screen” method replace the call to “draw_maze()” with the following.
1 | self.screen.blit(self.maze_image, (0, 0)) |
This will draw the image we stored to the display at position 0,0.
Now run the game again and note the FPS. On my system I’m hovering around 400 FPS. Quite a jump from 20 FPS.
Winning condition
We can move our players around the maze and we’ve optimized the drawing. Now how does a player win? We just need to check if the player’s position is at the opposing player’s starting position. We can do this in the “can_move” method.
First let’s add a couple of boolean flags to our “init” method so we can trigger a win condition. Add these after we create the player and player sprite variables.
1 | self.win1_flag = False |
Then in the “can_move” method before our return statement add.
1 | # Check for maze exit/win |
And change our “run_game” to not process key events if a player has won as well as call a method to display a win message.
1 | def run_game(self): |
Add a new method called “display_win”.
1 | def display_win(self): |
We’ll use PyGame’s font system to display a message in the center of the maze when a player wins and then wait 3 seconds. After the message displays we call “initialize” again to generate a new maze and then we can play again.
Let’s also add a color constant for our MESSAGE_COLOR. We’ll make it green.
1 | MESSAGE_COLOR = (0, 255, 0) |
You should be able to play it and see the win message appear when you reach an exit.
Finishing touches
Let’s finish up by adding some polish to our game. It would be nice to keep score and to also display the command keys for each player. Also you may notice that player 1 has a little easier time with the maze most of the time. This is because the maze_generator starts processing from the top-left corner so is usually far into the maze before it does any back-tracking. So in general player 2 will have more choices to make early on than player 1. We can alleviate this by alternating which corner the maze generator starts at each round.
Starting in our “init” method let’s add variables to store the current round and player scores.
1 | self.player1_score = 0 |
In the “generate_maze” method replace the following code:
1 | process_stack = [(0, 0)] |
with this code:
1 | # Start at alternating corners to be more fair |
In the “draw_screen” method, before the “display_update()” call add the following:
1 | font = pg.font.SysFont('Arial', 18, True) |
And finally in the “display_win” method right after we set the “msg” variable add the following:
1 | self.round += 1 |
Checkout more advanced topics in the next part:
Goto Part 6
Final code and code download
Download code: maze.zip
1 | # Simple Maze Game Using The Recursive Back-Tracker Algorithm |