Python+Pygame烟花动画:Particle与Firework拆解
简介这份面向Python初学者与图形编程爱好者的实践文档围绕使用Python结合Pygame制作带有动画与音效的新年烟花效果展开帮助读者在节日主题项目中理解Pygame的基本用法掌握粒子系统、烟花发射与爆炸逻辑以及颜色设置等图形界面编程要点。压缩包内共1个docx文件约16KB篇幅精简便于按环境准备、代码编写与参数调整的顺序阅读。文档从安装Pygame开始提供可运行的完整代码示例并借助Particle粒子类、Firework发射器类和主循环组织实现过程读者可据此搭建自己的烟花展示程序调整背景音乐路径、粒子数量、颜色与发射频率等配置。目前已有143人学习适合希望以小型项目入门Pygame、积累图形动画实践经验的读者参考。1. 一段能跑的烟花动画核心其实只有两个类新年前后总有人问用 Python 加 Pygame 写一个带音效的烟花动画要多久我的答案是拆完两个类就差不多了。这份项目拆开看只有两块硬骨头——管粒子的 Particle 和管升空、爆炸的 Firework其余都是窗口、时钟、事件循环这类基础设施。它适合刚接触图形界面编程、想找个有视觉反馈的练手对象的新手也适合要给节日页面加动态氛围、需要快速改参数复现效果的开发者。下面按「先把窗口立稳、再把粒子调顺、最后处理那些让人翻车的细节」的顺序走一遍代码都能直接抄。2. 窗口骨架Pygame 初始化、事件循环与帧率约束很多人第一次跑 Pygame 代码遇到的不是「效果不好看」而是窗口一闪就没了或者黑屏卡死。这部分不复杂但顺序错了就很难往回找。2.1 装库与最小可运行窗口装完之后先验证解释器别急着写业务代码。python -m pip install --upgrade pygame python -c import pygame; print(pygame.version.ver)用python -m pip而不是直接敲pip是因为机器上常常同时存在多个 Python直接调 pip 很容易装到另一个解释器里然后 import 报错白白浪费半小时。第二行打印版本号能打出来说明当前解释器确实能加载这个库。常见做法是先确认版本再动手血泪经验是这次省下的十秒钟后面要用一个小时还。最小窗口代码如下先跑通它再往上加东西import pygame pygame.init() # 必须早于 set_mode screen pygame.display.set_mode((800, 600)) pygame.display.set_caption(new year fireworks) clock pygame.time.Clock() running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False screen.fill((0, 0, 0)) # 每帧擦一遍黑板 pygame.display.flip() # 双缓冲交换把画好的内容推上屏 clock.tick(60) pygame.quit()pygame.init()要先于set_mode调用否则显示子系统可能没初始化完成。screen.fill((0, 0, 0))每帧把整屏刷黑这是清屏不是背景色设置后面画的所有东西都依赖它把上一帧擦掉。display.flip()做的是双缓冲交换所有绘制先发生在后台缓冲交换后才可见所以你不会看到画一半的画面。clock.tick(60)返回距上一帧的毫秒数同时把帧率压在 60 附近。分辨率(800, 600)是这份代码的原始尺寸。改成(1280, 720)时后文所有用到宽高的地方都不要写死数字改成从screen.get_size()取screen_width, screen_height screen.get_size()这样改分辨率只需要动一行不然发射位置、爆炸判定高度全得跟着改改漏一处烟花就从屏幕外飘走了。2.2 主循环的三件事事件、更新、渲染所有实时动画的主循环都干三件事顺序固定处理事件、更新状态、渲染画面。把烟花列表挂进去之后大概长这样import random fireworks [] MAX_FIREWORKS 12 running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.KEYDOWN and event.key pygame.K_SPACE: fw Firework() # 空格手动点一朵调参时不用干等随机 fw.launch() fireworks.append(fw) screen.fill(BLACK) if random.random() 0.04 and len(fireworks) MAX_FIREWORKS: fw Firework() fw.launch() fireworks.append(fw) for fw in fireworks[:]: # 遍历副本删除原件 if not fw.update(screen): fireworks.remove(fw) pygame.display.flip() clock.tick(60)fireworks[:]是浅拷贝遍历副本、删除原列表里的元素这是最省事也最不容易出事的写法第 5 章会讲为什么不能直接for fw in fireworks。random.random() 0.04表示每帧有 4% 的概率自发一朵烟花按 60 帧算大约一秒两朵多一点比原版的 0.05 略低因为加上MAX_FIREWORKS上限之后堆积感会更明显。加上限这件事很关键。上一次高压测试里我把概率调到 0.2 又没设上限跑了两分钟之后每帧要画上万个小圆帧率从 60 直接掉到个位数动画看起来像卡带。设置MAX_FIREWORKS 12之后同屏最多十二朵视觉上已经足够热闹性能稳得很。2.3 clock.tick(60) 到底约束了什么这一行容易被当成仪式性代码抄上去其实它决定了整个动画的时间基准。Particle.move()里的self.x self.speed * math.cos(self.angle)是固定步长每帧走 speed 个像素不管这一帧实际花了多久。帧率稳定在 60 时没问题一旦机器负载上来掉到 30 帧同样一秒内只走了原来一半的步数整段动画看起来像被放慢了而且越慢越容易堆积对象。要写与帧率无关的版本就用 tick 的返回值做时间缩放dt clock.tick(60) / 1000.0 # 换算成秒 ... self.x self.speed * math.cos(self.angle) * dt * 60 self.y - self.speed * math.sin(self.angle) * dt * 60dt * 60的含义是把按 60 帧设计的速度参数折算成与真实时间相关的量。帧率掉到 30 时dt大约是 0.033乘 60 得到 2相当于每帧走两步视觉速度就对上了。注意clock.tick(60)一帧只能调用一次它既是帧率限制器又是计时器在主循环里多调一次会把你的 dt 算成两段。3. Particle 参数调优极坐标、阻尼与生命周期的实际作用粒子系统的全部手感都在这个类里。位置和角度决定「炸开是什么形状」速度衰减决定「扩散得多远」生命周期决定「残影留多久」三个参数互相拉扯。3.1 极坐标发射与速度分解每颗粒子存的是角度和速率不是 vx/vy好处是发射方向上天然均匀import math import random class Particle: def __init__(self, x, y, color, speed_range(2, 5), life_range(25, 40), angleNone): self.x float(x) self.y float(y) self.color color self.size random.randint(2, 4) self.life random.randint(*life_range) self.max_life self.life self.angle random.uniform(0, 2 * math.pi) if angle is None else angle self.speed random.uniform(*speed_range) def move(self): self.x self.speed * math.cos(self.angle) self.y - self.speed * math.sin(self.angle) # 屏幕 y 轴向下向上要用减 self.life - 1 self.speed * 0.95 def draw(self, screen): if self.life 0: return pygame.draw.circle(screen, self.color, (int(self.x), int(self.y)), self.size)random.uniform(0, 2 * math.pi)让角度在整个圆周均匀分布爆炸才是正圆而不是扇形。y 方向必须用减号因为 Pygame 的坐标原点在左上角y 越大越靠下和数学习惯相反这一点写反了烟花会往地里钻。angleNone这个默认参数是为上升粒子留的口子升空时传一个接近math.pi / 2的角度它就笔直往上走爆炸时传 None随机方向散开。speed_range和life_range参数化之后同一次爆炸里可以混入快粒子和慢粒子边缘会自然形成层次比全部同速好看得多。int(self.x)这一步不能省。浮点坐标传给draw.circle会直接抛类型错误而且 Pygame 的绘制函数对元组和列表也挑剔(int(x), int(y))是最稳的写法。3.2 阻尼和重力不是一回事原版用self.speed * 0.95这叫阻尼速度标量按比例衰减各方向衰减幅度一致视觉上像向外冲出去之后被空气拖住最后停住再消失。它简单、便宜、不会出负数问题适合做那种「一团光炸开然后凝固」的效果。但它不是重力。想要真实礼花那种先向外冲、再整体下坠的尾巴得拆成分量来算# 写法 A纯阻尼原版思路 self.speed * 0.95 # 写法 B分量法阻尼叠加常量重力 self.vx * 0.98 self.vy self.vy * 0.98 0.12 # 每帧给 y 方向加一点速度 self.x self.vx self.y self.vy写法 B 里0.12是每帧的重力增量值越大下坠越明显0.08 到 0.2 之间都算合理0.98是阻尼系数越接近 1 越滑。注意写法 B 的 y 方向不用取负因为vy初值可以直接给负值表示向上物理上更直观初值这样生成speed random.uniform(2, 6) self.vx speed * math.cos(angle) self.vy -speed * math.sin(angle) # 取负才是向上两种写法别混用。我见过把speed * 0.95和vy 0.12塞在一起的版本结果粒子先减速再加速画面上像有一只手在往回拽特别别扭。3.3 参数对照改哪个数字会看到什么调参不需要盲试下面这张表是我反复改下来总结的区间按需要往两头推就行。参数代码位置调大之后调小之后常用区间sizerandom.randint(2, 4)颗粒粗、像火花团细腻但容易看不见2 到 5life_range(25, 40)残影拖得久、同屏粒子多一闪就没、显得干脆20 到 60speed_range(2, 5)炸得开、覆盖半个屏挤成一团光球2 到 7阻尼系数0.95接近 1 时飘得远0.85 以下几乎原地不动0.90 到 0.98burst_countFirework 里密、吃性能稀、省性能60 到 150最容易被忽略的是life和speed的耦合速度调大但寿命不变粒子会在还没飞到边缘的时候就集体消失看起来像爆炸被裁掉了一圈。加大速度时同步把 life 加个十来帧形状才完整。4. Firework 类与爆炸触发从上升点到一百颗粒子的状态切换一朵烟花其实分两个阶段一个白色上升点到达高处后炸成上百颗彩色粒子。这两段共用同一个粒子列表逻辑上必须有个明确的分界。4.1 上升段与爆炸段的显式状态在状态上再加一层exploded标志比靠「第一个粒子的 y 坐标小于某个值」去猜要稳class Firework: PALETTE [ (255, 84, 84), (255, 196, 64), (88, 224, 148), (96, 148, 255), (214, 112, 255), ] def __init__(self): self.particles [] self.exploded False self.color random.choice(self.PALETTE) self.burst_count 100 def launch(self): head Particle( random.randint(80, screen_width - 80), screen_height, (255, 255, 255), speed_range(4, 7), life_range(120, 160), anglerandom.uniform(math.pi / 2 - 0.25, math.pi / 2 0.25), ) self.particles.append(head) def explode(self, x, y): for _ in range(self.burst_count): self.particles.append(Particle(x, y, self.color)) self.exploded True def update(self, screen): alive [] for p in self.particles: p.move() p.draw(screen) if p.life 0: alive.append(p) self.particles alive if not self.exploded and self.particles: head self.particles[0] if head.y 120: head.life 0 self.explode(head.x, head.y) return bool(self.particles)上升粒子的角度取math.pi / 2上下浮动 0.25 弧度基本是垂直向上带一点随机偏斜比正正好好的 90 度自然。发射起点 y 用screen_height也就是屏幕底边x 限制在80到screen_width - 80之间防止在屏幕边缘炸开时有一半粒子直接丢到看不见的地方。head.life 0这一句是手动终结上升点。爆炸后它已经被染成白色并留在列表里如果不主动清掉会在炸开的彩色粒子中间留一个白点看起来像画面上的脏点。update返回bool(self.particles)这让主循环知道什么时候该把这朵烟花从列表里移除。原版返回的是一串带条件的判断容易在「上升点提前耗尽寿命、永远到不了顶部」的情况下卡住烟花对象永远留在列表里不释放内存慢慢上涨。简化成看列表空不空是这个场景下最不容易翻车的写法。4.2 遍历时删除元素的两种正确姿势爆炸瞬间会有一百个粒子追加进列表之后每帧都要清理掉寿命归零的。这里是最容易出隐蔽 bug 的地方。# 错误写法边遍历边删会跳过元素 for p in self.particles: if p.life 0: self.particles.remove(p) # 索引错位紧跟着的下一个元素被跳过 # 正确写法一倒序删除 for i in range(len(self.particles) - 1, -1, -1): if self.particles[i].life 0: del self.particles[i] # 正确写法二重建列表上面 update 里用的就是这种 alive [p for p in self.particles if p.life 0] self.particles alive第一种错误写法的现象很典型粒子明明寿命到了却没有消失屏幕上留下零星几个不动的点缀时间越长越多。原因是remove之后后面所有元素前移一位而循环游标继续往后走正好跳过了一个。列表越长越明显粒子少的时候根本测不出来上线才暴露。重建列表的写法多分配一个列表对象但可读性最好一百个粒子的量级下开销可以忽略。倒序删除则是在不额外分配的前提下最安全的做法。两种都行别用第一种。4.3 音效接入mixer 的三步和两个必查项音乐播放只有三行但顺序和初始化有讲究pygame.mixer.init(frequency44100, size-16, channels2, buffer512) pygame.mixer.music.load(bgm.mp3) # 相对路径基于运行目录不是脚本目录 pygame.mixer.music.set_volume(0.6) pygame.mixer.music.play(-1) # -1 表示无限循环mixer.init()要在music.load()之前调用。参数里buffer512是缓冲区大小调小能降低播放延迟太小会爆音44100 采样率、双声道是绝大多数 mp3 的默认规格照着填就行。play(-1)的-1是循环次数0表示不循环、只播一次1表示再重复一次总共两遍。想循环就别写成play(1)那是很多人以为「播放一遍」但实际播两遍的来源。两个必查项一是路径。load(bgm.mp3)是按当前工作目录找的如果你的脚本在子目录里、又从父目录启动文件明明在却报Unable to open file用绝对路径或者os.path.join(os.path.dirname(__file__), bgm.mp3)最保险。二是格式。mp3 在部分精简版环境里解码器缺失换 ogg 或 wav 立刻就好别在格式上跟它耗。5. 避坑与排查五个让烟花跑不起来的典型问题下面这些我基本都亲自踩过一遍按「现象、原因、解决」梳理遇到对得上的直接照方抓药。5.1 现象、原因、解决逐条对照坑一窗口开了闪一下就关掉。现象是双击运行之后黑框出现不到半秒就消失控制台没有任何报错。原因通常是pygame.quit()被放进了 while 循环内部循环第一轮结束就退出了或者事件循环里没有处理pygame.QUIT窗口关闭按钮点了没反应最后靠进程被强制结束。解决是确认pygame.quit()在while running之外且事件分支里把running False写对。另一个隐蔽诱因是主循环里抛了异常但被吞掉运行时用终端启动脚本而不是双击异常信息才会打出来。坑二整段动画只有一朵烟花或者干脆一朵都没有。现象是跑起来黑屏偶尔孤零零炸一下。原因分两种一是random.random() 0.05这类概率判断写在主循环里但被MAX_FIREWORKS或别的条件挡住看起来像没触发二是Firework.update()的返回值写错返回了None或False新加的烟花下一帧就被remove掉了。排查方法是在主循环里临时打印len(fireworks)正常应该稳定在几朵波动如果一直是 0 或 1问题就在返回值。坑三跑几分钟之后越来越卡最后像幻灯片。现象是帧率从 60 一路掉任务管理器里 CPU 占满单核。原因几乎都是没设同屏上限烟花对象只增不减列表里躺着几百个已经炸完的空壳。remove只在update返回假值时才执行如果那个返回值判断漏了某种情况对象就永远不会被清掉。解决是加MAX_FIREWORKS硬上限并在update里保证「粒子列表为空时一定返回 False」。坑四音效加载报错或者报 mixer 没初始化。现象是控制台打出pygame.error: mixer not initialized或者文件找不到。原因是mixer.init()漏了、或者调用顺序反了也可能是pygame.init()在某些环境下没有把音频子系统带起来。解决是显式调用pygame.mixer.init()并做异常兜底无音频设备的环境下直接跳过音乐让画面还能跑。try: pygame.mixer.init() pygame.mixer.music.load(bgm.mp3) pygame.mixer.music.play(-1) except pygame.error as e: print(audio disabled:, e) # 静音继续跑别让画面跟着挂掉坑五爆炸点永远在屏幕底部或者飞出屏幕才炸。现象是彩色粒子在底边炸开或者一直往上飞不炸。原因是 y 轴方向搞反了屏幕坐标 y 向下增大判断「升到高处」应该是head.y 120而不是 120。同理move里向上要用- math.sin。这类 bug 不看代码很难想到是符号问题改一个符号立刻正常。5.2 出问题时的排查顺序排查别乱抓按固定顺序走能省很多时间。第一步看控制台。Python 的异常信息里带文件和行号绝大部分问题一眼就能定位先确保你是从终端启动的双击运行时那些信息会被关掉的窗口一起带走。第二步看对象数量。在循环里打一行print(len(fireworks), sum(len(f.particles) for f in fireworks))正常情况下粒子总数应该在几百这个量级波动。如果持续往上走不回落就是清理逻辑的问题回到 4.2 节检查删除写法。第三步看帧率。clock.get_fps()每帧都能取到实时值稳定在 55 以上说明性能没问题掉到 30 以下就得减粒子数量或者降分辨率。分辨率和粒子数是平方级关系从 800x600 提到 1920x1080 时填充率涨了五倍多粒子数量最好同步降一点。第四步才是调画面。位置、颜色、扩散范围这类问题属于「不好看」而不是「跑不起来」等前三步都过了再动不然容易把逻辑 bug 和审美问题混在一起改改完自己都说不清是哪一行起的作用。6. 换一套自己的风格主题配色、拖尾和导出成片默认的黑底彩点看久了会腻改风格其实只需要动两个地方代码量很小。6.1 拖尾与主题色拖尾不用改粒子改清屏方式就行把每帧的纯黑填充换成半透明黑覆盖上一帧的痕迹就会以残影形式留下来。fade pygame.Surface((screen_width, screen_height)) fade.set_alpha(40) # 0 到 255越小残影越长 fade.fill((0, 0, 0)) ... screen.blit(fade, (0, 0)) # 替换原来的 screen.fill(BLACK)set_alpha(40)是关键参数。40 左右残影能拖出一两秒的尾巴像夜空里的烟调到 80 以上基本看不到拖尾调 20 以下会糊成一片光斑。这个值配合粒子寿命一起调寿命 25 帧配 alpha 40 观感最舒服。主题色在Firework.PALETTE里换。几套我试过还行的搭配主题配色值适合场景传统红金(255, 84, 84)、(255, 196, 64)节日气氛浓暖色调冷调夜空(96, 148, 255)、(88, 224, 148)屏幕上对比度高不刺眼单一白紫(255, 255, 255)、(214, 112, 255)极简风格适合做背景动画想让一朵烟花有两种颜色就别用一个 color 传给所有粒子改成在explode里按索引交替取色color self.PALETTE[0] if i % 2 0 else self.PALETTE[1]。6.2 导出成视频逐帧存图再用 ffmpeg 合成比截屏录制稳得多frame 0 ... if frame 600: # 录 10 秒60 帧 × 10 pygame.image.save(screen, fframes/{frame:05d}.png) frame 1先建好frames目录跑完再用ffmpeg -framerate 60 -i frames/%05d.png -pix_fmt yuv420p fireworks.mp4-framerate 60要和录制时的clock.tick(60)对上对不上视频会变快或变慢。yuv420p是为了兼容常见播放器不加这行出来的视频在一些设备上打不开。存图会拖慢帧率所以录制时最好关掉音频播放录完再合。我最开始做这类动画时习惯是先把「能跑」和「好看」彻底分开第一天只保证窗口不崩、粒子不漏、帧率不掉第二天才开始调颜色和残影。后来每次改别人的粒子系统我也强制走一遍这个顺序因为混在一起改的时候一个视觉问题背后往往藏着一个逻辑 bug改得越多越难回溯。希望帮到你。本文还有配套的精品资源点击获取