一:效果展示
本项目通过粒子系统和状态机动画创造了春节对联动态循环
二:主要功能
- 粒子系统: 使用5000个彩色粒子组成对联文字
- 动画循环: 包含四个阶段的动画循环:
- 散开阶段:粒子从文字位置散开到屏幕边缘
- 聚集阶段:粒子从边缘飞回形成新的对联文字
- 展示阶段:文字稳定显示并带有微妙的脉冲效果
- 淡出阶段:文字粒子逐渐淡出并散开
- 多副对联切换: 自动循环展示三副不同的春节对联
- 对联背景: 为每副对联添加红色背景和金色边框装饰
1. 代码分析
(1)粒子系统核心类
javascript
class Particle {
constructor() {
this.reset();
this.size = Math.random() * 3 + 1;
const hue = Math.random() * 10 + 50; // 金黄色调
this.color = `hsl(${hue}, 100%, ${Math.random() * 20 + 70}%)`;
this.easing = Math.random() * 0.05 + 0.02;
this.speed = Math.random() * 2 + 1;
}
// 粒子更新逻辑
update() {
if (!this.isTextParticle) return;
if (this.active) {
// 向目标位置移动
const dx = this.targetX - this.x;
const dy = this.targetY - this.y;
this.x += dx * this.easing;
this.y += dy * this.easing;
} else {
// 散开逻辑
if (animationState === 'scatter' || animationState === 'fadeOut') {
const dx = this.scatterTarget.x - this.x;
const dy = this.scatterTarget.y - this.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const speed = this.speed * (1 + (1 - dist/canvas.width) * 2);
this.x += dx / dist * speed;
this.y += dy / dist * speed;
}
}
}
// 绘制粒子
draw() {
ctx.globalAlpha = this.opacity * this.baseOpacity;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
}
}
(2)对联文字粒子化处理
javascript
function calculateCoupletPositions(couplet) {
// 创建临时canvas检测文字像素
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
tempCanvas.width = canvas.width;
tempCanvas.height = canvas.height;
// 设置字体样式
tempCtx.font = `bold ${fontSizeVertical}px ${fontFamily}`;
tempCtx.fillStyle = 'white';
tempCtx.textAlign = 'left';
tempCtx.textBaseline = 'top';
// 绘制横批
tempCtx.font = `bold ${fontSizeHorizontal}px ${fontFamily}`;
const topWidth = tempCtx.measureText(couplet.top).width;
const topX = centerX - topWidth / 2;
tempCtx.fillText(couplet.top, topX, topY);
// 绘制左联
tempCtx.font = `bold ${fontSizeVertical}px ${fontFamily}`;
for (let i = 0; i < couplet.left.length; i++) {
tempCtx.fillText(couplet.left[i], leftX, leftYStart + i * verticalSpacing);
}
// 绘制右联
for (let i = 0; i < couplet.right.length; i++) {
tempCtx.fillText(couplet.right[i], rightX, rightYStart + i * verticalSpacing);
}
// 检测文字像素位置
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data;
const textParticles = [];
for (let y = 0; y < tempCanvas.height; y += spacing) {
for (let x = 0; x < tempCanvas.width; x += spacing) {
const index = (y * tempCanvas.width + x) * 4;
if (data[index + 3] > 0) {
textParticles.push({x, y});
}
}
}
// 将文字像素位置分配给粒子
const shuffled = [...textParticles].sort(() => 0.5 - Math.random());
particles.forEach((particle, i) => {
if (i < shuffled.length) {
const pos = shuffled[i];
particle.targetX = pos.x;
particle.targetY = pos.y;
particle.active = true;
particle.baseOpacity = 1;
particle.isTextParticle = true;
}
});
return shuffled.length;
}
(3)动画状态机
javascript
let animationState = 'scatter';
const animationDurations = {
scatter: 1500, // 散开动画时长
gather: 2500, // 聚集动画时长
show: 4000, // 展示时长
fadeOut: 2000 // 淡出时长
};
function animate(timestamp) {
if (!animationStartTime) animationStartTime = timestamp;
const elapsed = timestamp - animationStartTime;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制对联背景
drawCoupletBackgrounds(0.8);
// 根据当前状态处理动画
if (animationState === 'scatter') {
// 散开逻辑
if (progress >= 1) {
animationState = 'gather';
currentCoupletIndex = (currentCoupletIndex + 1) % coupletList.length;
textParticlesCount = calculateCoupletPositions(coupletList[currentCoupletIndex]);
}
}
else if (animationState === 'gather') {
// 聚集逻辑
if (progress >= 1) {
animationState = 'show';
}
}
else if (animationState === 'show') {
// 展示逻辑(脉冲效果)
if (progress >= 1) {
animationState = 'fadeOut';
}
}
else if (animationState === 'fadeOut') {
// 淡出逻辑
if (progress >= 1) {
animationState = 'scatter';
}
}
requestAnimationFrame(animate);
}
2. 视觉效果特点
- 粒子颜色:使用金黄色调营造氛围
- 动态效果:文字粒子在状态转换时有流畅的动画效果
- 自适应布局:对联位置会根据屏幕尺寸自动调整
- 背景装饰:红色背景配金色边框,增强春节氛围
三:完整代码
html
<!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>
body {
margin: 0;
overflow: hidden;
background-color: #000;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-family: 'Arial', sans-serif;
}
canvas {
display: block;
}
</style>
</head>
<body>
<div class="loading"></div>
<canvas id="canvas"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
let coupletBackgrounds = {
top: { x: 0, y: 0, width: 0, height: 0 },
left: { x: 0, y: 0, width: 0, height: 0 },
right: { x: 0, y: 0, width: 0, height: 0 }
};
class Particle {
constructor() {
this.reset();
this.size = Math.random() * 3 + 1;
const hue = Math.random() * 10 + 50;
const saturation = 100;
const lightness = Math.random() * 20 + 70;
this.color = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
this.easing = Math.random() * 0.05 + 0.02;
this.active = false;
this.speed = Math.random() * 2 + 1;
this.baseOpacity = 0;
this.opacity = 0;
this.scatterTarget = { x: 0, y: 0 };
this.isTextParticle = false;
this.hadTarget = false;
}
reset() {
this.x = 0;
this.y = 0;
this.baseX = 0;
this.baseY = 0;
this.targetX = 0;
this.targetY = 0;
this.hadTarget = false;
}
calculateScatterTarget() {
const edge = Math.floor(Math.random() * 4);
if (edge === 0) {
this.scatterTarget.x = Math.random() * canvas.width;
this.scatterTarget.y = -20 - Math.random() * 50;
} else if (edge === 1) {
this.scatterTarget.x = canvas.width + 20 + Math.random() * 50;
this.scatterTarget.y = Math.random() * canvas.height;
} else if (edge === 2) {
this.scatterTarget.x = Math.random() * canvas.width;
this.scatterTarget.y = canvas.height + 20 + Math.random() * 50;
} else {
this.scatterTarget.x = -20 - Math.random() * 50;
this.scatterTarget.y = Math.random() * canvas.height;
}
}
update() {
if (!this.isTextParticle) return;
if (this.active) {
const dx = this.targetX - this.x;
const dy = this.targetY - this.y;
this.x += dx * this.easing;
this.y += dy * this.easing;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) {
this.x = this.targetX;
this.y = this.targetY;
}
} else {
if (animationState === 'scatter' || animationState === 'fadeOut') {
const dx = this.scatterTarget.x - this.x;
const dy = this.scatterTarget.y - this.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const speed = this.speed * (1 + (1 - dist/canvas.width) * 2);
if (dist > 1) {
this.x += dx / dist * speed;
this.y += dy / dist * speed;
}
}
}
}
draw() {
if (!this.isTextParticle && this.baseOpacity === 0) return;
ctx.globalAlpha = this.opacity * this.baseOpacity;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
}
function drawCoupletBackgrounds(opacity) {
if (opacity <= 0) return;
ctx.save();
ctx.globalAlpha = opacity;
const topBg = coupletBackgrounds.top;
ctx.fillStyle = '#c00';
ctx.fillRect(topBg.x - 30, topBg.y - 15, topBg.width + 60, topBg.height + 30);
ctx.strokeStyle = '#ffd700';
ctx.lineWidth = 3;
ctx.strokeRect(topBg.x - 30, topBg.y - 15, topBg.width + 60, topBg.height + 30);
const leftBg = coupletBackgrounds.left;
ctx.fillStyle = '#c00';
ctx.fillRect(leftBg.x - 30, leftBg.y - 15, leftBg.width + 60, leftBg.height + 30);
ctx.strokeStyle = '#ffd700';
ctx.lineWidth = 3;
ctx.strokeRect(leftBg.x - 30, leftBg.y - 15, leftBg.width + 60, leftBg.height + 30);
const rightBg = coupletBackgrounds.right;
ctx.fillStyle = '#c00';
ctx.fillRect(rightBg.x - 30, rightBg.y - 15, rightBg.width + 60, rightBg.height + 30);
ctx.strokeStyle = '#ffd700';
ctx.lineWidth = 3;
ctx.strokeRect(rightBg.x - 30, rightBg.y - 15, rightBg.width + 60, rightBg.height + 30);
ctx.restore();
}
const particles = [];
const particleCount = 5000;
for (let i = 0; i < particleCount; i++) {
const particle = new Particle();
particle.calculateScatterTarget();
particles.push(particle);
}
const coupletList = [
{
top: "常乐安宁",
left: "常怀欣喜心常乐",
right: "欣悦四季心安宁"
},
{
top: "欢度春节",
left: "迎喜迎春迎富贵",
right: "接财接福接平安"
},
{
top: "吉祥如意",
left: "春风入喜财入户",
right: "岁月更新福满门"
}
];
let currentCoupletIndex = 0;
const fontSizeVertical = 60;
const fontSizeHorizontal = 70;
const fontFamily = "SimHei, Microsoft YaHei, Arial";
function calculateCoupletPositions(couplet) {
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
tempCanvas.width = canvas.width;
tempCanvas.height = canvas.height;
tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
const centerX = canvas.width / 2;
const centerY = canvas.height / 2 + 40;
const verticalSpacing = 80;
const horizontalOffset = 300;
const textParticles = [];
const coupletHeight = couplet.left.length * verticalSpacing;
const topY = centerY - coupletHeight/2 - 80;
tempCtx.font = `bold ${fontSizeHorizontal}px ${fontFamily}`;
const topWidth = tempCtx.measureText(couplet.top).width;
const topX = centerX - topWidth / 2;
coupletBackgrounds.top = {
x: topX,
y: topY,
width: topWidth,
height: fontSizeHorizontal
};
tempCtx.fillStyle = 'white';
tempCtx.textAlign = 'left';
tempCtx.textBaseline = 'top';
tempCtx.fillText(couplet.top, topX, topY);
tempCtx.font = `bold ${fontSizeVertical}px ${fontFamily}`;
const leftYStart = centerY - (couplet.left.length * verticalSpacing) / 2;
const leftEdgeDistance = centerX - horizontalOffset;
const rightEdgeDistance = canvas.width - (centerX + horizontalOffset);
const uniformEdgeDistance = Math.min(leftEdgeDistance, rightEdgeDistance);
const leftX = uniformEdgeDistance + 35 - 50;
const rightX = canvas.width - uniformEdgeDistance - 50;
coupletBackgrounds.left = {
x: leftX,
y: leftYStart,
width: tempCtx.measureText(couplet.left[0]).width,
height: couplet.left.length * verticalSpacing
};
for (let i = 0; i < couplet.left.length; i++) {
const char = couplet.left[i];
const y = leftYStart + i * verticalSpacing;
tempCtx.fillText(char, leftX, y);
}
const rightYStart = centerY - (couplet.right.length * verticalSpacing) / 2;
coupletBackgrounds.right = {
x: rightX,
y: rightYStart,
width: tempCtx.measureText(couplet.right[0]).width,
height: couplet.right.length * verticalSpacing
};
for (let i = 0; i < couplet.right.length; i++) {
const char = couplet.right[i];
const y = rightYStart + i * verticalSpacing;
tempCtx.fillText(char, rightX, y);
}
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data;
const spacing = 3;
for (let y = 0; y < tempCanvas.height; y += spacing) {
for (let x = 0; x < tempCanvas.width; x += spacing) {
const index = (y * tempCanvas.width + x) * 4;
if (data[index + 3] > 0) {
textParticles.push({x, y});
}
}
}
const shuffled = [...textParticles].sort(() => 0.5 - Math.random());
particles.forEach(particle => {
particle.reset();
particle.active = false;
particle.baseOpacity = 0;
particle.opacity = 0;
particle.isTextParticle = false;
});
particles.forEach((particle, i) => {
if (i < shuffled.length) {
const pos = shuffled[i];
particle.targetX = pos.x;
particle.targetY = pos.y;
particle.active = true;
particle.baseOpacity = 1;
particle.opacity = 1;
particle.isTextParticle = true;
particle.calculateScatterTarget();
}
});
return shuffled.length;
}
let animationState = 'scatter';
let textParticlesCount = 0;
let animationStartTime = 0;
const animationDurations = {
scatter: 1500,
gather: 2500,
show: 4000,
fadeOut: 2000
};
function animate(timestamp) {
if (!animationStartTime) animationStartTime = timestamp;
const elapsed = timestamp - animationStartTime;
ctx.clearRect(0, 0, canvas.width, canvas.height);
let bgOpacity = 0.8;
drawCoupletBackgrounds(bgOpacity);
if (animationState === 'scatter') {
const progress = Math.min(elapsed / animationDurations.scatter, 1);
particles.forEach(particle => {
if (particle.isTextParticle) {
if (particle.active) {
if (progress > 0.3) {
const p = (progress - 0.3) / 0.7;
const easeP = easeOutCubic(Math.min(p, 1));
particle.x = particle.targetX + (particle.scatterTarget.x - particle.targetX) * easeP;
particle.y = particle.targetY + (particle.scatterTarget.y - particle.targetY) * easeP;
particle.opacity = 1 - easeP;
if (p >= 1) {
particle.active = false;
particle.opacity = 0;
}
}
}
}
particle.update();
particle.draw();
});
if (progress >= 1) {
animationState = 'gather';
animationStartTime = timestamp;
currentCoupletIndex = (currentCoupletIndex + 1) % coupletList.length;
textParticlesCount = calculateCoupletPositions(coupletList[currentCoupletIndex]);
}
}
else if (animationState === 'gather') {
const progress = Math.min(elapsed / animationDurations.gather, 1);
const easeP = easeOutCubic(progress);
particles.forEach((particle, i) => {
if (particle.isTextParticle) {
if (i < textParticlesCount) {
particle.active = true;
particle.opacity = Math.min(progress * 2, 1);
particle.baseOpacity = 1;
if (!particle.hadTarget) {
particle.x = particle.scatterTarget.x;
particle.y = particle.scatterTarget.y;
particle.hadTarget = true;
}
}
}
particle.update();
particle.draw();
});
if (progress >= 1) {
animationState = 'show';
animationStartTime = timestamp;
particles.forEach(p => {
if (p.isTextParticle) {
p.hadTarget = false;
}
});
}
}
else if (animationState === 'show') {
const progress = Math.min(elapsed / animationDurations.show, 1);
particles.forEach((particle, i) => {
if (particle.isTextParticle && i < textParticlesCount) {
particle.active = true;
particle.baseOpacity = 1;
particle.opacity = 1;
const pulse = Math.sin(timestamp * 0.003 + i * 0.1) * 0.4 + 0.8;
particle.size = 2 + pulse;
}
particle.update();
particle.draw();
});
if (progress >= 1) {
animationState = 'fadeOut';
animationStartTime = timestamp;
}
}
else if (animationState === 'fadeOut') {
const progress = Math.min(elapsed / animationDurations.fadeOut, 1);
const easeP = easeInCubic(progress);
particles.forEach((particle, i) => {
if (particle.isTextParticle && i < textParticlesCount) {
const dx = particle.scatterTarget.x - particle.x;
const dy = particle.scatterTarget.y - particle.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const speed = particle.speed * (1 + (1 - dist/canvas.width) * 3);
if (dist > 1) {
particle.x += dx / dist * speed * (1 + easeP * 2);
particle.y += dy / dist * speed * (1 + easeP * 2);
}
particle.opacity = 1 - easeP;
if (progress >= 1) {
particle.active = false;
particle.opacity = 0;
}
}
particle.update();
particle.draw();
});
if (progress >= 1) {
animationState = 'scatter';
animationStartTime = timestamp;
}
}
requestAnimationFrame(animate);
}
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
function easeInCubic(t) {
return t * t * t;
}
document.querySelector('.loading').style.display = 'none';
textParticlesCount = calculateCoupletPositions(coupletList[currentCoupletIndex]);
animationState = 'gather';
animationStartTime = performance.now();
requestAnimationFrame(animate);
</script>
</body>
</html>
以上为本项目的完整代码,复制粘贴即可使用
在这里提前祝大家小年快乐!!!
