☰
Python+pygame 复刻 AngryBirds:物理碰撞与手感调优
2026/9/30 2:07:08 网站建设 项目流程

Python 写 AngryBirds,很多人第一反应是"新手练手的玩具",但我前后推翻重写了三版之后发现,它其实一次性把 2D 游戏里最麻烦的几件事全塞给了你:矢量运算、帧率无关的物理积分、圆与矩形的碰撞检测、碰撞响应里的动量分配、相机跟随、关卡数据组织、胜负状态机。任何一块没想明白,结果就是穿模、抖动、手感软绵绵打不飞。

这篇文章我按"为什么这么设计 → 参数怎么算 → 代码怎么写 → 怎么排查"的顺序讲一遍,最后给出一个可以直接跑起来的完整版本。适合三类人看:刚学完 Python 基础语法、想做点有成就感东西的入门者;写过小游戏但对物理碰撞始终模模糊糊的中级选手;以及想找一个能改、能扩展、能放进简历的项目的人。你不需要会高等数学,但需要知道pygame.Vector2是什么,以及为什么游戏循环里那个dt不能省。

1. 先想清楚:为什么用 Python 复刻 AngryBirds

1.1 pygame 之外的几个选项,我把它们拉出来比了一轮

先说结论:pygame 是这类项目性价比最高的选择,但它不是唯一选择,选错了后面会很痛苦。

我最早试过纯tkinter的 Canvas 来画,理由是"Python 自带、不用装库"。结果画到第三个小鸟就放弃了——Canvas 的图形对象没有一套统一的坐标/矩形抽象,碰撞得自己拿coords()一个个去猜边界,帧率也压不住,跑 30 个物体就开始掉帧。后来又试了arcade,它底层走 OpenGL,渲染性能确实比 pygame 好一截,绘制接口也更现代(arcade.SpriteCircle之类开箱即用),但社区里成型的 AngryBirds 参考资料远不如 pygame 多,你遇到问题时能搜到的东西少一半。

至于直接上 Unity、Godot,那就是杀鸡用牛刀:你要花两天熟悉编辑器界面,而真正想练的物理和碰撞逻辑,引擎已经帮你封装好了,等于绕过了学习目标。

方案上手成本渲染性能物理可控性适合度
tkinter Canvas低差差不推荐
pygame低中等完全可控首选
arcade中好完全可控可选
pymunk(物理引擎)中高依赖渲染层交给引擎进阶
Unity / Godot高优秀被封装偏离学习目标

表里那个pymunk值得单独说一句。它是 Chipmunk 物理引擎的 Python 封装,能直接给你刚体、约束、关节、稳定的堆叠求解。如果你想做的是"能稳定堆三层木箱"的游戏,用 pymunk 是正解。但我不建议第一次就上它——因为你把最该练的东西外包了。用 pygame 手写一遍碰撞,你才会真正理解为什么方块会抖、为什么会穿模、为什么恢复系数调 0.9 会像弹球一样停不下来。这些认知,后面换任何引擎都用得上。

1.2 这个项目真正难的不是画图,是这三件事

把"画一只红色小鸟"这件事排除掉,剩下的全是硬骨头。

第一件是弹弓的手感。玩家按住小鸟往后拖,松手弹出去。听起来简单,但里面有三个坑:拖动距离怎么映射成初速度(线性还是平方)、拖出多远的限制、轨迹预览点要不要显示。我第一版用线性映射,结果轻拉一下就打不动,得拉到极限才有明显效果,手感很"糊"。第二版改成平方映射,又走向另一个极端——稍微一拉就飞太远。最后定下来的是线性映射配一个偏大的系数,具体数值在第 6 节讲。

第二件是帧率无关的物理积分。新手最常犯的错是写成bird.y += bird.vy,每帧加固定值。这样在 60Hz 显示器上跑得好好的代码,换到 144Hz 显示器上小鸟会飞得快一倍多。解决方案是用真实时间间隔dt参与积分,这一条如果没做对,后面所有调参都是白费。

第三件是碰撞检测与响应。游戏里有三种碰撞:小鸟(圆形)撞小猪(圆形)、小鸟(圆形)撞方块(矩形)、方块(矩形)落地和互相堆叠。圆形之间的检测很教科书,圆和矩形就麻烦一些——需要先求矩形上离圆心最近的点,再判断距离。而堆叠稳定性更是大坑,它牵扯到"睡眠机制",也就是什么时候允许一个物体停止计算。

这三件事我在下面都会展开,代码也会按这个顺序给。

2. 环境搭好再动手:Python、pygame 与编辑器配置

2.1 三行命令搞定运行环境

