Push Box Game with Pygame

PythonPythonBeginner
Practice Now

Introduction

This project is the development of the classic game Sokoban using the Python language and Pygame.

The knowledge points covered in this project include:

  • Basic syntax of Python
  • Basic game development with Pygame

This course has a moderate level of difficulty and is suitable for users who have a basic understanding of Python and want to further enhance their knowledge.

The source code sokoban.py.zip, is released under the GNU GPL v3 license, and the skin was created by Borgar.

👀 Preview

preview

ðŸŽŊ Tasks

In this project, you will learn:

  • How to initialize the game using Pygame
  • How to handle game events and keyboard operations
  • How to implement the map for the game
  • How to implement movement operations for the player and boxes
  • How to implement undo and redo operations
  • How to test the game interface

🏆 Achievements

After completing this project, you will be able to:

  • Initialize Pygame and set up the game window
  • Handle game events and keyboard inputs in Pygame
  • Implement the game map and display it using Pygame
  • Implement movement operations for the player and boxes
  • Implement undo and redo operations in the game
  • Test and run the game interface

Game Description

In the game of Sokoban, there is an enclosed wall that forms an irregular polygonal area. The player and the boxes can only move within this area. Inside the area, there is a person, several boxes, and target points. The objective of the game is to use the arrow keys to control the person's movement and push the boxes onto the target points. Only one box can be moved at a time, and if a box gets stuck in a corner, the game cannot continue.

Characters

From the above description, we can abstract the following characters in the game:

  1. Walls: Enclosed areas that block movement paths.
  2. Spaces: Areas where the person can walk and push boxes.
  3. Person: The player-controlled character.
  4. Boxes
  5. Target points

The person, boxes, and target points should all be initialized within the space area, and other characters should not appear within the wall area.

Controls

In the game of Sokoban, the only character we can control is the person. We use the arrow keys to control the person's movement, both for moving the person and for pushing boxes. There are two types of movements for the person, and we need to handle each case separately:

  1. Moving the person alone
  2. Moving the person while pushing a box

In addition, the game supports the following two operations:

  1. Undo: Undo the previous movement, controlled by the backspace key.
  2. Redo: Redo the previously undone movement, controlled by the space bar.

In summary, we need to support the keyboard events for the four arrow keys, the backspace key for undo, and the space bar for redo. In the next section of implementing pygame, we will need to handle these six keyboard events.

Development Preparation

To be able to use pygame in the environment, open the terminal in the experimental environment and enter the following command to install pygame:

sudo pip install pygame

There are many modules in pygame, including mouse, display devices, graphics, events, fonts, images, keyboards, sound, video, audio, etc. In the Sokoban game, we will use the following modules:

  • pygame.display: Access display devices to display images.
  • pygame.image: Load and store images, used to handle sprite sheets.
  • pygame.key: Read keyboard inputs.
  • pygame.event: Manage events, handle keyboard events in the game.
  • pygame.time: Manage time and display frame information.

The introduction above mentioned sprite sheets. Sprite sheet is a common image merging method in game development, which merges small icons and background images into one image, and then uses pygame's image positioning to display the required part of the image.

In the Sokoban game, we use a ready-made sprite sheet. I won't go into detail on how to crop images and merge sprite sheets here, as there are countless methods available online.

The image elements in the Sokoban sprite sheet used in this project are from borgar, and the file can be found at ~/project/borgar.png.

The game image elements include:

  • Game interface background color
  • Player
  • Normal box
  • Target point
  • Player and target point overlap effect
  • Box reaches target point overlap effect
  • Wall

Two box images in the sprite sheet are not needed in our implementation. We will explain in detail how to use the blit method in pygame to load and display the content of the sprite sheet in the subsequent implementation part.

Game Development

First, create a sokoban.py file in the ~/project directory, then input the following content into the file:

  1. Initialize pygame
import pygame, sys, os
from pygame.locals import *

from collections import deque


pygame.init()
  1. Set the display object
## Set the size of the pygame display window to 400 pixels wide, 300 pixels high
screen = pygame.display.set_mode((400,300))
  1. Load image elements
## Load image elements from a single file
skinfilename = os.path.join('borgar.png')

