177 lines
5.7 KiB
JavaScript
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 };
|
|
})();
|