用HTML5 Canvas打造交互式心形粒子动画:从基础到优化实战
用HTML5 Canvas打造交互式心形粒子动画:从基础到优化实战
引言
在Web交互设计中,粒子动画因其动态美感和视觉吸引力被广泛应用于节日特效、情感化界面等场景。本文将通过实战案例,详细讲解如何使用HTML5 Canvas和JavaScript实现一个「心之律动」交互式粒子艺术效果,包含心形粒子循环动画、鼠标轨迹粒子、烟花爆炸及坠落效果,并分享关键优化技巧。
技术栈概览
- HTML5 Canvas:实现高性能粒子渲染
- JavaScript:粒子系统逻辑控制
- Tailwind CSS:快速构建UI界面
- Font Awesome:图标库支持
一、基础框架搭建
1. 画布初始化
<canvas id="canvas"></canvas>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');// 自适应屏幕尺寸
function resizeCanvas() {canvas.width = window.innerWidth;canvas.height = window.innerHeight;
}
window.addEventListener('resize', resizeCanvas);
2. UI界面设计
使用Tailwind CSS构建半透明控制栏和信息面板,实现响应式布局:
<div class="controls"><div class="control-btn" id="reset-btn"><i class="fa fa-refresh"></i></div><!-- 暂停/增加/减少按钮 -->
</div><div class="info-panel"><div>粒子数量: <span id="particle-count">0</span></div><p><i class="fa fa-mouse-pointer"></i> 鼠标移动生成轨迹</p>
</div>
二、核心粒子系统实现
1. 粒子类设计
定义Particle
类,通过type
属性区分不同粒子类型(心形/鼠标轨迹/烟花/坠落),实现多态行为:
class Particle {constructor(x, y, type) {this.x = x;this.y = y;this.type = type;// 根据类型初始化不同属性type === 'heart' ? this.setupHeartParticle() :type === 'mouse' ? this.setupMouseParticle() :type === 'firework' ? this.setupFireworkParticle() :this.setupFallingParticle();}// 心形粒子专属属性setupHeartParticle() {this.layer = Math.floor(Math.random() * 4); // 0-3层this.color = particleColors[this.layer][Math.floor(Math.random() * 3)];this.size = 2 + (8 - this.layer * 2) * Math.random();this.angle = Math.random() * 2 * Math.PI; // 随机方向this.life = 150 + 100 * Math.random() - this.layer * 30; // 分层寿命}// 更新粒子状态update() {// 心形粒子使用极坐标运动if (this.type === 'heart') {this.x += Math.cos(this.angle) * this.speed;this.y += Math.sin(this.angle) * this.speed;}// 烟花粒子使用笛卡尔坐标+物理模拟else if (this.type === 'firework') {this.vx *= this.friction; // 摩擦力this.vy += this.gravity; // 重力this.x += this.vx;this.y += this.vy;}// 生命周期管理this.life--;this.currentAlpha = this.life / this.maxLife;}
}
三、心形动画核心实现
1. 心形参数方程
使用经典心形参数方程生成粒子初始位置:
// 心形参数方程:x=16sin³t,y=13cost-5cos2t-2cos3t-cos4t
generateHeartPoint(t, scale) {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: canvas.width/2 + x * (canvas.width*0.35*scale)/16,y: canvas.height/2 - y * (canvas.width*0.35*scale)/16};
}
2. 粒子循环再生机制
通过每帧检测心形粒子数量,动态补充消失的粒子,实现持续动画:
class HeartAnimation {constructor() {this.heartParticleCount = 1500; // 目标粒子数this.heartRegenRate = 5; // 每帧再生数量this.generateHeartParticles(this.heartParticleCount);}animate() {// 检测存活心形粒子数量let heartCount = this.particles.filter(p => p.type === 'heart').length;// 补充缺失粒子if (heartCount < this.heartParticleCount) {const toAdd = Math.min(this.heartRegenRate, this.heartParticleCount - heartCount);this.regenerateHeartParticles(toAdd);}requestAnimationFrame(this.animate.bind(this));}
}
四、烟花效果深度优化
1. 物理模拟增强
- 笛卡尔坐标系:使用
vx/vy
分量精确控制运动 - 重力系统:
this.gravity = 0.05
模拟自由落体 - 空气阻力:
this.friction = 0.97
实现速度衰减
setupFireworkParticle() {this.vx = Math.cos(this.angle) * this.baseSpeed;this.vy = Math.sin(this.angle) * this.baseSpeed;this.gravity = 0.05;this.friction = 0.97 + Math.random()*0.01;
}
2. 多阶段爆炸效果
通过延迟释放不同类型粒子,模拟真实烟花层次感:
createFirework(x, y) {// 主爆炸this.createFireworkWave(x, y, 180, 0);// 150ms后释放外围粒子setTimeout(() => {this.createFireworkWave(x, y, 120, 10, false, 1.5);}, 150);// 250ms后释放精细粒子setTimeout(() => {this.createFireworkWave(x, y, 150, 15, true);}, 250);
}
3. 坠落效果转换
当烟花粒子速度低于阈值时,转换为坠落粒子并添加风力效果:
if (this.type === 'firework' && Math.abs(this.vy) < 0.3) {this.type = 'falling';this.setupFallingParticle(); // 启用风力和更快下落
}
五、交互功能实现
1. 鼠标轨迹生成
通过高频次生成带随机偏移的粒子,形成连续轨迹:
handleMouseMove(e) {const now = Date.now();if (now - this.lastMouseMove > 15) {// 每次移动生成6个偏移粒子for (let i=0; i<6; i++) {this.particles.push(new Particle(e.clientX + (Math.random()-0.5)*20, e.clientY + (Math.random()-0.5)*20, 'mouse'));}this.lastMouseMove = now;}
}
2. 控制按钮逻辑
实现粒子数量调整、动画暂停和重置功能:
handleIncrease() {this.heartParticleCount += 300;this.generateHeartParticles(300); // 批量生成
}handleReset() {this.particles = []; // 清空所有粒子this.generateHeartParticles(this.heartParticleCount); // 重新生成心形
}
六、性能优化要点
- 粒子生命周期管理:及时移除死亡粒子,避免内存泄漏
for (let i=this.particles.length-1; i>=0; i--) {if (!this.particles[i].isAlive()) {this.particles.splice(i, 1); // 逆序删除避免索引错乱}
}
- 画布清理策略:使用
clearRect
而非全量重绘
ctx.clearRect(0, 0, canvas.width, canvas.height); // 只清除可见区域
- 分层渲染优化:将不同类型粒子分组管理,减少状态判断
效果展示
- 基础效果:中心悬浮动态心形,粒子随心跳效果呼吸缩放
- 交互效果:
- 鼠标移动生成彩色拖尾轨迹
- 点击屏幕触发多层烟花爆炸,伴随真实物理坠落
- 底部控制栏可调整粒子数量、暂停动画、重置场景
总结
通过HTML5 Canvas的高性能渲染能力,结合物理模拟和粒子系统设计,我们实现了一个兼具视觉美感和交互乐趣的心形动画。核心技术点包括:
- 基于参数方程的几何图形生成
- 多类型粒子的状态机设计
- 物理引擎(重力、摩擦力、风力)的实现
- 交互式粒子系统的性能优化
完整代码
<div class="overlay"><h1 class="title animate-pulse-slow">心之律动</h1><p class="subtitle">鼠标滑过留下痕迹,点击释放烟花</p>
</div><div class="controls"><div class="control-btn" id="reset-btn" title="重置"><i class="fa fa-refresh"></i></div><div class="control-btn" id="pause-btn" title="暂停/继续"><i class="fa fa-pause"></i></div><div class="control-btn" id="increase-btn" title="增加粒子"><i class="fa fa-plus"></i></div><div class="control-btn" id="decrease-btn" title="减少粒子"><i class="fa fa-minus"></i></div>
</div><div class="info-panel"><div class="particles-count"><span id="particle-count">粒子数量: 0</span></div><div class="instructions"><p><i class="fa fa-mouse-pointer heart-icon"></i> 鼠标移动: 留下粒子轨迹</p><p><i class="fa fa-hand-pointer-o heart-icon"></i> 点击: 释放烟花</p><p><i class="fa fa-refresh heart-icon"></i> 重置: 重新生成心形</p></div>
</div><script>// 初始化画布const canvas = document.getElementById('canvas');const ctx = canvas.getContext('2d');// 设置画布尺寸function resizeCanvas() {canvas.width = window.innerWidth;canvas.height = window.innerHeight;}resizeCanvas();window.addEventListener('resize', resizeCanvas);// 粒子颜色方案const particleColors = ['#FF5E87', '#FF85A2', '#FFB3C6', '#FFC2D1', '#FFD7E4','#FF9AA2', '#FFB7B2', '#FFDAC1', '#E2F0CB', '#B5EAD7', '#C7CEEA','#A79AFF', '#C2A7FF', '#D8A7FF', '#EAA7FF', '#F5A7FF'];// 粒子类class Particle {constructor(x, y, type = 'heart') {this.x = x;this.y = y;this.type = type; // 'heart', 'mouse', 'firework', 'falling'// 根据粒子类型设置不同属性if (type === 'heart') {this.setupHeartParticle();} else if (type === 'mouse') {this.setupMouseParticle();} else if (type === 'firework') {this.setupFireworkParticle();} else if (type === 'falling') {this.setupFallingParticle();}// 为烟花粒子添加延迟效果if (type === 'firework') {this.delay = Math.random() * 15; // 延迟发射时间this.isActive = false;}}setupHeartParticle() {// 粒子层次(0=外层,1=中层,2=内层,3=中心)this.layer = Math.floor(Math.random() * 4);// 根据层次确定颜色const colorPools = [particleColors.slice(0, 3), // 外层颜色 - 冷色particleColors.slice(3, 6), // 中层颜色 - 中色particleColors.slice(6, 9), // 内层颜色 - 暖色particleColors.slice(9) // 中心颜色 - 最暖色];this.color = colorPools[this.layer][Math.floor(Math.random() * colorPools[this.layer].length)];// 根据层次确定大小this.size = 2 + Math.random() * (8 - this.layer * 2);// 根据层次确定速度this.speed = 0.1 + Math.random() * 0.3 + this.layer * 0.05;// 随机方向this.angle = Math.random() * Math.PI * 2;// 粒子寿命this.life = 150 + Math.random() * 100 - this.layer * 30;this.maxLife = this.life;}setupMouseParticle() {// 鼠标轨迹粒子属性 - 延长寿命this.color = particleColors[Math.floor(Math.random() * particleColors.length)];this.size = 1 + Math.random() * 3;this.speed = 0.05 + Math.random() * 0.1; // 降低速度,延长轨迹this.angle = Math.random() * Math.PI * 2;this.life = 100 + Math.random() * 80; // 延长寿命this.maxLife = this.life;}setupFireworkParticle() {// 烟花粒子属性 - 更大范围this.color = particleColors[Math.floor(Math.random() * particleColors.length)];this.size = 1.5 + Math.random() * 4;this.baseSpeed = 2 + Math.random() * 3; // 更高初始速度,更大范围this.angle = Math.random() * Math.PI * 2;this.life = 100 + Math.random() * 80; // 延长烟花粒子寿命this.maxLife = this.life;this.gravity = 0.05; // 增加重力效果this.friction = 0.97 + Math.random() * 0.01; // 添加摩擦力// 使用笛卡尔坐标系统this.vx = Math.cos(this.angle) * this.baseSpeed;this.vy = Math.sin(this.angle) * this.baseSpeed;}setupFallingParticle() {// 坠落粒子属性this.color = particleColors[Math.floor(Math.random() * particleColors.length)];this.size = 0.5 + Math.random() * 2;this.speed = 0.5 + Math.random() * 1.5;// 确保角度主要向下(π到2π之间)this.angle = Math.PI + (Math.random() - 0.5) * Math.PI * 0.6; this.life = 80 + Math.random() * 120;this.maxLife = this.life;this.gravity = 0.03; // 增加重力效果this.wind = (Math.random() - 0.5) * 0.003; // 水平风力,减小偏移}update() {// 烟花粒子延迟激活if (this.type === 'firework' && !this.isActive) {this.delay--;if (this.delay <= 0) {this.isActive = true;}return;}// 更新位置 - 使用笛卡尔坐标系统if (this.type === 'firework' && this.isActive) {// 应用摩擦力this.vx *= this.friction;this.vy *= this.friction;// 应用重力this.vy += this.gravity;this.x += this.vx;this.y += this.vy;// 当烟花粒子速度足够慢时,转换为坠落粒子if (Math.abs(this.vy) > 0.3 && Math.random() < 0.08 && this.life > 40) {this.type = 'falling';this.setupFallingParticle();}} else {// 其他粒子使用极坐标系统this.x += Math.cos(this.angle) * this.speed;this.y += Math.sin(this.angle) * this.speed;}if (this.type === 'falling') {this.speed += this.gravity;this.x += this.wind;}// 更新寿命this.life--;// 心跳效果 - 改变粒子大小和不透明度const heartbeatPhase = (Date.now() / 800) % (Math.PI * 2);const heartbeatFactor = 1.0 + 0.15 * Math.sin(heartbeatPhase);// 对于心形粒子,使用更明显的心跳效果if (this.type === 'heart') {this.currentSize = this.size * heartbeatFactor * (this.life / this.maxLife);this.currentAlpha = (this.life / this.maxLife) * (0.8 + 0.2 * Math.sin(heartbeatPhase + this.layer * 0.5));} else {this.currentSize = this.size * (this.life / this.maxLife);this.currentAlpha = this.life / this.maxLife;}}draw() {// 延迟的烟花粒子不绘制if (this.type === 'firework' && !this.isActive) {return;}// 绘制粒子ctx.fillStyle = this.color;ctx.beginPath();ctx.arc(this.x, this.y, this.currentSize, 0, Math.PI * 2);ctx.closePath();ctx.globalAlpha = this.currentAlpha;ctx.fill();ctx.globalAlpha = 1;}isAlive() {return this.life > 0;}}// 心形粒子动画类class HeartAnimation {constructor() {this.particles = [];this.isPaused = false;this.mouse = { x: 0, y: 0, isDown: false };this.lastMouseMove = 0;this.particleCount = 0;this.heartParticleCount = 1500; // 心形粒子目标数量this.heartRegenRate = 5; // 每帧重新生成的心形粒子数量// 绑定事件处理函数this.handleMouseMove = this.handleMouseMove.bind(this);this.handleMouseDown = this.handleMouseDown.bind(this);this.handleMouseUp = this.handleMouseUp.bind(this);this.handleReset = this.handleReset.bind(this);this.handlePause = this.handlePause.bind(this);this.handleIncrease = this.handleIncrease.bind(this);this.handleDecrease = this.handleDecrease.bind(this);// 注册事件监听器window.addEventListener('mousemove', this.handleMouseMove);window.addEventListener('mousedown', this.handleMouseDown);window.addEventListener('mouseup', this.handleMouseUp);document.getElementById('reset-btn').addEventListener('click', this.handleReset);document.getElementById('pause-btn').addEventListener('click', this.handlePause);document.getElementById('increase-btn').addEventListener('click', this.handleIncrease);document.getElementById('decrease-btn').addEventListener('click', this.handleDecrease);// 生成初始心形粒子this.generateHeartParticles(this.heartParticleCount);// 开始动画循环this.animate();}// 判断点是否在心形内部isInsideHeart(x, y, scale = 1) {// 归一化坐标const centerX = canvas.width / 2;const centerY = canvas.height / 2;const nx = (x - centerX) / (canvas.width / 2);const ny = (y - centerY) / (canvas.height / 2);// 心形方程: (x² + y² - 1)³ - x²y³ ≤ 0const heartEq = Math.pow(nx*nx + ny*ny - 1, 3) - nx*nx*ny*ny*ny;return heartEq <= 0;}// 生成心形参数方程的点generateHeartPoint(t, scale = 1) {// 心形参数方程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);// 归一化并缩放const centerX = canvas.width / 2;const centerY = canvas.height / 2;const heartScale = Math.min(canvas.width, canvas.height) * 0.35 * scale;return {x: centerX + x * heartScale / 16,y: centerY - y * heartScale / 16 // 注意y轴是向下的};}// 生成心形粒子generateHeartParticles(count) {for (let i = 0; i < count; i++) {// 随机选择层次const layer = Math.floor(Math.random() * 4);// 根据层次确定缩放比例let scale;if (layer === 0) scale = 1.0; // 外层else if (layer === 1) scale = 0.95; // 中层else if (layer === 2) scale = 0.85; // 内层else scale = 0.7; // 中心层// 生成随机角度const t = Math.random() * Math.PI * 2;// 计算粒子位置const point = this.generateHeartPoint(t, scale);// 对于中心层,添加一些随机偏移,使其填充更均匀if (layer === 3) {const offset = Math.random() * 0.2 * (canvas.width / 2);point.x += (Math.random() - 0.5) * offset;point.y += (Math.random() - 0.5) * offset;// 确保点仍然在心形内部if (!this.isInsideHeart(point.x, point.y)) {continue;}}// 创建粒子this.particles.push(new Particle(point.x, point.y, 'heart'));}this.updateParticleCount();}// 重新生成心形粒子regenerateHeartParticles(count) {for (let i = 0; i < count; i++) {// 随机选择层次const layer = Math.floor(Math.random() * 4);// 根据层次确定缩放比例let scale;if (layer === 0) scale = 1.0; // 外层else if (layer === 1) scale = 0.95; // 中层else if (layer === 2) scale = 0.85; // 内层else scale = 0.7; // 中心层// 生成随机角度const t = Math.random() * Math.PI * 2;// 计算粒子位置const point = this.generateHeartPoint(t, scale);// 对于中心层,添加一些随机偏移,使其填充更均匀if (layer === 3) {const offset = Math.random() * 0.2 * (canvas.width / 2);point.x += (Math.random() - 0.5) * offset;point.y += (Math.random() - 0.5) * offset;// 确保点仍然在心形内部if (!this.isInsideHeart(point.x, point.y)) {continue;}}// 创建粒子this.particles.push(new Particle(point.x, point.y, 'heart'));}}// 鼠标移动处理handleMouseMove(e) {this.mouse.x = e.clientX;this.mouse.y = e.clientY;// 限制鼠标轨迹粒子生成频率const now = Date.now();if (now - this.lastMouseMove > 15) { // 增加生成频率// 创建更多鼠标轨迹粒子,形成更连续的轨迹for (let i = 0; i < 6; i++) {const offsetX = (Math.random() - 0.5) * 20; // 更大的偏移范围const offsetY = (Math.random() - 0.5) * 20;this.particles.push(new Particle(this.mouse.x + offsetX, this.mouse.y + offsetY, 'mouse'));}this.lastMouseMove = now;this.updateParticleCount();}}// 鼠标按下处理handleMouseDown() {this.mouse.isDown = true;this.createFirework(this.mouse.x, this.mouse.y);}// 鼠标释放处理handleMouseUp() {this.mouse.isDown = false;}// 创建烟花效果createFirework(x, y) {// 主烟花爆炸 - 分阶段释放粒子this.createFireworkWave(x, y, 180, 0);// 外围烟花 - 延迟释放,更大范围setTimeout(() => {if (this.isPaused) return;this.createFireworkWave(x, y, 120, 10, false, 1.5);}, 150);// 精细粒子 - 延迟释放,更精细的粒子setTimeout(() => {if (this.isPaused) return;this.createFireworkWave(x, y, 150, 15, true);}, 250);this.updateParticleCount();}// 创建一波烟花粒子createFireworkWave(x, y, count, baseDelay, fineParticles = false, speedMultiplier = 1) {for (let i = 0; i < count; i++) {const p = new Particle(x, y, 'firework');// 更精细的粒子if (fineParticles) {p.size = 0.5 + Math.random() * 1.5;p.baseSpeed = 1.5 + Math.random() * 2;p.life = 80 + Math.random() * 60;} else {p.size = 1 + Math.random() * 3;p.baseSpeed = (2 + Math.random() * 3) * speedMultiplier;}p.delay = baseDelay + Math.random() * 10;p.vx = Math.cos(p.angle) * p.baseSpeed;p.vy = Math.sin(p.angle) * p.baseSpeed;this.particles.push(p);}}// 重置动画handleReset() {// 清空现有粒子this.particles = [];// 生成新的心形粒子this.generateHeartParticles(this.heartParticleCount);}// 暂停/继续动画handlePause() {this.isPaused = !this.isPaused;const pauseBtn = document.getElementById('pause-btn');pauseBtn.innerHTML = this.isPaused ? '<i class="fa fa-play"></i>' : '<i class="fa fa-pause"></i>';}// 增加粒子数量handleIncrease() {this.heartParticleCount += 300;this.generateHeartParticles(300);}// 减少粒子数量handleDecrease() {// 保留最近添加的300个粒子if (this.heartParticleCount > 300) {this.heartParticleCount -= 300;// 移除部分粒子let removed = 0;for (let i = this.particles.length - 1; i >= 0; i--) {if (this.particles[i].type === 'heart') {this.particles.splice(i, 1);removed++;if (removed >= 300) break;}}this.updateParticleCount();}}// 更新粒子数量显示updateParticleCount() {this.particleCount = this.particles.length;document.getElementById('particle-count').textContent = `粒子数量: ${this.particleCount}`;}// 动画循环animate() {if (!this.isPaused) {// 清除画布ctx.clearRect(0, 0, canvas.width, canvas.height);// 计算当前心形粒子数量let heartParticles = 0;// 更新和绘制所有粒子for (let i = this.particles.length - 1; i >= 0; i--) {const particle = this.particles[i];if (particle.type === 'heart') {heartParticles++;}particle.update();if (particle.isAlive()) {particle.draw();} else {// 移除死亡的粒子this.particles.splice(i, 1);}}// 补充心形粒子if (heartParticles < this.heartParticleCount) {const toGenerate = Math.min(this.heartRegenRate,this.heartParticleCount - heartParticles);this.regenerateHeartParticles(toGenerate);}// 更新粒子数量显示if (this.particleCount !== this.particles.length) {this.updateParticleCount();}}// 继续动画循环requestAnimationFrame(this.animate.bind(this));}}// 初始化动画const animation = new HeartAnimation();
</script>