Compare commits

...
6 Commits
Author SHA1 Message Date
Super_Z 4545fe380b 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
2026-06-23 21:00:42 +00:00
Super_Z 8474f4caa7 0c799010-d26c-48d2-8c38-f465011f59d8 2026-06-23 20:32:33 +00:00
Super_Z c0535e6ebe d3c86ebe-91f4-4f29-a530-6cb46cab2abc 2026-06-23 20:19:46 +00:00
Super_Z 7111e154b2 906d0c00-e2bc-4edd-ae26-665c03a1ba37 2026-06-23 20:10:42 +00:00
Super_Z 72e1f3fc3c da4fe2ad-c3b9-41fc-82e7-336a69a7947c 2026-06-23 19:51:59 +00:00
Super_Z fafa2ee20a v0.6: 深空巡航 Canvas 2D 实时玩法 + UI 偏移重叠修复
## 玩法拓展(回应用户: 玩法太单调/全是点点点)
- 新增「深空巡航」Canvas 2D 全屏实时玩法
  - 飞船操控(WASD/方向键/移动端虚拟摇杆)
  - 实时躲避陨石/虚空风暴,收集晶体/洞见/信标
  - 到达星门通关,护盾归零失败
  - 3层视差星空 + 粒子系统(尾焰/收集/碰撞/烟花) + 辉光 + 屏幕震动
  - 奖励同步主游戏(grantCruiseReward)
  - 独立 localStorage 记录最高分/通关数

## UI 修复(回应用户: 解析球左上偏移/成就小屏重叠)
- DecodeArray 节点: 移除 Tailwind translate 与 inline transform 冲突
- AchievementsPanel: 移除固定 max-h, 改 flex-1 min-h-0 自适应
- page.tsx 布局: 小屏减小 min-h, 避免挤压重叠

## 集成
- gameStore 新增 grantCruiseReward action
- header 新增 amber 主题「巡航」按钮 + 全屏 CruiseMode 渲染

## 文件
- src/lib/game/cruise.ts (新增, ~1008行)
- src/components/game/CruiseMode.tsx (新增, ~1350行)
- src/app/page.tsx (集成巡航入口)
- src/store/gameStore.ts (grantCruiseReward action)
- src/components/game/DecodeArray.tsx (节点偏移修复)
- src/components/game/AchievementsPanel.tsx (小屏重叠修复)

