☰
HTML5 Canvas粒子流动爱心动画实战:从粒子系统到交互复用
2026/9/26 12:10:08 网站建设 项目流程

简介:面向需要提升Canvas动画开发能力的Web前端开发者,这是一份基于HTML5 Canvas实现粒子流动爱心形状动画的完整代码示例。内容围绕粒子系统展开,深入演示了Canvas API中路径绘制函数的使用,如开始路径、移动坐标、连接线段等,以及利用填充颜色绘制爱心轮廓的关键步骤。同时通过粒子对象的位置、速度、透明度等属性管理,配合请求动画帧机制持续刷新画面,形成从屏幕一端随机出现并向爱心形状流动、逐渐消散的动态视觉效果。

压缩包共包含四个文件,其中两个JavaScript文件分别负责粒子类定义、粒子数组初始化、状态更新和绘制循环,一个CSS文件用于设置画布尺寸、页面背景与整体布局,另一个HTML文件作为入口结构。资源包整体仅六KB,轻量且便于阅读。目前已有六千三百四十七人学习下载。研读源码可完整掌握粒子系统从定义、初始化到逐帧更新与渲染的流程,并理解如何用CSS与Canvas协同增强页面表现力,适合前端初学者夯实基础,也可作为交互创意特效的参考。

1. 散点聚成心形:HTML5 Canvas 粒子流动爱心动画到底解决什么问题

产品落地页的开场动画、个人主页的动态背景,或者一张想发给特别的人的告白卡片:满屏细碎粒子先无序漂浮,两三秒后像被磁铁吸住一样聚成一颗心形,随后又在轮廓上缓缓流动。这个标题所指向的东西——HTML5 Canvas 粒子流动爱心形状动画特效——就是用浏览器原生 Canvas 2D API 写出来的粒子动画,不依赖任何第三方库,一个 HTML 文件加一段 JavaScript 就能跑起来。它非常适合做网页粒子动态背景、加载动画、H5 小游戏开场屏这类场景,对前端初学者最大的价值是能把 canvas 绘图基础、坐标系换算、动画工作流一次摸透,对熟手则是做廉价的视觉氛围渲染。下面我按自己落地这类特效的顺序,把从零到可复用的完整链路讲清楚。

2. 从零搭粒子系统:Canvas 基础与动画主循环的两种写法

任何 Canvas 粒子动画拆开看都是同一副骨架:清屏、更新粒子坐标、把粒子画到画布上,然后不断重复这三件事。先把这个骨架跑通,再去想爱心形状,是这类特效最常见的落地路径。如果你一上来就套用网上的完整工程,后边调整参数时会分不清“哪个变量在影响哪个现象”,那才是真正的黑匣子。

2.1 一个能跑起来的最小粒子系统:canvas 绘图与动画主循环