try:
    skin = pygame.image.load(skinfilename)
except pygame.error as msg:
    print('cannot load skin')
    raise SystemExit(msg)

skin = skin.convert()

## Set the background color of the window to the element at coordinates (0,0) in the skin file
screen.fill(skin.get_at((0,0)))
  1. Set the clock and the repeat time for keyboard events. Use key.set_repeat to set the time interval for repeat events with the parameters (delay, interval).
clock = pygame.time.Clock()
pygame.key.set_repeat(200,50)
  1. Start the main loop
## Game main loop
while True:
    clock.tick(60)
    pass
  1. Handle game events and keyboard operations. In the main loop, we need to handle keyboard events, as mentioned earlier, we need to support six keys: up, down, left, right, backspace and space.
## Get game events
for event in pygame.event.get():
    ## Quit game event
    if event.type == QUIT:
        pygame.quit()
        sys.exit()
    ## Keyboard operation
    elif event.type == KEYDOWN:
        ## Move left
        if event.key == K_LEFT:
            pass
        ## Move up
        elif event.key == K_UP:
            pass
        ## Move right
        elif event.key == K_RIGHT:
            pass
        ## Move down
        elif event.key == K_DOWN:
            pass
        ## Undo operation
        elif event.key == K_BACKSPACE:
            pass
        ## Redo operation
        elif event.key == K_SPACE:
            pass

Now we have completed the pygame-based game framework. Let's start implementing the game logic.

âœĻ Check Solution and Practice

Implementation of the Map

First, we need to define the Sokoban object. We use a class to contain all the game-related logic.

class Sokoban:

    ## Initialize the Sokoban game
    def __init__(self):
        pass

