Files
chatroom/public/js/effects/effect-manager.js
T

335 lines
11 KiB
JavaScript
Raw Normal View History

/**
* 文件功能:聊天室特效管理器
*
* 统一管理全屏 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')
*/
const EffectManager = (() => {
// 当前正在播放的特效名称(防止同时播放两个特效)
let _current = null;
// 当前特效返回的取消函数,用于手动停止时真正停掉内部 RAF / 定时器
let _currentCancel = null;
// 全屏 Canvas 元素引用
let _canvas = null;
// 待播放特效队列,避免多个进场效果互相打断
const _queue = [];
// 队列最多保留 3 个待播特效,避免高频触发后播放过期动画
const MAX_QUEUE_LENGTH = 3;
2026-04-24 23:15:42 +08:00
// 当前特效播放批次,用于忽略手动停止后的旧回调
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 元素
2026-04-24 23:15:42 +08:00
* 属性: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",
2026-04-24 23:15:42 +08:00
"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);
}
}
/**
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();
}
/**
* 特效结束后清理 Canvas,重置状态,并停止音效。
*
* @param {Object} options 清理选项
* @param {boolean} options.playNext 是否继续播放队列中的下一个特效
* @param {number|null} options.token 当前特效播放批次
*/
function _cleanup({ playNext = true, token = null, cancelCurrent = false } = {}) {
2026-04-24 23:15:42 +08:00
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 EffectSounds !== "undefined") {
EffectSounds.stop();
}
2026-04-24 23:15:42 +08:00
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;
}
/**
* 播放指定特效
*
2026-04-12 16:48:58 +08:00
* @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;
2026-04-24 23:15:42 +08:00
const token = _playToken;
const finishCurrent = () => _cleanup({ token });
_bindClickStop();
// 同步触发对应音效
if (typeof EffectSounds !== "undefined") {
EffectSounds.play(type);
}
let started = false;
try {
switch (type) {
case "fireworks":
started = _startEffect(typeof FireworksEffect !== "undefined" ? FireworksEffect : undefined, canvas, finishCurrent);
break;
case "wedding-fireworks":
// 婚礼专属:双倍礼花,粉金浪漫配色,持续 12 秒
started = _startEffect(typeof FireworksEffect !== "undefined" ? FireworksEffect : undefined, canvas, finishCurrent, "startDouble");
break;
case "rain":
started = _startEffect(typeof RainEffect !== "undefined" ? RainEffect : undefined, canvas, finishCurrent);
break;
case "lightning":
started = _startEffect(typeof LightningEffect !== "undefined" ? LightningEffect : undefined, canvas, finishCurrent);
break;
case "snow":
started = _startEffect(typeof SnowEffect !== "undefined" ? SnowEffect : undefined, canvas, finishCurrent);
break;
case "sakura":
started = _startEffect(typeof SakuraEffect !== "undefined" ? SakuraEffect : undefined, canvas, finishCurrent);
break;
case "meteors":
started = _startEffect(typeof MeteorsEffect !== "undefined" ? MeteorsEffect : undefined, canvas, finishCurrent);
break;
case "gold-rain":
started = _startEffect(typeof GoldRainEffect !== "undefined" ? GoldRainEffect : undefined, canvas, finishCurrent);
break;
case "hearts":
started = _startEffect(typeof HeartsEffect !== "undefined" ? HeartsEffect : undefined, canvas, finishCurrent);
break;
case "confetti":
started = _startEffect(typeof ConfettiEffect !== "undefined" ? ConfettiEffect : undefined, canvas, finishCurrent);
break;
case "fireflies":
started = _startEffect(typeof FirefliesEffect !== "undefined" ? FirefliesEffect : undefined, canvas, finishCurrent);
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-04-24 23:15:42 +08:00
_queue.length = 0;
_cleanup({ playNext: false, cancelCurrent: true });
}
2026-04-24 23:15:42 +08:00
return { play, stop };
})();
window.EffectManager = EffectManager;