用Pygame手搓像素游戏:地图生成、A*寻路与碰撞检测实战

发布时间:2026/10/7 23:48:14
用Pygame手搓像素游戏:地图生成、A*寻路与碰撞检测实战 1. 整体思路与玩法设计最近把一个小项目翻出来重新收拾了一下是个叫caveman的复古像素风小游戏玩家控制一个原始人在迷宫一样的洞穴里找食物、躲怪物、往更深层跑。做这个游戏不是为了蹭什么热点纯粹是想验证“一个周末能不能把一个像素游戏做到能玩”所以从地图生成到角色动画再到音效全部从零手搓没有用现成游戏引擎渲染层只用了 Pygame 做窗口和画布。这个项目适合两类人看一类是想上手写游戏但不知道从哪下手的 Python 初学者可以跟着思路把骨架搭起来另一类是已经写过简单小游戏、但对“地图生成、碰撞体感、难度曲线”这些细节还没有系统经验的开发者我的很多选择和踩坑记录都是常规教程不会写的。caveman 的核心玩法是起终点明确的“往下爬”。每一层随机生成洞穴地形玩家靠键盘方向键移动把这一层的果实吃够数量后通往下一层的地洞入口才会激活。往下走会刷新新的地图同时怪物的移动速度会有提升而玩家的体力和命数是跟随全局的。这个设计听起来简单但真正动手之后才发现光是“随机生成的地图不能让玩家卡死在墙里”这一个问题就够折腾好几个晚上的。2. 引擎选型和渲染方案2.1 为什么用 Pygame 而不是 Unity 或 Godot我在一开始就排除了 Unity 这类重量级方案理由很实际项目目标是要在纯 Python 环境里快速跑起来并且地图生成、A* 寻路、碰撞判定这些逻辑我都希望用 Python 直接写方便调试。Pygame 只负责两件事创建窗口、把像素点阵列绘制到屏幕上其余所有逻辑都在 Python 侧控制调试时可以直接 print 状态变量不需要经过引擎的封装层。如果你对 Pygame 不熟悉先把它理解成一个“可以控制像素点的白板”它不能帮你做物理模拟、动画状态机或者场景烘焙这些都要自己写但这恰恰是学习游戏开发的最佳路径。2.2 渲染遮罩与帧率控制我使用了双缓冲绘图模式核心帧循环如下import pygame def main_loop(screen, clock, world, player): running True while running: dt clock.tick(60) / 1000.0 # 单位是秒 for event in pygame.event.get(): if event.type pygame.QUIT: running False keys pygame.key.get_pressed() player.handle_input(keys, dt, world) # 先清空画布再把整帧绘制到内存 surface surface pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)) world.draw(surface, player.camera_offset) player.draw(surface) # 一次 blit避免频繁刷新闪烁 screen.blit(surface, (0, 0)) pygame.display.flip() pygame.quit()有一点特别关键每一帧都新建一个 Surface 再整体 blit 到屏幕上而不是直接在显示表面画东西。直接往 screen 上画当然也能运行一旦地图里有大量动态元素就会闪烁因为你在擦除和重绘之间看到了中间状态。双缓冲是一次性把完整画面交换上去不会出现扫线感。2.3 帧率与运动数值的关系帧率控制为 60 FPS移动速度的设计就必须按“每秒像素数”来定而不是按帧数来调。比如我希望玩家的移动速度是每帧 2.0 像素在 60 FPS 下就等价于每秒 120 像素。但你没法保证所有机器都能稳定跑到 60 FPS所以输入处理里必须乘以 dt帧时间否则在高刷屏上角色会像开了两倍速。这是我早期踩过的一个很典型的坑把固定值加到坐标上在低帧率电脑上游戏变慢在高帧率电脑上变快手感完全不一致。统一用 dt 之后无论帧率多少每秒移动距离都恒定手感才稳定。同样的思路也用在重力加速度上地图中的果实摆动动画也可以直接用时间做相位偏移跟帧率解耦。3. 洞穴地图的随机生成与碰撞体设计3.1 地图生成的约束条件洞穴地图不能是纯随机撒砖块那样很容易生成根本走不通的死路。我用了“简单房间 走廊连接”的方案这套思路最早来源于 roguelike 游戏的地图生成社区但实现起来并不复杂把地图分割成 8x8 的格子区域每个格子区域内随机决定是“房间”还是“实心岩层”。尝试在区域内放置矩形空洞作为房间房间之间有走廊连接。走廊的生成采用逐步“挖隧道”的方式从上一个房间的中心钻到下一个房间的中心。在生成完成后做一次连通性检查如果玩家出生点所在的连通域面积小于地图总面积 60%重新生成整张地图。连通性检查用的是 BFS 算法非常快。如果你不检查就直接交付玩家可能遇到“能看见下一层的入口但中间隔着永远挖不过去的实心墙”的尴尬局面。以下是核心实现import random from collections import deque def generate_cave(width, height, room_attempts80): # 0 实心墙, 1 地面 grid [[0 for _ in range(width)] for _ in range(height)] rooms [] for _ in range(room_attempts): w random.randint(4, 9) h random.randint(3, 7) x random.randint(1, width - w - 2) y random.randint(1, height - h - 2) # 避免房间重叠过多 new_room [x, y, w, h] overlap False for r in rooms: if not (x w r[0] or r[0] r[2] x or y h r[1] or r[1] r[3] y): overlap True break if overlap: continue for i in range(y, y h): for j in range(x, x w): grid[i][j] 1 rooms.append(new_room) # 连接房间从中心点一路挖通 for i in range(1, len(rooms)): prev_center (rooms[i-1][0] rooms[i-1][2] // 2, rooms[i-1][1] rooms[i-1][3] // 2) curr_center (rooms[i][0] rooms[i][2] // 2, rooms[i][1] rooms[i][3] // 2) cx, cy prev_center while cx ! curr_center[0]: grid[cy][cx] 1 cx 1 if curr_center[0] cx else -1 while cy ! curr_center[1]: grid[cy][cx] 1 cy 1 if curr_center[1] cy else -1 # 连通性验证BFS visited [[False for _ in range(width)] for _ in range(height)] start (rooms[0][1] rooms[0][3] // 2, rooms[0][0] rooms[0][2] // 2) queue deque([start]) visited[start[0]][start[1]] True count 0 while queue: r, c queue.popleft() count 1 for dr, dc in [(1, 0), (-1, 0), (0, 1), (0, -1)]: nr, nc r dr, c dc if 0 nr height and 0 nc width: if grid[nr][nc] 1 and not visited[nr][nc]: visited[nr][nc] True queue.append((nr, nc)) open_ratio count / (width * height) if open_ratio 0.6: return generate_cave(width, height, room_attempts) return grid, rooms3.2 碰撞体不等于贴图尺寸碰撞体是我花了最多时间调细节的地方。像素游戏的贴图通常比逻辑格子大比如一个 32x32 的角色贴图里面有 5 个像素是蓬松的毛发还有 3 个像素是背景阴影如果整个矩形都参与碰撞玩家会在明明离墙还有半格的时候就被挡住视觉上就是“空气墙”。我的方案是把碰撞盒收缩到贴图的“核心区域”并且把坐标原点定在碰撞盒左下角而不是贴图左上角。这样绘制贴图时手动做一个偏移角色脚底和碰撞盒底部严格一致跳跃和下落的时候就不会出现脚下悬空或陷入地面的情况。方块碰撞检测我直接用 AABB轴对齐包围盒实现。虽然听起来高大上但本质就是看两个矩形的边界有没有交叉def check_collision(rect1, rect2): return (rect1.x rect2.x rect2.w and rect1.x rect1.w rect2.x and rect1.y rect2.y rect2.h and rect1.y rect1.h rect2.y)移动顺序上要先分离 X 轴和 Y 轴。如果你在一个方向上同时移动并检测碰撞就会出现“斜着撞墙时被卡进墙角”的经典 bug。正确做法是先沿 X 轴移动一格距离、检测碰撞、修正位置再沿 Y 轴移动、检测、修正。这样从墙边滑过去的时候角色能顺着墙面滑下而不是被牢牢吸住。3.3 四方向移动与八方向斜切问题caveman 的移动是四方向的经典复古手感但我还加了一个细节如果玩家同时按住两个方向键就将速度向量归一化确保斜向移动的速度不会超过水平方向。这个如果不做斜向移动会变成水平移动的 1.414 倍玩家会明显感觉到斜着走更快。4 方向游戏里这种情况不明显但我保留了斜方向输入处理因为手感上更流畅。归一化的实现就是先算出速度向量的模长如果大于最大速度就整体按比例缩放import math def clamp_speed(dx, dy, max_speed): magnitude math.hypot(dx, dy) if magnitude max_speed: dx dx / magnitude * max_speed dy dy / magnitude * max_speed return dx, dy4. 角色动画、怪物 AI 与参数调优4.1 像素帧动画的手工实现我没有使用骨骼动画或 Spine 这类工具caveman 的角色动画是逐帧绘制的像素图每帧 32x32走路、跳跃、拾取动作各做了 4 到 6 帧。动画播放的核心是一个简单的帧序列计时器class Animation: def __init__(self, frames, frame_duration0.12): self.frames frames self.frame_duration frame_duration self.timer 0.0 self.index 0 def update(self, dt): self.timer dt if self.timer self.frame_duration: self.timer - self.frame_duration self.index (self.index 1) % len(self.frames) def current_frame(self): return self.frames[self.index]frame_duration 的设定要根据动作实际挥动幅度来调走路时设为 0.12 秒左右手感比较自然太快会像在抽搐太慢又显得笨重。拾取动作因为整个动作持续时间短我按帧数来触发动画而不是按时间动作开始后播放一次完整序列播完即停。动画帧之间如果出现闪烁多半是帧的尺寸不完全一致。像素画软件里最好统一画布并且在加载后做一次格式检查确认所有帧的宽高完全一致否则 Pygame 把不同尺寸的 Surface 绘制到同一位置时会出现明显的错位抖动。4.2 怪物 AI状态机比“无脑追”更合适洞穴里的怪物不能全都无脑追玩家。我实现了一个简单的状态机每个怪物有巡逻、警戒、追击三种状态巡逻沿固定路径在房间内游走速度慢发现玩家后进入警戒。警戒站在原地朝向玩家方向持续 1 秒后进入追击。追击开启 A* 寻路以超过玩家 15% 的速度追赶。这个设计让怪物行为有了“可读性”玩家可以通过观察怪物的朝向判断自己是否被发现从而绕路。如果全地图的怪物都用 A* 追击那这个游戏就成了纯粹比拼手速的行动力测试缺少潜行策略的味道。A* 寻路的实现我维护了一个优先队列估算函数用曼哈顿距离因为地图是 4 方向移动曼哈顿距离比欧氏距离更贴合实际步数import heapq def astar(grid, start, goal): open_heap [] heapq.heappush(open_heap, (0, start)) came_from {} cost_so_far {start: 0} while open_heap: _, current heapq.heappop(open_heap) if current goal: break for dr, dc in [(1, 0), (-1, 0), (0, 1), (0, -1)]: nr, nc current[0] dr, current[1] dc if grid[nr][nc] 0: # 实心墙跳过 continue new_cost cost_so_far[current] 1 if (nr, nc) not in cost_so_far or new_cost cost_so_far[(nr, nc)]: cost_so_far[(nr, nc)] new_cost priority new_cost abs(nr - goal[0]) abs(nc - goal[1]) heapq.heappush(open_heap, (priority, (nr, nc))) came_from[(nr, nc)] current return came_from这里有个小坑A* 每次重新计算整条路径的开销不小如果场上同时有 8 个怪物都在追击每帧都跑一遍寻路帧率会明显下降。我的优化是每 0.4 秒才重新计算一次路径期间怪物只沿着已有路径点前进。这样既不会让怪物变“傻”也不会把帧率拖垮。4.3 果实数量与难度曲线果实分布的密度直接影响关卡节奏。我一开始把所有层设计成一样的果实数量结果玩家往下走了 5 层之后就感到了强烈的重复劳动感。后来改用递增曲线第 1 层 4 个果实第 2 层 6 个每层加 2 个到第 10 层封顶 22 个。同时增加“精英果实”——吃完后怪物减速 3 秒给玩家一个喘息窗口。实现起来就是一个简单函数def fruit_count_for_level(level): return min(4 (level - 1) * 2, 22)这个曲线的妙处在于前期容易上手后期有压力但不会太夸张。如果你把封顶值设置到 30 个以上玩家的时间大部分都花在找果子上而不是在移动和规避怪物上游戏节奏就会拖沓。5. 常见问题与排查技巧实录5.1 帧率上不去但 CPU 占用很高这个问题十有八九是碰撞检测里用了大量的表面矩形遍历。如果每帧对地图上所有格子做一次碰撞检测地图一大开销就上来了。我的优化办法是只检测玩家所在格子及其周围 3x3 范围内的格子也就是局部碰撞检测。远处的格子不可能和你相撞根本不需要参与计算。实际上可以提前把地图按 32x32 像素分块玩家每帧只需要根据自身坐标算出所在的块索引再对这个块和它四周的 8 个邻居做碰撞遍历复杂度立刻从 O(地图大小) 降到 O(1)。5.2 玩家被卡在两个方块之间的缝隙里卡死问题在“房间 走廊”类型地图里特别容易出现。走廊和房间的连接处偶尔生成的坐标是半格对齐视觉上看着有空间但碰撞盒尺寸和格点精度不匹配玩家就走不过去。排查思路很简单开启 Debug 绘制模式把碰撞盒画成半透明红色然后把地图格点画成灰色网格。一眼就能看到哪些交界处的碰撞盒超出了可行走区域。修正方案是把走廊和房间的尺寸都强制设置成奇数个整格避免 0.5 格的偏移。5.3 怪物追到嘴边却穿墙而过怪物穿墙通常不是 bug而是寻路路径没有做“平滑”。A* 寻路结果是网格点序列如果玩家在怪物寻路过程中改变位置怪物有可能拿到一条穿过实体墙的旧路径。我的做法是路径缓存只在怪物离开当前网格 0.5 格时失效并且在移动前增加一步“下一个路径点是否可通行”的检查如果不可通行就强制重新寻路。另外我建议你在移动怪物时用局部 AABB 检测兜底。无论寻路路径怎么算最终移动碰撞还是要靠碰撞检测来保证不穿墙。寻路只是给移动提供方向参考碰撞检测才是物理底线两者各司其职。5.4 常见问题速查表问题现象主要原因排查方案解决建议画面闪烁单缓冲绘制擦除与绘制可见检查是否双缓冲使用独立 Surface 整体 blit角色斜向移动偏快斜向速度未归一化计算速度向量模长超过最大速度时按比例缩放玩家被卡在墙角碰撞检测按单轴整体判定开启碰撞盒可视化分 XY 轴移动并独立修正怪物追人时穿墙旧路径未失效检查路径缓存逻辑路径点不可通行时强制重新寻路帧率波动每帧全量计算寻路观察 CPU 热点寻路间隔改为 0.4 秒 / 次5.5 存档与重新开始的坑caveman 的关卡进度和全局命数在每次进入新层时自动保存到一个 JSON 文件里。早期我直接把字典序列化后写入文件经常在游戏中途崩溃时发现存档损坏。后来改成两步安全写入先写临时文件再通过 os.replace 替换正式存档文件。这样即使写入过程中断电正式存档也只会停留在上一个完整版本不会出现半截 JSON。进度读取时我还加入了字段校验用 get 而不是直接索引字典。因为存档文件一旦被手工编辑或者版本更新导致字段缺失直接索引字典就会抛 KeyError游戏还没进入主界面就闪退。6. 打包发布与性能调优建议6.1 用 PyInstaller 打包时的资源路径问题Pygame 项目打包成独立可执行文件的时候最大的坑是资源文件路径。直接使用相对路径“assets/player.png”在开发环境没问题打包后当前工作目录不一定在安装目录程序会找不到图片。最稳的解法是使用以下方式获取资源路径import sys from pathlib import Path def resource_path(relative_path): try: base_path sys._MEIPASS except AttributeError: base_path Path(__file__).resolve().parent return Path(base_path).joinpath(relative_path)sys._MEIPASS 是 PyInstaller 在解压临时资源时设置的路径判断它是否存在就能同时兼容源码运行和打包后的环境。6.2 性能预算建议做像素游戏虽然画面简单但不要以为性能压力小。我给自己定了一个帧耗时预算渲染总耗时不超过 12 毫秒负责 60 FPS 下的流畅体验。其中地图绘制约 6 毫秒角色和怪物绘制约 3 毫秒逻辑计算约 2 毫秒其余留给系统开销。实际调优过程中我把画面里的装饰性粒子效果全部砍掉了四分之三因为它们的绘制耗时远超其带来的氛围感。小游戏不是大制作视觉元素每多一层性能开销都是一次实打实的翻倍。7. 一些个人心得caveman 这个项目从动工到跑出一个完整可玩的版本前后用了大概两个完整的周末。说实话整个开发过程中最耗时间的不是 A* 寻路也不是帧动画而是那种“看上去一切正常但玩起来总差一点手感”的时刻。手感这种东西没法通过公式一蹴而就只能在一次次试玩里慢慢调整。速度从 150 像素每秒调到 120再把果实掉落判定框缩小两个像素手感就会立刻不一样。我建议所有照着这个思路做同类游戏的朋友尽早把调试可视化做出来碰撞盒、路径点、帧率曲线都直接画在屏幕上不要等到觉得不对劲了再回头猜。凡是需要反复调优的项目可视化越早越省事。另外一个建议是不要一次性把所有功能都堆上去先把“生成地图 移动 捡东西 进下一层”这条最小闭环跑通再加入怪物和战斗。最小闭环手感对了后面的功能都是加分项最小闭环手感不对所有装饰性的努力都会被放大成累赘。