How to write a slide puzzle game with Python and Pygame (2020 tutorial)
Table of Contents
- Preparation
- Introduction
- Import modules and define contants
- Create the main game screen
- Creae the data structure of the board
- Let's draw the tiles
- The game logic
- Define the missing functions
Preparation
In this tutorial, we are gonna use Python 3.8 and Pygame v1.96. For the installation of Python and Pygame, please follow the documents found on each webiste.
Python:
Introduction
As you can see from the picture above, we create a board with 15 green tiles and one white tile which represent a empty slot. Our gaol is to place all the 15 green tiles back into its sorted order, namely:
A B C D
E F G H
I J K L
M N O
If we click on the tile "F", the tile will slide down south. And if all the tiles are back into their sorted order, you won the game and quit the game. That's pretty much the gist of the game, later you could add other features to the game.
Import modules and define contatnts
import pygame, sys, random
from pygame.locals import *
BOARDWIDTH = 4
BOARDHEIGHT = 4
TILE_SIZE = 80
TILE_SPACE = 2
START_POS = (156,76)
WINDOWWIDTH = 640
WINDOWHEIGHT = 480
FPS = 30
# color set up
BLACK = (0,0,0)
WHITE = (255,255,255)
GREEN = (0,255,0)
We create a 4 x 4 board, each tile is 80 pixels wide and there is a 2 px space between each tile.
We set the screen size as 640 x 480 and the FPS as 30 for smooth game play. In the end, we pre-set some colors for later use.
Create the main game screen
import pygame, sys, random
from pygame.locals import *
BOARDWIDTH = 4
BOARDHEIGHT = 4
TILE_SIZE = 80
TILE_SPACE = 2
START_POS = (156,76)
WINDOWWIDTH = 640
WINDOWHEIGHT = 480
FPS = 30
# color set up
BLACK = (0,0,0)
WHITE = (255,255,255)
RED = (255,0,0)
GREEN = (0,255,0)
pygame.init()
# create main game window
screen = pygame.display.set_mode((WINDOWWIDTH, WINDOWHEIGHT))
pygame.display.set_caption('Slide Puzzle')
# change the background color to white
screen.fill(WHITE)
# create a Clock object
game_clock = pygame.time.Clock()
while True: # main game loop
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
sys.exit()
pygame.display.update()
game_clock.tick(FPS)
Then we fill the background with white color.
screen.fill(WHITE)
Pygame let you easily control the framerate of your game by using:
game_clock = pygame.time.Clock()
game_clock.tick(FPS)
Then, we reach our game loop where we put the game logic.
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
sys.exit()
We loop all the events that returned by the pygame.event.get() method and check if it is a QUIT event or not (clicking on the 'x' button of the main window triggers the QUIT event.). If it is a QUIT event, we invoke pygame.quit() and sys.quit() together to safely quit the game.
Finally, in order to see changes we have made to the main screen, we have to invoke pygame.display.update() method to refresh the display screen, otherwise we won't see any changes that we made.
Then you could run the code above, you should see the following screenshot.
By clicking on the 'x' button, you could safely exit the application.
Create the data structure of the board
Just like any other 2D board games, we usually use a list of list data structure to store all the information of the board. The slide game is no exception.
def get_tile_rects(start_pos):
tile_rects = []
empty_tile = (random.randint(0,3), random.randint(0,3))
random_letters = [chr(i) for i in range(65,65+15)]
random.shuffle(random_letters)
n = 0
for i in range(4):
tile_rects.append([])
for j in range(4):
rect = pygame.Rect(
(start_pos[0]+i*(TILE_SIZE+TILE_SPACE),
start_pos[1]+j*(TILE_SIZE+TILE_SPACE),
TILE_SIZE,TILE_SIZE))
if (i,j) == empty_tile: # if empty tile, attach a empty string
tile_rects[i].append([rect,''])
continue
else:
tile_rects[i].append([rect,random_letters[n]])
n += 1
return tile_rects, empty_tile
The get_tile_rects() method takes the start_pos as the argument that is (156,76) in this case.
tile_rects = []
We create a tile_rects list which will hold all the 15 green tiles and 1 empty white tile's Rect object.
empty_tile = (random.randint(0,3), random.randint(0,3))
We need to specify a empty tile before hand. It is just a random position such as (0,0), (1,3).
random_letters = [chr(i) for i in range(65,65+15)]
ramdom.shuffle(random_letters)
Each of the 15 green tiles wil have a letter (A-O) attached and the empty tile is an empty string ''. We here use list comprehension [chr(i) for i in range(65,65+15)] to generate a list of 15 capitalized letters (A-O).
['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O']
The built-in function chr() will change a integer into a unicode character. Integers from 65-79 are characters 'A' - 'O', which is enough for our game.
'C'
>>> chr(45)
'-'
>>> chr(33)
'!'
We then shuffle the list in place by using random.shuffle():
>>> random.shuffle(random_letters)
>>> random_letters
['J', 'L', 'H', 'N', 'B', 'M', 'I', 'D', 'C', 'O', 'F', 'G', 'K', 'A', 'E']
We create a variable n for accessing random_letters list and attach the letter to each green tile.
Next, it is our nested for loop creating the list of list data structure.
for i in range(4):
tile_rects.append([])
for j in range(4):
rect = pygame.Rect(
(start_pos[0]+i*(TILE_SIZE+TILE_SPACE),
start_pos[1]+j*(TILE_SIZE+TILE_SPACE),
TILE_SIZE,TILE_SIZE))
if (i,j) == empty_tile: # if empty tile, attach a empty string
tile_rects[i].append([rect,''])
continue
else:
tile_rects[i].append([rect,random_letters[n]])
n += 1
test_list = []
for i in range(4):
test_list.append([])
for j in range(4):
test_list[i].append(f'({i},{j})')
We use pygame.Rect(left, top, width, height) to create a Rect object.
left: x coordinate of the top-left corner of the tile
top: y coordinate of the top-left corner of the tile
height, width : tile size
The confused part is the math trick we use to calculate the top-left corner coordinates. We first calculate the first column, then the second, and so forth.
After creating the Rect object, we need to associate a lettet with the Rect object. If the current iteration happens to be the empty tile, we associate an empty string with the Rect object that created before and jump to the next iteration without updating the variable n. Otherwise associate a letter with the Rect object and add it the list and update the variable n.
Finally, the furnction returns a tuple of tile_rects and empty_tile.
So, let's test the code:
import pygame, random
TILE_SIZE = 80
TILE_SPACE = 2
def get_tile_rects(start_pos):
tile_rects = []
empty_tile = (random.randint(0,3), random.randint(0,3))
random_letters = [chr(i) for i in range(65,65+15)]
random.shuffle(random_letters)
n = 0
for i in range(4):
tile_rects.append([])
for j in range(4):
rect = pygame.Rect(
(start_pos[0]+i*(TILE_SIZE+TILE_SPACE),
start_pos[1]+j*(TILE_SIZE+TILE_SPACE),
TILE_SIZE,TILE_SIZE))
if (i,j) == empty_tile: # if empty tile, attach a empty string
tile_rects[i].append([rect,''])
continue
else:
tile_rects[i].append([rect,random_letters[n]])
n += 1
return tile_rects, empty_tile
pygame.init()
tile_rects, empty_tile = get_tile_rects((156,76))
print(tile_rects)
The output shoud look like this (your code should be different for the letters):
[[[<rect(156, 76, 80, 80)>, 'M'], [<rect(156, 158, 80, 80)>, 'G'], [<rect(156, 240, 80, 80)>, 'N'], [<rect(156, 322, 80, 80)>, 'B']], [[<rect(238, 76, 80, 80)>, 'A'], [<rect(238, 158, 80, 80)>, 'J'], [<rect(238, 240, 80, 80)>, 'K'], [<rect(238, 322, 80, 80)>, 'L']], [[<rect(320, 76, 80, 80)>, 'O'], [<rect(320, 158, 80, 80)>, 'E'], [<rect(320, 240, 80, 80)>, 'D'], [<rect(320, 322, 80, 80)>, 'H']], [[<rect(402, 76, 80, 80)>, 'F'], [<rect(402, 158, 80, 80)>, 'I'], [<rect(402, 240, 80, 80)>, ''], [<rect(402, 322, 80, 80)>, 'C']]]
Let's draw the tiles
# create a text_font Font object to render text
text_font = pygame.font.SysFont('arial', 80)
def draw_tiles(tile_rects, empty_tile):
global text_font, screen
for i in range(4):
for j in range(4):
if (i,j) == empty_tile:
pygame.draw.rect(screen, WHITE, tile_rects[i][j][0])
else:
pygame.draw.rect(screen, GREEN, tile_rects[i][j][0])
# draw letters on each tile
txt_suf = text_font.render(tile_rects[i][j][1], True, BLACK)
screen.blit(txt_suf, tile_rects[i][j][0])
We need to draw both the tiles and the letter associated with it. First we create a text_font Font object ot render the text letters ('arial' font-type size 80).
text_font = pygame.font.SysFont('arial', 80)
Our draw_tiles() functions takes the tile_rects list and the empty_tile position as the arguments. It will iterate through all the Rect objects we created and all the letters associated with each Rect object; and draw both on the screen Surface object.
Here, we use two global variables text_font and screen.
global text_font, screen
Because our tile_rects object is a list of list or 2D list, we use our fimiliar nested for loop to draw its elements.
for i in range(4):
for j in range(4):
If the current iteration happens to be the empty_tile, we draw a white rectangle at this position only.
if (i,j) == empty_tile:
pygame.draw.rect(screen, WHITE, tile_rects[i][j][0])
Otherwise we draw the Rect object with green fill color and its associated letter on the screen.
else:
pygame.draw.rect(screen, GREEN, tile_rects[i][j][0])
# draw letters on each tile
txt_suf = text_font.render(tile_rects[i][j][1], True, BLACK)
screen.blit(txt_suf, tile_rects[i][j][0])
So let's test all the code we have written so far.
# Slide Puzzle Game
import pygame, sys, random
from pygame.locals import *
BOARDWIDTH = 4
BOARDHEIGHT = 4
TILE_SIZE = 80
TILE_SPACE = 2
START_POS = (156,76)
WINDOWWIDTH = 640
WINDOWHEIGHT = 480
FPS = 30
# color set up
BLACK = (0,0,0)
WHITE = (255,255,255)
RED = (255,0,0)
GREEN = (0,255,0)
# create a tile_rects list to store all the tile Rect objects
# the top-left corner coordinates of the top-left tile is (156,76)
# each square tile has a width of 80 and a space of 2 pixels between each of them
# there will be a random empty tile
# each tile should have a letter attached to it (A-O)
def get_tile_rects(start_pos):
tile_rects = []
empty_tile = (random.randint(0,3), random.randint(0,3))
random_letters = [chr(i) for i in range(65,65+15)]
random.shuffle(random_letters)
n = 0
for i in range(4):
tile_rects.append([])
for j in range(4):
rect = pygame.Rect(
(start_pos[0]+i*(TILE_SIZE+TILE_SPACE),
start_pos[1]+j*(TILE_SIZE+TILE_SPACE),
TILE_SIZE,TILE_SIZE))
if (i,j) == empty_tile: # if empty tile, attach a empty string
tile_rects[i].append([rect,''])
continue
else:
tile_rects[i].append([rect,random_letters[n]])
n += 1
return tile_rects, empty_tile
def draw_tiles(tile_rects, empty_tile):
global text_font, screen
for i in range(4):
for j in range(4):
if (i,j) == empty_tile:
pygame.draw.rect(screen, WHITE, tile_rects[i][j][0])
else:
pygame.draw.rect(screen, GREEN, tile_rects[i][j][0])
# draw letters on each tile
txt_suf = text_font.render(tile_rects[i][j][1], True, BLACK)
screen.blit(txt_suf, tile_rects[i][j][0])
pygame.init()
# create main game window
screen = pygame.display.set_mode((WINDOWWIDTH, WINDOWHEIGHT))
pygame.display.set_caption('Slide Puzzle')
# change the background color to white
screen.fill(WHITE)
# create a Clock object
game_clock = pygame.time.Clock()
# create a text_font Font object to render text
text_font = pygame.font.SysFont('arial', 80)
tile_rects, empty_tile = get_tile_rects(START_POS)
draw_tiles(tile_rects, empty_tile)
while True:
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
sys.exit()
pygame.display.update()
game_clock.tick(FPS)
After running this, you should see the following screenshot(your board may look different to mine):
The game logic
slide_dir = '' # slide direction (W, E, N, S)
while True:
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
sys.exit()
elif event.type == MOUSEBUTTONUP:
# print('button up')
# get the click point coordinates x, y
x,y = event.pos[0], event.pos[1]
# collide detection
(rect, pos) = get_clicked_rect(x,y,tile_rects, empty_tile)
if pos: # if we clicked on the tile, we check its west, east, north, south tiles
west_pos = pos[0]-1, pos[1]
east_pos = pos[0]+1, pos[1]
north_pos = pos[0], pos[1]-1
south_pos = pos[0], pos[1]+1
if is_valid_pos(west_pos) and check_move(tile_rects,west_pos):
slide_dir = 'W'
elif is_valid_pos(east_pos) and check_move(tile_rects,east_pos):
slide_dir = 'E'
elif is_valid_pos(north_pos) and check_move(tile_rects,north_pos):
slide_dir = 'N'
elif is_valid_pos(south_pos) and check_move(tile_rects,south_pos):
slide_dir = 'S'
if slide_dir:
slide_to_pos(rect,pos,slide_dir, tile_rects)
slide_dir = ''
draw_tiles(tile_rects, empty_tile)
pygame.display.update()
if win_check():
break
game_clock.tick(FPS)
Next we design the game logic
slide_dir = ''
We define a slide_dir variable to control the slide direction of the tile.
elif event.type == MOUSEBUTTONUP:
If we clicked on the screen and release the button, we generate a MOUSEBUTTONUP event. It is this event we want to handle.
x,y = event.pos[0], event.pos[1]
Then we get the x and y coordinates of the clicked point by directly retrieving the position info with event.pos attribute.
(rect, pos) = get_clicked_rect(x,y,tile_rects, empty_tile)
Next we create an imaginary function get_clicked_rect() (later we are gonna implement it) to get the the clicked Rect object and its position in the board.
if pos: # if we clicked on the tile, we check its west, east, north, south tiles
west_pos = pos[0]-1, pos[1]
east_pos = pos[0]+1, pos[1]
north_pos = pos[0], pos[1]-1
south_pos = pos[0], pos[1]+1
If there is a pos returned, then we find its west, east, north and south position by doing some simle plus and minus 1 operations.
If the pos returned is (2,2), then then its west, east, north, south position can be calculated as (1,2), (3,2), (2,1) (2,3).
if is_valid_pos(west_pos) and check_move(tile_rects,west_pos):
After we get the position next to the returned pos, we first need to check if the position calculated is a valid position, namely its x and y coordinates does not exceed the range 0-3. This is the job of is_valid_pos() function which will be defined later.
Then we check the pos's 4 directions, if its west direction is the empty tile we assign 'W' to the slide_dir vairable just like the one in the picture above. If not, assign 'E' or 'N' or 'S' to the slide_dir variable. Our check_move function takes the tile_rects and calculated position as arguments and returns True if the Rect object could move to either of the four directions (North, South, East, West). Later we'll define such function.
if slide_dir:
slide_to_pos(rect,pos,slide_dir, tile_rects)
slide_dir = ''
Then we check the value of slide_dir, if it is not empty string, we slide the tile to the desired direction. We are doing this by using an imaginary function slide_to_pos() function which will be later defined. It takes rect and pos argements returned by get_clicked_rect() function; and also takes the slide_dir and tile_rects object. What this method really does is to manipulate the tile_rects object. We will elaborate on this in a few minutes.
After slide to position, we assign slide_dir with empty string again before the next iteration.
draw_tiles(tile_rects, empty_tile)
After all the changes being made to the tile_rects object, we draw it each iteration.
if win_check():
break
Then we do a simle win check, if all tiles are in the right place, we just simply break the game loop and quit. Later we will modify this code.
Define missing functions
def get_clicked_rect(x,y, tile_rects, empty_tile):
for i in range(4):
for j in range(4):
if tile_rects[i][j][0].collidepoint(x,y) and (i,j) != empty_tile:
return (tile_rects[i][j][0], (i,j))
return (None, None)
The get_clicked_rect() function takes the x and y coordinates of the clicked point. It iterate through all the Rect objecst in the tile_rects object, if any one of them and not the empty tile, it returns the Rect object and its position. Otherwise it returns None.
To check if a point is within a Rect object or not, we use Rect.collidepoint(x, y) method.
As in the picture, (x1,y1) is within the Rect() object, so Rect.collidepoint(x1,y1) returns True and Rect.collidepoint(x2,y2) returnts False.
def is_valid_pos(pos):
return 0 <= pos[0] <=3 and 0 <= pos[1] <= 3
The is_valid_pos() function is simple, it just checks if the pos is within the range 0-3 or not.
def check_move(tile_rects, pos):
#print('check move')
return tile_rects[pos[0]][pos[1]][1] ==''
The check_move() function is simple too, it just checks whether the rect object at the pos has an associated empty string '' or not. If the Rect object's associated string value is an empty string '', it returns True, otherwise returns False.
def slide_to_pos(rect, pos, slide_dir, tile_rects):
global empty_tile
tile_rects[pos[0]][pos[1]][1], tile_rects[empty_tile[0]][empty_tile[1]][1] = \
tile_rects[empty_tile[0]][empty_tile[1]][1], tile_rects[pos[0]][pos[1]][1]
empty_tile = pos
The slide_to_pos() function uses the empty_tile as a global variable, it exchanges the empty string '' with the letter associated with the Rect object found at pos.
All the magic can be achieved with just one signle line code:
tile_rects[pos[0]][pos[1]][1], tile_rects[empty_tile[0]][empty_tile[1]][1] = \
tile_rects[empty_tile[0]][empty_tile[1]][1], tile_rects[pos[0]][pos[1]][1]
If we want to exchange two values in side a list of list or 2D list, we could just use this formula in python:
>>> l = [['a','b','c'],['d','e','f']]
>>> l[0][0], l[1][0] = l[1][0], l[0][0]
>>> l
[['d', 'b', 'c'], ['a', 'e', 'f']]
In this game, we just exchange the strings associated with the Rect object not the Rect object itself.
Then we update the global variable empty_tile with the pos that it has exchanged value with.
def win_check():
global win_rects, tile_rects
for i in range(4):
for j in range(4):
if tile_rects[j][i][1] != win_rects[i][j]:
return False
return True
Then, we define our win_check function, it checks each tile's associated letter with the one inside the win_rects object. If any mismatch happens, it returns False, otherwise returns True.
The win_rects is also a 2D list structure:
win_rects = [[chr(j) for j in range(65+i*4,65+i*4+4)] for i in range(4)]
win_rects[-1][-1] = ''
So far, the code we have created should look like this:
# Slide Puzzle Game
import pygame, sys, random
from pygame.locals import *
BOARDWIDTH = 4
BOARDHEIGHT = 4
TILE_SIZE = 80
TILE_SPACE = 2
START_POS = (156,76)
WINDOWWIDTH = 640
WINDOWHEIGHT = 480
FPS = 30
# color set up
BLACK = (0,0,0)
WHITE = (255,255,255)
GREEN = (0,255,0)
# create a tile_rects list to store all the tile Rect objects
# the top-left corner coordinates of the top-left tile is (156,76)
# each square tile has a width of 80 and a space of 2 pixels between each of them
# there will be a random empty tile
# each tile should have a letter attached to it (A-O)
def get_tile_rects(start_pos):
tile_rects = []
empty_tile = (random.randint(0,3), random.randint(0,3))
random_letters = [chr(i) for i in range(65,65+15)]
random.shuffle(random_letters)
n = 0
for i in range(4):
tile_rects.append([])
for j in range(4):
rect = pygame.Rect(
(start_pos[0]+i*(TILE_SIZE+TILE_SPACE),
start_pos[1]+j*(TILE_SIZE+TILE_SPACE),
TILE_SIZE,TILE_SIZE))
if (i,j) == empty_tile: # if empty tile, attach a empty string
tile_rects[i].append([rect,''])
continue
else:
tile_rects[i].append([rect,random_letters[n]])
n += 1
return tile_rects, empty_tile
def draw_tiles(tile_rects, empty_tile):
global text_font, screen
for i in range(4):
for j in range(4):
if (i,j) == empty_tile:
pygame.draw.rect(screen, WHITE, tile_rects[i][j][0])
else:
pygame.draw.rect(screen, GREEN, tile_rects[i][j][0])
# draw letters on each tile
txt_suf = text_font.render(tile_rects[i][j][1], True, BLACK)
screen.blit(txt_suf, tile_rects[i][j][0])
def get_clicked_rect(x,y, tile_rects, empty_tile):
for i in range(4):
for j in range(4):
if tile_rects[i][j][0].collidepoint(x,y) and (i,j) != empty_tile:
return (tile_rects[i][j][0], (i,j))
return (None, None)
def is_valid_pos(pos):
return 0 <= pos[0] <=3 and 0 <= pos[1] <= 3
def check_move(tile_rects, pos):
return tile_rects[pos[0]][pos[1]][1] ==''
def slide_to_pos(rect, pos, slide_dir, tile_rects):
global empty_tile
tile_rects[pos[0]][pos[1]][1], tile_rects[empty_tile[0]][empty_tile[1]][1] = \
tile_rects[empty_tile[0]][empty_tile[1]][1], tile_rects[pos[0]][pos[1]][1]
empty_tile = pos
def win_check():
global win_rects, tile_rects
for i in range(4):
for j in range(4):
if tile_rects[j][i][1] != win_rects[i][j]:
return False
return True
pygame.init()
# create main game window
screen = pygame.display.set_mode((WINDOWWIDTH, WINDOWHEIGHT))
pygame.display.set_caption('Slide Puzzle')
# change the background color to white
screen.fill(WHITE)
# create a Clock object
game_clock = pygame.time.Clock()
# create a text_font Font object to render text
text_font = pygame.font.SysFont('arial', 80)
tile_rects, empty_tile = get_tile_rects(START_POS)
draw_tiles(tile_rects, empty_tile)
# the direction that tile slide to, it could N(North), S(South), E(East), W(West)
slide_dir = ''
# solved rects
win_rects = [[chr(j) for j in range(65+i*4,65+i*4+4)] for i in range(4)]
win_rects[-1][-1] = ''
# main game loop
while True:
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
sys.exit()
elif event.type == MOUSEBUTTONUP:
# get the click point coordinates x, y
x,y = event.pos[0], event.pos[1]
# collide detection
(rect, pos) = get_clicked_rect(x,y,tile_rects, empty_tile)
#print(rect, pos)
if pos: # if we clicked on the tile, we check its west, east, north, south tiles
west_pos = pos[0]-1, pos[1]
east_pos = pos[0]+1, pos[1]
north_pos = pos[0], pos[1]-1
south_pos = pos[0], pos[1]+1
if is_valid_pos(west_pos) and check_move(tile_rects,west_pos):
slide_dir = 'W'
elif is_valid_pos(east_pos) and check_move(tile_rects,east_pos):
slide_dir = 'E'
elif is_valid_pos(north_pos) and check_move(tile_rects,north_pos):
slide_dir = 'N'
elif is_valid_pos(south_pos) and check_move(tile_rects,south_pos):
slide_dir = 'S'
if slide_dir:
slide_to_pos(rect,pos,slide_dir, tile_rects)
slide_dir = ''
draw_tiles(tile_rects, empty_tile)
pygame.display.update()
if win_check():
break
game_clock.tick(FPS)
Then, you can test run the file.
Comments
Post a Comment