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_attempts=80): # 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_duration=0.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,再把果实掉落判定框缩小两个像素,手感就会立刻不一样。
我建议所有照着这个思路做同类游戏的朋友,尽早把调试可视化做出来,碰撞盒、路径点、帧率曲线都直接画在屏幕上,不要等到觉得不对劲了再回头猜。凡是需要反复调优的项目,可视化越早越省事。另外一个建议是不要一次性把所有功能都堆上去,先把“生成地图 + 移动 + 捡东西 + 进下一层”这条最小闭环跑通,再加入怪物和战斗。最小闭环手感对了,后面的功能都是加分项;最小闭环手感不对,所有装饰性的努力都会被放大成累赘。