版本上我建议Python 3.10 以上,原因是match...case语法和更完善的类型提示,写状态机的时候舒服很多。pygame 用 2.5.x 以上的版本,Vector2的运算和Surface的缩放接口在 2.5 之后稳定了不少。

安装本身没有难度,难的是"装到哪个解释器里去了"。很多人机器上有三四个 Python,pip install pygame装到了 A,IDE 用的是 B,于是永远在报ModuleNotFoundError。

# 1. 先确认当前用的是哪个 python python --version python -c "import sys; print(sys.executable)" # 2. 用 -m 的方式调 pip,保证 pip 和 python 是同一个 python -m pip install --upgrade pip python -m pip install pygame==2.5.2 # 3. 验证 python -c "import pygame; print(pygame.ver)"

第二条命令里的-m pip是关键习惯。直接敲pip install有时候会调用到系统里另一个 pip,装完之后你在 IDE 里 import 不到,白折腾。用python -m pip就锁定了"用哪个解释器,就用它的 pip"。

编辑器这块,VS Code 和 PyCharm 都行,看习惯。VS Code 的配置重点是选对解释器:按Ctrl+Shift+P,输入Python: Select Interpreter,从列表里挑你刚才装 pygame 的那个路径。选完之后新建终端,终端会自动激活对应环境,这一步不做的话,代码里 import 会飘红。

PyCharm 稍微不一样,它默认给每个新项目建一个虚拟环境。建完之后你可能发现 pygame 不在里面,需要去File → Settings → Project → Python Interpreter,点加号搜 pygame 安装,或者切到系统解释器。

提示:如果你之前装过一堆乱七八糟的库,建议直接开一个干净虚拟环境:python -m venv venv,然后激活(Windows 是venv\Scripts\activate,macOS/Linux 是source venv/bin/activate),再装 pygame。这样后面用 PyInstaller 打包的时候,体积能小一大截。

2.2 工程目录这样分,后面改起来不痛苦

我见过太多人把整个游戏塞进一个main.py,写着写着到六百行,改一行要找五分钟。这个项目我建议拆成五个文件:

angrybirds/ ├── main.py # 入口,游戏主循环和事件处理 ├── settings.py # 所有全局常量:窗口、重力、摩擦、系数 ├── entities.py # Bird / Pig / Block 三个类 ├── physics.py # 碰撞检测与响应函数 ├── levels.py # 关卡数据,纯字典列表 └── assets/ # 图片、音效(先留空也能跑)

这个分法有个明确的好处:调参集中在settings.py。你调手感的时候只开一个文件,改数字、保存、重跑,不用在几百行代码里翻找GRAVITY =在哪里。我第三版才做这个拆分,前两版每次调重力都要滚半天。

physics.py单独拎出来,是因为碰撞函数是纯函数——输入位置和尺寸,输出是否碰撞、法线方向、穿透深度。纯函数好测试,你甚至可以写个test_physics.py断言两个相切的圆不算碰撞,这在后面排查"明明没碰到却弹飞了"这类问题时特别有用。

3. 物理内核:重力、抛物线、碰撞检测的底层账

3.1 为什么必须用 dt 做积分,而不是每帧加固定值

先把最简单也最致命的错误说清楚。

# 错误示范:每帧固定增量 bird.vy += 0.5 bird.y += bird.vy

这段代码在 60 帧每秒的环境里,一秒内vy会增加 30。如果玩家的显示器刷新率是 144Hz,游戏循环跑 144 次,vy一秒增加 72——重力直接变成 2.4 倍,小鸟像是被按在地上。更麻烦的是这种 bug 在你自己的机器上完全复现不出来,直到有人来反馈"你的游戏飞得好快"。

正确做法是把时间作为参数传进去:

dt = clock.tick(FPS) / 1000.0 # 毫秒转秒 dt = min(dt, 1 / 30) # 卡顿时封顶,防止一步跳太远 bird.vel.y += GRAVITY * dt # 加速度 × 时间 = 速度增量 bird.pos += bird.vel * dt # 速度 × 时间 = 位移

GRAVITY的单位是"像素每二次方秒",我一般设 1500。这个数字怎么来的?你可以反推:设定小鸟从最高点下落到地面需要大约 0.6 秒,那么h = ½gt² = 0.5 × 1500 × 0.36 ≈ 270像素。屏幕上从弹弓高度 460 到地面 640,落差 180 像素,对应下落时间约 0.49 秒,感觉是合适的——太慢像月球,太快像铅球。

这套积分方法叫半隐式欧拉积分(先更新速度,再用新速度更新位置)。它比显式欧拉(先用旧速度更新位置,再更新速度)稳定,尤其是在重力这种恒定力场下不容易产生能量漂移。真正的物理引擎会用 Verlet 或 RK4,但对于这个游戏,半隐式欧拉够用,而且代码短到可以一眼看懂。

