v0.7: CrystalOrb Canvas 粒子系统升级 + 角色属性系统(四维)
CrystalOrb 升级(P0 视觉震撼): - 从 CSS 动画重写为 Canvas 2D 粒子系统 - 环绕能量粒子(3层32个,emerald/fuchsia/rose,带拖尾) - 环境星尘(40个,闪烁漂移) - 点击爆发粒子 + 冲击波环(连击解锁更多层) - 晶核辉光呼吸 + 3层六边形纹理 + 鼠标视差 - VLM 视觉评分 8/10 角色属性系统(P1 RPG 深度): - 新增 attributes.ts 逻辑层 + AttributesPanel UI - 四维属性:探索力/智慧/勇气/灵感(emerald/fuchsia/amber/rose) - 两区加成公式(0-50线性,50-100递减) - 飞升获得属性点(ascensions×2+1),手动分配 - 完成活动获得属性经验,自动升级 - 接入 pulse/clickNode/resolveCurrentNode/grantCruiseReward/tickTide - 旧存档兼容(migrateAttributes) - 新增 2 项成就 + 第 8 个标签页「角色」 - VLM 视觉评分 7-8/10 lint 零错误;HTTP 200
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 角色属性面板(v0.7 P1)
|
||||
//
|
||||
// 无人机驾驶员四维属性可视化:
|
||||
// • 探索力 emerald / 智慧 fuchsia / 勇气 amber / 灵感 rose
|
||||
// • 显示数值(0-100)、加成百分比、经验进度条、等级 badge
|
||||
// • 待分配属性点时高亮 + 闪烁提示,「+」按钮可分配 1 点
|
||||
// • 顶部待分配点数提示;底部总览
|
||||
// • 小屏 2×2 网格,大屏 1×4 横排
|
||||
import { useMemo } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import {
|
||||
ATTRIBUTE_CONFIG,
|
||||
ATTRIBUTE_KEYS,
|
||||
ATTRIBUTE_HARD_CAP,
|
||||
ATTRIBUTE_LINEAR_CAP,
|
||||
getAttributeBonus,
|
||||
getAllBonuses,
|
||||
totalAttributeLevel,
|
||||
totalAttributeBonusPct,
|
||||
expRequiredForLevel,
|
||||
type AttributeKey,
|
||||
} from "@/lib/game/attributes";
|
||||
import {
|
||||
Compass,
|
||||
Brain,
|
||||
Swords,
|
||||
Sparkles,
|
||||
Plus,
|
||||
ChevronUp,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Compass,
|
||||
Brain,
|
||||
Swords,
|
||||
Sparkles,
|
||||
};
|
||||
|
||||
/** 主题色 → Tailwind/CSS 颜色映射 */
|
||||
const COLOR_STYLES: Record<
|
||||
AttributeKey,
|
||||
{
|
||||
text: string;
|
||||
border: string;
|
||||
borderActive: string;
|
||||
bg: string;
|
||||
ring: string;
|
||||
barFrom: string;
|
||||
barTo: string;
|
||||
glow: string;
|
||||
}
|
||||
> = {
|
||||
exploration: {
|
||||
text: "text-emerald-300",
|
||||
border: "border-emerald-400/30",
|
||||
borderActive: "border-emerald-400/70",
|
||||
bg: "bg-emerald-500/10",
|
||||
ring: "ring-emerald-400/40",
|
||||
barFrom: "from-emerald-400",
|
||||
barTo: "to-emerald-500",
|
||||
glow: "rgba(52,211,153,0.45)",
|
||||
},
|
||||
wisdom: {
|
||||
text: "text-fuchsia-300",
|
||||
border: "border-fuchsia-400/30",
|
||||
borderActive: "border-fuchsia-400/70",
|
||||
bg: "bg-fuchsia-500/10",
|
||||
ring: "ring-fuchsia-400/40",
|
||||
barFrom: "from-fuchsia-400",
|
||||
barTo: "to-fuchsia-500",
|
||||
glow: "rgba(232,121,249,0.45)",
|
||||
},
|
||||
courage: {
|
||||
text: "text-amber-300",
|
||||
border: "border-amber-400/30",
|
||||
borderActive: "border-amber-400/70",
|
||||
bg: "bg-amber-500/10",
|
||||
ring: "ring-amber-400/40",
|
||||
barFrom: "from-amber-400",
|
||||
barTo: "to-amber-500",
|
||||
glow: "rgba(251,191,36,0.45)",
|
||||
},
|
||||
inspiration: {
|
||||
text: "text-rose-300",
|
||||
border: "border-rose-400/30",
|
||||
borderActive: "border-rose-400/70",
|
||||
bg: "bg-rose-500/10",
|
||||
ring: "ring-rose-400/40",
|
||||
barFrom: "from-rose-400",
|
||||
barTo: "to-rose-500",
|
||||
glow: "rgba(251,113,133,0.45)",
|
||||
},
|
||||
};
|
||||
|
||||
export function AttributesPanel() {
|
||||
const attributes = useGameStore((s) => s.attributes);
|
||||
const attributeProgress = useGameStore((s) => s.attributeProgress);
|
||||
const pendingAttrPoints = useGameStore((s) => s.pendingAttrPoints ?? 0);
|
||||
const allocateAttribute = useGameStore((s) => s.allocateAttribute);
|
||||
|
||||
const bonuses = useMemo(() => getAllBonuses(attributes), [attributes]);
|
||||
const totalLvl = useMemo(() => totalAttributeLevel(attributes), [attributes]);
|
||||
const totalBonus = useMemo(
|
||||
() => totalAttributeBonusPct(attributes),
|
||||
[attributes]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5 h-full overflow-y-auto echo-scroll pr-0.5">
|
||||
<style jsx global>{`
|
||||
.echo-scroll::-webkit-scrollbar { width: 4px; }
|
||||
.echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
|
||||
.echo-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
@keyframes echo-pending-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(232,121,249,0.5); }
|
||||
50% { box-shadow: 0 0 14px 4px rgba(232,121,249,0.65); }
|
||||
}
|
||||
.echo-pending-pulse {
|
||||
animation: echo-pending-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes echo-card-glow {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 顶部:标题 + 待分配点数 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<span className="bg-gradient-to-br from-emerald-300 via-fuchsia-300 to-rose-300 bg-clip-text text-transparent">
|
||||
驾驶员属性
|
||||
</span>
|
||||
</h3>
|
||||
<div
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full border ${
|
||||
pendingAttrPoints > 0
|
||||
? "border-fuchsia-400/60 bg-fuchsia-500/15 text-fuchsia-200 echo-pending-pulse font-bold"
|
||||
: "border-white/15 bg-black/25 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
待分配 {pendingAttrPoints}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 属性卡片网格:小屏 2×2,大屏 1×4 */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
||||
{ATTRIBUTE_KEYS.map((key) => {
|
||||
const meta = ATTRIBUTE_CONFIG[key];
|
||||
const cs = COLOR_STYLES[key];
|
||||
const Icon = ICON_MAP[meta.icon] ?? Sparkles;
|
||||
const value = attributes?.[key] ?? 0;
|
||||
const prog = attributeProgress?.[key] ?? { exp: 0, level: value };
|
||||
const bonusPct = getAttributeBonus(value) * 100;
|
||||
const nextExp = expRequiredForLevel(value);
|
||||
const curExp = prog.exp;
|
||||
const expPct =
|
||||
nextExp > 0 ? Math.min(100, (curExp / nextExp) * 100) : 100;
|
||||
const isMax = value >= ATTRIBUTE_HARD_CAP;
|
||||
const canAllocate = pendingAttrPoints > 0 && !isMax;
|
||||
// 区域标记
|
||||
const inDiminishing = value > ATTRIBUTE_LINEAR_CAP;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`relative rounded-xl border ${cs.border} ${cs.bg} p-2.5 flex flex-col gap-1.5 overflow-hidden transition-all duration-300`}
|
||||
style={{
|
||||
boxShadow: `inset 0 0 18px ${cs.glow}`,
|
||||
}}
|
||||
>
|
||||
{/* 顶部光晕装饰 */}
|
||||
<div
|
||||
className="pointer-events-none absolute -top-8 -right-8 h-20 w-20 rounded-full blur-2xl opacity-50"
|
||||
style={{ background: meta.hex }}
|
||||
/>
|
||||
{/* 标题行:图标 + 名称 + 等级 badge */}
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
<div
|
||||
className={`shrink-0 h-7 w-7 rounded-lg flex items-center justify-center ${cs.bg} border ${cs.border}`}
|
||||
style={{ boxShadow: `0 0 8px ${cs.glow}` }}
|
||||
>
|
||||
<Icon className={`h-3.5 w-3.5 ${cs.text}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 leading-tight">
|
||||
<div className={`text-[11px] font-semibold ${cs.text} truncate`}>
|
||||
{meta.name}
|
||||
</div>
|
||||
<div className="text-[8px] text-muted-foreground/70 uppercase tracking-wider truncate">
|
||||
{meta.enName}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`shrink-0 text-[9px] px-1.5 py-0.5 rounded font-mono font-bold ${
|
||||
isMax
|
||||
? "bg-amber-400/20 text-amber-200 border border-amber-400/40"
|
||||
: "bg-black/30 text-white/80 border border-white/10"
|
||||
}`}
|
||||
>
|
||||
Lv.{value}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数值 + 加成 */}
|
||||
<div className="relative flex items-end justify-between">
|
||||
<span className={`text-lg font-mono font-bold ${cs.text} leading-none`}>
|
||||
{value}
|
||||
<span className="text-[9px] text-muted-foreground/60 ml-0.5">
|
||||
/{ATTRIBUTE_HARD_CAP}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`text-[10px] ${cs.text} font-mono`}>
|
||||
+{bonusPct.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 经验进度条 */}
|
||||
<div className="relative">
|
||||
<div className="h-1.5 rounded-full bg-black/40 overflow-hidden border border-white/5">
|
||||
<div
|
||||
className={`h-full bg-gradient-to-r ${cs.barFrom} ${cs.barTo} transition-all duration-500`}
|
||||
style={{
|
||||
width: `${isMax ? 100 : expPct}%`,
|
||||
boxShadow: `0 0 6px ${cs.glow}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5 text-[8px] text-muted-foreground/70 font-mono">
|
||||
<span>
|
||||
{isMax ? "已满级" : `${curExp} / ${nextExp} EXP`}
|
||||
</span>
|
||||
{inDiminishing && !isMax && (
|
||||
<span className="text-amber-300/70">递减区</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加成列表 */}
|
||||
<div className="relative flex flex-col gap-0.5 mt-0.5">
|
||||
{meta.effects.map((eff, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-[8.5px] text-muted-foreground/70 leading-tight flex items-center gap-1"
|
||||
>
|
||||
<span className={cs.text}>·</span>
|
||||
<span className="truncate">{eff}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分配按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canAllocate}
|
||||
onClick={() => allocateAttribute(key, 1)}
|
||||
aria-label={`分配 1 点到 ${meta.name}`}
|
||||
className={`relative mt-auto rounded-md border px-1.5 py-1 flex items-center justify-center gap-0.5 text-[10px] font-bold transition-all ${
|
||||
canAllocate
|
||||
? `${cs.borderActive} ${cs.bg} ${cs.text} hover:scale-105 cursor-pointer`
|
||||
: "border-white/5 bg-black/20 text-muted-foreground/30 cursor-not-allowed"
|
||||
}`}
|
||||
style={
|
||||
canAllocate
|
||||
? { boxShadow: `0 0 8px ${cs.glow}` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span>分配</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 底部:总览 */}
|
||||
<div className="mt-auto rounded-lg border border-white/10 bg-black/30 px-2.5 py-1.5 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-[10px]">
|
||||
<ChevronUp className="h-3 w-3 text-fuchsia-300" />
|
||||
<span className="text-muted-foreground">总等级</span>
|
||||
<span className="font-mono font-bold text-fuchsia-200">
|
||||
{totalLvl}
|
||||
<span className="text-muted-foreground/60 text-[9px]">/{ATTRIBUTE_HARD_CAP * 4}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
<span className="text-muted-foreground">总加成</span>
|
||||
<span className="font-mono font-bold text-emerald-200">
|
||||
+{totalBonus.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 当前生效修饰器(细节展示) */}
|
||||
<div className="grid grid-cols-2 gap-1 text-[9px]">
|
||||
<DetailLine label="探险力" value={`×${bonuses.expeditionPowerMult.toFixed(2)}`} color="text-emerald-300" />
|
||||
<DetailLine label="解码步数" value={`+${bonuses.decodeStepsBonus}`} color="text-fuchsia-300" />
|
||||
<DetailLine label="洞见倍率" value={`+${(bonuses.insightMultAdd * 100).toFixed(1)}%`} color="text-fuchsia-300" />
|
||||
<DetailLine label="自动解码周期" value={`×${bonuses.autoDecodeIntervalMult.toFixed(2)}`} color="text-fuchsia-300" />
|
||||
<DetailLine label="探险生命" value={`+${bonuses.expeditionHpBonus}`} color="text-amber-300" />
|
||||
<DetailLine label="BOSS 胜率" value={`+${(bonuses.bossWinRateBonus * 100).toFixed(1)}%`} color="text-amber-300" />
|
||||
<DetailLine label="巡航护盾" value={`+${bonuses.cruiseShieldBonus}`} color="text-amber-300" />
|
||||
<DetailLine label="接触率" value={`×${bonuses.contactRateMult.toFixed(2)}`} color="text-rose-300" />
|
||||
<DetailLine label="星潮触发" value={`+${(bonuses.tideTriggerBonus * 100).toFixed(1)}%`} color="text-rose-300" />
|
||||
<DetailLine label="脉冲连击" value={`+${(bonuses.pulseComboBonus * 100).toFixed(1)}%`} color="text-rose-300" />
|
||||
<DetailLine label="巡航速度" value={`×${bonuses.cruiseShipSpeedMult.toFixed(2)}`} color="text-emerald-300" />
|
||||
<DetailLine label="产能加成" value={`×${bonuses.crystalsPerSecMult.toFixed(2)}`} color="text-emerald-300" />
|
||||
</div>
|
||||
|
||||
{/* 飞升获得属性点提示 */}
|
||||
<div className="text-[9px] text-muted-foreground/60 text-center leading-tight">
|
||||
每次飞升获得 <span className="text-fuchsia-300 font-mono">飞升次数 × 2 + 1</span> 点属性点;完成对应活动自动累积经验
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailLine({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded bg-black/25 border border-white/5 px-1.5 py-0.5">
|
||||
<span className="text-muted-foreground/80 truncate">{label}</span>
|
||||
<span className={`font-mono ${color}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+532
-129
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 中央晶体集群 + 主动脉冲
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
// 回响星核 / 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";
|
||||
@@ -15,12 +15,62 @@ interface FloatNum {
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
id: number;
|
||||
// ===== Canvas 粒子系统类型 =====
|
||||
interface EnergyParticle {
|
||||
// 环绕能量粒子(持续)
|
||||
angle: number;
|
||||
dist: 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() {
|
||||
@@ -30,11 +80,141 @@ export function CrystalOrb() {
|
||||
const crystalCap = useGameStore((s) => s.crystalCap);
|
||||
const combo = useGameStore((s) => s._combo);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [floats, setFloats] = useState<FloatNum[]>([]);
|
||||
const [particles, setParticles] = useState<Particle[]>([]);
|
||||
const [pulseAnim, setPulseAnim] = useState(0);
|
||||
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();
|
||||
@@ -43,33 +223,24 @@ export function CrystalOrb() {
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const id = idRef.current++;
|
||||
const floatColor = res.combo >= 5 ? "#fbbf24" : res.combo >= 3 ? "#e879f9" : "#34d399";
|
||||
const floatColor = comboColor(res.combo);
|
||||
setFloats((f) => [
|
||||
...f,
|
||||
{ id, x, y, text: `+${res.gain.toFixed(1)}`, born: Date.now(), color: floatColor },
|
||||
]);
|
||||
// 粒子爆发
|
||||
const newParticles: Particle[] = [];
|
||||
const pcount = 6 + Math.min(6, res.combo);
|
||||
for (let i = 0; i < pcount; i++) {
|
||||
newParticles.push({
|
||||
id: idRef.current++,
|
||||
angle: (Math.PI * 2 * i) / pcount + Math.random() * 0.3,
|
||||
dist: 0,
|
||||
{
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
text: `+${res.gain.toFixed(1)}`,
|
||||
born: Date.now(),
|
||||
color: floatColor,
|
||||
});
|
||||
}
|
||||
setParticles((p) => [...p, ...newParticles]);
|
||||
},
|
||||
]);
|
||||
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);
|
||||
setTimeout(() => {
|
||||
setParticles((p) => p.filter((it) => !newParticles.includes(it)));
|
||||
}, 700);
|
||||
if (res.combo >= 5 && res.combo % 5 === 0) {
|
||||
toast({
|
||||
title: `×${res.combo} 连击!`,
|
||||
@@ -77,126 +248,349 @@ export function CrystalOrb() {
|
||||
});
|
||||
}
|
||||
},
|
||||
[pulse, toast]
|
||||
[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">
|
||||
<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="脉冲扫描,获取记忆晶体"
|
||||
>
|
||||
{/* 外层光晕 */}
|
||||
<div className="absolute inset-0 rounded-full bg-emerald-500/10 blur-2xl group-hover:bg-emerald-500/20 transition-colors" />
|
||||
{/* 旋转外环 */}
|
||||
{/* Canvas 粒子层 */}
|
||||
<div
|
||||
className="absolute inset-2 rounded-full border border-emerald-400/30"
|
||||
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"
|
||||
className="absolute inset-6 rounded-full border border-fuchsia-400/20 border-dashed pointer-events-none"
|
||||
style={{ animation: "echo-spin 24s linear infinite reverse" }}
|
||||
/>
|
||||
{/* 进度填充环 */}
|
||||
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 100 100">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="44"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.06)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="44"
|
||||
fill="none"
|
||||
stroke="url(#echoGrad)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 44}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 44 * (1 - fillPct / 100)}`}
|
||||
style={{ transition: "stroke-dashoffset 0.3s ease", filter: "drop-shadow(0 0 4px rgba(52,211,153,0.6))" }}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="echoGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#34d399" />
|
||||
<stop offset="50%" stopColor="#e879f9" />
|
||||
<stop offset="100%" stopColor="#fb7185" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
{/* 脉冲扩散环(点击时) */}
|
||||
|
||||
{/* 脉冲扩散环(点击时,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" }}
|
||||
/>
|
||||
{/* 中央晶体 */}
|
||||
<div
|
||||
key={pulseAnim}
|
||||
className="absolute inset-12 rounded-full flex items-center justify-center"
|
||||
style={{ animation: "echo-ping 0.4s ease-out" }}
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
{/* 晶核 */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at 35% 30%, rgba(255,255,255,0.9), rgba(52,211,153,0.7) 35%, rgba(232,121,249,0.5) 70%, rgba(251,113,133,0.3))",
|
||||
boxShadow:
|
||||
"0 0 30px rgba(52,211,153,0.6), 0 0 60px rgba(232,121,249,0.4), inset 0 0 20px rgba(255,255,255,0.3)",
|
||||
}}
|
||||
/>
|
||||
{/* 高光 */}
|
||||
<div className="absolute top-3 left-6 h-6 w-10 rounded-full bg-white/40 blur-sm rotate-[-20deg]" />
|
||||
{/* 内部六边形纹理 */}
|
||||
<div
|
||||
className="absolute inset-4 rounded-full opacity-30"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(60deg, rgba(255,255,255,0.4) 0 1px, transparent 1px 8px), repeating-linear-gradient(-60deg, rgba(255,255,255,0.4) 0 1px, transparent 1px 8px)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 粒子爆发 */}
|
||||
{particles.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="absolute left-1/2 top-1/2 rounded-full pointer-events-none"
|
||||
style={{
|
||||
width: 4,
|
||||
height: 4,
|
||||
background: p.color,
|
||||
boxShadow: `0 0 6px ${p.color}`,
|
||||
["--angle" as string]: `${p.angle}rad`,
|
||||
animation: "echo-burst 0.7s ease-out forwards",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* 浮动数字 */}
|
||||
|
||||
{/* 浮动数字(保留 HTML,便于清晰文字)*/}
|
||||
{floats.map((f) => (
|
||||
<span
|
||||
key={f.id}
|
||||
className="absolute pointer-events-none font-mono font-bold text-sm"
|
||||
className="absolute pointer-events-none font-mono font-bold text-sm z-10"
|
||||
style={{
|
||||
left: f.x,
|
||||
top: f.y,
|
||||
@@ -213,39 +607,48 @@ export function CrystalOrb() {
|
||||
|
||||
{/* 数值显示 */}
|
||||
<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)" }}>
|
||||
<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)}
|
||||
记忆晶体 · {crystalsPerSec.toFixed(1)}/s · 上限{" "}
|
||||
{formatNum(crystalCap)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground/70 mt-2">
|
||||
点击晶体发起<span className="text-emerald-300">脉冲扫描</span>,连击叠加加成
|
||||
点击晶体发起
|
||||
<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-ping {
|
||||
0% { transform: scale(0.92); }
|
||||
50% { transform: scale(1.04); }
|
||||
100% { transform: scale(1); }
|
||||
@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; }
|
||||
}
|
||||
@keyframes echo-burst {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) rotate(var(--angle)) translateX(0) rotate(calc(-1 * var(--angle)));
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) rotate(var(--angle)) translateX(80px) rotate(calc(-1 * var(--angle)));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user