ARTICLE · 1335682
【附源码】用 Python 复刻经典游戏《愤怒的小鸟》
别死磕枯燥练习题了!!!
做小游戏,才是学 Python 最划算的练手方式。
循环、状态、物理、碰撞、输入…… 编程最核心的知识点,一个项目全部练到。做完直接能玩,成就感拉满,正反馈超快~
今天这个《愤怒的小鸟》,不依赖任何图片和音频素材。

所有元素,全部用pygame.draw画出来。
你复制粘贴到一个.py文件里,装个 pygame 就能玩。(完整代码文末领!)
一、先看它长什么样
窗口 1000×570,左边是弹弓,右边是小猪的木头城堡:三只小猪,一块横梁、两根立柱
二、准备环境
pip install pygame
装不上、或者在新版本 Python 上报编译错误,就换成社区版:
pip install pygame-ce
两者的 import pygame 写法完全一样,本文代码通用。
字体说明: 中文文字使用系统字体渲染,程序会尝试匹配设备上已安装的中文字体。若出现中文乱码或方框,可安装相应的中文字体。
【这一步搞不定,问AI】
三、第一步:窗口和背景
几乎所有 pygame 项目的前 20 行都是同一套:
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("愤怒的小鸟 · pygame 版")
clock = pygame.time.Clock()
然后是背景。这里有个新手常踩的坑:把渐变天空写在主循环里。
# 反例:每帧画 570 条线
for y in range(HEIGHT):
pygame.draw.line(screen, color, (0, y), (WIDTH, y))
570 条线本身不算慢,但它每帧都在做完全重复的事。正确做法是先把静态背景画到一张 Surface 上,之后每帧只 blit 一次:
def make_bg(self):
bg = pygame.Surface((WIDTH, HEIGHT))
for y in range(HEIGHT): # 天空渐变
...
return bg
同理,云、远山、草地也一次性画好。这个「预渲染」(pre-render)思路能救很多项目:凡是不变的东西,都别每帧重画。
四、第二步:小鸟为什么会飞
不需要物理引擎,两行就够:
self.vel.y += GRAVITY # 速度被重力改变
self.pos += self.vel # 位置被速度改变
这就是最朴素的欧拉积分:每一帧给纵向速度加一点加速度,再把速度加到位置上。
GRAVITY = 0.45 的单位是「像素 / 帧²」,在 60 帧下相当于每秒加速 27 像素/帧——一开始不明显,飞出去 20 帧之后就明显往下掉了,正好是抛物线的手感。想要「月球重力」就调小,想要「铅球」就调大,其他代码一行都不用改。
配套的还有两件事:
一是边界。撞地要弹一下,但不能无限弹。所以落地时把纵向速度乘 -0.42(反向 + 衰减),横向速度乘 0.72(摩擦),抖两下就趴下了。
二是判断「这一箭结束了没有」。这里我用了一个计数器,而不是「落地就算结束」:
# 半秒都没怎么动,就认为这一箭结束了
if self.vel.length() < 0.9:
self.still += 1
else:
self.still = 0
if self.still >= 30:
self.settled = True
为什么不用「落地」判断?因为小鸟可能正好停在横梁上,或者卡在两根柱子中间,用落地判断会卡死在那儿,这一局再也进行不下去。用「连续 30 帧几乎没动」来判定,不管它停在哪,都能正常进入下一只鸟。
五、第三步:弹弓的手感
拖拽的本质是一个向量减法。鼠标在哪、皮兜在哪,两者一减就是拉伸向量:
def pull_vector(self):
"""皮兜 → 拉伸点,长度限制在 MAX_PULL 之内。"""
v = SLING - self.drag_pos
if v.length() > MAX_PULL:
v.scale_to_length(MAX_PULL)
return v
scale_to_length() 是 pygame.Vector2 自带的限位方法,比手写 math.hypot 再乘系数干净得多。限位很重要:不限制的话,鼠标拖到屏幕外就能拉出天文数字的初速度,一发射小鸟直接飞出银河系。
松手时,这个向量乘一个系数,就成了初速度:
self.vel = pull * POWER
POWER = 0.15,拉满 110 像素对应初速 16.5 像素/帧(约 990 像素/秒),45° 出手大约能飞 700 像素——刚好够打到右边城堡。这个系数就是「弹弓的力道」,想让它更暴力就调大。
预测弹道是这套代码里我最喜欢的一段:把发射后要做的事,用另一份变量先算一遍。
因为位移逻辑只有两行(加速度改变速度,速度改变位置),所以可以直接在一个循环里空跑,每 4 帧落一个白点,不落地不停。同一个物理,算两遍——一遍给真正的小鸟,一遍给瞄准线。做瞄准辅助、抛物线预览、AI 预判,思路都是这个。
六、第四步:碰撞检测(核心就 10 行)
这是全文唯一需要动点脑子的地方。
小鸟是圆,木箱是矩形。圆和矩形怎么判碰撞?把圆心「夹」到矩形上,得到矩形上离圆心最近的点,再看这个点到圆心的距离是否小于半径:
cx = max(rect.left, min(pos.x, rect.right))
cy = max(rect.top, min(pos.y, rect.bottom))
max(左边界, min(圆心, 右边界)) 这个写法,等于「如果圆心在矩形左边就取左边界,在右边就取右边界,在中间就取圆心自己」。两次 clamp,一个最近点就到手了。
有了最近点,就有法线方向,也就能做反弹:
vn = vel.x * nx + vel.y * ny
if vn < 0: # 只有朝里冲才反弹
vel.x -= (1 + bounce) * vn * nx
vel.y -= (1 + bounce) * vn * ny
vn 是速度在法线上的投影。如果它是正的,说明物体正在离开,这时候还去反弹就会把它又推回去,出现抽搐——所以只处理 vn < 0。这一行判断能让碰撞从「一碰就抖」变成「一碰就弹」。
那为什么不用 pygame 自带的 sprite.collide_rect?因为它只告诉你「碰了没有」,不告诉你从哪个方向碰的。没有法线,就没有反弹,小鸟只会黏在木箱上。
七、第五步:关卡与状态机
整局游戏只有四个状态:
self.state = "aim" # 瞄准 / 飞行 / 赢了 / 输了
aim:可以拖拽,显示弹弓皮筋和瞄准线;fly:小鸟在飞,每帧跑物理 + 碰撞;win/over:画面压一层半透明黑,显示结算文字,等着按R。
用状态机而不是一堆 if 堆在一起,好处是每一帧该做什么只有一个入口:update() 开头一句 if self.state != "fly": return,飞行之外的所有逻辑就都被挡在门外了,不用担心「结算了还在跑物理」。
关卡本身也只是几行数据:
self.pigs = [
Pig(760, GROUND_Y - 22), # 城堡里
Pig(760, GROUND_Y - 132), # 城堡顶
Pig(880, GROUND_Y - 22), # 右边站岗
]
self.blocks = [
Block(700, GROUND_Y - 110, 16, 110),
Block(800, GROUND_Y - 110, 16, 110),
Block(690, GROUND_Y - 126, 136, 16),
]
想改关卡,改这几行的坐标就行,不用碰任何逻辑。
八、完整代码
不想自己敲代码?可以直接领取完整项目!
关注公众号,后台回复【游戏】即可获取:
① Python 完整源码:复制运行,方便学习和二次修改。
② 一键运行 exe 版:不用装 Python 环境,解压双击直接打开游戏
(🔺点击上方名片关注🔺)












































































