Files
echo-nexus/src/components/game/CrystalOrb.tsx
T
2026-06-24 00:41:29 +00:00

668 lines
20 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}