简介:这是一份使用Python语言开发的推箱子游戏源码,面向Python初学者、游戏开发爱好者以及需要毕业设计项目的学生。游戏实现了经典Sokoban玩法,核心逻辑涵盖地图二维数组设计、玩家移动与箱子推动规则、碰撞检测、关卡状态管理、基于Tkinter或Pygame的图形界面与键盘交互,代码结构清晰,运行简单,下载解压后即可直接体验。压缩包为rar格式,共9个文件,主体为1个Python脚本,另有8个gif动画用于展示移动方向、箱子、墙壁等元素效果,便于理解游戏运行流程,包体仅11KB,十分轻量。目前已有289人学习,适合作为入门游戏编程的练习项目,也可直接作为毕业设计的基础框架,在此基础上可扩展关卡编辑、计分系统和存档功能,实践价值较高。
1. 用Python做推箱子游戏,为什么能“下载即开”
在Python入门项目里,推箱子与贪吃蛇、猜数字并称三大常客。网上搜“python推箱子游戏源码”,能找到的版本很多,但真正能下载即跑的没几个:要么地图数据散落在渲染代码里,要么把目标点当成障碍物导致永远无法通关。下面这套是我做毕业设计代码审查时沉淀下来的写法:地图用字符串数组描述,游戏逻辑和绘制层分开,带步数和撤销,有Python 3.8以上环境就能直接运行。
如果你是准备拿它当毕设的在校生,或者刚在VSCode配好Python环境想练一个像样实例,这套代码比网上源码大全里复制粘贴的版本更值得改:它留出了自动求解、关卡读取和打包分发三条扩展路径,后面逐一展开。
2. 先立住:推箱子游戏的结构与pygame选型
2.1 为什么选pygame而不是tkinter或纯控制台
写推箱子最先要想清楚的是展示层。用纯控制台能跑,但每次刷新都要清屏重绘,画出来的关卡没有层次感,指导老师会觉得不像一个“软件”。tkinter可以生成窗口,但它的mainloop和Button回调在遇到“按方向键移动角色”这种连续键盘输入时,写法比pygame绕很多。pygame本来就是为2D游戏设计的:事件队列、位图渲染、时钟节拍都是现成的,学会这一套,后面做吃豆人、俄罗斯方块都能复用。
选型时有个容易被忽略的点:pygame 2.x对Python 3.8及以上有官方轮子,安装不需要本地编译。如果你在安装时遇到超时或找不到对应版本,换国内镜像源一行命令就解决,例如pip install pygame -i https://pypi.tuna.tsinghua.edu.cn/simple。不需要考虑Linux下源码编译的老路,正常Windows或macOS环境都用不上。
2.2 地图数据是灵魂:用字符串数组描述关卡
推箱子的核心不是绘图,而是“状态”:玩家在哪、箱子在哪、哪些格子是墙、哪些格子是目标。常见做法是用一个字符串数组描述关卡,每个字符代表一种格子,社区关卡也大多沿用这组记号:
| 字符 | 含义 | 渲染时的表现 |
|---|---|---|
| # | 墙 | 不可穿过,颜色最深 |
| 空格 | 地板 | 可移动区域 |
| $ | 箱子 | 可以被推动 |
| . | 目标点 | 箱子到达此处即算归位 |
| @ | 玩家 | 使用方向键移动 |
| * | 箱子已在目标 | 归位状态 |
| + | 玩家正在目标 | 只是@与.重叠 |
用字符串的好处是关卡长什么样一眼能看出来,改地图只要改字符串,所有逻辑代码一行都不用动。加载时把它扫描成集合,后面判断移动和赢法都基于集合操作,性能在十几乘十几的小地图上毫无压力。最容易踩的坑是:目标点不是障碍物,玩家可以踩上去,箱子也可以从目标点上被推走。
2.3 游戏主循环:事件、更新、渲染三件事
pygame程序都是同一个骨架:初始化窗口,进入循环,循环里先处理事件(按键、关闭窗口),再更新游戏状态,最后把画面画出来并调用pygame.display.flip()。推箱子里的“更新”只有一个动作:根据上下左右按键修改玩家坐标,必要时修改被推动箱子的坐标。
import pygame pygame.init() screen = pygame.display.set_mode((640, 480)) clock = pygame.time.Clock() running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False screen.fill((0, 0, 0)) pygame.display.flip() clock.tick(60) pygame.quit()pygame.event.get()会取出这一帧所有事件,KEYDOWN事件里存有event.key,把上下左右映射成四个方向向量就是推箱子的全部输入。clock.tick(60)把帧率限制在每秒60帧,避免while循环空转到100% CPU。注意pygame.quit()要放在循环结束后,否则退出时Windows上偶尔会假死。
窗口宽高不要写死,按地图行数与列数计算是更工程化的写法:screen = pygame.display.set_mode((game.width * CELL_SIZE, game.height * CELL_SIZE))。CELL_SIZE建议取48或64,太小看不清,太大会把窗口撑出屏幕。这种“地图驱动窗口大小”的方式在后续读取外部关卡时尤其重要,因为不同关卡行列数完全不同。
3. 可照抄的Python推箱子最小可玩版本
3.1 项目结构与初始化设置
一个能直接跑的推箱子工程只需要两个文件:main.py放全部代码,关卡数据可以放在同一个文件顶部。依赖只有pygame,在命令行装好依赖后直接python main.py就会弹出游戏窗口。
用下面这组常量,颜色和尺寸都可以按自己审美替换:
| 常量 | 建议值 | 说明 |
|---|---|---|
| CELL_SIZE | 64 | 每格像素,电脑屏幕下视觉最舒服 |
| FPS | 30 | 动画节奏,数字越小越慢 |
| WALL_COLOR | (60, 60, 60) | 墙壁 |
| FLOOR_COLOR | (210, 210, 210) | 地板 |
| TARGET_COLOR | (255, 210, 100) | 目标点 |
| BOX_COLOR | (150, 100, 40) | 箱子 |
| BOX_ON_TARGET | (250, 200, 0) | 箱子和目标重叠 |
| PLAYER_COLOR | (60, 120, 220) | 玩家 |
pygame里颜色是(R, G, B)元组,范围0到255,不是CSS里的十六进制,写错会直接报TypeError。
3.2 完整最小可运行代码
将以下代码保存为main.py,安装pygame后即可运行。地图是一张小而完整的关卡:玩家需要绕到箱子后面,把它一路推到右下角目标点。
import sys import pygame # 关卡:字符串数组,每一行代表地图的一行 LEVELS = { "demo": [ "#######", "# #", "# $ #", "# @ #", "# .#", "#######", ] } CELL_SIZE = 64 FPS = 30 WALL_COLOR = (60, 60, 60) FLOOR_COLOR = (210, 210, 210) TARGET_COLOR = (255, 210, 100) BOX_COLOR = (150, 100, 40) BOX_ON_TARGET_COLOR = (250, 200, 0) PLAYER_COLOR = (60, 120, 220) class Game: def __init__(self, level_name="demo"): self.map_rows = LEVELS[level_name] self.height = len(self.map_rows) self.width = max(len(row) for row in self.map_rows) self.walls = set() self.boxes = set() self.targets = set() self.player = None self.steps = 0 self.history = [] # 撤销栈 for y, row in enumerate(self.map_rows): for x, ch in enumerate(row): if ch == "#": self.walls.add((x, y)) elif ch == "$": self.boxes.add((x, y)) elif ch == "*": self.boxes.add((x, y)) self.targets.add((x, y)) elif ch == "@": self.player = (x, y) elif ch == "+": self.player = (x, y) self.targets.add((x, y)) elif ch == ".": self.targets.add((x, y)) def move(self, dx, dy): px, py = self.player nx, ny = px + dx, py + dy if (nx, ny) in self.walls: return False if (nx, ny) in self.boxes: bx, by = nx + dx, ny + dy if (bx, by) in self.walls or (bx, by) in self.boxes: return False self.boxes.remove((nx, ny)) self.boxes.add((bx, by)) self.history.append((self.player, set(self.boxes))) self.player = (nx, ny) self.steps += 1 return True def undo(self): if not self.history: return False self.player, self.boxes = self.history.pop() self.steps = max(0, self.steps - 1) return True def is_win(self): return self.targets.issubset(self.boxes) def render(screen, game): screen.fill((20, 20, 20)) for y in range(game.height): for x in range(game.width): rect = pygame.Rect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE) if (x, y) in game.walls: pygame.draw.rect(screen, WALL_COLOR, rect) else: pygame.draw.rect(screen, FLOOR_COLOR, rect) if (x, y) in game.targets: pygame.draw.circle(screen, TARGET_COLOR, rect.center, 8) for x, y in game.boxes: rect = pygame.Rect(x * CELL_SIZE + 4, y * CELL_SIZE + 4, CELL_SIZE - 8, CELL_SIZE - 8) color = BOX_ON_TARGET_COLOR if (x, y) in game.targets else BOX_COLOR pygame.draw.rect(screen, color, rect, border_radius=6) if game.player: rect = pygame.Rect(game.player[0] * CELL_SIZE + 8, game.player[1] * CELL_SIZE + 8, CELL_SIZE - 16, CELL_SIZE - 16) pygame.draw.circle(screen, PLAYER_COLOR, rect.center, (CELL_SIZE - 16) // 2) def main(): pygame.init() game = Game() screen = pygame.display.set_mode((game.width * CELL_SIZE, game.height * CELL_SIZE)) pygame.display.set_caption("Python Sokoban - 方向键移动, U撤销, R重置") clock = pygame.time.Clock() while True: for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() if event.type == pygame.KEYDOWN: if event.key == pygame.K_UP: game.move(0, -1) elif event.key == pygame.K_DOWN: game.move(0, 1) elif event.key == pygame.K_LEFT: game.move(-1, 0) elif event.key == pygame.K_RIGHT: game.move(1, 0) elif event.key == pygame.K_u: game.undo() elif event.key == pygame.K_r: game = Game() screen = pygame.display.set_mode((game.width * CELL_SIZE, game.height * CELL_SIZE)) title = "步数: {} | {}".format(game.steps, "WIN!" if game.is_win() else "继续") pygame.display.set_caption(title) render(screen, game) pygame.display.flip() clock.tick(FPS) if __name__ == "__main__": main()3.3 逻辑拆解:move方法怎样判断“推得动”
Game.move(dx, dy)是核心,三个分支分别处理三种情况:
- 目标格是墙:返回False,玩家不动。
- 目标格是箱子:检查箱子再往前一格是不是墙或另一个箱子,如果是则推不动;否则把箱子从原坐标移除并加进新坐标。
- 其他情况:玩家直接走到目标格。
最容易犯的错是把目标点也当障碍。在这套代码里,targets集合完全没有参与移动判断,只用于is_win和渲染,所以玩家可以踩在目标点上,箱子也可以被推进目标点后继续推走。网上很多初级版本把目标点写死在障碍判断里,导致关卡根本设计不出“路过目标再绕走”的解。
另一个细节是:每次成功移动前把状态压入history,推墙失败则没有记录,撤销栈不会混入无效步骤。boxes在history里是set的浅拷贝,因为集合元素是坐标元组,浅拷贝已经足够。渲染顺序也有讲究:先画地板和墙壁,再画目标点,接着画箱子,最后画玩家。顺序反了,玩家会被箱子盖住。
3.4 换关卡、换皮肤、改窗口标题
换关卡只需在LEVELS字典里新增key,值是一个字符串列表。注意每一行长度保持一致,不然后面渲染时会出现一行宽一行窄。最稳妥的做法是读取后按最大宽度补空格,或者在写地图时就保持矩形。
改颜色就是改顶部那几个RGB常量,改完重新运行即可,不动逻辑代码。窗口标题在main()里通过pygame.display.set_caption()设置,上面代码已经演示了每次循环刷新标题文本,步数和通关状态都会实时显示。
4. 从“能玩”到“能交”:打包、踩坑与毕业设计加分项
4.1 用PyInstaller打包成exe,让“下载即可运行”落地
标题承诺“下载即可运行”,最直接的做法是打包成Windows可执行文件,别人机器上不需要装Python。在项目根目录执行:
pip install pyinstaller pyinstaller -F -w -i icon.ico main.py --name SokobanDemo-F生成单个exe文件,-w运行时隐藏命令行窗口,-i指定图标。没有现成的icon.ico就删掉-i参数,PyInstaller会用默认图标。打包完成后exe在dist目录里,双击即可玩。第一次启动会比源码慢1到2秒,因为PyInstaller先把内置资源解压到临时目录,这不是死机。
打包有两个常见坑:项目路径不要带中文,否则运行时可能提示找不到pygame资源;杀毒软件可能误报,因为单文件封装结构比较像压缩壳,给整个dist目录加白名单即可。下载慢就加国内源:pip install pyinstaller -i https://pypi.tuna.tsinghua.edu.cn/simple。
4.2 常见的运行报错与处理
| 报错 | 原因 | 处理 |
|---|---|---|
| python was not found; run without arguments to install | Python没有加入PATH | 重装Python时勾选Add Python to PATH |
| ModuleNotFoundError: No module named 'pygame' | pygame没有安装,或VSCode选错了解释器 | pip install pygame;检查VSCode右下角解释器 |
| TypeError: color argument must be an int or Sequence | 颜色写成了十六进制字符串 | 改用(255, 0, 0)这类RGB元组 |
| pygame.error: video system not initialized | 调用了pygame.quit()后还在画图 | 把quit放在唯一出口,确保不重复初始化 |
最容易被忽视的是解释器路径。Pycharm和VSCode里,终端pip装到了全局Python,但右下角选择的是虚拟环境,两者互不相通。出现ModuleNotFoundError时先看终端里pip --version和python --version是否指向同一个目录,再决定装到哪一端。
4.3 毕业设计里最值得做的三个小功能
如果只交一个能走的推箱子,工作量大概率过不了答辩。加三个功能,代码量不大,但展示时很有结构感:
- 计步与计时。步数已在标题栏显示,计时用
pygame.time.get_ticks()加一个起始变量:
start_time = pygame.time.get_ticks() elapsed = (pygame.time.get_ticks() - start_time) // 1000 pygame.display.set_caption("步数: {} | 用时: {}s".format(game.steps, elapsed))get_ticks()返回毫秒,整除1000得到秒。按R重置关卡后要重新给start_time赋值,否则传感器计时不会归零。
撤销与重置。方向键之外按U撤销、R重开。代码里已经实现
undo(),答辩时现场演示“推错一步按U”,比口述支持撤回更有说服力。关卡选择。在
LEVELS里放5个关卡,启动后按左右方向键切换,或者先打印一个编号菜单。只需要把Game()的入参改成用户选择,代码结构完全不用变。
这三个功能都围绕同一个思路:游戏逻辑与输入渲染分离。Game类里不出现任何pygame事件代码,所以加新操作只是多绑定一个按键。
5. 把推箱子玩出花:自动求解与核心逻辑测试
5.1 给游戏加一个BFS自动求解
答辩时现场演示“一键自动过关”,效果比解释100行代码更直观。推箱子地图小,状态空间不大,用广度优先搜索BFS能找出最短解。状态由玩家坐标和所有箱子坐标共同决定,箱子集合用frozenset转为可哈希对象,才能作为字典的key:
from collections import deque def bfs_solution(game): start = (game.player, frozenset(game.boxes)) queue = deque([start]) prev = {start: None} dirs = [(1, 0), (-1, 0), (0, 1), (0, -1)] while queue: player, boxes = queue.popleft() boxes_set = set(boxes) if game.targets.issubset(boxes_set): path = [] while prev[(player, boxes)] is not None: path.append((player, boxes)) player, boxes = prev[(player, boxes)] return path[::-1] for dx, dy in dirs: next_player = (player[0] + dx, player[1] + dy) if next_player in game.walls: continue next_boxes = boxes_set if next_player in boxes_set: pushed = (next_player[0] + dx, next_player[1] + dy) if pushed in game.walls or pushed in boxes_set: continue next_boxes = set(boxes_set) next_boxes.remove(next_player) next_boxes.add(pushed) next_state = (next_player, frozenset(next_boxes)) if next_state not in prev: prev[next_state] = (player, boxes) queue.append(next_state) return []拿到路径后,逐个计算相邻状态玩家坐标的差值,得到方向序列,按顺序执行即可。BFS在这里比A*简单:目标状态清晰、状态数有限、启发函数不好构造,对十几乘十几的演示地图足够快。
5.2 三个单元测试验证核心逻辑
把Game类单独提取到game.py后,可以写3个轻量测试,证明移动判定、胜利判定和撤销都正确:
from game import Game, LEVELS def test_move_blocked_by_wall(): LEVELS["wall"] = ["###", "#@#", "###"] g = Game("wall") old = g.player assert g.move(-1, 0) is False assert g.player == old def test_box_on_target_wins(): LEVELS["win"] = ["###", "#@*#", "###"] g = Game("win") assert g.is_win() is True def test_undo_restores_state(): g = Game() before = (g.player, frozenset(g.boxes)) g.move(0, 1) g.undo() assert (g.player, frozenset(g.boxes)) == before这几个用例顺手还验证了一件事:LEVELS字典应该由外部维护,不要在Game内部写死。把测试用例做进答辩材料,老师问“怎么保证程序是对的”时,直接现场跑一遍pytest。
5.3 外部关卡文件的加载技巧
最后一个值得加进毕设的功能是把关卡从代码里挪到外部txt,不用改代码就能换地图。解析逻辑很简单:按行读文件,过滤空行,去掉每行的换行符:
def load_level_from_file(path): with open(path, "r", encoding="utf-8") as f: return [line.rstrip("\n") for line in f if line.strip()]唯一要注意的是行首空格不能丢,空格代表地板;文件要保存成UTF-8无BOM,否则列表里混入不可见字符,渲染时会出现莫名其妙的墙。把它接入的方式是:在main()启动时先尝试读外部文件,读取失败再回退到内置LEVELS。答辩时只需在说明里写一句“支持自定义关卡文件”,评审当场让你换一个txt重开一局,这个功能就落地了。
本文还有配套的精品资源,点击获取