简介:一份可直接运行的Python爱心粒子代码,面向Python初学者、图形动画爱好者以及需要趣味编程素材的开发者。资源基于pygame库实现,通过定义HeartParticle粒子类来管理粒子的位置、速度、加速度和生命周期,利用三次贝塞尔曲线结合直线段绘制出爱心轮廓,并加入透明度随寿命递减的动画,配合循环判断让粒子呈现起伏呼吸的灵动效果。代码中提供了完整的HeartParticle类、bezier_curve函数及运行示例,同时细致讲解了粒子系统、贝塞尔曲线、动画渲染等核心知识点,读者可轻松调整粒子数量、速度、颜色等参数,打造个性化的爱心特效。包内共1个docx文档(39KB),文档将可运行代码与图文解析整合一体,离线即可对照阅读与复现。该资源已有7700余人学习下载,适合作为编程练习、节日表白或Python图形动画教学的实用参考。
1. 爱心粒子代码:一套能跑的粒子系统,比表白更重要的是这三块知识点
python 的软件生态里,爱心可视化一直是被搜得最多的资源之一,网上各种版本我也见过不少,大多是静态图形或者单纯循环动画。这份爱心粒子代码不太一样:它把粒子系统、贝塞尔曲线和动画循环三件事塞进了同一个 Demo 里——每个小红点都有自己的速度、加速度和寿命,爱心轮廓用曲线控制点逐帧扫描出来,整体跑起来是动态的粒子流。它适合两类人:一是刚学完 Python 基础、想练 pygame 和面向对象写法的同学,二是想找一份能直接改参数、改颜色、改粒子的可运行素材的开发者。接下来我按“结构 → 曲线原理 → 调参 → 避坑 → 二次封装”的顺序把这个项目拆透。
2. 环境与代码骨架:先把依赖和主循环理清,再谈效果
2.1 依赖准备:Python 版本、pygame 安装与运行入口
这份代码用到了 pygame 和 random、math 标准库,实际依赖只有一个第三方库。Python 版本方面,项目本身没有用到 3.10+ 的新语法,常见做法是装一个 Python 3.8 或 3.10 的环境,我这边两个版本都跑过,没有兼容性问题。安装依赖和执行入口如下:
pip install pygame python heart_particle.py依赖安装完成后,运行脚本会弹出一个 800×600 的窗口,标题是 “Python Love”,窗口里会有红色粒子从心形区域向外飞散,同时心形轮廓会持续显示。注意一点:pygame 在部分 Linux 环境下需要额外装 SDL 相关系统库,如果pip install pygame之后 import 报错,先检查系统包管理器里的 SDL2 依赖;Windows 和 macOS 下一般不会遇到这个问题。
2.2 粒子类 HeartParticle:位置、速度、加速度与寿命
粒子是这个项目最核心的抽象,代码里用了一个类来管理粒子的全部状态:
class HeartParticle(): def __init__(self, pos): self.pos = [pos[0], pos[1]] self.vel = [float(random.randint(-35, 35)) / 10, float(random.randint(-35, 35)) / 10] self.acc = [float(random.randint(-10, 10)) / 200, float(random.randint(-10, 10)) / 200] self.lifespan = random.randint(10, 50) def move(self): self.vel[0] += self.acc[0] self.vel[1] += self.acc[1] self.pos[0] += self.vel[0] self.pos[1] += self.vel[1] self.lifespan -= 1 def draw(self, window): if not self.lifespan: return False alpha = int(float(self.lifespan) / 50 * 255) pygame.draw.circle(window, (255, 0, 0, alpha), [int(self.pos[0]), int(self.pos[1])], 6) return True逻辑说明:粒子每一帧执行一次 move,速度先累加加速度,位置再累加速度,这是一个典型的欧拉积分模型。lifespan每帧减 1,减到 0 后 draw 返回 False,上层会把它从列表中移除。alpha的设计意图是让粒子随寿命增加而变淡,但这里有个坑,后面避坑章节会专门讲 pygame.draw 根本不处理 RGBA 的 alpha 分量。
参数含义:vel的随机范围是 -3.5 到 3.5,除以 10 是把随机整数变成小数;acc的随机范围是 -0.05 到 0.05,单位是“每帧每帧的速度变化量”。也就是说加速度非常小,粒子整体轨迹偏平滑,不会出现突然转向。lifespan在 10 到 50 帧之间随机,40fps 下大约存活 0.25 到 1.25 秒。
2.3 主循环与绘制顺序:fill、draw、flip 的顺序不能搞反
主循环是 pygame 的标准结构,但这份原始代码里的绘制顺序有一个严重问题,我先把修正后的版本贴出来:
screen = pygame.display.set_mode((800, 600)) pygame.font.init() pygame.display.set_caption("Python Love") clock = pygame.time.Clock() done = False particles = [] while not done: for event in pygame.event.get(): if event.type == pygame.QUIT: done = True screen.fill((255, 255, 255)) draw_heart(400, 325, 3) particles = draw_particles(screen, particles) append_particles(particles, [400, 300], 1) pygame.display.flip() clock.tick(40) pygame.quit()逻辑说明:每一帧的标准流程是“清屏 → 画静态背景 → 更新并绘制粒子 → 追加新粒子 → 翻转缓冲”。screen.fill必须放在所有绘制之前,否则上一帧的内容会残留在画面上,并且先画的图形会被 fill 覆盖掉。原始代码把draw_particles放在 fill 前面,等于粒子全部画在了即将被清空的画布上,窗口里根本看不到粒子。
clock.tick(40)控制帧率上限为 40fps,这个值决定了粒子的运动速度观感。调高到 60 会让粒子动作更细腻,但粒子寿命是按帧数计的,同样帧数下存活时间反而变短;调低到 30 会显得粒子运动一顿一顿。这个参数我一般不建议低于 30。
3. 贝塞尔曲线画爱心:控制点、扫描线与坐标换算详解
3.1 de Casteljau 递归:多控制点求曲线点
爱心轮廓没有用画图软件导出的位图,而是用贝塞尔曲线实时计算出来的。核心函数看名字很唬人,其实只有十几行:
def bezier_curve(points, time): if len(points) == 1: return points new_points = [] for i in range(len(points) - 1): x = (1 - time) * points[i][0] + time * points[i + 1][0] y = (1 - time) * points[i][1] + time * points[i + 1][1] new_points.append([x, y]) return bezier_curve(new_points, time)逻辑说明:这是 de Casteljau 算法的递归写法,每一次递归都对相邻控制点做一次线性插值,控制点数量减一,一直递归到只剩一个点时,这个点就是参数 time 对应的曲线上的点。time 从 0 连续变化到 1,所有点连起来就是一条完整贝塞尔曲线。
参数说明:points是控制点列表,数量可以是 3 个也可以是 5 个;time是曲线参数,范围 0 到 1,0 取起点,1 取终点。需要注意:严格来说 3 个控制点生成的是二次贝塞尔曲线,5 个控制点生成的是四次贝塞尔曲线,原文统称“三次贝塞尔曲线”并不严谨,但递归算法本身对任意阶数都成立,不影响运行。
3.2 六组控制点与扫描线填充技巧
draw_heart里定义了六组控制点,它们两两配对,在同一个 t 值下取两个曲线上的点并连线:
points1 = [(0.5, 0.2), (0.43, -0.05), (0, -0.05)] points2 = [(0, -0.05), (-0.43, -0.05), (-0.5, 0.2)] points3 = [(-0.5, 0.2), (-0.5 * 0.04, 0.55), (0, 0.78), (0.5 * 0.04, 0.55), (0.5, 0.2)] points4 = [(0.5, 0.2), (0.5 * 0.04, 0.55), (0, 0.78), (-0.5 * 0.04, 0.55), (-0.5, 0.2)] points5 = [(0, -0.05), (0, 0.34), (-0.083, 0.55)] points6 = [(0, -0.05), (0, 0.34), (0.083, 0.55)]这六组点的作用可以分成三对:
| 配对 | 控制点数量 | 覆盖区域 | 作用 |
|---|---|---|---|
| points1 + points2 | 3 + 3 | 心形上半部左右两侧 | 画出顶部到中心凹陷的轮廓 |
| points3 + points4 | 5 + 5 | 心形下半部 | 画出两个半圆弧和底部尖角 |
| points5 + points6 | 3 + 3 | 心形中间凹陷 | 补齐 V 形缺口附近的轮廓 |
实际的绘制代码对每组配对各取贝塞尔曲线上同一 t 处的点,用直线连接:
for i in range(100): t = float(i) / 100 p1 = bezier_curve(points1, t)[0] p2 = bezier_curve(points2, t)[0] p3 = bezier_curve(points3, t)[0] p4 = bezier_curve(points4, t)[0] p5 = bezier_curve(points5, t)[0] p6 = bezier_curve(points6, t)[0] pygame.draw.line(window, (255, 255, 255), [x + 64 * size * p1[0], y + 48 * size * p1[1]], [x + 64 * size * p2[0], y + 48 * size * p2[1]], 3) pygame.draw.line(window, (255, 255, 255), [x + 64 * size * p3[0], y + 48 * size * p3[1]], [x + 64 * size * p4[0], y + 48 * size * p4[1]], 3) pygame.draw.line(window, (255, 255, 255), [x + 64 * size * p5[0], y + 48 * size * p5[1]], [x + 64 * size * p6[0], y + 48 * size * p6[1]], 3)逻辑说明:这种画法叫同参数连线扫描。t 从 0 到 1 步进 100 次,每次把两个对称控制组上相同参数的点连起来,得到的是一簇扫描线。比如 points1 和 points2 的连线会从心形顶部一条一条往下扫,扫过整个上半部;points3 和 points4 的连线则从两侧往底部扫。100 条线叠在一起,视觉上就呈现出一个有厚度的实心爱心轮廓,而不是断开的点。线宽设为 3,是为了让相邻线之间几乎没有间隙。
3.3 归一化坐标与屏幕映射:64 和 48 这两个缩放因子
控制点坐标都是小数,范围在 -0.5 到 0.78 之间,直接画到窗口上只能得到几个像素的点。所以draw_heart里做了一步映射:
x = cx - 32 * size y = cy - 24 * size这里的cx、cy是爱心中心在屏幕上的像素坐标,size是整体缩放倍数。控制点坐标 p[0] 映射到屏幕时乘 64 再乘 size,p[1] 乘 48 再乘 size。也就是说,控制点 x 方向每 1.0 对应 64 个像素,y 方向每 1.0 对应 48 个像素。为什么要两个方向用不同缩放?因为爱心图形的原始宽高比大约是 64:48,即 4:3,两个方向分开缩放才能保持形状不变形。
左上角的偏移量cx - 32 * size和cy - 24 * size也由这个比例得来:图形总宽 64 个归一化单位,中心点 cx 要在正中,所以左边界是 cx 减一半宽度;y 方向同理,总高 48 个单位,上边界是 cy 减去 24 个单位。这个换算关系在做多爱心、做缩放动画时非常关键,后面第 6 章的呼吸效果就是靠动态改 size 实现的。
4. 粒子动力学与动画节奏:从能跑到好看的调参过程
4.1 粒子生成策略:正态分布半径与随机角度
粒子不是从同一个点喷出来的,而是在爱心中心附近按圆形区域随机扩散生成。原始代码的生成函数是这样的:
def append_particles(particles, center, size): for i in range(15): r = int(random.normalvariate(0, 10) * size) angle = random.uniform(0, 2 * 3.14) x = center[0] + r * math.sin(angle) y = center[1] + r * math.cos(angle) particles.append(HeartParticle([x, y]))逻辑说明:每一帧追加 15 个新粒子,生成位置用极坐标描述——先随机半径 r,再随机角度 angle,然后换算成屏幕坐标。radius 用的是正态分布而不是均匀分布,这意味着大多数粒子会落在中心附近,少数粒子离中心较远,视觉上更加自然。random.uniform(0, 2 * 3.14)覆盖完整圆周,粒子会从心形中心向 360 度方向散开。
参数说明:normalvariate(0, 10)的均值是 0,标准差是 10,乘 size 后半径分布范围基本在 ±30 像素以内。15 是每帧粒子数,这个值直接决定屏幕上同时存在的粒子总量,60 帧之后会达到一个动态平衡,数量大约在几百个的量级。如果机器配置低,可以把 15 改成 8,粒子密度下降但流畅度明显提升。
需要注意原始代码里粒子生成中心是[400, 300],而爱心中心画在[400, 325],y 方向差了 25 像素。实际上粒子是从爱心顶部上方一点的位置冒出来的,如果你希望粒子看起来是从心形内部涌出,把两处坐标统一即可。
4.2 生命周期与透明度渐隐
粒子的消失机制是寿命耗尽,这段逻辑在 HeartParticle 的 draw 方法中:
def draw(self, window): if not self.lifespan: return False alpha = int(float(self.lifespan) / 50 * 255) pygame.draw.circle(window, (255, 0, 0, alpha), [int(self.pos[0]), int(self.pos[1])], 6) return Truealpha的计算逻辑是:寿命从 50 减到 0,比例从 1 降到 0,乘 255 得到透明度值。设计意图是粒子越老越透明,最后淡出消失。这里有两个实际经验要提:第一,pygame.draw 在普通 Surface 上绘制时会忽略颜色元组里的 alpha 分量,所以这段代码里的 alpha 实际上不会生效,粒子直到寿命结束前都是纯红色,结束瞬间直接消失,没有渐隐效果;第二,如果你想真正实现渐隐,需要用带 SRCALPHA 的 Surface 或者用 set_alpha 处理,这个细节放到避坑章节展开。
粒子的绘制大小固定在半径 6 像素,这个值决定了单个粒子的视觉重量。调大到 8 会让爱心看起来更饱满,但粒子之间会快速重叠成一大片红色;调小到 3 会更细腻,接近星光效果,适合追求精致感的场景。
4.3 帧率、粒子数量和心跳效果
动画节奏由三个参数共同控制:帧率、每帧粒子数、粒子速度范围。下面是我实际调过的一组对照:
| 参数 | 默认值 | 调高效果 | 调低效果 |
|---|---|---|---|
| clock.tick 帧率 | 40 | 动作细腻,CPU 占用升高 | 粒子运动肉眼可见卡顿 |
| 每帧粒子数 | 15 | 粒子密度大,画面更满 | 粒子稀疏,心形区域空旷 |
| vel 随机范围 | ±35 / 10 | 粒子飞散速度快,轨迹长 | 粒子几乎原地蠕动 |
| lifespan | 10~50 | 粒子存活时间长,拖尾长 | 粒子一闪即逝 |
心跳效果是在不改变粒子系统的前提下,让心形轮廓的 size 随时间周期性缩放。实现方式很简单,用时间戳驱动一个正弦波:
import time while not done: beat = abs(math.sin(time.time() * 4)) * 0.15 + 0.85 screen.fill((255, 255, 255)) draw_heart(400, 325, 3 * beat) particles = draw_particles(screen, particles) append_particles(particles, [400, 300], 1) pygame.display.flip() clock.tick(40)逻辑说明:math.sin(time.time() * 4)产生的值在 -1 到 1 之间波动,取绝对值让波形不会出现负值,再乘 0.15 缩放、加 0.85 抬升,使 size 在 0.85 到 1.0 之间周期变化,看起来就像爱心在呼吸。乘 4 是心跳频率系数,数值越大心跳越快,我实测 4 到 5 之间观感比较自然,超过 8 会显得像抽搐。
5. 避坑指南:这份代码直接跑会遇到的五个实际问题
5.1 NameError: name 'math' is not defined
直接把原始代码保存运行,会在执行 append_particles 时抛出 NameError,因为代码里用了 math.sin 和 math.cos,文件头却只 import 了 pygame 和 random。
原因:项目引用了标准库 math,但没有导入。这类问题经常出现在从网页复制代码的场景,依赖导入行被漏掉。
解决:在文件头部补上import math。如果不想引入新依赖,也可以把 append_particles 里的math.sin(angle)换成random.uniform配合正弦近似,但完全没必要,math 是 Python 内置标准库,导入无成本。补上之后,函数里的2 * 3.14建议顺手改成2 * math.pi,精度更好。
5.2 粒子画出来了但窗口里看不到
运行后窗口只显示白色爱心轮廓,红色粒子完全看不到。
原因:主循环的绘制顺序错误。原始代码先执行draw_particles再执行screen.fill((255, 255, 255)),粒子画完之后被白色背景整个覆盖,等于白画。同时,draw_heart画轮廓用的颜色也是(255, 255, 255)白色,在白底上同样不可见。
解决:把绘制顺序调整为fill → 画爱心 → 画粒子 → 追加粒子 → flip,爱心轮廓颜色改成非白色,比如(255, 105, 180)粉色或(60, 60, 60)深灰。这个坑很隐蔽,因为它不报错,只是显示效果完全不对,新手排错时最容易忽略绘制顺序问题。
5.3 粒子在画面上一帧被绘制两次,轨迹变粗
原版 draw_particles 函数同时承担了两件事:绘制粒子和过滤死亡粒子。实现上把 draw 调用写了两遍:
def draw_particles(screen, particles): for p in particles: p.move() p.draw(screen) return [p for p in particles if p.draw(screen)]原因:循环里画了一遍,列表推导式里又画了一遍。存活粒子的圆会被绘制两次,同一位置叠加后颜色分量被重复写入,边缘发虚,粒子轨迹看起来比预期粗。
解决:把两件事合并成一次遍历:
def draw_particles(screen, particles): alive = [] for p in particles: p.move() if p.draw(screen): alive.append(p) return alive逻辑说明:每一帧只 move 一次、只 draw 一次,draw 返回 True 才保留到下一帧列表。这样既减少了重复绘制,又避免了粒子生命周期还没结束就被误删的情况。原始写法里,move 之后的粒子需要再次调用 draw 来判断是否存活,逻辑上是通的,但副作用是重绘。
5.4 粒子的 alpha 渐隐不生效,最后瞬间消失
预期效果是粒子慢慢变淡再消失,实际运行中粒子始终保持深红色,寿命结束直接消失。
原因:pygame.draw.circle 接受的 color 元组虽然可以是 RGBA 四元组,但在普通 RGB Surface 上绘制时 alpha 分量会被直接忽略,透明效果不生效。这是 pygame 绘制 API 的常见限制,不是代码逻辑的问题。
解决:如果临时想看到渐隐效果,最简单的方式是不用透明度,改用颜色深浅变化——把红色分量从 255 随寿命按比例调低,例如(255, 0, 0)变到(180, 0, 0),视觉上有一部分“变暗消失”的感觉。要真正实现半透明粒子,正确做法是创建一个带 SRCALPHA 标志的 Surface:
particle_surface = pygame.Surface((12, 12), pygame.SRCALPHA) particle_surface.set_alpha(alpha) pygame.draw.circle(particle_surface, (255, 0, 0), (6, 6), 6) window.blit(particle_surface, particle_pos)这种方式把每个粒子画到独立透明表面上,再用 set_alpha 控制整体不透明度,最后贴到主窗口。代价是每个粒子多一次 blit 操作,粒子数量多的时候帧率会下降。
5.5 运行几十秒后程序明显变卡
粒子系统跑了 30 到 60 秒后,画面帧率下降,CPU 占用升高。
原因:每帧追加 15 个粒子,如果 draw_particles 的过滤逻辑没修好,粒子只绘制不清理,列表无限增长。原版逻辑里 draw 返回 False 的粒子会被列表推导式过滤掉,但前面那层重复 draw 会让部分粒子多活一帧,加上生成速率较快,实际存活粒子数比预期高。另一个潜在因素是粒子列表里的死亡粒子如果未被正确移除,pygame.draw 每帧都要遍历大量不可见对象,CPU 开销线性上涨。
解决:先按 5.3 的方式修正过滤逻辑,确保死亡粒子当帧就被丢弃。更稳妥的做法是限制粒子总数:
def append_particles(particles, center, size): if len(particles) > 500: return for i in range(15): # 原有生成逻辑逻辑说明:粒子数超过 500 时直接跳过追加,配合每帧过滤,粒子数量会稳定在一个上界附近。500 这个值在 40fps 下基本感觉不到密度变化,如果追求更满的效果可以调到 800,但 CPU 占用会明显上升。这个限制对长期运行的展示型程序几乎是必需的。
6. 把爱心代码改造成可复用组件:参数化、多爱心与自定义配色
实际上跑通这份代码之后,最有价值的不是表白本身,而是把它变成能复用的特效函数。我的做法是把整个动画封装成一个带配置参数的类或函数,所有关键值都暴露为参数:
def heart_demo(center=(400, 325), heart_size=3, particle_color=(255, 0, 0), particle_per_frame=15, heart_beat=False): screen = pygame.display.set_mode((800, 600)) pygame.display.set_caption("Python Love") clock = pygame.time.Clock() particles = [] done = False while not done: for event in pygame.event.get(): if event.type == pygame.QUIT: done = True size = heart_size if heart_beat: size *= abs(math.sin(pygame.time.get_ticks() / 300)) * 0.15 + 0.85 screen.fill((255, 255, 255)) draw_heart(center[0], center[1], size, color=particle_color) particles = draw_particles(screen, particles) append_particles(particles, center, 1, particle_per_frame) pygame.display.flip() clock.tick(40)逻辑说明:这个封装把爱心中心、心形缩放、粒子颜色、每帧粒子数、是否启用心跳全部参数化。调用时只需要一行heart_demo(heart_beat=True)就能获得带呼吸效果的心形粒子动画,不再需要改主循环。
如果想同时显示多个爱心,可以直接建多个粒子列表,每一帧分别更新:
particles_a = [] particles_b = [] while not done: screen.fill((255, 255, 255)) draw_heart(250, 250, 2) draw_heart(550, 450, 2) particles_a = draw_particles(screen, particles_a) append_particles(particles_a, (250, 250), 1, 10) particles_b = draw_particles(screen, particles_b) append_particles(particles_b, (550, 450), 1, 10) pygame.display.flip()两个爱心各自维护独立的粒子列表,互不干扰,粒子颜色也可以分别指定。这里要注意粒子列表不能共用,否则过滤逻辑会把另一个爱心的粒子一并清除。
配色方面,粒子颜色和爱心轮廓颜色建议独立控制。原代码里轮廓和粒子都是红色,改颜色时最好把 draw_heart 里的 line 颜色也接收一个参数,否则粒子换成金色、轮廓还是红色会显得突兀。我一般会把轮廓设为比粒子略深的同色系,比如粒子用(255, 182, 193)浅粉,轮廓就用(255, 105, 180)粉红。
最后说一个我自己的习惯:从那次被绘制顺序坑过一次之后,我每写一个 pygame 动画,都会先固定检查三件事——fill 是否在所有绘制之前、粒子过滤是否在单次遍历中完成、draw 类 API 是否被重复调用。这三条过一遍,动画类代码 90% 的显示问题都能被前置拦截。这份爱心粒子代码是一个很好的练手样本,跑通之后建议你亲手把参数全改一遍,改完你就明白粒子系统调度的大致套路了,希望帮到你。
本文还有配套的精品资源,点击获取