现在算一下发射参数,看看初速度该给多大。假设弹弓最大拉伸 130 像素,系数取 9.5,那么满拉时初速度大小是130 × 9.5 = 1235像素每秒。玩家一般会往 45 度方向拉,所以:

  • 水平分量vx = 1235 × cos45° ≈ 873
  • 竖直分量vy = -1235 × sin45° ≈ -873
  • 上升时间t_up = 873 / 1500 ≈ 0.58秒
  • 总滞空时间约1.16秒
  • 水平射程873 × 1.16 ≈ 1013像素

窗口宽 1280,弹弓在 x=220,那么最远能打到 x≈1233,刚好覆盖屏幕右侧。这说明MAX_DRAG和POWER_SCALE这两个数不是随便拍的,它们是反推出来的。你要先把"我希望能打多远"这个目标定下来,再倒推系数。我见过有人把系数设成 20,结果小鸟一帧飞出屏幕,看起来像瞬移。

3.2 三种碰撞检测,覆盖这个游戏的全部情况

游戏里所有的碰撞都能归到三种,写三个函数就够了。

圆与圆(小鸟撞小鸟、小鸟撞小猪)。判断圆心距是否小于半径和,同时返回法线方向——也就是从 B 指向 A 的单位向量,后面修正位置要用。

def circle_circle(pos_a, r_a, pos_b, r_b): delta = pos_a - pos_b dist = delta.length() if dist == 0: return False, pygame.Vector2(1, 0), 0.0 # 完全重合,随便给个方向 overlap = r_a + r_b - dist if overlap <= 0: return False, pygame.Vector2(0, 0), 0.0 return True, delta / dist, overlap

圆与矩形(小鸟撞方块)。这里的核心技巧是先求出矩形上离圆心最近的那个点,把"圆与矩形"降维成"点与圆",再判断距离。这个最近点就是把圆心的 x、y 分别钳制到矩形的 x、y 范围内。

def circle_rect(c_pos, c_r, rect): closest_x = max(rect.left, min(c_pos.x, rect.right)) closest_y = max(rect.top, min(c_pos.y, rect.bottom)) closest = pygame.Vector2(closest_x, closest_y) delta = c_pos - closest dist = delta.length() if dist > c_r: return False, pygame.Vector2(0, 0), 0.0 if dist == 0: # 圆心落在矩形内部,取最近的一条边推出去 left = c_pos.x - rect.left right = rect.right - c_pos.x top = c_pos.y - rect.top bottom = rect.bottom - c_pos.y m = min(left, right, top, bottom) if m == left: return True, pygame.Vector2(-1, 0), left + c_r if m == right: return True, pygame.Vector2(1, 0), right + c_r if m == top: return True, pygame.Vector2(0, -1), top + c_r return True, pygame.Vector2(0, 1), bottom + c_r return True, delta / dist, c_r - dist

那个dist == 0的分支千万别省。小鸟速度一快,很容易在某帧的积分结果里直接"落"到方块内部,此时最近点和圆心重合,delta / dist会除零崩溃。我第一次线上崩就是崩在这里,而且它只在特定速度下出现,极其难复现。

矩形与矩形(方块互相堆叠、方块落地)。用轴对齐包围盒检测,直接比四条边。

def rect_rect(a, b): if a.right <= b.left or a.left >= b.right: return False, pygame.Vector2(0, 0), 0.0 if a.bottom <= b.top or a.top >= b.bottom: return False, pygame.Vector2(0, 0), 0.0 # 计算四个方向的穿透深度,取最小的那个作为分离轴 dx_left = a.right - b.left dx_right = b.right - a.left dy_up = a.bottom - b.top dy_down = b.bottom - a.top m = min(dx_left, dx_right, dy_up, dy_down) if m == dx_left: return True, pygame.Vector2(-1, 0), m if m == dx_right: return True, pygame.Vector2(1, 0), m if m == dy_up: return True, pygame.Vector2(0, -1), m return True, pygame.Vector2(0, 1), m

这里"取最小穿透深度"的思路叫最小平移向量(MTV),是分离轴定理在 AABB 上的简化版。它的逻辑很直观:既然两个盒子重叠了,那我就沿重叠最少的方向把它们分开,这样视觉上的跳动最小。

3.3 碰撞响应里那几个系数,决定了手感

检测出碰撞只是第一步,怎么响应才是手感的来源。核心是这一条:把速度分解到法线方向和切线方向,法线方向按恢复系数反弹,切线方向按摩擦衰减。

