
简介一份使用Pygame库实现的推箱子游戏完整项目包面向希望通过实战项目巩固Python编程能力、初步了解游戏开发流程的学习者。项目实现了完整的推箱子玩法包括多关卡地图加载、玩家操控、箱子推动、目标位置判定、胜利检测等功能并配合了简单的图形界面和音效反馈。代码采用模块化写法关键函数注释明确适合初学者逐段阅读和二次修改。压缩包内共有21个文件主要包含主程序脚本、两套关卡地图数据、若干人物与场景图片、一个音效文件以及项目配置文件与说明文档总大小为3.32MB携带方便几乎开箱即用。通过研究这份代码可以学到Pygame中窗口初始化、键盘事件响应、基于Rect对象的碰撞检测、利用游戏时钟控制帧率、多个关卡状态切换以及图像与声音资源的加载显示等核心技巧从而建立起二维游戏开发的基本知识框架。目前已有690人学习下载资料体积小、内容集中特别适合零基础读者将其作为第一个游戏编程练手项目来深入研读。1. 从 RAR 里跑起推箱子这个 Pygame 小项目到底能给你什么“Pygame实现推箱子.rar”这个资源很多人下载后只是跑起来看个动画就关掉了其实浪费。解压后你会面对一套能改的 Python 源码推箱子是少有的“地图数据、游戏逻辑、画面渲染”三层分得比较清的小项目恰好适合第一次完整走一遍“窗口程序”开发流程的人。你会在里面看到 while 循环怎么撑起游戏看到键盘事件怎么一步步变成角色的坐标变化也会看到为什么做游戏比写脚本多出一堆状态要维护。想认真学别急着读全部源码。先跑通再对照玩法拆逻辑最后合上源码自己写一遍。这个方向值得投入因为 Pygame 到今天仍然是课程设计和入门游戏开发的主力两三周业余时间足够你改出一个带多关卡、撤销和步数统计的完整版本。前提是你要把它拆开重装而不是只当玩家。2. Pygame 推箱子的核心拆解地图、状态与“推”的判定做游戏之前先把“游戏是什么”用数据描述出来。推箱子的全部规则可以压缩成一句话角色在地图上移动遇到箱子就推着箱子走墙和边界不能穿过所有箱子被推到目标点就胜利。落到 Pygame 项目里这句话会变成地图数据结构、一次按键的判定流程和主循环里的状态检查。2.1 用二维列表做地图字符约定和加载校验最常见的做法是每行一个字符串字符代表一格地面对象。我一般用这几个符号#表示墙空格表示地面.表示目标点$表示箱子*表示已经在目标点上的箱子表示人表示站在目标点上的人。地图读进内存后存成二维列表后续所有算法都操作这个二维数组而不是反复解析文本。为什么不直接把每个坐标存成对象因为推箱子的核心操作是“格子间关系”人走到相邻格箱子和墙都在相邻格二维索引可以直接用 x、y 偏移算出目标位置代码直观调试时也可以一行一列打印出来看。推箱子地图一般不超过 20×15二维数组的内存开销可以忽略没必要为了省几 KB 去设计复杂的坐标类。WALL # FLOOR TARGET . BOX $ BOX_ON_TARGET * PLAYER PLAYER_ON_TARGET def load_map(text): lines [line.rstrip(\n) for line in text.splitlines() if line.strip()] return [list(line) for line in lines]这段代码的关键在最后一行rstrip(\n)只去掉行尾换行不去掉行内空格避免把地图里表示地面的空格误删if line.strip()会把空行过滤掉但空行通常是多关卡分隔符。单关加载时过滤没问题多关加载时必须先按空行切分关卡再把每个关卡分别交给load_map否则地图会被拦腰截断。加载时还要做一步校验逐行检查地图宽度是否一致否则开屏就会画面错位。Pygame 的blit是按坐标画图的某一行短一截时右侧墙的位置全部左移箱子的路径判断也跟着出错而且这种错位很难用肉眼发现。更隐蔽的坑是字符匹配合法地图里有且只有一个PLAYER或PLAYER_ON_TARGET箱子数量和目标点数量要一致。如果手写关卡时多放了一个箱子会出现“箱子看起来够数但胜利判定永远不触发”的假锁死排查时极其折磨。2.2 移动判定三步走人动、箱动、墙不动Pygame 的按键事件只给你一个方向真正要回答的是“这个方向上能不能走、能不能推”。我把判定写成三步先算人的目标格目标格是墙就不动目标格是箱子再往箱子后面看一格后面是墙或边界就不推否则人和箱子一起移动目标格是地面或目标点就直接移位。注意一个细节人站在目标点上的状态用表示地图里可能同时出现和所以“找玩家位置”的函数必须同时查两种符号还要记住原本的类型。移动之后根据目标格决定新格子写成还是。这一步容易漏漏了的表现是人物走过目标点之后目标点变成普通地面关卡地图被无意修改箱子全部归位也赢不了。DIRS { pygame.K_UP: (0, -1), pygame.K_DOWN: (0, 1), pygame.K_LEFT: (-1, 0), pygame.K_RIGHT: (1, 0), } def move_player(grid, direction): x, y find_player(grid) dx, dy direction nx, ny x dx, y dy if not in_bounds(grid, nx, ny): return cell grid[ny][nx] if cell WALL: return if cell in (BOX, BOX_ON_TARGET): bx, by nx dx, ny dy if not in_bounds(grid, bx, by): return bcell grid[by][bx] if bcell in (WALL, BOX, BOX_ON_TARGET): return grid[by][bx] BOX_ON_TARGET if bcell TARGET else BOX grid[ny][nx] PLAYER_ON_TARGET if cell BOX and (nx, ny) in targets else PLAYER grid[y][x] TARGET if (x, y) in targets else FLOOR这里的核心是“先缓存目标格类型再整体赋值”不要在移动过程中反复读grid[ny][nx]因为第一步修改之后第二次读取到的已经是新值。我在这翻过一次车先写了grid[ny][nx] PLAYER再去判断原位是不是目标点结果人原来的站位写回去时把目标点状态丢了。正确做法是开始时用cell grid[ny][nx]缓存最后再根据它恢复地面。参数上要留意方向元组和 Pygame 坐标轴。屏幕坐标 y 向下为正所以向下移动是(0, 1)向上是(0, -1)。从其他游戏框架转过来的话很容易把向下写成-1表现是按“下”人往上走调试时还以为是按键映射反了。targets这个集合要在加载地图时单独记录所有目标点坐标不能靠遍历地图现场找现场找会把BOX_ON_TARGET和PLAYER_ON_TARGET混在一起无法区分“这个目标点有没有被箱子占住”。2.3 pygame.init() 和主循环事件到底在循环里扮演什么角色Pygame 程序跑起来后操作系统会持续向程序发送事件比如鼠标移动、键盘按下、窗口关闭。pygame.init()负责把所有子系统统一初始化窗口、字体、声音都需要先做好准备。pygame.display.set_mode(...)创建窗口返回的Surface是后续所有绘制的目标对象。记住import pygame; pygame.init()是固定起手式位置必须放在创建窗口之前。主循环的基本结构三件事处理事件、更新逻辑、重绘画布。事件处理里最忌讳写长逻辑。我一般把每种按键事件放进一个很小的方法只改地图数据和状态变量不直接调用画图代码。同一个动作后面可能要追加撤销、步数统计、动画播放事件处理里直接贴画图代码会让渲染频率和操作频率耦合在一起窗口刷新时容易出现闪烁或重复绘制。import pygame def main(): pygame.init() screen pygame.display.set_mode((800, 600)) pygame.display.set_caption(Pygame Sokoban) clock pygame.time.Clock() grid load_map(level_text) running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.KEYDOWN: if event.key in DIRS: move_player(grid, DIRS[event.key]) elif event.key pygame.K_r: grid load_map(level_text) render(screen, grid) pygame.display.flip() clock.tick(60) pygame.quit()clock.tick(60)是把帧率限制在 60 FPS同时让主循环变成稳定的节奏不会因为机器性能差异导致角色移动速度不同。真正关键的是pygame.display.flip()它把后台缓冲区一次性交换到屏幕上。如果只blit不flip在新版 Pygame 下窗口很可能一帧都不显示看起来就是黑屏这是新手翻车率最高的位置之一。旧教程里常见pygame.display.update()单缓冲下也能用但双缓冲模式下必须翻转缓冲区flip()是无脑正确选项。这段代码也说明了推箱子项目最重要的设计习惯地图是数据窗口是表现。同一个grid可以渲染成每格 36 像素也可以放大到 64 像素逻辑层完全不用动。这也是我把这个项目推荐给新人当第一个独立 Pygame 练习的原因三层分离的边界非常清晰。3. 从 RAR 包到跑起来解压、pip install pygame 和最小启动目录从 RAR 包开始做项目第一步不是写代码而是把包里的文件“盘”清楚。这类 Pygame 实现通常不会只有一个.py文件常见的结构是把入口、地图、画面绘制拆开。先理解包的布局再决定从哪个文件下手能省掉大量来回切换窗口的时间。3.1 解压后先分清三类文件入口、数据、资源我先按职责把文件分三类不急着读代码入口文件包含main()或pygame.init()的脚本负责创建窗口和主循环。数据文件地图文本、关卡模块或.json负责提供游戏初始状态。资源文件图片、字体、音频也可能是代码里用pygame.draw现场画的图形。如果解压后根目录很乱我会先搜pygame.image.load和pygame.font.Font的调用看它加载的资源路径是相对路径还是绝对路径。相对路径的基准是“当前工作目录”更准确说是运行时所在的目录不是脚本所在目录。直接用pygame.image.load(assets/box.png)时如果你从别的目录启动程序会直接报pygame.error: Couldnt open。常见解法是用os.path.join(os.path.dirname(__file__), assets, box.png)固定到脚本目录这个写法在任何环境下启动都不会丢资源。还有一类文件值得注意.ttf中文字体和地图文本的编码。中文 Windows 下解压 RAR 可能把文件名变成乱码如果 Python 脚本本身用 GBK 编码而代码里没写# -*- coding: utf-8 -*-Python 3 下要么报SyntaxError要么地图中文注释直接显示成乱码。我的习惯是拿到包先看文件头再用 UTF-8 重新保存一遍避免后面的坑。sokoban/ ├── main.py ├── maps.py ├── sprites.py └── README.txt上面这个布局很典型main.py是入口maps.py里存着地图文本或加载函数sprites.py负责把grid画到screen上。如果你拿到的包也是这种结构改关卡去maps.py改画面去sprites.py改游戏流程留在main.py分工明确互不干扰。3.2 用虚拟环境装 Pygame 的最小启动命令不管这个 RAR 里有没有requirements.txt我都建议先建虚拟环境再装 Pygame。直接全局pip install pygame在个人电脑上没问题但如果你同时开着多个课程项目依赖冲突只是时间问题。虚拟环境把依赖隔离在项目内部删掉重来也不影响系统 Python。cd C:\projects\sokoban python -m venv venv venv\Scripts\activate pip install pygame python main.pyLinux 或 macOS 下激活命令换成source venv/bin/activate。这段命令里最容易翻车的点是python -m venv venv失败通常是系统里没有安装python3-venv包Ubuntu 上需要先sudo apt install python3-venv。另一个坑是激活之后pip install pygame装到了系统 Python检查方法很简单python -m pip --version如果路径带venv字样就是在虚拟环境里。pip install pygame默认安装当前最新版本目前是 Pygame 2.x。2.x 和 1.9 的 API 大体兼容但细节有差异比如 2.x 里pygame.Rect的collidepoint返回值类型不同pygame.surfarray的像素格式也不同。如果你拿到的源码是十几年前基于 1.9 写的最稳妥的是装pygame2.0.3这种过渡版本或者先运行看报错。运行时最常见的错误是ModuleNotFoundError: No module named pygame这不是代码问题是解释器没找对检查虚拟环境激活状态比检查代码更优先。跑通之后把入口脚本运行方式记下来。有的包在main.py里直接pygame.init()没有if __name__ __main__保护这种文件一旦被别的脚本 import就会弹出一个窗口很烦。规范的入口应该有if __name__ __main__: main()这也是你判断作者工程习惯的一个信号。3.3 screen、blit 和 clock 的分工渲染层三个对象Pygame 的渲染体系理解成三层最省力Surface是画板screen是最终显示的那张Surfaceblit是把一个表面复制到另一个表面上Clock控制绘制节奏。推箱子画面很简单不需要复杂的精灵类直接在渲染函数里遍历地图把每个字符画成对应色块就可以。BOX_COLOR (200, 160, 80) WALL_COLOR (70, 70, 90) FLOOR_COLOR (210, 210, 190) TARGET_COLOR (220, 90, 90) def render(screen, grid, cell_size48): for row, line in enumerate(grid): for col, ch in enumerate(line): rect pygame.Rect(col * cell_size, row * cell_size, cell_size, cell_size) if ch WALL: screen.fill(WALL_COLOR, rect) elif ch BOX: screen.fill(BOX_COLOR, rect) elif ch BOX_ON_TARGET: screen.fill(TARGET_COLOR, rect) else: screen.fill(FLOOR_COLOR, rect)这里的cell_size是全局参数调整它就能改整个窗口里的方块大小但窗口大小不会自动跟着变需要同步改set_mode的第一个参数。我的经验是先用800, 600的窗口和40的格子跑通再根据实际地图尺寸重新计算窗口宽高。如果地图是 16 列16 * 48 768像素窗口设 800 刚好如果是 20 列就要设成960。渲染函数里有个容易忽略的性能细节pygame.draw.rect每次传rect对象如果整个地图重新画一帧要调几百次fill。推箱子地图小这没什么压力但如果你扩展成大图就要考虑只重绘变化区域比如记录角色曾经站过的旧格子。我一般不会过早优化这一层先把功能跑通等地图超过 30×20 再考虑脏矩形。4. 推箱子避坑指南常见问题与排查这部分是我最想写的因为推箱子看似简单实际跑起来遇到的问题几乎都集中在“状态没更新对”这件事上。下面四条坑是我在课程设计和帮别人调代码时遇到最多的按频率排序每一条都值得你在跑通后主动测一遍。4.1 箱子推不动或一人推两箱连续判定里的状态污染现象玩家站在墙边推箱子时箱子纹丝不动或者快速连续按两次方向键箱子被推进墙里人物也穿墙了。原因移动判定函数里没有检查边界或者检查顺序反了。我见过一种写法先if grid[by][bx] WALL: return但这里by或bx已经越界Python 列表会直接抛IndexError程序崩溃另一种写法是先if not in_bounds: return然后又去查grid[by][bx]看起来没问题但move_player被同一个方向键触发两次第二次时人物坐标已经更新箱子还没更新到位就会出现“人推空箱子”的假象。解决在move_player开头就统一做边界检查并且把“检查目标格”和“写入目标格”拆到两个阶段中间不要穿插任何pygame.event.get()或time.sleep()。单线程下事件循环不会自动暂停但如果你在移动逻辑里加了调试print或breakpoint状态就可能被外部调用切走。def in_bounds(grid, x, y): return 0 x len(grid[0]) and 0 y len(grid)这行代码越短越不容易错。len(grid[0])是列数len(grid)是行数。如果你的地图做过宽度校验这两个值在程序生命周期内是稳定的没做过校验这里就会偶发崩溃而且很难复现。4.2 撤销功能翻车只记录人的移动没记录箱子移动现象实现了撤销功能后按回退键发现箱子回到原位但人还在远处或者箱子回来了目标点颜色不对。原因撤销本质上要恢复“执行这一步之前的地图快照”。很多人图省事把人物坐标存进列表但箱子坐标没存。问题是推一步箱子时人的新旧坐标和箱子新旧坐标是四个格子的变化只恢复人物等于忽略了箱子的移动路径地图状态自然对不上。解决不存坐标直接存整个地图的快照。最稳妥的方式是每步之前把grid深拷贝一份放进列表推箱子地图很小内存消耗完全可以接受。执行撤销时从列表弹出一份快照赋值回当前地图。def push_snapshot(grid): return [row[:] for row in grid] history [] history.append(push_snapshot(grid)) # ... 玩家按下方向键后 ... history.append(push_snapshot(grid)) # 撤销时 grid[:] history.pop()这里的关键是grid[:] history.pop()不是grid history.pop()。前者修改grid这个列表对象本身所以外部所有引用都能看到新内容后者把变量重新绑定到新列表但你在main()里持有的旧引用不会自动更新撤销看起来没生效。我自己第一次写撤销就踩了这个坑表现是撤销无效还以为是栈操作写错了。4.3 黑屏或窗口未响应display.flip 与事件循环缺一不可现象窗口能打开但整片黑或者窗口白底但有方块闪烁最严重的是窗口直接显示“未响应”点关闭没反应。原因黑屏是因为blit之后忘了pygame.display.flip()。窗口未响应通常是主循环里没有pygame.event.get()或者循环里做耗时操作把事件队列堵死了。Pygame 事件处理是拉取式你必须每帧主动调用event.get()操作系统发来的QUIT事件才不会堆积。循环里如果接了time.sleep(1)窗口在这一秒内就冻结次数多了系统会判定未响应。解决把pygame.event.get()放在while running的顶部不要放在移动逻辑里离屏缓冲区更新后立刻flip()。还有一个隐蔽点pygame.display.set_mode的第二个参数是 flags如果你用了pygame.FULLSCREEN切窗口时容易丢事件调试阶段我总是不加这个参数用窗口模式跑稳定后再切换。窗口未响应时用任务管理器直接结束进程改代码重启不用等系统自己恢复。4.4 中文文件名与地图编码RAR 解压后最容易被忽视的坑现象解压后main.py里看见一坨类似\xe8\x8b\xb1的乱码启动程序时地图里的中文注释变成问号或者代码报SyntaxError: Non-UTF-8 code。原因RAR 包可能在 Windows 的 GBK 环境下打包资源文件名或源码注释是 GBK 编码。Python 3 默认读 UTF-8遇到非 UTF-8 字符直接报错或者显示乱码。地图文件如果用 GBK 编码读取地图里的.和#不受影响但注释和标题全废。解决先解压到本地目录用 Notepad 或 VS Code 批量把文件编码改成 UTF-8。如果文件名本身是乱码直接把相关文件重命名成拼音或英文改代码里对应的引用路径。地图文本加载时最好显式指定编码with open(level1.skb, encodingutf-8) as f: level_text f.read()如果你不敢确定文件原本是什么编码可以用chardet或 VS Code 右下角的编码提示查看。这个坑很小但它能把整个项目卡一小时因为报错信息指向的是“语法错误”你根本想不到是编码问题。5. 从单关到多关关卡切换、计步 UI 与胜利判定跑通一个关卡只是起点。大多数推箱子源码包能让你玩到一两关但距离“能交作业”的状态还差三件事多关卡切换、步数统计和胜利后的通关提示。这节就把这三块补完整。5.1 多关卡文本格式按空行切分并用分隔符控制把多个关卡放进同一个文本文件是常见做法标准规则是每行一个字符串表示一行地图关卡之间用空行分隔。加载函数先按空行把文本切成多个块再逐块校验宽度和箱子数量。这个方案的好处是关卡数据集中在一起方便手动设计新关卡。def load_levels(text): blocks [] current [] for line in text.splitlines(): if line.strip(): current.append(line.rstrip(\n)) else: if current: blocks.append(.join(current)) current [] if current: blocks.append(.join(current)) return [load_map(block) for block in blocks]current.append(line.rstrip(\n))保留行内空格这是地图格式里最容易丢信息的位置。空行判定用line.strip()也就是说一行哪怕只有空格也会被当成空行处理所以地图内不要用纯空格行做装饰否则关卡会被无故切开。加载后按顺序把每个grid放进列表当前关卡索引用整数保存。切换关卡时最省事的逻辑是如果当前胜利索引加一重新加载关卡列表中的下一项如果已经是最后一关就显示全部通关。重置当前关直接重新赋值grid load_map(level_text)不要尝试把当前地图逐个格子还原那样容易漏掉目标点状态。5.2 计步与推箱次数的文本渲染字体文件在 Pygame 里是独立模块计步可以按“有效步数”计也可以按“次数”计两种口径差别很大。按有效步数玩家在墙边反复按方向键不算步数按次数每次按键都会计。我一般先按“成功移动或推动”才算一步这样面板数字随操作平滑增长。实现时在move_player返回一个布尔值调用方拿到True才增加步数。moves 0 # 放在 main() 外层或全局状态里 def on_move(grid, direction): global moves if move_player(grid, direction): moves 1 return True return False推箱次数同理在move_player里判断是否发生了推动cell in (BOX, BOX_ON_TARGET)分支把计数累加。UI 渲染用pygame.font.SysFont或pygame.font.Font加载字体中文字体在 Windows 上可以指定Microsoft YaHeimacOS 用PingFang SC如果不想依赖系统字体就在资源目录放一个.ttf文件。score_font pygame.font.SysFont(microsoftyahei, 28) text_surface score_font.render(f步数: {moves} 推箱: {pushes}, True, (255, 255, 255)) screen.blit(text_surface, (10, 10))render方法每次生成新的Surface如果每帧都调用会在文本变化频繁时产生很多临时对象。实际上文本只会在移动后变化把文本渲染放在移动逻辑之后而不是渲染循环里性能更好。另一个细节是SysFont名字传错不会报错而是回退默认字体中文字体回退后变方块我排查过一次结论是字体名要用系统识别的名字Windows 下用microsoftyahei别传“微软雅黑”。5.3 胜利判定与通关状态状态机不要写进事件循环里胜利判定的标准做法是在地图加载时把目标点坐标存成一个集合每次移动后检查targets集合中所有坐标对应的格子如果都是BOX_ON_TARGET就胜利。直接判断目标点和箱子数量是否相等不可靠因为箱子上可能放着目标点但目标点可能不全被占。def is_win(grid, targets): for tx, ty in targets: if grid[ty][tx] ! BOX_ON_TARGET: return False return True这个函数只读不写可以在移动事件处理之后调用。把游戏状态拆成“进行中、本关胜利、全部通关”三种用布尔变量控制不要在每个分支里都写一套重载逻辑。状态机的好处是关卡胜利后需要弹出文本“本关完成按空格进入下一关”这段提示不能放在render里重复贴否则会跟画面刷新冲突。state playing if is_win(grid, targets): state win # 主循环中 if state win: if event.key pygame.K_SPACE: current_level 1 grid levels[current_level] history.clear() moves 0 pushes 0 state playing注意history.clear()一定要做否则上一关的撤销栈会被带到下一关撤销时把旧地图的尺寸写进新地图轻则画面错乱重则索引越界。这也是我在多关卡改版里翻车最多的地方撤销栈和计步是两个最容易忘记重置的全局状态。6. 最后一步验证你的实现、调整手感以及我保留的习惯程序跑通后先不要急着换大关卡按这三步验证第一手动设计一个“箱子在角落”的地图确认推不动时不报错第二把目标点设在地图边界确认箱子推到边界目标点后能正确显示合体状态第三连续快速按方向键确认不会出现人箱重叠。这三步能覆盖九成的移动逻辑问题。调手感主要调两个参数clock.tick的帧率和移动冷却。推箱子没有帧级动画需求帧率 30 到 60 都可以但事件处理里如果一帧触发多次移动操作会过于灵敏。我的习惯是在KEYDOWN分支里加一个简单的防抖变量用pygame.time.get_ticks()记录上次移动时间间隔小于 150ms 就忽略本次按键。这样按住方向键时不会瞬间冲到底关卡体验更像“一步一格”。我保留的另一个习惯是“先打印地图再关窗口”。在移动逻辑的调试阶段我总是在控制台输出当前grid和步数观察几次按键后的状态变化。不要盯着游戏画面猜测逻辑打印出来的二维数组最能暴露目标点丢失和箱子越界的问题。等逻辑稳定后再把这些打印从代码里删掉或者包到环境变量后面。顺便说下项目结构上的心得推箱子这个体量单文件写完也能跑但维护体验很差。我一般把load_map、move_player、is_win放在sokoban_logic.py把render和字体绘制放在sokoban_gui.py入口main.py只连接两者。这个三层结构的好处是你可以不装 Pygame 也能测逻辑层用普通 Python 列表手动调用move_player验证算法装好 Pygame 后再测渲染层排除问题速度快很多。最后想说的重点是别止步于跑通。把源码里的状态机删掉自己写一遍多关卡切换把固定的格子大小改成变量试试窗口缩放再加一个字符型的可视化调试模式。你会发现自己写第二遍时很多第一次没看懂的“为什么要这样设计”全都清楚了。希望帮到你。本文还有配套的精品资源点击获取