Python+pygame手写物理引擎:400行实现愤怒的小鸟核心玩法

📅 发布时间:2026/9/30 18:50:13
Python+pygame手写物理引擎:400行实现愤怒的小鸟核心玩法
很多人对用 Python 写一个愤怒的小鸟这件事的印象还停留在网上一搜一大把的截图式代码弹弓画出来了小鸟摆在那儿鼠标一点什么都没发生。我上周帮一个刚学完 Python 基础语法的朋友改作业他把能找到的源码翻了个遍要么是只做了个静态界面要么是直接 import pymunk 把物理全甩给引擎注释写着这段你不用管。后者当然没错但对一个想练手的人来说等于把最值得琢磨的部分外包了出去。所以我自己从头写了一遍纯 pygame、单文件、不引入任何物理引擎四百行左右能拖拽皮筋、能斜抛、能撞塌木箱、能弹死猪、能判定输赢。这篇文章就是这份代码的完整拆解从向量数学到碰撞响应从状态机到参数调参能抄的地方我直接贴代码需要自己想清楚的地方我会把为什么这么选讲透。适合已经装过 Python 环境、写过循环和类、想找一个不那么枯燥的练手项目的人如果你还卡在 python 安装或者 vscode 配置 python 环境这一步第 1 章里我把环境验证的命令也一并写了照着敲完就能跑。1. 动手之前先算清楚这个项目的时间到底花在哪1.1 别被四百行骗了工作量分布很不均匀初次做这类小游戏最容易犯的错是按代码行数估工作量。实际上写着写着你会发现行数最多的渲染部分反而最省心真正吃掉你时间的是那些看不见摸不着的手感问题。我把自己这版的时间分布大致列了个表模块代码占比实现难度最容易被低估的点窗口、主循环、事件处理约 15%低时间步长 dt 的处理弹弓拖拽与抛物线约 10%中拖拽方向是反向的新手极易搞反物理与碰撞检测约 35%高冲量分配、堆叠稳定性关卡数据与绘制约 25%中坐标系与贴图锚点的对齐手感调参约 15%中没有标准答案只能靠试这张表想说明一件事如果你只有两三个小时把碰撞和堆叠写完就该收工了渲染能用色块代替就先用色块。我第一次写的时候花了四十分钟调小鸟的红色圆形半径后来发现根本没必要物理跑通了再回来美化完全来得及。1.2 手写物理还是上 pymunk我的取舍逻辑这是绕不开的第一个决策。pymunk 是 Chipmunk 引擎的 Python 封装刚体、约束、摩擦、旋转一应俱全用它三天能做出一个手感接近原版的版本。但它有两个代价一是你学不到碰撞检测的任何原理二是当你想微调某个手感时会发现要学的东西比手写还多因为引擎的参数命名和直觉经常不一致。我的建议是这样分场景想搞懂游戏是怎么动起来的或者是在准备面试、想有一段能讲清楚的项目经历选纯手写。手写的物理一定不真实木箱不会有真实的翻倒旋转但只要参数调好玩起来是对的。目标是快速产出可玩成品比如课程设计要交作业、要给客户演示选 pymunk。这没什么好纠结的工具就是拿来用的。想两者兼顾可以先手写一版简化物理跑通之后把step_blocks整个函数换成 pymunk 的空间其余状态机、关卡、渲染代码一行不用改。这也是我后来做扩展时的做法。这篇文章走的是纯手写路线所以下面所有代码都不需要除 pygame 之外的任何依赖。1.3 环境准备三条命令验证你的机器能不能跑跑起来只需要 Python 3.10 或更高版本加上 pygame 2.5 以上。Python 版本别太低代码里用到了pygame.Vector2的原地更新语法和 f-string 里的表达式3.8 以下会有些别扭。安装和验证一共三步python --version # 期望输出 Python 3.10.x 或更高 pip install pygame # 或者 pip install pygame-ce社区维护版更新更快 python -c import pygame; print(pygame.ver)如果pip提示找不到命令把pip换成python -m pip基本都能解决这是 Windows 上多版本 Python 共存时的经典问题。编辑器方面VS Code 装好 Python 扩展后在命令面板里选一次解释器就行PyCharm 新建项目时会自动建虚拟环境装 pygame 记得在项目终端里装别装到全局去不然后面打包成 exe 会带一堆没用的库。再补一句macOS 上如果报pygame.error: NSWindow...之类的窗口错误一般是系统 Python 和 Homebrew Python 混用了用虚拟环境能一次性解决。Linux 上如果pygame.display.set_mode直接崩检查一下有没有装 SDL 的运行库或者干脆先用SDL_VIDEODRIVERdummy跑一遍逻辑测试。2. 皮筋拉得越远飞得越远弹弓瞄准背后的向量运算2.1 拖拽方向是反的这不是 bug 而是物理直觉游戏里最反直觉的一步是鼠标往左下拖小鸟要往右上飞。很多人第一次写会写成velocity mouse_pos - anchor结果拖到左边小鸟往左飞完全飞不出去。正确的写法只有一行pull ANCHOR - mouse_pos # 拉伸向量方向由锚点指向鼠标的反方向 velocity pull * POWER_SCALE * 0.08原因是弹弓的发力方向和拉伸方向相反皮筋被往左下拉回弹力就往右上去。理解这一点之后剩下的就是力度上限的问题——必须限制拉伸长度否则玩家可以把鼠标拖到屏幕外小鸟直接以光速飞出去。这就是MAX_PULL存在的原因我取 130 像素这个数字不是拍脑袋来的后面第 5 章会讲怎么根据屏幕宽度反推它。顺带说一句POWER_SCALE这个名字我换成过LAUNCH_POWER、SPEED_FACTOR好几个版本最后发现叫什么无所谓关键是在代码里只出现一次调参时只改这一个地方。我自己踩过的坑是把换算系数散落在三处调完速度发现射程没变排查了半小时才发现是另一处覆盖了。2.2 用 dt 积分而不是每帧加常量初学最容易写出的物理代码长这样bird.vel_y 1 # 每帧加 1 bird.y bird.vel_y # 每帧移动这段代码在 60 帧下看着正常一到 144Hz 高刷屏就变成了超级重力小鸟刚出手就砸地上。因为每帧这个单位和真实时间没有关系帧率一变加速度就变了。正确的做法是把所有速度、加速度的单位统一成每秒然后乘上每帧实际经过的秒数dt clock.tick(FPS) / 1000.0 # 毫秒转秒 dt min(dt, 1 / 30.0) # 卡顿时钳住防止大跳步穿透 bird.vel.y GRAVITY * dt bird.pos bird.vel * dtmin(dt, 1/30)这一行非常关键。不加它用户切出去看个微信再切回来clock.tick返回的可能是 800 毫秒小鸟会在一帧内位移两百多像素直接穿过木箱飞走——这就是后面第 6 章要讲的穿透问题的来源之一。2.3 轨迹预览线模拟一遍比公式推导更省事几乎所有人都希望拖拽时能看到一条虚线预测落点。理论上可以用斜抛公式算x v·cosθ·t, y v·sinθ·t ½gt²但手写物理里还有空气阻力、子步长的叠加公式算出来的线和实际轨迹会有肉眼可见的偏差。最省事、最准的办法是把小鸟未来的运动直接跑一遍只记录位置不产生副作用。def predict_trail(pos, vel, steps90, dt1 / 60.0): 预测轨迹返回一串点仅用于绘制虚线 points [] p pygame.Vector2(pos) v pygame.Vector2(vel) for _ in range(steps): v.y GRAVITY * dt v * AIR_DRAG p v * dt if p.y GROUND_Y or p.x WIDTH 100: break points.append((int(p.x), int(p.y))) return points注意这里的dt用的是固定的 1/60 而不是真实 dt因为预览线只要求看起来和实际差不多固定步长算出来的线更平滑不会因为帧率抖动而闪烁。这就是一个很典型的工程折中真实的物理步长必须自适应可视化的预测线反而要固定。拖拽过程中每帧都调用一次这个函数90 步就是 1.5 秒的飞行时间对 1280 宽的屏幕来说足够覆盖全程。如果嫌卡把 steps 降到 60肉眼几乎看不出区别。3. 碰撞检测圆和矩形的最近点算法以及冲量怎么分3.1 圆对矩形一个函数解决全部判定小鸟是圆木箱是矩形这是最经典也最容易搞混的一对形状。别去写什么分离轴定理圆对矩形有一个极简解法把圆心钳制到矩形的范围内得到矩形上离圆心最近的点再算这个点到圆心的距离。def closest_point_on_rect(p, rect): 矩形上距离点 p 最近的点 return pygame.Vector2( min(max(p.x, rect.left), rect.right), min(max(p.y, rect.top), rect.bottom), )为什么这个钳制是对的分三种情况想圆心在矩形左边时max(p.x, rect.left)会把它顶到左边界圆心在矩形内部时钳制不生效最近点就是圆心自己距离为 0圆心在矩形外时xy 分别钳制得到的恰好是最靠近的那个角。一个函数覆盖了所有情况这就是几何的优雅之处。拿到最近点后判定就只剩一行delta bird.pos - closest if delta.length() bird.r: normal delta.normalize() if delta.length() 1e-6 else pygame.Vector2(0, -1)normal是从矩形指向圆心的单位向量也就是碰撞法线。注意那个 1e-6 的判断圆心正好落在矩形内部时 delta 是零向量归一化会抛异常这时候随便给一个向上的法线就行。这个坑我在第一次写的时候踩过表现是小鸟偶尔撞到箱子正中央游戏直接崩排查了半天才定位到。3.2 反弹系数 0.35 是怎么定出来的碰撞响应用的是最基础的反射公式vn bird.vel.dot(normal) # 速度在法线方向的分量 if vn 0: # 只有正在靠近才处理远离时忽略 bird.vel - normal * (1 RESTITUTION) * vnRESTITUTION就是弹性系数取 0 是完全非弹性撞上去贴着不动取 1 是完全弹性速度大小不变地反弹。我试过的几个值取值实际观感适合场景0.0撞上去像撞了块海绵直接贴住太软缺乏打击感0.2轻微回弹力度感偏弱冰块材质可以用0.35有明显弹开但不会失控乱飞木箱和石头的默认值0.6弹得很欢小鸟经常二次撞墙适合做成弹力球玩法1.0永动小鸟停不下来必须配合阻尼否则死循环选 0.35 的核心理由是它能让小鸟在撞完主目标之后还保留一点余速去破坏旁边的结构但又不会弹回来打到自己。注意这个参数和地面摩擦是联动的弹性调高就得把摩擦调低否则小鸟会在地上弹个没完玩家要等五六秒才能进行下一发节奏就毁了。3.3 冲量分配小鸟撞箱子箱子该飞多快这是整个项目里唯一需要一点手感公式的地方。物理上讲动量守恒但游戏里追求的不是真实而是看起来爽。我的做法是引入一个基于密度的质量比MATERIAL { wood: {color: (198, 132, 66), hp: 45, density: 1.0}, stone: {color: (150, 150, 155), hp: 120, density: 2.2}, ice: {color: (150, 220, 245), hp: 20, density: 0.7}, }density越大被撞之后获得的速度越小。冲量计算写成impact abs(vn) # 撞击强度单位 像素/秒 m_ratio 1.0 / (1.0 block.density) # 质量比 block.vel -normal * impact * 0.9 * m_ratio block.vel.y - min(impact * 0.25, 260) # 补一点上抛视觉上更被撞飞 block.hp - impact * 0.04 # 扣血那个0.9是全局的力度手感系数0.25是上抛系数。为什么要在法线方向之外再单独补一个向上的速度因为纯反射的话正面撞击几乎没有垂直分量箱子只会水平滑走看着很呆。补 0.25 倍的上抛之后箱子会先微微弹起再落下视觉上立刻有了被砸中的感觉。这个技巧在任何打击类的游戏里都通用代价是物理不再守恒——但对一个休闲游戏来说爽感比守恒重要得多。3.4 堆叠稳定迭代分离加休眠阈值方块之间怎么处理是手写物理最麻烦的部分。完整解法需要带旋转的刚体我这里用的是轴对齐矩形的近似方案核心思路是谁在上面谁留下def separate_blocks(blocks, iterations4): for _ in range(iterations): for i in range(len(blocks)): for j in range(i 1, len(blocks)): a, b blocks[i], blocks[j] if not a.rect.colliderect(b.rect): continue ox min(a.rect.right, b.rect.right) - max(a.rect.left, b.rect.left) oy min(a.rect.bottom, b.rect.bottom) - max(a.rect.top, b.rect.top) if ox 0 or oy 0: continue if oy ox: top, bottom (a, b) if a.rect.centery b.rect.centery else (b, a) top.rect.bottom bottom.rect.top if top.vel.y 0: top.vel.y -top.vel.y * 0.1 if abs(top.vel.y) 80: top.vel.y 0.0 bottom.vel.x * 0.9 else: left, right (a, b) if a.rect.centerx b.rect.centerx else (b, a) right.rect.left left.rect.right right.vel.x max(0.0, right.vel.x)iterations4不是随意的数字迭代次数决定了堆叠几层还能保持稳定。只迭代一次三层的塔会明显下陷迭代八次性能开始有感知。四层以内用 4 次迭代刚好碰撞检测是 O(n²)二十个方块也就 190 对现代机器随便跑。休眠机制是另一个关键。每帧都给静止的方块做重力积分是浪费而且会积累浮点误差导致抖动if abs(b.vel.x) 12 and abs(b.vel.y) 12: b.sleep_timer dt if b.sleep_timer 0.4: b.vel.update(0, 0) b.sleeping True else: b.sleep_timer 0.0 b.sleeping False被小鸟撞到时hit_block里会把sleeping置回 False 唤醒它。这个机制的另一个好处是判断场景是否稳定下来变得非常简单——所有方块都 sleeping 了就可以切到下一只鸟了。4. 状态机与关卡数据把物理片段拼成一个能玩的游戏4.1 五个状态就够了游戏逻辑不需要复杂的状态机五个状态能覆盖全部流程状态含义进入条件退出条件aim瞄准中等待玩家拖拽新小鸟就位鼠标松开且拉伸大于阈值fly小鸟飞行物理全开发射小鸟静止或飞出屏幕settle场景等待静止小鸟停下全场休眠或超时win通关猪全部死亡按 R 键重开lose失败鸟用完了还有猪按 R 键重开关键在于settle这个独立状态。很多人会把判断胜负直接塞进fly里结果是小鸟还没落地就弹出了通关提示因为撞飞的过程中猪可能已经死了但方块还在飞。单独留一个结算阶段等所有东西停下来再判定体验会好很多。我给 settle 加了个 1 秒的延时即使场景立刻静止也等一下下让玩家看清战果。def check_state(self, dt): if not any(p.alive for p in self.pig_list): self.state, self.message win, 关卡通过按 R 重开 return moving max([b.vel.length() for b in self.block_list] [self.active_bird.vel.length()]) if moving 90 or not self.active_bird.alive: self.settle_timer dt else: self.settle_timer 0.0 if self.settle_timer 1.0: self.settle_timer 0.0 self.next_bird()4.2 关卡用数据描述别写死在代码里第一版我把每个箱子的坐标用block_list.append(Block(860, 520, 24, 120))硬写改一次关卡要改二十行代码而且很容易漏改。改成数据驱动之后关卡就是一个嵌套的字典LEVELS [ { name: L1 木棚, birds: 3, pigs: [(912, 618, 22), (912, 476, 20)], blocks: [ (860, 520, 24, 120, wood), (960, 520, 24, 120, wood), (855, 496, 134, 24, wood), ], }, { name: L2 石头底座, birds: 4, pigs: [(880, 618, 22), (1040, 618, 22)], blocks: [ (860, 540, 30, 100, stone), (1020, 540, 30, 100, stone), (855, 516, 200, 24, stone), (900, 476, 24, 40, ice), (990, 476, 24, 40, ice), ], }, ]坐标的含义要说清楚方块的(x, y, w, h)里xy 是左上角所以地面在 640一个高 120 的立柱 y 就是 520。猪的坐标是圆心所以要额外减半径。我建议在关卡文件顶部用注释把这条规则写死否则过两天回来改关卡一定会搞错。数据驱动最大的收益是试错成本趋近于零。想试试把木箱换成冰块改一个字符串就行想加一只猪加一个元组。我后来甚至写了个小脚本直接读这个字典画一张关卡俯视图用来检查有没有方块悬空。4.3 伤害判定为什么用相对速度一个容易忽略的细节猪被落下的箱子砸死是因为箱子撞它。如果只用箱子的绝对速度算伤害那么箱子在自由落体时的速度会算成猪在撞箱子判定就错了。正确做法是用相对速度def hit_pig(body_pos, body_vel, radius, pig): delta pig.pos - body_pos dist delta.length() if dist radius pig.r: return False normal delta.normalize() if dist 1e-6 else pygame.Vector2(0, -1) rel_speed abs(body_vel.dot(normal)) # 相对法线的接近速度 if rel_speed 60: # 轻微接触不扣血 return False pig.hp - rel_speed * 0.05 pig.vel normal * rel_speed * 0.4 if pig.hp 0: pig.alive False return True那个rel_speed 60的阈值很重要。没有它小鸟慢慢滚过去蹭一下猪就死了玩家会觉得莫名其妙。60 像素/秒大约相当于明显在移动的速度这个数字我是靠反复试出来的感觉太灵敏就往上调感觉砸不动就往下调。4.4 主循环与渲染把前面的零件装起来到这里把第 2 章的predict_trail、第 3 章的closest_point_on_rect和separate_blocks加上下面的骨架放进同一个.py文件就是一个可以直接运行的游戏了。class Game: def __init__(self): self.level_index 0 self.dragging False self.drag_pos pygame.Vector2() self.settle_timer 0.0 self.message self.load_level(0) def load_level(self, idx): data LEVELS[idx] self.block_list [Block(*b) for b in data[blocks]] self.pig_list [Pig(*p) for p in data[pigs]] self.queue [Bird(ANCHOR.x, ANCHOR.y) for _ in range(data[birds])] self.active_bird self.queue.pop(0) self.state aim def handle_event(self, e): if e.type pygame.KEYDOWN and e.key pygame.K_r: self.load_level(self.level_index) if self.state ! aim: return if e.type pygame.MOUSEBUTTONDOWN and e.button 1: if (pygame.Vector2(e.pos) - ANCHOR).length() 220: self.dragging True self.drag_pos pygame.Vector2(e.pos) elif e.type pygame.MOUSEMOTION and self.dragging: self.drag_pos pygame.Vector2(e.pos) elif e.type pygame.MOUSEBUTTONUP and e.button 1 and self.dragging: self.dragging False pull ANCHOR - self.drag_pos if pull.length() 15: # 拉伸太短视为取消 self.active_bird.reset() return if pull.length() MAX_PULL: pull.scale_to_length(MAX_PULL) self.active_bird.launch(pull * POWER_SCALE * 0.08) self.state fly def update(self, dt): if self.state aim: if self.dragging: pull ANCHOR - self.drag_pos if pull.length() MAX_PULL: pull.scale_to_length(MAX_PULL) self.active_bird.pos ANCHOR - pull return if self.state in (win, lose): return for _ in range(SUBSTEPS): self.step_physics(dt / SUBSTEPS) self.check_state(dt) def draw(self, surf): surf.fill((126, 200, 240)) pygame.draw.rect(surf, (110, 176, 82), (0, GROUND_Y, WIDTH, HEIGHT - GROUND_Y)) # 弹弓 pygame.draw.line(surf, (90, 60, 30), (ANCHOR.x, ANCHOR.y), (ANCHOR.x - 18, GROUND_Y), 10) pygame.draw.line(surf, (90, 60, 30), (ANCHOR.x, ANCHOR.y), (ANCHOR.x 18, GROUND_Y), 10) if self.dragging: pts predict_trail(self.active_bird.pos, (ANCHOR - self.active_bird.pos) * POWER_SCALE * 0.08) if len(pts) 1: pygame.draw.lines(surf, (255, 255, 255), False, pts, 2) for b in self.block_list: pygame.draw.rect(surf, MATERIAL[b.kind][color], b.rect, border_radius3) for p in self.pig_list: if p.alive: pygame.draw.circle(surf, (124, 196, 92), (int(p.pos.x), int(p.pos.y)), p.r) bird self.active_bird if bird.alive: pygame.draw.circle(surf, (220, 60, 60), (int(bird.pos.x), int(bird.pos.y)), bird.r) if self.message: surf.blit(font.render(self.message, True, (255, 255, 255)), (WIDTH // 2 - 120, 40)) def main(): pygame.init() screen pygame.display.set_mode((WIDTH, HEIGHT)) clock pygame.time.Clock() game Game() while True: dt min(clock.tick(FPS) / 1000.0, 1 / 30.0) for e in pygame.event.get(): if e.type pygame.QUIT: pygame.quit() sys.exit() game.handle_event(e) game.update(dt) game.draw(screen) pygame.display.flip()如果想加拖尾把小鸟每帧的位置存进一个列表绘制时用pygame.draw.lines连起来就行长度超过 120 个点就弹出队首。这个小效果对速度感的提升非常明显几乎不消耗性能。5. 从能跑到好玩那几个必须手调的参数5.1 一张表看完全部可调参数代码能跑不等于游戏好玩中间的差距全在参数上。我把这份代码里所有值得调的参数罗列出来包括我最后选定的值和调坏之后的典型症状参数我的取值作用调大了会怎样调小了会怎样GRAVITY1400重力加速度抛物线过陡打不到远处小鸟飘像在月球MAX_PULL130皮筋最大拉伸力度上限过高容易打飞力气太小打不穿第一层POWER_SCALE11.0拉伸到速度的换算一拉就飞出屏幕拖满也够不到箱子RESTITUTION0.35弹性到处乱弹节奏拖沓撞上去贴住没有打击感GROUND_FRICTION0.86地面摩擦落地后滑很远停不下来一落地就死住看着假SUBSTEPS4物理子步数性能下降收益递减高速穿透箱子被打穿伤害系数0.04撞击扣血量一撞就碎没有层次打不动需要多打几次这张表里最需要解释的是GRAVITY和POWER_SCALE的关系。它们不是独立的重力决定了下坠多快初速度决定了飞多远。要让小鸟从屏幕左侧飞到右侧大约 900 像素的位置在 45 度角发射时需要的初速度大约是sqrt(900 * 1400) ≈ 1122像素/秒。而满拉伸时初速度是MAX_PULL * POWER_SCALE * 0.08 130 * 11 * 0.08 ≈ 114——差了十倍这说明我的POWER_SCALE实际取值需要调整或者理解成满拉伸并不是打到最远的力度玩家需要在中段找到最佳角度。这其实是个设计选择如果满拉伸刚好能打到最远那玩家永远拖到底就行失去了策略性。我在实测中的做法是让满拉伸能打到屏幕 95% 的位置留一点点余量。这样《愤怒的小鸟》那种要算角度的感觉就出来了。5.2 相机跟随小鸟飞出屏幕怎么办固定视角在 1280 宽的屏幕上够用但你只要把射程调大一档小鸟就会飞出去。加相机的做法比想象中简单不要真的移动画布而是维护一个camera_x偏移量在绘制时把所有坐标减掉它。camera_x (target_x - camera_x) * min(1.0, dt * 4)dt * 4是插值系数控制相机跟随的软硬程度。直接赋值会跟随得很硬画面会抖插值太慢又会有拖影感。4 这个值是我试出来比较舒服的大约 0.25 秒追上目标。目标是小鸟的位置但要做边界钳制防止相机拍到地图外面去target_x min(max(bird.pos.x - WIDTH * 0.4, 0), LEVEL_WIDTH - WIDTH)加相机的代价是所有点击判定都要把屏幕坐标加上camera_x还原成世界坐标这个转换很容易漏。我的习惯是在事件处理的最开头统一转换一次后面一律用世界坐标避免中途混用。5.3 拖尾和命中反馈花小钱办大事的两处游戏性的提升有时候不来自物理而来自反馈。两个改动成本极低但效果明显拖尾小鸟飞行时留下半透明的红色残影落地后慢慢消失。实现上就是一个列表加pygame.draw.lines改完立刻能感觉到速度。命中特效碰撞时在接触点画一个逐渐扩散并变淡的白色圆圈生命周期 0.2 秒。这个只需在hit_block里往特效列表 push 一个字典更新时减年龄、绘制时按年龄算半径和透明度。这两处加起来不到 40 行代码但对打击感的提升比调任何物理参数都直接。我最初跳过这一步把游戏给朋友试玩反馈是不知道有没有打中加上特效之后同样是那套物理反馈变成了挺爽的。6. 实测踩坑清单这些问题几乎每个人都会遇到6.1 高速穿透小鸟打穿了木箱却没撞到这是我遇到的第一个也是最诡异的问题力度调大之后小鸟以 2000 像素/秒的速度飞行在 60 帧下每帧移动 33 像素而木箱只有 24 像素厚。当小鸟在两帧之间正好跨过木箱时两个位置都没有重叠碰撞检测自然查不到——小鸟直接穿了过去。解决方式有三种从简到繁限制最大速度把速度上限钳在单帧位移小于最薄方块厚度以内。最简单但也限制了手感。提高物理频率把每个渲染帧拆成 N 个物理子步SUBSTEPS4时单帧位移就降到 8 像素安全了。这是我采用的方式也是性价比最高的。连续碰撞检测用线段和矩形求交而不是点求交。最严谨但实现复杂度翻倍。需要注意的是子步长不能无限加。加到 8 时性能开始有感知加到 16 收益已经很小了。判断依据很简单把所有方块的最薄尺寸除以预估的最大单帧位移向上取整就是需要的最小子步数。6.2 帧率相关的代码同一个游戏两种手感写完物理之后一定要在两台不同刷新率的设备上跑一遍这是血泪教训。我自己在 60Hz 的显示器上感觉完美换到 144Hz 的笔记本上发现小鸟落地后滑得特别远——因为地面摩擦写成了每帧乘 0.86144 帧下每秒乘了 144 次而 60 帧下只乘了 60 次。修法是把每帧的系数换算成每秒GROUND_FRICTION 0.86 # 60Hz 下的每帧系数 factor GROUND_FRICTION ** (dt * 60) b.vel.x * factor这样无论帧率是多少一秒内的衰减总量都一致。同样的坑还有每秒扣血 N 点写成了每帧扣血 N 点以及静止 0.4 秒后休眠写成了静止 24 帧后休眠。写游戏的时候养成习惯只要一个量带有时间的语义就必须乘以 dt。6.3 箱子抖动和自爆堆叠的两个经典症状堆叠不稳的表现有两种症状不同原因也不同。抖动箱子在原地高频微颤看着像在发抖。原因是分离算法每帧把重叠推开下一帧重力又把它压回去形成往复。修法是休眠阈值——速度低于 12 像素/秒持续 0.4 秒就彻底静止不再参与积分。自爆明明小鸟没碰到某一层的箱子突然自己飞出去了。这个通常是因为重叠量算反了或者分离时把本来应该在下面的箱子推到了上面导致它悬空后下落加速撞到下一层时速度已经很大。排查方法很土但有效把分离函数的每一次位移都打印出来看哪一对产生了异常巨大的位移。我当时打印发现是两个箱子的oy和ox数值接近交替走了垂直和水平两条分支改成分离时优先看谁的中心更靠近就解决了。6.4 打包成 exe 时的资源路径坑用 PyInstaller 打包这一步如果代码里读了图片或字体文件几乎必然会踩路径的坑。开发时用相对路径assets/bird.png能跑打包后运行会报文件不存在因为 exe 运行时的工作目录变了。通用的修法是这样一段函数def resource_path(rel): base getattr(sys, _MEIPASS, os.path.abspath(.)) return os.path.join(base, rel)再加一条--add-data assets;assetsWindows 用分号macOS 和 Linux 用冒号把资源打进去。顺便提醒一句这份代码如果全部用pygame.draw画图形、用系统字体渲染文字就完全没有这个问题这也是我刻意不引入图片资源的原因之一。6.5 后续可以往哪几个方向扩如果基础版本玩腻了有几个扩展方向性价比很高。一是给不同颜色的小鸟加技能比如黄色加速、蓝色分裂成三只实现上只是在 launch 之后给速度做一次修改加上各自的专属逻辑。二是把物理替换成 pymunk界面和状态机代码一行不改直接获得真实的旋转和翻倒效果。三是做一个简易的关卡编辑器用鼠标拖拽放方块导出成前面LEVELS那种字典格式这一步做完你会发现关卡设计比写代码有意思得多也更能体会到参数和关卡是相互成就的。我自己在这个项目上最深的体会是物理代码写完只算完成了一半剩下那一半是坐在那儿一局一局地玩觉得哪里别扭就改哪个数字。第一次把力度调得太小时小鸟软绵绵地砸在箱子上那一瞬间我突然理解了很多游戏里打击感这个词到底指什么——它不是一个参数而是一堆参数配合出来的整体感受。