/** * 文件功能:聊天室彩带庆典特效 * * 通过大量彩纸碎片与飘带在空中散落、翻转,形成明显的庆典氛围, * 适合活动开始、中奖提示和管理员公告等场景。 */ const ConfettiEffect = (() => { class Piece { constructor(w, h) { this.w = w; this.h = h; this.reset(); } /** * 初始化彩带碎片参数。 */ reset() { this.x = this.w * (0.15 + Math.random() * 0.7); this.y = -Math.random() * this.h * 0.2; this.vx = Math.random() * 4.4 - 2.2; this.vy = Math.random() * 1.6 + 0.8; this.gravity = Math.random() * 0.03 + 0.025; this.rot = Math.random() * Math.PI * 2; this.rotSpeed = (Math.random() - 0.5) * 0.16; this.width = Math.random() * 10 + 6; this.height = Math.random() * 18 + 8; this.wave = Math.random() * Math.PI * 2; this.waveSpeed = Math.random() * 0.06 + 0.03; this.alpha = Math.random() * 0.2 + 0.75; this.color = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899"][ Math.floor(Math.random() * 6) ]; this.isRibbon = Math.random() > 0.72; } /** * 更新碎片运动状态。 */ update() { this.wave += this.waveSpeed; this.vy += this.gravity; this.vx *= 0.995; this.x += this.vx + Math.sin(this.wave) * 0.65; this.y += this.vy; this.rot += this.rotSpeed; } /** * 判断彩带是否仍在画布内。 */ get alive() { return this.y < this.h + 60; } /** * 绘制单个彩带碎片。 * * @param {CanvasRenderingContext2D} ctx */ draw(ctx) { const scaleX = Math.max(0.18, Math.abs(Math.cos(this.rot))); ctx.save(); ctx.translate(this.x, this.y); ctx.rotate(this.rot); ctx.scale(scaleX, 1); ctx.globalAlpha = this.alpha; ctx.fillStyle = this.color; ctx.shadowColor = `${this.color}66`; ctx.shadowBlur = 6; if (this.isRibbon) { ctx.fillRect(-this.width * 0.2, -this.height / 2, this.width * 0.4, this.height); } else { ctx.fillRect(-this.width / 2, -this.height / 2, this.width, this.height); } 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 = 7800; const isMobile = window.matchMedia?.("(max-width: 640px)")?.matches || window.innerWidth <= 640; const initialPieceCount = isMobile ? 56 : 90; const spawnPieceCount = isMobile ? 6 : 10; let pieces = Array.from({ length: initialPieceCount }, () => new Piece(w, h)); const startTime = performance.now(); let lastSpawnAt = startTime; let animId = null; let finished = false; /** * 统一结束彩纸动画,手动取消时只清理不回调。 * * @param {boolean} canceled 是否为手动取消 */ function finish(canceled) { if (finished) { return; } finished = true; if (animId) { cancelAnimationFrame(animId); } pieces = []; ctx.clearRect(0, 0, w, h); if (!canceled) { onEnd(); } } function animate(now) { ctx.clearRect(0, 0, w, h); pieces = pieces.filter((piece) => { piece.update(); piece.draw(ctx); return piece.alive; }); if (now - startTime < DURATION * 0.9 && now - lastSpawnAt >= 120) { pieces.push(...Array.from({ length: spawnPieceCount }, () => new Piece(w, h))); lastSpawnAt = now; } if (now - startTime < DURATION) { animId = requestAnimationFrame(animate); } else { finish(false); } } animId = requestAnimationFrame(animate); return { cancel() { finish(true); }, }; } return { start }; })(); window.ConfettiEffect = ConfettiEffect;