ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Python+Pygame烟花动画:Particle与Firework拆解

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 * 60

dt * 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), angle=None): 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 越大越靠下,和数学习惯相反,这一点写反了烟花会往地里钻。

angle=None这个默认参数是为上升粒子留的口子:升空时传一个接近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 到 5
life_range(25, 40)残影拖得久、同屏粒子多一闪就没、显得干脆20 到 60
speed_range(2, 5)炸得开、覆盖半个屏挤成一团光球2 到 7
阻尼系数0.95接近 1 时飘得远0.85 以下几乎原地不动0.90 到 0.98
burst_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), angle=random.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(frequency=44100, size=-16, channels=2, buffer=512) pygame.mixer.music.load("bgm.mp3") # 相对路径基于运行目录,不是脚本目录 pygame.mixer.music.set_volume(0.6) pygame.mixer.music.play(-1) # -1 表示无限循环

mixer.init()要在music.load()之前调用。参数里buffer=512是缓冲区大小,调小能降低播放延迟,太小会爆音;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, f"frames/{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,改得越多越难回溯。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表