收口聊天室安全边界并优化特效生命周期

This commit is contained in:
2026-04-25 02:52:30 +08:00
parent 4d3f4f7a4b
commit 855d031b04
26 changed files with 1219 additions and 175 deletions
+28 -3
View File
@@ -96,6 +96,28 @@ const ConfettiEffect = (() => {
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);
@@ -114,13 +136,16 @@ const ConfettiEffect = (() => {
if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate);
} else {
cancelAnimationFrame(animId);
ctx.clearRect(0, 0, w, h);
onEnd();
finish(false);
}
}
animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
}
return { start };
+139 -65
View File
@@ -9,16 +9,33 @@
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 元素
@@ -41,13 +58,28 @@ const EffectManager = (() => {
"cursor:pointer",
"touch-action:manipulation",
].join(";");
c.width = window.innerWidth;
c.height = window.innerHeight;
_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);
}
}
/**
* 绑定点击屏幕立即停止当前特效的监听。
*
@@ -129,7 +161,7 @@ const EffectManager = (() => {
* @param {boolean} options.playNext 是否继续播放队列中的下一个特效
* @param {number|null} options.token 当前特效播放批次
*/
function _cleanup({ playNext = true, token = null } = {}) {
function _cleanup({ playNext = true, token = null, cancelCurrent = false } = {}) {
if (token !== null && token !== _playToken) {
return;
}
@@ -137,9 +169,16 @@ const EffectManager = (() => {
_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;
// 通知音效引擎停止(兜底:正常情况下音效会自行计时结束)
@@ -155,6 +194,52 @@ const EffectManager = (() => {
}
}
/**
* 将特效加入有限队列,同类型短时间重复触发时只保留一份。
*
* @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;
}
/**
* 播放指定特效
*
@@ -164,7 +249,7 @@ const EffectManager = (() => {
// 防重入:同时只允许一个特效
if (_current) {
console.log(`[EffectManager] 特效 ${_current} 正在播放,加入队列 ${type}`);
_queue.push(type);
_enqueue(type);
return;
}
@@ -179,66 +264,53 @@ const EffectManager = (() => {
EffectSounds.play(type);
}
switch (type) {
case "fireworks":
if (typeof FireworksEffect !== "undefined") {
FireworksEffect.start(canvas, finishCurrent);
}
break;
case "wedding-fireworks":
// 婚礼专属:双倍礼花,粉金浪漫配色,持续 12 秒
if (typeof FireworksEffect !== "undefined") {
FireworksEffect.startDouble(canvas, finishCurrent);
}
break;
case "rain":
if (typeof RainEffect !== "undefined") {
RainEffect.start(canvas, finishCurrent);
}
break;
case "lightning":
if (typeof LightningEffect !== "undefined") {
LightningEffect.start(canvas, finishCurrent);
}
break;
case "snow":
if (typeof SnowEffect !== "undefined") {
SnowEffect.start(canvas, finishCurrent);
}
break;
case "sakura":
if (typeof SakuraEffect !== "undefined") {
SakuraEffect.start(canvas, finishCurrent);
}
break;
case "meteors":
if (typeof MeteorsEffect !== "undefined") {
MeteorsEffect.start(canvas, finishCurrent);
}
break;
case "gold-rain":
if (typeof GoldRainEffect !== "undefined") {
GoldRainEffect.start(canvas, finishCurrent);
}
break;
case "hearts":
if (typeof HeartsEffect !== "undefined") {
HeartsEffect.start(canvas, finishCurrent);
}
break;
case "confetti":
if (typeof ConfettiEffect !== "undefined") {
ConfettiEffect.start(canvas, finishCurrent);
}
break;
case "fireflies":
if (typeof FirefliesEffect !== "undefined") {
FirefliesEffect.start(canvas, finishCurrent);
}
break;
default:
console.warn(`[EffectManager] 未知特效类型:${type}`);
finishCurrent();
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();
}
}
@@ -253,8 +325,10 @@ const EffectManager = (() => {
}
_queue.length = 0;
_cleanup({ playNext: false });
_cleanup({ playNext: false, cancelCurrent: true });
}
return { play, stop };
})();
window.EffectManager = EffectManager;
+27 -3
View File
@@ -126,6 +126,27 @@ const FirefliesEffect = (() => {
const fireflies = Array.from({ length: COUNT }, () => new Firefly(w, h));
const startTime = performance.now();
let animId = null;
let finished = false;
/**
* 统一结束萤火虫动画,手动取消时只清理不回调。
*
* @param {boolean} canceled 是否为手动取消
*/
function finish(canceled) {
if (finished) {
return;
}
finished = true;
if (animId) {
cancelAnimationFrame(animId);
}
ctx.clearRect(0, 0, w, h);
if (!canceled) {
onEnd();
}
}
function animate(now) {
ctx.clearRect(0, 0, w, h);
@@ -138,13 +159,16 @@ const FirefliesEffect = (() => {
if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate);
} else {
cancelAnimationFrame(animId);
ctx.clearRect(0, 0, w, h);
onEnd();
finish(false);
}
}
animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
}
return { start };
+70 -15
View File
@@ -358,6 +358,22 @@ const FireworksEffect = (() => {
}
}
/**
* 在粒子预算内追加粒子,避免主爆炸阶段瞬间超量。
*
* @param {Particle[]} target
* @param {Particle[]} incoming
* @param {number} budget
*/
function _appendParticlesWithinBudget(target, incoming, budget) {
const remaining = Math.max(0, budget - target.length);
if (remaining <= 0) {
return;
}
_appendParticles(target, incoming.slice(0, remaining));
}
/**
* 统一发射一枚火箭。
*
@@ -388,9 +404,14 @@ const FireworksEffect = (() => {
const ctx = canvas.getContext("2d");
const w = canvas.width;
const h = canvas.height;
const isMobile = window.matchMedia?.("(max-width: 640px)")?.matches || window.innerWidth <= 640;
const mobileScale = isMobile ? 0.72 : 1;
const duration = config.duration;
const hardStopAt = duration + 2600;
const peakParticleBudget = config.peakParticleBudget ?? 1650;
const peakParticleBudget = Math.round((config.peakParticleBudget ?? 1650) * mobileScale);
const maxLaunches = Math.max(8, Math.round(config.maxLaunches * mobileScale));
const particleDensity = config.particleDensity * mobileScale;
const secondaryDensity = config.secondaryDensity * mobileScale;
let rockets = [];
let particles = [];
@@ -398,15 +419,17 @@ const FireworksEffect = (() => {
let scheduledBursts = [];
let animId = null;
let launchCnt = 0;
let finished = false;
const timers = [];
const launchInterval = setInterval(() => {
if (launchCnt >= config.maxLaunches) {
if (launchCnt >= maxLaunches) {
clearInterval(launchInterval);
return;
}
const batchSize = config.getBatchSize(launchCnt);
for (let i = 0; i < batchSize && launchCnt < config.maxLaunches; i++) {
for (let i = 0; i < batchSize && launchCnt < maxLaunches; i++) {
_launchRocket(
rockets,
w,
@@ -421,9 +444,9 @@ const FireworksEffect = (() => {
// 开场礼炮先把气氛撑起来,避免一开始太空。
if (typeof config.openingVolley === "function") {
setTimeout(() => {
timers.push(setTimeout(() => {
config.openingVolley(rockets, w, h);
}, 120);
}, 120));
}
const startTime = performance.now();
@@ -440,9 +463,10 @@ const FireworksEffect = (() => {
for (let i = scheduledBursts.length - 1; i >= 0; i--) {
if (scheduledBursts[i].triggerAt <= now) {
const burst = scheduledBursts[i];
_appendParticles(
_appendParticlesWithinBudget(
particles,
_burst(burst.x, burst.y, burst.color, burst.type, burst.density),
peakParticleBudget,
);
halos.push(new Halo(burst.x, burst.y, burst.color, burst.haloRadius));
scheduledBursts.splice(i, 1);
@@ -452,15 +476,16 @@ const FireworksEffect = (() => {
for (let i = rockets.length - 1; i >= 0; i--) {
const rocket = rockets[i];
if (rocket.done) {
_appendParticles(
_appendParticlesWithinBudget(
particles,
_burst(
rocket.x,
rocket.y,
rocket.color,
rocket.type,
config.particleDensity,
particleDensity,
),
peakParticleBudget,
);
halos.push(new Halo(rocket.x, rocket.y, rocket.color, config.primaryHaloRadius));
@@ -475,7 +500,7 @@ const FireworksEffect = (() => {
y: rocket.y + (Math.random() - 0.5) * 26,
color: _pick(config.colors),
type: Math.random() > 0.5 ? "sphere" : "ring",
density: config.secondaryDensity,
density: secondaryDensity,
haloRadius: config.secondaryHaloRadius,
});
}
@@ -502,14 +527,44 @@ const FireworksEffect = (() => {
if (shouldContinue && elapsed < hardStopAt) {
animId = requestAnimationFrame(animate);
} else {
clearInterval(launchInterval);
cancelAnimationFrame(animId);
ctx.clearRect(0, 0, w, h);
onEnd();
finish(false);
}
}
animId = requestAnimationFrame(animate);
/**
* 统一结束烟花演出,取消时不再回调管理器。
*
* @param {boolean} canceled 是否为手动取消
*/
function finish(canceled) {
if (finished) {
return;
}
finished = true;
clearInterval(launchInterval);
timers.forEach((timer) => clearTimeout(timer));
if (animId) {
cancelAnimationFrame(animId);
}
rockets = [];
particles = [];
halos = [];
scheduledBursts = [];
ctx.clearRect(0, 0, w, h);
if (!canceled) {
onEnd();
}
}
return {
cancel() {
finish(true);
},
};
}
/**
@@ -519,7 +574,7 @@ const FireworksEffect = (() => {
* @param {Function} onEnd 特效结束回调
*/
function start(canvas, onEnd) {
_runShow(canvas, onEnd, {
return _runShow(canvas, onEnd, {
duration: 10500,
launchEvery: 340,
maxLaunches: 24,
@@ -577,7 +632,7 @@ const FireworksEffect = (() => {
"#00ddff", // 其他
];
_runShow(canvas, onEnd, {
return _runShow(canvas, onEnd, {
duration: 12400,
launchEvery: 280,
maxLaunches: 34,
+27 -3
View File
@@ -106,6 +106,27 @@ const GoldRainEffect = (() => {
const coins = Array.from({ length: COIN_COUNT }, () => new Coin(w, h));
const startTime = performance.now();
let animId = null;
let finished = false;
/**
* 统一结束金币雨动画,手动取消时只清理不回调。
*
* @param {boolean} canceled 是否为手动取消
*/
function finish(canceled) {
if (finished) {
return;
}
finished = true;
if (animId) {
cancelAnimationFrame(animId);
}
ctx.clearRect(0, 0, w, h);
if (!canceled) {
onEnd();
}
}
function animate(now) {
ctx.clearRect(0, 0, w, h);
@@ -118,13 +139,16 @@ const GoldRainEffect = (() => {
if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate);
} else {
cancelAnimationFrame(animId);
ctx.clearRect(0, 0, w, h);
onEnd();
finish(false);
}
}
animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
}
return { start };
+27 -3
View File
@@ -88,6 +88,27 @@ const HeartsEffect = (() => {
const hearts = Array.from({ length: HEART_COUNT }, () => new Heart(w, h));
const startTime = performance.now();
let animId = null;
let finished = false;
/**
* 统一结束爱心动画,手动取消时只清理不回调。
*
* @param {boolean} canceled 是否为手动取消
*/
function finish(canceled) {
if (finished) {
return;
}
finished = true;
if (animId) {
cancelAnimationFrame(animId);
}
ctx.clearRect(0, 0, w, h);
if (!canceled) {
onEnd();
}
}
function animate(now) {
ctx.clearRect(0, 0, w, h);
@@ -100,13 +121,16 @@ const HeartsEffect = (() => {
if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate);
} else {
cancelAnimationFrame(animId);
ctx.clearRect(0, 0, w, h);
onEnd();
finish(false);
}
}
animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
}
return { start };
+26 -12
View File
@@ -87,7 +87,7 @@ const LightningEffect = (() => {
* @param {HTMLCanvasElement} canvas
* @param {CanvasRenderingContext2D} ctx
*/
function _flash(canvas, ctx) {
function _flash(canvas, ctx, timers) {
const w = canvas.width;
const h = canvas.height;
@@ -122,16 +122,16 @@ const LightningEffect = (() => {
}
// 短促残影:闪电消失后保留一层很淡的余辉,避免“闪一下就没了”。
setTimeout(() => {
timers.push(setTimeout(() => {
ctx.clearRect(0, 0, w, h);
_drawStormGlow(canvas, ctx);
ctx.fillStyle = "rgba(185, 205, 255, 0.12)";
ctx.fillRect(0, 0, w, h);
}, 90);
}, 90));
setTimeout(() => {
timers.push(setTimeout(() => {
ctx.clearRect(0, 0, w, h);
}, 190);
}, 190));
}
/**
@@ -146,6 +146,7 @@ const LightningEffect = (() => {
const DURATION = 7600;
let count = 0;
let finished = false;
const timers = [];
/**
* 统一结束特效,避免多次触发 onEnd。
@@ -156,6 +157,7 @@ const LightningEffect = (() => {
}
finished = true;
timers.forEach((timer) => clearTimeout(timer));
ctx.clearRect(0, 0, canvas.width, canvas.height);
onEnd();
}
@@ -163,29 +165,41 @@ const LightningEffect = (() => {
// 间隔不规则触发多次闪电(模拟真实雷电节奏)
function nextFlash() {
if (count >= FLASHES) {
setTimeout(() => {
timers.push(setTimeout(() => {
finish();
}, 520);
}, 520));
return;
}
_flash(canvas, ctx);
_flash(canvas, ctx, timers);
count++;
// 让雷电节奏有“成组爆发”的感觉:有时连续两下,有时间隔更久。
const delay = Math.random() > 0.65
? 140 + Math.random() * 140
: 420 + Math.random() * 520;
setTimeout(nextFlash, delay);
timers.push(setTimeout(nextFlash, delay));
}
// 短暂延迟后开始第一次闪电
setTimeout(nextFlash, 300);
timers.push(setTimeout(nextFlash, 300));
// 安全兜底:超时强制结束
setTimeout(() => {
timers.push(setTimeout(() => {
finish();
}, DURATION + 500);
}, DURATION + 500));
return {
cancel() {
if (finished) {
return;
}
finished = true;
timers.forEach((timer) => clearTimeout(timer));
ctx.clearRect(0, 0, canvas.width, canvas.height);
},
};
}
return { start };
+27 -3
View File
@@ -140,6 +140,27 @@ const MeteorsEffect = (() => {
const meteors = Array.from({ length: 14 }, () => new Meteor(w, h));
const startTime = performance.now();
let animId = null;
let finished = false;
/**
* 统一结束流星动画,手动取消时只清理不回调。
*
* @param {boolean} canceled 是否为手动取消
*/
function finish(canceled) {
if (finished) {
return;
}
finished = true;
if (animId) {
cancelAnimationFrame(animId);
}
ctx.clearRect(0, 0, w, h);
if (!canceled) {
onEnd();
}
}
function animate(now) {
ctx.clearRect(0, 0, w, h);
@@ -163,13 +184,16 @@ const MeteorsEffect = (() => {
if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate);
} else {
cancelAnimationFrame(animId);
ctx.clearRect(0, 0, w, h);
onEnd();
finish(false);
}
}
animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
}
return { start };
+27 -3
View File
@@ -75,8 +75,29 @@ const RainEffect = (() => {
});
let animId = null;
let finished = false;
const startTime = performance.now();
/**
* 统一结束雨滴动画,手动取消时不触发队列续播。
*
* @param {boolean} canceled 是否为手动取消
*/
function finish(canceled) {
if (finished) {
return;
}
finished = true;
if (animId) {
cancelAnimationFrame(animId);
}
ctx.clearRect(0, 0, w, h);
if (!canceled) {
onEnd();
}
}
function animate(now) {
// 清除画布(透明,不遮挡聊天背景)
ctx.clearRect(0, 0, w, h);
@@ -89,13 +110,16 @@ const RainEffect = (() => {
if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate);
} else {
cancelAnimationFrame(animId);
ctx.clearRect(0, 0, w, h);
onEnd();
finish(false);
}
}
animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
}
return { start };
+27 -3
View File
@@ -93,6 +93,27 @@ const SakuraEffect = (() => {
const petals = Array.from({ length: PETAL_COUNT }, () => new Petal(w, h));
const startTime = performance.now();
let animId = null;
let finished = false;
/**
* 统一结束樱花动画,手动取消时只清理不回调。
*
* @param {boolean} canceled 是否为手动取消
*/
function finish(canceled) {
if (finished) {
return;
}
finished = true;
if (animId) {
cancelAnimationFrame(animId);
}
ctx.clearRect(0, 0, w, h);
if (!canceled) {
onEnd();
}
}
function animate(now) {
ctx.clearRect(0, 0, w, h);
@@ -105,13 +126,16 @@ const SakuraEffect = (() => {
if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate);
} else {
cancelAnimationFrame(animId);
ctx.clearRect(0, 0, w, h);
onEnd();
finish(false);
}
}
animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
}
return { start };
+27 -3
View File
@@ -181,8 +181,29 @@ const SnowEffect = (() => {
}));
let animId = null;
let finished = false;
const startTime = performance.now();
/**
* 统一结束雪花动画,手动取消时只清理不回调。
*
* @param {boolean} canceled 是否为手动取消
*/
function finish(canceled) {
if (finished) {
return;
}
finished = true;
if (animId) {
cancelAnimationFrame(animId);
}
ctx.clearRect(0, 0, w, h);
if (!canceled) {
onEnd();
}
}
function animate(now) {
ctx.clearRect(0, 0, w, h);
@@ -225,13 +246,16 @@ const SnowEffect = (() => {
if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate);
} else {
cancelAnimationFrame(animId);
ctx.clearRect(0, 0, w, h);
onEnd();
finish(false);
}
}
animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
}
return { start };