
简介这份资源是面向Python初学者与游戏开发入门者的Pygame推箱子项目源码包围绕经典推箱子玩法帮助读者理解2D游戏开发的基本流程与核心机制。压缩包共21个文件约3.32MB包含1个main.py主程序、2个dat关卡数据、6个jpg与2个png图像素材、1个wav音效、6个xml配置及iml、md等工程说明文件覆盖代码、关卡、美术与声音资源。项目完整呈现了游戏循环、键盘事件处理、基于Rect的碰撞检测、关卡状态管理与胜负判定等关键知识点并集成背景音乐与移动、解谜音效配合自定义角色、箱子与目标点图形便于直接运行调试或二次修改。目前已有690人学习下载适合想通过一个可运行小项目打通Pygame基础、积累游戏开发实践经验的读者参考。1. 从 Pygame 推箱子看 2D 网格游戏的完整落地链路很多人第一次接触游戏开发都是从推箱子这类网格逻辑游戏开始的。它规则简单到一句话能说清把箱子推到目标点不能拉只能推不能穿墙。但真动手用 Pygame 写一个能跑、能玩、能扩展的版本你会发现它几乎覆盖了 2D 游戏开发的所有核心环节——地图数据结构、渲染循环、事件驱动、碰撞检测、状态管理、关卡切换。标题里的「Pygame 实现推箱子」不是一个玩具练习它是一条从零到可交付的完整链路。这篇文章面向两类人刚学完 Pygame 基础想找个项目练手的开发者以及想用最小成本验证网格类玩法原型的从业者。我会把地图怎么设计、渲染怎么分层、移动怎么判定、关卡怎么组织、坑在哪里全部拆开讲清楚让你照着能复现改参数能扩展。2. 地图数据结构与渲染分层先把地基打对2.1 为什么用二维字符数组而不是对象列表推箱子的地图本质是一个静态网格加少量动态实体。常见做法有两种一种是用二维数组存每个格子的类型另一种是维护墙体列表、箱子列表、目标点列表分开管理。我一般会选第一种原因是推箱子的碰撞判定天然依赖「某个坐标上有什么」用二维数组做随机访问是 O(1)代码也最直观。先定义格子类型常量。不要用魔法数字后面排查问题时你会感谢自己# 格子类型常量定义 WALL 1 # 墙 FLOOR 0 # 空地 TARGET 2 # 目标点 BOX 3 # 箱子在空地上 BOX_ON_TARGET 4 # 箱子在目标点上 PLAYER 5 # 玩家在空地上 PLAYER_ON_TARGET 6 # 玩家在目标点上这里有个关键设计决策箱子和玩家是否在目标点上用独立的状态值表示而不是额外维护一个布尔标记。这样做的好处是渲染时只需要查一次格子值就能决定画什么不需要再判断「这个箱子下面有没有目标点」。代价是移动逻辑里要处理状态值的转换但转换规则是固定的写一次就不会错。地图用字符串列表定义可读性最好# 用字符画定义关卡解析时转成二维整数数组 LEVEL_1 [ ########, # #, # .$ #, # #, # .$ #, # #, ########, ] # 字符到格子类型的映射 CHAR_MAP { #: WALL, : FLOOR, .: TARGET, $: BOX, *: BOX_ON_TARGET, : PLAYER, : PLAYER_ON_TARGET, } def parse_level(level_lines): 把字符画关卡解析成二维整数数组 grid [] for line in level_lines: row [CHAR_MAP[ch] for ch in line] grid.append(row) return grid解析函数里没有做长度校验这是故意的——关卡数据是你自己写的格式错误应该在开发阶段就暴露而不是在运行时静默容错。如果你要做关卡编辑器那另说但手写关卡时让错误尽早崩掉更省时间。2.2 渲染分层的三个层次与绘制顺序Pygame 的渲染没有内置层级概念后画的会覆盖先画的。推箱子的渲染顺序必须是背景层 → 静态层墙、目标点→ 动态层箱子、玩家。顺序错了会出现箱子被墙盖住、玩家被目标点盖住这类玄学问题。我一般把绘制拆成三个函数每个函数只负责一类元素import pygame TILE_SIZE 48 # 每个格子的像素尺寸 def draw_background(screen, grid): 绘制地板背景统一铺底色 for y, row in enumerate(grid): for x, _ in enumerate(row): rect pygame.Rect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE) pygame.draw.rect(screen, (40, 40, 40), rect) def draw_static(screen, grid): 绘制墙和目标点这些不会移动 for y, row in enumerate(grid): for x, cell in enumerate(row): rect pygame.Rect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE) if cell WALL: pygame.draw.rect(screen, (100, 100, 100), rect) elif cell in (TARGET, BOX_ON_TARGET, PLAYER_ON_TARGET): # 目标点画一个圆点标记 center rect.center pygame.draw.circle(screen, (200, 180, 60), center, TILE_SIZE // 6) def draw_dynamic(screen, grid): 绘制箱子和玩家 for y, row in enumerate(grid): for x, cell in enumerate(row): rect pygame.Rect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE) if cell in (BOX, BOX_ON_TARGET): color (180, 120, 60) if cell BOX else (220, 160, 80) pygame.draw.rect(screen, color, rect.inflate(-8, -8)) elif cell in (PLAYER, PLAYER_ON_TARGET): center rect.center pygame.draw.circle(screen, (80, 160, 220), center, TILE_SIZE // 3)注意draw_static里对目标点的判断包含了BOX_ON_TARGET和PLAYER_ON_TARGET因为这两种状态下目标点依然存在只是被箱子或玩家占了。如果你漏掉这两个分支箱子推到目标点上之后目标点的标记会消失玩家就分不清哪些点还没被占。绘制顺序在主循环里体现def render(screen, grid): screen.fill((0, 0, 0)) # 清屏 draw_background(screen, grid) draw_static(screen, grid) draw_dynamic(screen, grid) pygame.display.flip()screen.fill不能省否则上一帧的残影会留在屏幕上。pygame.display.flip()做整屏刷新推箱子这种规模不需要脏矩形优化。2.3 地图解析的边界处理与参数校验手写关卡最容易翻车的地方是行长度不一致。上面parse_level直接按每行字符数生成如果某一行多了一个空格网格就会变成锯齿状渲染时右边会缺一块。我一般会在解析后加一个校验def validate_grid(grid): 校验网格是否为规则矩形 if not grid: raise ValueError(关卡为空) width len(grid[0]) for i, row in enumerate(grid): if len(row) ! width: raise ValueError(f第 {i} 行长度为 {len(row)}期望 {width}) return True这个校验在开发阶段跑一次就够发布版本可以去掉。但如果你做的是关卡加载器每次读外部关卡文件都应该校验因为外部数据不可控。另外TILE_SIZE的选择会影响窗口尺寸。窗口宽度 最大列数 × TILE_SIZE高度 行数 × TILE_SIZE。如果关卡尺寸不固定窗口要么做成可调整的要么按最大关卡尺寸固定。我一般选固定窗口加居中绘制避免窗口频繁变化带来的布局问题。3. 移动判定与状态更新推箱子逻辑的核心3.1 玩家移动的四步判定链推箱子的移动逻辑可以用一条判定链描述玩家想往方向 D 走一格先看目标格是什么再决定能不能走、要不要推箱子。具体分四种情况目标格是墙 → 不能走。目标格是空地或目标点 → 玩家直接走过去。目标格是箱子 → 看箱子后面一格如果是墙或箱子则不能推否则推动。目标格是箱子在目标点上 → 同情况 3但推动后箱子状态要区分。把这四种情况写成代码关键是先算出三个坐标玩家当前位置、玩家目标位置、箱子目标位置。# 方向向量上、下、左、右 DIRECTIONS { up: (0, -1), down: (0, 1), left: (-1, 0), right: (1, 0), } def find_player(grid): 找到玩家坐标 for y, row in enumerate(grid): for x, cell in enumerate(row): if cell in (PLAYER, PLAYER_ON_TARGET): return x, y raise ValueError(关卡中没有玩家) def try_move(grid, direction): 尝试移动玩家返回是否成功 dx, dy DIRECTIONS[direction] px, py find_player(grid) nx, ny px dx, py dy # 边界检查 if not (0 ny len(grid) and 0 nx len(grid[0])): return False target_cell grid[ny][nx] # 情况 1墙 if target_cell WALL: return False # 情况 2空地或目标点直接走 if target_cell in (FLOOR, TARGET): move_player(grid, px, py, nx, ny) return True # 情况 3 和 4箱子 if target_cell in (BOX, BOX_ON_TARGET): bx, by nx dx, ny dy # 箱子后面越界或撞墙撞箱子 if not (0 by len(grid) and 0 bx len(grid[0])): return False beyond_cell grid[by][bx] if beyond_cell in (WALL, BOX, BOX_ON_TARGET): return False # 推动箱子 push_box(grid, nx, ny, bx, by) move_player(grid, px, py, nx, ny) return True return False这段代码里find_player每次移动都遍历全图关卡小的时候无所谓但如果关卡是 100×100每帧移动都扫一遍就浪费了。优化做法是把玩家坐标缓存在一个变量里移动时更新。我建议一开始就缓存因为改起来不麻烦# 在游戏状态里维护玩家坐标 player_pos find_player(grid) def try_move_fast(grid, player_pos, direction): dx, dy DIRECTIONS[direction] px, py player_pos # ... 后续逻辑用 px, py 而不是重新查找3.2 箱子状态转换与目标点判定推动箱子时箱子的状态值要根据它落在什么格子上决定。这里有一个容易写错的地方箱子被推离目标点时目标点要恢复箱子被推到目标点上时目标点被覆盖但标记还在。def push_box(grid, from_x, from_y, to_x, to_y): 把箱子从 (from_x, from_y) 推到 (to_x, to_y) # 箱子离开的格子如果原来是 BOX_ON_TARGET恢复成 TARGET if grid[from_y][from_x] BOX_ON_TARGET: grid[from_y][from_x] TARGET else: grid[from_y][from_x] FLOOR # 箱子到达的格子如果原来是 TARGET变成 BOX_ON_TARGET if grid[to_y][to_x] TARGET: grid[to_y][to_x] BOX_ON_TARGET else: grid[to_y][to_x] BOX def move_player(grid, from_x, from_y, to_x, to_y): 移动玩家处理目标点状态 # 玩家离开的格子 if grid[from_y][from_x] PLAYER_ON_TARGET: grid[from_y][from_x] TARGET else: grid[from_y][from_x] FLOOR # 玩家到达的格子 if grid[to_y][to_x] TARGET: grid[to_y][to_x] PLAYER_ON_TARGET else: grid[to_y][to_x] PLAYER注意push_box和move_player的调用顺序先推箱子再移动玩家。如果反过来玩家会先占据箱子原来的位置然后箱子推动时读到的from格子已经是玩家了状态就乱了。这个顺序错误在调试时表现为「推箱子后玩家消失」或者「箱子变成玩家」血泪经验。3.3 胜利条件检测与关卡切换胜利条件很简单所有目标点上都有箱子。也就是网格里不存在TARGET和PLAYER_ON_TARGET这两种「空的目标点」状态。但注意PLAYER_ON_TARGET不算空目标点因为玩家站在上面不算箱子。def check_win(grid): 检查是否所有目标点都被箱子覆盖 for row in grid: for cell in row: if cell TARGET: # 还有空的目标点 return False return True这个检测每帧跑一次开销很小不需要优化。检测到胜利后常见做法是加载下一关或者显示通关提示。关卡数据可以用列表组织LEVELS [LEVEL_1, LEVEL_2, LEVEL_3] current_level_index 0 def load_next_level(): global current_level_index, grid, player_pos current_level_index 1 if current_level_index len(LEVELS): # 全部通关 return False grid parse_level(LEVELS[current_level_index]) validate_grid(grid) player_pos find_player(grid) return True关卡切换时记得重置玩家坐标缓存否则下一关的移动判定会用到上一关的坐标表现为「玩家瞬移」或者「按键没反应」。4. 事件循环与帧率控制让操作手感不飘4.1 键盘事件与连续移动的处理差异Pygame 的键盘事件有两种处理方式pygame.KEYDOWN事件驱动和pygame.key.get_pressed()状态轮询。推箱子这种网格移动游戏我建议用事件驱动因为每次按键只移动一格符合回合制手感。如果用状态轮询按住方向键会每帧移动一格速度取决于帧率手感会飘。import pygame from pygame.locals import QUIT, KEYDOWN, K_UP, K_DOWN, K_LEFT, K_RIGHT, K_r def handle_events(): 处理输入事件返回是否退出 for event in pygame.event.get(): if event.type QUIT: return False if event.type KEYDOWN: if event.key K_UP: try_move(grid, up) elif event.key K_DOWN: try_move(grid, down) elif event.key K_LEFT: try_move(grid, left) elif event.key K_RIGHT: try_move(grid, right) elif event.key K_r: # 重置当前关卡 reset_level() return True事件驱动的一个副作用是按键重复延迟。操作系统在按住键时会先发一个 KEYDOWN延迟一段时间后连续发 KEYDOWN。这个延迟由系统设置决定游戏内改不了。如果你想要更快的连续移动可以在 KEYDOWN 时记录按键状态然后在主循环里用计时器控制移动间隔。但推箱子不需要这么快节奏事件驱动足够。4.2 帧率控制与渲染循环的配合主循环的结构是处理事件 → 更新状态 → 渲染 → 控制帧率。推箱子没有物理模拟状态更新在事件处理里就完成了所以主循环里主要是渲染和帧率控制。def main(): pygame.init() screen pygame.display.set_mode((640, 480)) pygame.display.set_caption(推箱子) clock pygame.time.Clock() running True while running: running handle_events() render(screen, grid) clock.tick(30) # 限制 30 帧 pygame.quit()clock.tick(30)的作用是限制循环每秒最多跑 30 次。推箱子这种静态画面30 帧和 60 帧肉眼没区别但 30 帧能省一半 CPU。如果你要做移动动画比如箱子平滑滑动那需要 60 帧。我一般先用 30 帧有动画需求再调。窗口尺寸(640, 480)是写死的如果关卡尺寸变化需要根据网格计算def calc_window_size(grid): rows len(grid) cols len(grid[0]) return (cols * TILE_SIZE, rows * TILE_SIZE)但窗口尺寸在set_mode之后改不了除非重建窗口所以要么在加载关卡前确定最大尺寸要么用可调整窗口。简单做法是固定一个足够大的窗口关卡居中绘制。4.3 重置关卡与撤销功能的实现思路重置关卡就是把当前关卡的原始数据重新解析一遍。注意要保存原始关卡数据因为grid在游戏过程中会被修改。# 保存当前关卡的原始字符画 current_level_lines LEVELS[current_level_index] grid parse_level(current_level_lines) def reset_level(): global grid, player_pos grid parse_level(current_level_lines) player_pos find_player(grid)撤销功能稍微复杂一点需要记录每一步的状态。最简单的方式是每次移动前把整个grid深拷贝一份压入栈import copy history [] def try_move_with_undo(grid, direction): # 移动前保存状态 history.append(copy.deepcopy(grid)) if not try_move(grid, direction): # 移动失败弹出刚保存的状态 history.pop() return False return True def undo(): global grid, player_pos if history: grid history.pop() player_pos find_player(grid)深拷贝整个网格在关卡小的时候没问题但如果关卡很大、步数很多内存会涨。优化做法是只记录变化的那几个格子但实现复杂度高。我一般先用深拷贝真遇到性能问题再优化。5. 避坑与排查推箱子开发中最容易翻车的五个点5.1 箱子推到目标点上后目标点标记消失现象箱子推到目标点后目标点的圆点标记不见了玩家分不清哪些点还没被占。原因draw_static里只判断了TARGET没有判断BOX_ON_TARGET和PLAYER_ON_TARGET。解决目标点的绘制条件要包含所有「底下是目标点」的状态。参考 2.2 节的draw_static实现判断条件写成cell in (TARGET, BOX_ON_TARGET, PLAYER_ON_TARGET)。5.2 推动箱子后玩家消失或箱子变成玩家现象按方向键推箱子箱子动了但玩家不见了或者玩家位置变成了箱子。原因push_box和move_player的调用顺序反了。如果先移动玩家玩家会占据箱子原来的格子然后push_box读取from格子时读到的是玩家状态导致状态覆盖。解决严格保证先push_box再move_player。在try_move里把这两行的顺序写死不要调换。5.3 关卡切换后按键没反应或玩家瞬移现象通关后加载下一关按方向键玩家不动或者玩家出现在上一关的位置。原因player_pos缓存没有在关卡切换时更新。try_move_fast用的是缓存的坐标如果缓存还是上一关的移动判定就会基于错误坐标。解决每次加载新关卡后重新调用find_player更新player_pos。参考 3.3 节的load_next_level里面有一行player_pos find_player(grid)。5.4 窗口尺寸和关卡尺寸不匹配导致显示不全现象关卡比窗口大右边或下边的格子显示不出来。原因set_mode的尺寸是写死的没有根据关卡尺寸计算。解决在加载关卡后计算所需窗口尺寸如果超过当前窗口要么调整窗口需要重建要么缩放TILE_SIZE。简单做法是固定一个足够大的窗口关卡居中绘制def get_offset(grid, screen_size): 计算关卡居中绘制的偏移量 rows len(grid) cols len(grid[0]) grid_w cols * TILE_SIZE grid_h rows * TILE_SIZE offset_x (screen_size[0] - grid_w) // 2 offset_y (screen_size[1] - grid_h) // 2 return offset_x, offset_y然后在所有绘制函数里加上偏移量。这个改动涉及所有draw_*函数建议一开始就设计好偏移参数不然后期加很麻烦。5.5 按住方向键移动速度过快或过慢现象按住方向键玩家移动速度明显快于或慢于预期。原因用了pygame.key.get_pressed()状态轮询移动速度取决于帧率。帧率高时移动快帧率低时移动慢。解决改用KEYDOWN事件驱动每次按键只移动一格。如果确实需要连续移动用计时器控制移动间隔不要直接依赖帧率。参考 4.1 节的handle_events实现。6. 从可玩到可扩展关卡编辑器与自动求解的接入点推箱子做到能玩之后下一步通常是两个方向做关卡编辑器方便自己设计关卡或者接入自动求解验证关卡可解性。这两个方向都能让项目从练习变成工具。关卡编辑器的核心是鼠标点击切换格子类型。Pygame 里用pygame.mouse.get_pos()拿到像素坐标除以TILE_SIZE得到格子坐标然后修改grid对应位置的值。注意编辑器模式下要禁用玩家移动逻辑否则点一下鼠标玩家就跑了。我一般用一个mode变量区分游戏模式和编辑模式主循环里根据mode决定调用handle_events还是handle_editor_events。mode play # 或 edit def handle_editor_events(grid): for event in pygame.event.get(): if event.type QUIT: return False if event.type pygame.MOUSEBUTTONDOWN: mx, my pygame.mouse.get_pos() gx, gy mx // TILE_SIZE, my // TILE_SIZE if 0 gy len(grid) and 0 gx len(grid[0]): # 循环切换格子类型 current grid[gy][gx] grid[gy][gx] (current 1) % 7 return True自动求解推箱子是一个经典搜索问题常用 BFS 或 A*。状态空间是「玩家位置 所有箱子位置」的组合随着箱子数量增加指数级膨胀。对于小关卡3 个箱子以内BFS 能在几秒内出结果。接入方式是把grid转成状态元组用队列做广度优先搜索每层尝试四个方向直到所有箱子在目标点上。from collections import deque def solve(grid): BFS 求解推箱子返回移动序列或 None start grid_to_state(grid) if is_goal(start): return [] queue deque([(start, [])]) visited {start} while queue: state, path queue.popleft() for direction in DIRECTIONS: next_state apply_move(state, direction) if next_state is None or next_state in visited: continue if is_goal(next_state): return path [direction] visited.add(next_state) queue.append((next_state, path [direction])) return Nonegrid_to_state把二维数组转成可哈希的元组apply_move是纯函数版的移动逻辑不修改原状态而是返回新状态。这个纯函数版本和游戏里的try_move逻辑一样但要去掉所有副作用。写两套逻辑容易不一致我一般把核心判定抽成一个纯函数游戏里调用后手动应用状态变更求解器直接调用纯函数。自动求解的实用价值在于验证关卡可解性。自己设计的关卡有时候会不小心做成死局跑一遍求解器就知道。如果求解器返回 None说明关卡无解需要调整箱子或目标点位置。这个反馈循环对关卡设计效率提升很大。最后说一个我自己的习惯每加一个新功能先写一个最小可跑的版本确认核心逻辑通了再往上叠。推箱子这个项目我第一版只做了单关卡、无撤销、无动画跑通移动和胜利判定后才加关卡切换和撤销。这样每次出问题都能定位到最近一次改动不会在一堆功能里大海捞针。希望帮到你。本文还有配套的精品资源点击获取