/** * 文件功能:聊天室特效管理器 * * 统一管理全屏 Canvas 特效的入口、防重入和资源清理。 * 播放期间用户点击屏幕任意位置可立即结束当前全屏特效。 * 使用方式:EffectManager.play('fireworks' | 'rain' | 'lightning' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies') */ const EffectManager = (() => { // 当前正在播放的特效名称(防止同时播放两个特效) let _current = null; // 当前特效返回的取消函数,用于手动停止时真正停掉内部 RAF / 定时器 let _currentCancel = null; // 全屏 Canvas 元素引用 let _canvas = null; // 待播放特效队列,避免多个进场效果互相打断 const _queue = []; // 队列最多保留 3 个待播特效,避免高频触发后播放过期动画 const MAX_QUEUE_LENGTH = 3; // 当前特效播放批次,用于忽略手动停止后的旧回调 let _playToken = 0; // 是否已经绑定本轮点击停止监听 let _clickStopBound = false; // 延迟绑定定时器,避免触发播放的同一次点击立即停止特效 let _clickStopTimer = null; // 当前画布像素倍率固定为 1,避免高清手机放大绘制面积拖慢特效 const MAX_DPR = 1; /** * 按窗口尺寸重置画布像素尺寸。 * * @param {HTMLCanvasElement} canvas 全屏特效画布 */ function _resizeCanvas(canvas) { const ratio = Math.min(window.devicePixelRatio || 1, MAX_DPR); canvas.width = Math.floor(window.innerWidth * ratio); canvas.height = Math.floor(window.innerHeight * ratio); } /** * 获取或创建全屏 Canvas 元素 * 属性:fixed 定位,覆盖全屏,播放期间接收点击用于立即停止特效。 */ function _getCanvas() { if (_canvas && document.body.contains(_canvas)) { return _canvas; } const c = document.createElement("canvas"); c.id = "effect-canvas"; c.style.cssText = [ "position:fixed", "top:0", "left:0", "width:100vw", "height:100vh", "z-index:99999", "pointer-events:auto", "cursor:pointer", "touch-action:manipulation", ].join(";"); _resizeCanvas(c); document.body.appendChild(c); _canvas = c; window.addEventListener("resize", _handleResize); window.addEventListener("orientationchange", _handleResize); return c; } /** * 响应窗口尺寸变化,确保手机横竖屏切换后覆盖范围正确。 */ function _handleResize() { if (_current) { stop(); return; } if (_canvas) { _resizeCanvas(_canvas); } } /** * 绑定点击屏幕立即停止当前特效的监听。 * * 延后一帧绑定,避免触发特效的同一次点击被误判为结束点击。 */ function _bindClickStop() { if (_clickStopBound) { return; } _clickStopTimer = window.setTimeout(() => { if (!_current || _clickStopBound) { return; } _clickStopBound = true; if (_canvas) { _canvas.addEventListener("pointerdown", _handleStopClick, { capture: true, }); _canvas.addEventListener("mousedown", _handleStopClick, { capture: true, }); } document.addEventListener("pointerdown", _handleStopClick, { capture: true, }); document.addEventListener("mousedown", _handleStopClick, { capture: true, }); }, 120); } /** * 解绑点击停止监听,避免特效结束后影响正常聊天操作。 */ function _unbindClickStop() { if (_clickStopTimer) { window.clearTimeout(_clickStopTimer); _clickStopTimer = null; } if (!_clickStopBound) { return; } if (_canvas) { _canvas.removeEventListener("pointerdown", _handleStopClick, { capture: true, }); _canvas.removeEventListener("mousedown", _handleStopClick, { capture: true, }); } document.removeEventListener("pointerdown", _handleStopClick, { capture: true, }); document.removeEventListener("mousedown", _handleStopClick, { capture: true, }); _clickStopBound = false; } /** * 处理屏幕点击结束特效。 */ function _handleStopClick(event) { if (event) { event.stopPropagation(); } stop(); } /** * 特效结束后清理 Canvas,重置状态,并停止音效。 * * @param {Object} options 清理选项 * @param {boolean} options.playNext 是否继续播放队列中的下一个特效 * @param {number|null} options.token 当前特效播放批次 */ function _cleanup({ playNext = true, token = null, cancelCurrent = false } = {}) { if (token !== null && token !== _playToken) { return; } _playToken++; _unbindClickStop(); if (cancelCurrent && typeof _currentCancel === "function") { _currentCancel(); } _currentCancel = null; if (_canvas && document.body.contains(_canvas)) { document.body.removeChild(_canvas); } window.removeEventListener("resize", _handleResize); window.removeEventListener("orientationchange", _handleResize); _canvas = null; _current = null; // 通知音效引擎停止(兜底:正常情况下音效会自行计时结束) if (typeof window.EffectSounds !== "undefined") { window.EffectSounds.stop(); } if (playNext && _queue.length > 0) { const nextType = _queue.shift(); if (nextType) { play(nextType); } } } /** * 将特效加入有限队列,同类型短时间重复触发时只保留一份。 * * @param {string} type 待播放特效类型 */ function _enqueue(type) { const existingIndex = _queue.indexOf(type); if (existingIndex !== -1) { _queue.splice(existingIndex, 1); } _queue.push(type); while (_queue.length > MAX_QUEUE_LENGTH) { _queue.shift(); } } /** * 记录具体特效返回的取消句柄。 * * @param {Object|undefined} controller 特效启动返回值 */ function _bindEffectController(controller) { _currentCancel = typeof controller?.cancel === "function" ? controller.cancel : null; } /** * 启动具体特效并保存取消句柄。 * * @param {Object|undefined} effectObject 特效全局对象 * @param {HTMLCanvasElement} canvas 全屏特效画布 * @param {Function} finishCurrent 当前特效结束回调 * @param {string} startMethod 启动方法名称 * @returns {boolean} 是否成功找到并启动特效 */ function _startEffect(effectObject, canvas, finishCurrent, startMethod = "start") { if (!effectObject || typeof effectObject[startMethod] !== "function") { return false; } _bindEffectController(effectObject[startMethod](canvas, finishCurrent)); return true; } /** * 播放指定特效 * * @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies */ function play(type) { // 防重入:同时只允许一个特效 if (_current) { console.log(`[EffectManager] 特效 ${_current} 正在播放,加入队列 ${type}`); _enqueue(type); return; } const canvas = _getCanvas(); _current = type; const token = _playToken; const finishCurrent = () => _cleanup({ token }); _bindClickStop(); // 同步触发对应音效 if (typeof window.EffectSounds !== "undefined") { window.EffectSounds.play(type); } let started = false; try { switch (type) { case "fireworks": started = _startEffect(window.FireworksEffect, canvas, finishCurrent); break; case "wedding-fireworks": // 婚礼专属:双倍礼花,粉金浪漫配色,持续 12 秒 started = _startEffect(window.FireworksEffect, canvas, finishCurrent, "startDouble"); break; case "rain": started = _startEffect(window.RainEffect, canvas, finishCurrent); break; case "lightning": started = _startEffect(window.LightningEffect, canvas, finishCurrent); break; case "snow": started = _startEffect(window.SnowEffect, canvas, finishCurrent); break; case "sakura": started = _startEffect(window.SakuraEffect, canvas, finishCurrent); break; case "meteors": started = _startEffect(window.MeteorsEffect, canvas, finishCurrent); break; case "gold-rain": started = _startEffect(window.GoldRainEffect, canvas, finishCurrent); break; case "hearts": started = _startEffect(window.HeartsEffect, canvas, finishCurrent); break; case "confetti": started = _startEffect(window.ConfettiEffect, canvas, finishCurrent); break; case "fireflies": started = _startEffect(window.FirefliesEffect, canvas, finishCurrent); break; default: console.warn(`[EffectManager] 未知特效类型:${type}`); } } catch (error) { console.error(`[EffectManager] 启动特效失败:${type}`, error); } if (!started) { finishCurrent(); } } /** * 用户手动停止当前全屏特效。 * * 会立即移除画布、停止音效,并清空排队中的后续特效。 */ function stop() { if (!_current) { return; } _queue.length = 0; _cleanup({ playNext: false, cancelCurrent: true }); } return { play, stop }; })(); window.EffectManager = EffectManager;