用Python+Pygame实现粒子烟花动画:从主循环到性能调优
简介:这份围绕Python与Pygame实现新年烟花动画的实践文档,面向已有Python基础、想入门图形界面与动画编程的学习者,也可作为节日庆祝小项目的参考。内容从环境准备和Pygame安装讲起,逐层拆解粒子系统的位置、速度、角度与生命周期设计,说明烟花发射、上升、爆炸和随机生成逻辑,并涉及颜色配置、背景音乐加载、事件循环、帧率控制以及鼠标键盘响应等环节,附有可运行代码示例,方便读者边学边改。压缩包内仅1个docx文件,大小约16KB,轻量易查,适合快速浏览与本地实践。文档已有143人学习,能帮助读者掌握Pygame基础绘图、动画更新和简单交互,理解如何把图形、音效与参数调节组合成完整演示,并据此调整烟花密度、颜色、粒子数量和发射频率,逐步建立图形编程的排错思路。
1. 一次跨年投屏需求,催生出的 Pygame 烟花动画
某开发者接到的需求很具体:给一场线下活动的投屏做一段能循环播放的烟花动画,不能是录好的视频,得实时跑、能改颜色、最好还能按空格手动放一发。翻了几轮方案之后,他用 Python 加 Pygame 写了不到两百行:一个 Particle 类管粒子,一个 Firework 类管发射和爆炸,外面套一个 60 帧的主循环,再挂一段背景音乐。这套烟花动画的骨架很薄,但该有的东西一样不少——粒子系统、随机色彩、三角函数扩散、事件响应、音效循环。
适合谁看:刚学完 Python 语法、想找个能立刻看见画面的东西练手的人;需要快速做节日氛围素材、又不想背一整套游戏引擎的开发者;以及想把粒子思维迁移到其他可视化场景的人。下面按我实际拆这份代码的顺序走,从环境装起,一路拆到怎么把它改成自己能用的配置化发射器。
2. 环境与工程骨架:从 pip 到 800×600 窗口的第一帧
2.1 依赖安装与三个必查的自检项
Pygame 的安装本身没什么坑,pip install pygame在当前主流 2.x 系列上,Windows、macOS、常见 Linux 发行版都有预编译轮子,一般不会触发本地编译。真正容易翻车的是后面三件事:SDL 后端起没起来、音频设备有没有被识别、窗口能不能在无显示环境里创建。我习惯在动手写业务代码之前先跑一个自检脚本,把这三个问题一次性暴露出来。
# 建议在虚拟环境里装,避免污染系统 Python python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install pygame装完之后不要急着写烟花,先跑一遍下面这段,把版本号、SDL 版本、mixer 状态都打出来。这段代码的价值在于:当后面出现「窗口能开但没声音」「窗口打不开但也不报错」这类玄学现象时,你能一眼看出是环境问题还是代码问题。
import pygame print("pygame:", pygame.version.ver) print("SDL:", pygame.get_sdl_version()) # mixer 的初始化状态单独检查,pygame.init() 会吞掉音频初始化失败 pygame.mixer.pre_init(44100, -16, 2, 512) pygame.init() print("mixer 已初始化:", pygame.mixer.get_init()) # 真正开一个窗口再关掉,验证渲染后端可用 screen = pygame.display.set_mode((800, 600)) pygame.display.set_caption("selftest") pygame.display.flip() pygame.quit()pygame.mixer.get_init()返回None就说明音频子系统没起来,这时音乐加载一定失败,但pygame.init()本身不会抛异常,会静默吞掉——这就是后面「代码没错但没声音」的头号来源。pre_init里的四个参数依次是采样率、位深、声道数和缓冲区大小,缓冲区调到 512 是为了降低播放延迟,默认值在部分机器上会有肉眼可见的延迟感。
提示:如果自检脚本报
No available audio device,先在系统层面确认声卡可用;无显示环境的服务器上跑,需要设置SDL_VIDEODRIVER=dummy才能创建窗口,但 dummy 驱动下什么都看不见,只适合跑逻辑测试。
2.2 目录结构与常量收口
这份烟花代码最容易被忽略的问题是全局变量满天飞:屏幕尺寸、白色、黑色、屏幕对象screen全都是模块级变量,Particle 的draw()直接引用全局screen。单个文件跑没问题,一旦要加第二个场景或者做单元测试就痛苦了。我在复现时的做法是把常量抽到单独的配置文件里,屏幕对象通过参数传进去,文件结构大致是这样:
main.py:入口,负责初始化、主循环、事件分发config.py:屏幕尺寸、颜色表、粒子数量、重力系数、帧率particle.py:Particle 类,管单个粒子的运动和绘制firework.py:Firework 类,管发射、爆炸、粒子集合的更新assets/bgm.ogg:背景音乐,放独立目录便于打包
把参数收口到config.py是这套改造里性价比最高的一步。烟花的观感几乎完全由几个数字决定:一炸多少粒子、粒子初速多大、重力多强、生命周期几帧。散落在代码里的时候,调一次效果要改五六个地方;收口之后,改一个数字就能对比两种风格。
# config.py SCREEN_WIDTH = 800 SCREEN_HEIGHT = 600 FPS = 60 WHITE = (255, 255, 255) BLACK = (0, 0, 0) # 爆炸参数 PARTICLE_COUNT = 100 # 单次爆炸产生的粒子数 EXPLODE_Y = 120 # 升到这个 y 坐标(屏幕上方)就炸 SPAWN_CHANCE = 0.05 # 每帧自动发射新烟花的基础概率 # 粒子参数 GRAVITY = 0.12 # 每帧给竖直速度叠加的量 LIFE_DECAY = 1 # 每帧扣多少生命值FPS=60决定了clock.tick(60)的节拍,也决定了 25 帧生命周期的粒子大约存活 0.42 秒。GRAVITY这个量在原始代码里是不存在的,原文用speed *= 0.95做减速来「模拟重力」,这个做法会在第 3 章详细拆,它其实是整份代码里最容易翻车的一行。
2.3 主循环骨架:事件、更新、绘制、flip 四拍
不管后面加多少特效,主循环永远是这四拍:收事件、更新状态、清屏重绘、交换缓冲区。顺序错了会出现拖影、事件堆积不响应、画面撕裂这类问题。原始代码的循环结构是对的,只是没有把「更新」和「绘制」拆开——Particle 的move()里干的是纯计算,draw()里直接画到屏幕上,两者交替执行,逻辑上能跑,但等你想要加「先算完所有粒子位置再统一画」的需求时就得推倒重来。
import pygame import random from config import SCREEN_WIDTH, SCREEN_HEIGHT, FPS, BLACK pygame.init() screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption("fireworks") clock = pygame.time.Clock() fireworks = [] 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: fireworks.append(Firework()) # 第二拍:更新,倒序遍历以便安全删除 for fw in fireworks[:]: if not fw.update(): fireworks.remove(fw) # 第三拍:绘制 screen.fill(BLACK) for fw in fireworks: fw.draw(screen) # 第四拍:交换缓冲区 + 限帧 pygame.display.flip() clock.tick(FPS) pygame.quit()clock.tick(FPS)做的事情不只是限帧,它返回上一帧耗时,内部还负责让循环不要空转吃满 CPU。少了这一行,主循环会以几千帧每秒的速度跑,粒子生命周期按帧计数,25 帧在 1000fps 下只有 25 毫秒,看起来就是一闪而过。fireworks[:]这个切片拷贝是为了在遍历过程中删除元素时不出错,直接遍历原列表删元素会跳项,这是 Python 里非常经典的一个坑。
注意:
pygame.display.flip()和pygame.display.update()在双缓冲模式下作用相同,但flip()是全量刷新,粒子数量多的时候比update()指定脏矩形更省心,代价是带宽高一点。800×600 这个量级完全不用纠结。
3. 粒子模块拆解:Particle 类的坐标、角度与 25 帧生命
3.1 六个字段定生死
Particle 是整套烟花里最底层的单元,它不关心自己属于哪一朵烟花,只负责三件事:我是谁(颜色、大小)、我在哪、我往哪走。原始代码里给了它六个字段,这六个字段基本上决定了一颗粒子的全部命运。
class Particle: def __init__(self, x, y, color): self.x = x self.y = y self.color = color self.size = random.randint(4, 7) # 半径 4~7 像素 self.life = 25 # 存活 25 帧 self.angle = random.uniform(0, 2 * math.pi) # 0~360 度随机方向 self.speed = random.uniform(2, 5) # 初速 2~5 像素/帧x、y用浮点数存,不用整数,这一点很关键:如果一开始就存int,每帧的位移量小于 1 的时候会被截断成 0,粒子直接定死在原地不动,看起来就是「炸开之后没有扩散」。size用randint(4, 7)给了粒子大小差异,视觉上比统一半径自然得多。angle用uniform(0, 2π)配合后面的cos/sin,让 100 颗粒子均匀铺满一个圆,比randint更均匀,不会出现方向上的扎堆。
life = 25是个硬编码的帧数,它和时间的关系依赖帧率。60fps 下是 0.42 秒,30fps 下就变成 0.83 秒,同一个参数在低配机器上效果会明显变形。想稳定就得改成按秒计时,用dt去扣减,或者干脆固定 60 帧跑,把clock.tick(60)当契约。
3.2 move() 里的运动方程,以及 0.95 这个衰减系数埋的雷
粒子的位移靠一行三角函数算出来,水平方向用speed * cos(angle),竖直方向用speed * sin(angle)再取负号(屏幕坐标系 y 轴向下,取负才是向上)。
def move(self): self.x += self.speed * math.cos(self.angle) self.y -= self.speed * math.sin(self.angle) self.life -= 1 self.speed *= 0.95 # 减速,意图是模拟空气阻力这段逻辑本身没毛病,0.95是一个指数衰减系数,每帧速度降到 95%,第 20 帧只剩初始的 36%。问题出在它和 Firework 的爆炸触发条件配合上。原始代码里,上升粒子从y = 600出发,爆炸条件是self.particles[0].y < 100,也就是要往上飞 500 像素。可速度按 0.95 衰减,初速最大 5 的情况下,累计位移的上限是5 / (1 - 0.95) = 100像素——连 500 的零头都不到。这意味着这朵烟花永远飞不到触发高度,永远不会爆炸。
我第一次跑到这个现象的时候以为是自己环境有问题,打了日志才发现粒子飞到y = 500左右就彻底不动了。修法有两条路,看你想保留多少原始结构:
# 修法一:保持单 speed 结构,衰减系数调小,并把触发条件改成相对位移 self.speed *= 0.995 # 每帧只衰减 0.5%,累计位移约 200 倍初速 # 修法二(推荐):拆成 vx / vy,竖直方向受重力,水平方向不衰减 def move(self): self.x += self.vx self.y += self.vy self.vy += GRAVITY # 屏幕坐标向下为正,正重力让粒子往下掉 self.life -= self.life_decay修法二更接近真实物理:爆炸瞬间粒子朝四面八方飞出,竖直方向被重力持续拉弯,形成经典的抛物线拖尾。修法一只能做到「飞得远一点」,做不出「炸开后往下坠」的观感。我一般会选修法二,因为后续想加「风力偏移」只需要在vx上叠一个常量,扩展点很自然。
提示:把
speed拆成vx和vy之后,初始速度的生成方式要跟着改:vx = cos(angle) * speed、vy = -sin(angle) * speed,负号别丢,丢了粒子全部往下炸。
3.3 draw() 与存活判定:先画后判还是先判后画
原始代码把存活判定塞进了draw():
def draw(self): if self.life > 0: pygame.draw.circle(screen, self.color, (int(self.x), int(self.y)), self.size)这个写法有两个问题。第一,screen是全局变量,Particle 被绑死在这一个窗口上,想做多窗口或者把粒子渲染到离屏 Surface 做残影效果就没法复用。第二,判活和绘制混在一起,update()里已经有一份if particle.life <= 0: remove()的清理逻辑,两处判活标准必须严格一致,改一处忘另一处就会出现「明明该消失的粒子还在画」或者「粒子提前消失」。
我改成的写法是把绘制职责收干净,判活只留在update()一处:
def draw(self, surface): # 半径必须传 int,pygame 不接受浮点半径 radius = max(1, int(self.size * (self.life / self.max_life))) # 颜色随生命衰减,做出「渐渐熄灭」的过渡 fade = max(0.0, self.life / self.max_life) color = tuple(int(c * fade) for c in self.color) pygame.draw.circle(surface, color, (int(self.x), int(self.y)), radius)这里多做了两件事:半径随生命线性缩小、颜色随生命线性变暗。原始的draw()是粒子到死都是满亮度满大小,一帧之内集体消失,视觉上很生硬。加上这两行衰减之后,爆炸的观感会从「一团突然没了」变成「慢慢散尽」,成本几乎为零。
int(self.x)这个转换也是必须的,pygame.draw.circle只接整数坐标,传浮点会抛TypeError。但转换只发生在绘制这一瞬间,内部存储始终是浮点——这个「内部浮点、出口取整」的约定,是所有基于 Pygame 的粒子系统都要遵守的基本纪律,违反了就会出现位移精度丢失。
4. 发射器与爆炸链路:Firework 类如何把一枚白点炸成 100 个粒子
4.1 launch():一枚白点从屏幕底部起飞
Firework 的职责和 Particle 完全不同:它管理一组粒子,并且知道「什么时候该让这一组从 1 个变成 100 个」。这个「1 变 100」的跃变就是整个烟花的戏剧性所在,所以 Firework 内部必须区分两个阶段——上升期只有一颗粒子,爆炸期是一大堆粒子。
class Firework: def __init__(self): self.particles = [] self.explosion_color = random.choice([ (255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 215, 0), (255, 105, 180), ]) def launch(self): # 从屏幕底部随机横坐标起飞,颜色固定白色,和爆炸色区分开 self.particles.append( Particle(random.randint(0, SCREEN_WIDTH), SCREEN_HEIGHT, WHITE) )explosion_color在构造时就定死,而不是爆炸那一刻再随机,原因是同一朵烟花的 100 颗粒子必须同色,否则炸出来是彩色杂点,观感散掉。颜色候选表从原来的三原色扩到了五种,加了金色和粉色,跨年氛围下金色比纯绿纯蓝好看得多。
launch()里的坐标用的是SCREEN_HEIGHT,也就是从屏幕最底边外面一点点起飞。严格说应该用SCREEN_HEIGHT + size,让粒子完全在屏幕外生成再飞进来,否则第一帧就能看到一颗白点在底边上凭空冒出来,有点突兀。这个细节改不改都行,想较真就加个偏移。
上升粒子的初速和角度需要特别处理。直接复用Particle.__init__的随机角度,粒子就不是往上飞,而是朝随机方向飘,看起来像一堆白点在乱窜。我一般会给上升粒子单独指定角度:
def launch(self): p = Particle(random.randint(60, SCREEN_WIDTH - 60), SCREEN_HEIGHT, WHITE) p.angle = math.pi / 2 + random.uniform(-0.15, 0.15) # 接近正上方 p.speed = random.uniform(6, 9) # 上升速度比爆炸粒子快 p.life = 200 # 生命给足,够飞到顶 self.particles.append(p) self.rising = p # 单独持有引用,方便判断把上升粒子单独存一个引用self.rising,比原文用self.particles[0]索引要稳。原文的写法依赖「上升粒子永远是列表第 0 个」这个隐含契约,一旦爆炸逻辑里删元素顺序变了,这个契约就断了。
4.2 explode():100 个粒子从同一个点炸开
爆炸这一段是整份代码里最有戏剧性的部分,也是最容易被写坏的部分。
def explode(self): for _ in range(100): self.particles.append( Particle(self.particles[0].x, self.particles[0].y, self.explosion_color) ) del self.particles[0] # 删掉上升粒子先说正确的部分:先 append 再删索引 0,这个顺序保证了 100 个新粒子拿到的是上升粒子还在列表里时的坐标,写法是自洽的。但有几个地方值得推敲。
第一,100 颗粒子的初始坐标完全一样,炸出来是一个密度极高的点,视觉上先是一坨白,两帧之后才散开。加一点位置抖动会自然很多:
src = self.particles[0] for _ in range(PARTICLE_COUNT): p = Particle(src.x + random.uniform(-3, 3), src.y + random.uniform(-3, 3), self.explosion_color) p.speed = random.uniform(1.5, 6) self.particles.append(p) self.particles.remove(src)第二,del self.particles[0]假设上升粒子一定在索引 0 上。如果哪天在爆炸前又往列表里塞了东西,删掉的就是别的粒子。用remove(src)按对象删,语义清楚得多,代价是一次 O(n) 查找,100 个元素的开销可以忽略。
第三,爆炸粒子的初速用random.uniform(1.5, 6)而不是统一值,这样炸出来的形状是一个有厚度的环,不是一层薄壳。速度分布的范围直接决定烟花的「花型」:范围窄,炸出来是规整的圆环;范围宽,是不规则的星芒。这个参数值得手动调几轮,找到自己喜欢的感觉。
第四,颜色可以加一层随机微扰。同一朵花的主色定了,但每颗粒子在主色上抖 ±20,炸出来会有明暗层次,比 100 个完全一样的 RGB 值立体。
4.3 update() 三段式:移动、清理、触发
update()是这个类里唯一被主循环每帧调用的方法,它同时承担了推进和决策两个职责。
def update(self): if not self.particles: return False # 粒子全没了,通知主循环把这朵烟花移除 for particle in self.particles[:]: particle.move() if particle.life <= 0: self.particles.remove(particle) # 上升粒子飞到指定高度,触发爆炸 if self.rising is not None and self.rising.y < EXPLODE_Y: self.explode() self.rising = None return True第一段是空列表早退,返回False让主循环把整个 Firework 对象从列表里摘掉。这个返回值设计很关键,如果没有它,空的 Firework 会永远留在fireworks列表里,每帧被遍历一次但什么也不做,跑上十分钟就是几百个空对象,纯浪费。原文的写法里有这个判断,但位置在update最后,容易被后续改动覆盖掉,我习惯放在函数最开头,保证任何分支都走不到后面。
第二段遍历用self.particles[:]切片拷贝,边遍历边删。原地遍历删元素会跳项这件事在第 2 章提过一次,这里是它真正咬人的地方:100 个粒子同时到期的时候,用原地遍历大概只能删掉一半,剩下的要等下一帧,表现出来就是「粒子消失得拖泥带水」。
第三段的触发条件我换成了self.rising.y < EXPLODE_Y。原文用的是len(self.particles) == 1 and self.particles[0].y < 100,两个条件里第一个是防重复触发(只剩一颗说明还没炸),第二个是高度。用单独引用之后,self.rising is not None就等价于「还没炸」,语义更直接,也不依赖粒子数量。EXPLODE_Y抽到配置里,改爆炸高度不用翻代码。
注意:
explode()里新增的粒子是在同一帧被move()处理还是下一帧,取决于调用顺序。我这里的写法是爆炸后当帧不再遍历新粒子,所以爆炸效果从下一帧开始扩散,视觉上会有一帧的「聚点」。想让它当帧就散开,把触发段挪到遍历之前。
4.4 主循环里的调度器:5% 概率与空格键手动触发
烟花的自动发射用的是概率而不是定时器:
if random.random() < SPAWN_CHANCE: fw = Firework() fw.launch() fireworks.append(fw)random.random()返回 [0, 1) 的浮点数,和 0.05 比较,每帧有 5% 的概率触发。60fps 下平均每秒触发 3 次,但这个「平均」的方差很大——可能连着三帧都触发,也可能两秒一发没有。这种不均匀在烟花场景里反而是优点,人工定时器放出来的烟花节奏太机械,随机间隔看起来更像真的。
真正的坑在数量控制上。概率触发没有上限,跑久了fireworks列表可能攒到几十朵,每朵 100 颗粒子,一帧要画几千个圆,帧率直接塌。我一般会加一道硬上限:
MAX_FIREWORKS = 12 if len(fireworks) < MAX_FIREWORKS and random.random() < SPAWN_CHANCE: ...空格键手动触发走的是同一条构造路径,只是绕过了概率判断。这里有个体验细节:按住空格不放会触发操作系统的按键重复,一秒放出几十发。想只响应单次按下,用pygame.KEYDOWN事件判断就够了,因为KEYDOWN本身不重复(重复的是pygame.key.set_repeat开启后才有的行为,默认关闭)。
事件循环里除了空格和退出,还应该处理ESC。原文只处理了QUIT和SPACE,用户按 ESC 没反应,只能去点窗口右上角的叉,在投屏场景下很尴尬——播放的人手里只有键盘。
5. 排查手册:音效无声、帧率掉到 20、粒子拖尾不消失的现场记录
5.1 现象:程序正常跑,画面正常,就是没有声音
原因有三个层次,按出现频率排:第一,pygame.mixer初始化失败但被pygame.init()静默吞掉;第二,音乐文件路径用的是相对路径,而程序的工作目录不是脚本所在目录,music.load()抛异常但没被捕获;第三,文件格式是 mp3,部分平台的 SDL_mixer 编译时没带 mp3 解码器。
解决顺序是先查 mixer 状态,再查路径,最后换格式。路径这块我习惯用绝对路径兜底:
import os import pygame bgm_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "bgm.ogg") try: pygame.mixer.music.load(bgm_path) pygame.mixer.music.set_volume(0.6) pygame.mixer.music.play(-1) # -1 表示无限循环 except pygame.error as e: print("背景音乐加载失败,已跳过:", e)os.path.dirname(os.path.abspath(__file__))这一段保证不管从哪里启动脚本,都能定位到脚本旁边的assets目录。音频格式优先用 ogg 或 wav,mp3 的兼容性在跨平台时确实差一截。try/except是必须的——背景音乐挂了不应该让整个烟花程序崩掉,静音也要能放。
5.2 现象:烟花升到半空就停住,永远不爆炸
这是第 3 章分析过的speed *= 0.95导致累计位移不足。表现很迷惑:粒子明明在动,但就是到不了顶部,而且因为life还在扣,飞着飞着就自己消失了,一朵烟花从头到尾没炸过。
原因是速度按几何级数衰减,累计位移收敛到v0 / (1 - 0.95),初速 5 时上限只有 100 像素,而触发高度要求飞 500 像素。解决方法是把单speed拆成vx/vy,竖直方向用重力累加而不是乘衰减系数,同时把触发条件从绝对坐标改成相对发射点的高度。
排查这类问题有个笨办法但很好用:在update()里每隔 10 帧打一次particle.y,看它是不是在某个值附近收敛了。如果是,基本可以确定是衰减系数的问题。
5.3 现象:跑三分钟之后帧率从 60 掉到 20,风扇开始转
三个可能的位置,从高到低排。第一是fireworks列表只增不减,空的 Firework 对象没被移除——检查update()的返回值分支是不是被别的逻辑提前 return 了。第二是粒子的move()和draw()分在两个循环里各遍历一次列表,粒子多的时候遍历开销翻倍,能合并就合并。第三是pygame.draw.circle的调用次数,每帧几千次圆绘制在 Pygame 里确实吃力。
实测下来,800×600 分辨率、每个圆半径 2~4 像素的情况下,同时存在 2000 颗左右的粒子还能稳在 60 帧,超过 3000 开始明显掉。控制手段就是MAX_FIREWORKS和PARTICLE_COUNT两个上限,把「同时存在的粒子总数」压在 1500 以内比较安全。真要更多粒子,就得换渲染方式,比如用 Surface 预渲染几种大小的圆点再 blit,或者上pygame.sprite.Group批量绘制。
5.4 现象:高分屏上窗口被拉伸,粒子变成方块
原因是 SDL2 在 Windows 的高 DPI 缩放设置下会做一次系统级放大,800×600 的逻辑窗口被拉成 1200×900 之类,像素被整数倍放大,圆形变方,边缘发虚。
两个解法。一是在创建窗口时用SCALED标志让 SDL 自己处理缩放:
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SCALED)二是在程序最开头声明 DPI 感知,让系统不要替你做缩放:
import os os.environ["SDL_WINDOWS_DPI_AWARENESS"] = "permonitorv2"第一种更省事,第二种对多显示器场景更友好。选一个就行,别两个一起上,会缩放两次。
5.5 现象:按 ESC 没反应,关窗之后程序还卡几秒
按 ESC 没反应纯粹是事件分支没写,加一个elif event.key == pygame.K_ESCAPE: running = False就完了。
关窗卡顿的原因通常是pygame.quit()之后主循环还在跑。用了running标志的写法一般不会出这个问题,但如果退出逻辑写成了「在事件循环里直接pygame.quit()然后继续往下执行」,就会出现pygame.display.flip()对已销毁的显示表面操作,抛异常或者阻塞。正确的做法是把pygame.quit()放在主循环外面,循环靠标志位退出。
另一个隐藏坑在 macOS 上:Pygame 的窗口和事件循环必须在主线程跑,放到子线程里会出现窗口不刷新、事件收不到的现象,排查起来非常费劲,因为完全不报错。
6. 二次开发:把硬编码烟花改成可配置粒子发射器
改到这一步,代码已经能稳定跑起来了,接下来是我认为最值钱的部分——把这套东西变成能复用的发射器。我一般会做三件事,按投入产出比排序。
第一件,把所有影响观感的数字抽成一份配置,用字典或者 dataclass 承载,运行时可以热改。改完之后,做不同的烟花风格就是换一组参数,不用动逻辑代码。
| 参数 | 作用 | 调大的效果 | 建议范围 |
|---|---|---|---|
PARTICLE_COUNT | 单次爆炸粒子数 | 花型更密实,性能压力线性上升 | 60~200 |
SPEED_RANGE | 爆炸初速区间 | 炸开半径更大,花型更散 | (1.5, 6)~(3, 12) |
GRAVITY | 每帧竖直加速度 | 拖尾下坠更明显,像垂柳 | 0.05~0.3 |
MAX_LIFE | 粒子存活帧数 | 拖尾更长,同时在线粒子更多 | 20~60 |
EXPLODE_Y | 触发爆炸的高度 | 越高,上升段越长 | 80~250 |
第二件,把draw()里的screen全局依赖彻底去掉,改成传 surface。这一步做完之后,同一套粒子逻辑可以渲染到主窗口,也可以渲染到一张离屏 Surface 上再做模糊叠加,做出「发光」效果——把上一帧整体降低透明度再叠到当前帧,两行代码就能出残影。这个改动看着小,但它把粒子和窗口解耦了,是从「一个 demo」走向「一个组件」的分界线。
第三件,加一个事件驱动的发射接口,而不是让主循环直接操作fireworks列表。比如定义一个emit(x, y, style)函数,内部按 style 查配置、造 Firework、塞进列表。这样外部想在哪里放、放什么风格,都跟内部数据结构无关。
最后说一个我踩过的坑。早期我做这类粒子效果,习惯先把参数调到「自己觉得好看」,然后直接交付。结果换一台机器、换一个分辨率,效果全变——因为粒子大小是固定像素值,800×600 下的 4 像素半径在 1920×1080 的投影上细得像头发丝。从那以后我每次做像素相关的效果,都强制把所有尺寸参数写成相对于屏幕短边的比例,跑之前先在三档分辨率下各看一遍。这个习惯帮我省了很多次返工。
代码不复杂,但把它调到「能拿出去放」的程度,靠的就是这些参数和边界条件的反复试。希望帮到你。
本文还有配套的精品资源,点击获取