
简介这是一份基于Pygame的推箱子游戏完整源码包面向正在学习Python游戏开发或准备课程设计的开发者。游戏UI全部由作者手工绘制不依赖外部图片素材通过pygame.draw等绘图函数逐一生成背景、墙壁、箱子与玩家角色内置三关可玩地图用二维数组定义关卡数据完整覆盖地图解析、箱子推动判定、胜利检测、按R键重新开始以及关卡切换等核心机制。压缩包共20个文件以PNG位图、XML配置、编译后的pyc和少量py源码为主总体积仅103KB源码部分包括入口main.py与关卡level.py配合其余辅助文件可清晰了解工程目录结构。目前已有822人学习下载对于想掌握Pygame事件循环、精灵对象、绘图接口与多关卡状态管理的读者而言这是一份小而精的实战范例既能用于课程设计验收也适合自学时逐行拆解运行。1. 从 Boxman 源码包开始为什么这版推箱子值得拆开看在 Pygame 的各类小游戏源码里推箱子属于「看着简单、写起来全是细节」的类型。Boxman 这个项目只有两个核心 Python 文件main.py 和 level.py却把地图渲染、碰撞判定、UI 状态切换、关卡切换全塞进去了。拿到压缩包后先别急着跑python main.py如果提示类似error: failed to build pygame when getting requirements to build wheel说明本地 Python 版本和 pygame 版本不匹配。源码包里同时存在main.cpython-39.pyc和main.cpython-38.pyc这本身就暴露了一个信息作者在多个 Python 版本间迁移过项目用 PyCharm 管理.idea目录。对想学 2D 游戏开发的人来说这份源码的价值在于它没有依赖外部 UI 框架所有界面元素都是用pygame.draw系列函数现场绘制的这正好能解释很多初学者在pygame gui上的困惑为什么别人的界面这么流畅自己的界面却卡到没法看。2. 地图建模与渲染二维数组如何定义三关的结构2.1 数字地图的编码约定打开level.py你会发现整个游戏的地图不是图片而是二维数组。这是 Pygame 推箱子项目最常见的做法用数字代表地图元素用一个数组切换关卡。从资源文件名0.png到7.png可以反向推断出编码约定。在 Boxman 这个项目里0通常代表可通行的地面1代表墙壁2代表箱子3代表目标点4代表玩家起始位置。资源文件里的5.png、6.png、7.png往往用于特殊状态比如箱子进入目标点后的「已放置」状态或者目标点与玩家位置重叠时的处理。我拆过不少同类项目常见做法是EMPTY 0 # 空地 WALL 1 # 墙 BOX 2 # 箱子 TARGET 3 # 目标点 PLAYER 4 # 玩家初始位置 BOX_ON_TARGET 5 # 箱子已在目标点上 PLAYER_ON_TARGET 6 # 玩家站在目标点上这段编码有两个设计考量。第一把「箱子在目标点上」编码成独立值5而不是用两个数组分别记录箱子位置和目标位置这样在绘制时直接查表取图即可不需要每帧做集合判断。第二玩家站在目标点上时编码为6这样当你移动角色离开后只需要把那个格子恢复成TARGET3不需要额外记录「这里曾经是目标点」。第三关的地图数据格式大致长这样level_3 [ [1, 1, 1, 1, 1, 1, 1], [1, 0, 0, 3, 0, 4, 1], [1, 0, 2, 2, 0, 0, 1], [1, 0, 3, 0, 3, 0, 1], [1, 0, 0, 0, 0, 0, 1], [1, 1, 1, 1, 1, 1, 1], ]注意这个数组的每一行长度必须一致否则在后续按行列索引读取时会直接抛出IndexError。这是推箱子地图建模最容易踩的坑尤其是第三关为了增加复杂度会出现不对称的墙体布局空格子补齐不及时的话游戏在渲染阶段就会崩。2.2 渲染顺序先从 level.py 到屏幕的管线main.py中的渲染部分通常是这样组织的for row in range(len(level_map)): for col in range(len(level_map[row])): tile_id level_map[row][col] image tile_images[tile_id] screen.blit(image, (col * TILE_SIZE, row * TILE_SIZE))这里TILE_SIZE是每格像素大小常见值是 32 或 64。Boxman 的资源图中每个 PNG 都是正方形用pygame.image.load()加载后在pygame.display.set_mode()创建的主画布上按坐标平铺。这段代码里有几个值得注意的性能点第一tile_images字典应该在游戏初始化时一次性加载而不是每帧去读取pygame.image.load()否则会产生严重的 I/O 阻塞表现在用户眼里就是「ui界面卡顿」第二blit本身是内存拷贝操作图块尺寸越大单次拷贝越耗时三个关卡的地图尺寸不大所以没有压力但如果想扩展成 50×50 的地图就需要考虑只渲染可视区域也就是类似相机视口的概念。常见做法是加一个摄像机的偏移量camera_x max(0, player_x - SCREEN_WIDTH // 2)然后所有图块的blit坐标减去偏移量。Boxman 的源码没有做这个优化因为三关的地图宽度都没有超过屏幕范围直接全量绘制就不会有性能问题。2.3 关卡切换的数据读取关卡选择不能只靠改level.py的全局变量main.py里通常维护一个关卡列表和当前关卡的索引levels [level_1, level_2, level_3] current_level 0 def load_level(index): global current_map, player_pos, box_positions current_map [row[:] for row in levels[index]] # 重新扫描玩家位置 for r in range(len(current_map)): for c in range(len(current_map[r])): if current_map[r][c] PLAYER: player_pos [r, c] current_map[r][c] EMPTY这里用row[:]做了一层深拷贝原因很直接如果你直接把levels[index]赋给current_map那么游戏过程中对current_map的所有修改都会污染关卡原数据重新开始本关时地图就乱了。这是新手写推箱子最容易犯的错误。玩家位置单独用一个二元素列表记录地图中原本的PLAYER标记被清成EMPTY绘制时再单独把角色图片画上去。这样做的好处是移动角色时只需要更新一个坐标不用反复修改二维数组。目标点、箱子位置的匹配判断每走一步遍历整个地图即可三关地图规模下这个开销可以忽略。3. 事件循环与角色控制从按键到箱子移动的完整链路3.1 pygame.event.get() 与键盘响应推箱子的核心交互是方向键控制角色移动。Pygame 的事件系统通过pygame.event.get()从事件队列中取出当前帧的所有事件常见写法是在主循环的固定位置轮询while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.KEYDOWN: if event.key pygame.K_UP: try_move(-1, 0) elif event.key pygame.K_DOWN: try_move(1, 0) elif event.key pygame.K_LEFT: try_move(0, -1) elif event.key pygame.K_RIGHT: try_move(0, 1)这段代码的触发机制要搞清楚KEYDOWN只在按键按下瞬间进入事件队列如果按住不放系统会根据操作系统设置重复派发KEYDOWN事件。Boxman 源码里没有像《俄罗斯方块》那样实现「长按连续移动」所以玩家必须一下一下地按方向键手感更接近传统推箱子。try_move函数的参数表示行偏移和列偏移向上是-1向下是1向左0, -1向右0, 1。pygame.K_UP这类常量定义在pygame.locals中如果你在import pygame后直接写K_UP而不是pygame.K_UP位于pygame.locals的常量并不会被自动引入。很多从网上拷贝代码的初学者会在这里踩坑。我一般建议全量导入本地常量from pygame.locals import *这样K_UP、QUIT、KEYDOWN可以直接使用代码更简洁也不容易因拼写pygame.KEYDOWN而出错。3.2 移动判定推箱子与撞墙检测移动判定是整个源码中最值得反复看的部分。玩家尝试进入一个格子时需要判断该格子的状态def try_move(dr, dc): global player_pos r, c player_pos nr, nc r dr, c dc if current_map[nr][nc] WALL: return # 撞墙什么都不做 if current_map[nr][nc] BOX or current_map[nr][nc] BOX_ON_TARGET: br, bc nr dr, nc dc # 箱子再往前一格 if (current_map[br][bc] WALL or current_map[br][bc] BOX or current_map[br][bc] BOX_ON_TARGET): return # 箱子后面是墙或另一个箱子推不动 # 执行移动 if current_map[nr][nc] TARGET or current_map[nr][nc] EMPTY or current_map[nr][nc] PLAYER_ON_TARGET: player_pos [nr, nc]逻辑分成三层。第一层目标位置是墙直接返回不移动。第二层目标位置是箱子检查箱子的下一个位置是否可进入——如果箱子后面是墙、另一个箱子则不能推。第三层目标位置是空地或目标点则允许移动。这里有个细节BOX_ON_TARGET同时参与「箱子移动」的判定也就是说被推到目标点上的箱子可以被再次推走。如果你在设计关卡时想做一个「箱子被推进死角就永远无法通关」的布局靠的就是这个判定逻辑。常见做法是在移动箱子后更新目标点状态if current_map[nr][nc] BOX_ON_TARGET: current_map[nr][nc] EMPTY # 箱子移走恢复地面 current_map[br][bc] BOX_ON_TARGET if current_map[br][bc] TARGET else BOX else: current_map[nr][nc] BOX这段代码的顺序很关键先改箱子原来所在的位置再改箱子到达的位置。如果你反过来操作current_map[br][bc]的状态会被它原来的值覆盖就丢失了「这个格子到底是目标点还是普通地面」的信息。所有推箱子游戏的难点都集中在这几行状态更新里——没有处理好就会出现箱子推到位后目标点消失的 bug。3.3 胜利检测与重新开始的触发三关设计的核心在于「每关有独立的胜利条件」。通关判定通常是遍历整个地图看所有目标点是否都被箱子覆盖def check_win(): for row in current_map: for tile in row: if tile TARGET or tile PLAYER_ON_TARGET: return False return True判定逻辑的关键是地图上一旦存在未被箱子占据的TARGET说明还有箱子没有推到位。这里把PLAYER_ON_TARGET也视为未通关因为在 Boxman 的编码方案里角色站在目标点上时地图格子的临时值是6但最终判定标准是「目标点必须被箱子占住」而不是「目标点上不能站人」。实现时需要注意胜利检测要在玩家移动和箱子推动全部完成后再执行否则会出现「玩家推箱子的动作还没结束就被判定胜利」的错位。重新开始的功能一般通过监听K_r键实现直接调用关卡加载函数把current_map还原成初始状态。三关的流程是通关后弹出胜利状态然后加载下一关的地图数据current_level 1。许多网友提供的免费python源码大全里推箱子项目的这一步做得很粗糙通常只是print(You Win)就结束了Boxman 的多关卡切换逻辑值得借鉴。4. UI 全自制的实现路径从 PYGAME.DRAW 到多状态界面4.1 不依赖任何 UI 框架的场景绘制该项目的标签明确写了「UI 全自制」这意味着整个项目的开始界面、游戏画面、胜利提示都不是由外部 GUI 库生成的而是直接在 Pygame 的Surface上绘制。要理解这种实现方式需要先区分pygame.gui和纯pygame.draw绘图方案。pygame.gui这类第三方库虽然提供了标准的按钮、文本框、滚动条但它们有自己的主题系统加载慢定制麻烦在某些分辨率屏幕上还会出现元素拉伸变形。Boxman 的做法更接近游戏引擎的 UI 思路把所有界面分成「开始界面」「关卡选择」「游戏画面」「胜利画面」几个场景每个场景对应一个绘制函数。例如开始界面def draw_start_interface(screen): screen.fill((30, 30, 30)) title_font pygame.font.Font(None, 64) title_surface title_font.render(Boxman, True, (255, 255, 255)) screen.blit(title_surface, (SCREEN_WIDTH // 2 - title_surface.get_width() // 2, 120)) pygame.draw.rect(screen, (70, 130, 180), (SCREEN_WIDTH // 2 - 80, 280, 160, 60)) # 绘制开始游戏按钮pygame.draw.rect()接收一个Rect对象或坐标元组这里传递了按钮的左上角坐标和宽高。按钮的响应逻辑不依赖事件绑定而是在鼠标点击事件中手动检测鼠标位置是否落在Rect对象内elif event.type pygame.MOUSEBUTTONDOWN: mouse_x, mouse_y pygame.mouse.get_pos() start_button_rect pygame.Rect(SCREEN_WIDTH // 2 - 80, 280, 160, 60) if start_button_rect.collidepoint(mouse_x, mouse_y): game_state playing这种坐标检测方案比pygame.sprite.Sprite的事件回调更直观也更容易控制。一个常见的误区是有些人会把pygame.draw.rect的坐标写死换一个窗口大小后按钮全错位。正确的做法是像上面这样基于SCREEN_WIDTH计算居中坐标这样无论窗口怎么缩放按钮都保持在视觉中心。官方文档中的pygame.Rect提供一个collidepoint(x, y)方法专门用于这类需求比手写if mouse_x rect_x and mouse_x rect_x rect_w更简洁也更易维护。4.2 三关卡界面的状态切换与资源预加载UI 状态切换的核心是维护一个全局状态变量可能是字符串也可能是整形枚举GAME_STATE { START: 0, LEVEL_SELECT: 1, PLAYING: 2, WIN: 3, } current_state GAME_STATE[START]游戏主循环每帧检查current_state分派给不同的绘制和事件处理函数。这种做法在 Pygame 中叫状态机状态之间的跳转靠事件触发比如点击「开始游戏」后进入「关卡选择」点击某一关后进入「PLAYING」。三层状态机的好处在于每个状态的绘制逻辑独立新增关卡不需要改动主循环的骨架。Boxman 项目用图片文件startInterface.png作为开始界面素材其余数字命名的png作为地图元素贴图文件名和地图编码的对应关系在加载时建立tile_images {} for tile_id in range(8): tile_images[tile_id] pygame.image.load(f{tile_id}.png)这里要注意一个细节如果在开发过程中给地图新增了编码8但忘记准备8.png程序会在加载时抛FileNotFoundError。合理做法是在开发初期就统一图片命名规范并且把加载操作写成一个遍历目录的函数而非逐个pygame.image.load。另外需要注意加载后的图片是Surface对象直接用blit绘制的速度与图片尺寸直接相关。Boxman 的自制 UI 之所以负担小是因为整套素材都是小尺寸像素风图片单个Surface只有几十乘几十像素缩放后也不会出现明显的渲染开销。如果在其他项目中你将图片pygame.transform.scale到全屏大小再绘制性能会急剧下降这才是 UI 卡顿的真正来源。4.3 用字体与颜色区分不同 UI 状态回到startInterface.png这个素材如果你在代码中找到了screen.blit(start_img, (0, 0))之类的调用说明作者把开始界面做成了整张图片只在按钮区域给了高亮。另一种常见的 UI 实现方式是纯代码绘制那就不需要这张 PNG而是配合pygame.font.Font渲染文字font pygame.font.SysFont(simhei, 30) text_surface font.render(第 1 关, True, (255, 255, 255)) text_rect text_surface.get_rect(center(SCREEN_WIDTH // 2, 350)) screen.blit(text_surface, text_rect)pygame.font.SysFont的第二个参数是字号render的第二个参数控制抗锯齿。字体名称在 Windows 和 Linux 上可能不同写死simhei会在没有该字体的系统上回退为默认字体。合理的做法是先用pygame.font.get_fonts()检查可用的中文字体列表再选择第一个包含hei的项。这些都属于 UI 细节但正是这些细节决定了一个源码是「能跑」还是「好用」。在关卡选择界面使用文字排列三关并高亮当前可进入的关卡配合鼠标hover检测改变按钮背景颜色能给玩家清晰的反馈。这种方式需要手动管理多个Rect对象组成的数组遍历检测点击与前端开发中的「热区」概念相同。pygame官方文档对Rect对象的碰撞检测有详细说明collidepoint只是其中一种Rect与Rect之间还有colliderect可以在绘制提示文字时判断两个按钮是否有重叠。5. 环境排查与性能验证从源码包到可玩状态的关键测试5.1 Pygame 安装失败的定位与解决最后这一章收在具体的可用性验证上。既然网上有人问到error: failed to build pygame when getting requirements to build wheel这一点值得展开。这个报错信息在 pygame 安装中极其常见通常是因为从源码编译而不是从 wheel 安装。在 Windows 上推荐直接使用官方预编译 wheelpython -m pip install pygame --pre--pre参数允许安装预发布版本某些情况下可以获取到包含最新 bug 修复的 wheel。如果仍然失败先用以下命令检查 Python 版本和 pip 配置python --version pip --version pip debug --verbosepip debug --verbose输出中会有cp39-cp39-win_amd64之类的标识确认与当前 Python 版本对应。Linux 上则需要先安装 SDL 依赖库libsdl2-dev、libsdl2-image-dev、libsdl2-mixer-dev、libsdl2-ttf-dev。Boxman 源码包里的main.cpython-39.pyc暗示它曾在 Python 3.9 环境下运行过所以优先用 Python 3.8 或 3.9 搭配 pygame 2.x 测试成功率较高。5.2 运行 main.py 后应观测的正常行为确认 pygame 安装无误后从压缩包解压目录运行python main.py正常的启动流程是黑色窗口弹出然后切换到开始界面出现 Boxman 标题和开始按钮。点击开始后进入关卡玩家角色出现在地图起点位置。分别用方向键测试八方向移动实际上推箱子只需要四方向观察箱子贴图是否跟随移动墙壁是否阻挡角色进入。如果程序运行后立刻闪退大概率是图片路径问题——pygame.image.load(0.png)会在工作目录不包含这个文件时抛异常。解法是把 Python 脚本所在目录设为工作目录cd /path/to/Boxman python main.py或者在代码里用os.path.dirname(__file__)拼接资源路径。这两种方式选一种即可推荐后者写进源码更规范。帧率验证可以直接在主循环中计算clock pygame.time.Clock() fps clock.get_fps()pygame.time.Clock()对象的tick(60)方法会限制循环最大 60 FPS然后在窗口标题栏显示当前 FPS能直观判断 UI 渲染是否有瓶颈。如果 FPS 远低于 30优先检查是否在每帧循环里重复加载了图片而不是绘制代码本身的问题。最后如果要把这个源码分享给没有安装 Python 环境的用户常规做法是用 PyInstaller 打包成单个可执行文件pip install pyinstaller pyinstaller --onefile --windowed --add-data *.png;. main.py这里--add-data的写法在 Windows 上用分号分隔源路径和目标路径Linux 或 macOS 上则是冒号。打包后生成dist/main.exe点击就能运行。注意--windowed参数会隐藏控制台窗口如果程序启动时发生 Python 异常你根本看不到报错信息所以我一般在调试阶段不加这个参数确认稳定后再打包这也是把源码转成成品资源时的常见流程。本文还有配套的精品资源点击获取