668 lines
20 KiB
TypeScript
Executable File
668 lines
20 KiB
TypeScript
Executable File
"use client";
|
||
// 回响星核 / Echo Nexus — 中央晶体集群 + 主动脉冲(v0.7 Canvas 粒子系统)
|
||
import { useRef, useState, useCallback, useEffect } from "react";
|
||
import { useGameStore } from "@/store/gameStore";
|
||
import { formatNum } from "@/lib/game/config";
|
||
import { useToast } from "@/hooks/use-toast";
|
||
import { sfx } from "@/hooks/useAudio";
|
||
import { PULSE_WHISPERS, maybeWhisper } from "@/lib/game/narrative";
|
||
|
||
interface FloatNum {
|
||
id: number;
|
||
x: number;
|
||
y: number;
|
||
text: string;
|
||
born: number;
|
||
color: string;
|
||
}
|
||
|
||
// ===== Canvas 粒子系统类型 =====
|
||
interface EnergyParticle {
|
||
// 环绕能量粒子(持续)
|
||
angle: number;
|
||
radius: number;
|
||
baseRadius: number;
|
||
speed: number;
|
||
size: number;
|
||
color: string;
|
||
alpha: number;
|
||
phase: number;
|
||
}
|
||
|
||
interface AmbientParticle {
|
||
// 环境星尘(缓慢漂浮)
|
||
x: number;
|
||
y: number;
|
||
vx: number;
|
||
vy: number;
|
||
size: number;
|
||
alpha: number;
|
||
twinkle: number;
|
||
}
|
||
|
||
interface ShockRing {
|
||
// 冲击波环(点击触发)
|
||
born: number;
|
||
color: string;
|
||
maxRadius: number;
|
||
duration: number;
|
||
}
|
||
|
||
interface BurstParticle {
|
||
// 爆发粒子(点击触发,径向发散)
|
||
x: number;
|
||
y: number;
|
||
vx: number;
|
||
vy: number;
|
||
size: number;
|
||
color: string;
|
||
born: number;
|
||
life: number;
|
||
}
|
||
|
||
const COLORS = {
|
||
emerald: "#34d399",
|
||
fuchsia: "#e879f9",
|
||
rose: "#fb7185",
|
||
amber: "#fbbf24",
|
||
white: "#ffffff",
|
||
};
|
||
|
||
function comboColor(combo: number): string {
|
||
if (combo >= 5) return COLORS.amber;
|
||
if (combo >= 3) return COLORS.fuchsia;
|
||
return COLORS.emerald;
|
||
}
|
||
|
||
export function CrystalOrb() {
|
||
const pulse = useGameStore((s) => s.pulse);
|
||
const crystals = useGameStore((s) => s.crystals);
|
||
const crystalsPerSec = useGameStore((s) => s.crystalsPerSec);
|
||
const crystalCap = useGameStore((s) => s.crystalCap);
|
||
const combo = useGameStore((s) => s._combo);
|
||
const { toast } = useToast();
|
||
|
||
const [floats, setFloats] = useState<FloatNum[]>([]);
|
||
const idRef = useRef(0);
|
||
|
||
// Canvas refs
|
||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const pulseAnimRef = useRef(0);
|
||
const [pulseAnim, setPulseAnim] = useState(0); // 触发 SVG 环动画
|
||
|
||
// 粒子状态 refs(不触发 re-render)
|
||
const energyParticlesRef = useRef<EnergyParticle[]>([]);
|
||
const ambientParticlesRef = useRef<AmbientParticle[]>([]);
|
||
const shockRingsRef = useRef<ShockRing[]>([]);
|
||
const burstParticlesRef = useRef<BurstParticle[]>([]);
|
||
|
||
const comboRef = useRef(combo);
|
||
const fillPctRef = useRef(0);
|
||
const crystalsRef = useRef(crystals);
|
||
const crystalCapRef = useRef(crystalCap);
|
||
useEffect(() => {
|
||
comboRef.current = combo;
|
||
crystalsRef.current = crystals;
|
||
crystalCapRef.current = crystalCap;
|
||
}, [combo, crystals, crystalCap]);
|
||
|
||
const mouseRef = useRef<{ x: number; y: number; inside: boolean }>({
|
||
x: 0,
|
||
y: 0,
|
||
inside: false,
|
||
});
|
||
|
||
// ===== 初始化粒子 =====
|
||
const initParticles = useCallback((w: number, h: number) => {
|
||
const cx = w / 2;
|
||
const cy = h / 2;
|
||
const baseR = Math.min(w, h) * 0.28;
|
||
|
||
// 环绕能量粒子(3 层,每层不同速度方向)
|
||
const energy: EnergyParticle[] = [];
|
||
const layers = [
|
||
{ count: 14, rMul: 0.95, speed: 0.6, color: COLORS.emerald },
|
||
{ count: 10, rMul: 1.15, speed: -0.4, color: COLORS.fuchsia },
|
||
{ count: 8, rMul: 1.35, speed: 0.3, color: COLORS.rose },
|
||
];
|
||
layers.forEach((layer) => {
|
||
for (let i = 0; i < layer.count; i++) {
|
||
energy.push({
|
||
angle: (Math.PI * 2 * i) / layer.count + Math.random() * 0.3,
|
||
radius: baseR * layer.rMul,
|
||
baseRadius: baseR * layer.rMul,
|
||
speed: layer.speed * (0.8 + Math.random() * 0.4),
|
||
size: 1.5 + Math.random() * 2,
|
||
color: layer.color,
|
||
alpha: 0.5 + Math.random() * 0.4,
|
||
phase: Math.random() * Math.PI * 2,
|
||
});
|
||
}
|
||
});
|
||
energyParticlesRef.current = energy;
|
||
|
||
// 环境星尘
|
||
const ambient: AmbientParticle[] = [];
|
||
for (let i = 0; i < 40; i++) {
|
||
ambient.push({
|
||
x: Math.random() * w,
|
||
y: Math.random() * h,
|
||
vx: (Math.random() - 0.5) * 8,
|
||
vy: (Math.random() - 0.5) * 8,
|
||
size: 0.5 + Math.random() * 1.5,
|
||
alpha: 0.2 + Math.random() * 0.5,
|
||
twinkle: Math.random() * Math.PI * 2,
|
||
});
|
||
}
|
||
ambientParticlesRef.current = ambient;
|
||
void cx;
|
||
void cy;
|
||
}, []);
|
||
|
||
// ===== 触发脉冲效果(Canvas 部分)=====
|
||
const triggerPulseEffect = useCallback(
|
||
(x: number, y: number, res: { combo: number; gain: number }) => {
|
||
const color = comboColor(res.combo);
|
||
const now = performance.now();
|
||
|
||
// 冲击波环(多层错峰)
|
||
shockRingsRef.current.push({
|
||
born: now,
|
||
color: COLORS.emerald,
|
||
maxRadius: 140,
|
||
duration: 700,
|
||
});
|
||
if (res.combo >= 3) {
|
||
shockRingsRef.current.push({
|
||
born: now + 100,
|
||
color: COLORS.fuchsia,
|
||
maxRadius: 170,
|
||
duration: 800,
|
||
});
|
||
}
|
||
if (res.combo >= 5) {
|
||
shockRingsRef.current.push({
|
||
born: now + 200,
|
||
color: COLORS.amber,
|
||
maxRadius: 200,
|
||
duration: 900,
|
||
});
|
||
}
|
||
|
||
// 径向爆发粒子
|
||
const pcount = 10 + Math.min(10, res.combo) * 2;
|
||
for (let i = 0; i < pcount; i++) {
|
||
const angle = (Math.PI * 2 * i) / pcount + Math.random() * 0.4;
|
||
const speed = 120 + Math.random() * 180;
|
||
burstParticlesRef.current.push({
|
||
x,
|
||
y,
|
||
vx: Math.cos(angle) * speed,
|
||
vy: Math.sin(angle) * speed,
|
||
size: 2 + Math.random() * 3,
|
||
color,
|
||
born: now,
|
||
life: 600 + Math.random() * 300,
|
||
});
|
||
}
|
||
|
||
// 让能量粒子被"推开"再回弹
|
||
energyParticlesRef.current.forEach((p) => {
|
||
p.radius = p.baseRadius * (1.25 + Math.random() * 0.15);
|
||
});
|
||
},
|
||
[]
|
||
);
|
||
|
||
// ===== 点击处理 =====
|
||
const handleClick = useCallback(
|
||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||
const res = pulse();
|
||
if (!res) return;
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
const id = idRef.current++;
|
||
const floatColor = comboColor(res.combo);
|
||
setFloats((f) => [
|
||
...f,
|
||
{
|
||
id,
|
||
x,
|
||
y,
|
||
text: `+${res.gain.toFixed(1)}`,
|
||
born: Date.now(),
|
||
color: floatColor,
|
||
},
|
||
]);
|
||
triggerPulseEffect(x, y, res);
|
||
setPulseAnim((n) => n + 1);
|
||
sfx(res.combo >= 3 ? "pulseCombo" : "pulse", { combo: res.combo });
|
||
setTimeout(() => {
|
||
setFloats((f) => f.filter((it) => it.id !== id));
|
||
}, 900);
|
||
if (res.combo >= 5 && res.combo % 5 === 0) {
|
||
// v0.9 叙事融入:高连击时注入世界观低语
|
||
const whisper = maybeWhisper(PULSE_WHISPERS, 0.6);
|
||
toast({
|
||
title: `×${res.combo} 连击!`,
|
||
description: whisper ? `「${whisper}」` : "谐振叠加,产能飙升。",
|
||
});
|
||
} else if (res.combo >= 3 && res.combo % 3 === 0) {
|
||
// 3 连击时也有概率出现叙事
|
||
const whisper = maybeWhisper(PULSE_WHISPERS, 0.3);
|
||
if (whisper) {
|
||
toast({ title: "低语", description: `「${whisper}」` });
|
||
}
|
||
}
|
||
},
|
||
[pulse, toast, triggerPulseEffect]
|
||
);
|
||
|
||
// ===== Canvas 动画循环 =====
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current;
|
||
const container = containerRef.current;
|
||
if (!canvas || !container) return;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return;
|
||
|
||
let rafId = 0;
|
||
let lastTime = performance.now();
|
||
let dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||
|
||
const resize = () => {
|
||
const rect = container.getBoundingClientRect();
|
||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
|
||
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
|
||
canvas.style.width = `${rect.width}px`;
|
||
canvas.style.height = `${rect.height}px`;
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
initParticles(rect.width, rect.height);
|
||
};
|
||
resize();
|
||
const ro = new ResizeObserver(resize);
|
||
ro.observe(container);
|
||
|
||
const draw = (now: number) => {
|
||
const dt = Math.min(0.05, (now - lastTime) / 1000);
|
||
lastTime = now;
|
||
const w = canvas.width / dpr;
|
||
const h = canvas.height / dpr;
|
||
const cx = w / 2;
|
||
const cy = h / 2;
|
||
const baseR = Math.min(w, h) * 0.28;
|
||
|
||
ctx.clearRect(0, 0, w, h);
|
||
|
||
// === 1. 环境星尘 ===
|
||
ambientParticlesRef.current.forEach((p) => {
|
||
p.x += p.vx * dt;
|
||
p.y += p.vy * dt;
|
||
p.twinkle += dt * 2;
|
||
if (p.x < 0) p.x = w;
|
||
if (p.x > w) p.x = 0;
|
||
if (p.y < 0) p.y = h;
|
||
if (p.y > h) p.y = 0;
|
||
const tw = 0.5 + 0.5 * Math.sin(p.twinkle);
|
||
ctx.globalAlpha = p.alpha * tw;
|
||
ctx.fillStyle = "#ffffff";
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
});
|
||
ctx.globalAlpha = 1;
|
||
|
||
// === 2. 外层辉光(呼吸)===
|
||
const breath = 0.85 + 0.15 * Math.sin(now * 0.001);
|
||
const glowGrad = ctx.createRadialGradient(
|
||
cx,
|
||
cy,
|
||
baseR * 0.3,
|
||
cx,
|
||
cy,
|
||
baseR * 2.2
|
||
);
|
||
glowGrad.addColorStop(0, `rgba(52,211,153,${0.18 * breath})`);
|
||
glowGrad.addColorStop(0.5, `rgba(232,121,249,${0.1 * breath})`);
|
||
glowGrad.addColorStop(1, "rgba(0,0,0,0)");
|
||
ctx.fillStyle = glowGrad;
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
// === 3. 进度填充环(背景 + 进度)===
|
||
ctx.lineWidth = 2.5;
|
||
ctx.strokeStyle = "rgba(255,255,255,0.06)";
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, baseR * 1.45, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
|
||
const fp = Math.min(
|
||
100,
|
||
(crystalsRef.current / Math.max(1, crystalCapRef.current)) * 100
|
||
);
|
||
fillPctRef.current = fp;
|
||
const ringR = baseR * 1.45;
|
||
const grad = ctx.createLinearGradient(
|
||
cx - ringR,
|
||
cy - ringR,
|
||
cx + ringR,
|
||
cy + ringR
|
||
);
|
||
grad.addColorStop(0, COLORS.emerald);
|
||
grad.addColorStop(0.5, COLORS.fuchsia);
|
||
grad.addColorStop(1, COLORS.rose);
|
||
ctx.strokeStyle = grad;
|
||
ctx.lineWidth = 3;
|
||
ctx.lineCap = "round";
|
||
ctx.shadowBlur = 8;
|
||
ctx.shadowColor = COLORS.emerald;
|
||
ctx.beginPath();
|
||
ctx.arc(
|
||
cx,
|
||
cy,
|
||
ringR,
|
||
-Math.PI / 2,
|
||
-Math.PI / 2 + (Math.PI * 2 * fp) / 100
|
||
);
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
|
||
// === 4. 环绕能量粒子 ===
|
||
energyParticlesRef.current.forEach((p) => {
|
||
p.angle += p.speed * dt;
|
||
// 回弹到 baseRadius
|
||
p.radius += (p.baseRadius - p.radius) * Math.min(1, dt * 4);
|
||
const px = cx + Math.cos(p.angle) * p.radius;
|
||
const py = cy + Math.sin(p.angle) * p.radius;
|
||
const flicker = 0.7 + 0.3 * Math.sin(now * 0.005 + p.phase);
|
||
ctx.globalAlpha = p.alpha * flicker;
|
||
ctx.fillStyle = p.color;
|
||
ctx.shadowBlur = 10;
|
||
ctx.shadowColor = p.color;
|
||
ctx.beginPath();
|
||
ctx.arc(px, py, p.size, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// 拖尾(向心方向的小线段)
|
||
ctx.globalAlpha = p.alpha * flicker * 0.4;
|
||
ctx.strokeStyle = p.color;
|
||
ctx.lineWidth = p.size * 0.6;
|
||
ctx.beginPath();
|
||
ctx.moveTo(px, py);
|
||
ctx.lineTo(
|
||
cx + Math.cos(p.angle - p.speed * 0.08) * (p.radius - 2),
|
||
cy + Math.sin(p.angle - p.speed * 0.08) * (p.radius - 2)
|
||
);
|
||
ctx.stroke();
|
||
});
|
||
ctx.globalAlpha = 1;
|
||
ctx.shadowBlur = 0;
|
||
|
||
// === 5. 中央晶核 ===
|
||
const coreR = baseR * 0.95;
|
||
// 鼠标接近时晶核轻微偏移(视差感)
|
||
let coreOffsetX = 0;
|
||
let coreOffsetY = 0;
|
||
if (mouseRef.current.inside) {
|
||
const dx = mouseRef.current.x - cx;
|
||
const dy = mouseRef.current.y - cy;
|
||
const dist = Math.hypot(dx, dy);
|
||
if (dist > 0.1) {
|
||
coreOffsetX = (dx / dist) * Math.min(8, dist * 0.05);
|
||
coreOffsetY = (dy / dist) * Math.min(8, dist * 0.05);
|
||
}
|
||
}
|
||
const ccx = cx + coreOffsetX;
|
||
const ccy = cy + coreOffsetY;
|
||
|
||
// 晶核主体(径向渐变)
|
||
const coreGrad = ctx.createRadialGradient(
|
||
ccx - coreR * 0.25,
|
||
ccy - coreR * 0.3,
|
||
coreR * 0.1,
|
||
ccx,
|
||
ccy,
|
||
coreR
|
||
);
|
||
coreGrad.addColorStop(0, "rgba(255,255,255,0.95)");
|
||
coreGrad.addColorStop(0.3, `rgba(52,211,153,${0.85 * breath})`);
|
||
coreGrad.addColorStop(0.65, "rgba(232,121,249,0.55)");
|
||
coreGrad.addColorStop(1, "rgba(251,113,133,0.2)");
|
||
ctx.fillStyle = coreGrad;
|
||
ctx.shadowBlur = 30;
|
||
ctx.shadowColor = COLORS.emerald;
|
||
ctx.beginPath();
|
||
ctx.arc(ccx, ccy, coreR, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// 晶核内部六边形纹理
|
||
ctx.save();
|
||
ctx.translate(ccx, ccy);
|
||
ctx.globalAlpha = 0.25;
|
||
ctx.strokeStyle = "rgba(255,255,255,0.6)";
|
||
ctx.lineWidth = 0.8;
|
||
const hexR = coreR * 0.7;
|
||
for (let layer = 0; layer < 3; layer++) {
|
||
const r = hexR * (1 - layer * 0.3);
|
||
ctx.beginPath();
|
||
for (let i = 0; i < 6; i++) {
|
||
const a = (Math.PI / 3) * i + now * 0.0002 * (layer % 2 ? 1 : -1);
|
||
const px = Math.cos(a) * r;
|
||
const py = Math.sin(a) * r;
|
||
if (i === 0) ctx.moveTo(px, py);
|
||
else ctx.lineTo(px, py);
|
||
}
|
||
ctx.closePath();
|
||
ctx.stroke();
|
||
}
|
||
ctx.restore();
|
||
|
||
// 高光
|
||
ctx.globalAlpha = 0.4;
|
||
ctx.fillStyle = "#ffffff";
|
||
ctx.beginPath();
|
||
ctx.ellipse(
|
||
ccx - coreR * 0.25,
|
||
ccy - coreR * 0.35,
|
||
coreR * 0.35,
|
||
coreR * 0.18,
|
||
-0.4,
|
||
0,
|
||
Math.PI * 2
|
||
);
|
||
ctx.fill();
|
||
ctx.globalAlpha = 1;
|
||
ctx.shadowBlur = 0;
|
||
|
||
// === 6. 冲击波环 ===
|
||
shockRingsRef.current = shockRingsRef.current.filter((ring) => {
|
||
const elapsed = now - ring.born;
|
||
if (elapsed < 0 || elapsed > ring.duration) return elapsed <= ring.duration + 50;
|
||
const t = elapsed / ring.duration;
|
||
const r = ring.maxRadius * t;
|
||
const alpha = (1 - t) * 0.8;
|
||
ctx.globalAlpha = alpha;
|
||
ctx.strokeStyle = ring.color;
|
||
ctx.lineWidth = 2.5 * (1 - t * 0.5);
|
||
ctx.shadowBlur = 12;
|
||
ctx.shadowColor = ring.color;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
return true;
|
||
});
|
||
shockRingsRef.current = shockRingsRef.current.filter(
|
||
(r) => now - r.born < r.duration
|
||
);
|
||
ctx.globalAlpha = 1;
|
||
|
||
// === 7. 爆发粒子 ===
|
||
burstParticlesRef.current = burstParticlesRef.current.filter((p) => {
|
||
const age = now - p.born;
|
||
if (age > p.life) return false;
|
||
p.x += p.vx * dt;
|
||
p.y += p.vy * dt;
|
||
p.vx *= 0.96;
|
||
p.vy *= 0.96;
|
||
const t = age / p.life;
|
||
const alpha = 1 - t;
|
||
ctx.globalAlpha = alpha;
|
||
ctx.fillStyle = p.color;
|
||
ctx.shadowBlur = 8;
|
||
ctx.shadowColor = p.color;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, p.size * (1 - t * 0.5), 0, Math.PI * 2);
|
||
ctx.fill();
|
||
return true;
|
||
});
|
||
ctx.globalAlpha = 1;
|
||
ctx.shadowBlur = 0;
|
||
|
||
rafId = requestAnimationFrame(draw);
|
||
};
|
||
rafId = requestAnimationFrame(draw);
|
||
|
||
return () => {
|
||
cancelAnimationFrame(rafId);
|
||
ro.disconnect();
|
||
};
|
||
}, [initParticles]);
|
||
|
||
// ===== 鼠标追踪(视差)=====
|
||
const handleMouseMove = useCallback(
|
||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
mouseRef.current = {
|
||
x: e.clientX - rect.left,
|
||
y: e.clientY - rect.top,
|
||
inside: true,
|
||
};
|
||
},
|
||
[]
|
||
);
|
||
const handleMouseLeave = useCallback(() => {
|
||
mouseRef.current.inside = false;
|
||
}, []);
|
||
|
||
// 进度比例(用于显示)
|
||
const fillPct = Math.min(100, (crystals / Math.max(1, crystalCap)) * 100);
|
||
|
||
return (
|
||
<div className="relative flex flex-col items-center justify-center gap-4 select-none">
|
||
{/* 连击显示 */}
|
||
{combo > 1 && (
|
||
<div className="absolute -top-2 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-rose-500/20 border border-rose-400/40 text-rose-200 text-xs font-mono animate-pulse z-10">
|
||
×{combo} 连击
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
onClick={handleClick}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseLeave={handleMouseLeave}
|
||
className="relative h-52 w-52 sm:h-64 sm:w-64 rounded-full focus:outline-none group"
|
||
aria-label="脉冲扫描,获取记忆晶体"
|
||
>
|
||
{/* Canvas 粒子层 */}
|
||
<div
|
||
ref={containerRef}
|
||
className="absolute inset-0 rounded-full overflow-hidden"
|
||
>
|
||
<canvas ref={canvasRef} className="block w-full h-full" />
|
||
</div>
|
||
|
||
{/* CSS 旋转外环装饰(与 Canvas 叠加)*/}
|
||
<div
|
||
className="absolute inset-2 rounded-full border border-emerald-400/30 pointer-events-none"
|
||
style={{ animation: "echo-spin 18s linear infinite" }}
|
||
>
|
||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-2 rounded-full bg-emerald-300 shadow-[0_0_8px_#34d399]" />
|
||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 h-1.5 w-1.5 rounded-full bg-rose-300 shadow-[0_0_8px_#fb7185]" />
|
||
</div>
|
||
{/* 反向旋转虚线内环 */}
|
||
<div
|
||
className="absolute inset-6 rounded-full border border-fuchsia-400/20 border-dashed pointer-events-none"
|
||
style={{ animation: "echo-spin 24s linear infinite reverse" }}
|
||
/>
|
||
|
||
{/* 脉冲扩散环(点击时,CSS 叠加 Canvas 冲击波)*/}
|
||
<div
|
||
key={`ring-${pulseAnim}`}
|
||
className="absolute inset-8 rounded-full border-2 border-emerald-400/60 pointer-events-none"
|
||
style={{ animation: "echo-ring 0.7s ease-out forwards" }}
|
||
/>
|
||
|
||
{/* 浮动数字(保留 HTML,便于清晰文字)*/}
|
||
{floats.map((f) => (
|
||
<span
|
||
key={f.id}
|
||
className="absolute pointer-events-none font-mono font-bold text-sm z-10"
|
||
style={{
|
||
left: f.x,
|
||
top: f.y,
|
||
color: f.color,
|
||
transform: "translate(-50%, -50%)",
|
||
animation: "echo-float 0.9s ease-out forwards",
|
||
textShadow: `0 0 8px ${f.color}`,
|
||
}}
|
||
>
|
||
{f.text}
|
||
</span>
|
||
))}
|
||
</button>
|
||
|
||
{/* 数值显示 */}
|
||
<div className="text-center">
|
||
<div
|
||
className="text-2xl font-mono font-bold text-emerald-300"
|
||
style={{ textShadow: "0 0 12px rgba(52,211,153,0.5)" }}
|
||
>
|
||
{formatNum(crystals)}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground mt-0.5">
|
||
记忆晶体 · {crystalsPerSec.toFixed(1)}/s · 上限{" "}
|
||
{formatNum(crystalCap)}
|
||
</div>
|
||
<div className="text-[11px] text-muted-foreground/70 mt-2">
|
||
点击晶体发起
|
||
<span className="text-emerald-300">脉冲扫描</span>,连击叠加加成
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground/50 mt-1 tabular-nums">
|
||
仓库 {fillPct.toFixed(1)}% · 连击 ×{Math.max(1, combo)}
|
||
</div>
|
||
</div>
|
||
|
||
<style jsx>{`
|
||
@keyframes echo-spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
@keyframes echo-float {
|
||
0% {
|
||
opacity: 1;
|
||
transform: translate(-50%, -50%) scale(1);
|
||
}
|
||
100% {
|
||
opacity: 0;
|
||
transform: translate(-50%, -180%) scale(1.3);
|
||
}
|
||
}
|
||
@keyframes echo-ring {
|
||
0% {
|
||
transform: scale(0.8);
|
||
opacity: 0.8;
|
||
}
|
||
100% {
|
||
transform: scale(1.6);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|