1. 从“李峋同款”说起:爱心代码飘散效果到底是个什么东西
如果你最近刷到过《点燃我,温暖你》里李峋那段在屏幕上敲出爱心代码的片段,大概会注意到一个细节——他敲出来的爱心不是静止的,而是带着一种粒子飘散、缓缓上升的动效,配合背景的深色终端界面,视觉冲击力相当强。很多人第一次看到就想着自己动手复现一个,于是“爱心代码飘散效果”这个关键词就慢慢热了起来。
我最早接触这类效果是在做终端可视化的小工具时,当时想给一个命令行程序加一点“仪式感”,试过几种方案后发现,爱心代码飘散效果本质上是一个字符粒子系统:用字符(比如*、♥、.、+)作为基本粒子,给每个粒子赋予位置、速度、生命周期,再通过逐帧刷新把它们画在终端或画布上。听起来简单,但要做到“比李峋那个还好看”,就得在粒子分布、运动轨迹、颜色渐变、刷新节奏这几个维度上花心思。
这篇文章面向的读者很明确:会一点编程基础(Python、C 或 HTML/JS 任意一种都行),想做出一个能直接跑起来、能发给朋友看、能贴到社交平台的爱心飘散效果。我会把三种主流实现路径——Python 终端版、C 语言控制台版、HTML 网页版——都拆开讲清楚,包括参数怎么算、粒子怎么生成、飘散轨迹怎么设计,以及我在实际调试中踩过的坑。你不需要有图形学基础,跟着做就能复现。
先给一个整体判断:如果你只是想快速看到效果、方便复制粘贴分享,HTML 版最省事;如果你想在终端里跑、追求那种“黑客感”,Python 版最合适;如果你在学 C 语言、想练手控制台绘图,C 语言版最有教学价值。三条路线我都会给出可直接运行的代码和逐段解释。
2. 核心思路拆解:飘散效果背后的粒子系统逻辑
2.1 为什么是“粒子”而不是“画一个爱心”
很多人第一反应是:爱心不就是个形状吗,画出来不就行了?确实,静态爱心用数学公式就能画——经典的心形线参数方程x = 16sin³t,y = 13cos t - 5cos2t - 2cos3t - cos4t,在坐标系里描点连线就是一个标准爱心。但“飘散效果”的关键在于动,而且是大量小元素各自独立地动,这就不是一条曲线能解决的了。
粒子系统的思路是:把爱心看作一个“发射源”,在爱心轮廓或内部随机撒下几百个粒子,每个粒子有自己的初始位置、速度向量、生命值。每一帧里,粒子位置按速度更新,生命值递减,生命值归零就让它消失或重置。这样自然就形成了“从爱心上飘散出去、逐渐消散”的观感。这个思路和游戏里的火焰、烟雾、爆炸特效是同一套逻辑,只是把贴图换成了字符。
我试过直接对爱心曲线做缩放动画,效果很生硬,像在拉伸一张图;换成粒子之后,因为每个粒子的运动带一点随机性,整体就有了“呼吸感”。这也是为什么标题里强调“飘散”而不是“跳动”——跳动是整体位移,飘散是粒子级的离散运动。
2.2 三种实现路径的选型对比
在动手之前,先想清楚你要在哪个环境里跑。我把三条路线的关键差异列成表,方便你对号入座。
| 维度 | Python 终端版 | C 语言控制台版 | HTML 网页版 |
|---|---|---|---|
| 运行环境 | 任意带 Python 的终端 | 任意 C 编译器 | 任意现代浏览器 |
| 视觉效果 | 字符粒子,颜色可选 | 字符粒子,颜色受限 | 可做渐变、发光、3D |
| 上手难度 | 低 | 中 | 低 |
| 分享便利度 | 需对方装 Python | 需对方编译 | 发个文件就能看 |
| 性能上限 | 中 | 高 | 高 |
| 适合场景 | 终端仪式感、练手 | 学 C、理解底层 | 社交分享、展示 |
选型逻辑很简单:看你的最终用途。要发给不懂编程的朋友看,HTML 是唯一选择,双击就能打开;要在自己终端里跑着玩、顺便练 Python,那就 Python;如果是课程作业或者想搞懂字符刷新的底层原理,C 语言版能让你把printf和sleep用到极致。
提示:三条路线的核心算法是相通的,都是“粒子生成—位置更新—渲染—清理”四步循环。建议先吃透一套,再迁移到其他语言,会快很多。
2.3 飘散轨迹的数学设计:为什么有的效果“飘”得好看
飘散效果好不好看,八成取决于轨迹设计。我一开始用的是最简单的“向上匀速 + 左右随机抖动”,结果看起来像一群虫子在爬,很乱。后来调整成带阻尼的上升 + 正弦横向摆动 + 生命周期衰减,观感立刻不一样了。
具体来说,每个粒子的速度向量可以这样设计:
- 垂直方向:初始速度
vy为负值(向上),每帧加上一个小的重力加速度g,让上升逐渐减速,模拟“飘上去又慢慢落下”的感觉; - 水平方向:用一个正弦函数
sin(phase + t * freq)控制左右摆动,phase是每个粒子的随机初相,freq是摆动频率,这样粒子之间不会同步,看起来更自然; - 透明度/字符变化:粒子生命值从 1 衰减到 0,映射到字符上就是从
♥变成*再变成.最后消失,或者用颜色从亮到暗过渡。
这套参数不是拍脑袋定的。以垂直方向为例,如果初始速度太大,粒子一瞬间就飞出屏幕;太小又飘不起来。我的经验值是:终端高度约 30 行时,初始vy取-0.3到-0.6行/帧,重力g取0.02到0.05,这样粒子大约在 15 到 25 帧内完成一次完整的上升下落,节奏刚好。
3. Python 终端版:从零写出可复制的爱心飘散代码
3.1 环境准备与依赖选择
Python 版我推荐用标准库加一个轻量颜色库的组合。核心依赖只有两个:
random:生成随机粒子参数,标准库自带;time:控制帧率,标准库自带;colorama(可选):让终端支持彩色输出,Windows 下尤其需要。
安装 colorama 就一行:
pip install colorama如果你不想装任何第三方库,纯用标准库也能做,只是颜色只能用终端默认色,视觉上会朴素一些。我个人的建议是装上 colorama,因为它跨平台处理 ANSI 转义序列很省心,Windows 的 cmd 和 PowerShell 都能正常显示颜色。
关于终端尺寸,代码里需要动态获取,用shutil.get_terminal_size()就行,这样不管你在多大的窗口里跑,爱心都能自适应。这一点很重要,我见过不少示例代码把宽高写死成 80x24,换个窗口就错位了。
3.2 粒子类的设计与关键参数
我用一个简单的类来管理粒子,字段不多但每个都有用:
import random import math class Particle: def __init__(self, x, y): self.x = x self.y = y self.vx = random.uniform(-0.3, 0.3) self.vy = random.uniform(-0.6, -0.3) self.phase = random.uniform(0, 2 * math.pi) self.freq = random.uniform(0.1, 0.3) self.life = 1.0 self.decay = random.uniform(0.01, 0.03)逐个解释这些参数为什么这么取:
vx取-0.3到0.3:水平初始速度给一个小范围随机,让粒子一出生就有轻微散开,但不会横向飞太远;vy取-0.6到-0.3:负值代表向上,范围控制上升快慢的差异,避免所有粒子同步上升;phase和freq:正弦摆动的初相和频率,phase全范围随机保证不同步,freq取小值让摆动缓慢优雅;life和decay:生命值从 1 开始,每帧减去decay,decay随机让粒子消失时间错开,形成层次感。
这里有个细节:decay如果统一取固定值,所有粒子会在同一帧集体消失,看起来像“闪断”。加上随机后,粒子是逐渐稀疏的,观感柔和很多。这是我调试了三四版才定下来的。
3.3 爱心轮廓的生成与粒子撒点
爱心轮廓用参数方程生成,然后沿着轮廓撒粒子。参数方程前面提过,这里给出 Python 实现:
def heart_points(n, scale, cx, cy): points = [] for i in range(n): t = 2 * math.pi * i / n x = 16 * math.sin(t) ** 3 y = 13 * math.cos(t) - 5 * math.cos(2*t) - 2 * math.cos(3*t) - math.cos(4*t) points.append((cx + x * scale, cy - y * scale)) return pointsscale是缩放系数,终端字符是“高瘦”的(一个字符宽约是高的两倍),所以横向缩放要比纵向小一些,通常scale_x取scale_y的一半左右,爱心才不会被拉扁。cx、cy是爱心中心在终端里的坐标,一般取屏幕宽高的一半。
撒点的时候,我建议轮廓上密一点、内部稀一点。纯轮廓撒点看起来像个空心圈,内部加一些随机点才有“实心爱心在飘散”的感觉。我的做法是:70% 的粒子撒在轮廓附近(加一点随机偏移),30% 撒在爱心内部随机位置。
3.4 逐帧渲染与刷新技巧
渲染部分是最容易出问题的地方。终端里做动画,核心是光标归位 + 重绘,而不是清屏。清屏(os.system('cls')或clear)会闪得厉害,正确做法是用 ANSI 转义序列把光标移到左上角:
import sys def move_cursor_home(): sys.stdout.write('\033[H') sys.stdout.flush()每一帧的流程是:先把光标移到左上角,然后用一个二维字符缓冲区记录这一帧所有粒子的位置,最后一次性打印整个缓冲区。为什么要用缓冲区?因为如果每个粒子单独print,光标会乱跳,而且性能差。用一个list of list存字符,最后'\n'.join拼起来输出,效率高且不闪。
帧率控制用time.sleep(0.03)左右,大约 30 帧每秒。太快了终端刷新跟不上会撕裂,太慢了动画卡顿。这个值我在不同终端上试过,0.03 是比较稳的。
注意:Windows 的旧版 cmd 对 ANSI 转义支持不完整,如果发现光标没归位,先执行
colorama.init()或者用 Windows Terminal 跑。
3.5 完整可运行代码与逐段说明
把上面的部分拼起来,就是一份可以直接复制运行的代码。我把它整理成完整版,关键行都加了注释:
import random import math import time import sys import shutil try: from colorama import init, Fore init() COLORS = [Fore.RED, Fore.LIGHTRED_EX, Fore.MAGENTA, Fore.LIGHTMAGENTA_EX] except ImportError: COLORS = [''] class Particle: def __init__(self, x, y): self.x = x self.y = y self.vx = random.uniform(-0.3, 0.3) self.vy = random.uniform(-0.6, -0.3) self.phase = random.uniform(0, 2 * math.pi) self.freq = random.uniform(0.1, 0.3) self.life = 1.0 self.decay = random.uniform(0.01, 0.03) self.color = random.choice(COLORS) def update(self): self.vy += 0.02 self.x += self.vx + math.sin(self.phase + self.life * 10) * 0.1 self.y += self.vy self.life -= self.decay def heart_points(n, scale, cx, cy): points = [] for i in range(n): t = 2 * math.pi * i / n x = 16 * math.sin(t) ** 3 y = 13 * math.cos(t) - 5 * math.cos(2*t) - 2 * math.cos(3*t) - math.cos(4*t) points.append((cx + x * scale, cy - y * scale * 0.5)) return points def main(): size = shutil.get_terminal_size() W, H = size.columns, size.lines cx, cy = W // 2, H // 2 scale = min(W / 40, H / 20) particles = [] for (px, py) in heart_points(120, scale, cx, cy): for _ in range(2): particles.append(Particle(px + random.uniform(-1, 1), py + random.uniform(-1, 1))) for _ in range(80): t = random.uniform(0, 2 * math.pi) x = 16 * math.sin(t) ** 3 * random.uniform(0, 1) y = (13 * math.cos(t) - 5 * math.cos(2*t) - 2 * math.cos(3*t) - math.cos(4*t)) * random.uniform(0, 1) particles.append(Particle(cx + x * scale, cy - y * scale * 0.5)) sys.stdout.write('\033[2J') while True: buf = [[' ' for _ in range(W)] for _ in range(H)] for p in particles: ix, iy = int(p.x), int(p.y) if 0 <= ix < W and 0 <= iy < H and p.life > 0: ch = '♥' if p.life > 0.7 else ('*' if p.life > 0.4 else '.') buf[iy][ix] = ch sys.stdout.write('\033[H') for row in buf: sys.stdout.write(''.join(row) + '\n') sys.stdout.flush() for p in particles: p.update() particles = [p for p in particles if p.life > 0] while len(particles) < 300: t = random.uniform(0, 2 * math.pi) x = 16 * math.sin(t) ** 3 * random.uniform(0, 1) y = (13 * math.cos(t) - 5 * math.cos(2*t) - 2 * math.cos(3*t) - math.cos(4*t)) * random.uniform(0, 1) particles.append(Particle(cx + x * scale, cy - y * scale * 0.5)) time.sleep(0.03) if __name__ == '__main__': main()这段代码里有两个设计点值得单独说。第一,粒子消失后不是简单丢弃,而是补充新粒子,这样爱心会持续“发射”,形成源源不断的飘散感,而不是飘完就没了。第二,字符随生命值变化(♥→*→.),模拟淡出,比直接消失自然得多。
4. C 语言控制台版:理解字符动画的底层逻辑
4.1 为什么用 C 语言再写一遍
有人会问,Python 版都跑通了,为什么还要折腾 C?我的理由是:C 语言版能让你真正理解“帧”是什么。Python 里time.sleep和列表操作把很多细节藏起来了,而 C 里你得自己管数组、自己算坐标、自己控制输出缓冲,做完一遍,你对字符动画的理解会上一个台阶。而且 C 版性能好,粒子数可以开到上千,效果更密。
C 版的核心难点在于:没有现成的动态数组,得用固定大小的数组;没有shutil获取终端尺寸,得用ioctl或者干脆写死一个合理值;颜色输出要用 ANSI 转义码手动拼。这些听起来麻烦,但都是很实在的底层技能。
4.2 粒子数组与坐标映射
C 里我用一个结构体数组存粒子,大小开到 2000 足够:
#include <stdio.h> #include <math.h> #include <stdlib.h> #include <unistd.h> #include <time.h> #define WIDTH 80 #define HEIGHT 30 #define MAX_PARTICLES 2000 typedef struct { double x, y; double vx, vy; double phase; double life; double decay; } Particle; Particle particles[MAX_PARTICLES]; int particle_count = 0;坐标映射要注意:终端字符格子的宽高比不是 1:1,所以爱心参数方程算出来的y要乘一个系数(我取 0.5)再映射到行号,否则爱心会被拉长。这个系数和 Python 版里的scale * 0.5是一个道理。
4.3 帧缓冲与光标控制
C 里做无闪烁刷新,同样要用帧缓冲。我开一个char buffer[HEIGHT][WIDTH+1],每帧先全部填空格,再把粒子画进去,最后一次性printf出来。光标归位用\033[H,和 Python 版一致。
void render() { char buffer[HEIGHT][WIDTH + 1]; for (int y = 0; y < HEIGHT; y++) { for (int x = 0; x < WIDTH; x++) buffer[y][x] = ' '; buffer[y][WIDTH] = '\0'; } for (int i = 0; i < particle_count; i++) { int ix = (int)particles[i].x; int iy = (int)particles[i].y; if (ix >= 0 && ix < WIDTH && iy >= 0 && iy < HEIGHT && particles[i].life > 0) { char ch = particles[i].life > 0.7 ? '@' : (particles[i].life > 0.4 ? '*' : '.'); buffer[iy][ix] = ch; } } printf("\033[H"); for (int y = 0; y < HEIGHT; y++) printf("%s\n", buffer[y]); fflush(stdout); }这里用@、*、.三个字符表示不同生命阶段,因为 C 控制台输出 Unicode 爱心字符在不同系统上兼容性不一,用 ASCII 字符最稳。如果你确定环境支持 UTF-8,把@换成♥的字节序列也行,但我不建议,容易乱码。
4.4 编译运行与性能调优
编译命令很简单:
gcc heart.c -o heart -lm ./heart-lm是链接数学库,因为用了sin、cos。运行前把终端窗口调到 80x30 左右,效果最好。
性能调优方面,C 版可以轻松跑到 60 帧每秒(usleep(16000)),粒子数开到 1500 也不卡。但要注意,printf每帧输出 30 行字符串,如果终端本身刷新慢,还是会闪。我的经验是:减少每帧的输出量比提高帧率更重要。可以把帧率降到 25 左右,反而更稳。
注意:
usleep在部分新标准里被标记为过时,如果编译报错,换成nanosleep或者加-D_DEFAULT_SOURCE宏。
5. HTML 网页版:最容易分享的爱心飘散实现
5.1 Canvas 还是 DOM:选型分析
HTML 版有两条路:用 Canvas 画,或者用 DOM 元素堆。我强烈推荐Canvas,原因有三:一是性能好,几百上千个粒子用 Canvas 的fillRect或fillText画毫无压力;二是控制精细,每个粒子的位置、透明度、颜色都能单独设;三是代码集中,不用管一堆 div 的样式。
DOM 方案虽然写起来直观(每个粒子一个 span),但粒子一多,浏览器的重排重绘开销就上来了,而且做透明度渐变很麻烦。我早期用 DOM 做过一版,200 个粒子就开始卡,换成 Canvas 后 1000 个粒子依然流畅。
5.2 粒子生成与 requestAnimationFrame 循环
HTML 版的核心循环用requestAnimationFrame,它比setInterval更贴合浏览器刷新节奏,动画更顺滑。粒子生成逻辑和前面一致,只是渲染换成 Canvas API:
const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = window.innerHeight; const particles = []; function heartPoint(t) { const x = 16 * Math.pow(Math.sin(t), 3); const y = 13 * Math.cos(t) - 5 * Math.cos(2*t) - 2 * Math.cos(3*t) - Math.cos(4*t); return { x, y }; } function spawn() { const t = Math.random() * Math.PI * 2; const p = heartPoint(t); const scale = Math.min(canvas.width / 40, canvas.height / 40); const cx = canvas.width / 2; const cy = canvas.height / 2; const r = Math.random(); particles.push({ x: cx + p.x * scale * r, y: cy - p.y * scale * r, vx: (Math.random() - 0.5) * 0.6, vy: -Math.random() * 0.8 - 0.2, life: 1, decay: 0.005 + Math.random() * 0.01, size: 1 + Math.random() * 2, hue: 330 + Math.random() * 30 }); }hue取 330 到 360 是红色到粉色的范围,配合hsla颜色模式,能做出很漂亮的渐变。r是随机半径系数,让粒子在爱心内部也有分布,不是只有轮廓。
5.3 颜色渐变与发光效果的实现
Canvas 里做发光,最简单的方式是用shadowBlur和shadowColor:
ctx.shadowBlur = 8; ctx.shadowColor = `hsla(${p.hue}, 100%, 60%, ${p.life})`; ctx.fillStyle = `hsla(${p.hue}, 100%, 70%, ${p.life})`; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();shadowBlur会让每个粒子周围有一圈光晕,整体看起来就像在发光。这个属性有性能开销,粒子数控制在 500 以内比较稳。如果嫌卡,可以把shadowBlur去掉,改用两层圆叠加(外层大而透明,内层小而亮)来模拟发光,性能更好。
5.4 移动端适配与分享注意事项
HTML 版最大的优势是分享方便,但要注意移动端适配。canvas.width和height要跟随窗口变化,监听resize事件重新设置。另外,移动端浏览器的requestAnimationFrame在页面不可见时会暂停,这是好事,省电。
分享的时候,把 HTML、CSS、JS 写在一个文件里最省事,对方双击就能打开。如果想让爱心上带字(比如“某某某”),可以在 Canvas 上用fillText在爱心中心画一行文字,字体选粗体、颜色用白色带阴影,效果不错。
提示:微信内置浏览器对
shadowBlur支持良好,但部分旧版安卓机可能掉帧,建议把粒子数降到 300 左右。
6. 常见问题与排查技巧实录
6.1 终端版闪屏、错位、乱码怎么办
闪屏是终端动画最常见的问题,九成是因为用了清屏而不是光标归位。检查你的代码里有没有os.system('clear')或cls,有的话换成\033[H。如果还是闪,看看是不是每帧输出太慢,把帧率降一点试试。
错位通常是坐标计算的问题。终端字符宽高比不是 1:1,爱心纵向要乘 0.5 左右的系数。另外,shutil.get_terminal_size()返回的lines可能比实际可显示行数少一行,因为最后一行打印换行会滚动,建议H = size.lines - 1。
乱码基本是字符编码问题。Windows 终端默认可能是 GBK,输出 Unicode 爱心字符会乱。解决办法是用colorama.init(),或者干脆用 ASCII 字符。我在 Windows 上测试时,用 Windows Terminal 跑 UTF-8 最稳。
6.2 粒子消失太快或飘不起来的参数调整
粒子消失太快,调小decay;飘不起来,调大vy的绝对值(更负);飘得太高飞出屏幕,调小vy或者加大重力g。这几个参数是联动的,我建议一次只调一个,观察效果。
有个经验值可以参考:终端高度 30 行时,vy初始-0.5、g取0.02、decay取0.015,粒子大约 20 帧完成一次生命周期,节奏比较舒服。你可以在这个基础上微调。
6.3 网页版卡顿与性能优化
网页版卡顿,先看粒子数。超过 800 个还开shadowBlur,中低端机必卡。优化顺序是:先减粒子数,再关shadowBlur,最后考虑用离屏 Canvas 预渲染粒子贴图。离屏 Canvas 的思路是先把一个发光圆画到小 Canvas 上,主循环里用drawImage贴上去,比每次arc + fill快很多。
另一个容易忽略的点是clearRect。每帧要清空画布,但不要用fillRect填背景色,那样会覆盖掉透明效果。用clearRect(0, 0, w, h)最干净。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 终端闪屏 | 用了清屏命令 | 改用\033[H光标归位 |
| 爱心被拉长 | 宽高比未校正 | 纵向乘 0.5 系数 |
| 字符乱码 | 编码不匹配 | 用 colorama 或改 ASCII |
| 粒子集体消失 | decay 固定 | decay 加随机 |
| 网页卡顿 | 粒子过多/发光开销 | 减粒子、关 shadowBlur |
| 移动端错位 | 未监听 resize | 重新设置 canvas 尺寸 |
| 飘散方向乱 | vx 范围过大 | 收窄 vx 到 ±0.3 |
7. 让效果更高级的几个进阶思路
7.1 加入文字与爱心结合
纯爱心看久了会腻,加一行字立刻不一样。做法是在爱心中心位置用fillText画文字,字体选bold,颜色用白色或浅粉,加一点shadowBlur让它和粒子融合。文字不要太大,占爱心宽度的三分之一左右比较协调。如果想做“文字从爱心里浮现”的效果,可以让文字的透明度随帧数从 0 渐变到 1。
7.2 3D 旋转与透视的简化实现
3D 爱心听起来复杂,其实有个取巧的办法:给每个粒子的x坐标乘一个随时间变化的cos(angle),模拟绕 Y 轴旋转,同时根据sin(angle)调整粒子的亮度和大小,近大远小。这样不用真的建 3D 坐标系,也能做出旋转的观感。我试过这个方案,效果比纯 2D 惊艳不少,代码量只多了十几行。
7.3 多种粒子形状混合
只用一种字符或圆点,视觉上比较单调。可以混合几种:大部分是圆点,少量是爱心字符,再少量是星形。不同形状的粒子用不同的颜色和速度,整体层次感会强很多。这个思路在游戏特效里很常见,叫“粒子多样性”,是提升观感性价比最高的手段之一。
7.4 参数调优的个人经验
最后分享几个我调参时总结的经验。第一,随机不等于均匀,random.uniform是均匀分布,但自然效果往往需要高斯分布,比如粒子速度用random.gauss(0, 0.2)会比均匀分布更自然。第二,颜色不要超过三种色相,红、粉、紫这个组合最稳,加太多颜色会显得廉价。第三,帧率不是越高越好,25 到 30 帧的“轻微卡顿感”反而更有复古终端味,60 帧太顺滑了反而少了那种味道。
我在实际使用中发现,把粒子的生命周期和字符变化绑定(♥→*→.)比单纯用透明度淡出更有“字符动画”的特色,这也是终端版比网页版更有味道的地方。如果你要做的是发到社交平台的效果,网页版的渐变发光更讨喜;如果是自己终端里跑着玩,字符变化那套更有感觉。两条路都走一遍,你就知道哪种更适合你的场景了。