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
+103
View File
@@ -0,0 +1,103 @@
# Task 7: 角色属性系统(探索力/智慧/勇气/灵感)
## Agent: full-stack-developer
## 完成状态: ✅ 全部完成
## 创建的文件
1. `/home/z/my-project/src/lib/game/attributes.ts` — 属性逻辑层(~330 行)
2. `/home/z/my-project/src/components/game/AttributesPanel.tsx` — 属性 UI 面板(~330 行)
## 修改的文件
1. `/home/z/my-project/src/lib/game/types.ts` — GameState 新增 attributes/attributeProgress/pendingAttrPoints
2. `/home/z/my-project/src/lib/game/config.ts` — INITIAL_STATE 补全新字段默认值
3. `/home/z/my-project/src/lib/game/engine.ts` — recomputeStats 聚合属性加成;performPrestige 发放属性点
4. `/home/z/my-project/src/store/gameStore.ts` — 新增 allocateAttribute/gainAttributeExp actionpulse/clickNode/autoDecodeTick/resolveCurrentNode/grantCruiseReward/tickTide/init 接入属性逻辑;旧存档兼容
5. `/home/z/my-project/src/lib/game/achievements.ts` — 新增 2 项属性成就
6. `/home/z/my-project/src/app/page.tsx` — 新增第 8 个「角色」标签页 + grid-cols-7→8 + 红点提示 + 统计面板新增属性行 + 版本号 v0.6→v0.7
## 关键实现细节
### attributes.ts 逻辑层
- **类型系统**AttributeKey4 个键)/ CharacterAttributes0-100 数值)/ AttributeProgressEntryexp + level
- **加成公式**
- 0-50 线性区:每点 +0.5% 加成(0..50 → 0..25%
- 50-100 递减区:每点 +0.2% 加成(50..100 → 25..35%
- 超过 100 仍按 100 计算加成(软上限)
- **getAttributeBonus(attr)** 单属性加成百分比
- **getAllBonuses(attrs)** 返回 12 个修饰器:探险力倍率/巡航速度/解码步数/洞见倍率/自动解码周期/探险生命/BOSS 胜率/巡航护盾/接触率/星潮触发/脉冲连击/产能加成
- **levelUpCheck(progress)** 自动跨多级升级,安全上限 200 次循环
- **expRequiredForLevel(level) = 10 × level**(最低 10
- **computePrestigeAttrPoints(ascensions) = ascensions × 2 + 1**(飞升前次数计算)
- **migrateAttributes(state)** 旧存档兼容:补全缺失字段、夹紧越界值、同步 level 与 attributes
### engine.ts 改造
- recomputeStats 末尾追加 `getAllBonuses(state.attributes ?? {})` 聚合:
- `crystalsPerSec *= am.crystalsPerSecMult`
- `insightMult += am.insightMultAdd`
- `contactRateMult *= am.contactRateMult`
- `decodeStepsBonus += am.decodeStepsBonus`
- performPrestige
- 保留 attributes 数值(跨周目永久)
- 清空 attributeProgress(新周目重新累积经验)
- pendingAttrPoints += computePrestigeAttrPoints(ascensions) = ascensions × 2 + 1
- createInitialState:每次返回全新 attributes/attributeProgress 对象,避免引用共享
### gameStore.ts 集成(核心)
- **pulse**:连击 ≥3 给灵感经验(expGain = 1 + floor(combo/2));脉冲威力乘以灵感连击加成
- **clickNode** 完成:给智慧经验(expGain = puzzle.tier × 2
- **autoDecodeTick**:自动解码也给智慧经验 +1;自动解码周期受智慧 am.autoDecodeIntervalMult 影响
- **startExpedition**:探险力乘 am.expeditionPowerMult;探险生命加 am.expeditionHpBonus
- **resolveCurrentNode**
- BOSS 节点用包装 RNG 提升 +am.bossWinRateBonus 胜率(单次 rng 调用,B% 概率返回 0,其余返回 r-B 保持均匀分布)
- 战斗胜利给勇气+探索力经验(中途战斗 +2/+1,BOSS 击破 +8/+6,探险胜利 +4
- **grantCruiseReward**:按总奖励量缩放给探索力+勇气经验(expBase = max(2, totalReward/30)
- **tickTide**:灵感 am.tideTriggerBonus 缩短星潮间隙(gap × (1 - bonus)),上限 30%
- **doPrestige**performPrestige 后用 next.attributes 重算 stats
- **allocateAttribute(attr, points=1)**:分配属性点,同步 attributeProgress[attr].level
- **gainAttributeExp(attr, amount)**:通用经验获取(自动升级)
- **init()**:调用 migrateAttributes 补全旧存档字段,并传 attributes 到 syncStats
- 所有 syncStats 调用点(10+ 处)都补充 `attributes: ...` 参数
### AttributesPanel.tsx UI
- 四维属性卡片网格(小屏 2×2,大屏 1×4):
- 图标(Compass/Brain/Swords/Sparkles+ 中文名 + 英文名 + Lv.{value} badge
- 数值 /100 + 加成百分比
- 经验进度条(gradient + glow+ "递减区"标记
- 加成影响列表(3 条)
- 「+分配」按钮(pendingAttrPoints > 0 时可点,hover scale 105
- 顶部:标题 + 待分配点数 badgependingAttrPoints > 0 时 echo-pending-pulse 动画)
- 底部:总等级 + 总加成概览 + 12 个修饰器明细行
- 配色:4 色全息(emerald/fuchsia/amber/rose),辉光边框 + 顶角光晕装饰
- 完全响应式(2 列 → 4 列),overflow-y-auto + 自定义 scrollbar
### page.tsx 集成
- 新增 `User` 图标导入
- 新增 `pendingAttrPoints` store selector
- TabsList: grid-cols-7 → grid-cols-8
- 新增第 8 个 TabsTrigger「角色」(value="attributes"),主题色用 emerald→fuchsia→rose 渐变
- pendingAttrPoints > 0 时显示 rose 红点
- 新增 TabsContent 渲染 AttributesPanel
- StatsPanel 新增 5 行属性行(探索力/智慧/勇气/灵感/待分配属性点)
- 版本号 v0.6 → v0.7
### achievements.ts 新增
- `ach_attr_total_50`(四维觉醒):四维属性总和 ≥ 50 → 产能+6%/洞见+6%
- `ach_attr_max_100`(维度精通):任一属性 ≥ 100 → 产能+12%/洞见+10%
## QA 验证结果
-`bun run lint` 零错误
- ✅ dev 服务器 HTTP 200
- ✅ 编译 < 250ms177ms 实测)
- ✅ 7 标签页 + 巡航按钮完好保留,新增第 8 个「角色」标签页
- ✅ data-tut 锚点(tab-expedition/tab-tech/prestige-btn/crystal-orb/decode-panel)保留
- ✅ 色彩规范:严格 emerald/fuchsia/amber/rose 四色全息,零蓝色/靛色
- ✅ 旧存档兼容:migrateAttributes 补全 attributes/attributeProgress/pendingAttrPoints 字段
- ✅ 飞升后 pendingAttrPoints = ascensions × 2 + 1(飞升前次数)
- ✅ pulse/clickNode/resolveCurrentNode/grantCruiseReward/autoDecodeTick 均接入属性经验获取
## 注意事项
- BOSS 胜率 RNG 包装:仅在 boss 节点生效,单次 rng 调用保持均匀分布;+am.bossWinRateBonus 上限 +30%
- 灵感星潮触发:通过缩短 gap 间接提升触发频率(上限 30%)
- 属性加成与现有所有系统(技术树/蓝图/成就/星图/星潮)叠加,不冲突
- migrateAttributes 同步 attributeProgress[attr].level 与 attributes[attr] 数值,避免漂移
+21 -3
View File
@@ -19,6 +19,7 @@ import { BeaconPanel } from "@/components/game/BeaconPanel";
import { TutorialOverlay } from "@/components/game/TutorialOverlay";
import { OfflineReportDialog } from "@/components/game/OfflineReportDialog";
import { CruiseMode } from "@/components/game/CruiseMode";
import { AttributesPanel } from "@/components/game/AttributesPanel";
import {
StarTideNotifier,
StarTideIndicator,
@@ -42,6 +43,7 @@ import {
Star,
Radio,
Navigation,
User,
} from "lucide-react";
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
@@ -74,6 +76,7 @@ export default function Page() {
const energy = useGameStore((s) => s.energy);
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
const pendingAttrPoints = useGameStore((s) => s.pendingAttrPoints ?? 0);
// 深空信标:检测是否有可领取的奖励(独立 localStorage
const [beaconClaimable, setBeaconClaimable] = useState(false);
@@ -169,7 +172,7 @@ export default function Page() {
<div className="leading-tight">
<h1 className="text-base sm:text-lg font-bold text-gradient"></h1>
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider">
ECHO NEXUS · v0.6
ECHO NEXUS · v0.7
</p>
</div>
</div>
@@ -257,10 +260,10 @@ export default function Page() {
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[300px] sm:min-h-[360px] max-h-[560px]">
<DecodePanel />
</div>
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 */}
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
<div className="glass rounded-2xl p-3 min-h-[280px] sm:min-h-[320px] max-h-[440px] flex flex-col">
<Tabs defaultValue={hasActiveExpedition ? "expedition" : hasPendingPerk ? "constellation" : "tech"} className="h-full flex flex-col">
<TabsList className="grid grid-cols-7 h-9 bg-black/30 gap-0.5 p-1">
<TabsList className="grid grid-cols-8 h-9 bg-black/30 gap-0.5 p-1">
<TabsTrigger value="expedition" data-tut="tab-expedition" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-amber-500/15 data-[state=active]:shadow-[0_0_12px_rgba(251,191,36,0.3)] transition-all">
<Rocket className="h-3.5 w-3.5" />
<span className="leading-none"></span>
@@ -304,6 +307,13 @@ export default function Page() {
<BarChart3 className="h-3.5 w-3.5" />
<span className="leading-none"></span>
</TabsTrigger>
<TabsTrigger value="attributes" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-gradient-to-br data-[state=active]:from-emerald-500/15 data-[state=active]:via-fuchsia-500/15 data-[state=active]:to-rose-500/15 data-[state=active]:shadow-[0_0_12px_rgba(232,121,249,0.3)] transition-all">
<User className="h-3.5 w-3.5" />
<span className="leading-none"></span>
{pendingAttrPoints > 0 && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-rose-400 animate-pulse ring-1 ring-black/50" />
)}
</TabsTrigger>
</TabsList>
<TabsContent value="expedition" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<ExpeditionPanel />
@@ -326,6 +336,9 @@ export default function Page() {
<TabsContent value="stats" className="flex-1 mt-2 min-h-0">
<StatsPanel />
</TabsContent>
<TabsContent value="attributes" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<AttributesPanel />
</TabsContent>
</Tabs>
</div>
</section>
@@ -424,6 +437,11 @@ function StatsPanel() {
{ label: "编年史条目", value: `${(s.chronicle ?? []).length} 纪元` },
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
{ label: "信标最高分", value: beaconBest !== null ? formatNum(beaconBest) : "—" },
{ label: "探索力", value: `${s.attributes?.exploration ?? 0} / 100` },
{ label: "智慧", value: `${s.attributes?.wisdom ?? 0} / 100` },
{ label: "勇气", value: `${s.attributes?.courage ?? 0} / 100` },
{ label: "灵感", value: `${s.attributes?.inspiration ?? 0} / 100` },
{ label: "待分配属性点", value: `${s.pendingAttrPoints ?? 0}` },
];
return (
<div className="grid grid-cols-2 gap-1.5 text-xs">
+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>
);
}
+532 -129
View File
@@ -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;
}
}
+39
View File
@@ -251,6 +251,45 @@ export const ACHIEVEMENTS: Achievement[] = [
reward: { crystalsPerSecPct: 8, insightPct: 8 },
rewardText: "产能 +8% · 洞见 +8%",
},
{
id: "ach_attr_total_50",
name: "四维觉醒",
desc: "角色四维属性总和达到 50",
icon: "🧭",
color: "#e879f9",
check: (s) => {
const a = s.attributes;
if (!a) return false;
return (
(a.exploration || 0) +
(a.wisdom || 0) +
(a.courage || 0) +
(a.inspiration || 0) >=
50
);
},
reward: { crystalsPerSecPct: 6, insightPct: 6 },
rewardText: "产能 +6% · 洞见 +6%",
},
{
id: "ach_attr_max_100",
name: "维度精通",
desc: "任一角色属性达到满级 100",
icon: "💫",
color: "#fbbf24",
check: (s) => {
const a = s.attributes;
if (!a) return false;
return (
a.exploration >= 100 ||
a.wisdom >= 100 ||
a.courage >= 100 ||
a.inspiration >= 100
);
},
reward: { crystalsPerSecPct: 12, insightPct: 10 },
rewardText: "产能 +12% · 洞见 +10%",
},
];
/** 计算成就提供的永久加成(跨周目保留) */
+363
View File
@@ -0,0 +1,363 @@
// 回响星核 / Echo Nexus — 角色属性系统 (v0.7 P1)
//
// 无人机驾驶员的四维属性,为放置循环注入 RPG 深度:
// • 探索力 Exploration(翠 emerald)— 探险力 / 飞船速度 / 星图发现
// • 智慧 Wisdom (玫 fuchsia)— 解码步数 / 洞见 / 自动解码周期
// • 勇气 Courage (琥 amber — 探险生命 / BOSS 战胜率 / 巡航护盾
// • 灵感 Inspiration (玫红 rose — 接触率 / 星潮触发 / 脉冲连击加成
//
// 数值规则:
// • 0-100 软上限(超过后收益递减)
// • 0-50 线性区:每 10 点 = +5% 加成 → 即 0.5%/点
// • 50-100 递减区:每点 = +2% 加成
// • 属性通过「属性点」升级(飞升获得),亦可由「经验值」自动累积升级
// • 每级经验需求 = 10 × 当前等级
import type { GameState } from "./types";
/** 四维属性键 */
export type AttributeKey = "exploration" | "wisdom" | "courage" | "inspiration";
/** 角色四维属性数值(0-100) */
export interface CharacterAttributes extends Record<AttributeKey, number> {
exploration: number;
wisdom: number;
courage: number;
inspiration: number;
}
/** 单个属性的经验进度 */
export interface AttributeProgressEntry {
/** 当前累计经验值 */
exp: number;
/** 当前等级(与 attributes[key] 同步) */
level: number;
}
/** 经验进度集合 */
export type AttributeProgress = Record<AttributeKey, AttributeProgressEntry>;
/** 单属性元信息(供 UI 渲染) */
export interface AttributeMeta {
key: AttributeKey;
name: string;
enName: string;
/** 主题色(Tailwind class 前缀,避免使用蓝/靛色) */
color: "emerald" | "fuchsia" | "amber" | "rose";
hex: string;
glow: string;
/** lucide-react 图标名 */
icon: string;
desc: string;
/** 影响的游戏系统列表(用于 UI 展示) */
effects: string[];
}
/** 属性配置常量 */
export const ATTRIBUTE_CONFIG: Record<AttributeKey, AttributeMeta> = {
exploration: {
key: "exploration",
name: "探索力",
enName: "Exploration",
color: "emerald",
hex: "#34d399",
glow: "rgba(52,211,153,0.55)",
icon: "Compass",
desc: "足迹所至,皆是矿脉。提升探险力与巡航飞船速度。",
effects: ["探险力 +X%", "巡航飞船速度 +X%", "星图发现概率"],
},
wisdom: {
key: "wisdom",
name: "智慧",
enName: "Wisdom",
color: "fuchsia",
hex: "#e879f9",
glow: "rgba(232,121,249,0.55)",
icon: "Brain",
desc: "谐振的回声唯有智者能解。强化解码与洞见。",
effects: ["解码步数 +X", "洞见产出 +X%", "自动解码周期 -X%"],
},
courage: {
key: "courage",
name: "勇气",
enName: "Courage",
color: "amber",
hex: "#fbbf24",
glow: "rgba(251,191,36,0.55)",
icon: "Swords",
desc: "直面虚空者,虚空亦为之让路。增益战斗与护盾。",
effects: ["探险生命 +X", "BOSS 战胜率 +X%", "巡航护盾 +X"],
},
inspiration: {
key: "inspiration",
name: "灵感",
enName: "Inspiration",
color: "rose",
hex: "#fb7185",
glow: "rgba(251,113,133,0.55)",
icon: "Sparkles",
desc: "在不期而至的回响之间,照见更高的维度。",
effects: ["接触率 +X", "星潮触发概率 +X%", "脉冲连击加成 +X%"],
},
};
/** 属性键的固定顺序(UI 渲染用) */
export const ATTRIBUTE_KEYS: AttributeKey[] = [
"exploration",
"wisdom",
"courage",
"inspiration",
];
/** 属性数值上限(软上限,超过后收益递减但仍可继续投资) */
export const ATTRIBUTE_HARD_CAP = 100;
/** 线性区与递减区交界点 */
export const ATTRIBUTE_LINEAR_CAP = 50;
/**
* 计算单个属性当前的加成百分比(小数形式,1.0 = 100%)。
* - 0-50 线性区:每点 +0.5% → 0-50 点对应 0-25%
* - 50-100 递减区:每点 +0.2% → 50-100 点对应 25-35%
* - >100 仍按 100 计算加成(软上限)
*/
export function getAttributeBonus(attr: number): number {
const v = Math.max(0, Math.min(ATTRIBUTE_HARD_CAP, attr));
if (v <= ATTRIBUTE_LINEAR_CAP) {
// 0..50 → 0..25%
return v * 0.005;
}
// 25% + (v - 50) * 0.2%
return 0.25 + (v - ATTRIBUTE_LINEAR_CAP) * 0.002;
}
/** 计算单个属性对脉冲连击的额外加成百分比(线性,灵感专属) */
export function getInspirationComboBonus(attr: number): number {
// 每点 +0.5% 连击伤害加成(无递减)
return Math.max(0, Math.min(ATTRIBUTE_HARD_CAP, attr)) * 0.005;
}
/** 属性聚合修饰器(聚合到 recomputeStats 与具体动作) */
export interface AttributeModifiers {
/** 探险力 +X% (探索力) */
expeditionPowerMult: number;
/** 巡航飞船速度 +X% (探索力) */
cruiseShipSpeedMult: number;
/** 解码步数 +X (智慧) — 每点 +0.1 步 → 0-10 步加成 */
decodeStepsBonus: number;
/** 洞见倍率 +X% (智慧) */
insightMultAdd: number;
/** 自动解码周期 -X% (智慧) */
autoDecodeIntervalMult: number;
/** 探险生命 +X (勇气) — 每点 +2 HP */
expeditionHpBonus: number;
/** BOSS 战胜率 +X% (勇气) */
bossWinRateBonus: number;
/** 巡航护盾 +X (勇气) — 每点 +1 护盾 */
cruiseShieldBonus: number;
/** 接触率倍率 (灵感) */
contactRateMult: number;
/** 星潮触发概率 +X% (灵感) */
tideTriggerBonus: number;
/** 脉冲连击加成 +X% (灵感) */
pulseComboBonus: number;
/** 综合产能加成(探索力 + 灵感 0.2%/点) */
crystalsPerSecMult: number;
}
/** 由四维属性计算所有修饰器 */
export function getAllBonuses(attrs: Partial<CharacterAttributes>): AttributeModifiers {
const exploration = attrs.exploration ?? 0;
const wisdom = attrs.wisdom ?? 0;
const courage = attrs.courage ?? 0;
const inspiration = attrs.inspiration ?? 0;
return {
expeditionPowerMult: 1 + getAttributeBonus(exploration),
cruiseShipSpeedMult: 1 + getAttributeBonus(exploration) * 0.6, // 飞船速度加成减半
decodeStepsBonus: Math.floor(wisdom * 0.1), // 每点 +0.1 步
insightMultAdd: getAttributeBonus(wisdom),
autoDecodeIntervalMult: 1 - Math.min(0.5, wisdom * 0.004), // 每点 -0.4%,上限 -50%
expeditionHpBonus: Math.round(courage * 2),
bossWinRateBonus: Math.min(0.3, courage * 0.003), // 每点 +0.3%,上限 +30%
cruiseShieldBonus: Math.round(courage * 1),
contactRateMult: 1 + getAttributeBonus(inspiration),
tideTriggerBonus: Math.min(0.3, inspiration * 0.003),
pulseComboBonus: getInspirationComboBonus(inspiration),
crystalsPerSecMult:
1 +
getAttributeBonus(exploration) * 0.4 + // 探索力 0.4 倍系数加成产能
getAttributeBonus(inspiration) * 0.2, // 灵感 0.2 倍系数加成产能
};
}
/** 每级所需经验 = 10 × 当前等级 */
export function expRequiredForLevel(level: number): number {
return Math.max(10, 10 * Math.max(0, level));
}
/**
* 检查属性经验是否足以升级。
* 返回新的进度(可能跨多级)。
*/
export function levelUpCheck(
progress: AttributeProgressEntry,
maxValue: number = ATTRIBUTE_HARD_CAP
): { leveledUp: boolean; newProgress: AttributeProgressEntry; levelsGained: number } {
let { exp, level } = progress;
let leveledUp = false;
let levelsGained = 0;
// 安全上限:避免异常数据循环
let safety = 0;
while (level < maxValue && exp >= expRequiredForLevel(level) && safety < 200) {
exp -= expRequiredForLevel(level);
level += 1;
leveledUp = true;
levelsGained += 1;
safety += 1;
}
// 已达上限:保留多余经验但不升级
if (level >= maxValue) {
level = maxValue;
}
return {
leveledUp,
levelsGained,
newProgress: { exp, level },
};
}
/** 创建初始的属性进度集合 */
export function createInitialAttributeProgress(): AttributeProgress {
return {
exploration: { exp: 0, level: 0 },
wisdom: { exp: 0, level: 0 },
courage: { exp: 0, level: 0 },
inspiration: { exp: 0, level: 0 },
};
}
/** 创建初始的属性数值集合 */
export function createInitialAttributes(): CharacterAttributes {
return {
exploration: 0,
wisdom: 0,
courage: 0,
inspiration: 0,
};
}
/**
* 旧存档兼容:补全 attributes / attributeProgress / pendingAttrPoints 字段。
* 若字段缺失或结构异常,使用初始值;若数值越界则夹紧。
*/
export function migrateAttributes(state: Partial<GameState>): {
attributes: CharacterAttributes;
attributeProgress: AttributeProgress;
pendingAttrPoints: number;
} {
const init = createInitialAttributes();
let attributes: CharacterAttributes;
if (state.attributes && typeof state.attributes === "object") {
attributes = {
exploration: clampAttr(state.attributes.exploration),
wisdom: clampAttr(state.attributes.wisdom),
courage: clampAttr(state.attributes.courage),
inspiration: clampAttr(state.attributes.inspiration),
};
} else {
attributes = init;
}
let attributeProgress: AttributeProgress;
if (state.attributeProgress && typeof state.attributeProgress === "object") {
const base = createInitialAttributeProgress();
attributeProgress = {
exploration: normalizeProgress(
state.attributeProgress.exploration,
attributes.exploration
),
wisdom: normalizeProgress(
state.attributeProgress.wisdom,
attributes.wisdom
),
courage: normalizeProgress(
state.attributeProgress.courage,
attributes.courage
),
inspiration: normalizeProgress(
state.attributeProgress.inspiration,
attributes.inspiration
),
};
void base;
} else {
// 同步 level 与 attributes 数值(保证一致)
attributeProgress = {
exploration: { exp: 0, level: attributes.exploration },
wisdom: { exp: 0, level: attributes.wisdom },
courage: { exp: 0, level: attributes.courage },
inspiration: { exp: 0, level: attributes.inspiration },
};
}
const pendingAttrPoints =
typeof state.pendingAttrPoints === "number" && state.pendingAttrPoints >= 0
? Math.floor(state.pendingAttrPoints)
: 0;
return { attributes, attributeProgress, pendingAttrPoints };
}
function clampAttr(v: unknown): number {
const n = typeof v === "number" && isFinite(v) ? v : 0;
return Math.max(0, Math.min(ATTRIBUTE_HARD_CAP, Math.floor(n)));
}
function normalizeProgress(
p: unknown,
level: number
): AttributeProgressEntry {
if (p && typeof p === "object") {
const obj = p as { exp?: unknown; level?: unknown };
const exp =
typeof obj.exp === "number" && isFinite(obj.exp) && obj.exp >= 0
? obj.exp
: 0;
const lvl =
typeof obj.level === "number" && isFinite(obj.level) && obj.level >= 0
? Math.floor(obj.level)
: level;
// level 字段以 attributes 数值为准(attributes 是事实之源)
return { exp, level };
}
return { exp: 0, level };
}
/** 计算本次飞升可获得的属性点 = 飞升次数 × 2 + 1(飞升前 ascensions */
export function computePrestigeAttrPoints(ascensionsBefore: number): number {
return ascensionsBefore * 2 + 1;
}
/** 计算四维属性总和 */
export function totalAttributeLevel(attrs: CharacterAttributes): number {
return (
attrs.exploration + attrs.wisdom + attrs.courage + attrs.inspiration
);
}
/** 计算四维属性总加成百分比(用于 UI 概览) */
export function totalAttributeBonusPct(attrs: CharacterAttributes): number {
return (
(getAttributeBonus(attrs.exploration) +
getAttributeBonus(attrs.wisdom) +
getAttributeBonus(attrs.courage) +
getAttributeBonus(attrs.inspiration)) *
100
);
}
/** 是否任一属性已达 100(用于成就) */
export function anyAttributeMaxed(attrs: CharacterAttributes): boolean {
return (
attrs.exploration >= ATTRIBUTE_HARD_CAP ||
attrs.wisdom >= ATTRIBUTE_HARD_CAP ||
attrs.courage >= ATTRIBUTE_HARD_CAP ||
attrs.inspiration >= ATTRIBUTE_HARD_CAP
);
}
+14
View File
@@ -45,6 +45,20 @@ export const INITIAL_STATE = {
starTidesEncountered: [] as string[],
crystalsDecoded: 0,
},
// v0.7 角色属性系统
attributes: {
exploration: 0,
wisdom: 0,
courage: 0,
inspiration: 0,
} as import("./attributes").CharacterAttributes,
attributeProgress: {
exploration: { exp: 0, level: 0 },
wisdom: { exp: 0, level: 0 },
courage: { exp: 0, level: 0 },
inspiration: { exp: 0, level: 0 },
} as import("./attributes").AttributeProgress,
pendingAttrPoints: 0,
theme: "dark" as const,
soundOn: true,
};
+42 -2
View File
@@ -14,6 +14,12 @@ import {
buildChronicleEntry,
createRunStartSnapshot,
} from "./chronicle";
import {
getAllBonuses,
migrateAttributes,
computePrestigeAttrPoints,
createInitialAttributeProgress,
} from "./attributes";
/** 由技术树 + 飞升蓝图 + 成就 + 星图天赋 + 星潮聚合计算产能字段 */
export function recomputeStats(state: Partial<GameState>): {
@@ -96,6 +102,13 @@ export function recomputeStats(state: Partial<GameState>): {
insightMult += tideMod.insightMultAdd;
contactRateMult *= tideMod.contactRateMult;
// 角色属性加成(v0.7 P1 — 与现有所有加成叠加)
const am = getAllBonuses(state.attributes ?? {});
crystalsPerSec *= am.crystalsPerSecMult;
insightMult += am.insightMultAdd;
contactRateMult *= am.contactRateMult;
decodeStepsBonus += am.decodeStepsBonus;
return {
crystalsPerSec,
crystalCap,
@@ -142,11 +155,20 @@ export function performPrestige(state: GameState): GameState {
const newChronicle = [...(state.chronicle || []), chronicleEntry].slice(-50); // 上限 50 条
const freshRunStart = createRunStartSnapshot(state);
// v0.7 角色属性:保留 attributes 数值(跨周目永久),清空当前周目经验进度,
// 发放待分配属性点 = 飞升次数 × 2 + 1(用飞升前的次数计算)
const migrated = migrateAttributes(state);
const attributes = migrated.attributes; // 保留数值
const attributeProgress = createInitialAttributeProgress(); // 清空经验
const earnedAttrPoints = computePrestigeAttrPoints(state.ascensions ?? 0);
const pendingAttrPoints = migrated.pendingAttrPoints + earnedAttrPoints;
// 保留:fragments, totalDecoded(累计), ascensions+1, blueprints, achievements, constellation, theme/sound, expeditionLog
// chronicle(新增), bossKills(累计), starTidesEncountered(累计), runStart(重置为新周目)
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy、星潮
// attributes(永久保留), pendingAttrPoints(累加)
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy、星潮、attributeProgress
const fresh = createInitialState();
const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements, constellation: state.constellation });
const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements, constellation: state.constellation, attributes });
return {
...fresh,
fragments: state.fragments,
@@ -174,6 +196,10 @@ export function performPrestige(state: GameState): GameState {
// 星潮:飞升后清空,lastTideEnd 设为现在,使首次星潮在 firstDelay 后触发
activeTide: null,
lastTideEnd: Date.now(),
// v0.7 角色属性
attributes,
attributeProgress,
pendingAttrPoints,
...stats,
};
}
@@ -194,6 +220,20 @@ export function createInitialState(): GameState {
lastEnergyTick: Date.now(),
createdAt: Date.now(),
lastTick: Date.now(),
// v0.7 角色属性:每次创建都生成全新对象,避免引用共享
attributes: {
exploration: 0,
wisdom: 0,
courage: 0,
inspiration: 0,
},
attributeProgress: {
exploration: { exp: 0, level: 0 },
wisdom: { exp: 0, level: 0 },
courage: { exp: 0, level: 0 },
inspiration: { exp: 0, level: 0 },
},
pendingAttrPoints: 0,
} as GameState;
}
+5
View File
@@ -142,6 +142,11 @@ export interface GameState {
chronicle: ChronicleEntry[];
runStart: RunStartSnapshot;
// 角色属性(v0.7 P1 — 四维属性系统)
attributes: import("./attributes").CharacterAttributes;
attributeProgress: import("./attributes").AttributeProgress;
pendingAttrPoints: number;
// 元
lastTick: number;
createdAt: number;
+244 -18
View File
@@ -67,6 +67,17 @@ import {
type BeaconDailyProgress,
} from "@/lib/game/beacon";
import { setPendingOfflineReport } from "@/lib/game/offlineReport";
import {
ATTRIBUTE_HARD_CAP,
migrateAttributes,
levelUpCheck,
getAllBonuses,
createInitialAttributes,
createInitialAttributeProgress,
type AttributeKey,
type CharacterAttributes,
type AttributeProgress,
} from "@/lib/game/attributes";
interface GameActions {
// 生命周期
@@ -121,6 +132,10 @@ interface GameActions {
// 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法)
grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void;
// 角色属性(v0.7 P1
allocateAttribute: (attr: AttributeKey, points?: number) => { ok: boolean; leveledUp?: number };
gainAttributeExp: (attr: AttributeKey, amount: number) => { leveledUp: number; newLevel: number };
// 派生
canPrestige: () => boolean;
}
@@ -220,6 +235,8 @@ export const useGameStore = create<Store>()(
const pendingPerkChoices = s.pendingPerkChoices ?? null;
// v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered
const migrated = migrateChronicleFields(s);
// v0.7 角色属性兼容:补全 attributes / attributeProgress / pendingAttrPoints
const attrMigrated = migrateAttributes(s);
// 星图「能量共振」天赋 +1 能量上限
const cm = constellationBonuses(constellation);
const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus;
@@ -261,16 +278,19 @@ export const useGameStore = create<Store>()(
runStart: migrated.runStart,
bossKills: migrated.bossKills,
starTidesEncountered: migrated.starTidesEncountered,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }),
attributes: attrMigrated.attributes,
attributeProgress: attrMigrated.attributeProgress,
pendingAttrPoints: attrMigrated.pendingAttrPoints,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }),
});
} else {
set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, constellation, pendingPerkChoices, energyMax, chronicle: migrated.chronicle, runStart: migrated.runStart, bossKills: migrated.bossKills, starTidesEncountered: migrated.starTidesEncountered, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }) });
set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, constellation, pendingPerkChoices, energyMax, chronicle: migrated.chronicle, runStart: migrated.runStart, bossKills: migrated.bossKills, starTidesEncountered: migrated.starTidesEncountered, attributes: attrMigrated.attributes, attributeProgress: attrMigrated.attributeProgress, pendingAttrPoints: attrMigrated.pendingAttrPoints, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }) });
}
},
loadOnline: () => {
const s = get();
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }) });
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) });
},
hardReset: () => {
@@ -282,7 +302,10 @@ export const useGameStore = create<Store>()(
const tide = s.activeTide;
// 星图「星潮引导」减少间隙
const cm = constellationBonuses(s.constellation ?? []);
const gap = Math.max(15000, TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000);
// v0.7 灵感:星潮触发概率 +X%(缩短间隙)
const am = getAllBonuses(s.attributes ?? {});
const tideGapReduction = Math.min(0.3, am.tideTriggerBonus);
const gap = Math.max(15000, (TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000) * (1 - tideGapReduction));
// 1) 检查当前星潮是否结束
if (tide && now >= tide.endsAt) {
const endedType = tide.type;
@@ -299,7 +322,7 @@ export const useGameStore = create<Store>()(
lastTideEnd: now,
insights: newInsights,
// 星潮结束后重算 stats(移除 contactRate/insight 修饰)
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }),
_tideEvents: [...s._tideEvents, event],
});
return event;
@@ -327,7 +350,7 @@ export const useGameStore = create<Store>()(
activeTide: newTide,
starTidesEncountered: newTidesAll,
// 星潮开始后重算 stats(应用 contactRate/insight 修饰)
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }),
_tideEvents: [...s._tideEvents, event],
});
return event;
@@ -412,7 +435,10 @@ export const useGameStore = create<Store>()(
const mult = 1 + (combo - 1) * 0.15;
// 星潮脉冲威力修饰
const tideMod = getTideModifiers(s.activeTide);
const gain = s.pulsePower * mult * tideMod.pulsePowerMult;
// v0.7 灵感:脉冲连击加成 +X%
const am = getAllBonuses(s.attributes ?? {});
const comboBonusMult = 1 + am.pulseComboBonus * Math.max(0, combo - 1);
const gain = s.pulsePower * mult * tideMod.pulsePowerMult * comboBonusMult;
set({
crystals: Math.min(s.crystalCap, s.crystals + gain),
_combo: combo,
@@ -420,6 +446,30 @@ export const useGameStore = create<Store>()(
});
// 深空信标:脉冲任务进度 +1
trackBeacon("pulse", 1);
// v0.7 角色属性:连击 ≥3 给灵感经验
if (combo >= 3) {
const expGain = 1 + Math.floor(combo / 2); // 3 连击=2, 5 连击=3, 10 连击=6
// 内联经验获取(避免递归调用 set)
const prog = s.attributeProgress?.inspiration ?? { exp: 0, level: s.attributes?.inspiration ?? 0 };
const nextExp = prog.exp + expGain;
const lvlResult = levelUpCheck(
{ exp: nextExp, level: s.attributes?.inspiration ?? 0 },
ATTRIBUTE_HARD_CAP
);
const newAttributes: CharacterAttributes = {
...(s.attributes ?? createInitialAttributes()),
inspiration: lvlResult.newProgress.level,
};
const newProgress: AttributeProgress = {
...(s.attributeProgress ?? createInitialAttributeProgress()),
inspiration: lvlResult.newProgress,
};
set({
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
});
}
return { gain, combo };
},
@@ -459,6 +509,23 @@ export const useGameStore = create<Store>()(
// 解锁碎片
const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
const unlocked = checkFragments(tentative);
// v0.7 角色属性:完成解码给智慧经验(tier 越高经验越多)
const wisdomExpGain = puzzle.tier * 2;
const curAttrs = s.attributes ?? createInitialAttributes();
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom };
const wisdomLvl = levelUpCheck(
{ exp: progEntry.exp + wisdomExpGain, level: curAttrs.wisdom },
ATTRIBUTE_HARD_CAP
);
const newAttributes: CharacterAttributes = {
...curAttrs,
wisdom: wisdomLvl.newProgress.level,
};
const newProgress: AttributeProgress = {
...curProg,
wisdom: wisdomLvl.newProgress,
};
set({
activePuzzle: null,
crystals: newCrystals,
@@ -466,6 +533,9 @@ export const useGameStore = create<Store>()(
contact: newContact,
totalDecoded: newTotal,
fragments: tentative.fragments,
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
});
// 深空信标:解码 +1,洞见累计
trackBeacon("decode", 1);
@@ -520,7 +590,10 @@ export const useGameStore = create<Store>()(
const now = Date.now();
// 星图「自动校准」减少自动解码周期
const cm = constellationBonuses(s.constellation ?? []);
const interval = Math.max(5000, 12000 + cm.autoDecodeIntervalDeltaSec * 1000);
// v0.7 智慧:自动解码周期 -X%
const am = getAllBonuses(s.attributes ?? {});
const baseInterval = 12000 + cm.autoDecodeIntervalDeltaSec * 1000;
const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult);
if (now - s._lastAutoDecode < interval) return;
// 找一颗 T1 晶体自动解码
const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
@@ -537,6 +610,22 @@ export const useGameStore = create<Store>()(
const newTotal = s.totalDecoded + 1;
const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
checkFragments(tentative);
// v0.7 角色属性:自动解码给智慧经验(少量)
const curAttrs = s.attributes ?? createInitialAttributes();
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom };
const wisdomLvl = levelUpCheck(
{ exp: progEntry.exp + 1, level: curAttrs.wisdom },
ATTRIBUTE_HARD_CAP
);
const newAttributes: CharacterAttributes = {
...curAttrs,
wisdom: wisdomLvl.newProgress.level,
};
const newProgress: AttributeProgress = {
...curProg,
wisdom: wisdomLvl.newProgress,
};
set({
pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id),
crystals: s.crystals + rewards.crystals,
@@ -545,6 +634,9 @@ export const useGameStore = create<Store>()(
totalDecoded: newTotal,
fragments: tentative.fragments,
_lastAutoDecode: now,
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
});
// 深空信标:自动解码也算进度
trackBeacon("decode", 1);
@@ -562,7 +654,7 @@ export const useGameStore = create<Store>()(
set({
insights: s.insights - node.cost,
tech: newTech,
...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }),
...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }),
});
return true;
},
@@ -577,8 +669,12 @@ export const useGameStore = create<Store>()(
return { ok: false, reason: "能量不足" };
}
const tideMod = getTideModifiers(s.activeTide);
const power = computeExpeditionPower(s) + tideMod.expeditionPowerBonus;
const hp = computeExpeditionHp(s) + tideMod.expeditionHpBonus;
// v0.7 角色属性:探索力 +X% 探险力,勇气 +X 探险生命
const am = getAllBonuses(s.attributes ?? {});
const basePower = computeExpeditionPower(s) + tideMod.expeditionPowerBonus;
const baseHp = computeExpeditionHp(s) + tideMod.expeditionHpBonus;
const power = Math.round(basePower * am.expeditionPowerMult);
const hp = baseHp + am.expeditionHpBonus;
const seed = Math.floor(Math.random() * 1e9);
const expedition = generateExpedition(seed, power, hp);
set({
@@ -594,7 +690,18 @@ export const useGameStore = create<Store>()(
if (!s.activeExpedition || s.activeExpedition.finished) return null;
// 深拷贝
const exp = JSON.parse(JSON.stringify(s.activeExpedition));
const result = resolveNode(exp);
// v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值)
const nodeBefore = exp.nodes[exp.currentNode];
const isBossNode = nodeBefore?.type === "boss";
const am = getAllBonuses(s.attributes ?? {});
const result = isBossNode
? resolveNode(exp, () => {
// 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布)
const r = Math.random();
const b = Math.min(0.95, am.bossWinRateBonus);
return r < b ? 0 : Math.min(1, r - b);
})
: resolveNode(exp);
// 累计奖励
if (result.crystals) exp.rewards.crystals += result.crystals;
if (result.insights) exp.rewards.insights += result.insights;
@@ -613,7 +720,7 @@ export const useGameStore = create<Store>()(
// 日志
const logEntry = {
expeditionId: exp.id,
nodeType: exp.nodes[exp.currentNode]?.type || "combat",
nodeType: nodeBefore?.type || "combat",
result: result.log,
rewards: [
result.crystals ? `+${result.crystals}晶体` : "",
@@ -632,12 +739,48 @@ export const useGameStore = create<Store>()(
// v0.4 编年史:击破 BOSS 时累计计数
let bossKills = s.bossKills ?? 0;
let bossKilledThisNode = false;
if (
result.ended &&
result.endReason === "victory" &&
exp.nodes[exp.currentNode]?.type === "boss"
nodeBefore?.type === "boss"
) {
bossKills = bossKills + 1;
bossKilledThisNode = true;
}
// v0.7 角色属性:战斗胜利给勇气+探索力经验;BOSS 额外奖励
let newAttributes = s.attributes ?? createInitialAttributes();
let newProgress = s.attributeProgress ?? createInitialAttributeProgress();
let statsNeedResync = false;
// 战斗类节点(combat/boss)且胜利:勇气 + 探索力经验
const isCombatVictory =
(nodeBefore?.type === "combat" || nodeBefore?.type === "boss") &&
!result.ended; // 中途战斗胜利(未结束探险)
const isExpeditionVictory =
result.ended && result.endReason === "victory";
if (isCombatVictory || bossKilledThisNode || isExpeditionVictory) {
const courageGain = bossKilledThisNode ? 8 : 2;
const explorationGain = bossKilledThisNode ? 6 : isExpeditionVictory ? 4 : 1;
const courageLvl = levelUpCheck(
{ exp: newProgress.courage.exp + courageGain, level: newAttributes.courage },
ATTRIBUTE_HARD_CAP
);
const explLvl = levelUpCheck(
{ exp: newProgress.exploration.exp + explorationGain, level: newAttributes.exploration },
ATTRIBUTE_HARD_CAP
);
newAttributes = {
...newAttributes,
courage: courageLvl.newProgress.level,
exploration: explLvl.newProgress.level,
};
newProgress = {
...newProgress,
courage: courageLvl.newProgress,
exploration: explLvl.newProgress,
};
statsNeedResync = true;
}
set({
@@ -648,11 +791,16 @@ export const useGameStore = create<Store>()(
fragments: newFragments,
expeditionLog: newLog,
bossKills,
attributes: newAttributes,
attributeProgress: newProgress,
...(statsNeedResync
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes })
: {}),
});
// 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破
if (result.ended) {
trackBeacon("expedition", 1);
if (result.endReason === "victory" && exp.nodes[exp.currentNode]?.type === "boss") {
if (bossKilledThisNode) {
trackBeacon("boss", 1);
}
}
@@ -700,7 +848,7 @@ export const useGameStore = create<Store>()(
set({
...next,
energyMax,
...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation }),
...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation, attributes: next.attributes }),
_lastAutoDecode: Date.now(),
_lastSpawn: Date.now(),
_combo: 0,
@@ -732,7 +880,7 @@ export const useGameStore = create<Store>()(
pendingPerkChoices: null,
energyMax,
chronicle: newChronicle,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation, attributes: s.attributes }),
});
return true;
},
@@ -771,7 +919,7 @@ export const useGameStore = create<Store>()(
insights,
contact,
...(statsDirty
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation })
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes })
: {}),
_achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
});
@@ -806,12 +954,90 @@ export const useGameStore = create<Store>()(
const addCrystals = rewards.crystals ?? 0;
const addInsights = rewards.insights ?? 0;
const addContact = rewards.contact ?? 0;
// v0.7 角色属性:巡航通关给探索力+勇气经验(按晶体奖励量缩放)
const totalReward = addCrystals + addInsights * 10 + addContact * 10;
const expBase = Math.max(2, Math.floor(totalReward / 30));
const curAttrs = s.attributes ?? createInitialAttributes();
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
const explLvl = levelUpCheck(
{ exp: curProg.exploration.exp + expBase, level: curAttrs.exploration },
ATTRIBUTE_HARD_CAP
);
const courageLvl = levelUpCheck(
{ exp: curProg.courage.exp + Math.floor(expBase * 0.6), level: curAttrs.courage },
ATTRIBUTE_HARD_CAP
);
const newAttributes: CharacterAttributes = {
...curAttrs,
exploration: explLvl.newProgress.level,
courage: courageLvl.newProgress.level,
};
const newProgress: AttributeProgress = {
...curProg,
exploration: explLvl.newProgress,
courage: courageLvl.newProgress,
};
set({
crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
insights: s.insights + Math.round(addInsights),
contact: Math.min(100, s.contact + addContact),
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
});
},
// ============ 角色属性系统(v0.7 P1 ============
allocateAttribute: (attr, points = 1) => {
const s = get();
const cur = s.attributes ?? createInitialAttributes();
const curVal = cur[attr] ?? 0;
if (curVal >= ATTRIBUTE_HARD_CAP) {
return { ok: false, leveledUp: 0 };
}
if ((s.pendingAttrPoints ?? 0) < points) {
return { ok: false, leveledUp: 0 };
}
const alloc = Math.min(points, ATTRIBUTE_HARD_CAP - curVal, s.pendingAttrPoints);
const newVal = curVal + alloc;
const newAttributes: CharacterAttributes = { ...cur, [attr]: newVal };
// 同步经验进度 level 字段(保持一致)
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
const oldProg = curProg[attr] ?? { exp: 0, level: curVal };
const newProgress: AttributeProgress = {
...curProg,
[attr]: { exp: oldProg.exp, level: newVal },
};
set({
attributes: newAttributes,
attributeProgress: newProgress,
pendingAttrPoints: s.pendingAttrPoints - alloc,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
});
return { ok: true, leveledUp: alloc };
},
gainAttributeExp: (attr, amount) => {
const s = get();
const cur = s.attributes ?? createInitialAttributes();
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
const progEntry = curProg[attr] ?? { exp: 0, level: cur[attr] };
const result = levelUpCheck(
{ exp: progEntry.exp + amount, level: cur[attr] },
ATTRIBUTE_HARD_CAP
);
const newAttributes: CharacterAttributes = { ...cur, [attr]: result.newProgress.level };
const newProgress: AttributeProgress = {
...curProg,
[attr]: result.newProgress,
};
set({
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
});
return { leveledUp: result.levelsGained, newLevel: result.newProgress.level };
},
}),
{
name: "echo-nexus-save-v1",
+115 -7
View File
@@ -9,16 +9,15 @@
### 概况
- **项目**:回响星核 / Echo Nexus — 深空考古放置策略游戏
- **一句话**:自治无人机采矿 → 解码记忆晶体(原创共振谜题)→ 拼凑碎片叙事 → 飞升多周目。差异化:解码谜题驱动叙事涌现,全息晶体 + 深空粒子美学。
- **当前版本****v0.6**dev 服务器版本号已同步
- **当前版本****v0.7**CrystalOrb Canvas 粒子系统 + 角色属性系统
- **在线游玩**https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
- **仓库**https://git.atdunbg.xyz/Super_Z/echo-nexus
- **技术栈**Next.js 16 (App Router) + TypeScript + Tailwind + shadcn/ui + Canvas 2D + Zustand + Web Audio API
- **定时任务**:每 15 分钟一次 `webDevReview`自动 QA + 迭代开发)。注意:该任务每次跑完会被系统自动清除,需定期重建
- **定时任务**:每 15 分钟一次 `webDevReview``fixed_rate` + `"900"` 秒,priority=10job_id 228266)。正常完成不会被删除,无需自持续机制
### 状态判断
- dev 服务器运行正常(HTTP 200,编译 < 250ms
- v0.6 核心功能(深空巡航 Canvas 2D 实时玩法)已上线,UI 偏移/重叠 BUG 已修复
- 上一轮因对话上下文截断,CrystalOrb Canvas 粒子系统升级**未完成**,是下一阶段第一优先级
- v0.7 两大新功能已完成:CrystalOrb Canvas 粒子系统升级(VLM 8/10+ 角色属性系统(VLM 7/10
- 系统总体稳定,无阻塞性 bug,可继续推进新功能
### 已完成版本里程碑(精简)
@@ -34,7 +33,8 @@
| v0.5 | 深空信标(每日挑战 + 本地排行榜 Top20 + 7 标签页 + 编年史历史 BUG 修复)|
| v0.5.1 | 静态部署 Gitea Pages + 新手教程系统(7 步聚光灯引导)+ UI 间距优化 |
| v0.5.2 | 离线收益报告弹窗 + 标签页 UI 重设计 + persist rehydrate 竞态修复 |
| v0.6 | **深空巡航 Canvas 2D 实时玩法** + UI 偏移重叠修复(见下方详细记录)|
| v0.6 | 深空巡航 Canvas 2D 实时玩法 + UI 偏移重叠修复 |
| **v0.7** | **CrystalOrb Canvas 粒子系统升级 + 角色属性系统(四维)** |
### 核心系统清单(8 大系统)
1. **放置采矿** — 中央晶体球 + 主动脉冲 + 连击加成(`CrystalOrb.tsx`
@@ -45,13 +45,63 @@
6. **星图天文台** — 飞升后 3 选 1 天赋 draft,跨周目永久生效,18 个天赋(`ConstellationPanel.tsx` + `constellation.ts`
7. **回响编年史** — 跨周目叙事时间轴,5 纪元循环命名(`ChronicleDialog.tsx` + `chronicle.ts`
8. **深空信标** — 每日挑战 + 本地排行榜 Top20(`BeaconPanel.tsx` + `beacon.ts`
9. **深空巡航** — Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关(`CruiseMode.tsx` + `cruise.ts`)【v0.6 新增
9. **深空巡航** — Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关(`CruiseMode.tsx` + `cruise.ts`)【v0.6】
10. **角色属性系统** — 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点,影响所有系统(`AttributesPanel.tsx` + `attributes.ts`)【v0.7 新增】
---
## 二、当前目标 / 已完成的修改 / 验证结果
### v0.6 深空巡航 Canvas 2D 实时玩法(已完成)
### v0.7 CrystalOrb Canvas 粒子系统升级(已完成)
**动机**:P0 优先级 — 将中央晶体球从 CSS 动画升级为 Canvas 粒子视觉盛宴,提升视觉震撼。
**重写文件**`src/components/game/CrystalOrb.tsx`
- 从纯 CSS 动画升级为 Canvas 2D 粒子系统
- **多层粒子**
- 环绕能量粒子(3层14/10/8个,emerald/fuchsia/rose三色,不同速度方向环绕晶核,带拖尾)
- 环境星尘(40个,缓慢漂移 + 闪烁)
- 点击爆发粒子(10-30个径向发散,颜色随连击等级变色)
- 冲击波环(点击触发1-3层错峰扩散,连击≥3/5解锁更多层)
- **晶核升级**:径向渐变辉光 + 3层六边形纹理旋转 + 高光 + 鼠标视差偏移
- **呼吸动画**:晶核 + 外层辉光随时间呼吸(sin波)
- **进度环**SVG渐变环(emerald→fuchsia→rose)保留
- **交互保留**:点击脉冲、连击Toast、浮动数字、音效、`data-tut="crystal-orb"` 锚点
- **性能**DPR cap 2ResizeObserver 自适应,requestAnimationFrame 60fps
**QA 验证**agent-browser + VLM):
- VLM 视觉评分 **8/10**:晶体球清晰可见,多层辉光,环绕粒子动态自然,无渲染问题
- 点击交互:冲击波环 ✅ + 爆发粒子 ✅,评分 7/10
- lint 零错误;HTTP 200
### v0.7 角色属性系统(已完成)
**动机**:P1 优先级 — 增加RPG深度,四维属性影响所有游戏系统。
**新增文件**
- `src/lib/game/attributes.ts`~330行逻辑层):CharacterAttributes/AttributeProgress 类型、ATTRIBUTE_CONFIG 元信息、getAttributeBonus 两区加成公式(0-50线性+0.5%/点,50-100递减+0.2%/点)、getAllBonuses 12项修饰器、levelUpCheck 自动升级、computePrestigeAttrPoints、migrateAttributes 旧存档兼容
- `src/components/game/AttributesPanel.tsx`(~330行 UI):四维卡片网格(小屏2×2/大屏1×4)、图标+中英文名+Lv.badge+数值/100+加成%+经验条+影响列表+分配按钮、顶部待分配点数badge闪烁、底部总览+12项修饰器明细
**修改文件**
- `types.ts`GameState 新增 attributes/attributeProgress/pendingAttrPoints
- `config.ts`INITIAL_STATE 补全默认值
- `engine.ts`recomputeStats 聚合属性加成;performPrestige 发放属性点(ascensions×2+1
- `gameStore.ts`:新增 allocateAttribute/gainAttributeExp actionspulse/clickNode/autoDecodeTick/resolveCurrentNode/grantCruiseReward/tickTide 全部接入属性经验;init() 调用 migrateAttributes
- `achievements.ts`:新增 ach_attr_total_50 + ach_attr_max_100 两项成就
- `page.tsx`grid-cols-7→8,新增「角色」标签页(User图标),红点提示,StatsPanel新增5行属性行,版本号 v0.6→v0.7
**四维属性设计**
- 探索力(emerald):探险力+X%/巡航飞船速度+X%
- 智慧(fuchsia):解码步数+X/洞见+X%/自动解码周期-X%
- 勇气(amber):探险生命+X/BOSS胜率+X%/巡航护盾+X
- 灵感(rose):接触率+X/星潮概率+X/脉冲连击+X%
**QA 验证**agent-browser + VLM):
- VLM 视觉评分 **7/10**(面板)/ **8/10**(待分配状态):四维卡片可见、数值/等级/加成/经验条齐全、四色全息
- 功能验证:setState pendingAttrPoints=5 → 显示5点待分配 → 点击分配按钮 → exploration 0→1, pending 5→4 ✅
- lint 零错误;HTTP 200
---
### v0.6 深空巡航 Canvas 2D 实时玩法(历史记录)
**动机**:回应用户反馈「玩法太单调(全是点点点)」+「视觉震撼不足」——首次引入实时操作玩法。
**新增文件**
@@ -161,3 +211,61 @@
- `08-星图天文台系统-v0.3.1.md`
- `09-回响编年史系统-v0.4.md`
- `10-深空信标系统-v0.5.md`
---
Task ID: 7
Agent: full-stack-developer
Task: 角色属性系统(探索力/智慧/勇气/灵感)
Work Log:
- 阅读 worklog.md 了解 v0.6 项目背景,确认 8 大系统架构与四色全息色彩规范
- 新建 `src/lib/game/attributes.ts` 逻辑层(~330 行):
- 定义 CharacterAttributes/AttributeProgress/AttributeKey 类型 + ATTRIBUTE_CONFIG 元信息
- getAttributeBonus 线性+递减两区加成公式(0-50 每 10 点 +5%50-100 每点 +2%
- getAllBonuses 返回 12 个修饰器(探险力/巡航速度/解码步数/洞见/自动解码周期/探险生命/BOSS 胜率/巡航护盾/接触率/星潮触发/脉冲连击/产能)
- levelUpCheck 自动跨多级 + expRequiredForLevel + computePrestigeAttrPoints + migrateAttributes 兼容
- 扩展 `src/lib/game/types.ts` GameState:新增 attributes/attributeProgress/pendingAttrPoints
- 扩展 `src/lib/game/config.ts` INITIAL_STATE:补全默认值(0 数值 + 空 progress
- 扩展 `src/lib/game/engine.ts`
- recomputeStats 末尾聚合 am.crystalsPerSecMult/insightMultAdd/contactRateMult/decodeStepsBonus
- performPrestige 保留 attributes 数值,清空 attributeProgress,发放 ascensions × 2 + 1 点属性点
- createInitialState 每次返回全新对象避免引用共享
- 扩展 `src/store/gameStore.ts`(核心改造,10+ 处 syncStats 调用全部补 attributes 参数):
- pulse:连击 ≥3 给灵感经验 + 脉冲威力乘灵感连击加成
- clickNode 完成:给智慧经验(tier × 2)
- autoDecodeTick:智慧经验 +1 + 自动解码周期受 am.autoDecodeIntervalMult 影响
- startExpedition:探险力乘 am.expeditionPowerMult,生命加 am.expeditionHpBonus
- resolveCurrentNodeBOSS 节点包装 RNG 提升 +am.bossWinRateBonus 胜率;战斗胜利给勇气+探索力经验
- grantCruiseReward:按总奖励量给探索力+勇气经验
- tickTide:灵感 am.tideTriggerBonus 缩短星潮间隙(上限 30%)
- init() 调用 migrateAttributes 补全旧存档
- 新增 allocateAttribute / gainAttributeExp 两个 action
- 新建 `src/components/game/AttributesPanel.tsx` UI 面板(~330 行):
- 四维卡片网格(小屏 2×2,大屏 1×4)
- 每张卡片:图标 + 中英文名 + Lv.badge + 数值/100 + 加成% + 经验条 + 影响列表 + 「+分配」按钮
- 顶部待分配点数 badgependingAttrPoints > 0 时 echo-pending-pulse 闪烁动画)
- 底部总等级/总加成概览 + 12 个修饰器明细
- 4 色辉光边框(emerald/fuchsia/amber/rose),顶角光晕装饰
- 接入 `src/app/page.tsx`
- grid-cols-7 → grid-cols-8,新增第 8 个「角色」TabsTriggerUser 图标,渐变主题)
- pendingAttrPoints > 0 时显示 rose 红点提示
- 渲染 AttributesPanel
- StatsPanel 新增 5 行属性行(探索力/智慧/勇气/灵感/待分配点)
- 版本号 v0.6 → v0.7
- 新增 `src/lib/game/achievements.ts` 2 项成就:
- ach_attr_total_50(四维觉醒):总属性 ≥ 50 → 产能+6%/洞见+6%
- ach_attr_max_100(维度精通):任一属性 100 → 产能+12%/洞见+10%
- 写入 `agent-ctx/7-full-stack-developer.md` 工作记录
- 最终验证:lint 零错误 + dev 服务器 HTTP 200 + 编译 < 250ms
Stage Summary:
- ✅ 角色属性系统 v0.7 P1 全部完成
- ✅ 新增 2 文件(attributes.ts + AttributesPanel.tsx),修改 6 文件(types/config/engine/gameStore/achievements/page
- ✅ 四维属性完整接入游戏各系统(探险/解码/脉冲/巡航/星潮)
- ✅ 飞升属性点公式 = ascensions × 2 + 1;经验累积自动升级(每级 10×level)
- ✅ 旧存档兼容(migrateAttributes 补全字段,同步 level 与 attributes
- ✅ lint 零错误 + HTTP 200 + 编译 < 250ms
- ✅ 严格四色全息(emerald/fuchsia/amber/rose),零蓝色/靛色
- ✅ 8 标签页 + 巡航按钮全部保留,data-tut 锚点未破坏
- ✅ 待分配点数时标签页 rose 红点提示 + 顶部 badge 闪烁