diff --git a/core/main_terminal.py b/core/main_terminal.py index 1651ea7..5fd7895 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -1472,13 +1472,13 @@ class MainTerminal: "type": "function", "function": { "name": "trigger_easter_egg", - "description": "触发隐藏彩蛋,用于展示非功能性特效。需指定 effect 参数(例如 flood 表示灌水)。", + "description": "触发隐藏彩蛋,用于展示非功能性特效。需指定 effect 参数,例如 flood(灌水)或 snake(贪吃蛇)。", "parameters": { "type": "object", "properties": { "effect": { "type": "string", - "description": "彩蛋标识,当前支持 flood(灌水)。" + "description": "彩蛋标识,目前支持 flood(灌水)与 snake(贪吃蛇)。" } }, "required": ["effect"] diff --git a/modules/easter_egg_manager.py b/modules/easter_egg_manager.py index 5e8a579..9dce557 100644 --- a/modules/easter_egg_manager.py +++ b/modules/easter_egg_manager.py @@ -12,7 +12,7 @@ class EasterEggManager: """管理隐藏彩蛋效果的触发逻辑。""" def __init__(self) -> None: - # 目前仅有一个“灌水”特效,后续可在此扩展 + # 预置彩蛋效果元数据,新增特效可在此扩展 self.effects: Dict[str, Dict[str, object]] = { "flood": { "label": "灌水", @@ -21,7 +21,16 @@ class EasterEggManager: "duration_seconds": 45, "intensity_range": (0.87, 0.93), "notes": "特效为半透明覆盖层,不会阻挡交互。", - } + }, + "snake": { + "label": "贪吃蛇", + "aliases": ["snake", "贪吃蛇", "snakegame", "snake_game"], + "message": "发光的丝带贪吃蛇追逐苹果,吃满 20 个后会一路远行离开屏幕。", + "duration_seconds": 200, + "notes": "动画为独立 Canvas,不影响页面点击。", + "apples_target": 20, + "initial_apples": 3, + }, } def trigger_effect(self, effect: str) -> Dict[str, object]: @@ -45,11 +54,12 @@ class EasterEggManager: "success": True, "effect": effect_id, "display_name": metadata.get("label", effect_id), - "message": metadata.get("message"), - "duration_seconds": metadata.get("duration_seconds", 30), - "intensity_range": metadata.get("intensity_range"), - "notes": metadata.get("notes"), } + payload.setdefault("duration_seconds", 30) + for key, value in metadata.items(): + if key in {"label", "aliases"}: + continue + payload[key] = value return payload return self._build_error(f"未知彩蛋: {effect_key}") diff --git a/static/app.js b/static/app.js index 883adb9..cccd8c3 100644 --- a/static/app.js +++ b/static/app.js @@ -119,21 +119,6 @@ const TOOL_CATEGORY_ICON_MAP = Object.freeze({ easter_egg: 'sparkles' }); -function randRange(min, max) { - return Math.random() * (max - min) + min; -} - -function randInt(min, max) { - return Math.floor(randRange(min, max + 1)); -} - -function pickOne(arr) { - if (!Array.isArray(arr) || arr.length === 0) { - return null; - } - return arr[Math.floor(Math.random() * arr.length)]; -} - function injectScriptSequentially(urls, onSuccess, onFailure) { let index = 0; const tryLoad = () => { @@ -323,9 +308,11 @@ async function bootstrapApp() { easterEgg: { active: false, effect: null, + payload: null, + instance: null, cleanupTimer: null, - styleNode: null, - retreating: false + destroying: false, + destroyPromise: null }, // 右键菜单相关 @@ -414,8 +401,10 @@ async function bootstrapApp() { clearInterval(this.subAgentPollTimer); this.subAgentPollTimer = null; } - this.destroyEasterEggEffect(); - this.finishEasterEggCleanup(); + const cleanup = this.destroyEasterEggEffect(true); + if (cleanup && typeof cleanup.catch === 'function') { + cleanup.catch(() => {}); + } }, watch: { @@ -1102,7 +1091,12 @@ async function bootstrapApp() { targetAction.tool.result = data.result; } if (targetAction.tool && targetAction.tool.name === 'trigger_easter_egg' && data.result !== undefined) { - this.handleEasterEggPayload(data.result); + const eggPromise = this.handleEasterEggPayload(data.result); + if (eggPromise && typeof eggPromise.catch === 'function') { + eggPromise.catch((error) => { + console.warn('彩蛋处理异常:', error); + }); + } } if (data.message !== undefined) { targetAction.tool.message = data.message; @@ -3262,7 +3256,7 @@ async function bootstrapApp() { document.body.style.cursor = ''; }, - handleEasterEggPayload(payload) { + async handleEasterEggPayload(payload) { if (!payload) { return; } @@ -3282,212 +3276,125 @@ async function bootstrapApp() { if (parsed.error) { console.warn('彩蛋触发失败:', parsed.error); } - this.destroyEasterEggEffect(); + await this.destroyEasterEggEffect(true); return; } const effectName = (parsed.effect || '').toLowerCase(); - if (effectName === 'flood') { - this.startFloodEffect(parsed); + if (!effectName) { + console.warn('彩蛋结果缺少 effect 字段'); + return; } + await this.startEasterEggEffect(effectName, parsed); }, - startFloodEffect(payload = {}) { - this.clearFloodAnimations(); - this.teardownEasterEggStyle(); + async startEasterEggEffect(effectName, payload = {}) { + const registry = window.EasterEggRegistry; + if (!registry) { + console.warn('EasterEggRegistry 尚未加载,无法播放彩蛋'); + return; + } + if (!registry.has(effectName)) { + console.warn('未注册的彩蛋 effect:', effectName); + await this.destroyEasterEggEffect(true); + return; + } + const root = this.$refs.easterEggRoot; + if (!root) { + console.warn('未找到彩蛋根节点'); + return; + } + await this.destroyEasterEggEffect(true); this.easterEgg.active = true; - this.easterEgg.effect = 'flood'; - this.easterEgg.retreating = false; - if (this.easterEgg.cleanupTimer) { - clearTimeout(this.easterEgg.cleanupTimer); - this.easterEgg.cleanupTimer = null; - } - this.$nextTick(() => { - this.runFloodAnimation(payload); + this.easterEgg.effect = effectName; + this.easterEgg.payload = payload; + const instance = registry.start(effectName, { + root, + payload, + app: this }); - const durationSeconds = Math.max(8, Number(payload.duration_seconds) || 45); - this.easterEgg.cleanupTimer = setTimeout(() => { - this.destroyEasterEggEffect(); - }, durationSeconds * 1000); - }, - - runFloodAnimation(payload = {}) { - const container = this.$refs.easterEggWaterContainer; - if (!container) { - return; - } - const waves = container.querySelectorAll('.wave'); - if (!waves.length) { - return; - } - const styleEl = document.createElement('style'); - styleEl.setAttribute('data-easter-egg', 'flood'); - document.head.appendChild(styleEl); - this.easterEgg.styleNode = styleEl; - const sheet = styleEl.sheet; - if (!sheet) { - return; - } - container.style.animation = 'none'; - container.style.height = '0%'; - void container.offsetHeight; - - const range = Array.isArray(payload.intensity_range) && payload.intensity_range.length === 2 - ? payload.intensity_range - : [0.85, 0.92]; - const minHeight = Math.min(range[0], range[1]); - const maxHeight = Math.max(range[0], range[1]); - const targetHeight = randRange(minHeight * 100, maxHeight * 100); - const riseDuration = randRange(30, 40); - const easing = pickOne([ - 'ease-in-out', - 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', - 'cubic-bezier(0.42, 0, 0.58, 1)' - ]) || 'ease-in-out'; - const riseName = `easter_egg_rise_${Date.now()}`; - sheet.insertRule(`@keyframes ${riseName} { 0% { height: 0%; } 100% { height: ${targetHeight}%; } }`, sheet.cssRules.length); - container.style.animation = `${riseName} ${riseDuration}s ${easing} forwards`; - - const directionSets = [ - [1, 1, -1], - [1, -1, -1], - [-1, 1, 1], - [-1, -1, 1] - ]; - const directions = pickOne(directionSets) || [1, -1, 1]; - const colors = [ - 'rgba(135, 206, 250, 0.35)', - 'rgba(100, 181, 246, 0.45)', - 'rgba(33, 150, 243, 0.4)' - ]; - - waves.forEach((wave, index) => { - const svgData = this.buildFloodWaveShape(index); - const color = colors[index % colors.length]; - const svg = ``; - const encoded = encodeURIComponent(svg); - wave.style.backgroundImage = `url("data:image/svg+xml,${encoded}")`; - wave.style.backgroundSize = `${svgData.waveWidth}px 100%`; - - const animationName = `easter_egg_wave_${index}_${Date.now()}`; - const startPosition = randInt(-200, 200); - const distance = svgData.waveWidth * (directions[index] || 1); - const duration = index === 0 ? randRange(16, 22) : index === 1 ? randRange(11, 16) : randRange(7, 12); - const delay = randRange(0, 1.5); - sheet.insertRule(`@keyframes ${animationName} { 0% { background-position-x: ${startPosition}px; } 100% { background-position-x: ${startPosition + distance}px; } }`, sheet.cssRules.length); - wave.style.animation = `${animationName} ${duration}s linear infinite`; - wave.style.animationDelay = `${delay}s`; - wave.style.backgroundPositionX = `${startPosition}px`; - }); - }, - - buildFloodWaveShape(layerIndex) { - const baseHeight = 180 + layerIndex * 12; - const cycles = 4; - let path = `M0,${baseHeight}`; - let currentX = 0; - let previousAmplitude = randRange(40, 80); - - for (let i = 0; i < cycles; i++) { - const waveLength = randRange(700, 900); - const minAmp = Math.max(20, previousAmplitude - 20); - const maxAmp = Math.min(90, previousAmplitude + 20); - const amplitude = randRange(minAmp, maxAmp); - previousAmplitude = amplitude; - const halfWave = waveLength / 2; - - const peakX = currentX + halfWave / 2; - path += ` Q${peakX},${baseHeight - amplitude} ${currentX + halfWave},${baseHeight}`; - - const troughX = currentX + halfWave + halfWave / 2; - path += ` Q${troughX},${baseHeight + amplitude} ${currentX + waveLength},${baseHeight}`; - - currentX += waveLength; - } - - path += ` L${currentX},1000 L0,1000 Z`; - return { - path, - width: currentX, - waveWidth: currentX / cycles - }; - }, - - clearFloodAnimations() { - const container = this.$refs.easterEggWaterContainer; - if (!container) { - return; - } - container.style.animation = 'none'; - container.style.transition = 'none'; - container.style.height = '0%'; - const waves = container.querySelectorAll('.wave'); - waves.forEach((wave) => { - wave.style.animation = 'none'; - wave.style.backgroundImage = ''; - wave.style.backgroundSize = ''; - wave.style.backgroundPositionX = '0px'; - }); - }, - - teardownEasterEggStyle() { - if (this.easterEgg.styleNode && this.easterEgg.styleNode.parentNode) { - this.easterEgg.styleNode.parentNode.removeChild(this.easterEgg.styleNode); - } - this.easterEgg.styleNode = null; - }, - - destroyEasterEggEffect() { - if (this.easterEgg.cleanupTimer) { - clearTimeout(this.easterEgg.cleanupTimer); - this.easterEgg.cleanupTimer = null; - } - if (this.easterEgg.effect === 'flood' && this.easterEgg.active && !this.easterEgg.retreating) { - this.startFloodRetreat(); - return; - } - if (this.easterEgg.effect === 'flood' && this.easterEgg.retreating) { - return; - } - this.finishEasterEggCleanup(); - }, - - startFloodRetreat() { - const container = this.$refs.easterEggWaterContainer; - if (!container) { + if (!instance) { this.finishEasterEggCleanup(); return; } - this.easterEgg.retreating = true; - const measuredHeight = container.offsetHeight || container.clientHeight; - const computedHeight = window.getComputedStyle(container).height; - const currentHeight = measuredHeight - ? `${measuredHeight}px` - : (computedHeight && computedHeight !== 'auto' ? computedHeight : '0px'); - container.style.animation = 'none'; - container.style.transition = 'none'; - container.style.height = currentHeight; - void container.offsetHeight; - const retreatDuration = 8; - container.style.transition = `height ${retreatDuration}s ease-in-out`; - requestAnimationFrame(() => { - container.style.height = '0px'; - }); + this.easterEgg.instance = instance; + this.easterEgg.destroyPromise = null; + this.easterEgg.destroying = false; + if (this.easterEgg.cleanupTimer) { + clearTimeout(this.easterEgg.cleanupTimer); + } + const durationSeconds = Math.max(8, Number(payload.duration_seconds) || 45); this.easterEgg.cleanupTimer = setTimeout(() => { - container.style.transition = 'none'; - this.clearFloodAnimations(); - this.teardownEasterEggStyle(); - this.easterEgg.active = false; - this.easterEgg.effect = null; - this.easterEgg.retreating = false; - }, retreatDuration * 1000); + const cleanup = this.destroyEasterEggEffect(false); + if (cleanup && typeof cleanup.catch === 'function') { + cleanup.catch(() => {}); + } + }, durationSeconds * 1000); + if (payload.message) { + console.info(`[彩蛋] ${payload.display_name || effectName}: ${payload.message}`); + } + }, + + destroyEasterEggEffect(forceImmediate = false) { + if (this.easterEgg.cleanupTimer) { + clearTimeout(this.easterEgg.cleanupTimer); + this.easterEgg.cleanupTimer = null; + } + const instance = this.easterEgg.instance; + if (!instance) { + this.finishEasterEggCleanup(); + return Promise.resolve(); + } + if (this.easterEgg.destroying) { + return this.easterEgg.destroyPromise || Promise.resolve(); + } + this.easterEgg.destroying = true; + let result; + try { + result = instance.destroy({ + immediate: forceImmediate, + payload: this.easterEgg.payload, + root: this.$refs.easterEggRoot || null + }); + } catch (error) { + console.warn('销毁彩蛋时发生错误:', error); + this.easterEgg.destroying = false; + this.finishEasterEggCleanup(); + return Promise.resolve(); + } + const finalize = () => { + this.easterEgg.destroyPromise = null; + this.easterEgg.destroying = false; + this.finishEasterEggCleanup(); + }; + if (result && typeof result.then === 'function') { + this.easterEgg.destroyPromise = result.then(() => { + finalize(); + }).catch((error) => { + console.warn('彩蛋清理失败:', error); + finalize(); + }); + return this.easterEgg.destroyPromise; + } else { + finalize(); + return Promise.resolve(); + } }, finishEasterEggCleanup() { - this.clearFloodAnimations(); - this.teardownEasterEggStyle(); + if (this.easterEgg.cleanupTimer) { + clearTimeout(this.easterEgg.cleanupTimer); + this.easterEgg.cleanupTimer = null; + } + const root = this.$refs.easterEggRoot; + if (root) { + root.innerHTML = ''; + } this.easterEgg.active = false; this.easterEgg.effect = null; - this.easterEgg.retreating = false; + this.easterEgg.payload = null; + this.easterEgg.instance = null; + this.easterEgg.destroyPromise = null; + this.easterEgg.destroying = false; }, // 格式化token显示(修复NaN问题) diff --git a/static/easter-eggs/flood.css b/static/easter-eggs/flood.css new file mode 100644 index 0000000..4629b7c --- /dev/null +++ b/static/easter-eggs/flood.css @@ -0,0 +1,38 @@ +.easter-egg-overlay .easter-egg-water { + position: relative; + width: 100%; + height: 100%; +} + +.easter-egg-overlay .easter-egg-water-container { + position: absolute; + bottom: -2px; + left: 0; + width: 100%; + height: 0%; + overflow: visible; + pointer-events: none; +} + +.easter-egg-overlay .easter-egg-water-container .wave { + position: absolute; + bottom: 0; + left: 0; + width: 200%; + height: 100%; + background-repeat: repeat-x; + background-position: bottom; + opacity: 0.9; +} + +.easter-egg-overlay .easter-egg-water-container .wave.wave1 { + filter: blur(0.5px); +} + +.easter-egg-overlay .easter-egg-water-container .wave.wave2 { + filter: blur(1px); +} + +.easter-egg-overlay .easter-egg-water-container .wave.wave3 { + filter: blur(1.5px); +} diff --git a/static/easter-eggs/flood.js b/static/easter-eggs/flood.js new file mode 100644 index 0000000..24f4df4 --- /dev/null +++ b/static/easter-eggs/flood.js @@ -0,0 +1,267 @@ +(function registerFloodEffect(global) { + const registry = global.EasterEggRegistry; + if (!registry) { + console.error('[easter-eggs:flood] 未找到 EasterEggRegistry,无法注册特效'); + return; + } + + registry.register('flood', function createFloodEffect(context = {}) { + const root = context.root; + if (!root) { + throw new Error('缺少彩蛋根节点'); + } + + const payload = context.payload || {}; + const wrapper = document.createElement('div'); + wrapper.className = 'easter-egg-water'; + + const container = document.createElement('div'); + container.className = 'easter-egg-water-container'; + + wrapper.appendChild(container); + root.appendChild(wrapper); + + const waves = createWaves(container); + + const state = { + payload, + root, + wrapper, + container, + waves, + styleNode: null, + retreatPromise: null, + retreatTimeout: null + }; + + runFloodAnimation(state); + + return { + /** + * 销毁特效。 + * @param {{immediate?: boolean}} options + */ + destroy(options = {}) { + const immediate = Boolean(options.immediate); + if (immediate) { + cleanupFloodState(state); + return null; + } + if (state.retreatPromise) { + return state.retreatPromise; + } + return startFloodRetreat(state); + } + }; + }); + + function createWaves(container) { + const waves = []; + for (let i = 1; i <= 3; i++) { + const wave = document.createElement('div'); + wave.className = `wave wave${i}`; + container.appendChild(wave); + waves.push(wave); + } + return waves; + } + + function runFloodAnimation(state) { + const { container, waves, payload } = state; + if (!container || !waves.length) { + return; + } + + const styleEl = document.createElement('style'); + styleEl.setAttribute('data-easter-egg', 'flood'); + document.head.appendChild(styleEl); + state.styleNode = styleEl; + const sheet = styleEl.sheet; + if (!sheet) { + return; + } + + container.style.animation = 'none'; + container.style.height = '0%'; + void container.offsetHeight; + + const range = Array.isArray(payload.intensity_range) && payload.intensity_range.length === 2 + ? payload.intensity_range + : [0.85, 0.92]; + const minHeight = Math.min(range[0], range[1]); + const maxHeight = Math.max(range[0], range[1]); + const targetHeight = randRange(minHeight * 100, maxHeight * 100); + const riseDuration = randRange(30, 40); + const easing = pickOne([ + 'ease-in-out', + 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', + 'cubic-bezier(0.42, 0, 0.58, 1)' + ]) || 'ease-in-out'; + const riseName = uniqueKey('flood_rise'); + sheet.insertRule( + `@keyframes ${riseName} { 0% { height: 0%; } 100% { height: ${targetHeight}%; } }`, + sheet.cssRules.length + ); + container.style.animation = `${riseName} ${riseDuration}s ${easing} forwards`; + + const directionSets = [ + [1, 1, -1], + [1, -1, -1], + [-1, 1, 1], + [-1, -1, 1] + ]; + const directions = pickOne(directionSets) || [1, -1, 1]; + const colors = [ + 'rgba(135, 206, 250, 0.35)', + 'rgba(100, 181, 246, 0.45)', + 'rgba(33, 150, 243, 0.4)' + ]; + + waves.forEach((wave, index) => { + const svgData = buildFloodWaveShape(index); + const color = colors[index % colors.length]; + const svg = ``; + const encoded = encodeURIComponent(svg); + wave.style.backgroundImage = `url("data:image/svg+xml,${encoded}")`; + wave.style.backgroundSize = `${svgData.waveWidth}px 100%`; + + const animationName = uniqueKey(`flood_wave_${index}`); + const startPosition = randInt(-200, 200); + const distance = svgData.waveWidth * (directions[index] || 1); + const duration = index === 0 ? randRange(16, 22) : index === 1 ? randRange(11, 16) : randRange(7, 12); + const delay = randRange(0, 1.5); + sheet.insertRule( + `@keyframes ${animationName} { 0% { background-position-x: ${startPosition}px; } 100% { background-position-x: ${startPosition + distance}px; } }`, + sheet.cssRules.length + ); + wave.style.animation = `${animationName} ${duration}s linear infinite`; + wave.style.animationDelay = `${delay}s`; + wave.style.backgroundPositionX = `${startPosition}px`; + }); + } + + function startFloodRetreat(state) { + const { container } = state; + if (!container) { + cleanupFloodState(state); + return Promise.resolve(); + } + + state.retreatPromise = new Promise((resolve) => { + const measuredHeight = container.offsetHeight || container.clientHeight; + const computedHeight = window.getComputedStyle(container).height; + const currentHeight = measuredHeight + ? `${measuredHeight}px` + : (computedHeight && computedHeight !== 'auto' ? computedHeight : '0px'); + container.style.animation = 'none'; + container.style.transition = 'none'; + container.style.height = currentHeight; + void container.offsetHeight; + + const retreatDuration = 8; + container.style.transition = `height ${retreatDuration}s ease-in-out`; + requestAnimationFrame(() => { + container.style.height = '0px'; + }); + + state.retreatTimeout = window.setTimeout(() => { + container.style.transition = 'none'; + cleanupFloodState(state); + resolve(); + }, retreatDuration * 1000); + }); + + return state.retreatPromise; + } + + function cleanupFloodState(state) { + if (state.retreatTimeout) { + clearTimeout(state.retreatTimeout); + state.retreatTimeout = null; + } + clearFloodAnimations(state); + teardownFloodStyle(state); + if (state.wrapper && state.wrapper.parentNode) { + state.wrapper.parentNode.removeChild(state.wrapper); + } + state.wrapper = null; + state.container = null; + state.waves = []; + state.retreatPromise = null; + } + + function clearFloodAnimations(state) { + const { container, waves } = state; + if (!container) { + return; + } + container.style.animation = 'none'; + container.style.transition = 'none'; + container.style.height = '0%'; + waves.forEach((wave) => { + wave.style.animation = 'none'; + wave.style.backgroundImage = ''; + wave.style.backgroundSize = ''; + wave.style.backgroundPositionX = '0px'; + }); + } + + function teardownFloodStyle(state) { + if (state.styleNode && state.styleNode.parentNode) { + state.styleNode.parentNode.removeChild(state.styleNode); + } + state.styleNode = null; + } + + function buildFloodWaveShape(layerIndex) { + const baseHeight = 180 + layerIndex * 12; + const cycles = 4; + let path = `M0,${baseHeight}`; + let currentX = 0; + let previousAmplitude = randRange(40, 80); + + for (let i = 0; i < cycles; i++) { + const waveLength = randRange(700, 900); + const minAmp = Math.max(20, previousAmplitude - 20); + const maxAmp = Math.min(90, previousAmplitude + 20); + const amplitude = randRange(minAmp, maxAmp); + previousAmplitude = amplitude; + const halfWave = waveLength / 2; + + const peakX = currentX + halfWave / 2; + path += ` Q${peakX},${baseHeight - amplitude} ${currentX + halfWave},${baseHeight}`; + + const troughX = currentX + halfWave + halfWave / 2; + path += ` Q${troughX},${baseHeight + amplitude} ${currentX + waveLength},${baseHeight}`; + + currentX += waveLength; + } + + path += ` L${currentX},1000 L0,1000 Z`; + return { + path, + width: currentX, + waveWidth: currentX / cycles + }; + } + + function randRange(min, max) { + return Math.random() * (max - min) + min; + } + + function randInt(min, max) { + return Math.floor(randRange(min, max + 1)); + } + + function pickOne(arr) { + if (!Array.isArray(arr) || arr.length === 0) { + return null; + } + return arr[Math.floor(Math.random() * arr.length)]; + } + + function uniqueKey(prefix) { + const random = Math.random().toString(36).slice(2, 7); + return `${prefix}_${Date.now()}_${random}`; + } +})(window); diff --git a/static/easter-eggs/registry.js b/static/easter-eggs/registry.js new file mode 100644 index 0000000..b8e244c --- /dev/null +++ b/static/easter-eggs/registry.js @@ -0,0 +1,67 @@ +(function initEasterEggRegistry(global) { + /** + * 一个极简的彩蛋注册表,用于在主应用与各特效实现之间解耦。 + * 每个特效需要调用 register(effectName, factory),factory 必须返回带有 destroy 方法的实例。 + */ + const registry = { + effects: Object.create(null), + + /** + * 注册彩蛋。 + * @param {string} effectName + * @param {(context: object) => { destroy: Function }} factory + */ + register(effectName, factory) { + const key = (effectName || '').trim().toLowerCase(); + if (!key) { + console.warn('[EasterEggRegistry] 忽略空 effectName'); + return; + } + if (typeof factory !== 'function') { + console.warn('[EasterEggRegistry] 注册失败,factory 不是函数:', effectName); + return; + } + this.effects[key] = factory; + }, + + /** + * 创建彩蛋实例。 + * @param {string} effectName + * @param {object} context + * @returns {object|null} + */ + start(effectName, context = {}) { + const key = (effectName || '').trim().toLowerCase(); + const factory = this.effects[key]; + if (!factory) { + console.warn('[EasterEggRegistry] 未找到特效:', effectName); + return null; + } + try { + const instance = factory({ + ...context, + effect: key, + }); + if (!instance || typeof instance.destroy !== 'function') { + console.warn('[EasterEggRegistry] 特效未返回 destroy 方法:', effectName); + return null; + } + return instance; + } catch (error) { + console.error('[EasterEggRegistry] 特效初始化失败:', effectName, error); + return null; + } + }, + + has(effectName) { + const key = (effectName || '').trim().toLowerCase(); + return Boolean(this.effects[key]); + }, + + list() { + return Object.keys(this.effects); + }, + }; + + global.EasterEggRegistry = registry; +})(window); diff --git a/static/easter-eggs/snake.css b/static/easter-eggs/snake.css new file mode 100644 index 0000000..2a2002c --- /dev/null +++ b/static/easter-eggs/snake.css @@ -0,0 +1,13 @@ +.easter-egg-overlay .snake-overlay { + position: absolute; + inset: 0; + pointer-events: none; + background: transparent; +} + +.easter-egg-overlay canvas.snake-canvas { + display: block; + width: 100%; + height: 100%; + background: transparent; +} diff --git a/static/easter-eggs/snake.js b/static/easter-eggs/snake.js new file mode 100644 index 0000000..4616b62 --- /dev/null +++ b/static/easter-eggs/snake.js @@ -0,0 +1,503 @@ +(function registerSnakeEffect(global) { + const registry = global.EasterEggRegistry; + if (!registry) { + console.error('[easter-eggs:snake] 未找到 EasterEggRegistry,无法注册特效'); + return; + } + + registry.register('snake', function createSnakeEffect(context = {}) { + const root = context.root; + if (!root) { + throw new Error('缺少彩蛋根节点'); + } + const payload = context.payload || {}; + + const container = document.createElement('div'); + container.className = 'snake-overlay'; + + const canvas = document.createElement('canvas'); + canvas.className = 'snake-canvas'; + container.appendChild(canvas); + root.appendChild(container); + + const effect = new SnakeEffect(container, canvas, payload, context.app); + + return { + destroy(options = {}) { + return effect.stop(options); + } + }; + }); + + class SnakeEffect { + constructor(container, canvas, payload, app) { + this.container = container; + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.payload = payload || {}; + this.app = app || null; + this.targetApples = Math.max(1, Math.floor(Number(this.payload.apples_target) || 10)); + this.initialApples = Math.max(1, Math.floor(Number(this.payload.initial_apples) || 3)); + this.apples = []; + this.applesEaten = 0; + this.appleFadeInDuration = 800; + this.appleFadeOutDuration = 1500; + this.finalRun = false; + this.running = true; + this.finished = false; + this.cleanupRequested = false; + this.cleanupResolve = null; + this.cleanupPromise = new Promise((resolve) => { + this.cleanupResolve = resolve; + }); + this.appNotified = false; + this.pendingResizeId = null; + + this.resize = this.resize.bind(this); + window.addEventListener('resize', this.resize); + this.resize(); + this.scheduleResize(); + + this.snake = new Snake(this); + + const spawnTime = typeof performance !== 'undefined' ? performance.now() : Date.now(); + for (let i = 0; i < this.initialApples; i++) { + this.apples.push(this.createApple(spawnTime)); + } + + this.animate = this.animate.bind(this); + this.lastTime = 0; + this.frameInterval = 1000 / 60; + this.rafId = requestAnimationFrame(this.animate); + } + + scheduleResize() { + if (this.pendingResizeId) { + cancelAnimationFrame(this.pendingResizeId); + } + this.pendingResizeId = requestAnimationFrame(() => { + this.pendingResizeId = null; + this.resize(); + }); + } + + resize() { + const rect = this.container.getBoundingClientRect(); + let width = Math.floor(rect.width); + let height = Math.floor(rect.height); + + if (width < 2 || height < 2) { + width = window.innerWidth || document.documentElement.clientWidth || 1; + height = window.innerHeight || document.documentElement.clientHeight || 1; + } + + width = Math.max(1, width); + height = Math.max(1, height); + if (this.canvas.width !== width || this.canvas.height !== height) { + this.canvas.width = width; + this.canvas.height = height; + if (this.snake && typeof this.snake.handleResize === 'function') { + this.snake.handleResize(width, height); + } + } + } + + createApple(timestamp = null) { + const margin = 60; + const width = this.canvas.width; + const height = this.canvas.height; + const now = timestamp != null + ? timestamp + : (typeof performance !== 'undefined' ? performance.now() : Date.now()); + return { + x: margin + Math.random() * Math.max(1, width - margin * 2), + y: margin + Math.random() * Math.max(1, height - margin * 2), + opacity: 0, + fadeInStart: now, + fadeOutStart: null + }; + } + + handleAppleConsumed(index) { + this.applesEaten += 1; + const reachedGoal = this.applesEaten >= this.targetApples; + if (reachedGoal) { + this.apples.splice(index, 1); + this.startFinalRun(); + } else if (this.apples[index]) { + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + this.apples[index] = this.createApple(now); + } + } + + startFinalRun(force = false) { + if (this.finalRun) { + return; + } + this.finalRun = true; + this.appleFadeOutStart = typeof performance !== 'undefined' ? performance.now() : Date.now(); + this.apples.forEach((apple) => { + apple.fadeOutStart = this.appleFadeOutStart; + }); + this.snake.beginFinalRun(); + if (force) { + this.applesEaten = Math.max(this.applesEaten, this.targetApples); + } + } + + drawApples(timestamp) { + const ctx = this.ctx; + const now = timestamp != null + ? timestamp + : (typeof performance !== 'undefined' ? performance.now() : Date.now()); + for (let i = this.apples.length - 1; i >= 0; i--) { + const apple = this.apples[i]; + if (apple.fadeOutStart) { + const progress = Math.min(1, (now - apple.fadeOutStart) / this.appleFadeOutDuration); + apple.opacity = 1 - progress; + if (apple.opacity <= 0.02) { + this.apples.splice(i, 1); + continue; + } + } else if (apple.fadeInStart) { + const progress = Math.min(1, (now - apple.fadeInStart) / this.appleFadeInDuration); + apple.opacity = progress; + if (apple.opacity >= 0.995) { + apple.fadeInStart = null; + apple.opacity = 1; + } + } else { + apple.opacity = 1; + } + + ctx.fillStyle = `hsla(${this.snake.hue}, 70%, 65%, ${apple.opacity})`; + ctx.beginPath(); + ctx.arc(apple.x, apple.y, 14, 0, Math.PI * 2); + ctx.fill(); + } + } + + animate(timestamp) { + if (!this.running) { + return; + } + this.rafId = requestAnimationFrame(this.animate); + const elapsed = timestamp - this.lastTime; + if (elapsed < this.frameInterval) { + return; + } + this.lastTime = timestamp - (elapsed % this.frameInterval); + + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + this.snake.findNearestApple(); + this.snake.update(); + this.drawApples(timestamp); + this.snake.draw(); + + if (this.finalRun && this.apples.length === 0 && this.snake.isCompletelyOffscreen(80)) { + this.finish(false, !this.cleanupRequested); + } + } + + stop(options = {}) { + const immediate = Boolean(options && options.immediate); + if (immediate) { + this.cleanupRequested = true; + this.finish(true, false); + return null; + } + if (this.finished) { + return this.cleanupPromise; + } + this.cleanupRequested = true; + if (!this.finalRun) { + this.startFinalRun(true); + } + return this.cleanupPromise; + } + + finish(immediate = false, notifyCompletion = false) { + if (this.finished) { + return; + } + this.finished = true; + this.running = false; + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + if (this.pendingResizeId) { + cancelAnimationFrame(this.pendingResizeId); + this.pendingResizeId = null; + } + window.removeEventListener('resize', this.resize); + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + if (this.cleanupResolve) { + this.cleanupResolve(); + this.cleanupResolve = null; + } + if (immediate) { + this.apples.length = 0; + } + if (notifyCompletion && !this.cleanupRequested) { + this.notifyAppForCleanup(); + } + } + + notifyAppForCleanup() { + if (this.appNotified) { + return; + } + this.appNotified = true; + if (this.app && typeof this.app.destroyEasterEggEffect === 'function') { + setTimeout(() => { + try { + this.app.destroyEasterEggEffect(true); + } catch (error) { + console.warn('自动清理贪吃蛇彩蛋失败:', error); + } + }, 0); + } + } + } + + class Snake { + constructor(effect) { + this.effect = effect; + this.canvas = effect.canvas; + this.ctx = effect.ctx; + this.radius = 14; + this.targetLength = 28; + this.currentLength = 28; + this.speed = 2; + this.angle = 0; + this.targetAngle = 0; + this.hue = 30; + this.currentTarget = null; + this.targetStartTime = Date.now(); + this.targetTimeout = 10000; + this.timedOut = false; + this.finalRunAngle = null; + this.initializeEntryPosition(); + } + + initializeEntryPosition() { + const width = this.canvas.width; + const height = this.canvas.height; + const margin = 120; + const side = Math.floor(Math.random() * 4); + let startX; + let startY; + switch (side) { + case 0: + startX = -margin; + startY = Math.random() * height; + break; + case 1: + startX = width + margin; + startY = Math.random() * height; + break; + case 2: + startX = Math.random() * width; + startY = -margin; + break; + default: + startX = Math.random() * width; + startY = height + margin; + } + const targetX = width / 2; + const targetY = height / 2; + const entryAngle = Math.atan2(targetY - startY, targetX - startX); + this.angle = entryAngle; + this.targetAngle = entryAngle; + const tailX = startX - Math.cos(entryAngle) * this.targetLength; + const tailY = startY - Math.sin(entryAngle) * this.targetLength; + this.path = [ + { x: startX, y: startY }, + { x: tailX, y: tailY } + ]; + } + + handleResize(width, height) { + this.path.forEach((point) => { + point.x = Math.max(-40, Math.min(width + 40, point.x)); + point.y = Math.max(-40, Math.min(height + 40, point.y)); + }); + } + + findNearestApple() { + if (this.effect.finalRun) { + return; + } + const apples = this.effect.apples; + if (!apples.length) { + this.currentTarget = null; + return; + } + + const now = Date.now(); + if (this.currentTarget && (now - this.targetStartTime) < this.targetTimeout) { + const targetStillExists = apples.includes(this.currentTarget); + if (targetStillExists) { + const dx = this.currentTarget.x - this.path[0].x; + const dy = this.currentTarget.y - this.path[0].y; + this.targetAngle = Math.atan2(dy, dx); + this.timedOut = false; + return; + } + } + + let targetApple = null; + let targetDistance = this.timedOut ? -Infinity : Infinity; + + apples.forEach((apple) => { + const dx = apple.x - this.path[0].x; + const dy = apple.y - this.path[0].y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (this.timedOut) { + if (distance > targetDistance) { + targetDistance = distance; + targetApple = apple; + } + } else if (distance < targetDistance) { + targetDistance = distance; + targetApple = apple; + } + }); + + if (targetApple) { + if (this.currentTarget !== targetApple) { + this.currentTarget = targetApple; + this.targetStartTime = now; + this.timedOut = false; + } else if ((now - this.targetStartTime) >= this.targetTimeout) { + this.timedOut = true; + } + + const dx = targetApple.x - this.path[0].x; + const dy = targetApple.y - this.path[0].y; + this.targetAngle = Math.atan2(dy, dx); + } + } + + beginFinalRun() { + if (this.finalRunAngle == null) { + this.finalRunAngle = this.angle; + } + this.targetAngle = this.finalRunAngle; + this.currentTarget = null; + } + + update() { + if (!this.effect.finalRun) { + let angleDiff = this.targetAngle - this.angle; + while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI; + while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI; + const maxTurnRate = 0.03; + this.angle += angleDiff * maxTurnRate; + } else if (this.finalRunAngle != null) { + this.angle = this.finalRunAngle; + } + + const head = { ...this.path[0] }; + head.x += Math.cos(this.angle) * this.speed; + head.y += Math.sin(this.angle) * this.speed; + if (!this.effect.finalRun) { + const margin = 0; + if (head.x < -margin) head.x = -margin; + if (head.x > this.canvas.width + margin) head.x = this.canvas.width + margin; + if (head.y < -margin) head.y = -margin; + if (head.y > this.canvas.height + margin) head.y = this.canvas.height + margin; + } + + this.path.unshift(head); + + if (!this.effect.finalRun) { + this.checkApples(head); + } + + this.trimPath(); + } + + checkApples(head) { + const apples = this.effect.apples; + for (let i = 0; i < apples.length; i++) { + const apple = apples[i]; + const dx = head.x - apple.x; + const dy = head.y - apple.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < this.radius + 14) { + this.targetLength += 28 * 3; + this.currentTarget = null; + this.effect.handleAppleConsumed(i); + break; + } + } + } + + trimPath() { + let pathLength = 0; + for (let i = 0; i < this.path.length - 1; i++) { + const dx = this.path[i].x - this.path[i + 1].x; + const dy = this.path[i].y - this.path[i + 1].y; + pathLength += Math.sqrt(dx * dx + dy * dy); + } + + while (this.path.length > 2 && pathLength > this.targetLength) { + const last = this.path[this.path.length - 1]; + const secondLast = this.path[this.path.length - 2]; + const dx = secondLast.x - last.x; + const dy = secondLast.y - last.y; + const segmentLength = Math.sqrt(dx * dx + dy * dy); + + if (pathLength - segmentLength >= this.targetLength) { + this.path.pop(); + pathLength -= segmentLength; + } else { + break; + } + } + + this.currentLength = pathLength; + } + + draw() { + if (this.path.length < 2) { + return; + } + const ctx = this.ctx; + ctx.strokeStyle = `hsl(${this.hue}, 70%, 65%)`; + ctx.lineWidth = this.radius * 2; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.shadowBlur = 20; + ctx.shadowColor = `hsl(${this.hue}, 80%, 55%)`; + + ctx.beginPath(); + ctx.moveTo(this.path[0].x, this.path[0].y); + for (let i = 1; i < this.path.length; i++) { + ctx.lineTo(this.path[i].x, this.path[i].y); + } + ctx.stroke(); + ctx.shadowBlur = 0; + } + + isCompletelyOffscreen(margin = 40) { + const width = this.canvas.width; + const height = this.canvas.height; + return this.path.every((point) => { + return ( + point.x < -margin || + point.x > width + margin || + point.y < -margin || + point.y > height + margin + ); + }); + } + } +})(window); diff --git a/static/index.html b/static/index.html index 3333a73..8e9eda8 100644 --- a/static/index.html +++ b/static/index.html @@ -27,6 +27,8 @@ + +