2026-02-27 14:14:35 +08:00
|
|
|
/**
|
|
|
|
|
* 文件功能:聊天室特效管理器
|
|
|
|
|
*
|
|
|
|
|
* 统一管理全屏 Canvas 特效的入口、防重入和资源清理。
|
2026-04-24 23:15:42 +08:00
|
|
|
* 播放期间用户点击屏幕任意位置可立即结束当前全屏特效。
|
2026-04-12 16:48:58 +08:00
|
|
|
* 使用方式:EffectManager.play('fireworks' | 'rain' | 'lightning' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies')
|
2026-02-27 14:14:35 +08:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const EffectManager = (() => {
|
2026-04-25 03:34:19 +08:00
|
|
|
// 音效模块只在首次播放特效时加载,缓存 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();
|
2026-02-27 14:14:35 +08:00
|
|
|
// 当前正在播放的特效名称(防止同时播放两个特效)
|
|
|
|
|
let _current = null;
|
2026-04-25 02:52:30 +08:00
|
|
|
// 当前特效返回的取消函数,用于手动停止时真正停掉内部 RAF / 定时器
|
|
|
|
|
let _currentCancel = null;
|
2026-02-27 14:14:35 +08:00
|
|
|
// 全屏 Canvas 元素引用
|
|
|
|
|
let _canvas = null;
|
2026-04-11 15:44:30 +08:00
|
|
|
// 待播放特效队列,避免多个进场效果互相打断
|
|
|
|
|
const _queue = [];
|
2026-04-25 02:52:30 +08:00
|
|
|
// 队列最多保留 3 个待播特效,避免高频触发后播放过期动画
|
|
|
|
|
const MAX_QUEUE_LENGTH = 3;
|
2026-04-25 03:34:19 +08:00
|
|
|
// 普通特效排队超过 10 秒后丢弃,避免播放过期动画
|
|
|
|
|
const QUEUED_EFFECT_TTL = 10000;
|
2026-04-24 23:15:42 +08:00
|
|
|
// 当前特效播放批次,用于忽略手动停止后的旧回调
|
|
|
|
|
let _playToken = 0;
|
|
|
|
|
// 是否已经绑定本轮点击停止监听
|
|
|
|
|
let _clickStopBound = false;
|
|
|
|
|
// 延迟绑定定时器,避免触发播放的同一次点击立即停止特效
|
|
|
|
|
let _clickStopTimer = null;
|
2026-04-25 02:52:30 +08:00
|
|
|
// 当前画布像素倍率固定为 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);
|
|
|
|
|
}
|
2026-02-27 14:14:35 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 获取或创建全屏 Canvas 元素
|
2026-04-24 23:15:42 +08:00
|
|
|
* 属性:fixed 定位,覆盖全屏,播放期间接收点击用于立即停止特效。
|
2026-02-27 14:14:35 +08:00
|
|
|
*/
|
|
|
|
|
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",
|
2026-04-24 23:15:42 +08:00
|
|
|
"pointer-events:auto",
|
|
|
|
|
"cursor:pointer",
|
|
|
|
|
"touch-action:manipulation",
|
2026-02-27 14:14:35 +08:00
|
|
|
].join(";");
|
2026-04-25 02:52:30 +08:00
|
|
|
_resizeCanvas(c);
|
2026-02-27 14:14:35 +08:00
|
|
|
document.body.appendChild(c);
|
|
|
|
|
_canvas = c;
|
2026-04-25 02:52:30 +08:00
|
|
|
window.addEventListener("resize", _handleResize);
|
|
|
|
|
window.addEventListener("orientationchange", _handleResize);
|
2026-02-27 14:14:35 +08:00
|
|
|
return c;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 02:52:30 +08:00
|
|
|
/**
|
|
|
|
|
* 响应窗口尺寸变化,确保手机横竖屏切换后覆盖范围正确。
|
|
|
|
|
*/
|
|
|
|
|
function _handleResize() {
|
|
|
|
|
if (_current) {
|
|
|
|
|
stop();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_canvas) {
|
|
|
|
|
_resizeCanvas(_canvas);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 14:14:35 +08:00
|
|
|
/**
|
2026-04-24 23:15:42 +08:00
|
|
|
* 绑定点击屏幕立即停止当前特效的监听。
|
|
|
|
|
*
|
|
|
|
|
* 延后一帧绑定,避免触发特效的同一次点击被误判为结束点击。
|
|
|
|
|
*/
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 03:34:19 +08:00
|
|
|
/**
|
|
|
|
|
* 页面进入后台时停止特效,避免隐藏标签页继续运行 Canvas 和音效。
|
|
|
|
|
*/
|
|
|
|
|
function _handleVisibilityChange() {
|
|
|
|
|
if (document.hidden) {
|
|
|
|
|
stop();
|
|
|
|
|
_queue.length = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 23:15:42 +08:00
|
|
|
/**
|
|
|
|
|
* 特效结束后清理 Canvas,重置状态,并停止音效。
|
|
|
|
|
*
|
|
|
|
|
* @param {Object} options 清理选项
|
|
|
|
|
* @param {boolean} options.playNext 是否继续播放队列中的下一个特效
|
|
|
|
|
* @param {number|null} options.token 当前特效播放批次
|
2026-02-27 14:14:35 +08:00
|
|
|
*/
|
2026-04-25 02:52:30 +08:00
|
|
|
function _cleanup({ playNext = true, token = null, cancelCurrent = false } = {}) {
|
2026-04-24 23:15:42 +08:00
|
|
|
if (token !== null && token !== _playToken) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_playToken++;
|
|
|
|
|
_unbindClickStop();
|
|
|
|
|
|
2026-04-25 02:52:30 +08:00
|
|
|
if (cancelCurrent && typeof _currentCancel === "function") {
|
|
|
|
|
_currentCancel();
|
|
|
|
|
}
|
|
|
|
|
_currentCancel = null;
|
|
|
|
|
|
2026-02-27 14:14:35 +08:00
|
|
|
if (_canvas && document.body.contains(_canvas)) {
|
|
|
|
|
document.body.removeChild(_canvas);
|
|
|
|
|
}
|
2026-04-25 02:52:30 +08:00
|
|
|
window.removeEventListener("resize", _handleResize);
|
|
|
|
|
window.removeEventListener("orientationchange", _handleResize);
|
2026-02-27 14:14:35 +08:00
|
|
|
_canvas = null;
|
|
|
|
|
_current = null;
|
2026-03-01 13:07:36 +08:00
|
|
|
// 通知音效引擎停止(兜底:正常情况下音效会自行计时结束)
|
2026-04-25 03:02:56 +08:00
|
|
|
if (typeof window.EffectSounds !== "undefined") {
|
|
|
|
|
window.EffectSounds.stop();
|
2026-03-01 13:07:36 +08:00
|
|
|
}
|
2026-04-11 15:44:30 +08:00
|
|
|
|
2026-04-25 03:34:19 +08:00
|
|
|
if (playNext) {
|
|
|
|
|
const nextType = _dequeueNextType();
|
2026-04-11 15:44:30 +08:00
|
|
|
if (nextType) {
|
|
|
|
|
play(nextType);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-27 14:14:35 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-25 02:52:30 +08:00
|
|
|
/**
|
|
|
|
|
* 将特效加入有限队列,同类型短时间重复触发时只保留一份。
|
|
|
|
|
*
|
|
|
|
|
* @param {string} type 待播放特效类型
|
|
|
|
|
*/
|
|
|
|
|
function _enqueue(type) {
|
2026-04-25 03:34:19 +08:00
|
|
|
const existingIndex = _queue.findIndex((item) => item.type === type);
|
2026-04-25 02:52:30 +08:00
|
|
|
if (existingIndex !== -1) {
|
|
|
|
|
_queue.splice(existingIndex, 1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 03:34:19 +08:00
|
|
|
_queue.push({
|
|
|
|
|
type,
|
|
|
|
|
queuedAt: Date.now(),
|
|
|
|
|
keepUntilPlayed: type === "wedding-fireworks",
|
|
|
|
|
});
|
2026-04-25 02:52:30 +08:00
|
|
|
while (_queue.length > MAX_QUEUE_LENGTH) {
|
|
|
|
|
_queue.shift();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 03:34:19 +08:00
|
|
|
/**
|
|
|
|
|
* 取出下一个仍然有效的排队特效。
|
|
|
|
|
*
|
|
|
|
|
* @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<void>}
|
|
|
|
|
*/
|
|
|
|
|
function _loadSoundModule() {
|
|
|
|
|
if (!_soundModulePromise) {
|
|
|
|
|
_soundModulePromise = import("./effect-sounds.js");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return _soundModulePromise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 确保指定特效模块已经加载。
|
|
|
|
|
*
|
|
|
|
|
* @param {string} type 特效类型
|
|
|
|
|
* @returns {Promise<void>}
|
|
|
|
|
*/
|
|
|
|
|
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<void>}
|
|
|
|
|
*/
|
|
|
|
|
function _loadModulesFor(type) {
|
|
|
|
|
return Promise.all([
|
|
|
|
|
_loadEffectModule(type),
|
|
|
|
|
_loadSoundModule(),
|
|
|
|
|
]).then(() => {});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 02:52:30 +08:00
|
|
|
/**
|
|
|
|
|
* 记录具体特效返回的取消句柄。
|
|
|
|
|
*
|
|
|
|
|
* @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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 14:14:35 +08:00
|
|
|
/**
|
|
|
|
|
* 播放指定特效
|
|
|
|
|
*
|
2026-04-12 16:48:58 +08:00
|
|
|
* @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
|
2026-02-27 14:14:35 +08:00
|
|
|
*/
|
|
|
|
|
function play(type) {
|
2026-04-25 03:34:19 +08:00
|
|
|
if (document.hidden) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!EFFECT_MODULES[type]) {
|
|
|
|
|
console.warn(`[EffectManager] 未知特效类型:${type}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 14:14:35 +08:00
|
|
|
// 防重入:同时只允许一个特效
|
|
|
|
|
if (_current) {
|
2026-04-25 02:52:30 +08:00
|
|
|
_enqueue(type);
|
2026-02-27 14:14:35 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 03:34:19 +08:00
|
|
|
_play(type);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 加载模块后播放指定特效。
|
|
|
|
|
*
|
|
|
|
|
* @param {string} type 特效类型
|
|
|
|
|
*/
|
|
|
|
|
async function _play(type) {
|
2026-02-27 14:14:35 +08:00
|
|
|
_current = type;
|
2026-04-24 23:15:42 +08:00
|
|
|
const token = _playToken;
|
2026-04-25 03:34:19 +08:00
|
|
|
|
|
|
|
|
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();
|
2026-04-24 23:15:42 +08:00
|
|
|
const finishCurrent = () => _cleanup({ token });
|
|
|
|
|
_bindClickStop();
|
2026-02-27 14:14:35 +08:00
|
|
|
|
2026-03-01 13:07:36 +08:00
|
|
|
// 同步触发对应音效
|
2026-04-25 03:02:56 +08:00
|
|
|
if (typeof window.EffectSounds !== "undefined") {
|
|
|
|
|
window.EffectSounds.play(type);
|
2026-03-01 13:07:36 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-25 02:52:30 +08:00
|
|
|
let started = false;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
switch (type) {
|
|
|
|
|
case "fireworks":
|
2026-04-25 03:02:56 +08:00
|
|
|
started = _startEffect(window.FireworksEffect, canvas, finishCurrent);
|
2026-04-25 02:52:30 +08:00
|
|
|
break;
|
|
|
|
|
case "wedding-fireworks":
|
|
|
|
|
// 婚礼专属:双倍礼花,粉金浪漫配色,持续 12 秒
|
2026-04-25 03:02:56 +08:00
|
|
|
started = _startEffect(window.FireworksEffect, canvas, finishCurrent, "startDouble");
|
2026-04-25 02:52:30 +08:00
|
|
|
break;
|
|
|
|
|
case "rain":
|
2026-04-25 03:02:56 +08:00
|
|
|
started = _startEffect(window.RainEffect, canvas, finishCurrent);
|
2026-04-25 02:52:30 +08:00
|
|
|
break;
|
|
|
|
|
case "lightning":
|
2026-04-25 03:02:56 +08:00
|
|
|
started = _startEffect(window.LightningEffect, canvas, finishCurrent);
|
2026-04-25 02:52:30 +08:00
|
|
|
break;
|
|
|
|
|
case "snow":
|
2026-04-25 03:02:56 +08:00
|
|
|
started = _startEffect(window.SnowEffect, canvas, finishCurrent);
|
2026-04-25 02:52:30 +08:00
|
|
|
break;
|
|
|
|
|
case "sakura":
|
2026-04-25 03:02:56 +08:00
|
|
|
started = _startEffect(window.SakuraEffect, canvas, finishCurrent);
|
2026-04-25 02:52:30 +08:00
|
|
|
break;
|
|
|
|
|
case "meteors":
|
2026-04-25 03:02:56 +08:00
|
|
|
started = _startEffect(window.MeteorsEffect, canvas, finishCurrent);
|
2026-04-25 02:52:30 +08:00
|
|
|
break;
|
|
|
|
|
case "gold-rain":
|
2026-04-25 03:02:56 +08:00
|
|
|
started = _startEffect(window.GoldRainEffect, canvas, finishCurrent);
|
2026-04-25 02:52:30 +08:00
|
|
|
break;
|
|
|
|
|
case "hearts":
|
2026-04-25 03:02:56 +08:00
|
|
|
started = _startEffect(window.HeartsEffect, canvas, finishCurrent);
|
2026-04-25 02:52:30 +08:00
|
|
|
break;
|
|
|
|
|
case "confetti":
|
2026-04-25 03:02:56 +08:00
|
|
|
started = _startEffect(window.ConfettiEffect, canvas, finishCurrent);
|
2026-04-25 02:52:30 +08:00
|
|
|
break;
|
|
|
|
|
case "fireflies":
|
2026-04-25 03:02:56 +08:00
|
|
|
started = _startEffect(window.FirefliesEffect, canvas, finishCurrent);
|
2026-04-25 02:52:30 +08:00
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
console.warn(`[EffectManager] 未知特效类型:${type}`);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`[EffectManager] 启动特效失败:${type}`, error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!started) {
|
|
|
|
|
finishCurrent();
|
2026-04-24 23:15:42 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 用户手动停止当前全屏特效。
|
|
|
|
|
*
|
|
|
|
|
* 会立即移除画布、停止音效,并清空排队中的后续特效。
|
|
|
|
|
*/
|
|
|
|
|
function stop() {
|
|
|
|
|
if (!_current) {
|
|
|
|
|
return;
|
2026-02-27 14:14:35 +08:00
|
|
|
}
|
2026-04-24 23:15:42 +08:00
|
|
|
|
|
|
|
|
_queue.length = 0;
|
2026-04-25 02:52:30 +08:00
|
|
|
_cleanup({ playNext: false, cancelCurrent: true });
|
2026-02-27 14:14:35 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-25 03:34:19 +08:00
|
|
|
document.addEventListener("visibilitychange", _handleVisibilityChange);
|
|
|
|
|
|
2026-04-24 23:15:42 +08:00
|
|
|
return { play, stop };
|
2026-02-27 14:14:35 +08:00
|
|
|
})();
|
2026-04-25 02:52:30 +08:00
|
|
|
|
|
|
|
|
window.EffectManager = EffectManager;
|