def resolve_bird_block(bird, block): hit, normal, depth = circle_rect(bird.pos, bird.radius, block.rect) if not hit: return False # 1) 位置修正:把鸟沿法线推出去,消除穿透 bird.pos += normal * depth # 2) 速度分解 vn = bird.vel.dot(normal) # 法向速度分量 if vn < 0: # 只有还在往物体里飞才处理 impulse = -(1 + RESTITUTION) * vn bird.vel += normal * impulse block.vel -= normal * impulse * 0.35 # 方块被推走一点 block.wake() block.damage(abs(vn) * 0.25) # 伤害正比于撞击速度 return True

RESTITUTION(恢复系数)我设 0.35。它的含义是:撞墙后反弹速度是入射速度的 35%。设成 1.0 就是完全弹性碰撞,小鸟会像乒乓球一样弹个不停;设成 0 就完全不弹,贴上去就停住。0.35 这个值是我试出来的——再低一点,小鸟撞方块后像粘住一样没有反馈;再高一点,整个画面会很"跳",玩家算不准第二次能打到哪。

normal * impulse * 0.35里的 0.35 是质量比的近似。严格来说,两个物体碰撞后的速度变化应该按质量分配,但我这里把方块当作"比小鸟重一些",所以它只分到 35% 的冲量。你可以把这个数字理解成"方块有多容易被撞飞":调到 0.8,方块会像纸片一样飞出去;调到 0.1,方块几乎纹丝不动。

伤害计算用abs(vn) * 0.25,也就是只有垂直于表面的撞击才产生伤害。这样设计的原因是:擦着边飞过去的鸟不应该造成太大破坏,正面撞上去才有效果。如果你用速度的模长算伤害,那么贴着方块快速掠过的小鸟会把方块打碎,视觉上很违和。

4. 完整代码拆解:从弹弓到胜负判定

4.1 配置文件与全局参数

把所有魔法数字集中到一个文件,是我做这个项目收益最大的一个决定。

# settings.py WIDTH, HEIGHT = 1280, 720 FPS = 60 GRAVITY = 1500.0 # 重力加速度,像素/秒² AIR_DRAG = 0.999 # 每帧速度衰减,模拟空气阻力 GROUND_Y = 640 # 地面线的 y 坐标 SLING_ANCHOR = (220, 460) # 弹弓皮兜的静止位置 MAX_DRAG = 130 # 最大拉伸距离,像素 POWER_SCALE = 9.5 # 拉伸距离 → 初速度的换算系数 RESTITUTION = 0.35 # 碰撞恢复系数 GROUND_FRIC = 0.82 # 触地后的水平摩擦 BLOCK_HP = {"wood": 60, "ice": 25, "stone": 120} BLOCK_COLOR = {"wood": (196, 148, 92), "ice": (150, 220, 240), "stone":(140, 140, 145)}

AIR_DRAG = 0.999每帧乘一次,这个数值是配合 60FPS 的。严格来说它应该写成drag ** (dt * 60)才能在任意帧率下表现一致。我在实际项目里的处理方式是:既然dt已经被封顶在1/30,误差可以接受,就先这样。但如果你要追求严谨,改成帧率无关的写法会更好。

三种材质的血量差异是有讲究的。木头 60、冰 25、石头 120,意味着冰一撞就碎(打击感强、爽),石头需要全力撞击两三次(有挑战性),木头介于中间。这个梯度不是随便排的,它是构建关卡节奏的基础:前期关卡多放冰和木头,让玩家快速获得正反馈;后期关卡用石头筑墙,逼玩家找角度。

4.2 弹弓拖拽与发射:手感全在这个函数里

弹弓的交互逻辑我写过三版,最终这版是最顺的。

# main.py 中的事件处理片段 anchor = pygame.Vector2(SLING_ANCHOR) dragging = False drag_pos = pygame.Vector2(anchor) for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: mouse = pygame.Vector2(event.pos) # 判定容差给 60 像素,比小鸟半径大不少,手机上更好点 if bird.state == "idle" and mouse.distance_to(bird.pos) < 60: dragging = True elif event.type == pygame.MOUSEMOTION and dragging: mouse = pygame.Vector2(event.pos) offset = mouse - anchor if offset.length() > MAX_DRAG: offset.scale_to_length(MAX_DRAG) # 超出部分直接截断 drag_pos = anchor + offset bird.pos = drag_pos # 小鸟跟着手指走 elif event.type == pygame.MOUSEBUTTONUP and event.button == 1 and dragging: dragging = False pull = anchor - drag_pos # 注意方向:是"拉的反方向" if pull.length() > 12: bird.launch(pull * POWER_SCALE) else: bird.pos = anchor # 拉得太少,视为放弃

