Files

177 lines
5.7 KiB
JavaScript

/**
* 文件功能:聊天室流星特效
*
* 在透明 Canvas 上绘制夜空光点和多枚斜向掠过的流星,
* 通过长尾渐变与随机节奏制造快速划空的视觉效果。
*/
const MeteorsEffect = (() => {
class Meteor {
constructor(w, h) {
this.w = w;
this.h = h;
this.reset(true);
}
/**
* 重置单颗流星的出发状态。
*
* @param {boolean} initial 是否首次初始化
*/
reset(initial = false) {
this.x = initial ? Math.random() * this.w : this.w + Math.random() * 160;
this.y = initial ? Math.random() * this.h * 0.45 : Math.random() * this.h * 0.52;
this.vx = -(12 + Math.random() * 7);
this.vy = 4.4 + Math.random() * 2.6;
this.length = 170 + Math.random() * 170;
this.alpha = 0;
this.maxAlpha = Math.random() * 0.28 + 0.72;
this.delay = Math.random() * 1500;
this.birth = performance.now();
this.life = 1800 + Math.random() * 1000;
this.width = Math.random() * 2.4 + 1.8;
this.tint = [
[255, 255, 255],
[191, 219, 254],
[125, 211, 252],
[253, 224, 71],
][Math.floor(Math.random() * 4)];
this.active = false;
}
/**
* 更新流星位置。
*
* @param {number} now
*/
update(now) {
if (!this.active) {
if (now - this.birth >= this.delay) {
this.active = true;
} else {
return;
}
}
this.x += this.vx;
this.y += this.vy;
const progress = (now - this.birth - this.delay) / this.life;
if (progress < 0.2) {
this.alpha = this.maxAlpha * (progress / 0.2);
} else if (progress > 0.78) {
this.alpha = this.maxAlpha * Math.max(0, (1 - progress) / 0.22);
} else {
this.alpha = this.maxAlpha;
}
if (progress >= 1 || this.x < -this.length || this.y > this.h + this.length) {
this.reset(false);
}
}
/**
* 绘制流星主体和尾迹。
*
* @param {CanvasRenderingContext2D} ctx
*/
draw(ctx) {
if (!this.active || this.alpha <= 0.01) {
return;
}
const tailX = this.x - this.vx * 9;
const tailY = this.y - this.vy * 9;
const [r, g, b] = this.tint;
const gradient = ctx.createLinearGradient(this.x, this.y, tailX, tailY);
gradient.addColorStop(0, `rgba(255,255,255,${Math.min(1, this.alpha + 0.12)})`);
gradient.addColorStop(0.18, `rgba(${r},${g},${b},${this.alpha})`);
gradient.addColorStop(0.62, `rgba(${r},${g},${b},${this.alpha * 0.42})`);
gradient.addColorStop(1, `rgba(${r},${g},${b},0)`);
ctx.save();
ctx.strokeStyle = gradient;
ctx.lineWidth = this.width;
ctx.lineCap = "round";
ctx.shadowColor = `rgba(${r},${g},${b},0.95)`;
ctx.shadowBlur = 18;
ctx.beginPath();
ctx.moveTo(this.x, this.y);
ctx.lineTo(tailX, tailY);
ctx.stroke();
ctx.strokeStyle = `rgba(255,255,255,${this.alpha * 0.65})`;
ctx.lineWidth = this.width * 0.42;
ctx.beginPath();
ctx.moveTo(this.x, this.y);
ctx.lineTo(this.x - this.vx * 2.2, this.y - this.vy * 2.2);
ctx.stroke();
ctx.fillStyle = `rgba(255,255,255,${Math.min(1, this.alpha + 0.18)})`;
ctx.beginPath();
ctx.arc(this.x, this.y, this.width * 1.55, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = `rgba(${r},${g},${b},${this.alpha * 0.55})`;
ctx.beginPath();
ctx.arc(this.x, this.y, this.width * 3.2, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
/**
* 启动流星特效。
*
* @param {HTMLCanvasElement} canvas
* @param {Function} onEnd
*/
function start(canvas, onEnd) {
const ctx = canvas.getContext("2d");
const w = canvas.width;
const h = canvas.height;
const DURATION = 9000;
const stars = Array.from({ length: 48 }, () => ({
x: Math.random() * w,
y: Math.random() * h * 0.62,
r: Math.random() * 1.9 + 0.6,
alpha: Math.random() * 0.42 + 0.2,
}));
const meteors = Array.from({ length: 14 }, () => new Meteor(w, h));
const startTime = performance.now();
let animId = null;
function animate(now) {
ctx.clearRect(0, 0, w, h);
stars.forEach((star) => {
ctx.save();
ctx.fillStyle = `rgba(248,250,252,${star.alpha})`;
ctx.shadowColor = "rgba(255,255,255,0.6)";
ctx.shadowBlur = 7;
ctx.beginPath();
ctx.arc(star.x, star.y, star.r, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
});
meteors.forEach((meteor) => {
meteor.update(now);
meteor.draw(ctx);
});
if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate);
} else {
cancelAnimationFrame(animId);
ctx.clearRect(0, 0, w, h);
onEnd();
}
}
animId = requestAnimationFrame(animate);
}
return { start };
})();