/** * 文件功能:聊天室特效管理器 * * 统一管理全屏 Canvas 特效的入口、防重入和资源清理。 * 播放期间用户点击屏幕任意位置可立即结束当前全屏特效。 * 使用方式:EffectManager.play('fireworks' | 'rain' | 'lightning' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies') */ const EffectManager = (() => { // 音效模块只在首次播放特效时加载,缓存 Promise 避免重复 import let _soundModulePromise = null; // 各特效模块加载器,使用静态路径让 Vite 拆分为可按需加载的 chunk const EFFECT_MODULES = { fireworks: { key: "fireworks", load: () => import("./fireworks.js") }, "wedding-fireworks": { key: "fireworks", load: () => import("./fireworks.js") }, rain: { key: "rain", load: () => import("./rain.js") }, lightning: { key: "lightning", load: () => import("./lightning.js") }, snow: { key: "snow", load: () => import("./snow.js") }, sakura: { key: "sakura", load: () => import("./sakura.js") }, meteors: { key: "meteors", load: () => import("./meteors.js") }, "gold-rain": { key: "gold-rain", load: () => import("./gold-rain.js") }, hearts: { key: "hearts", load: () => import("./hearts.js") }, confetti: { key: "confetti", load: () => import("./confetti.js") }, fireflies: { key: "fireflies", load: () => import("./fireflies.js") }, }; // 特效模块 Promise 缓存,同类型重复触发时复用同一次加载 const _effectModulePromises = new Map(); // 当前正在播放的特效名称(防止同时播放两个特效) let _current = null; // 当前特效返回的取消函数,用于手动停止时真正停掉内部 RAF / 定时器 let _currentCancel = null; // 全屏 Canvas 元素引用 let _canvas = null; // 待播放特效队列,避免多个进场效果互相打断 const _queue = []; // 队列最多保留 3 个待播特效,避免高频触发后播放过期动画 const MAX_QUEUE_LENGTH = 3; // 普通特效排队超过 10 秒后丢弃,避免播放过期动画 const QUEUED_EFFECT_TTL = 10000; // 当前特效播放批次,用于忽略手动停止后的旧回调 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 和音效。 */ function _handleVisibilityChange() { if (document.hidden) { stop(); _queue.length = 0; } } /** * 特效结束后清理 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) { const nextType = _dequeueNextType(); if (nextType) { play(nextType); } } } /** * 将特效加入有限队列,同类型短时间重复触发时只保留一份。 * * @param {string} type 待播放特效类型 */ function _enqueue(type) { const existingIndex = _queue.findIndex((item) => item.type === type); if (existingIndex !== -1) { _queue.splice(existingIndex, 1); } _queue.push({ type, queuedAt: Date.now(), keepUntilPlayed: type === "wedding-fireworks", }); while (_queue.length > MAX_QUEUE_LENGTH) { _queue.shift(); } } /** * 取出下一个仍然有效的排队特效。 * * @returns {string|null} */ function _dequeueNextType() { const now = Date.now(); while (_queue.length > 0) { const next = _queue.shift(); if (next.keepUntilPlayed || now - next.queuedAt <= QUEUED_EFFECT_TTL) { return next.type; } } return null; } /** * 确保音效模块已经加载。 * * @returns {Promise} */ function _loadSoundModule() { if (!_soundModulePromise) { _soundModulePromise = import("./effect-sounds.js"); } return _soundModulePromise; } /** * 确保指定特效模块已经加载。 * * @param {string} type 特效类型 * @returns {Promise} */ function _loadEffectModule(type) { const moduleConfig = EFFECT_MODULES[type]; if (!moduleConfig) { return Promise.reject(new Error(`未知特效类型:${type}`)); } if (!_effectModulePromises.has(moduleConfig.key)) { _effectModulePromises.set(moduleConfig.key, moduleConfig.load()); } return _effectModulePromises.get(moduleConfig.key); } /** * 播放前加载对应特效模块和音效模块。 * * @param {string} type 特效类型 * @returns {Promise} */ function _loadModulesFor(type) { return Promise.all([ _loadEffectModule(type), _loadSoundModule(), ]).then(() => {}); } /** * 记录具体特效返回的取消句柄。 * * @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 (document.hidden) { return; } if (!EFFECT_MODULES[type]) { console.warn(`[EffectManager] 未知特效类型:${type}`); return; } // 防重入:同时只允许一个特效 if (_current) { _enqueue(type); return; } _play(type); } /** * 加载模块后播放指定特效。 * * @param {string} type 特效类型 */ async function _play(type) { _current = type; const token = _playToken; try { await _loadModulesFor(type); } catch (error) { console.error(`[EffectManager] 加载特效模块失败:${type}`, error); _cleanup({ token }); return; } if (token !== _playToken || _current !== type) { return; } if (document.hidden) { _cleanup({ playNext: false, token }); return; } const canvas = _getCanvas(); 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 }); } document.addEventListener("visibilitychange", _handleVisibilityChange); return { play, stop }; })(); window.EffectManager = EffectManager;