几个细节值得展开。

第一,offset.scale_to_length(MAX_DRAG)而不是offset = offset.normalize() * MAX_DRAG。两种写法结果一样,但scale_to_length是原地修改,省一次对象创建。在每帧都可能调用的路径上,这是有意义的。当然更重要的原因是它可读性好一点。

第二,pull = anchor - drag_pos的方向。很多人第一次写会写成drag_pos - anchor,结果小鸟往反方向飞。想一下:你把小鸟往左下拖,它应该往右上飞,所以速度方向是"从当前位置指向锚点",也就是anchor - drag_pos。

第三,判定松手的阈值 12 像素。低于这个距离说明玩家只是点了一下,不算蓄力。如果不做这个判断,偶尔的误触会让小鸟原地掉下来,很烦。

第四,右键取消。真实游戏里玩家拉了一半想反悔,需要一个取消途径。加一个MOUSEBUTTONDOWN里判断event.button == 3就重置位置,体验会好很多。

轨迹预览点也值得加,它的实现就是跑一遍物理模拟:

def simulate_trajectory(pos, vel, dt=1/30, steps=45): pts = [] p = pygame.Vector2(pos) v = pygame.Vector2(vel) for _ in range(steps): v.y += GRAVITY * dt p += v * dt pts.append((p.x, p.y)) if p.y > GROUND_Y: break return pts

注意这里用的是固定的1/30而不是真实dt。因为预览点需要和实际飞行轨迹吻合,而实际飞行中dt会波动,所以模拟用的步长应该和"理想帧率"对齐。我用 1/30 是因为它正好是dt封顶值,模拟出来的前几帧和实际最接近。

4.3 小鸟、小猪、方块三个类的设计

小鸟的关键状态是idle / flying / rest三态。

class Bird: def __init__(self, x, y, radius=18, color=(214, 52, 52)): self.pos = pygame.Vector2(x, y) self.vel = pygame.Vector2(0, 0) self.radius = radius self.color = color self.state = "idle" # idle / flying / rest self.mass = 1.0 def launch(self, v): self.vel = pygame.Vector2(v) self.state = "flying" def update(self, dt): if self.state != "flying": return self.vel.y += GRAVITY * dt self.vel *= AIR_DRAG self.pos += self.vel * dt # 地面碰撞 if self.pos.y + self.radius >= GROUND_Y: self.pos.y = GROUND_Y - self.radius if self.vel.y > 0: self.vel.y = -self.vel.y * RESTITUTION self.vel.x *= GROUND_FRIC # 判定"停下来",避免无限微动 if abs(self.vel.x) < 8 and abs(self.vel.y) < 30: self.vel.update(0, 0) self.state = "rest" # 飞出屏幕外也算停下 if self.pos.x < -300 or self.pos.x > WIDTH + 400: self.state = "rest"

那个"停下来"的判定非常重要。如果不做,小鸟会在地面上以极小的速度永远弹跳,虽然幅度小到看不见,但状态永远是flying,导致关卡永远不判定结束。这个 bug 我调了很久才定位到,因为它不报错、不崩溃,只是关卡卡住。

小猪比小鸟多了血量和受击闪白:

class Pig: def __init__(self, x, y, radius=22): self.pos = pygame.Vector2(x, y) self.vel = pygame.Vector2(0, 0) self.radius = radius self.hp = 40 self.alive = True self.flash = 0.0 # 受击闪白的剩余时间 def damage(self, amount): self.hp -= amount self.flash = 0.12 if self.hp <= 0: self.alive = False def update(self, dt): if not self.alive: return self.flash = max(0.0, self.flash - dt) self.vel.y += GRAVITY * dt self.pos += self.vel * dt if self.pos.y + self.radius >= GROUND_Y: self.pos.y = GROUND_Y - self.radius if self.vel.y > 0: self.vel.y = -self.vel.y * RESTITUTION self.vel.x *= GROUND_FRIC if abs(self.vel.x) < 5: self.vel.x = 0

flash这个字段是个小成本大收益的设计。受击瞬间把小猪画成白色,0.12 秒后渐变回来,玩家立刻能感知到"打中了"。如果没有这个反馈,撞击和结果之间会有一层模糊感。

方块的核心是sleeping标记:

class Block: def __init__(self, x, y, w, h, kind="wood"): self.rect = pygame.Rect(x, y, w, h) self.vel = pygame.Vector2(0, 0) self.kind = kind self.max_hp = BLOCK_HP[kind] self.hp = self.max_hp self.dead = False self.sleeping = True # 初始就是静止的 def wake(self): self.sleeping = False

