/** * 文件功能:聊天室流星特效 * * 在透明 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; let finished = false; /** * 统一结束流星动画,手动取消时只清理不回调。 * * @param {boolean} canceled 是否为手动取消 */ function finish(canceled) { if (finished) { return; } finished = true; if (animId) { cancelAnimationFrame(animId); } ctx.clearRect(0, 0, w, h); if (!canceled) { onEnd(); } } 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 { finish(false); } } animId = requestAnimationFrame(animate); return { cancel() { finish(true); }, }; } return { start }; })(); window.MeteorsEffect = MeteorsEffect;