From e3cba255f9d27576a269f032ad4c80a676ea3b3a Mon Sep 17 00:00:00 2001 From: lkddi Date: Sat, 25 Apr 2026 03:34:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=81=8A=E5=A4=A9=E5=AE=A4?= =?UTF-8?q?=E7=89=B9=E6=95=88=E5=8A=A0=E8=BD=BD=E4=B8=8E=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/js/effects.js | 13 +-- resources/js/effects/confetti.js | 7 +- resources/js/effects/effect-manager.js | 146 ++++++++++++++++++++++++- resources/js/effects/gold-rain.js | 4 +- resources/js/effects/rain.js | 3 +- resources/js/effects/snow.js | 10 +- 6 files changed, 158 insertions(+), 25 deletions(-) diff --git a/resources/js/effects.js b/resources/js/effects.js index 49466fc..295489b 100644 --- a/resources/js/effects.js +++ b/resources/js/effects.js @@ -1,18 +1,7 @@ /** * 文件功能:聊天室全屏特效 Vite 入口 * - * 按原 public script 顺序加载所有全屏特效模块,让生产环境由 Vite 统一压缩、加 hash 并减少请求数。 + * 首屏仅加载特效管理器,具体特效模块和音效模块由管理器在首次播放时按需加载。 */ -import "./effects/effect-sounds.js"; import "./effects/effect-manager.js"; -import "./effects/fireworks.js"; -import "./effects/rain.js"; -import "./effects/lightning.js"; -import "./effects/snow.js"; -import "./effects/sakura.js"; -import "./effects/meteors.js"; -import "./effects/gold-rain.js"; -import "./effects/hearts.js"; -import "./effects/confetti.js"; -import "./effects/fireflies.js"; diff --git a/resources/js/effects/confetti.js b/resources/js/effects/confetti.js index c2a443a..c119ca0 100644 --- a/resources/js/effects/confetti.js +++ b/resources/js/effects/confetti.js @@ -92,7 +92,10 @@ const ConfettiEffect = (() => { const w = canvas.width; const h = canvas.height; const DURATION = 7800; - let pieces = Array.from({ length: 90 }, () => new Piece(w, h)); + const isMobile = window.matchMedia?.("(max-width: 640px)")?.matches || window.innerWidth <= 640; + const initialPieceCount = isMobile ? 56 : 90; + const spawnPieceCount = isMobile ? 6 : 10; + let pieces = Array.from({ length: initialPieceCount }, () => new Piece(w, h)); const startTime = performance.now(); let lastSpawnAt = startTime; let animId = null; @@ -129,7 +132,7 @@ const ConfettiEffect = (() => { }); if (now - startTime < DURATION * 0.9 && now - lastSpawnAt >= 120) { - pieces.push(...Array.from({ length: 10 }, () => new Piece(w, h))); + pieces.push(...Array.from({ length: spawnPieceCount }, () => new Piece(w, h))); lastSpawnAt = now; } diff --git a/resources/js/effects/effect-manager.js b/resources/js/effects/effect-manager.js index 767798c..32ad9f3 100644 --- a/resources/js/effects/effect-manager.js +++ b/resources/js/effects/effect-manager.js @@ -7,6 +7,24 @@ */ 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 / 定时器 @@ -17,6 +35,8 @@ const EffectManager = (() => { const _queue = []; // 队列最多保留 3 个待播特效,避免高频触发后播放过期动画 const MAX_QUEUE_LENGTH = 3; + // 普通特效排队超过 10 秒后丢弃,避免播放过期动画 + const QUEUED_EFFECT_TTL = 10000; // 当前特效播放批次,用于忽略手动停止后的旧回调 let _playToken = 0; // 是否已经绑定本轮点击停止监听 @@ -154,6 +174,16 @@ const EffectManager = (() => { stop(); } + /** + * 页面进入后台时停止特效,避免隐藏标签页继续运行 Canvas 和音效。 + */ + function _handleVisibilityChange() { + if (document.hidden) { + stop(); + _queue.length = 0; + } + } + /** * 特效结束后清理 Canvas,重置状态,并停止音效。 * @@ -186,8 +216,8 @@ const EffectManager = (() => { window.EffectSounds.stop(); } - if (playNext && _queue.length > 0) { - const nextType = _queue.shift(); + if (playNext) { + const nextType = _dequeueNextType(); if (nextType) { play(nextType); } @@ -200,17 +230,84 @@ const EffectManager = (() => { * @param {string} type 待播放特效类型 */ function _enqueue(type) { - const existingIndex = _queue.indexOf(type); + const existingIndex = _queue.findIndex((item) => item.type === type); if (existingIndex !== -1) { _queue.splice(existingIndex, 1); } - _queue.push(type); + _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(() => {}); + } + /** * 记录具体特效返回的取消句柄。 * @@ -246,16 +343,51 @@ const EffectManager = (() => { * @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) { - console.log(`[EffectManager] 特效 ${_current} 正在播放,加入队列 ${type}`); _enqueue(type); return; } - const canvas = _getCanvas(); + _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(); @@ -328,6 +460,8 @@ const EffectManager = (() => { _cleanup({ playNext: false, cancelCurrent: true }); } + document.addEventListener("visibilitychange", _handleVisibilityChange); + return { play, stop }; })(); diff --git a/resources/js/effects/gold-rain.js b/resources/js/effects/gold-rain.js index 32ef249..da035a7 100644 --- a/resources/js/effects/gold-rain.js +++ b/resources/js/effects/gold-rain.js @@ -102,7 +102,9 @@ const GoldRainEffect = (() => { const w = canvas.width; const h = canvas.height; const DURATION = 8600; - const COIN_COUNT = Math.min(58, Math.max(28, Math.floor(w / 28))); + const isMobile = window.matchMedia?.("(max-width: 640px)")?.matches || window.innerWidth <= 640; + const densityScale = isMobile ? 0.72 : 1; + const COIN_COUNT = Math.round(Math.min(58, Math.max(28, Math.floor(w / 28))) * densityScale); const coins = Array.from({ length: COIN_COUNT }, () => new Coin(w, h)); const startTime = performance.now(); let animId = null; diff --git a/resources/js/effects/rain.js b/resources/js/effects/rain.js index 4bb742b..2737cc6 100644 --- a/resources/js/effects/rain.js +++ b/resources/js/effects/rain.js @@ -66,7 +66,8 @@ const RainEffect = (() => { const w = canvas.width; const h = canvas.height; const DURATION = 8000; - const DROP_COUNT = 200; // 增加雨滴数量(原 180) + const isMobile = window.matchMedia?.("(max-width: 640px)")?.matches || window.innerWidth <= 640; + const DROP_COUNT = isMobile ? 130 : 200; // 移动端降低雨线数量,避免满屏线段拖慢滚动和输入。 const drops = Array.from({ length: DROP_COUNT }, () => { const d = new Drop(w, h); diff --git a/resources/js/effects/snow.js b/resources/js/effects/snow.js index 208d5cd..ec73805 100644 --- a/resources/js/effects/snow.js +++ b/resources/js/effects/snow.js @@ -161,18 +161,22 @@ const SnowEffect = (() => { const w = canvas.width; const h = canvas.height; const DURATION = 10000; + const isMobile = window.matchMedia?.("(max-width: 640px)")?.matches || window.innerWidth <= 640; + const densityScale = isMobile ? 0.68 : 1; + const backFlakeCount = Math.round(Math.min(120, Math.max(70, Math.floor(w / 18))) * densityScale); + const frontFlakeCount = Math.round(Math.min(64, Math.max(34, Math.floor(w / 42))) * densityScale); const flakes = [ ...Array.from( - { length: Math.min(120, Math.max(70, Math.floor(w / 18))) }, + { length: backFlakeCount }, () => new Flake(w, h, "back"), ), ...Array.from( - { length: Math.min(64, Math.max(34, Math.floor(w / 42))) }, + { length: frontFlakeCount }, () => new Flake(w, h, "front"), ), ]; - const breezeBands = Array.from({ length: 2 }, () => ({ + const breezeBands = Array.from({ length: isMobile ? 1 : 2 }, () => ({ x: Math.random() * w, y: Math.random() * h, radius: 180 + Math.random() * 140,