ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python推箱子游戏开发实战:pygame实现、源码打包与自动求解

Python推箱子游戏开发实战:pygame实现、源码打包与自动求解 简介这是一份使用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_SIZE64每格像素电脑屏幕下视觉最舒服FPS30动画节奏数字越小越慢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_namedemo): 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_radius6) 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 installPython没有加入PATH重装Python时勾选Add Python to PATHModuleNotFoundError: No module named pygamepygame没有安装或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转为可哈希对象才能作为字典的keyfrom 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, encodingutf-8) as f: return [line.rstrip(\n) for line in f if line.strip()]唯一要注意的是行首空格不能丢空格代表地板文件要保存成UTF-8无BOM否则列表里混入不可见字符渲染时会出现莫名其妙的墙。把它接入的方式是在main()启动时先尝试读外部文件读取失败再回退到内置LEVELS。答辩时只需在说明里写一句“支持自定义关卡文件”评审当场让你换一个txt重开一局这个功能就落地了。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进