先写一个无差别漂浮的粒子demo。这个文件里包含 canvas 初始化、粒子对象、边界反弹和主循环,是后续所有功能的地基:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>粒子流动爱心</title> <style> html, body { margin: 0; height: 100%; overflow: hidden; background: #0a0a1a; } canvas { display: block; width: 100vw; height: 100vh; } </style> </head> <body> <canvas id="canvas"></canvas> <script> const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); let width, height; let particles = []; const PARTICLE_COUNT = 300; function resize() { // 按设备像素比换算,避免高分屏模糊,稍后单独说明 const dpr = window.devicePixelRatio || 1; width = window.innerWidth; height = window.innerHeight; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } window.addEventListener('resize', resize); resize(); class Particle { constructor() { this.x = Math.random() * width; this.y = Math.random() * height; this.vx = (Math.random() - 0.5) * 0.6; // 初始随机速度 this.vy = (Math.random() - 0.5) * 0.6; this.size = Math.random() * 3 + 1; // 粒子半径 1~4 } update() { this.x += this.vx; this.y += this.vy; // 边界回弹,暂时替代后续的目标点约束 if (this.x < 0 || this.x > width) this.vx *= -1; if (this.y < 0 || this.y > height) this.vy *= -1; } draw() { ctx.beginPath(); ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); ctx.fillStyle = '#ff6b9d'; ctx.fill(); } } for (let i = 0; i < PARTICLE_COUNT; i++) { particles.push(new Particle()); } function tick() { // 清屏:用不透明色覆盖整块画布,避免上一帧残留 ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, width, height); for (const p of particles) { p.update(); p.draw(); } requestAnimationFrame(tick); } tick(); </script> </body> </html>

这段代码的逻辑很直接:resize 里根据窗口大小重置画布物理尺寸,Particle 的 update 负责位移,draw 用 arc + fill 画出圆点,tick 里清屏再逐粒子绘制。关键参数是 PARTICLE_COUNT 和速度初始值 0.6:粒子数决定画面密度,速度决定漂浮的轻快感。速度低于 0.2 时画面近乎静止,高于 1.5 时会像受惊的鱼群,这两个边界值常见于所有粒子动画。

提示:清屏时如果改用 rgba(10,10,26,0.2) 这样的半透明色,会留下运动拖影,效果很迷幻,但不是这个标题要的“流动爱心”,我在后文不会展开这条支线。

2.2 canvas 高分屏适配:devicePixelRatio 是第一道门槛

很多新手把 canvas.width 直接设置成 window.innerWidth,在普通笔记本上看着没事,换到 Retina 屏或手机上一看,边缘发虚、粒子像隔着一层毛玻璃。原因是 CSS 像素和物理像素在设备像素比(devicePixelRatio,简称 dpr)大于 1 的屏幕上不是一回事。canvas 的 width/height 属性是物理像素,style.width/height 是逻辑像素,两者必须分开设置。

上面的 resize 函数里已经处理了这件事:canvas.width = width * dpr让绘图缓冲区跟着物理像素走,canvas.style.width = width + 'px'让元素在页面上占据逻辑尺寸,最后的ctx.setTransform(dpr, 0, 0, dpr, 0, 0)把坐标系整体放大,这样后续所有绘图代码仍按逻辑像素(内宽高)书写,不需要在 draw 里到处乘 dpr。这一步不做,后面做爱心形状坐标时,所有位置都会偏,且不同屏幕上偏移量不一致,排查起来非常折磨人。

2.3 驱动动画的循环:为什么选 requestAnimationFrame 而非 setInterval

setInterval(fn, 16) 是新手最容易写出的驱动方式,但它有两个原生缺陷:一是浏览器切到后台标签页时 setInterval 仍在跑,占用 CPU;二是它不感知显示器刷新率,在 120Hz 屏幕上会掉帧或撕裂。requestAnimationFrame(简称 rAF)是浏览器专为动画设计的主循环 API,它会自动匹配显示器刷新率,并且在标签页不可见时暂停调用,省电也省心。

不过 rAF 有一个新手不知道的参数细节:回调函数能收到一个 timestamp,表示当前帧相对页面加载的毫秒时间。拿它做帧间隔计算,才能让动画速度在不同刷新率屏幕上一致。改进后的主循环骨架是:

let lastTime = 0; let rafId = null; function tick(timestamp) { // 钳制最大帧间隔为 50ms,防止后台切回时粒子瞬移 const delta = Math.min(50, timestamp - lastTime || 16); lastTime = timestamp; for (const p of particles) p.update(); draw(); rafId = requestAnimationFrame(tick); } rafId = requestAnimationFrame(tick);

这里把timestamp - lastTime钳制到 50ms 是血泪经验:如果用户切到其他标签页再回来,rAF 恢复后的第一帧 timestamp 与 lastTime 之间可能隔了几秒,若不钳制,粒子会以几百毫秒的跨度做一次位移,直接飞出画布。后续加爱心目标点后,这个问题会表现为“爱心爆开”,后面第 5 章还要再提一次。

3. 把数学公式画成爱心:参数方程、坐标映射与目标点分配

粒子系统能跑了,下一步是决定“粒子要聚成什么形状”。爱心是最适合做演示的形状,因为它的参数方程足够简单,生成一组坐标点 20 行代码就能搞定。这一章的关键是把数学坐标系里的爱心,无损地搬进屏幕坐标系。

3.1 心形曲线的选择:极坐标心形与经典参数方程的差别

网上最常见的两个心形公式:一个是极坐标形式r = a(1 - cosθ),θ 从 0 到 2π;另一个是参数方程形式x = 16sin³(t),y = 13cos(t) - 5cos(2t) - 2cos(3t) - cos(4t)。我一般用后者,因为它生成的心形轮廓更饱满,顶部凹陷和底部尖角的位置关系更接近手绘爱心,而极坐标心形偏瘦长,在屏幕上远看更像水滴。

参数方程采样同样简单:把 t 从 0 到 2π 等分成 N 份,逐个代入即可。代码里我用-y是提前做了 y 轴翻转,原因下一节讲:

function generateHeartPoints(count) { const points = []; const step = (Math.PI * 2) / count; for (let i = 0; i < count; i++) { const t = i * step; const x = 16 * Math.pow(Math.sin(t), 3); // x 范围约 -16 ~ 16 const y = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t); // y 范围约 -17 ~ 15 points.push({ x: x, y: -y }); // 翻转 y,让爱心在屏幕上正立 } return points; }

注意这里我用的是等步长 t,不是等弧长。这意味着心形曲率大的地方(比如底部尖角)采样点更密集,画出来的粒子会在这几处略密一些。很多教程会专门做弧长重采样去消除这个不均匀,但我的经验是:在流动动画里,这个密度差反而强化了心形轮廓的“笔锋感”,而且实现成本低得多。如果你需要均匀分布,可以再做一次累加弧长插值,那就是另一套逻辑了。

3.2 从数学坐标到屏幕坐标:y 轴翻转与自适应缩放

数学坐标系的原点在中心、y 轴向上;canvas 坐标系的原点在左上角、y 轴向下。这两个差异不处理,爱心会上下颠倒、偏到画布右下角。处理分三步:翻转 y、缩放、平移。

翻转 y 已经在生成点时做了,接下来做缩放和平移。心形参数方程的范围大约在 x:[-16, 16],y:[-17, 15],整体宽度约 32 个单位。我一般取画布短边的 1/50 作为缩放系数,再平移到画布中心:

function mapToScreen(heartPoints, centerX, centerY, scaleFactor) { return heartPoints.map(p => ({ x: centerX + p.x * scaleFactor, y: centerY + p.y * scaleFactor })); } // 实际调用 const scaleFactor = Math.min(width, height) / 50; // 心形约占短边 60% 面积 const targets = mapToScreen(heartPoints, width / 2, height / 2, scaleFactor);

Math.min(width, height) / 50这个经验系数,画出来的心形在竖屏和横屏下都保持合适占比。想变大就除以 40,想变小就除以 60。这一行是整段动画里最值得调的参数,因为它直接决定爱心在页面上的视觉权重。调完之后注意验证一件事:屏幕正中心的坐标必须和width/2, height/2对齐,否则后续 resize 时会对着偏了的目标点更容易出问题。

3.3 目标点分配策略:顺序绑定会让成形动画“穿模”

粒子有了,目标点有了,接下来把两者绑定。最直观的做法是particle[i]对应targets[i],但这会让成形过程出现严重的交叉轨迹:左上角的粒子飞向右下角的目标点,右上角的粒子飞向左下角,中间地带全是穿模的线。解决办法是先把目标点数组洗牌,再按顺序绑定:

function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; // Fisher-Yates 洗牌 } return arr; } // 粒子初始化时 const shuffledTargets = shuffle(targets.slice()); particles.forEach((p, i) => { p.targetX = shuffledTargets[i].x; p.targetY = shuffledTargets[i].y; });

洗牌之后,每个粒子的目标点变成随机分配,飞行路径在视觉上互相交错,反而形成了“流动”的初步观感。如果想进一步减少交叉,可以改用最近邻贪婪配对(每个粒子找最近且未被占用的目标点),但那样会损失随机性,成形后的流动感会发僵。我的习惯是默认洗牌,只有在粒子数量少于 200 且边缘抖动明显时才考虑最近邻。

4. 流动感从哪来:三段式状态机与缓动参数调优

爱心成形只是底线要求,“流动”才是这个标题打动人的部分。流动感不是粒子简单地从一个点线性移动到另一个点,而是先散乱、再收束、最后在轮廓上持续呼吸。这需要一个简单的状态机来控制粒子的运动模式,也需要一套弹簧阻尼参数来制造柔顺的速度变化。

4.1 三段式状态机:漂浮、聚形、环绕的切换逻辑

状态机是这类动画最常见的组织方式,三个状态分别是:FLOAT(初始漂浮,约 2 秒)、FORM(向目标点聚形,约 3 秒)、ORBIT(在目标点附近环绕流动,无限循环)。我用一个状态变量和一个进入时间记录来管理切换:

const STATE = { FLOAT: 'float', FORM: 'form', ORBIT: 'orbit' }; let state = STATE.FLOAT; let stateTime = 0; let rafId = null; let lastTime = 0; const PHASE_DURATION = { FLOAT: 2200, // 漂浮阶段时长 ms FORM: 3200, // 聚形阶段时长 ms }; function switchState(next, now) { state = next; stateTime = now; if (next === STATE.FORM) { // 重新洗牌并绑定目标点,保证每次聚形路径不同 const shuffled = shuffle(targets.slice()); particles.forEach((p, i) => { p.targetX = shuffled[i].x; p.targetY = shuffled[i].y; }); } }

在 tick 主循环里,每帧根据timestamp - stateTime判断是否切换:FLOAT 走满 2200ms 就切 FORM,FORM 走满 3200ms 就切 ORBIT。这个时间配比是我常用的:漂浮太短,用户还没建立“这是一片粒子海洋”的预期;聚形太快,收束的视觉冲击力会被削弱。如果你做的是加载页,漂浮可以压缩到 600ms,让爱心更快出现。

注意:switchState 里的洗牌只应在进入 FORM 时执行一次,不能在每帧 update 里重复执行,否则粒子永远追不上一个不断变化的目标点。

4.2 让粒子柔软地飞向目标:弹簧阻尼模型而非直接插值

新手最容易写的聚形逻辑是p.x += (p.targetX - p.x) * 0.05,也就是每帧往目标点方向移动 5%。这种线性插值的问题在于速度曲线是前快后慢的指数衰减,视觉上像“被拽过去”而不是“飘过去”,并且粒子到达目标点后仍会有微小抖动残留,停不干净。

我一般用弹簧阻尼模型:加速度指向目标点,速度施加阻尼系数,系统自然衰减。核心代码:

function updateParticle(p, dt, state, now) { if (state === STATE.FLOAT) { // 漂浮:给随机扰动,并限制最大速度 p.vx += (Math.random() - 0.5) * 0.05; p.vy += (Math.random() - 0.5) * 0.05; capSpeed(p, 1.2); } else if (state === STATE.FORM) { // 聚形:向目标点加速,速度越大接近越快 const dx = p.targetX - p.x; const dy = p.targetY - p.y; p.vx += dx * 0.016; // 弹性系数,控制加速度 p.vy += dy * 0.016; p.vx *= 0.92; // 阻尼系数,消耗速度 p.vy *= 0.92; capSpeed(p, 4); // 距离小于阈值则吸附到目标点,消除残抖 if (dx * dx + dy * dy < 0.5) { p.x = p.targetX; p.y = p.targetY; p.vx = 0; p.vy = 0; } } else if (state === STATE.ORBIT) { // 环绕:以目标点为中心做低频正弦扰动,制造“呼吸感” const dx = p.targetX - p.x; const dy = p.targetY - p.y; p.vx += dx * 0.012; p.vy += dy * 0.012; p.vx += Math.cos(now * 0.001 + p.phaseOffset) * 0.12; p.vy += Math.sin(now * 0.001 + p.phaseOffset) * 0.12; p.vx *= 0.94; p.vy *= 0.94; capSpeed(p, 1.6); } p.x += p.vx * dt * 0.06; p.y += p.vy * dt * 0.06; } function capSpeed(p, maxSpeed) { const speed = Math.hypot(p.vx, p.vy); if (speed > maxSpeed) { p.vx = (p.vx / speed) * maxSpeed; p.vy = (p.vy / speed) * maxSpeed; } }

这里 dt 来自前面第 2 章的 rAF timeStamp 差值,乘dt * 0.06是为了把时间单位换算成模拟步长。弹簧系数 0.016 决定“吸力”大小,阻尼 0.92 决定速度记忆长短。理解这两个参数,就等于理解了整套粒子流动的手感:系数越大,粒子越快地冲向目标,但过冲也越明显;阻尼越小,粒子越容易在目标点附近来回摆动。吸附阈值 0.5(像素距离平方)是我试过很多次的经验值,小于这个距离直接锁定,动画肉眼看不到任何突跳。

4.3 流动感的调参表:把玄学变成可见的参数调节

很多初次接触粒子动画的开发者调“流动感”全靠试,一天调出一版,第二天又觉得不对。我把常用参数整理成一张表,改动画时照着表调,能少走大半年弯路:

参数建议范围作用调大后的观感
粒子数量240 ~ 800画面密度与性能越密越像烟雾,越稀疏越像星点
弹性系数0.010 ~ 0.020聚形阶段的加速度太大则抽搐,太小则拖沓
阻尼系数0.90 ~ 0.96速度的衰减速度越大越飘逸,越小越粘滞
最大速度2 ~ 4(聚形阶段)防止粒子飞过头限制过冲幅度
吸附阈值0.25 ~ 1.0到达目标点的判定距离越小收尾越干净
扰动幅度0.08 ~ 0.15环绕阶段的呼吸强度越大轮廓越松散

调参顺序也很重要:先定粒子数量和心形大小,再调阻尼让粒子飞行动线符合预期,最后加弹性系数微调收束速度。顺序反了会陷入改一个参数坏两个效果的循环。开发时我习惯直接把这三个关键参数放进 URL 查询串,比如?count=600&damping=0.92&force=0.016,页面加载时读一遍,秒改秒见效,不用反复刷编辑器。

5. 粒子动画常见问题排查:白屏、爱心偏位与帧率崩掉的 5 个原因

到了这章,全是实打实踩过的坑。每个问题我都按现象、原因、解决三步写,方便你直接对着查。这些坑单独看都不难,但组合在一起时,排查顺序错了会让人怀疑是自己的数学有问题。

5.1 打开页面白屏或黑屏:canvas 尺寸为 0 与 dpr 适配缺失

现象:页面打开后一片空白,控制台无报错,或者只看到背景色看不到粒子。原因排查有两个位置:一是 canvas 的 width/height 被设置成 0,常见于把 resize 逻辑放在 DOM 结构加载之前执行,拿到的是 0 宽高;二是只设置了 canvas.width 而没有同步设置 style.width,导致画布在页面上占据 0 高度的空间。解决方法是把 resize 逻辑封装,并在页面加载完成后先调用一次,同时在 resize 里同时设置物理尺寸和 CSS 尺寸:

function resize() { const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); // 用实际占位尺寸兜底 canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }

如果你用 CSS 把 canvas 设成width: 100vw; height: 100vh,getBoundingClientRect 拿到的就是视口真实尺寸,比依赖 window.innerWidth 更不容易出错。我现在的写法是 resize 里 display 属性检查加一层,canvas 确定非 none 再去取值,这个习惯堵住了大量隐藏 bug。

5.2 爱心偏出画面中心:y 轴翻转与 center 坐标的不同步

现象:粒子确实聚成了爱心,但爱心整体偏向画布右下角,或者上下颠倒。原因是第 3 章的坐标映射里只做了缩放和平移,漏了 y 轴翻转;或者翻转了但中心点用的是width/2, height/2,却忘了一开始生成 heartPoints 时还在用数学坐标的原始值。解决时先确认生成点的代码里 y 已经取负,再确认 mapToScreen 的 centerX 和 centerY 与画布中心一致。一个快速自检方法是把scaleFactor临时设为 1,在控制台输出 points 数组,x 的最大值和最小值应该关于 0 对称,y 同理。不对称,说明参数方程写错了;对称但显示偏,说明翻转或平移有问题。

5.3 聚形完成后粒子仍在“抽搐”:弹簧过冲与目标点分配失误

现象:爱心已经成形,但不少粒子在目标点附近小幅抖动,像筛子一样停不下来;严重的会看到粒子在两三个目标点之间来回弹。第一原因是第 4.2 节的弹簧模型里弹性系数太大或阻尼太小,粒子到达目标点后仍带着残余速度,冲到对面又被拉回来。解决方法是调高阻尼到 0.94 以上,或者把吸附阈值从 0.5 提到 2.0,让粒子提前锁定。第二原因是 FLOAT 阶段结束时,粒子的初始速度没有被清空,带着 m/s 量级的速度进入 FORM 阶段,过冲更明显。我习惯在 switchState 切到 FORM 时把所有粒子的速度衰减一次:

particles.forEach(p => { p.vx *= 0.2; p.vy *= 0.2; });

这样既保留了流动的连贯性,又防止带着高速进入聚形阶段。这个细节是很多成品动画和 demo 的差别所在:Demo 让你看到收束,成品让你看到收束得干净。

5.4 改变浏览器窗口大小后粒子全部飞走:resize 重算与相对坐标

现象:窗口一缩放,粒子四散奔逃,爱心瞬间碎掉。原因是 resize 后画布中心变了,但粒子的 targetX/targetY 还指向旧的坐标系位置。解决思路是让目标点不存绝对坐标,而是存相对中心的比例,或者干脆在 resize 事件里重新生成 whole 目标点序列。我用后面这种,代码量更少:

window.addEventListener('resize', () => { resize(); // 先更新 width/height 和 canvas 尺寸 scaleFactor = Math.min(width, height) / 50; targets = mapToScreen(heartPoints, width / 2, height / 2, scaleFactor); const shuffled = shuffle(targets.slice()); particles.forEach((p, i) => { p.targetX = shuffled[i].x; p.targetY = shuffled[i].y; }); });

注意 resize 事件要加防抖(debounce),否则连续拖动窗口边缘时会触发几十次重算,粒子数量多时卡顿十分明显。防抖的常见实现是 setTimeout 包一层,200ms 内重复触发就重置定时器。

5.5 设备发烫、后台标签页卡顿:主循环没有刹车机制

现象:页面挂着不动,风扇开始转;切到后台再回来,粒子位置直接乱掉。原因有两层:一是 rAF 在后台会自动暂停,但转子恢复后没有处理大 delta(对应第 2.3 节的钳制);二是粒子数量开到 1000 以上时,每帧全量 update+draw 无论如何都会让低端设备吃力。解决手段按优先级排列:先把 delta 钳制加上,再监听 visibilitychange 暂停整个循环,最后适配低电量模式或低帧率设备,降低粒子密度:

document.addEventListener('visibilitychange', () => { if (document.hidden) { cancelAnimationFrame(rafId); } else { lastTime = performance.now(); rafId = requestAnimationFrame(tick); } });

性能上还有一个特别容易被忽视的点:给粒子绘制加 shadowBlur 或阴影颜色会触发 canvas 的慢路径渲染,性能直接掉一半以上。粒子特效想要光晕感,正确的做法是用几层半透明圆形叠加模拟,或者用离屏 canvas 预渲染一张发光的圆点 sprite,每帧 drawImage 复制,而不是每帧重新计算阴影。300 粒子加 shadowBlur 可能看不出问题,加到 800 粒子时帧率会从 60 直接掉到 20,这是最隐蔽的翻车点之一。

6. 把爱心动画做成可复用组件:参数化、鼠标交互与一个小彩蛋

到这一步,核心动画链路已经完整。最后一章讲怎么把这套东西从“能跑的 demo”变成“能放进任何页面的组件”。我会把配置集中到一个对象里,再加上两个增强交互:鼠标划开粒子和点击重新聚形。

先做参数化。把所有可调项收进 CONFIG,避免散落在代码各处的魔法数:

const CONFIG = { count: 480, floatDuration: 2200, formDuration: 3200, force: 0.016, damping: 0.92, palette: ['#ff6b9d', '#ff8e53', '#ffc3a0'], bg: '#0a0a1a' };

读取 URL 参数覆盖成开发习惯后,调试效率会高很多。我用一个小函数解析location.search,把 count、force、damping 等字段直接映射进 CONFIG,这样不同设备上的效果差异能立刻对照。

然后是鼠标交互。让粒子被划开,核心不是直接改坐标,而是给它一个冲量,让弹簧阻尼自然把它拉回来:

canvas.addEventListener('pointermove', (e) => { const mouseX = e.clientX, mouseY = e.clientY; for (const p of particles) { const dx = p.x - mouseX, dy = p.y - mouseY; const distSq = dx * dx + dy * dy; const RADIUS = 80; if (distSq < RADIUS * RADIUS) { const dist = Math.sqrt(distSq) || 1; p.vx += (dx / dist) * 0.4; // 沿径向推开 p.vy += (dy / dist) * 0.4; } } });

用速度不用位置,是为了保留粒子的惯性轨迹,划过之后爱心会像被风吹过的水面一样慢慢复原。

最后一个小彩蛋:点击鼠标时,让动画重新走一遍漂浮到聚形的过程,相当于手动触发一次“重生”。实现也很简单,把 switchState 先切到 FLOAT,再按 CONFIG.floatDuration 延时切回 FORM:

canvas.addEventListener('pointerdown', () => { switchState(STATE.FLOAT, performance.now()); setTimeout(() => { if (state === STATE.FLOAT) { switchState(STATE.FORM, performance.now()); } }, CONFIG.floatDuration); });

加上这个,这份动画就不再是单调的循环播放,而是一个可以被用户“打扰”并且能自我修复的小系统。我现在的习惯是任何 Canvas 动效都先做 CONFIG 参数化,再做鼠标交互,最后才考虑视觉细节——这个顺序帮助我把第 5 章里那些坑变成了一次性成本。把这个文件拿去做网页动态背景、告白卡片或 H5 作业都够用,希望你也能跑出自己满意的流动效果,少走我走过的弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询