feat: modularize easter egg effects
This commit is contained in:
parent
afd1bb9d28
commit
e25384d342
@ -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"]
|
||||
|
||||
@ -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}")
|
||||
|
||||
307
static/app.js
307
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;
|
||||
this.easterEgg.effect = effectName;
|
||||
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) {
|
||||
clearTimeout(this.easterEgg.cleanupTimer);
|
||||
this.easterEgg.cleanupTimer = null;
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.runFloodAnimation(payload);
|
||||
});
|
||||
const durationSeconds = Math.max(8, Number(payload.duration_seconds) || 45);
|
||||
this.easterEgg.cleanupTimer = setTimeout(() => {
|
||||
this.destroyEasterEggEffect();
|
||||
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}`);
|
||||
}
|
||||
},
|
||||
|
||||
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 = `<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() {
|
||||
destroyEasterEggEffect(forceImmediate = false) {
|
||||
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;
|
||||
}
|
||||
const instance = this.easterEgg.instance;
|
||||
if (!instance) {
|
||||
this.finishEasterEggCleanup();
|
||||
},
|
||||
|
||||
startFloodRetreat() {
|
||||
const container = this.$refs.easterEggWaterContainer;
|
||||
if (!container) {
|
||||
this.finishEasterEggCleanup();
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
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';
|
||||
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
|
||||
});
|
||||
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);
|
||||
} 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问题)
|
||||
|
||||
38
static/easter-eggs/flood.css
Normal file
38
static/easter-eggs/flood.css
Normal 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
267
static/easter-eggs/flood.js
Normal 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);
|
||||
67
static/easter-eggs/registry.js
Normal file
67
static/easter-eggs/registry.js
Normal 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);
|
||||
13
static/easter-eggs/snake.css
Normal file
13
static/easter-eggs/snake.css
Normal 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
503
static/easter-eggs/snake.js
Normal 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);
|
||||
@ -27,6 +27,8 @@
|
||||
|
||||
<!-- Custom 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>
|
||||
<body>
|
||||
<div id="app">
|
||||
@ -826,14 +828,8 @@
|
||||
<div class="easter-egg-overlay"
|
||||
v-show="easterEgg.active"
|
||||
:class="{ active: easterEgg.active }"
|
||||
aria-hidden="true">
|
||||
<div class="easter-egg-water" ref="easterEggWater">
|
||||
<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>
|
||||
aria-hidden="true"
|
||||
ref="easterEggRoot">
|
||||
</div>
|
||||
<div class="context-menu"
|
||||
v-if="contextMenu.visible"
|
||||
@ -892,6 +888,10 @@
|
||||
}
|
||||
});
|
||||
</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>
|
||||
</body>
|
||||
|
||||
@ -2629,42 +2629,3 @@ o-files {
|
||||
.easter-egg-overlay.active {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -1444,13 +1444,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"]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user