为什么一开始就是sleeping = True?因为关卡刚加载时,方块是我们手动摆好的——每个方块的位置都精确地搁在下面那个方块的顶面,速度是零。如果这时候让它们参与物理计算,浮点数误差会让它们互相挤压,产生微小抖动,几个方块摞起来就会肉眼可见地"哆嗦"。所以初始状态直接标记为睡眠,只有被撞到才唤醒。

这个机制的思路在真实物理引擎里叫island sleeping(岛屿休眠),是稳定堆叠的关键技术之一。

4.4 主循环、相机与状态机

主循环要处理的事情按顺序是:算时间 → 收事件 → 更新物理 → 解算碰撞 → 更新相机 → 绘制。

pygame.init() screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("AngryBirds - Pygame") clock = pygame.time.Clock() font = pygame.font.SysFont("arial", 24) big_font= pygame.font.SysFont("arial", 48, bold=True) camera_x = 0.0 running = True SUBSTEPS = 4 # 每个物理帧切成 4 小步,防止穿模 while running: dt = min(clock.tick(FPS) / 1000.0, 1 / 30) # ---- 1. 事件 ---- for event in pygame.event.get(): handle_event(event) # ---- 2. 物理(分子步)---- sub_dt = dt / SUBSTEPS for _ in range(SUBSTEPS): bird.update(sub_dt) for pig in pigs: pig.update(sub_dt) for blk in blocks: blk.update(sub_dt) handle_collisions() # ---- 3. 相机 ---- target_cam = 0.0 if bird.state == "flying": target_cam = max(0.0, bird.pos.x - WIDTH * 0.4) camera_x += (target_cam - camera_x) * min(1.0, dt * 6) # ---- 4. 绘制 ---- screen.fill((120, 190, 235)) draw_ground(screen, camera_x) draw_slingshot(screen, camera_x) for blk in blocks: blk.draw(screen, camera_x) for pig in pigs: pig.draw(screen, camera_x) bird.draw(screen, camera_x) draw_ui(screen) pygame.display.flip()

分子步(substeps)是我加的第二版才想到的东西。满拉时小鸟的初速度是 1235 像素每秒,60FPS 下每帧移动约 20 像素,而木柱宽度只有 24 像素。这意味着小鸟在某一帧可能正好落在柱子内部,下一帧已经到柱子另一边,碰撞检测完全错过,看起来就是"穿过去了"。把一帧的物理拆成 4 小步,每步位移降到 5 像素,就基本不会穿。

代价是物理计算量变成 4 倍。但这个游戏的物体数量通常在 30 个以内,4 倍完全撑得住。如果你以后要把方块数量堆到几百个,就需要换成连续碰撞检测(CCD)或者空间划分,而不是继续加子步。

相机跟随用的是指数平滑,camera_x += (target - camera_x) * min(1.0, dt * 6)。这里的min(1.0, ...)很重要——当dt特别大时(比如窗口被拖动导致卡了一下),dt * 6可能超过 1,相机就会过冲,表现为画面"甩"一下。钳制到 1.0 就限制了单帧最大移动量。

4.5 关卡数据与胜负、计分

关卡数据我用纯 Python 列表描述,改起来比 JSON 文件方便,也方便写注释。

# levels.py def build_level_1(): blocks, pigs = [], [] # 两根木柱 + 一块横板,经典的"小房子" blocks.append(Block(760, 560, 24, 80, "wood")) # 左柱 blocks.append(Block(880, 560, 24, 80, "wood")) # 右柱 blocks.append(Block(748, 536, 168, 24, "wood")) # 横板 # 一只猪躲在横板上 pigs.append(Pig(820, 514, 22)) # 一只猪站在地面 pigs.append(Pig(980, 618, 22)) return blocks, pigs

坐标是怎么算的,值得说清楚,因为这是很多人第一次写关卡会算错的地方。地面在y = 640,木柱高 80,所以柱子的顶面在640 - 80 = 560,这就是它的rect.y。横板厚 24,放在柱子顶面上,所以它的顶面在560 - 24 = 536。横板要能盖住两根柱子,左柱左边缘在 760,右柱右边缘在 904,宽度取 168 就能从 748 延伸到 916,稍微出点头,视觉上更像"搭上去的"。

猪坐在横板上,它的中心 y 应该是536 - 22 = 514(减去自己的半径)。站在地面的猪,中心 y 是640 - 22 = 618。所有坐标都是从"底面贴着支撑面"这个约束反推出来的,不要凭感觉填。

胜负判定就三行:

alive_pigs = [p for p in pigs if p.alive] all_birds_used = all(b.state == "rest" for b in bird_queue) and not current_bird.state == "flying" if not alive_pigs: state = "win" elif all_birds_used and not alive_pigs: state = "lose"