The Sokoban game requires an operational area, which is the map area. We use a character list to represent the map, where different characters represent different elements in the game:

  1. Wall: ## symbol
  2. Space: - symbol
  3. Player: @ symbol
  4. Box: $ symbol
  5. Target point: . symbol`
  6. Player on target point: + symbol
  7. Box on target point: * symbol

When the game starts, we need to set a default character list for the map. At the same time, we need to know the width and height of the map in order to generate a 2D map from this one-dimensional list.

The map representation is similar to the following code. Can you imagine what it would look like after starting based on this code?

class Sokoban:

    ## Initialize the Sokoban game
    def __init__(self):
        ## Set the map
        self.level = list(
            "----#####----------"
            "----#---#----------"
            "----#$--#----------"
            "--###--$##---------"
            "--#--$-$-#---------"
            "###-#-##-#---######"
            "#---#-##-#####--..#"
            "#-$--$----------..#"
            "#####-###-#@##--..#"
            "----#-----#########"
            "----#######--------")

        ## Set the width and height of the map and the position of the player in the map (index value in the map list)
        ## Total 19 columns
        self.w = 19

        ## Total 11 rows
        self.h = 11

        ## The initial position of the player is at self.level[163]
        self.man = 163

The map is displayed by scanning the character list and displaying different elements in the corresponding positions based on the characters.

Since the display is 2D, the width and height are used to determine the position of each character in the 2D display area. We need to pass screen and skin mentioned in pygame as parameters to the drawing function draw.

It is important to note that the drawing function we implemented uses blit from pygame, which extracts the image from the sprite sheet and displays it at the specified position:

screen.blit(skin, (i*w, j*w), (0,0,w,w))

The complete implementation of the draw function is as follows. First, the scan is performed, and then the image corresponding to each character is displayed based on the sprite sheet:

class Sokoban:

    ## Draw the map on the pygame window based on the map level
    def draw(self, screen, skin):

        ## Get the width of each image element
        w = skin.get_width() / 4

        ## Iterate through each character element in the map level
        for i in range(0, self.w):
            for j in range(0, self.h):

                ## Get the character at the j-th row and i-th column in the map
                item = self.level[j*self.w + i]

                ## Display as a wall(#) at this position
                if item == '#':
                    ## Use the blit method from pygame to display the image at the specified position,
                    ## with the position coordinates (i*w, j*w), and the coordinates and length-width of the image in the skin as (0,2*w,w,w)
                    screen.blit(skin, (i*w, j*w), (0,2*w,w,w))
                ## Display as a space(-) at this position
                elif item == '-':
                    screen.blit(skin, (i*w, j*w), (0,0,w,w))
                ## Display as a player(@) at this position
                elif item == '@':
                    screen.blit(skin, (i*w, j*w), (w,0,w,w))
                ## Display as a box($) at this position
                elif item == '$':
                    screen.blit(skin, (i*w, j*w), (2*w,0,w,w))
                ## Display as a target point(.) at this position
                elif item == '.':
                    screen.blit(skin, (i*w, j*w), (0,w,w,w))
                ## Display as the player on a target point effect
                elif item == '+':
                    screen.blit(skin, (i*w, j*w), (w,w,w,w))
                ## Display as the box placed on a target point effect
                elif item == '*':
                    screen.blit(skin, (i*w, j*w), (2*w,w,w,w))
âœĻ Check Solution and Practice

Implementing Move Operation

The move operation uses arrow keys to control movement in four directions: left, right, up, and down. We use four characters 'l' (left), 'r' (right), 'u' (up), and 'd' (down) to specify the movement direction.

Since the process required for redo operation and move operation is similar, we define an internal function, _move(), to handle movement in the Sokoban class:

class Sokoban:

    ## Internal move function: used to update the position changes of elements in the map after the move operation, where d represents the direction of movement
    def _move(self, d):
        ## Get the displacement in the map for the movement
        h = get_offset(d, self.w)

        ## If the target area of the movement is empty space or a target point, only the player needs to move
        if self.level[self.man + h] == '-' or self.level[self.man + h] == '.':
            ## Move the player to the target position
            move_man(self.level, self.man + h)
            ## Set the original position of the player after movement
            move_floor(self.level, self.man)
            ## The new position of the player
            self.man += h
            ## Add the move operation to the solution
            self.solution += d

        ## If the target area of the movement is a box, both the box and the player need to move
        elif self.level[self.man + h] == '*' or self.level[self.man + h] == '$':
            ## The displacement of the box and the player's position
            h2 = h * 2
            ## The box can only be moved if the next position is empty space or a target point
            if self.level[self.man + h2] == '-' or self.level[self.man + h2] == '.':
                ## Move the box to the target point
                move_box(self.level, self.man + h2)
                ## Move the player to the target point
                move_man(self.level, self.man + h)
                ## Reset the current position of the player
                move_floor(self.level, self.man)
                ## Set the player's new position
                self.man += h
                ## Mark the move operation as an uppercase character to indicate that a box was pushed in this step
                self.solution += d.upper()
                ## Increment the number of steps for pushing the box
                self.push += 1

In the _move function, we need to use the following functions:

  • get_offset(d, width): Get the displacement of the movement in the map. d represents the movement direction, and width represents the width of the game window.
  • move_man(level, i): Move the player's position in the map. level is the map list, and i is the player's position.
  • move_floor(level, i): Reset the position after movement. After the player moves from a position, it needs to be reset as empty space or a target point.
  • move_box(level, i): Move the box's position in the map. level is the map list, and i is the box's position.

The implementation of these functions can be seen in the complete code. It is important to consider what the original element at the target position is when moving each element to determine what element should be set after the movement.

To perform the move operation, simply call _move and set todo[] to empty (the redo list is only activated when performing undo operations).

âœĻ Check Solution and Practice

Implement Undo

Undo is the reverse operation of a movement. It retrieves the previous step from solution and performs the reverse operation. See the detailed code:

class Sokoban:

    ## Undo operation: undo the previous movement
    def undo(self):
        ## Check if there is a movement record
        if self.solution.__len__()>0:
            ## Store the movement record in the todo list for redo operation
            self.todo.append(self.solution[-1])
            ## Delete the movement record
            self.solution.pop()

            ## Get the offset to be moved for the undo operation: the negative of the offset of the last movement
            h = get_offset(self.todo[-1],self.w) * -1

            ## Check if this operation only moves the character without pushing a box
            if self.todo[-1].islower():
                ## Move the character back to its original position
                move_man(self.level, self.man + h)
                ## Set the current position of the character
                move_floor(self.level, self.man)
                ## Set the position of the character on the map
                self.man += h
            else:
                ## If this step pushes a box, move the character, box, and perform related operations in _move
                move_floor(self.level, self.man - h)
                move_box(self.level, self.man)
                move_man(self.level, self.man + h)
                self.man += h
                self.push -= 1
âœĻ Check Solution and Practice

Redo Operation

When the undo command is executed, the content is moved from solution[] to todo[], and we only need to extract and call the _move function.

    ## Redo operation: When the undo operation is executed and activated, move back to the position before the undo
    def redo(self):
        ## Check if there is an undo operation recorded
        if self.todo.__len__() > 0:
            ## Move back the undone steps
            self._move(self.todo[-1].lower())
            ## Delete this record
            self.todo.pop()

With the above steps, the main content of the game has been completed. Please continue to independently complete the complete game code, test the screenshots, and ask any questions in the Q&A section of Experiment Room if you have any unclear points. The Experiment Room team and teachers will promptly reply to any questions you may have.

âœĻ Check Solution and Practice

Additional Functions and Code Refactoring

Now we have a basic game, but it is not perfect. We need to add some additional functions to make it more playable.

We also need to refactor the code to make it more readable and maintainable.

Click to see the full code
import pygame, sys, os
from pygame.locals import *

from collections import deque


def to_box(level, index):
    if level[index] == "-" or level[index] == "@":
        level[index] = "$"
    else:
        level[index] = "*"


def to_man(level, i):
    if level[i] == "-" or level[i] == "$":
        level[i] = "@"
    else:
        level[i] = "+"


def to_floor(level, i):
    if level[i] == "@" or level[i] == "$":
        level[i] = "-"
    else:
        level[i] = "."


def to_offset(d, width):
    d4 = [-1, -width, 1, width]
    m4 = ["l", "u", "r", "d"]
    return d4[m4.index(d.lower())]


def b_manto(level, width, b, m, t):
    maze = list(level)
    maze[b] = "#"
    if m == t:
        return 1
    queue = deque([])
    queue.append(m)
    d4 = [-1, -width, 1, width]
    m4 = ["l", "u", "r", "d"]
    while len(queue) > 0:
        pos = queue.popleft()
        for i in range(4):
            newpos = pos + d4[i]
            if maze[newpos] in ["-", "."]:
                if newpos == t:
                    return 1
                maze[newpos] = i
                queue.append(newpos)
    return 0


def b_manto_2(level, width, b, m, t):
    maze = list(level)
    maze[b] = "#"
    maze[m] = "@"
    if m == t:
        return []
    queue = deque([])
    queue.append(m)
    d4 = [-1, -width, 1, width]
    m4 = ["l", "u", "r", "d"]
    while len(queue) > 0:
        pos = queue.popleft()
        for i in range(4):
            newpos = pos + d4[i]
            if maze[newpos] in ["-", "."]:
                maze[newpos] = i
                queue.append(newpos)
                if newpos == t:
                    path = []
                    while maze[t] != "@":
                        path.append(m4[maze[t]])
                        t = t - d4[maze[t]]
                    return path

    return []


class Sokoban:
    def __init__(self):
        self.level = list(
            "----#####--------------#---#--------------#$--#------------###--$##-----------#--$-$-#---------###-#-##-#---#######---#-##-#####--..##-$--$----------..######-###-#@##--..#----#-----#########----#######--------"
        )
        self.w = 19
        self.h = 11
        self.man = 163
        self.hint = list(self.level)
        self.solution = []
        self.push = 0
        self.todo = []
        self.auto = 0
        self.sbox = 0
        self.queue = []

    def draw(self, screen, skin):
        w = skin.get_width() / 4
        offset = (w - 4) / 2
        for i in range(0, self.w):
            for j in range(0, self.h):
                if self.level[j * self.w + i] == "#":
                    screen.blit(skin, (i * w, j * w), (0, 2 * w, w, w))
                elif self.level[j * self.w + i] == "-":
                    screen.blit(skin, (i * w, j * w), (0, 0, w, w))
                elif self.level[j * self.w + i] == "@":
                    screen.blit(skin, (i * w, j * w), (w, 0, w, w))
                elif self.level[j * self.w + i] == "$":
                    screen.blit(skin, (i * w, j * w), (2 * w, 0, w, w))
                elif self.level[j * self.w + i] == ".":
                    screen.blit(skin, (i * w, j * w), (0, w, w, w))
                elif self.level[j * self.w + i] == "+":
                    screen.blit(skin, (i * w, j * w), (w, w, w, w))
                elif self.level[j * self.w + i] == "*":
                    screen.blit(skin, (i * w, j * w), (2 * w, w, w, w))
                if self.sbox != 0 and self.hint[j * self.w + i] == "1":
                    screen.blit(
                        skin, (i * w + offset, j * w + offset), (3 * w, 3 * w, 4, 4)
                    )

    def move(self, d):
        self._move(d)
        self.todo = []

    def _move(self, d):
        self.sbox = 0
        h = to_offset(d, self.w)
        h2 = 2 * h
        if self.level[self.man + h] == "-" or self.level[self.man + h] == ".":
            ## move
            to_man(self.level, self.man + h)
            to_floor(self.level, self.man)
            self.man += h
            self.solution += d
        elif self.level[self.man + h] == "*" or self.level[self.man + h] == "$":
            if self.level[self.man + h2] == "-" or self.level[self.man + h2] == ".":
                ## push
                to_box(self.level, self.man + h2)
                to_man(self.level, self.man + h)
                to_floor(self.level, self.man)
                self.man += h
                self.solution += d.upper()
                self.push += 1

    def undo(self):
        if self.solution.__len__() > 0:
            self.todo.append(self.solution[-1])
            self.solution.pop()

            h = to_offset(self.todo[-1], self.w) * -1
            if self.todo[-1].islower():
                ## undo a move
                to_man(self.level, self.man + h)
                to_floor(self.level, self.man)
                self.man += h
            else:
                ## undo a push
                to_floor(self.level, self.man - h)
                to_box(self.level, self.man)
                to_man(self.level, self.man + h)
                self.man += h
                self.push -= 1

    def redo(self):
        if self.todo.__len__() > 0:
            self._move(self.todo[-1].lower())
            self.todo.pop()

    def manto(self, x, y):
        maze = list(self.level)
        maze[self.man] = "@"
        queue = deque([])
        queue.append(self.man)
        d4 = [-1, -self.w, 1, self.w]
        m4 = ["l", "u", "r", "d"]
        while len(queue) > 0:
            pos = queue.popleft()
            for i in range(4):
                newpos = pos + d4[i]
                if maze[newpos] in ["-", "."]:
                    maze[newpos] = i
                    queue.append(newpos)

        t = y * self.w + x
        if maze[t] in range(4):
            self.todo = []
            while maze[t] != "@":
                self.todo.append(m4[maze[t]])
                t = t - d4[maze[t]]

        self.auto = 1

    def automove(self):
        if self.auto == 1 and self.todo.__len__() > 0:
            self._move(self.todo[-1].lower())
            self.todo.pop()
        else:
            self.auto = 0

    def boxhint(self, x, y):
        d4 = [-1, -self.w, 1, self.w]
        m4 = ["l", "u", "r", "d"]
        b = y * self.w + x
        maze = list(self.level)
        to_floor(maze, b)
        to_floor(maze, self.man)
        mark = maze * 4
        size = self.w * self.h
        self.queue = []
        head = 0
        for i in range(4):
            if b_manto(maze, self.w, b, self.man, b + d4[i]):
                if len(self.queue) == 0:
                    self.queue.append((b, i, -1))
                mark[i * size + b] = "1"

        while head < len(self.queue):
            pos = self.queue[head]
            head += 1

            for i in range(4):
                if mark[pos[0] + i * size] == "1" and maze[pos[0] - d4[i]] in [
                    "-",
                    ".",
                ]:
                    if mark[pos[0] - d4[i] + i * size] != "1":
                        self.queue.append((pos[0] - d4[i], i, head - 1))
                        for j in range(4):
                            if b_manto(
                                maze,
                                self.w,
                                pos[0] - d4[i],
                                pos[0],
                                pos[0] - d4[i] + d4[j],
                            ):
                                mark[j * size + pos[0] - d4[i]] = "1"
        for i in range(size):
            self.hint[i] = "0"
            for j in range(4):
                if mark[j * size + i] == "1":
                    self.hint[i] = "1"

    def boxto(self, x, y):
        d4 = [-1, -self.w, 1, self.w]
        m4 = ["l", "u", "r", "d"]
        om4 = ["r", "d", "l", "u"]
        b = y * self.w + x
        maze = list(self.level)
        to_floor(maze, self.sbox)
        to_floor(
            maze, self.man
        )  ## make a copy of working maze by removing the selected box and the man
        for i in range(len(self.queue)):
            if self.queue[i][0] == b:
                self.todo = []
                j = i
                while self.queue[j][2] != -1:
                    self.todo.append(om4[self.queue[j][1]].upper())
                    k = self.queue[j][2]
                    if self.queue[k][2] != -1:
                        self.todo += b_manto_2(
                            maze,
                            self.w,
                            self.queue[k][0],
                            self.queue[k][0] + d4[self.queue[k][1]],
                            self.queue[k][0] + d4[self.queue[j][1]],
                        )
                    else:
                        self.todo += b_manto_2(
                            maze,
                            self.w,
                            self.queue[k][0],
                            self.man,
                            self.queue[k][0] + d4[self.queue[j][1]],
                        )
                    j = k

                self.auto = 1
                return
        print("not found!")

    def mouse(self, x, y):
        if x >= self.w or y >= self.h:
            return
        m = y * self.w + x
        if self.level[m] in ["-", "."]:
            if self.sbox == 0:
                self.manto(x, y)
            else:
                self.boxto(x, y)
        elif self.level[m] in ["$", "*"]:
            if self.sbox == m:
                self.sbox = 0
            else:
                self.sbox = m
                self.boxhint(x, y)
        elif self.level[m] in ["-", ".", "@", "+"]:
            self.boxto(x, y)


## start pygame
pygame.init()
screen = pygame.display.set_mode((400, 300))

## load skin
skinfilename = os.path.join("borgar.png")
try:
    skin = pygame.image.load(skinfilename)
except pygame.error as msg:
    print("cannot load skin")
    raise SystemExit(msg)
skin = skin.convert()

## screen.fill((255,255,255))
screen.fill(skin.get_at((0, 0)))
pygame.display.set_caption("sokoban.py")

## create Sokoban object
skb = Sokoban()
skb.draw(screen, skin)

clock = pygame.time.Clock()
pygame.key.set_repeat(200, 50)

## main game loop
while True:
    clock.tick(60)

    if skb.auto == 0:
        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                sys.exit()
            elif event.type == KEYDOWN:
                if event.key == K_LEFT:
                    skb.move("l")
                    skb.draw(screen, skin)
                elif event.key == K_UP:
                    skb.move("u")
                    skb.draw(screen, skin)
                elif event.key == K_RIGHT:
                    skb.move("r")
                    skb.draw(screen, skin)
                elif event.key == K_DOWN:
                    skb.move("d")
                    skb.draw(screen, skin)
                elif event.key == K_BACKSPACE:
                    skb.undo()
                    skb.draw(screen, skin)
                elif event.key == K_SPACE:
                    skb.redo()
                    skb.draw(screen, skin)
            elif event.type == MOUSEBUTTONUP and event.button == 1:
                mousex, mousey = event.pos
                mousex /= skin.get_width() / 4
                mousey /= skin.get_width() / 4
                skb.mouse(mousex, mousey)
                skb.draw(screen, skin)
    else:
        skb.automove()
        skb.draw(screen, skin)

    pygame.display.update()
    pygame.display.set_caption(
        skb.solution.__len__().__str__() + "/" + skb.push.__str__() + " - sokoban.py"
    )
âœĻ Check Solution and Practice

Running and Testing

To run in the terminal:

cd ~/project
python sokoban.py

If everything is normal, you will see the following game interface:

preview
âœĻ Check Solution and Practice

Summary

This project has only implemented a basic functionality of a Sokoban game. Based on the experiment, one can consider expanding on this code by:

  1. Figuring out how to extract the map data from the written code and save it into a file.
  2. Implementing mouse controls to quickly move the character to a specific position.
  3. Developing an algorithm to automatically determine whether a map is solvable.

Other Python Tutorials you may like