feat: modularize easter egg effects

This commit is contained in:
JOJO 2025-11-22 13:07:54 +08:00
parent afd1bb9d28
commit e25384d342
11 changed files with 1033 additions and 267 deletions

View File

@ -1472,13 +1472,13 @@ class MainTerminal:
"type": "function", "type": "function",
"function": { "function": {
"name": "trigger_easter_egg", "name": "trigger_easter_egg",
"description": "触发隐藏彩蛋,用于展示非功能性特效。需指定 effect 参数(例如 flood 表示灌水)。", "description": "触发隐藏彩蛋,用于展示非功能性特效。需指定 effect 参数,例如 flood灌水或 snake贪吃蛇)。",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"effect": { "effect": {
"type": "string", "type": "string",
"description": "彩蛋标识,当前支持 flood灌水)。" "description": "彩蛋标识,目前支持 flood灌水与 snake贪吃蛇)。"
} }
}, },
"required": ["effect"] "required": ["effect"]

View File

@ -12,7 +12,7 @@ class EasterEggManager:
"""管理隐藏彩蛋效果的触发逻辑。""" """管理隐藏彩蛋效果的触发逻辑。"""
def __init__(self) -> None: def __init__(self) -> None:
# 目前仅有一个“灌水”特效,后续可在此扩展 # 预置彩蛋效果元数据,新增特效可在此扩展
self.effects: Dict[str, Dict[str, object]] = { self.effects: Dict[str, Dict[str, object]] = {
"flood": { "flood": {
"label": "灌水", "label": "灌水",
@ -21,7 +21,16 @@ class EasterEggManager:
"duration_seconds": 45, "duration_seconds": 45,
"intensity_range": (0.87, 0.93), "intensity_range": (0.87, 0.93),
"notes": "特效为半透明覆盖层,不会阻挡交互。", "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]: def trigger_effect(self, effect: str) -> Dict[str, object]:
@ -45,11 +54,12 @@ class EasterEggManager:
"success": True, "success": True,
"effect": effect_id, "effect": effect_id,
"display_name": metadata.get("label", 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 payload
return self._build_error(f"未知彩蛋: {effect_key}") return self._build_error(f"未知彩蛋: {effect_key}")

View File

@ -119,21 +119,6 @@ const TOOL_CATEGORY_ICON_MAP = Object.freeze({
easter_egg: 'sparkles' 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) { function injectScriptSequentially(urls, onSuccess, onFailure) {
let index = 0; let index = 0;
const tryLoad = () => { const tryLoad = () => {
@ -323,9 +308,11 @@ async function bootstrapApp() {
easterEgg: { easterEgg: {
active: false, active: false,
effect: null, effect: null,
payload: null,
instance: null,
cleanupTimer: null, cleanupTimer: null,
styleNode: null, destroying: false,
retreating: false destroyPromise: null
}, },
// 右键菜单相关 // 右键菜单相关
@ -414,8 +401,10 @@ async function bootstrapApp() {
clearInterval(this.subAgentPollTimer); clearInterval(this.subAgentPollTimer);
this.subAgentPollTimer = null; this.subAgentPollTimer = null;
} }
this.destroyEasterEggEffect(); const cleanup = this.destroyEasterEggEffect(true);
this.finishEasterEggCleanup(); if (cleanup && typeof cleanup.catch === 'function') {
cleanup.catch(() => {});
}
}, },
watch: { watch: {
@ -1102,7 +1091,12 @@ async function bootstrapApp() {
targetAction.tool.result = data.result; targetAction.tool.result = data.result;
} }
if (targetAction.tool && targetAction.tool.name === 'trigger_easter_egg' && data.result !== undefined) { 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) { if (data.message !== undefined) {
targetAction.tool.message = data.message; targetAction.tool.message = data.message;
@ -3262,7 +3256,7 @@ async function bootstrapApp() {
document.body.style.cursor = ''; document.body.style.cursor = '';
}, },
handleEasterEggPayload(payload) { async handleEasterEggPayload(payload) {
if (!payload) { if (!payload) {
return; return;
} }
@ -3282,212 +3276,125 @@ async function bootstrapApp() {
if (parsed.error) { if (parsed.error) {
console.warn('彩蛋触发失败:', parsed.error); console.warn('彩蛋触发失败:', parsed.error);
} }
this.destroyEasterEggEffect(); await this.destroyEasterEggEffect(true);
return; return;
} }
const effectName = (parsed.effect || '').toLowerCase(); const effectName = (parsed.effect || '').toLowerCase();
if (effectName === 'flood') { if (!effectName) {
this.startFloodEffect(parsed); console.warn('彩蛋结果缺少 effect 字段');
return;
} }
await this.startEasterEggEffect(effectName, parsed);
}, },
startFloodEffect(payload = {}) { async startEasterEggEffect(effectName, payload = {}) {
this.clearFloodAnimations(); const registry = window.EasterEggRegistry;
this.teardownEasterEggStyle(); 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.active = true;
this.easterEgg.effect = 'flood'; this.easterEgg.effect = effectName;
this.easterEgg.retreating = false; this.easterEgg.payload = payload;
const instance = registry.start(effectName, {
root,
payload,
app: this
});
if (!instance) {
this.finishEasterEggCleanup();
return;
}
this.easterEgg.instance = instance;
this.easterEgg.destroyPromise = null;
this.easterEgg.destroying = false;
if (this.easterEgg.cleanupTimer) { if (this.easterEgg.cleanupTimer) {
clearTimeout(this.easterEgg.cleanupTimer); clearTimeout(this.easterEgg.cleanupTimer);
this.easterEgg.cleanupTimer = null;
} }
this.$nextTick(() => {
this.runFloodAnimation(payload);
});
const durationSeconds = Math.max(8, Number(payload.duration_seconds) || 45); const durationSeconds = Math.max(8, Number(payload.duration_seconds) || 45);
this.easterEgg.cleanupTimer = setTimeout(() => { this.easterEgg.cleanupTimer = setTimeout(() => {
this.destroyEasterEggEffect(); const cleanup = this.destroyEasterEggEffect(false);
if (cleanup && typeof cleanup.catch === 'function') {
cleanup.catch(() => {});
}
}, durationSeconds * 1000); }, durationSeconds * 1000);
if (payload.message) {
console.info(`[彩蛋] ${payload.display_name || effectName}: ${payload.message}`);
}
}, },
runFloodAnimation(payload = {}) { destroyEasterEggEffect(forceImmediate = false) {
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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgData.width} 1000" preserveAspectRatio="none"><path d="${svgData.path}" fill="${color}"/></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) { if (this.easterEgg.cleanupTimer) {
clearTimeout(this.easterEgg.cleanupTimer); clearTimeout(this.easterEgg.cleanupTimer);
this.easterEgg.cleanupTimer = null; this.easterEgg.cleanupTimer = null;
} }
if (this.easterEgg.effect === 'flood' && this.easterEgg.active && !this.easterEgg.retreating) { const instance = this.easterEgg.instance;
this.startFloodRetreat(); if (!instance) {
return;
}
if (this.easterEgg.effect === 'flood' && this.easterEgg.retreating) {
return;
}
this.finishEasterEggCleanup(); this.finishEasterEggCleanup();
}, return Promise.resolve();
startFloodRetreat() {
const container = this.$refs.easterEggWaterContainer;
if (!container) {
this.finishEasterEggCleanup();
return;
} }
this.easterEgg.retreating = true; if (this.easterEgg.destroying) {
const measuredHeight = container.offsetHeight || container.clientHeight; return this.easterEgg.destroyPromise || Promise.resolve();
const computedHeight = window.getComputedStyle(container).height; }
const currentHeight = measuredHeight this.easterEgg.destroying = true;
? `${measuredHeight}px` let result;
: (computedHeight && computedHeight !== 'auto' ? computedHeight : '0px'); try {
container.style.animation = 'none'; result = instance.destroy({
container.style.transition = 'none'; immediate: forceImmediate,
container.style.height = currentHeight; payload: this.easterEgg.payload,
void container.offsetHeight; root: this.$refs.easterEggRoot || null
const retreatDuration = 8;
container.style.transition = `height ${retreatDuration}s ease-in-out`;
requestAnimationFrame(() => {
container.style.height = '0px';
}); });
this.easterEgg.cleanupTimer = setTimeout(() => { } catch (error) {
container.style.transition = 'none'; console.warn('销毁彩蛋时发生错误:', error);
this.clearFloodAnimations(); this.easterEgg.destroying = false;
this.teardownEasterEggStyle(); this.finishEasterEggCleanup();
this.easterEgg.active = false; return Promise.resolve();
this.easterEgg.effect = null; }
this.easterEgg.retreating = false; const finalize = () => {
}, retreatDuration * 1000); 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() { finishEasterEggCleanup() {
this.clearFloodAnimations(); if (this.easterEgg.cleanupTimer) {
this.teardownEasterEggStyle(); clearTimeout(this.easterEgg.cleanupTimer);
this.easterEgg.cleanupTimer = null;
}
const root = this.$refs.easterEggRoot;
if (root) {
root.innerHTML = '';
}
this.easterEgg.active = false; this.easterEgg.active = false;
this.easterEgg.effect = null; 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问题 // 格式化token显示修复NaN问题

View File

@ -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);
}

267
static/easter-eggs/flood.js Normal file
View File

@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgData.width} 1000" preserveAspectRatio="none"><path d="${svgData.path}" fill="${color}"/></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);

View File

@ -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);

View File

@ -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;
}

503
static/easter-eggs/snake.js Normal file
View File

@ -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);

View File

@ -27,6 +27,8 @@
<!-- Custom CSS --> <!-- Custom CSS -->
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/easter-eggs/flood.css">
<link rel="stylesheet" href="/static/easter-eggs/snake.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@ -826,14 +828,8 @@
<div class="easter-egg-overlay" <div class="easter-egg-overlay"
v-show="easterEgg.active" v-show="easterEgg.active"
:class="{ active: easterEgg.active }" :class="{ active: easterEgg.active }"
aria-hidden="true"> aria-hidden="true"
<div class="easter-egg-water" ref="easterEggWater"> ref="easterEggRoot">
<div class="easter-egg-water-container" ref="easterEggWaterContainer">
<div class="wave wave1"></div>
<div class="wave wave2"></div>
<div class="wave wave3"></div>
</div>
</div>
</div> </div>
<div class="context-menu" <div class="context-menu"
v-if="contextMenu.visible" v-if="contextMenu.visible"
@ -892,6 +888,10 @@
} }
}); });
</script> </script>
<!-- 加载彩蛋模块 -->
<script src="/static/easter-eggs/registry.js"></script>
<script src="/static/easter-eggs/flood.js"></script>
<script src="/static/easter-eggs/snake.js"></script>
<!-- 加载应用脚本 --> <!-- 加载应用脚本 -->
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>

