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:
2026-06-23 21:00:42 +00:00
parent 8474f4caa7
commit 4545fe380b
11 changed files with 1812 additions and 159 deletions
+334
View File
@@ -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>
);
}