v0.9 工单修复部署

This commit is contained in:
2026-06-24 00:41:29 +00:00
commit 73720d8306
279 changed files with 39708 additions and 0 deletions
+667
View File
@@ -0,0 +1,667 @@
"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>
);
}