计分我用了比较克制的方案:每只猪 5000 分,每只剩余的小鸟 10000 分,方块碎裂不加分。剩余小鸟给高分是有意为之——它鼓励玩家用最少的鸟解决问题,而不是无脑乱轰。这个设计是 AngryBirds 原作的精髓,抄过来是值得的。

5. 踩坑记录:物理抖动、穿模、打包这些事

5.1 堆叠方块睡不好觉,一直在抖

这是我遇到的第一个顽固 bug。表现是:三个方块摞起来,最上面那个会以大概每两秒一次的频率轻微抖动一下,看久了眼睛很难受。

排查过程是这样的。先怀疑是渲染精度问题,加了int()取整,没用。然后怀疑是dt波动,把主循环的dt打印出来,发现在 0.0160 到 0.0172 之间跳,这确实是抖动来源之一,但不至于这么明显。

真正的原因是位置修正和速度更新在打架。每次碰撞解算时,我把方块沿法线推出去depth距离;下一帧重力又把它压下来一点点;再解算又推出去。这个"压下来-推出去"的循环就是抖动。

解决办法有三个,我都试过:

方案一,加睡眠阈值。如果一个方块的vel.length() < 5且已经贴着支撑物,就把它标记为sleeping,跳过后续的物理更新。唤醒条件是任何一个速度足够大的物体碰到它。这个方案治本,但需要维护唤醒逻辑。

方案二,位置修正只做 80%。也就是pos += normal * depth * 0.8。这样会残留一点穿透,视觉上看不出来,但能大幅减少来回震荡。代价是如果两个物体持续挤压,穿透会累积。

方案三,位置修正只在必要时做。如果穿透深度小于 0.5 像素,就直接不管。这个方案最简单,而且效果不错,因为小于半像素的穿透玩家根本看不见。

我最终用的是方案一 + 方案三的组合:小穿透忽略,静止物体睡眠。这套组合下来,堆四层方块也能稳如泰山。

def resolve_block_stack(a, b): hit, normal, depth = rect_rect(a.rect, b.rect) if not hit or depth < 0.5: # 方案三:小穿透忽略 return # 位置修正:各推一半 a.rect.move_ip(-normal.x * depth * 0.5, -normal.y * depth * 0.5) b.rect.move_ip( normal.x * depth * 0.5, normal.y * depth * 0.5) # 速度沿法线交换一部分 vn_a = a.vel.dot(normal) vn_b = b.vel.dot(normal) if vn_a - vn_b > 0: # 还在互相接近 avg = (vn_a + vn_b) * 0.5 * RESTITUTION a.vel -= normal * (vn_a - avg) b.vel += normal * (vn_b - avg) a.wake() b.wake()

5.2 高速小鸟穿过薄木板

前面提过,这是子步(substeps)解决的问题。但还有几个补充点。

薄物体的最小厚度建议。我把所有方块的厚度下限设在 12 像素。小于这个值,即使有子步,穿透概率也会明显上升。如果你非要做一个 6 像素厚的玻璃片,那就得把SUBSTEPS提到 8,性能代价是翻倍。

速度上限。我在Bird.update里加了一行钳制:self.vel = self.vel.clamp_magnitude(2000)。原因是如果玩家在某个极端角度拉到极限,再加上重力加速,速度可能冲到 3000 以上,这时候 8 个子步都不一定够。钳到 2000 之后,最高帧位移是2000 / 60 / 4 ≈ 8.3像素,比最薄方块还小,就安全了。

碰撞检测的顺序。这一点容易被忽略:同一帧内应该先检测"移动最快的物体",再检测其他。因为快速物体可能一帧内与多个物体碰撞,先处理它能让结果更符合直觉。我实现的时候偷懒了,直接按列表顺序遍历,好在游戏里同时高速移动的物体通常只有一只鸟,所以问题不大。

5.3 报错速查表

我把调试过程中遇到的所有报错整理成一张表,方便你对号入座。

报错信息真实原因解决方式
ModuleNotFoundError: No module named 'pygame'pip 和 python 不是同一个解释器用python -m pip install pygame,并在 IDE 里选对解释器
ZeroDivisionError: float division by zero圆心落在矩形内部时delta / dist加dist == 0分支,用最近边推出
TypeError: unsupported operand type(s) for -: 'tuple' and 'Vector2'用了元组存位置统一用pygame.Vector2,不要混用 tuple
游戏在 144Hz 显示器上速度翻倍物理积分没用dt全部改成vel += a * dt、pos += vel * dt
小鸟穿模飞过木柱单帧位移大于物体厚度加SUBSTEPS子步,或钳制速度上限
关卡无法结束小鸟永远停在微小速度上加静止判定,速度足够小就置零并切rest
方块堆叠持续抖动位置修正与重力互相拉扯加睡眠机制 + 忽略小于 0.5 像素的穿透
打包成 exe 后闪退资源路径用了相对路径用sys._MEIPASS或os.path.dirname(__file__)拼绝对路径
画面撕裂、闪烁没开双缓冲或用pygame.display.update()用pygame.display.flip(),set_mode保持默认双缓冲

