介绍
本项目是使用 Python 语言和 Pygame 开发经典游戏《推箱子》。
本项目涵盖的知识点包括:
- Python 的基本语法
- 使用 Pygame 进行基本游戏开发
本课程难度适中,适合对 Python 有基本了解并希望进一步提升知识的用户。
源代码sokoban.py.zip遵循 GNU GPL v3 许可发布,皮肤由 Borgar 创建。
👀 预览

🎯 任务
在本项目中,你将学习:
- 如何使用 Pygame 初始化游戏
- 如何处理游戏事件和键盘操作
- 如何实现游戏地图
- 如何实现玩家和箱子的移动操作
- 如何实现撤销和重做操作
- 如何测试游戏界面
🏆 成果
完成本项目后,你将能够:
- 初始化 Pygame 并设置游戏窗口
- 在 Pygame 中处理游戏事件和键盘输入
- 实现游戏地图并使用 Pygame 进行显示
- 实现玩家和箱子的移动操作
- 在游戏中实现撤销和重做操作
- 测试并运行游戏界面
游戏描述
在推箱子游戏中,有一堵封闭的墙,围成一个不规则的多边形区域。玩家和箱子只能在这个区域内移动。区域内有一个人、几个箱子和目标点。游戏的目标是使用方向键控制人的移动,将箱子推到目标点上。一次只能移动一个箱子,如果箱子卡在角落里,游戏就无法继续。
角色
从上述描述中,我们可以抽象出游戏中的以下角色:
- 墙壁:阻挡移动路径的封闭区域。
- 空格:人可以行走和推箱子的区域。
- 人:玩家控制的角色。
- 箱子
- 目标点
人、箱子和目标点都应该在空格区域内初始化,其他角色不应出现在墙壁区域内。
控制
在推箱子游戏中,我们唯一能控制的角色是人。我们使用方向键控制人的移动,既可以让人移动,也可以推箱子。人的移动有两种情况,我们需要分别处理:
- 单独移动人
- 推箱子时移动人
此外,游戏支持以下两种操作:
- 撤销:撤销上一步移动,由退格键控制。
- 重做:重做之前撤销的移动,由空格键控制。
综上所述,我们需要支持四个方向键、用于撤销的退格键和用于重做的空格键的键盘事件。在下一节实现 pygame 时,我们需要处理这六个键盘事件。
开发准备
为了能够在该环境中使用 pygame,在实验环境中打开终端并输入以下命令来安装 pygame:
sudo pip install pygame
pygame 中有许多模块,包括鼠标、显示设备、图形、事件、字体、图像、键盘、声音、视频、音频等。在推箱子游戏中,我们将使用以下模块:
pygame.display:访问显示设备以显示图像。pygame.image:加载和存储图像,用于处理精灵表。pygame.key:读取键盘输入。pygame.event:管理事件,处理游戏中的键盘事件。pygame.time:管理时间并显示帧信息。
上面的介绍提到了精灵表。精灵表是游戏开发中一种常见的图像合并方法,它将小图标和背景图像合并为一张图像,然后使用 pygame 的图像定位来显示图像的所需部分。
在推箱子游戏中,我们使用现成的精灵表。这里我不会详细介绍如何裁剪图像和合并精灵表,因为网上有无数种方法。
本项目中使用的推箱子精灵表中的图像元素来自 borgar,文件位于 ~/project/borgar.png。
游戏图像元素包括:
- 游戏界面背景颜色
- 玩家
- 普通箱子
- 目标点
- 玩家与目标点重叠效果
- 箱子到达目标点重叠效果
- 墙壁
精灵表中的两个箱子图像在我们的实现中不需要。在后续的实现部分,我们将详细解释如何使用 pygame 中的 blit 方法来加载和显示精灵表的内容。
游戏开发
首先,在 ~/project 目录下创建一个 sokoban.py 文件,然后在该文件中输入以下内容:
- 初始化 pygame
import pygame, sys, os
from pygame.locals import *
from collections import deque
pygame.init()
- 设置显示对象
## 将 pygame 显示窗口的大小设置为宽 400 像素,高 300 像素
screen = pygame.display.set_mode((400,300))
- 加载图像元素
## 从单个文件加载图像元素
skinfilename = os.path.join('borgar.png')
try:
skin = pygame.image.load(skinfilename)
except pygame.error as msg:
print('无法加载皮肤')
raise SystemExit(msg)
skin = skin.convert()
## 将窗口的背景颜色设置为皮肤文件中坐标 (0,0) 处的元素
screen.fill(skin.get_at((0,0)))
- 设置时钟和键盘事件的重复时间。使用
key.set_repeat以(delay, interval)为参数设置重复事件的时间间隔。
clock = pygame.time.Clock()
pygame.key.set_repeat(200,50)
- 开始主循环
## 游戏主循环
while True:
clock.tick(60)
pass
- 处理游戏事件和键盘操作。在主循环中,我们需要处理键盘事件,如前所述,我们需要支持六个键:上、下、左、右、退格和空格。
## 获取游戏事件
for event in pygame.event.get():
## 退出游戏事件
if event.type == QUIT:
pygame.quit()
sys.exit()
## 键盘操作
elif event.type == KEYDOWN:
## 向左移动
if event.key == K_LEFT:
pass
## 向上移动
elif event.key == K_UP:
pass
## 向右移动
elif event.key == K_RIGHT:
pass
## 向下移动
elif event.key == K_DOWN:
pass
## 撤销操作
elif event.key == K_BACKSPACE:
pass
## 重做操作
elif event.key == K_SPACE:
pass
现在我们已经完成了基于 pygame 的游戏框架。让我们开始实现游戏逻辑。
地图的实现
首先,我们需要定义推箱子对象。我们使用一个类来包含所有与游戏相关的逻辑。
class Sokoban:
## 初始化推箱子游戏
def __init__(self):
pass
推箱子游戏需要一个操作区域,也就是地图区域。我们使用一个字符列表来表示地图,其中不同的字符代表游戏中的不同元素:
- 墙壁:
## 符号 - 空格:
- 符号 - 玩家:
@ 符号 - 箱子:
$ 符号 - 目标点:
. 符号 - 位于目标点上的玩家:
+ 符号 - 位于目标点上的箱子:
* 符号
游戏开始时,我们需要为地图设置一个默认的字符列表。同时,我们需要知道地图的宽度和高度,以便从这个一维列表生成二维地图。
地图表示类似于以下代码。你能想象根据这段代码开始后它会是什么样子吗?
class Sokoban:
## 初始化推箱子游戏
def __init__(self):
## 设置地图
self.level = list(
"----#####----------"
"----#---#----------"
"----#$--#----------"
"--###--$##---------"
"--#--$-$-#---------"
"###-#-##-#---######"
"#---#-##-#####--..#"
"#-$--$----------..#"
"#####-###-#@##--..#"
"----#-----#########"
"----#######--------")
## 设置地图的宽度和高度以及玩家在地图中的位置(地图列表中的索引值)
## 总共 19 列
self.w = 19
## 总共 11 行
self.h = 11
## 玩家的初始位置在 self.level[163]
self.man = 163
通过扫描字符列表并根据字符在相应位置显示不同的元素来展示地图。
由于显示是二维的,宽度和高度用于确定每个字符在二维显示区域中的位置。我们需要将 pygame 中提到的 screen 和 skin 作为参数传递给绘图函数 draw。
需要注意的是,我们实现的绘图函数使用了 pygame 中的 blit,它从精灵表中提取图像并在指定位置显示:
screen.blit(skin, (i*w, j*w), (0,0,w,w))
draw 函数的完整实现如下。首先进行扫描,然后根据精灵表为每个字符显示相应的图像:
class Sokoban:
## 根据地图关卡在 pygame 窗口上绘制地图
def draw(self, screen, skin):
## 获取每个图像元素的宽度
w = skin.get_width() / 4
## 遍历地图关卡中的每个字符元素
for i in range(0, self.w):
for j in range(0, self.h):
## 获取地图中第 j 行第 i 列的字符
item = self.level[j*self.w + i]
## 在此位置显示为墙壁 (#)
if item == '#':
## 使用 pygame 的 blit 方法在指定位置显示图像,
## 位置坐标为 (i*w, j*w),图像在 skin 中的坐标和长宽为 (0,2*w,w,w)
screen.blit(skin, (i*w, j*w), (0,2*w,w,w))
## 在此位置显示为空格 (-)
elif item == '-':
screen.blit(skin, (i*w, j*w), (0,0,w,w))
## 在此位置显示为玩家 (@)
elif item == '@':
screen.blit(skin, (i*w, j*w), (w,0,w,w))
## 在此位置显示为箱子 ($)
elif item == '$':
screen.blit(skin, (i*w, j*w), (2*w,0,w,w))
## 在此位置显示为目标点 (.)
elif item == '.':
screen.blit(skin, (i*w, j*w), (0,w,w,w))
## 在此位置显示为位于目标点上的玩家效果
elif item == '+':
screen.blit(skin, (i*w, j*w), (w,w,w,w))
## 在此位置显示为位于目标点上的箱子效果
elif item == '*':
screen.blit(skin, (i*w, j*w), (2*w,w,w,w))
实现移动操作
移动操作使用方向键来控制四个方向的移动:左、右、上、下。我们使用四个字符 'l'(左)、'r'(右)、'u'(上)和 'd'(下)来指定移动方向。
由于重做操作和移动操作所需的过程相似,我们在推箱子类中定义一个内部函数 _move() 来处理移动:
class Sokoban:
## 内部移动函数:用于在移动操作后更新地图中元素的位置变化,其中 d 表示移动方向
def _move(self, d):
## 获取地图中移动的位移
h = get_offset(d, self.w)
## 如果移动的目标区域是空格或目标点,只需移动玩家
if self.level[self.man + h] == '-' or self.level[self.man + h] == '.':
## 将玩家移动到目标位置
move_man(self.level, self.man + h)
## 设置玩家移动后的原始位置
move_floor(self.level, self.man)
## 玩家的新位置
self.man += h
## 将移动操作添加到解决方案中
self.solution += d
## 如果移动的目标区域是箱子,箱子和玩家都需要移动
elif self.level[self.man + h] == '*' or self.level[self.man + h] == '$':
## 箱子和玩家位置的位移
h2 = h * 2
## 只有当下一个位置是空格或目标点时箱子才能移动
if self.level[self.man + h2] == '-' or self.level[self.man + h2] == '.':
## 将箱子移动到目标点
move_box(self.level, self.man + h2)
## 将玩家移动到目标点
move_man(self.level, self.man + h)
## 重置玩家的当前位置
move_floor(self.level, self.man)
## 设置玩家的新位置
self.man += h
## 将移动操作标记为大写字符,以表示在这一步推动了箱子
self.solution += d.upper()
## 增加推箱子的步数
self.push += 1
在 _move 函数中,我们需要使用以下函数:
- get_offset(d, width):获取地图中移动的位移。
d表示移动方向,width表示游戏窗口的宽度。 - move_man(level, i):在地图中移动玩家的位置。
level是地图列表,i是玩家的位置。 - move_floor(level, i):移动后重置位置。玩家从一个位置移动后,该位置需要重置为空或目标点。
- move_box(level, i):在地图中移动箱子的位置。
level是地图列表,i是箱子的位置。
这些函数的实现可以在完整代码中看到。在移动每个元素时,重要的是要考虑目标位置原来的元素是什么,以确定移动后应该设置什么元素。
要执行移动操作,只需调用 _move 并将 todo[] 设置为空(重做列表仅在执行撤销操作时激活)。
实现撤销功能
撤销是移动操作的反向操作。它从 solution 中获取上一步并执行反向操作。查看详细代码:
class Sokoban:
## 撤销操作:撤销上一次移动
def undo(self):
## 检查是否有移动记录
if self.solution.__len__()>0:
## 将移动记录存储在 todo 列表中以便重做操作
self.todo.append(self.solution[-1])
## 删除移动记录
self.solution.pop()
## 获取撤销操作要移动的偏移量:上一次移动偏移量的负数
h = get_offset(self.todo[-1],self.w) * -1
## 检查此操作是否仅移动角色而不推箱子
if self.todo[-1].islower():
## 将角色移回其原始位置
move_man(self.level, self.man + h)
## 设置角色的当前位置
move_floor(self.level, self.man)
## 设置角色在地图上的位置
self.man += h
else:
## 如果此步骤推了箱子,则移动角色、箱子并在 _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
重做操作
当执行撤销命令时,内容从 solution[] 移动到 todo[],我们只需要提取并调用 _move 函数。
## 重做操作:当执行并激活撤销操作时,移回到撤销前的位置
def redo(self):
## 检查是否记录了撤销操作
if self.todo.__len__() > 0:
## 撤销已撤销的步骤
self._move(self.todo[-1].lower())
## 删除此记录
self.todo.pop()
通过上述步骤,游戏的主要内容已经完成。请继续独立完成完整的游戏代码、测试截图,如果你有任何不清楚的地方,可以在实验区的问答板块提问。实验区团队和老师会及时回复你的任何问题。
附加功能与代码重构
现在我们有了一个基本的游戏,但它并不完美。我们需要添加一些附加功能,使其更具可玩性。
我们还需要重构代码,使其更具可读性和可维护性。
点击查看完整代码
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"
)
运行与测试
要在终端中运行:
cd ~/project
python sokoban.py
如果一切正常,你将看到以下游戏界面:

总结
本项目仅实现了推箱子游戏的基本功能。基于该实验,你可以考虑通过以下方式扩展此代码:
- 弄清楚如何从编写的代码中提取地图数据并保存到文件中。
- 实现鼠标控制,以便快速将角色移动到特定位置。
- 开发一种算法,自动确定地图是否可解。



