504 lines
18 KiB
JavaScript
504 lines
18 KiB
JavaScript
(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);
|