View File

@ -2629,42 +2629,3 @@ o-files {
.easter-egg-overlay.active { .easter-egg-overlay.active {
opacity: 1; opacity: 1;
} }
.easter-egg-water {
position: relative;
width: 100%;
height: 100%;
}
.easter-egg-water-container {
position: absolute;
bottom: -2px;
left: 0;
width: 100%;
height: 0%;
overflow: visible;
pointer-events: none;
}
.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-water-container .wave.wave1 {
filter: blur(0.5px);
}
.easter-egg-water-container .wave.wave2 {
filter: blur(1px);
}
.easter-egg-water-container .wave.wave3 {
filter: blur(1.5px);
}

View File

@ -1444,13 +1444,13 @@ class MainTerminal:
"type": "function", "type": "function",
"function": { "function": {
"name": "trigger_easter_egg", "name": "trigger_easter_egg",
"description": "触发隐藏彩蛋,用于展示非功能性特效。需指定 effect 参数(例如 flood 表示灌水)。", "description": "触发隐藏彩蛋,用于展示非功能性特效。需指定 effect 参数,例如 flood灌水或 snake贪吃蛇)。",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"effect": { "effect": {
"type": "string", "type": "string",
"description": "彩蛋标识,当前支持 flood灌水)。" "description": "彩蛋标识,目前支持 flood灌水与 snake贪吃蛇)。"
} }
}, },
"required": ["effect"] "required": ["effect"]