QA: lint 零错误; agent-browser+VLM 验证主界面无偏移+巡航玩法正常运行
2026-06-23 19:33:51 +00:00
17 changed files with 5248 additions and 417 deletions
+52
View File
@@ -0,0 +1,52 @@
# Task 6: 开发 Canvas 2D 深空巡航实时玩法
## Agent: full-stack-developer
## 完成状态: ✅ 全部完成
## 创建的文件
1. `/home/z/my-project/src/lib/game/cruise.ts` — 逻辑层(~520 行纯 TS
2. `/home/z/my-project/src/components/game/CruiseMode.tsx` — Canvas 2D 渲染层(~830 行)
## 修改的文件
1. `/home/z/my-project/src/app/page.tsx` — 新增巡航入口按钮 + CruiseMode 渲染
## 关键实现细节
### cruise.ts 逻辑层
- **类型系统**CruiseEntity 联合类型(8 种实体:ship/asteroid/storm/crystal/insight/beacon/stargate/particle),完整 CruiseState/Phase/RunResult/Stats/Input
- **关卡生成**mulberry32 + FNV-1a 种子化 RNG;陨石(8+level×2)、风暴(1+level/2)、晶体(5+level)、洞见(2+level/3)、信标(1+level/4)、星门;时长 60-90s
- **物理**:飞船 8 方向加速度+摩擦+限速+边界反弹;陨石碰撞扣盾+击退+600ms 无敌;风暴持续 DPS;收集物吸引半径 90px 自动吸入
- **奖励**crystals=碎片×(8+lvl×2)、insights=洞见×(2+lvl×0.5)、contact=信标×(1.5+lvl×0.3);通关×1.5、失败×0.5
- **localStorage**:独立 key `echo-nexus-cruise-v1`,记录最高分/通关数/最高关卡/累计奖励/最近 20 局
### CruiseMode.tsx 渲染层
- **全屏 Canvas**fixed inset-0 z-50DPR cap 2resize 监听
- **3 层视差星空**:远/中/近 100/60/30 星,独立漂移+飞船速度视差+闪烁
- **实体辉光绘制**:全部使用 ctx.shadowBlur;飞船三角形+尾焰渐变;陨石不规则多边形;风暴 5 层云团+闪电;晶体菱形+脉冲环;洞见球体+漩涡;信标光束+旋转环;星门 4 层旋涡+吸入粒子
- **粒子系统**:尾焰/收集/碰撞/烟花,上限 200
- **屏幕震动**:受击 280ms 衰减抖动
- **低护盾警告**shield<30% 屏幕边缘 rose 脉冲边框
- **HUD**HTML 叠层(非 canvas),glass+backdrop-blur;护盾/能量/分数/用时/收集计数;80ms 节流更新
- **控制**:桌面 WASD/方向键;移动端 floating 虚拟摇杆;P 暂停;Esc 退出;失焦自动暂停
- **奖励同步**:结算时 grantCruiseReward + recordRunrewardGrantedRef 防重
### page.tsx 集成
- header 按钮区新增「巡航」按钮(Navigation 图标,amber 主题)
- 新增 cruiseOpen state
- 组件末尾渲染 {cruiseOpen && <CruiseMode onClose={...} />}
- 未破坏现有 7 标签页和其他功能
## QA 验证结果
- ✅ lint 零错误
- ✅ 巡航按钮出现在顶部 header
- ✅ 点击进入全屏 Canvas,ready 界面完整
- ✅ WASD 控制飞船移动,按 W 直冲星门通关
- ✅ 收集 2 晶体 → 奖励 +30(2×10×1.5=30 计算正确)
- ✅ localStorage 正确记录 highScore/totalWins/bestLevel
- ✅ 奖励同步到 gameStore
- ✅ 退出返回主界面,7 标签页完好
- ✅ 移动端 viewport 测试通过
## 色彩遵循
emerald(#34d399) / rose(#fb7185) / amber(#fbbf24) / fuchsia(#e879f9) 四色全息色谱,零蓝色/靛色
+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] 数值,避免漂移
+40 -7
View File
@@ -18,6 +18,8 @@ import { ChronicleDialog } from "@/components/game/ChronicleDialog";
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,
@@ -40,6 +42,8 @@ import {
Trophy,
Star,
Radio,
Navigation,
User,
} from "lucide-react";
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
@@ -54,6 +58,7 @@ export default function Page() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [constellationOpen, setConstellationOpen] = useState(false);
const [chronicleOpen, setChronicleOpen] = useState(false);
const [cruiseOpen, setCruiseOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const contact = useGameStore((s) => s.contact);
@@ -71,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);
@@ -166,12 +172,23 @@ 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.5.2
ECHO NEXUS · v0.7
</p>
</div>
</div>
<div className="ml-auto flex items-center gap-1.5">
<StarTideIndicator />
<Button
size="sm"
variant="outline"
onClick={() => setCruiseOpen(true)}
className="border-amber-400/50 text-amber-200 hover:bg-amber-500/10 h-8 px-2.5"
aria-label="深空巡航"
title="深空巡航 · 实时玩法"
>
<Navigation className="h-3.5 w-3.5 mr-1" />
</Button>
<Button
size="icon"
variant="ghost"
@@ -224,11 +241,11 @@ export default function Page() {
<ResourceBar onPrestige={() => setPrestigeOpen(true)} />
</header>
{/* 主体 */}
{/* 主体 — 小屏允许自然滚动,避免 min-h 总和超过视口导致挤压重叠 */}
<main className="flex-1 px-3 sm:px-5 pb-3 min-h-0">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_minmax(360px,420px)] gap-3 h-full">
{/* 左侧:脉冲晶体 */}
<section className="glass rounded-2xl p-4 sm:p-6 flex flex-col items-center justify-center min-h-[420px] relative overflow-hidden">
<section className="glass rounded-2xl p-4 sm:p-6 flex flex-col items-center justify-center min-h-[340px] sm:min-h-[420px] relative overflow-hidden">
{/* 装饰光圈 */}
<div className="pointer-events-none absolute -top-20 -left-20 h-60 w-60 rounded-full bg-emerald-500/10 blur-3xl" />
<div className="pointer-events-none absolute -bottom-20 -right-20 h-60 w-60 rounded-full bg-fuchsia-500/10 blur-3xl" />
@@ -240,13 +257,13 @@ export default function Page() {
{/* 右侧:解码 + 标签面板 */}
<section className="flex flex-col gap-3 min-h-0">
{/* 解码面板 */}
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[360px] max-h-[560px]">
<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-[320px] max-h-[440px]">
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
<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>
@@ -290,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 />
@@ -312,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>
@@ -373,6 +400,7 @@ export default function Page() {
<StarTideNotifier />
<TutorialOverlay />
<OfflineReportDialog />
{cruiseOpen && <CruiseMode onClose={() => setCruiseOpen(false)} />}
</div>
);
}
@@ -409,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">
+2 -2
View File
@@ -37,8 +37,8 @@ export function AchievementsPanel() {
)}
</div>
{/* 成就网格 */}
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-1.5 content-start overflow-y-auto echo-scroll pr-1 max-h-[300px]">
{/* 成就网格 — 移除固定 max-h,用 flex-1 min-h-0 自适应父容器,避免小屏挤压重叠 */}
<div className="flex-1 min-h-0 grid grid-cols-1 sm:grid-cols-2 gap-1.5 content-start overflow-y-auto echo-scroll pr-1">
{ACHIEVEMENTS.map((a) => {
const unlocked = !!achievements[a.id];
return (
+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>
);
}
File diff suppressed because it is too large Load Diff
+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;
}
}
+3 -2
View File
@@ -192,11 +192,11 @@ export function DecodeArray() {
onMouseEnter={() => setHoverId(node.id)}
onMouseLeave={() => setHoverId(null)}
disabled={used}
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full transition-all duration-200 focus:outline-none disabled:cursor-not-allowed"
className="absolute rounded-full transition-[box-shadow,background,border,opacity] duration-200 focus:outline-none disabled:cursor-not-allowed"
style={{
left: `${cx}%`,
top: `${cy}%`,
width: `min(${100 / cols * 0.62}%, 56px)`,
width: `min(${(100 / cols) * 0.62}%, 56px)`,
aspectRatio: "1",
background: used
? "rgba(255,255,255,0.04)"
@@ -214,6 +214,7 @@ export function DecodeArray() {
? `1.5px solid ${v.hex}aa`
: "1px solid rgba(255,255,255,0.12)",
opacity: used ? 0.35 : isDeadStart ? 0.5 : 1,
// 统一用 inline transform 定位居中 + 缩放,避免与 Tailwind translate 冲突导致左上偏移
transform: `translate(-50%, -50%) scale(${
isHover && !used ? 1.12 : isFlash ? (flash?.ok ? 1.25 : 0.8) : 1
})`,
+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,
};
File diff suppressed because it is too large Load Diff
+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;
+260 -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 {
// 生命周期
@@ -118,6 +129,13 @@ interface GameActions {
// 深空信标奖励发放(v0.5
grantBeaconReward: (insights: number, contact: number) => void;
// 深空巡航奖励发放(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;
}
@@ -217,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;
@@ -258,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: () => {
@@ -279,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;
@@ -296,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;
@@ -324,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;
@@ -409,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,
@@ -417,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 };
},
@@ -456,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,
@@ -463,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);
@@ -517,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);
@@ -534,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,
@@ -542,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);
@@ -559,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;
},
@@ -574,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({
@@ -591,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;
@@ -610,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}晶体` : "",
@@ -629,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({
@@ -645,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);
}
}
@@ -697,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,
@@ -729,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;
},
@@ -768,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],
});
@@ -796,6 +947,97 @@ export const useGameStore = create<Store>()(
contact: Math.min(100, s.contact + contact),
});
},
// 深空巡航:发放实时玩法奖励(v0.6)
grantCruiseReward: (rewards) => {
const s = get();
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",
@@ -0,0 +1,843 @@
1→"use client";
2→// 回响星核 / Echo Nexus — Zustand 游戏状态管理
3→import { create } from "zustand";
4→import { persist, createJSONStorage } from "zustand/middleware";
5→import type {
6→ GameState,
7→ Crystal,
8→ CrystalTier,
9→ DecodePuzzle,
10→ ExpeditionResult,
11→} from "@/lib/game/types";
12→import {
13→ INITIAL_STATE,
14→ TECH_TREE,
15→ CRYSTAL_VALUE,
16→ CONTACT,
17→ CRYSTAL_SPAWN,
18→ FRAGMENTS,
19→ PRESTIGE,
20→} from "@/lib/game/config";
21→import {
22→ createInitialState,
23→ recomputeStats,
24→ decodeRewards,
25→ rollCrystalTierWithBonus,
26→ computeNewBlueprints,
27→ performPrestige,
28→} from "@/lib/game/engine";
29→import {
30→ generatePuzzle,
31→ tryClickNode,
32→ isSolvable,
33→ resetPuzzle as resetPuz,
34→} from "@/lib/game/decode";
35→import {
36→ generateExpedition,
37→ resolveNode,
38→ advanceExpedition,
39→ computeExpeditionPower,
40→ computeExpeditionHp,
41→ computeEnergyRegen,
42→ EXPEDITION_CONFIG,
43→} from "@/lib/game/expedition";
44→import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements";
45→import {
46→ TIDE_CONFIG,
47→ rollTide,
48→ getTideModifiers,
49→ computeSilenceCompensation,
50→ type StarTide,
51→ type TideType,
52→} from "@/lib/game/starTide";
53→import {
54→ getPerk,
55→ constellationBonuses,
56→ rollPerkChoices,
57→} from "@/lib/game/constellation";
58→import {
59→ migrateChronicleFields,
60→ withPerks,
61→} from "@/lib/game/chronicle";
62→import {
63→ generateDailyChallenge,
64→ loadDailyProgress,
65→ addBeaconProgress,
66→ type BeaconDailyChallenge,
67→ type BeaconDailyProgress,
68→} from "@/lib/game/beacon";
69→import { setPendingOfflineReport } from "@/lib/game/offlineReport";
70→
71→interface GameActions {
72→ // 生命周期
73→ init: () => void;
74→ loadOnline: () => void;
75→ hardReset: () => void;
76→
77→ // 主循环
78→ tick: (now: number) => void;
79→ pulse: () => { gain: number; combo: number } | null;
80→
81→ // 星潮
82→ tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null;
83→ consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
84→
85→ // 解码
86→ startDecode: (crystalId: string) => void;
87→ clickNode: (nodeId: number) => { ok: boolean; finished: boolean; solvable?: boolean; failReason?: string };
88→ undoStep: () => void;
89→ retryPuzzle: () => void;
90→ abandonPuzzle: () => void;
91→ /** 自动解码 T1(技术解锁后由 tick 调用) */
92→ autoDecodeTick: () => void;
93→
94→ // 探险
95→ startExpedition: () => { ok: boolean; reason?: string };
96→ resolveCurrentNode: () => ExpeditionResult | null;
97→ advanceNode: () => void;
98→ abortExpedition: () => void;
99→
100→ // 技术
101→ buyTech: (techId: string) => boolean;
102→
103→ // 飞升
104→ doPrestige: () => { newBp: number } | null;
105→
106→ // 星图天文台
107→ chooseConstellationPerk: (perkId: string) => boolean;
108→ rerollPerkChoices: () => void;
109→
110→ // 成就
111→ checkAchievements: () => Achievement[];
112→ consumeAchievementQueue: () => Achievement[];
113→
114→ // 设置
115→ toggleTheme: () => void;
116→ toggleSound: () => void;
117→
118→ // 深空信标奖励发放(v0.5)
119→ grantBeaconReward: (insights: number, contact: number) => void;
120→
121→ // 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法)
122→ grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void;
123→
124→ // 派生
125→ canPrestige: () => boolean;
126→}
127→
128→type Store = GameState & GameActions & {
129→ _lastAutoDecode: number;
130→ _lastSpawn: number;
131→ _combo: number;
132→ _lastPulse: number;
133→ _achievementQueue: Achievement[];
134→ _tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
135→};
136→
137→/** 计算并写回产能字段 */
138→function syncStats(state: Partial<GameState>) {
139→ const s = recomputeStats(state);
140→ return {
141→ crystalsPerSec: s.crystalsPerSec,
142→ crystalCap: s.crystalCap,
143→ pulsePower: s.pulsePower,
144→ offlineEff: s.offlineEff,
145→ insightMult: s.insightMult,
146→ contactRateMult: s.contactRateMult,
147→ autoDecode: s.autoDecode,
148→ decodeStepsBonus: s.decodeStepsBonus,
149→ };
150→}
151→
152→/**
153→ * 深空信标进度追踪(v0.5)。
154→ * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,按今日挑战类型增量更新进度。
155→ * 进度独立存储于 localStorageecho-nexus-beacon-prog-v1),不污染 GameState。
156→ * @returns 若刚完成则返回 true(供 UI 触发通知)
157→ */
158→function trackBeacon(
159→ type: "pulse" | "decode" | "expedition" | "boss" | "insight",
160→ delta: number
161→): boolean {
162→ if (typeof window === "undefined") return false;
163→ try {
164→ const challenge: BeaconDailyChallenge = generateDailyChallenge();
165→ if (challenge.type !== type) return false;
166→ const current: BeaconDailyProgress = loadDailyProgress();
167→ if (current.completedAt !== null) return false; // 已完成不再累加
168→ const { justCompleted } = addBeaconProgress(current, challenge, delta);
169→ return justCompleted;
170→ } catch {
171→ return false;
172→ }
173→}
174→
175→/** 检查并解锁叙事碎片 */
176→function checkFragments(state: GameState): string[] {
177→ const unlocked: string[] = [];
178→ for (const f of FRAGMENTS) {
179→ if (!state.fragments[f.id] && state.totalDecoded >= f.threshold) {
180→ state.fragments[f.id] = true;
181→ unlocked.push(f.id);
182→ }
183→ }
184→ return unlocked;
185→}
186→
187→export const useGameStore = create<Store>()(
188→ persist(
189→ (set, get) => ({
190→ ...createInitialState(),
191→ _lastAutoDecode: Date.now(),
192→ _lastSpawn: Date.now(),
193→ _combo: 0,
194→ _lastPulse: 0,
195→ _achievementQueue: [],
196→ _tideEvents: [],
197→
198→ init: () => {
199→ const s = get();
200→ const now = Date.now();
201→ // 兼容旧存档:若存在不可解的活跃谜题(旧算法生成),直接清掉
202→ let activePuzzle = s.activePuzzle;
203→ if (activePuzzle && !isSolvable(activePuzzle)) {
204→ // 把晶体放回队列,避免玩家卡死
205→ const crystal: Crystal = {
206→ id: `c_${now}_rec`,
207→ tier: activePuzzle.tier,
208→ value: CRYSTAL_VALUE[activePuzzle.tier].crystals,
209→ createdAt: now,
210→ };
211→ activePuzzle = null;
212→ set({
213→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
214→ });
215→ }
216→ // 兼容旧存档:补全 achievements / 星潮 / 星图 字段
217→ const achievements = s.achievements ?? {};
218→ const activeTide = s.activeTide ?? null;
219→ const constellation = s.constellation ?? [];
220→ const pendingPerkChoices = s.pendingPerkChoices ?? null;
221→ // v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered
222→ const migrated = migrateChronicleFields(s);
223→ // 星图「能量共振」天赋 +1 能量上限
224→ const cm = constellationBonuses(constellation);
225→ const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus;
226→ // lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效
227→ const lastTideEndRaw = s.lastTideEnd ?? 0;
228→ // 若旧存档有已过期的星潮,清掉
229→ const tide = activeTide && activeTide.endsAt > now ? activeTide : null;
230→ // 首次进入:补发离线收益
231→ const elapsed = Math.max(0, (now - s.lastTick) / 1000);
232→ if (elapsed > 5) {
233→ const cap = 8 * 3600;
234→ const secs = Math.min(elapsed, cap);
235→ const gain = s.crystalsPerSec * secs * s.offlineEff;
236→ const crystalsBefore = s.crystals;
237→ const crystalsAfter = Math.min(s.crystalCap, s.crystals + gain);
238→ // 设置离线报告(v0.5.2):通知 UI 弹出"欢迎回来"对话框
239→ setPendingOfflineReport({
240→ elapsedSec: secs,
241→ rawElapsedSec: elapsed,
242→ gain: crystalsAfter - crystalsBefore,
243→ rate: s.crystalsPerSec,
244→ eff: s.offlineEff,
245→ capped: elapsed > cap,
246→ crystalsBefore,
247→ crystalsAfter,
248→ crystalCap: s.crystalCap,
249→ });
250→ set({
251→ crystals: crystalsAfter,
252→ lastTick: now,
253→ activePuzzle,
254→ achievements,
255→ activeTide: tide,
256→ lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw,
257→ constellation,
258→ pendingPerkChoices,
259→ energyMax,
260→ chronicle: migrated.chronicle,
261→ runStart: migrated.runStart,
262→ bossKills: migrated.bossKills,
263→ starTidesEncountered: migrated.starTidesEncountered,
264→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }),
265→ });
266→ } else {
267→ 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 }) });
268→ }
269→ },
270→
271→ loadOnline: () => {
272→ const s = get();
273→ set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }) });
274→ },
275→
276→ hardReset: () => {
277→ set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] });
278→ },
279→
280→ tickTide: (now) => {
281→ const s = get();
282→ const tide = s.activeTide;
283→ // 星图「星潮引导」减少间隙
284→ const cm = constellationBonuses(s.constellation ?? []);
285→ const gap = Math.max(15000, TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000);
286→ // 1) 检查当前星潮是否结束
287→ if (tide && now >= tide.endsAt) {
288→ const endedType = tide.type;
289→ // 寂静期补偿洞见
290→ let silenceCompensation = 0;
291→ if (tide.type === "silence") {
292→ silenceCompensation = computeSilenceCompensation(tide);
293→ }
294→ const newInsights = s.insights + silenceCompensation;
295→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType };
296→ if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
297→ set({
298→ activeTide: null,
299→ lastTideEnd: now,
300→ insights: newInsights,
301→ // 星潮结束后重算 stats(移除 contactRate/insight 修饰)
302→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation }),
303→ _tideEvents: [...s._tideEvents, event],
304→ });
305→ return event;
306→ }
307→ // 2) 检查是否该触发新星潮(间隙已过)
308→ if (!tide) {
309→ const since = now - s.lastTideEnd;
310→ // 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
311→ const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000;
312→ const need = firstStart ? TIDE_CONFIG.firstDelay : gap;
313→ if (since >= need) {
314→ const type = rollTide();
315→ const newTide: StarTide = {
316→ id: `tide_${now}_${Math.random().toString(36).slice(2, 7)}`,
317→ type,
318→ startedAt: now,
319→ endsAt: now + TIDE_CONFIG.duration,
320→ };
321→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type };
322→ // v0.4 编年史:累计遇到的星潮 ID(去重)
323→ const tidesAll = s.starTidesEncountered ?? [];
324→ const tideId = `tide_${type}`;
325→ const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
326→ set({
327→ activeTide: newTide,
328→ starTidesEncountered: newTidesAll,
329→ // 星潮开始后重算 stats(应用 contactRate/insight 修饰)
330→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation }),
331→ _tideEvents: [...s._tideEvents, event],
332→ });
333→ return event;
334→ }
335→ }
336→ return null;
337→ },
338→
339→ consumeTideEvents: () => {
340→ const s = get();
341→ if (s._tideEvents.length === 0) return [];
342→ const items = s._tideEvents;
343→ set({ _tideEvents: [] });
344→ return items;
345→ },
346→
347→ tick: (now) => {
348→ const s = get();
349→ const dt = Math.max(0, (now - s.lastTick) / 1000);
350→ if (dt <= 0) return;
351→
352→ // 星潮产能修饰(即时乘)
353→ const tideMod = getTideModifiers(s.activeTide);
354→ const effCps = s.crystalsPerSec * tideMod.crystalsPerSecMult;
355→ // 产能累加(仅闲置产能受仓库上限;探险奖励可超出)
356→ const newCrystals =
357→ s.crystals >= s.crystalCap
358→ ? s.crystals // 已达/超上限,不再自动产出
359→ : Math.min(s.crystalCap, s.crystals + effCps * dt);
360→
361→ // 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速)
362→ const bpBoost = 1 + s.blueprints.length * 0.03;
363→ const spawnInterval = (CRYSTAL_SPAWN.baseIntervalSec / bpBoost) * 1000;
364→ let pending = s.pendingCrystals;
365→ let lastSpawn = s._lastSpawn;
366→ if (
367→ now - lastSpawn > spawnInterval &&
368→ pending.length < CRYSTAL_SPAWN.maxPending
369→ ) {
370→ // 星图「晶体富集」提升 T2/T3 概率
371→ const cm = constellationBonuses(s.constellation ?? []);
372→ const tier = rollCrystalTierWithBonus(cm.t2t3BonusRate);
373→ const crystal: Crystal = {
374→ id: `c_${now}_${Math.random().toString(36).slice(2, 7)}`,
375→ tier,
376→ value: CRYSTAL_VALUE[tier].crystals,
377→ createdAt: now,
378→ };
379→ pending = [...pending, crystal];
380→ lastSpawn = now;
381→ }
382→
383→ // 能量恢复(探险系统)
384→ let energy = s.energy;
385→ let lastEnergyTick = s.lastEnergyTick;
386→ if (energy < s.energyMax) {
387→ const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax);
388→ energy = regen.energy;
389→ lastEnergyTick = regen.lastTick;
390→ } else {
391→ lastEnergyTick = now;
392→ }
393→
394→ set({
395→ crystals: newCrystals,
396→ lastTick: now,
397→ pendingCrystals: pending,
398→ _lastSpawn: lastSpawn,
399→ energy,
400→ lastEnergyTick,
401→ });
402→ },
403→
404→ pulse: () => {
405→ const s = get();
406→ const now = Date.now();
407→ // 连击
408→ let combo = 1;
409→ if (now - s._lastPulse < 1500) {
410→ combo = Math.min(10, s._combo + 1);
411→ }
412→ const mult = 1 + (combo - 1) * 0.15;
413→ // 星潮脉冲威力修饰
414→ const tideMod = getTideModifiers(s.activeTide);
415→ const gain = s.pulsePower * mult * tideMod.pulsePowerMult;
416→ set({
417→ crystals: Math.min(s.crystalCap, s.crystals + gain),
418→ _combo: combo,
419→ _lastPulse: now,
420→ });
421→ // 深空信标:脉冲任务进度 +1
422→ trackBeacon("pulse", 1);
423→ return { gain, combo };
424→ },
425→
426→ startDecode: (crystalId) => {
427→ const s = get();
428→ const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
429→ if (!crystal) return;
430→ const puzzle = generatePuzzle(crystal.tier);
431→ set({
432→ activePuzzle: puzzle,
433→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId),
434→ });
435→ },
436→
437→ clickNode: (nodeId) => {
438→ const s = get();
439→ if (!s.activePuzzle) return { ok: false, finished: false, failReason: "无活跃谜题" };
440→ // 深拷贝谜题
441→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
442→ const res = tryClickNode(puzzle, nodeId);
443→ if (res.ok) {
444→ if (res.finished) {
445→ // 结算奖励(星潮解码奖励修饰 + 星图「二周目经验」)
446→ const tideMod = getTideModifiers(s.activeTide);
447→ const cm = constellationBonuses(s.constellation ?? []);
448→ const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult);
449→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
450→ const rewards = {
451→ crystals: Math.round(base.crystals * finalMult),
452→ insights: Math.round(base.insights * finalMult),
453→ contact: +(base.contact * finalMult).toFixed(2),
454→ };
455→ const newTotal = s.totalDecoded + 1;
456→ const newContact = Math.min(100, s.contact + rewards.contact);
457→ const newInsights = s.insights + rewards.insights;
458→ const newCrystals = s.crystals + rewards.crystals;
459→ // 解锁碎片
460→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
461→ const unlocked = checkFragments(tentative);
462→ set({
463→ activePuzzle: null,
464→ crystals: newCrystals,
465→ insights: newInsights,
466→ contact: newContact,
467→ totalDecoded: newTotal,
468→ fragments: tentative.fragments,
469→ });
470→ // 深空信标:解码 +1,洞见累计
471→ trackBeacon("decode", 1);
472→ trackBeacon("insight", rewards.insights);
473→ return { ok: true, finished: true, failReason: unlocked.join(",") || undefined };
474→ }
475→ // 点击成功但未完成:检测当前局面是否仍可解
476→ const solvable = isSolvable(puzzle);
477→ set({ activePuzzle: puzzle });
478→ return { ok: true, finished: false, solvable };
479→ }
480→ return res;
481→ },
482→
483→ undoStep: () => {
484→ const s = get();
485→ if (!s.activePuzzle || s.activePuzzle.path.length === 0) return;
486→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
487→ const lastId = puzzle.path.pop();
488→ if (lastId !== undefined) {
489→ const node = puzzle.grid.find((n) => n.id === lastId);
490→ if (node) node.used = false;
491→ }
492→ set({ activePuzzle: puzzle });
493→ },
494→
495→ retryPuzzle: () => {
496→ const s = get();
497→ if (!s.activePuzzle) return;
498→ set({ activePuzzle: resetPuz(s.activePuzzle) });
499→ },
500→
501→ abandonPuzzle: () => {
502→ const s = get();
503→ if (!s.activePuzzle) return;
504→ // 晶体放回队列末尾
505→ const crystal: Crystal = {
506→ id: `c_${Date.now()}_ret`,
507→ tier: s.activePuzzle.tier,
508→ value: CRYSTAL_VALUE[s.activePuzzle.tier].crystals,
509→ createdAt: Date.now(),
510→ };
511→ set({
512→ activePuzzle: null,
513→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
514→ });
515→ },
516→
517→ autoDecodeTick: () => {
518→ const s = get();
519→ if (!s.autoDecode) return;
520→ const now = Date.now();
521→ // 星图「自动校准」减少自动解码周期
522→ const cm = constellationBonuses(s.constellation ?? []);
523→ const interval = Math.max(5000, 12000 + cm.autoDecodeIntervalDeltaSec * 1000);
524→ if (now - s._lastAutoDecode < interval) return;
525→ // 找一颗 T1 晶体自动解码
526→ const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
527→ if (idx < 0) return;
528→ const crystal = s.pendingCrystals[idx];
529→ const tideMod = getTideModifiers(s.activeTide);
530→ const base = decodeRewards(1, s.insightMult, s.contactRateMult);
531→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
532→ const rewards = {
533→ crystals: Math.round(base.crystals * finalMult),
534→ insights: Math.round(base.insights * finalMult),
535→ contact: +(base.contact * finalMult).toFixed(2),
536→ };
537→ const newTotal = s.totalDecoded + 1;
538→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
539→ checkFragments(tentative);
540→ set({
541→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id),
542→ crystals: s.crystals + rewards.crystals,
543→ insights: s.insights + rewards.insights,
544→ contact: Math.min(100, s.contact + rewards.contact),
545→ totalDecoded: newTotal,
546→ fragments: tentative.fragments,
547→ _lastAutoDecode: now,
548→ });
549→ // 深空信标:自动解码也算进度
550→ trackBeacon("decode", 1);
551→ trackBeacon("insight", rewards.insights);
552→ },
553→
554→ buyTech: (techId) => {
555→ const s = get();
556→ const node = TECH_TREE.find((t) => t.id === techId);
557→ if (!node) return false;
558→ const cur = s.tech[techId] ?? 0;
559→ if (cur >= 1) return false; // v0.1 每节点 1 级
560→ if (s.insights < node.cost) return false;
561→ const newTech = { ...s.tech, [techId]: 1 };
562→ set({
563→ insights: s.insights - node.cost,
564→ tech: newTech,
565→ ...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }),
566→ });
567→ return true;
568→ },
569→
570→ // ============ 探险系统 ============
571→ startExpedition: () => {
572→ const s = get();
573→ if (s.activeExpedition && !s.activeExpedition.finished) {
574→ return { ok: false, reason: "已有进行中的探险" };
575→ }
576→ if (s.energy < EXPEDITION_CONFIG.energyCost) {
577→ return { ok: false, reason: "能量不足" };
578→ }
579→ const tideMod = getTideModifiers(s.activeTide);
580→ const power = computeExpeditionPower(s) + tideMod.expeditionPowerBonus;
581→ const hp = computeExpeditionHp(s) + tideMod.expeditionHpBonus;
582→ const seed = Math.floor(Math.random() * 1e9);
583→ const expedition = generateExpedition(seed, power, hp);
584→ set({
585→ activeExpedition: expedition,
586→ energy: s.energy - EXPEDITION_CONFIG.energyCost,
587→ totalExpeditions: s.totalExpeditions + 1,
588→ });
589→ return { ok: true };
590→ },
591→
592→ resolveCurrentNode: () => {
593→ const s = get();
594→ if (!s.activeExpedition || s.activeExpedition.finished) return null;
595→ // 深拷贝
596→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
597→ const result = resolveNode(exp);
598→ // 累计奖励
599→ if (result.crystals) exp.rewards.crystals += result.crystals;
600→ if (result.insights) exp.rewards.insights += result.insights;
601→ if (result.contact) exp.rewards.contact += result.contact;
602→ if (result.fragments) exp.rewards.fragments.push(...result.fragments);
603→ if (result.hpDelta) exp.hp = Math.max(0, Math.min(exp.maxHp, exp.hp + result.hpDelta));
604→ // 实时入账(玩家立即获得)
605→ const newCrystals = s.crystals + (result.crystals || 0);
606→ const newInsights = s.insights + (result.insights || 0);
607→ const newContact = Math.min(100, s.contact + (result.contact || 0));
608→ // 碎片解锁
609→ const newFragments = { ...s.fragments };
610→ if (result.fragments) {
611→ for (const fid of result.fragments) newFragments[fid] = true;
612→ }
613→ // 日志
614→ const logEntry = {
615→ expeditionId: exp.id,
616→ nodeType: exp.nodes[exp.currentNode]?.type || "combat",
617→ result: result.log,
618→ rewards: [
619→ result.crystals ? `+${result.crystals}晶体` : "",
620→ result.insights ? `+${result.insights}洞见` : "",
621→ result.contact ? `+${result.contact.toFixed(1)}接触` : "",
622→ result.hpDelta ? `${result.hpDelta > 0 ? "+" : ""}${result.hpDelta}生命` : "",
623→ ].filter(Boolean).join(" "),
624→ timestamp: Date.now(),
625→ };
626→ const newLog = [logEntry, ...s.expeditionLog].slice(0, 30);
627→
628→ if (result.ended) {
629→ // 探险结束(胜利或失败)
630→ exp.finished = true;
631→ }
632→
633→ // v0.4 编年史:击破 BOSS 时累计计数
634→ let bossKills = s.bossKills ?? 0;
635→ if (
636→ result.ended &&
637→ result.endReason === "victory" &&
638→ exp.nodes[exp.currentNode]?.type === "boss"
639→ ) {
640→ bossKills = bossKills + 1;
641→ }
642→
643→ set({
644→ activeExpedition: exp,
645→ crystals: newCrystals,
646→ insights: newInsights,
647→ contact: newContact,
648→ fragments: newFragments,
649→ expeditionLog: newLog,
650→ bossKills,
651→ });
652→ // 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破
653→ if (result.ended) {
654→ trackBeacon("expedition", 1);
655→ if (result.endReason === "victory" && exp.nodes[exp.currentNode]?.type === "boss") {
656→ trackBeacon("boss", 1);
657→ }
658→ }
659→ if (result.insights) trackBeacon("insight", result.insights);
660→ return result;
661→ },
662→
663→ advanceNode: () => {
664→ const s = get();
665→ if (!s.activeExpedition || s.activeExpedition.finished) return;
666→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
667→ const node = exp.nodes[exp.currentNode];
668→ if (!node || !node.cleared) return; // 当前节点未结算不能前进
669→ if (exp.currentNode >= exp.nodes.length - 1) return;
670→ exp.currentNode++;
671→ set({ activeExpedition: exp });
672→ },
673→
674→ abortExpedition: () => {
675→ const s = get();
676→ if (!s.activeExpedition) return;
677→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
678→ exp.finished = true;
679→ const logEntry = {
680→ expeditionId: exp.id,
681→ nodeType: "rest" as const,
682→ result: "探险队主动撤退,保留已获奖励。",
683→ rewards: "",
684→ timestamp: Date.now(),
685→ };
686→ set({
687→ activeExpedition: exp,
688→ expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30),
689→ });
690→ },
691→
692→ doPrestige: () => {
693→ const s = get();
694→ if (s.contact < CONTACT.prestigeMin) return null;
695→ const newBp = computeNewBlueprints(s);
696→ const next = performPrestige(s);
697→ // 星图「能量共振」提升上限
698→ const cm = constellationBonuses(next.constellation ?? []);
699→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
700→ set({
701→ ...next,
702→ energyMax,
703→ ...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation }),
704→ _lastAutoDecode: Date.now(),
705→ _lastSpawn: Date.now(),
706→ _combo: 0,
707→ _lastPulse: 0,
708→ _tideEvents: [],
709→ });
710→ return { newBp };
711→ },
712→
713→ chooseConstellationPerk: (perkId) => {
714→ const s = get();
715→ if (!s.pendingPerkChoices || !s.pendingPerkChoices.includes(perkId)) return false;
716→ const perk = getPerk(perkId);
717→ if (!perk) return false;
718→ if (s.constellation?.includes(perkId)) return false;
719→ const newConstellation = [...(s.constellation ?? []), perkId];
720→ const cm = constellationBonuses(newConstellation);
721→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
722→ // v0.4 编年史:回填最近一条 entry 的 perksThisAscension
723→ const chronicle = s.chronicle ?? [];
724→ let newChronicle = chronicle;
725→ if (chronicle.length > 0) {
726→ const lastEntry = chronicle[chronicle.length - 1];
727→ const updatedLast = withPerks(lastEntry, [perkId]);
728→ newChronicle = [...chronicle.slice(0, -1), updatedLast];
729→ }
730→ set({
731→ constellation: newConstellation,
732→ pendingPerkChoices: null,
733→ energyMax,
734→ chronicle: newChronicle,
735→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation }),
736→ });
737→ return true;
738→ },
739→
740→ rerollPerkChoices: () => {
741→ const s = get();
742→ if (!s.pendingPerkChoices) return;
743→ const choices = rollPerkChoices(s.constellation ?? []);
744→ if (choices.length > 0) set({ pendingPerkChoices: choices });
745→ },
746→
747→ checkAchievements: () => {
748→ const s = get();
749→ const newlyUnlocked: Achievement[] = [];
750→ const updated = { ...s.achievements };
751→ let crystals = s.crystals;
752→ let insights = s.insights;
753→ let contact = s.contact;
754→ let statsDirty = false;
755→ for (const a of ACHIEVEMENTS) {
756→ if (updated[a.id]) continue;
757→ if (a.check(s)) {
758→ updated[a.id] = true;
759→ newlyUnlocked.push(a);
760→ // 发放即时奖励
761→ if (a.reward.crystals) crystals += a.reward.crystals;
762→ if (a.reward.insights) insights += a.reward.insights;
763→ if (a.reward.contact) contact = Math.min(100, contact + a.reward.contact);
764→ if (a.reward.crystalsPerSecPct || a.reward.insightPct) statsDirty = true;
765→ }
766→ }
767→ if (newlyUnlocked.length === 0) return [];
768→ set({
769→ achievements: updated,
770→ crystals,
771→ insights,
772→ contact,
773→ ...(statsDirty
774→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation })
775→ : {}),
776→ _achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
777→ });
778→ return newlyUnlocked;
779→ },
780→
781→ consumeAchievementQueue: () => {
782→ const s = get();
783→ if (s._achievementQueue.length === 0) return [];
784→ const items = s._achievementQueue;
785→ set({ _achievementQueue: [] });
786→ return items;
787→ },
788→
789→ canPrestige: () => get().contact >= CONTACT.prestigeMin,
790→
791→ toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
792→ toggleSound: () => set({ soundOn: !get().soundOn }),
793→
794→ // 深空信标:发放每日挑战奖励(v0.5)
795→ grantBeaconReward: (insights, contact) => {
796→ const s = get();
797→ set({
798→ insights: s.insights + Math.round(insights),
799→ contact: Math.min(100, s.contact + contact),
800→ });
801→ },
802→
803→ // 深空巡航:发放实时玩法奖励(v0.6)
804→ grantCruiseReward: (rewards) => {
805→ const s = get();
806→ const addCrystals = rewards.crystals ?? 0;
807→ const addInsights = rewards.insights ?? 0;
808→ const addContact = rewards.contact ?? 0;
809→ set({
810→ crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
811→ insights: s.insights + Math.round(addInsights),
812→ contact: Math.min(100, s.contact + addContact),
813→ });
814→ },
815→ }),
816→ {
817→ name: "echo-nexus-save-v1",
818→ storage: createJSONStorage(() => localStorage),
819→ // 不持久化临时字段
820→ partialize: (s) => {
821→ const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s;
822→ void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents;
823→ return rest as GameState;
824→ },
825→ }
826→ )
827→);
828→
829→/** 选择器:未解锁碎片中下一个门槛 */
830→export function nextFragmentThreshold(totalDecoded: number): number | null {
831→ for (const f of FRAGMENTS) {
832→ if (totalDecoded < f.threshold) return f.threshold;
833→ }
834→ return null;
835→}
836→
837→export { FRAGMENTS, PRESTIGE, TECH_TREE };
838→
839→// 开发期调试:暴露 store 到 window,便于 QA 测试
840→if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
841→ (window as unknown as { __gameStore?: typeof useGameStore }).__gameStore = useGameStore;
842→}
843→
+258 -257
View File
@@ -4,267 +4,268 @@
---
## 项目当前状态描述 / 判断
## 一、项目当前状态描述 / 判断
- **阶段**:v0.5.1 已完成(**游戏已上线 Gitea Pages** + 新手教程系统 + UI 优化)
### 概况
- **项目**:回响星核 / Echo Nexus — 深空考古放置策略游戏
- **一句话**:自治无人机采矿 → 解码记忆晶体(原创共振谜题)→ 拼凑碎片叙事 → 飞升多周目。差异化:解码谜题驱动叙事涌现,全息晶体 + 深空粒子美学。
- **当前版本****v0.7**CrystalOrb Canvas 粒子系统 + 角色属性系统)
- **在线游玩**https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
- **已完成**
- 游戏市场调研(多源交叉验证,2024-2026 数据)
- GDD 游戏设计文档(世界观、核心循环、六大系统、MVP 范围)
- 技术架构设计(栈选型、目录结构、状态模型)
- Gitea 仓库 `Super_Z/echo-nexus` 已创建,文档已推送
- v0.1 MVP:放置采矿 + 解码共振谜题 + 技术树 + 记忆图谱 + 飞升 + 统计
- v0.1.1:解码可解性修复(路径构造法生成器)
- v0.2:遗迹探险肉鸽系统(能量/探险力/6 节点/程序化路径/BOSS)
- v0.2.1:程序化音频 + 14 项成就 + 视觉打磨
- v0.3:星潮事件系统(6 种星潮 + 全屏叠层 + 程序化音效)
- v0.3.1:星图天文台元进程(6 类 × 3 = 18 天赋 + 飞升后 3 选 1 draft + Canvas 星图)
- v0.4:回响编年史(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复)
- v0.5:深空信标(每日挑战 + 本地排行榜 Top20 + 7 标签页 + 编年史历史 BUG 修复)
- **v0.5.1:静态部署 Gitea Pages + 新手教程系统(7 步聚光灯引导)+ UI 间距优化**
- **当前目标**:回复 Issue #1 #28 + UI 进一步优化 + 云存档/云排行榜 + 全 5 纪元叙事
- **技术栈**Next.js 16 + TypeScript + Tailwind + shadcn/ui + Canvas + Zustand
- **仓库**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``fixed_rate` + `"900"` 秒,priority=10job_id 228266)。正常完成不会被删除,无需自持续机制。
## 游戏核心概念(一句话)
### 状态判断
- dev 服务器运行正常(HTTP 200,编译 < 250ms
- v0.7 两大新功能已完成:CrystalOrb Canvas 粒子系统升级(VLM 8/10+ 角色属性系统(VLM 7/10
- 系统总体稳定,无阻塞性 bug,可继续推进新功能
深空考古放置策略游戏:自治无人机采矿→解码记忆晶体(原创共振谜题)→拼凑碎片叙事→飞升多周目。差异化:解码谜题驱动叙事涌现,全息晶体+深空粒子美学。
### 已完成版本里程碑(精简)
| 版本 | 核心内容 |
|------|---------|
| v0.1 | MVP:放置采矿 + 解码共振谜题 + 技术树 + 记忆图谱 + 飞升 + 统计 |
| v0.1.1 | 解码可解性修复(路径构造法生成器,100% 可解率)|
| v0.2 | 遗迹探险肉鸽系统(能量/探险力/6 节点/程序化路径/BOSS)|
| v0.2.1 | 程序化音频(15 种音效)+ 14 项成就 + 视觉打磨 |
| v0.3 | 星潮事件系统(6 种星潮 + 全屏叠层 + 程序化音效)|
| v0.3.1 | 星图天文台元进程(6 类 × 3 = 18 天赋 + 飞升后 3 选 1 draft + Canvas 星图)|
| v0.4 | 回响编年史(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复)|
| v0.5 | 深空信标(每日挑战 + 本地排行榜 Top20 + 7 标签页 + 编年史历史 BUG 修复)|
| v0.5.1 | 静态部署 Gitea Pages + 新手教程系统(7 步聚光灯引导)+ UI 间距优化 |
| v0.5.2 | 离线收益报告弹窗 + 标签页 UI 重设计 + persist rehydrate 竞态修复 |
| v0.6 | 深空巡航 Canvas 2D 实时玩法 + UI 偏移重叠修复 |
| **v0.7** | **CrystalOrb Canvas 粒子系统升级 + 角色属性系统(四维)** |
## 当前目标 / 已完成的修改 / 验证结果
### 已完成
- 调研报告、GDD、技术架构文档 → 已提交 Gitea
- v0.1 MVP 实现:放置采矿 + 解码共振谜题 + 技术树 + 记忆图谱 + 飞升 + 统计
- 仓库地址:https://git.atdunbg.xyz/Super_Z/echo-nexus
### v0.1.1 解码系统修复(用户反馈「大量无法通过」
- **根因**generatePuzzle 随机填色未保证有解,产生大量死局
- **修复**:路径构造法生成器(100% 可解率)+ isSolvable 精确检测 + undoStep 撤销 + 卡死提示横幅 + canStartFrom 可行起点高亮 + 旧存档兼容
- **验证**3000/3000 可解;agent-browser T1/T2 端到端通过;死路检测/撤销/可行起点高亮全部准确
- 详见 docs/repo/docs/04-解码系统修复-v0.1.1.md
### v0.2 遗迹探险肉鸽系统(本轮完成)
- **新功能**:能量系统(3/5, 45s恢复) + 探险力/生命 + 6种节点类型(战斗/宝藏/抉择/解谜/休整/BOSS) + 程序化路径(5-7节点) + 实时奖励 + 失败保留 + 主动撤退 + 探险日志
- **配套修复**:HP clamp + 探险奖励绕过仓库上限 + 仓库满仓UX警告 + 闲置产能上限优化
- **UI**:探险标签页(入口态/进行态) + 节点路径图可视化 + 事件卡 + 生命/能量条
- **验证**:全流程通过(出发→探索→前进→BOSS→结束),奖励绕过cap验证(101>50)HP clamp验证
- 详见 docs/repo/docs/05-遗迹探险系统-v0.2.md
### v0.2.1 程序化音频 + 成就系统 + 视觉打磨(本轮完成)
- **QA 结论**:v0.2 全系统稳定,解码/探险/技术树/通知均正常,无 bug,故推进新功能
- **程序化音频系统**`src/lib/game/audio.ts`):Web Audio API 零资源文件,15 种音效(脉冲/解码按色变调/探险/飞升/成就/界面),受 soundOn 开关控制,首次交互后初始化 AudioContext
- **成就系统**`src/lib/game/achievements.ts`):14 项成就,跨周目永久产能/洞见加成,Toast+音效通知,每秒由主循环检测
- `GameState.achievements` 新字段;`recomputeStats` 聚合三层加成(技术+蓝图+成就)
- 旧存档兼容:init() 补全 achievements={}
- UI:5 列标签栏新增「成就」页 + AchievementsPanel + AchievementNotifier
- **视觉打磨**:脉冲粒子爆发(6-12粒子径向发散) + 扩散环 + 浮动数字按连击变色(绿→紫→琥)
- **Gitea 工单**:处理 Issue #1「就一直连连看?」——回复说明 6 大玩法层 + 路线图
- **验证**agent-browser 全流程通过;成就 3/14 解锁;lint 零错误;HTTP 200
- 详见 docs/repo/docs/06-音频与成就系统-v0.2.1.md
### v0.3 星潮事件系统(本轮完成)
- **动机**:回应 Issue #1「玩法单一」反馈,为放置循环注入动态变化
- **6 种星潮**`src/lib/game/starTide.ts`):晶体潮(产能×2)/谐振风暴(解码×1.6)/遗迹共振(探险+5力+30血)/虚空低语(洞见×2)/星核悸动(接触×3脉冲×2)/寂静期(产能×0.5,结束补偿洞见)
- **触发**:首次 30s,常规间隙 40s,持续 75s,权重抽取,飞升重置
- **修饰器应用**:产能/脉冲/解码奖励即时乘;洞见/接触率进 recomputeStats 缓存;探险力/血量即时加
- **UI**`src/components/game/StarTide.tsx`):顶部指示器芯片(图标+名称+倒计时) + 全屏背景叠层(色带+呼吸光) + Toast通知 + Footer联动
- **音效**tideStart(神秘扫频) + tideEnd(柔和消退),程序化合成
- **QA**agent-browser 全流程通过(触发/指示器/叠层/寂静期补偿 50→81 洞见);VLM 视觉确认;lint 零错误
- 详见 docs/repo/docs/07-星潮事件系统-v0.3.md
### v0.3.1 星图天文台 · 元进程天赋系统(本轮完成)
- **动机**:再次回应 Issue #1「就一直连连看?」——加入 Slay the Spire 式飞升后 3 选 1 天赋 draft,跨周目永久生效,增加策略深度 + 视觉变化
- **核心模块**`src/lib/game/constellation.ts`):
- 6 大星座 × 3 颗星 = **18 个天赋**:永动矿脉(翠)/光谱矩阵(玫)/远征星图(琥)/接触回响(紫)/虚空市场(灰)/宇宙回响(金)
- `constellationBonuses()` 聚合 19 项修饰器(产能/上限/脉冲/洞见/解码步数/自动解码周期/探险生命/探险力/能量上限/接触率/飞升门槛/蓝图全属性/离线效率/T2T3概率/星潮间隙/飞升礼包/解码奖励/全局产能)
- `rollPerkChoices()` 智能抽取:保证 3 个来自不同类别
- `getConstellationLayout()` 六边形布局 + `getStarsInCategory()` 三角形排布
- **状态扩展**`types.ts` + `config.ts`):`GameState.constellation: string[]` + `pendingPerkChoices: string[] | null`
- **引擎接入**`engine.ts`):`recomputeStats` 聚合三层加成(技术+蓝图+成就+星图);`performPrestige` 触发天赋选择 + 飞升礼包补偿;新增 `rollCrystalTierWithBonus()`
- **探险系统接入**`expedition.ts`):`computeExpeditionPower` + `computeExpeditionHp` 应用星座修饰器
- **Store 接入**`gameStore.ts`):
- 新增 `chooseConstellationPerk(perkId)` + `rerollPerkChoices()` 动作
- `tickTide` 应用星潮间隙缩减;`autoDecodeTick` 应用周期缩减;`tick` 应用 T2/T3 概率补偿;`clickNode`/`autoDecodeTick` 应用解码奖励倍率
- 旧存档兼容:init() 补全 constellation=[] / pendingPerkChoices=null / energyMax 加成
- **UI 组件**
- `ConstellationPanel.tsx`:Canvas 动态星图(六边形 6 星座 × 3 星点,闪烁/光晕/十字光线/连接线/中心星核呼吸)+ Hover 提示 + 类别图例 + 已解锁列表
- `ConstellationDialog.tsx`:飞升后自动弹出,3 卡片 draft + 重新抽取 + 选中粒子动画
- `PrestigeDialog.tsx`:增加「星图觉醒预告」卡片
- `page.tsx`:6 列标签栏(探险/技术/星图/图谱/成就/统计)+ 顶部「觉醒」按钮(pendingPerkChoices 存在时脉冲提示)+ 统计面板加星图天赋条目
- **音频**:新增 `constellation` SFX(上升琶音 C5→E5→G5→C6 + 高频闪光 2093Hz
- **成就**:新增 2 项 —— `ach_constellation_1`「星图初绘」(+30 洞见·产能+5%) / `ach_constellation_6`「六分星辉」(产能+12%·洞见+12%)
- **QA 验证**agent-browser + VLM):
- 星图标签渲染正确,6 类别图例齐全
- 设置 pendingPerkChoices 后对话框自动弹出,3 卡片显示不同类别
- 点击天赋 → constellation 数组增加,crystalsPerSec 提升,pending 清空,对话框关闭
- 重新抽取 → 3 张新卡片(不同类别)
- 多天赋叠加:c_min_1 + c_con_1 → crystalsPerSec 1.416, contactRateMult 1.2
- 6 类别各 1 天赋 → 自动解锁「六分星辉」成就(Toast 弹出)
- VLM 视觉确认:星图 Canvas 渲染正确,单星点亮(绿色永动矿脉)
- **lint 零错误;HTTP 200;编译 < 200ms**
- 详见 docs/repo/docs/08-星图天文台系统-v0.3.1.md
### v0.3.1 收尾(本轮提交)
- 推送 Gitea3 个 commitsubmodule 文档 + 主仓功能 + 清理)
- 回复 Issue #1:列出 6 大玩法层 + v0.3.1 新增 + 路线图
- 暴露 dev 期 store 调试钩子 `window.__gameStore`(仅 non-production
- 目标提示增加星图天赋引导(飞升后觉醒时显示)
### v0.4 回响编年史 · 跨周目叙事时间轴(本轮完成)
- **QA 发现关键 BUG 并修复**PrestigeDialog 的 `disabled={newBp <= 0 || confirming}` 逻辑错误——`confirming=true` 后按钮被永久禁用,玩家**永远无法完成飞升**!修复为 `disabled={confirming && !canReconfirm}` + 900ms 冷却窗口
- **修复 UX**`newBp=0` 时不再永久禁用飞升按钮(v0.3.1 星图+v0.4 编年史已为飞升提供动机),按钮文字改为「飞升(无新蓝图)」
- **新功能:回响编年史**`src/lib/game/chronicle.ts` + `src/components/game/ChronicleDialog.tsx`
- 每次飞升自动铭刻一条 ChronicleEntry,记录纪元名/持续时间/解码数/技术数/探险数/BOSS 击破/星潮列表/天赋觉醒/里程碑
- 5 纪元循环命名(第一纪元·觉醒之晨 → 第五纪元·起源重述),朝 v0.5 全 5 纪元叙事推进
- 模板化 lore 生成:开场 → 核心活动 → 星潮段落 → 觉醒段落 → 里程碑 → 收尾
- 9 种里程碑检测:首次飞升/首杀 BOSS/单周目解码 10+/30+/技术狂人/星潮亲历者/星图六分/星图十二宫
- RunStartSnapshot 机制:飞升时快照当前累计计数,新周目 delta = 当前 - runStart
- **状态扩展**`types.ts` + `config.ts`):`GameState.chronicle: ChronicleEntry[]` + `runStart: RunStartSnapshot` + `bossKills: number`(累计)+ `starTidesEncountered: string[]`(累计去重)
- **引擎接入**`engine.ts`):`performPrestige` 在重置前构建编年史条目,追加到 chronicle(上限 50),重置 runStart 为新周目快照
- **Store 接入**`gameStore.ts`):
- `init()` 调用 `migrateChronicleFields` 补全旧存档
- `tickTide` 新星潮触发时追加 `tide_${type}` 到 starTidesEncountered(去重)
- `resolveCurrentNode` BOSS 战胜利时 `bossKills++`
- `chooseConstellationPerk` 调用 `withPerks` 回填最近一条 entry 的 perksThisAscension 并重新生成 lore
- **UI 组件**`ChronicleDialog.tsx`):
- 顶部 BookOpen 图标按钮 + badge 显示条目数
- 全屏对话框,深紫黑渐变 + 顶部三色装饰条
- 顶部总览统计:飞升次数/BOSS 击破/星潮亲历
- 垂直时间轴:左侧渐变线 + 5 纪元循环色节点(翠/玫/琥/紫/金)+ 最新条目 ping 动画
- 卡片含:#编号 / 纪元名(textShadow 光晕)/ 时间戳+持续时长 / 衬线叙事文本 / 6 项统计芯片 / 星潮徽章 / 天赋徽章 / 里程碑徽章
- 入场动画:fade-in + slide-up,错峰 80ms
- 空状态:根据 ascensions 是否 > 0 显示不同提示
- **音频**:新增 `chronicle`G3+C4+G4+C5+G5 多层泛音深沉钟声)+ `chronicleOpen`(A4→D5→A5 翻页声);飞升成功延迟 600ms 触发 chronicle sfx
- **成就**:新增 6 项 —— `ach_chronicle_1`「首部编年」(+40洞见·产能+6%) / `ach_chronicle_3`「三纪元回响」(产能+10%·洞见+10%) / `ach_chronicle_5`「五纪元闭环」(产能+15%·洞见+15%) / `ach_boss_1`「首杀维度」(+25洞见·产能+4%) / `ach_boss_5`「维度猎手」(产能+10%·洞见+8%) / `ach_tides_all`「星潮亲历者」(产能+8%·洞见+8%)
- **统计面板**:新增 BOSS 击破/星潮亲历/编年史条目 3 行
- **QA 验证**agent-browser + VLM):
- 飞升 BUG 修复验证:第一次点飞升 → 900ms 冷却 → 第二次点击成功触发,星图觉醒对话框弹出
- 编年史条目创建验证:ascensions 1→2, chronicle 0→1;选天赋后 perksThisAscension=["c_cos_2"] 正确回填
- 多条目验证:第二次飞升后 chronicle 1→2,新条目 lore 正确使用「星核悸动」中文名(修复了 tide_ruins 显示 bug
- 5 纪元循环色正确显示(第二纪元玫色 / 第三纪元紫色)
- VLM 视觉评估 8/10:时间轴清晰、纪元颜色区分明显、信息密度合理、无瑕疵
- 成就解锁:ach_chronicle_1 + ach_prestige_3 自动检测通过
- **lint 零错误;HTTP 200;编译 < 250ms**
- **Gitea 推送**2 个 commit(主仓功能 + submodule 文档)
- 详见 docs/repo/docs/09-回响编年史系统-v0.4.md
### v0.5 深空信标 · 每日挑战 + 本地排行榜(本轮完成)
- **QA 发现并修复编年史历史 BUG**:v0.4 之前创建的编年史条目 lore 中显示 `tide_ruins` 等原始键名而非「遗迹共振」(最新条目已修复,旧条目 lore 文本一次性生成未回填)。修复方案:新增 `regenerateLoreFromEntry(entry)`,在 ChronicleDialog 显示时从 entry 结构化数据重新生成 lore,保证历史与未来条目命名一致
- **新功能:深空信标系统**`src/lib/game/beacon.ts` ~290 行 + `src/components/game/BeaconPanel.tsx` ~280 行)
- **每日挑战**:基于 UTC 日期 key 的 FNV-1a 种子 → mulberry32 PRNG,确定性生成(同一天全球同一挑战)
- **5 种挑战类型**:解码协议 / 远征指令 / 脉冲任务 / 猎杀契约 / 洞见采集
- **3 档难度**(加权):常规信标 ×1.0(55%) / 异常波动 ×1.6(33%) / 奇点回响 ×2.4(12%)
- **得分公式**:完成度 × 1000 × 难度倍率 + 速度奖励 max(0, 500 - 用时秒×0.5)
- **进度追踪**:独立 localStorage`echo-nexus-beacon-prog-v1`),不污染 GameState;在 pulse/clickNode/autoDecodeTick/resolveCurrentNode 后调用 `trackBeacon(type, delta)`
- **本地排行榜**:Top 20,按得分降序,奖牌图标(金/银/铜)+ 难度色点 + 今日记录高亮
- **倒计时**:距 UTC 次日 0 点 HH:MM:SS,每秒更新
- **Store 接入**`gameStore.ts`):
- 新增 `trackBeacon()` 模块级辅助函数(按今日挑战类型增量更新)
- 新增 `grantBeaconReward(insights, contact)` action(领取时发放奖励到 GameState)
- pulse / clickNode 完成 / autoDecodeTick / resolveCurrentNode(探险结束/BOSS击破/洞见获取)后接入 trackBeacon
- **UI 接入**`page.tsx`):
- 第 7 个标签页「信标」(grid-cols-6 → grid-cols-7
- `beaconClaimable` 状态(2s 轮询,可领取时标签页显示绿点)
- StatsPanel 新增「信标最高分」行
- 版本号 v0.4 → v0.5
- **UI 设计细节**
- 挑战卡片:难度色渐变背景 + 呼吸光动画(beacon-glow keyframes+ 双层脉冲环动画(beacon-pulse-ring
- 进度条带难度色填充
- 排行榜行:奖牌图标 + 难度色点 + 类型名 + 完成标记✓ + 用时 + 得分(难度色)
- 难度图例三档横排
- **QA 验证**agent-browser + VLM):
- 编年史历史 BUG 修复:第二纪元 lore 现正确显示「遗迹共振、晶体潮、虚空低语、谐振风暴」
- 信标标签页渲染:挑战卡片 + 倒计时 08:47:12 + 难度图例齐全
- 进度追踪:解码 1 颗晶体 → 洞见采集进度 0→12
- 领取流程:完成 → 领取按钮亮 → 点击 → contact +5.113.47→18.57)→ 排行榜生成条目「1m30s 1.46K」→ 按钮变「已领取」
- 统计面板「信标最高分」行显示 1.46K
- 可领取时标签页绿点提示
- VLM 视觉评估:高美观度,颜色搭配佳,无重叠 bug
- 全系统回归:探险/解码/星潮/成就/星图/编年史均正常
- **lint 零错误;HTTP 200;编译 < 250ms**
- 详见 docs/repo/docs/10-深空信标系统-v0.5.md
### v0.5.1 静态部署 + 新手教程系统 + UI 优化(本轮完成)
- **里程碑:游戏正式上线 Gitea Pages** 玩家无需下载编译,直接访问 https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/ 即可游玩
- **背景**:回应 Issue #1 评论 #28atdunbg 反馈"如何游玩?需要教程?UI 太紧凑遮挡字体")
- **静态导出配置**`next.config.ts`):
- 环境变量 `BUILD_EXPORT=true` 切换 `output: export`dev 不受影响)
- `basePath: /Super_Z/echo-nexus` + `trailingSlash: true` 适配 Gitea Pages 子路径
- `images.unoptimized: true`(静态导出禁用图片优化)
- 移除 `src/app/api/route.ts`(静态导出不支持 API routes)
- 新增 `bun run build:static` 脚本
- `out/.nojekyll` 防 Jekyll 忽略 `_next` 目录
- **Gitea Pages 部署流程**`bun run build:static``cd out && git init -b gh-pages``git push -f origin gh-pages`
- **新功能:新手教程系统**`src/lib/game/tutorial.ts` + `src/components/game/TutorialOverlay.tsx`
- **7 步引导**:欢迎 → 脉冲扫描 → 谐振解码 → 技术树 → 遗迹探险 → 飞升 → 完成
- **聚光灯高亮**:4 块 div 挖洞遮罩 + 目标元素呼吸光边框(`tut-pulse` keyframes
- **动作检测**:rAF 轮询 store 状态,检测 pulse/decode-start/tech-buy/expedition-start 自动推进
- **提示气泡**:紫粉渐变边框 + 顶部三色装饰条 + 步骤进度条(1/7)+ 上一步/跳过/下一步按钮
- **首次自动弹窗**localStorage `echo-nexus-tutorial-v1` 标记,600ms 延迟等 mount
- **设置重看**SettingsDialog 新增"重看教程"按钮,dispatch `echo-nexus-tutorial-restart` 事件
- **UI 优化**(基于 VLM 视觉分析):
- **ResourceBar 间距**`gap-2 → gap-x-3/4`,分隔线改渐变透明→白→透明,`shrink-0` 防图标挤压,`whitespace-nowrap` 防文字断行,`tabular-nums` 数字等宽对齐
- **SettingsDialog**:新增"在线游玩"gitea-pages 链接(绿色高亮)+ 教程重看卡片
- **globals.css**:新增 `tut-pulse` + `tut-pop-in` 动画 keyframes
- **data-tut 属性锚点**crystal-orb / decode-panel / tab-expedition / tab-tech / prestige-btn
- **QA 验证**agent-browser + VLM,部署站点):
- 静态站点 HTTP 200HTML 完整,资源路径含 basePath 正确
- 首次访问教程自动弹出(hasWelcome:true, hasNext:true
- 点击"下一步"→ 步骤 2/7,高亮聚焦框框住中央晶体球(VLM 确认 8/10)
- 点击"跳过"→ 教程关闭,游戏正常加载
- 设置→重看教程→ 弹窗重新打开回 1/7
- VLM 确认弹窗位置合理、遮罩半透明、紫色高亮边框、按钮清晰
- 核心交互(点击晶体 +8)正常
- **lint 零错误**(修复 2 处 react-hooks/set-state-in-effect,改用 rAF 异步轮询)
- **Gitea 推送**2 个 commitmain 源码 + gh-pages 静态资源)
- **dev 服务器 Turbopack 缓存不稳定问题**:偶发 `Unable to open static sorted file` + 进程被杀,需 `rm -rf .next` 重启。静态导出构建稳定可靠
### v0.5.2 离线收益系统 + 标签页 UI 重设计 + persist 竞态修复(本轮完成)
- **新功能:离线收益报告弹窗**`src/lib/game/offlineReport.ts` + `src/components/game/OfflineReportDialog.tsx`
- 玩家离开后回来,自动弹出"欢迎回来,无人机"弹窗
- 显示:离线采集获得的晶体数(+1.44K)+ 离线时长(1小时2分)+ 采集速率(0.4/s)+ 离线效率(50%)
- 当前晶体进度条(1.45K / 5.00K)+ 仓库满仓警告
- 领取收益按钮 + 程序化音效
- 模块级存储 + 自定义事件(`echo-nexus-offline-report`),不污染 GameState schema
- **关键 BUG 修复:persist rehydrate 竞态**`src/hooks/useGameLoop.ts`
- **根因**Zustand persist 即使用同步 localStoragerehydrate 也是异步(`Promise.resolve` 包装)。init 在 useEffect 中执行时,rehydrate 可能未完成,`s.lastTick` 是 INITIAL_STATE 的 undefined`elapsed = NaN`,不进入离线补发分支 → `setPendingOfflineReport` 不调用 → 弹窗不弹
- **修复**useGameLoop 用 `useGameStore.persist.hasHydrated()` 检查,若未 rehydrated 则 `onFinishHydration(doInit)` + 500ms 兜底 setTimeout
- **验证**:通过 5 轮调试(window.__initTrace / __lastOfflineReport / __offlineCheck / __loopMounted 标记法)定位
- **UI 优化:标签页重设计**`src/app/page.tsx`
- 图标+文字纵向排列(`flex-col`),图标 h-3.5 w-3.5,文字 leading-none
- 每个标签独立主题色 glow:探险(琥珀)/技术(翠)/星图(玫)/图谱(青)/成就(琥珀)/信标(翠)/统计(灰)
- 通知圆点增大到 h-2 w-2 + `ring-1 ring-black/50` 描边
- active 状态阴影 `shadow-[0_0_12px_rgba(...)]`
- **QA 验证**agent-browser + VLM):
- 离线弹窗 UI 视觉评分 9/10(标题/晶体数/时长/速率/按钮/进度条全部正确)
- 标签页 UI 评分 7/10(VLM 建议强化颜色区分,已用 per-tab glow 实现)
- lint 零错误
- **Gitea 推送**main 分支 1 commit + gh-pages 分支静态资源
- **定时任务重置**job_id 227933cron `0 0/15 * * * ?`,每15分钟 webDevReview
### 进行中
- [ ] 持续迭代:云存档+云排行榜(v0.5+)、全5纪元手写叙事节点(v0.5+)、socket 多人同步星潮(后续)
- [ ] 回复 Issue #1 #28:告知已上线 Gitea Pages + 教程系统 + UI 优化(v0.5.1 已回复 #29
## 未解决问题或风险 / 下一阶段优先事项
- v0.3 星潮为单机版(原计划 socket 全局事件),后续可扩展为多人同步
- v0.5 信标排行榜为本地版(纯 localStorage),后续 v0.5+ 升级为云排行榜需后端 API
- v0.5 每日挑战仅 1 个/天,后续可加入"周挑战"或"信标链"(连续完成 N 天奖励)
- Issue #1 玩家反馈已通过 v0.3 星潮 + v0.3.1 星图 + v0.4 编年史 + v0.5 信标 + v0.5.1 教程+上线 五层回应
- v0.2 探险 BOSS 战胜率较低(基础探索力 10 vs BOSS 难度 5-7,胜率约 25%),需玩家投资探险技术
- 探险能量恢复较慢(45s/点),后续可加技术提升恢复速度
- 编年史上限 50 条,超过自动丢弃最早的;v0.4 之前的飞升无回填(空状态有对应提示)
- 5 纪元叙事内容目前为模板化生成,v0.5+ 将加入手写剧情节点
- dev 服务器 Turbopack 缓存偶发损坏,静态导出构建更稳定
- **下一阶段优先**
1. 回复 Issue #1 #28atdunbg 等待游玩方式答复)
2. UI 进一步优化(标签页图标/文字比例、视觉层次强化)
3. 云存档+云排行榜(v0.5+)
4. 全 5 纪元手写叙事节点(v0.5+)
5. socket 多人同步星潮
## 定时任务
- 已设置:每 15 分钟 webDevReview(自动 QA + 迭代开发,job_id: 227933cron `0 0/15 * * * ?`tz Asia/Shanghai
- 上一轮 job_id 227909 已失效(列表为空),已重新创建
### 核心系统清单(8 大系统)
1. **放置采矿** — 中央晶体球 + 主动脉冲 + 连击加成(`CrystalOrb.tsx`
2. **谐振解码** — 原创路径谜题,路径构造法 100% 可解(`DecodeArray.tsx` + `engine.ts`
3. **技术树** — 三层科技解锁,提升产能/解码/探险(`TechTree.tsx`
4. **遗迹探险** — 肉鸽系统,6 种节点 + 程序化路径 + BOSS(`ExpeditionPanel.tsx` + `expedition.ts`
5. **星潮事件** — 6 种动态事件,为放置循环注入变化(`StarTide.tsx` + `starTide.ts`
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】
10. **角色属性系统** — 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点,影响所有系统(`AttributesPanel.tsx` + `attributes.ts`)【v0.7 新增】
---
## 二、当前目标 / 已完成的修改 / 验证结果
### 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 实时玩法(历史记录)
**动机**:回应用户反馈「玩法太单调(全是点点点)」+「视觉震撼不足」——首次引入实时操作玩法。
**新增文件**
- `src/lib/game/cruise.ts`~520 行逻辑层)
- 类型系统:CruiseEntity 联合类型(ship/asteroid/storm/crystal/insight/beacon/stargate/particle 共 8 种)
- mulberry32 + FNV-1a 种子化 RNG`cruiseSeed(level)`
- `generateLevel`:程序化生成陨石带(8+lvl×2)、虚空风暴(1+lvl/2)、晶体碎片(5+lvl)、洞见光球(2+lvl/3)、信标(1+lvl/4)、星门
- `updateCruise`:物理推进 + 碰撞检测 + 粒子系统 + 震动 + 600ms 无敌帧
- `computeRewards`crystals=碎片×(8+lvl×2)、insights=洞见×(2+lvl×0.5)、contact=信标×(1.5+lvl×0.3);通关×1.5、失败×0.5
- 独立 localStorage `echo-nexus-cruise-v1`:最高分/累计通关/最高关卡/累计奖励/最近 20 局
- `src/components/game/CruiseMode.tsx`~830 行渲染层)
- 全屏 fixed inset-0 z-50 CanvasDPR cap 2resize 监听
- 3 层视差星空:远(100星)/中(60星)/近(30星),独立漂移 + 飞船速度视差 + 闪烁
- 8 种实体全部 ctx.shadowBlur 辉光绘制
- 粒子系统:尾焰/收集/碰撞/烟花,上限 200
- 屏幕震动 280ms 衰减;低护盾(<30%)红色边框脉冲
- HUDHTML 叠层,glass+backdrop-blur80ms 节流):护盾/能量/分数/用时/收集计数
- 控制:桌面 WASD/方向键 8 方向 + 移动端虚拟摇杆 + P 暂停 + Esc 退出 + 失焦自动暂停
- 阶段:ready → playing → won/lost(结算明细 + 奖励 + 下一关/重试/返回)
**修改文件**
- `src/app/page.tsx`header 新增 amber 主题「巡航」按钮(Navigation 图标)+ cruiseOpen state + 条件渲染 CruiseMode
- `src/store/gameStore.ts`:新增 `grantCruiseReward({crystals?, insights?, contact?})` actioncrystals 受 crystalCap 限制,contact 受 100 上限)
- 版本号 v0.5.2 → v0.6
**UI 偏移/重叠 BUG 修复**3 处):
- `DecodeArray.tsx`:移除 className 冲突的 `-translate-x-1/2 -translate-y-1/2`,统一用 inline transform 定位+缩放
- `AchievementsPanel.tsx`:移除内层 `max-h-[300px]`,改 `flex-1 min-h-0` 自适应父容器
- `page.tsx`:调整三个 section 的 min-h,避免小屏单列时总高度超过手机视口
**色彩规范**:严格遵循 emerald(#34d399) / rose(#fb7185) / amber(#fbbf24) / fuchsia(#e879f9) 四色全息色谱,零蓝色/靛色
**QA 验证**agent-browser + VLM):
- lint 零错误;dev 服务器 HTTP 200;编译 < 250ms
- VLM 确认主界面:巡航按钮存在、晶体球居中、解码区正常、无重叠偏移
- VLM 确认巡航玩法:飞船可见(绿色三角形)、HUD 完整、陨石+星空背景、粒子特效(黄色尾焰+紫色辉光)
- WASD 控制飞船移动,按 W 直冲星门 → 通关,奖励 +30 晶体(2×10×1.5=30 计算正确)
- localStorage 正确记录 highScore/totalRuns/totalWins/bestLevel
- 奖励同步 gameStoregrantCruiseReward,满仓时 cap 逻辑正确)
- 退出返回主界面,7 标签页 + 晶体球 + 解码面板均完好
---
## 三、未解决问题或风险 / 下一阶段优先事项
### 已知问题 / 风险
1. **dev 服务器 Turbopack 缓存偶发损坏**`Unable to open static sorted file` + 进程被杀,需 `rm -rf .next` 重启。静态导出构建稳定可靠。
2. **定时任务调度类型**:必须用 `fixed_rate` + `"900"` 秒(15分钟),不要用 cron 表达式。`webDevReview` 类型任务正常完成不会被删除,只有抛出未捕获异常才会被标记失败。
3. **v0.3 星潮为单机版**:原计划 socket 全局事件,后续可扩展为多人同步。
4. **v0.5 信标排行榜为本地版**(纯 localStorage):升级云排行榜需后端 API。
5. **v0.5 每日挑战仅 1 个/天**:可加入"周挑战"或"信标链"(连续完成 N 天奖励)。
6. **探险 BOSS 战胜率较低**:基础探索力 10 vs BOSS 难度 5-7,胜率约 25%,需玩家投资探险技术。
7. **探险能量恢复较慢**45s/点,可加技术提升恢复速度。
8. **编年史上限 50 条**:超过自动丢弃最早的;v0.4 之前飞升无回填(空状态有提示)。
9. **5 纪元叙事为模板化生成**v0.5+ 将加入手写剧情节点。
10. **CrystalOrb 仍为 CSS 动画**v0.6 未完成 Canvas 粒子系统升级(上下文截断),是下一阶段第一优先级。
### 下一阶段优先级
1. **🔴 P0 — CrystalOrb Canvas 粒子系统升级**:中央晶体球从 CSS 动画升级为 Canvas 粒子视觉(能量流动/晶体共振/脉冲冲击波/环境粒子),提升视觉震撼。文件:`src/components/game/CrystalOrb.tsx`,保留对 `data-tut="crystal-orb"` 锚点。
2. **🟠 P1 — 角色属性系统**:探索力/智慧/勇气/灵感四维属性,影响探险/解码/星潮/巡航,增加 RPG 深度。
3. **🟠 P1 — 巡航玩法增强**:BOSS 战关卡 / 事件选择节点 / 更多关卡类型 / 道具掉落。
4. **🟡 P2 — UI 样式细节打磨**:标签页图标/文字比例、视觉层次强化、移动端响应式进一步优化。
5. **🟡 P2 — 云存档 + 云排行榜**:v0.5+ 后端 API(需评估是否引入 mini-service)。
6. **🟢 P3 — 全 5 纪元手写叙事节点**:替换模板化 lore。
7. **🟢 P3 — socket 多人同步星潮**:全局事件共享。
### 定时任务(fixed_rate · 900s
- **当前 job_id**: 2282662026-06-24 04:31 创建)
- **配置**: `fixed_rate` + `"900"`15分钟间隔),`priority=10`(高优先级),`payload.kind=webDevReview`
- **任务名**: Echo Nexus - 任务审查与持续迭代
- **正常完成不会被删除**,只有未捕获异常会标记失败。若任务列表为空,需用 cron create 重建(schedule.kind=fixed_rate, expr="900", priority=10, payload.kind=webDevReview)。
- **job_id 历史**: 227909(失效) → 228130(失效) → 228172(失效) → 228186(失效) → 228218(删除重建) → 228236(cron版,删除重建) → 228266(当前,fixed_rate版)
---
## 定时任务描述模板(webDevReview · fixed_rate
> 重建参数:schedule.kind=`fixed_rate`, schedule.expr=`"900"`, params.priority=`10`, payload.kind=`webDevReview`
```
请先判断当前项目状态,使用 agent-browser 进行必要测试和 QA,然后自主选择本轮最合适的工作重点并继续开发。
要求:
1. 开始任务时查看 /home/z/my-project/worklog.md 了解实际进展,拆解详细todo
2. 使用 agent-browser 进行测试和QA,有bug优先修复
3. 如果稳定,自主提出新需求继续推进
4. 样式要越做细节越多!!
5. 功能要越做越多!!
6. 工作告一段落更新 /home/z/my-project/worklog.md
7. 项目结束后一定要把工作记录写在 worklog.md 里
交接文档建议包含三部分:
- 项目当前状态描述/判断
- 当前目标/已完成的修改/验证结果
- 未解决问题或风险,建议下一阶段优先事项
```
---
## 历史详细版本记录(按需查阅 docs/repo/docs/
- `04-解码系统修复-v0.1.1.md`
- `05-遗迹探险系统-v0.2.md`
- `06-音频与成就系统-v0.2.1.md`
- `07-星潮事件系统-v0.3.md`
- `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 闪烁