最后一行那个打包问题值得单独说。用 PyInstaller 打包后,assets/目录不会自动跟着走,而且运行时的工作目录会变。标准做法是在--add-data里声明资源,然后在代码里用这个函数解析路径:

import sys, os def resource_path(rel): base = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) return os.path.join(base, rel)

提示:顺带说一句,热词里出现的 "python flet 打包 apk" 这条路走不通——flet 是 Flutter 风格的 UI 框架,它没法承载 pygame 的 Surface 渲染。你要把 pygame 项目弄到安卓上,正路是 Kivy + Buildozer,或者干脆用pygbag编译成 WebAssembly 跑在浏览器里。后者我试过,改动量比想象中小,因为大部分 pygame 的 2D 绘制接口都支持。

6. 手感调优与后续扩展方向

6.1 让打击感变"爽"的几个参数

代码能跑通只是及格线,能不能让人玩得下去,九成看打击感。我把调过的地方列一下,这些都是参数层面改一下就能见效的。

第一,撞击后的顿帧。当小鸟以超过 800 像素每秒的速度撞到东西时,让整个游戏暂停 0.05 秒。这短短 50 毫秒会让撞击感觉"重"了很多,这是动作游戏里的经典技巧。实现上就是在主循环里加一个hit_stop计时器,大于零时不更新物理。

if hit_stop > 0: hit_stop -= dt dt = 0 # 物理冻结,但依然渲染

第二,方块碎裂的粒子效果。不需要贴图,用pygame.draw.rect画 8 到 12 个小方块,给它们随机初速度,受重力下落,1 秒内淡出。这一块的代码量不到 30 行,但视觉回报特别高。

第三,镜头震动。撞击时给相机加一个随机偏移,camera_offset = Vector2(random.uniform(-4, 4), random.uniform(-4, 4)),然后在 0.15 秒内衰减到零。

第四,音效的音高随机化。同一个音效连续播放会显得很假。可以给每次播放加 ±5% 的音高抖动,人耳几乎察觉不到差异,但整体听感会自然很多。

这几个效果里,顿帧的性价比最高,强烈建议先做这个。

6.2 可以往上叠的玩法

基础版跑通之后,能加的东西其实很多,我按"改动量从小到大"排一下。

加更多鸟的类型,改动最小。黄鸟(撞一下加速)、黑鸟(点击爆炸)、蓝鸟(点击分裂成三只)。实现上就是在Bird类里加一个ability字段,在事件处理里判断点击时触发对应逻辑。黑鸟的爆炸就是遍历范围内所有物体,按距离衰减施加一个冲量。

加关卡编辑器,中等改动量。按E进入编辑模式,鼠标点一下放一个方块,滚轮切换材质,按S把当前布局序列化成 Python 列表打印到控制台。有了这个工具,你造关卡的速度会快十倍,这也是我强烈建议做的一个工具。

加关卡进度存档,小改动量。用json模块把"已通关到第几关""每关最高分"写到一个文件里。

换一套真正的物理引擎,大改动量。如果你的目标是做商业级别的效果,那还是把物理部分换成pymunk。你前面手写的那套碰撞经验不会白费——你会立刻看懂 pymunk 的shape、body、space.step()分别在干什么,也知道body.sleeping为什么存在。

联机对战,超大改动量。这个我不建议作为第一个扩展,因为网络同步和物理确定性是另一个深坑,够单独写一篇文章了。

我自己的实际体会是:先把基础版打磨到"自己愿意玩十分钟",再考虑加功能。我第一版加了十种鸟,结果每种都手感很差,玩了两次就不想打开了。第二版砍到只有一种鸟,但把弹弓、碰撞、顿帧这三件事做到位,反而有朋友主动问我要去玩。

现在这个版本我大概改了两周,代码总量在 700 行左右,其中物理和碰撞占了 250 行。如果你也想动手做,我的建议是先把 3.1 节的物理积分和 4.2 节的弹弓交互吃透,这两块搞明白了,剩下的都是体力活。等你调出第一次"啪"的一声把猪打飞的那种感觉,就会明白为什么这个看起来幼稚的项目,值得花时间从头写一遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询