"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([]); const idRef = useRef(0); // Canvas refs const canvasRef = useRef(null); const containerRef = useRef(null); const pulseAnimRef = useRef(0); const [pulseAnim, setPulseAnim] = useState(0); // 触发 SVG 环动画 // 粒子状态 refs(不触发 re-render) const energyParticlesRef = useRef([]); const ambientParticlesRef = useRef([]); const shockRingsRef = useRef([]); const burstParticlesRef = useRef([]); 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) => { 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) => { 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 (
{/* 连击显示 */} {combo > 1 && (
×{combo} 连击
)} {/* 数值显示 */}
{formatNum(crystals)}
记忆晶体 · {crystalsPerSec.toFixed(1)}/s · 上限{" "} {formatNum(crystalCap)}
点击晶体发起 脉冲扫描,连击叠加加成
仓库 {fillPct.toFixed(1)}% · 连击 ×{Math.max(1, combo)}
); }