335 lines
13 KiB
TypeScript
Executable File
335 lines
13 KiB
TypeScript
Executable File
"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>
|
||
);
|
||
}
|