
简介本资源是基于Python实现的经典物理弹射类游戏《愤怒的小鸟》完整开源项目面向具备基础Python编程能力的学习者用于深入理解游戏开发中的物理模拟、碰撞检测、图形渲染与面向对象设计等核心实践。压缩包共77个文件包含39张PNG游戏素材角色、场景、UI元素、12个.zbak备份源码、9个WAV音效文件如发射、通关、失败音效、5个JPG/JPEG背景图及4个核心Python模块main.py、characters.py、polygon.py、level.py辅以README.md和流程图等说明文档总大小26.38MB。已有34人下载学习项目结构清晰、注释详尽涵盖pygame坐标系应用、slingshot弹射逻辑、重力与刚体碰撞建模、关卡状态管理等关键实现细节可直接运行调试并支持二次开发与功能拓展。 我去年花了两周时间用Python把《愤怒的小鸟》的核心玩法从零搓了出来。不是拿现成引擎套模板而是从物理模拟、碰撞检测到弹弓交互一点点写最终源码可以正常跑完整个关卡流程也能存档、计分、切换关卡。这篇文章就把这套源码实现的完整思路、关键代码和调试过程拆开讲清楚。如果你正想用Python写一个2D小游戏或者对游戏中的物理模拟和碰撞检测感兴趣这篇内容应该能帮你少走不少弯路。先说明一下这套源码完全是个人项目没有依赖Pygame之外的重型库所以你在自己电脑上安装Python和Pygame就能跑起来。后面的内容会从核心玩法拆解一直讲到对象池优化顺序基本就是我当时实现的顺序很多地方带着血泪教训希望对你有用。1. 从弹弓到抛物线先把核心玩法拆清楚1.1 所谓愤怒的小鸟本质上是个二维弹道游戏愤怒的小鸟看起来花里胡哨拆掉美术和音效之后核心就是一个二维弹道游戏你拖住小鸟松手给它一个初速度小鸟被发射出去在重力作用下沿抛物线飞行撞击由木块、石头、冰块搭起来的建筑把建筑撞塌、把猪砸到就算赢。这句话听起来简单但真正实现时会发现要做出手感并不容易。所有玩家能感知到的反馈都来自物理模拟和碰撞检测的精度。早期的《愤怒的小鸟》之所以火爆就是因为它把抛射碰撞破坏这套手感做得极其细腻。我们用Python复刻不可能达到原版那种像素级物理的精细度但至少要让抛物线的轨迹看着自然碰撞命中判定不出错。这里用一个生活化类比你站在篮球场上投篮篮球离手后只受重力和空气阻力影响轨迹是一条抛物线的变形。游戏里的鸟也一样只是它离手后可能还会撞到各种障碍物撞到以后速度方向和大小都会发生变化。理解了这一点就知道下一步该做什么先计算鸟在每一帧的位置再检测它是否碰到障碍物碰到后更新速度。1.2 技术选型为什么用Pygame而不是Unity或Godot最开始我其实犹豫过要不要用Unity毕竟现成的物理引擎Box2D、PhysX能省很多事。但想到这是一个Python项目目标是源码可读、可二次修改、用来学习物理模拟原理那Pygame就是更合适的选择。Pygame本身不提供物理引擎只提供窗口渲染、事件处理、图片加载这些基础能力所以重力、碰撞、反弹全都得自己写。这听起来麻烦但恰恰是它最大的好处你被迫理解游戏物理的实现细节而不是拖一个Rigidbody组件就完事。做个简单对比方案物理引擎上手难度源码可读性适用场景Unity C#自带Box2D/PhysX中等低被引擎封装商业游戏、3D项目Godot GDScript自带PhysicsBody中等中等独立2D/3D游戏Pygame Python自己实现低极高学习原理、快速原型、毕设项目Arcade/Pymunk集成Chipmunk低中等需要物理但不求源码学习如果你只是想做一款完整的游戏、不在乎底层原理Pymunk等库会更省力。但我是冲着源码实现来的所以选择纯手工物理。事实证明这个选择让后面所有调试都变得更加可控。1.3 源码目录结构一个自底向上的模块划分写这种项目最忌把所有代码塞进一个main.py。两三天后你自己都会找不到哪里改重力参数。我最终采用的目录结构很简单但对单人项目来说足够清晰angry-birds/ ├── main.py # 程序入口只负责启动和主循环 ├── settings.py # 全局配置项屏幕尺寸、重力、帧率等 ├── scene/ │ ├── __init__.py │ ├── menu_scene.py # 菜单场景 │ ├── game_scene.py # 游戏主场景处理输入和更新 │ └── level_scene.py # 关卡加载和结算 ├── entities/ │ ├── __init__.py │ ├── bird.py # 小鸟实体包含飞行状态 │ ├── pig.py # 猪实体被撞到会扣除生命 │ └── block.py # 木块/石头/冰块可被推动和摧毁 ├── physics/ │ ├── __init__.py │ ├── vector.py # 二维向量运算 │ ├── collision.py # 圆形/矩形碰撞检测 │ └── world.py # 游戏世界维护所有对象和更新顺序 ├── ui/ │ ├── __init__.py │ ├── hud.py # 计分、剩余小鸟数量显示 │ └── button.py # 菜单按钮 ├── data/ │ ├── levels/ │ │ ├── level1.json │ │ └── level2.json │ ├── images/ │ └── sounds/ └── utils/ ├── __init__.py └── assets.py # 资源加载缓存避免重复读文件我为什么要把物理模块单独拆出来因为小鸟和木块都要用同一套运动规律如果每个实体各写各的最后改重力参数时要改好几个文件坑死自己。统一用physics.world来管理所有实体的更新顺序会让整个循环非常清晰每帧调用一次world.update(dt)内部遍历所有实体依次调用它们的apply_force和update。2. 物理模拟与碰撞检测这俩是决定手感的关键2.1 用欧拉法做运动积分代码就这几行物理模拟最基础的方法是欧拉法也叫前向欧拉积分。它的思想很简单把时间切成很多小片段每个片段里假设速度和加速度恒定不变然后累加位置和速度。在2D平面里小鸟的受力情况是重力向下初速度由弹弓松手时决定。位置更新公式为vx ax * dt vy ay * dt x vx * dt y vy * dt这里ax, ay是加速度水平方向通常为0先忽略空气阻力垂直方向就是重力加速度g。代码如下class Bird: def __init__(self, x, y, vx, vy): self.x x self.y y self.vx vx self.vy vy self.radius 15 self.alive True def update(self, dt, gravity): self.vy gravity * dt self.x self.vx * dt self.y self.vy * dt如果你测试这段代码会发现轨迹非常接近真实抛物线但会有一个问题如果dt太大也就是每一帧间隔时间太短导致更新次数太少那么小鸟在高速运动时可能一帧跨过好几个像素碰撞检测就会出现穿透。解决方式是固定时间步长这个问题在第4章会展开。这里要特别说明欧拉法在长时间模拟下会有能量误差导致抛物线高度比理论值略低一点。对于游戏来说这种误差肉眼几乎看不出来而且容易调所以我直接用欧拉法。如果你希望更精确可以换用Verlet积分但代码复杂度会高不少性价比并不高。2.2 圆形碰撞与矩形碰撞的实际取舍愤怒的小鸟里的鸟、猪、木块形状其实都不规则但如果完全按像素级碰撞来检测每一帧处理几百个对象的像素掩码性能会非常难看。实际开发中我采用的方案是所有对象都有逻辑碰撞体小鸟和猪用圆形木块用矩形。圆形和矩形的碰撞检测公式网上很多最好用的是圆心到矩形最近点距离小于半径则碰撞。核心代码def circle_rect_collision(cx, cy, cr, rect): # 找到矩形上离圆心最近的点 closest_x max(rect.left, min(cx, rect.right)) closest_y max(rect.top, min(cy, rect.bottom)) dx cx - closest_x dy cy - closest_y return dx * dx dy * dy cr * cr这里rect是Pygame的Rect对象left/right/top/bottom分别代表矩形的四边坐标。这个检测方法比想象中快很多因为只需要几次比较和一次平方运算。圆形之间碰撞就更简单了两个圆心距离的平方小于半径和平方即碰撞。def circle_circle_collision(b1, b2): dx b1.x - b2.x dy b1.y - b2.y dist_sq dx * dx dy * dy r_sum b1.radius b2.radius return dist_sq r_sum * r_sum为什么不直接用矩形作为小鸟的碰撞体因为旋转后矩形碰撞检测会很麻烦而圆形没有方向性问题。木块我一般默认不旋转直接用矩形就能满足大部分场景。这里引出一个经验物理碰撞的形状不需要和美术视觉完全一致但要保证视觉上看起来撞到了和逻辑上判定撞到了相差不大。2.3 亲身踩坑为什么小鸟穿透了木块却判定碰撞成功这块必须拿出来单独说因为我在测试时遇到过非常诡异的情况小鸟明明飞到了木块上方屏幕上都看不出接触但方块已经碎了。一开始我还以为是自己碰撞算法写错了白白排查了三天。后来偶然发现问题出在pygame.Rect的坐标体系和碰撞半径的设置不一致。我的小鸟贴图是64x64像素但背景透明实际鸟的身体只占中间圆形部分半径大概20像素左右。我设置bird.radius 20木块矩形也按贴图尺寸直接取整看起来没问题。但我的图片素材中小鸟绘制的中心点并没有对齐图片中心偏右下方了大约8个像素这个偏差平时看不出但在高速运动下就会让视觉位置和物理位置明显分离。最后解决方案很简单所有实体在加载图片时统一把原点设置到图片几何中心并且用调试模式绘制碰撞体。我写了一个DEBUG_DRAW开关打开后会在每个对象周围画一个绿色圆圈或矩形。持续开着几局就能发现偏移。if settings.DEBUG_DRAW: pygame.draw.circle(screen, (0, 255, 0), (int(camera_x), int(camera_y)), int(obj.radius), 2)所以如果你也在做相似项目记得第一件事就是开启碰撞体可视化不要靠肉眼判断。视觉上贴着边不等于物理上碰撞了这个误差在高速物体上会被放大得非常明显。3. 弹弓交互与视觉跟随把手感做出来才叫游戏3.1 拖拽发射的状态机蓄力、瞄准、释放核心玩法是拖拽弹弓把鸟射出去。这需要一个状态机来管理当前时刻玩家的操作是否有效。我定义了三态READY小鸟待在弹弓上等待鼠标按下。DRAGGING鼠标已经按下并拖拽计算拉力方向和大小。RELEASED鼠标松开小鸟按初速度飞出去之后不再响应鼠标拖拽。代码结构可以这样组织class Slingshot: def __init__(self, pos): self.pos pos self.dragging False self.drag_offset (0, 0) self.max_power 800 # 最大初速度像素/秒 self.state READY def handle_event(self, event): if event.type pygame.MOUSEBUTTONDOWN: if self.state READY and self.pos.distance_to(event.pos) 40: self.dragging True self.state DRAGGING elif event.type pygame.MOUSEMOTION: if self.dragging: dx event.pos[0] - self.pos[0] dy event.pos[1] - self.pos[1] # 限制最大拖拽距离防止飞出去 dist math.hypot(dx, dy) max_dist 60 if dist max_dist: dx dx / dist * max_dist dy dy / dist * max_dist self.drag_offset (dx, dy) elif event.type pygame.MOUSEBUTTONUP: if self.dragging: self.release() def release(self): dx, dy self.drag_offset # 注意松手时速度方向是拖拽方向的反方向 self.bird.vx -dx * 10 self.bird.vy -dy * 10 self.state RELEASED注意这里有一个物理直觉弹弓向后拉松手后物体向前飞所以速度方向是拖拽偏移的反方向。我记得第一次实现时反了鸟朝后射测试了半小时才意识到。这个太容易犯大家留意一下。蓄力大小和拖拽距离的比例也是一个手感参数。我设成初速度 拖拽距离 * 系数然后限制最大拖拽距离这样玩家不会一拉就飞出屏幕外。max_power我设成800太大会导致游戏难度下降太小则飞不过第一道墙。这个参数需要反复试建议放到settings.py里方便改。3.2 摄像机跟随与视差背景场景大于屏幕时怎么处理游戏场景通常比窗口大尤其是关卡中需要放很多木块和猪。要显示超出屏幕的部分就需要摄像机偏移。实现思路很简单所有绘制对象时把世界坐标减去摄像机偏移量得到屏幕坐标。class Camera: def __init__(self, viewport_width, viewport_height): self.offset_x 0 self.offset_y 0 self.viewport_w viewport_width self.viewport_h viewport_height def follow(self, target_x, target_y, world_width, world_height): # 让目标处于屏幕中心附近 desired_x target_x - self.viewport_w // 2 desired_y target_y - self.viewport_h // 2 desired_x max(0, min(desired_x, world_width - self.viewport_w)) desired_y max(0, min(desired_y, world_height - self.viewport_h)) self.offset_x desired_x self.offset_y desired_y def apply(self, world_x, world_y): return world_x - self.offset_x, world_y - self.offset_y这样做的效果是小鸟飞行时镜头会跟着移动玩家始终能看到鸟。如果不加边界限制镜头会滑出关卡边界背景出现黑色空隙很难看。视差背景是在摄像机基础上加一个缩放系数形成的虚拟深度效果。比如天空背景移动速度只有主场景的0.2倍山丘是0.5倍前景是1倍。绘制时用不同的偏移量def draw_background(screen, camera, bg_image, factor): bg_x -camera.offset_x * factor screen.blit(bg_image, (bg_x, 0))这个实现很便宜但视觉提升非常明显。我建议就算用简单的色块渐变也一定要加分层的视差滚动否则会感觉场景很死。3.3 坐标转换翻车记录鼠标点不到目标物的真实原因这个坑困扰了我一个下午。现象是当我拖拽小鸟后鼠标箭头明明就在小鸟旁边但松手后发射角度总是偏得离谱。我一度以为是事件处理的问题后来才发现是鼠标坐标和世界坐标混用了。Pygame的mouse.get_pos()返回的是窗口屏幕坐标而我的弹弓位置是存储在世界坐标里的。当摄像机偏移不为零时屏幕坐标到世界坐标需要加上偏移量mouse_world_x mouse_screen_x camera.offset_x mouse_world_y mouse_screen_y camera.offset_y这个转换如果不做就会出现在关卡左侧时玩家实际要向后拉但程序认为鼠标在右侧发射方向完全错误。发现后我在Slingshot.handle_event里统一把所有鼠标坐标都转换成世界坐标再处理问题立刻消失。所以做这类游戏时一定要区分两个坐标系。输入事件拿到的坐标永远是屏幕坐标所有和游戏物体位置比较的逻辑都应该先转换到世界坐标。我甚至在代码里刻意用screen_to_world函数封装转换避免后续再来回改。4. 帧率、资源与对象池源码能跑是一回事跑得顺是另一回事4.1 用固定时间步长稳定物理表现而不是让物理跟随FPS漂Pygame的主循环如果直接用clock.tick(60)控制帧率然后用每帧的真实时间dt去更新物理在性能波动时会出现物理变化不一致的问题。帧率高的机器上小鸟飞得远帧率低的机器上飞得近这显然不合理。正确的做法是使用固定时间步长累加器。维护一个accumulator每帧累加真实经过的时间只有当累加值超过固定步长时才更新一次物理。这样无论帧率是30还是120物理更新的频率都保持一致。FIXED_DT 1 / 120 # 物理更新频率120Hz accumulator 0.0 clock pygame.time.Clock() while running: frame_time clock.tick(60) / 1000.0 # 秒 accumulator frame_time while accumulator FIXED_DT: world.update(FIXED_DT) accumulator - FIXED_DT这里我把物理更新频率设成了120Hz而不是60Hz。原因是60Hz下高速物体每帧移动距离大碰撞检测容易发生穿透。提升到120Hz后碰撞检测的漏判率大幅下降性能开销也完全可以接受。4.2 减少Surface创建绘制原语与图片缓存的取舍Pygame中每创建一个Surface都是开销尤其是在主循环里创建临时Surface会导致明显的卡顿。我最初写代码时为了让小鸟有拖影效果每帧都创建半透明Surface结果帧率直接降到30以下。优化思路很简单把不变的资源提前加载并缓存不要在循环里创建。class AssetManager: def __init__(self): self._cache {} def load_image(self, path): if path not in self._cache: self._cache[path] pygame.image.load(path).convert_alpha() return self._cache[path]另外Pygame的convert_alpha()非常好用它会把图片转换成更适合当前显示模式的内存格式绘制速度会快不少。如果你在某个界面发现图片加载变慢大概率是没做转换。还有一个小技巧在很多游戏中半透明效果可以通过预先生成的Surface反复blit而不是每帧新建。比如弹弓的橡皮筋线条可以直接用pygame.draw.line绘制不需要额外Surface。我写的时候尽量用基本绘制原语减少Alpha混合的Surface数量。4.3 对象池在弹丸和碎块中的应用当木块被小鸟撞碎时会产生很多小碎块和粒子效果。如果每次碰撞都创建新的对象之后又马上销毁Python的垃圾回收会被频繁触发造成偶发卡顿。一个小型项目可能不明显但如果你把碎块数量调高到50个以上就会感觉到帧率抖动。我的做法是实现一个简单的对象池class ObjectPool: def __init__(self, obj_class, initial_size20): self.obj_class obj_class self.pool [obj_class() for _ in range(initial_size)] self.active [] def spawn(self, *args): if self.pool: obj self.pool.pop() else: obj self.obj_class() obj.reset(*args) self.active.append(obj) return obj def recycle_all(self): self.pool.extend(self.active) self.active.clear()每次关卡开始时调用recycle_all()把上一关所有对象回收。这种方式虽然简单但能大幅降低频繁创建对象的开销。对于小鸟本体我没有用对象池因为一次最多几只但碎块和特效非常适合。5. 关卡、计分与音效补齐游戏体验的最后一公里5.1 用JSON定义关卡不用每个关卡写死代码如果每个关卡都在代码里硬编码木块坐标关卡设计会变成一场灾难。我用JSON文件定义关卡数据程序启动时加载对应文件。{ name: Level 1, slingshot_pos: [120, 480], birds: [ {type: red, pos: [100, 470]}, {type: yellow, pos: [150, 470]} ], blocks: [ {type: wood, x: 700, y: 420, angle: 0}, {type: wood, x: 720, y: 360, angle: 90}, {type: stone, x: 680, y: 300, angle: 0} ], pigs: [ {type: small, x: 710, y: 450} ] }解析时我写了一个LevelLoader把JSON里的type映射到实体类。这样关卡策划哪怕是自己只需要改JSON不需要碰代码。如果你后续想加新关卡直接复制一个JSON改坐标就行。有一点要注意JSON里没有注释层级较深时容易看花眼。我会在命名上用slingshot_pos这种清晰字段并在settings.py里留下每个字段的说明注释。5.2 计分规则与界面刷新以及UI和场景生命周期划分计分规则我设计得很简单砸中一只猪得500分撞碎一个木块得100分石块200分小鸟停留在场上且关卡通关时有200分奖励。这个规则影响了结算界面和HUD的显示。HUD使用独立于游戏世界的绘制函数在每帧最后绘制def draw_hud(screen, score, birds_left): font pygame.font.SysFont(Arial, 24) score_text font.render(fSCORE: {score}, True, (255, 255, 255)) birds_text font.render(fBIRDS: {birds_left}, True, (255, 255, 255)) screen.blit(score_text, (20, 20)) screen.blit(birds_text, (20, 60))一开始我把HUD绘制放在世界绘制之前结果背景图片把文字盖住了。后来调整了绘制顺序先世界、再UI、最后闪屏特效。这是一个很小的坑但如果你没注意顺序UI永远显示不出来。另外要严格区分游戏世界对象和场景管理对象。游戏世界中不应该有menu_scene这种东西的存在它只负责物理和实体。UI场景、菜单场景、结算场景是游戏状态机它们控制世界是否更新。比如在结算弹出时世界应该暂停更新否则小鸟还在继续飞用户没法看结果。我实现了一个简单的GameState枚举class GameState(Enum): MENU 1 PLAYING 2 LEVEL_COMPLETE 3 GAME_OVER 4主循环里根据状态决定是否调用world.update()。5.3 音效资源处理和缺失时的防御代码音效是让游戏有感觉的重要环节但资源路径经常缺失处理不好程序直接崩溃。我在AssetManager里加了一个防御性补充如果音效文件不存在仍返回一个空对象但记录日志不影响主循环运行。def load_sound(self, path): if path not in self._sound_cache: try: self._sound_cache[path] pygame.mixer.Sound(path) except pygame.error: self._sound_cache[path] None print(fWarning: sound not loaded {path}) return self._sound_cache[path]后面在播放时先判断是否为空snd assets.load_sound(data/sounds/launch.wav) if snd: snd.play()这样即使你换了一台机器没有声音文件游戏也不会崩只会静音。这个做法强烈建议复制因为网上不少源码项目一换路径就闪退体验很差。6. 从运行到二次开发环境准备与扩展建议6.1 环境准备Python版本、依赖安装与虚拟环境运行这套源码对Python版本要求不高3.8以上都可以我最近在Python 3.11下跑也没有问题。Pygame推荐安装2.x版本不要装老旧的1.9.x。建议为项目创建虚拟环境避免污染全局Python环境python -m venv venv source venv/bin/activate # Windows 下为 venv\Scripts\activate pip install pygame如果你用的是Anaconda也可以conda create -n angry-birds python3.10 conda activate angry-birds pip install pygame安装完成后运行python main.py即可启动。如果窗口没有出现可能是环境变量或者Pygame初始化问题可以检查main.py里是否调用了pygame.init()和pygame.display.set_mode()。6.2 高频启动报错排查模块找不到、窗口闪退、图片路径问题我整理了几个自己遇到过的启动问题方便你排查。症状可能原因解决方案ModuleNotFoundError: No module named pygame没有安装Pygame或安装在别的Python环境pip install pygame并检查当前虚拟环境窗口闪退没有报错图片路径错误但在try/except中被吞掉检查AssetManager.load_image的路径缺失时打印完整路径窗口黑屏主循环中没有screen.fill()或没有pygame.display.flip()在绘制前填充背景色绘制后调用flip()运行后CPU占用高主循环没有限制帧率调用clock.tick(60)游戏物理速度在高低配机器上不一致没有用固定时间步长参考4.1节的累加器写法其中最烦的是图片路径问题。我建议所有资源路径都基于项目根目录的相对路径不要使用绝对路径。我一开始在Windows上写死C:/Users/...换到Mac上就崩了。后来改成BASE_DIR Path(__file__).resolve().parent.parent ASSETS_DIR BASE_DIR / data / images这样就完全跨平台了。6.3 想让源码变成真正好玩的游戏可以从这些参数下手源码能跑之后你可能会觉得手感还是不够好。这时候不要急着改结构先去调参数。重点看settings.py中的这几个值GRAVITY默认我设为980单位是像素/秒^2。调高会让鸟飞得更坠手调低则更像太空漫步。SLINGSHOT_POWER默认800影响最大初速度。调大会让鸟更容易飞过整个关卡调小则更考验角度。RESTITUTION碰撞后的反弹系数我设在0.3到0.6之间。调太高物体会像弹力球一样弹来弹去太低则会显得很生硬。FIXED_DT物理更新的时间步长。如果发现高速物体穿透可以把FIXED_DT调小但代价是CPU占用上升。如果你想让游戏更多样化可以从这几个方向扩展多只不同鸟红鸟直线、黄鸟加速、炸弹鸟爆炸、猪的AI躲避、风力系统、甚至联机对战。这些扩展都不需要推翻现有框架只需在实体基类上增加新的行为方法即可。我个人在实际操作中的体会是这种从零手写物理的项目最值钱的不是最终能玩的Demo而是调试过程中对各种坐标、帧率、碰撞概念的理解。尤其当你把物理步长、对象池、资源缓存这些细节逐一解决后你会发现很多看似复杂的游戏机制底层其实就是这些基础模块的组合。如果你也想用Python写一个小游戏千万不要急着找现成引擎像我这样一步步从弹弓写到碰撞做完之后对游戏开发的理解会完全不同。本文还有配套的精品资源点击获取