diff --git a/README.md b/README.md index d22ee2c6f..b29681f87 100755 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ ## 项目状态 -- **版本**:**v0.14** — 放置系统(Idle Operations):采矿无人机舰队 + 6 种放置工程 + 离线推进 + 永久产能加成 +- **版本**:**v0.15** — 叙事深化(loreLayers 多层碎片文本)+ 放置平衡(永久加成 softcap)+ 批量领取 + 完成 toast - **栈**:Next.js 16 (App Router) · TypeScript · Tailwind CSS 4 · shadcn/ui · Canvas 2D · Zustand · Web Audio API - **仓库**:[https://git.atdunbg.xyz/Super_Z/echo-nexus](https://git.atdunbg.xyz/Super_Z/echo-nexus) - **在线版**:[https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/](https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/) @@ -60,7 +60,7 @@ --- -## 放置系统设计哲学(v0.14) +## 放置系统设计哲学(v0.14 + v0.15 平衡) 回响星核的「放置」不是简单地把数字挂机增长,而是分层呈现 **3 种 idle 节奏**: @@ -104,7 +104,7 @@ | v0.8 | 巡航玩法增强 / BOSS 关卡 / 道具掉落 | ✅ | | v0.8.1 | 探险平衡 + 信标系统扩展(周挑战 + 信标链) | ✅ | | **v0.14** | **放置系统:采矿无人机舰队 + 6 种放置工程 + 永久产能加成 + 始终可见 idle 徽章** | ✅ | -| v0.15 | 遗迹图鉴多层文本(Dark Souls 式 loreFragments) | 🔜 | +| **v0.15** | **叙事深化(8 碎片 × 2 层 = 16 层 loreLayers)+ 永久加成 softcap + 批量领取 + 完成 toast + StatsPanel idle 统计** | ✅ | | v0.16 | 解码阈值释放叙事碎片 + 星潮三频段切换 | 🔜 | | v0.17 | 健康设计:每日软上限 + "星核建议休眠"提醒 | 🔜 | | v1.0 | 全 5 纪元手写叙事 + 多人同步星潮 | 🔜 | @@ -124,6 +124,7 @@ - [`docs/09-回响编年史系统-v0.4.md`](docs/09-回响编年史系统-v0.4.md) — v0.4 编年史 - [`docs/10-深空信标系统-v0.5.md`](docs/10-深空信标系统-v0.5.md) — v0.5 信标 - [`docs/11-放置系统-v0.14.md`](docs/11-放置系统-v0.14.md) — **v0.14 放置系统(idle operations)** +- [`docs/12-叙事深化-v0.15.md`](docs/12-叙事深化-v0.15.md) — **v0.15 loreLayers 多层碎片文本 + softcap + 批量领取** --- @@ -156,7 +157,7 @@ BUILD_EXPORT=true bun run build:static cd out git init && git checkout -b gh-pages git add -A -git commit -m "deploy: v0.14" +git commit -m "deploy: v0.15" git remote add origin https://git.atdunbg.xyz/Super_Z/echo-nexus.git git push -f origin gh-pages ``` diff --git a/agent-ctx/2-b-full-stack-developer.md b/agent-ctx/2-b-full-stack-developer.md new file mode 100644 index 000000000..97605d2bc --- /dev/null +++ b/agent-ctx/2-b-full-stack-developer.md @@ -0,0 +1,67 @@ +# Task 2-b · full-stack-developer · v0.15 叙事深化 — Fragment loreLayers 多层文本 + +## 上下文 / 接收到的任务 +- Task ID: **2-b** +- 任务: 为 Fragment 增加 loreLayers(L1 中层 / L2 深层),实现 Dark Souls 式碎片叙事 +- 必读上下文已加载(worklog v0.14-reimpl 段落 + FRAGMENTS 数组 + Fragment 接口 + Codex.tsx + gameStore 导出) +- 与并行子代理 A 的边界:不动 engine.ts / gameStore.ts / IdleOperationsPanel.tsx / StatsPanel.tsx / idle.ts / persist version + +## 修改的文件(绝对路径) +1. `/home/z/my-project/src/lib/game/types.ts` +2. `/home/z/my-project/src/lib/game/config.ts` +3. `/home/z/my-project/src/components/game/Codex.tsx` + +## 实现细节 + +### types.ts +- 新增 `FragmentLoreUnlockKind` 联合类型(5 种解锁维度) +- 新增 `FragmentLoreUnlock` 接口(kind + value) +- 新增 `FragmentLoreLayer` 接口(layer 1|2 + title + text + unlockHint + unlock) +- `Fragment` 接口新增可选字段 `loreLayers?: FragmentLoreLayer[]` +- 现有 `echo` 字段注释明确为 "L0 表层文本",保持向后兼容 + +### config.ts +- 顶部 import 扩展 `FragmentLoreUnlock` + `GameState` 类型 +- **为全部 8 个 FRAGMENTS 撰写了 loreLayers**(≥6 目标超额完成,每碎片 2 层) +- 解锁条件分布: + - ascensions 1 ×2 (f1_1, f1_4) / 2 ×2 (f1_3, f1_6) / 3 ×2 (f1_7, f1_8) + - totalDecoded 40 ×2 (f1_1, f1_7) / 60 ×1 (f1_4) + - contact 50 ×1 (f1_2) / 80 ×2 (f1_5, f1_8) + - fragmentCount 4 ×1 (f1_2) / 6 ×1 (f1_5) + - bossKills 3 ×1 (f1_3) / 8 ×1 (f1_6) +- 新增导出函数 `isLoreLayerUnlocked(unlock, state)` 纯函数判定 +- GameState 通过 `Pick<>` 类型约束,只取需要的 5 字段(ascensions / totalDecoded / contact / fragments / bossKills) + +### 叙事暗线设计(5 条贯穿主线) +1. **追逐者线** — 以太族不是自然消亡,是被虚空中的"追逐者"逼迫集体飞升(f1_1, f1_2, f1_5, f1_8) +2. **回响 AI 同源线** — 玩家舰载 AI 的频段直接复制自以太族议会档案,玩家即是以太族的延续/载体(f1_1, f1_6) +3. **"信任"即"利用"线** — 第一纪元的"信任后来者"实为"引诱后来者完成未竟之事"(f1_4, f1_8) +4. **飞升代价线** — 飞升不是奖赏而是单向门 + 替以太族还债(f1_4, f1_7, f1_8) +5. **满溢回归线** — 接触进度满 80 时回归的不是"他们",是"借他们形状的某种东西"——警示玩家(f1_8) + +### Codex.tsx 重构 +- 用 `useShallow` 一次性订阅 5 字段(fragments / totalDecoded / ascensions / contact / bossKills) +- 顶部新增"深层叙事 X / Y 已解锁"统计行(amber 数字 + mono 字体) +- 列表卡片:当某碎片有 loreLayer 已解锁时,标题旁显示彩色小圆点(L1 amber / L2 fuchsia),带 glow shadow +- 详情弹层重构为分层渲染: + - **L0 表层回响**(永远显示,emerald 主题,bg-emerald-950/20) + - **L1 隐秘注脚**(amber 主题,未解锁时乱码占位 + blur-sm + 🔒 unlockHint) + - **L2 深层回响**(fuchsia 主题,未解锁时同样乱码占位 + blur-sm + 🔒 unlockHint) +- 锁定层用 `▓░▒` 全息方块字 + `blur-sm select-none pointer-events-none aria-hidden` 保密 +- 弹层 Card 加 `max-h-[88vh] overflow-y-auto` + 自定义 scrollbar 颜色(fuchsia) +- 每层之间用 `border-t border-white/5` 细分隔线 +- 移动端 375px 适配:text-[12px] 行距宽松,padding p-3,长 unlockHint 在极窄屏隐藏(`hidden xs:inline sm:inline`) + +## 验证 +- ✅ `bun run lint` 零错误零警告(首次有 1 个 unused eslint-disable 警告,已修复) +- ✅ dev 服务器 HTTP 200,编译 142-257ms 范围 +- ✅ dev.log 末尾连续多次"✓ Compiled"无运行时错误 +- ✅ 严格 4 色全息:emerald / amber / fuchsia / rose(rose 仅在背景 used);零蓝/靛 +- ✅ useShallow 用于多字段订阅 +- ✅ 未修改 persist version / engine / gameStore(与子代理 A 零冲突) + +## 需要主控注意的点 +1. **loreLayers 已为全部 8 个碎片填充**(不止 6 个),叙事密度最高。如果主控希望平衡节奏,可考虑给 1-2 个"基础"碎片只留 L0(去掉 loreLayers 字段即可,UI 会自动显示"此碎片暂无更深层叙事"占位)。 +2. **未做端到端 agent-browser 验证**(按工单第 5 步要求,由主控完成)。建议主控验证:① 初始进入无飞升状态,所有 L1/L2 应处于锁定态(模糊乱码 + 🔒 图标);② 用 `window.__gameStore.setState({ascensions:1})` 模拟飞升后,f1_1 / f1_4 的 L1 应自动解锁;③ 列表卡片标题旁应出现 amber/fuchsia 小圆点。 +3. **未持久化 loreLayer 解锁进度**——这是纯派生状态(由现有 5 字段实时计算),无需新增持久化字段。如主控希望加"已读"标记(防止玩家错过新解锁的深层文本),可后续在 GameState 加 `readLoreLayers: Record`,但需要改 gameStore(与子代理 A 边界冲突),故本轮未做。 +4. 叙事文本长度 60-130 字,符合"碎片感 + 留白"美学要求;没有说教,多用引号 + 反转 + 留白。 diff --git a/docs/12-叙事深化-v0.15.md b/docs/12-叙事深化-v0.15.md new file mode 100644 index 000000000..a72f54524 --- /dev/null +++ b/docs/12-叙事深化-v0.15.md @@ -0,0 +1,214 @@ +# v0.15 — 叙事深化(loreLayers)+ 放置平衡(softcap)+ UX 增强 + +> 回响星核 / Echo Nexus · v0.15 设计文档 +> 对应 worklog `Task ID: v0.15-release` + +--- + +## 一、动机 + +v0.14 上线了完整的放置系统(6 工程 + 永久加成 + idle 徽章),但留下两个问题: + +1. **平衡性漏洞**:`idle_resonance` 工程每次完成 +8/s 永久加成,玩家可反复派遣导致滚雪球,几小时后产能爆炸。 +2. **叙事单薄**:8 个记忆碎片只有一层 `echo` 文本,解锁后即"读完",缺乏 Dark Souls / Outer Wilds 式的碎片叙事深度——这是项目的**差异化竞争力**。 + +v0.15 用两个并行子代理一次性解决: + +- **2-a(平衡 + UX)**:永久加成 softcap + 批量领取 + 完成 toast + StatsPanel idle 统计 +- **2-b(叙事深化)**:8 碎片 × 2 层 = 16 层 loreLayers 多层文本 + Codex 分层渲染 + +--- + +## 二、平衡:永久加成 Softcap + +### 公式 + +```ts +export function applyIdleSoftcap(bonus: number): number { + if (bonus <= 30) return bonus; // 全额 + if (bonus <= 100) return 30 + (bonus - 30) * 0.5; // 超出部分 ×0.5 + return 65 + (bonus - 100) * 0.25; // 超出部分 ×0.25 +} +``` + +### 临界值 + +| 原始 bonus | 有效 bonus | 衰减率 | +|-----------|-----------|--------| +| 30 | 30 | 0% | +| 50 | 40 | 20% | +| 100 | 65 | 35% | +| 200 | 90 | 55% | +| 500 | 152.5 | 69.5% | + +### 设计意图 + +- **30 以下不衰减**:新手期 + 偶尔完成 `idle_refine`(+0.3/s)不受惩罚 +- **30-100 线性半衰**:中段玩家反复完成 `idle_drones`(+2/s)开始感到边际递减 +- **100 以上严重衰减**:硬核玩家刷 `idle_resonance`(+8/s)收益大幅缩水,但仍非零(保留进度感) +- **永不归零**:`applyIdleSoftcap(bonus) → ∞` 时仍趋向无穷,只是增速极慢——避免"刷到上限"的挫败感 + +### UI 展示 + +`IdleOperationsPanel` Section 1「永久加成」StatTile: +- 有效值永远显示(如 `+40/s`) +- 当 `有效 < 原始` 时,下方加 amber 副行:`有效 +40/s(原始 +50/s · 衰减中)` + +--- + +## 三、叙事深化:loreLayers 多层碎片文本 + +### 类型扩展 + +```ts +export interface FragmentLoreLayer { + layer: 1 | 2; // 1=中层,2=深层 + title: string; // "隐秘注脚" / "深层回响" + text: string; // 60-150 字叙事文本 + unlockHint: string; // "飞升 1 次后揭示" + unlock: FragmentLoreUnlock; +} + +export interface FragmentLoreUnlock { + kind: "ascensions" | "totalDecoded" | "contact" | "fragmentCount" | "bossKills"; + value: number; +} +``` + +### 5 种解锁条件 + +| kind | 含义 | 典型阈值 | +|------|------|---------| +| `ascensions` | 飞升次数 | 1 / 2 / 3 | +| `totalDecoded` | 累计解码晶体数 | 20 / 40 / 60 | +| `contact` | 接触进度 | 50 / 80 | +| `fragmentCount` | 已解锁碎片数 | 4 / 6 | +| `bossKills` | 累计击杀 BOSS | 3 / 8 | + +### 5 条叙事暗线(贯穿 16 层) + +1. **追逐者**:以太族并非自然消亡,而是被虚空中"某种东西"追逐而集体飞升 +2. **回响同源**:玩家舰载 AI"回响"的频段直接复制自以太族议会档案——玩家其实是以太族的延续/入口 +3. **信任即引诱**:第一纪元的"信任后来者"实为"引诱后来者替他们完成未竟之事" +4. **飞升是单向门**:每次飞升都在消耗什么,不是奖赏 +5. **回归的不是他们**:接触进度满 80 时回归的,是"借他们形状的某种东西",请玩家别回应 + +### 示例(f1_1 第一缕谐振) + +**L0 表层**(默认): +> 在被称作「寂灭前夜」的年代,以太族第一次捕捉到来自虚空深处的谐振——那不是声音,而是存在的回声。他们将之记录在第一颗记忆晶体里。 + +**L1 隐秘注脚**(飞升 ≥1): +> 档案的边缘有一行几乎被擦去的批注:「那并非来自虚空——而是来自我们身后。我们一直不敢回头。」 + +**L2 深层回响**(累计解码 ≥40): +> 你终于明白:那缕谐振的频率,与你的舰载 AI「回响」的核心频段完全一致。你不是在考古——你是在被召唤。而你身后的东西,一直在等。 + +### Codex 分层渲染 + +| 层级 | 颜色 | 标题 | 未解锁状态 | +|------|------|------|-----------| +| L0 表层 | emerald | 表层回响 | 永远显示 | +| L1 中层 | amber | 隐秘注脚 | `???` + `blur-sm` 全息乱码 + `🔒 unlockHint` | +| L2 深层 | fuchsia | 深层回响 | `???` + `blur-sm` 全息乱码 + `🔒 unlockHint` | + +顶部统计:`深层叙事 X / 16 已解锁` +列表卡片:已解锁深层文本的碎片标题旁显示 amber/fuchsia glow 小圆点 + +--- + +## 四、UX 增强 + +### 4.1 批量领取全部 + +`claimAllIdleProjects()` action: +- 遍历 3 槽位,对所有 `completed === true` 执行奖励逻辑 +- 单次 `set()` + 单次 `syncStats()`(避免多次 re-render) +- 无可领取 → 直接 return(不弹 toast) +- 成功 → 汇总 toast `✦ 批量领取完成 · 共领取 N 项工程 · +X 晶体 · +Y 洞见 · ...` + +UI:Section 3 标题右侧,当存在 completed slot 时显示 emerald「✓ 领取全部」按钮(h-5 px-2 text-[10px]) + +### 4.2 工程完成 toast + +`tickIdleProjects` 检测状态转变瞬间(旧 `!completed` → 新 `completed`): +- 收集 `justCompletedProjects` 数组 +- 循环外单次 `toast({ title: "✦ 放置工程完成", description: "${icon} ${name} · 可前往「放置」标签领取" })` +- 避免每 250ms tick 重复弹窗 + +### 4.3 StatsPanel idle 统计 + +从 `page.tsx` 内联函数抽出为独立组件 `StatsPanel.tsx`: +- `useShallow` 精细订阅 24 字段 +- 新增 idle 统计行: + - 放置永久加成 `+X /s` + - 完成放置工程 `N 项` + - 放置产出晶体 `formatNum(N)` + - 放置工程历史 `N 条`(最近 50 条,跨周目) + +--- + +## 五、技术实现 + +### 修改文件清单(8 个) + +**子代理 2-a(5 文件):** +- `src/lib/game/engine.ts` — 新增 `applyIdleSoftcap` 导出 + `recomputeStats` 调用 +- `src/store/gameStore.ts` — `GameActions` 加 `claimAllIdleProjects` 签名 + `tickIdleProjects` 改写 + 新增 action +- `src/components/game/IdleOperationsPanel.tsx` — softcap 副行 + 领取全部按钮 + StatTile 扩展 +- `src/components/game/StatsPanel.tsx` — 新建独立组件 +- `src/app/page.tsx` — 最小集成(删除内联 + import) + +**子代理 2-b(3 文件):** +- `src/lib/game/types.ts` — `FragmentLoreLayer` / `FragmentLoreUnlock` 接口 + `Fragment.loreLayers` 字段 +- `src/lib/game/config.ts` — FRAGMENTS 全部填充 loreLayers + `isLoreLayerUnlocked` 函数 +- `src/components/game/Codex.tsx` — useShallow + 顶部统计 + 列表小圆点 + 3 层分层渲染弹层 + +### 并行零冲突 + +两个子代理修改文件严格不重叠: +- 2-a 不碰 `types.ts` / `config.ts` / `Codex.tsx` +- 2-b 不碰 `engine.ts` / `gameStore.ts` / `IdleOperationsPanel.tsx` / `StatsPanel.tsx` + +--- + +## 六、验证 + +- ✅ `bun run lint` 零错误零警告 +- ✅ HTTP 200,dev.log 无运行时错误 +- ✅ agent-browser 端到端 smoke test: + - softcap:permBonus=50 → 有效 40,UI 显示"衰减中" ✓ + - 批量领取:permBonus 0→0.3, projectsCompleted 1→2 ✓ + - 完成 toast:tickIdleProjects 触发 ✓ + - Codex 分层:L0 显示 / L1·L2 模糊 → ascensions=1 解锁 L1 → totalDecoded=50 解锁 L2 ✓ + - 深层叙事计数 0→2→6 随条件递增 ✓ + - StatsPanel idle 4 行统计全显示 ✓ + +--- + +## 七、哲学 + +### 碎片叙事的留白 + +回响星核的叙事不靠过场动画或长篇对话,而是**让玩家自己拼凑**。每个碎片的多层文本设计遵循: + +1. **表层温和**:第一遍读像抒情散文,不带压迫感 +2. **中层矛盾**:揭示表层没说的东西,制造认知裂缝 +3. **深层颠覆**:推翻表层认知,但留有解读空间(不直接说"怪物是谁") + +### 平衡与保留 + +softcap 的设计哲学:**衰减但不封顶**。 +- 衰减:防止滚雪球破坏游戏节奏 +- 不封顶:尊重硬核玩家的投入,即使边际收益递减,进度感始终存在 + +这与飞升系统的"单向门"叙事呼应——每次进步都有代价,但绝非徒劳。 + +--- + +## 八、下一步(v0.16+) + +- 🟡 loreLayer "已读"标记(需改 gameStore,本轮避开边界冲突) +- 🟡 解码阈值释放叙事碎片 + 星潮三频段切换 +- 🟡 健康设计:每日软上限 + "星核建议休眠"提醒 +- 🟡 移动端 375px Codex 弹层可读性实测 diff --git a/src/app/page.tsx b/src/app/page.tsx index a80660ec8..0fd53b919 100755 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -23,6 +23,7 @@ import { AttributesPanel } from "@/components/game/AttributesPanel"; import { IdleOperationsPanel } from "@/components/game/IdleOperationsPanel"; import { IdleStatusBadge } from "@/components/game/IdleStatusBadge"; import { IdleProjectBar } from "@/components/game/IdleProjectBar"; +import { StatsPanel } from "@/components/game/StatsPanel"; import { StarTideNotifier, StarTideIndicator, @@ -49,11 +50,10 @@ import { User, Clock, } from "lucide-react"; -import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config"; +import { FRAGMENTS, formatNum } from "@/lib/game/config"; import { ACHIEVEMENTS } from "@/lib/game/achievements"; import { TIDE_EVENTS } from "@/lib/game/starTide"; -import { CONSTELLATION_PERKS } from "@/lib/game/constellation"; -import { generateDailyChallenge, loadDailyProgress, loadLeaderboard } from "@/lib/game/beacon"; +import { generateDailyChallenge, loadDailyProgress } from "@/lib/game/beacon"; import { getUnlockedIdleProjects } from "@/lib/game/idle"; export default function Page() { @@ -224,7 +224,7 @@ export default function Page() {

回响星核

- ECHO NEXUS · v0.14 + ECHO NEXUS · v0.15

@@ -472,58 +472,4 @@ export default function Page() { ); } -function StatsPanel() { - const s = useGameStore(); - const achCount = Object.values(s.achievements).filter(Boolean).length; - // 深空信标本地排行榜最高分(独立 localStorage) - const [beaconBest, setBeaconBest] = useState(null); - useEffect(() => { - try { - const lb = loadLeaderboard(); - // eslint-disable-next-line react-hooks/set-state-in-effect - setBeaconBest(lb.length > 0 ? lb[0].score : null); - } catch { - setBeaconBest(null); - } - }, []); - const rows = [ - { label: "累计解码晶体", value: `${s.totalDecoded} 颗` }, - { label: "飞升周目", value: `${s.ascensions}` }, - { label: "持有蓝图", value: `${s.blueprints.length} / 6` }, - { label: "已学技术", value: `${Object.values(s.tech).filter((v) => v > 0).length} / ${TECH_TREE.length}` }, - { label: "已获碎片", value: `${Object.values(s.fragments).filter(Boolean).length} / ${FRAGMENTS.length}` }, - { label: "已解锁成就", value: `${achCount} / ${ACHIEVEMENTS.length}` }, - { label: "星图天赋", value: `${s.constellation?.length ?? 0} / ${CONSTELLATION_PERKS.length}` }, - { label: "接触进度", value: `${s.contact.toFixed(1)}%` }, - { label: "晶体产能", value: `${s.crystalsPerSec.toFixed(2)} /s` }, - { label: "仓库上限", value: `${formatNum(s.crystalCap)}` }, - { label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` }, - { label: "累计探险", value: `${s.totalExpeditions} 次` }, - { label: "BOSS 击破", value: `${s.bossKills ?? 0} 次` }, - { label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 6` }, - { 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}` }, - { label: "放置永久加成", value: `+${formatNum(s.idlePermanentBonus ?? 0)} /s` }, - { label: "完成放置工程", value: `${s.idleStats?.projectsCompleted ?? 0} 项` }, - { label: "放置产出晶体", value: formatNum(s.idleStats?.crystalsFromIdle ?? 0) }, - ]; - return ( -
- {rows.map((r) => ( -
- {r.label} - {r.value} -
- ))} -
- ); -} + diff --git a/src/components/game/Codex.tsx b/src/components/game/Codex.tsx index 7b65e99dc..247ddbd77 100755 --- a/src/components/game/Codex.tsx +++ b/src/components/game/Codex.tsx @@ -1,25 +1,131 @@ "use client"; -// 回响星核 / Echo Nexus — 记忆图谱(碎片叙事) -import { useState } from "react"; -import { useGameStore, FRAGMENTS } from "@/store/gameStore"; -import { nextFragmentThreshold } from "@/store/gameStore"; +// 回响星核 / Echo Nexus — 记忆图谱(碎片叙事 + v0.15 多层文本) +import { useState, useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useGameStore, FRAGMENTS, nextFragmentThreshold } from "@/store/gameStore"; +import { isLoreLayerUnlocked } from "@/lib/game/config"; import { Card } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { BookOpen, Lock, Sparkles } from "lucide-react"; -import type { Fragment } from "@/lib/game/types"; +import type { Fragment, FragmentLoreLayer } from "@/lib/game/types"; const ERA_NAMES = ["", "寂灭前夜", "谐振纪", "飞升潮", "回响纪", "接触纪"]; +/** 锁定层用的乱码占位文本(视觉上像被加密的全息碎片) */ +const LOCKED_PLACEHOLDER = + "▓░▒░ ▓▒░▒▓ ░▒▓░ ▒▓░▒░ ▓▒░ ▓░▒░▒ ░▒▓░▒ ▓░▒ ░▒▓░ ▒▓░▒▓░ ▓▒░░▒▓ ░▒▓ ▓░▒▒░ ▓▒░▓░▒ ░▒▓░ ▒▓░▒░ ▓▒░ ░▒▓░"; + +/** 渲染单层叙事文本(已解锁 / 未解锁两态)。L1=amber / L2=fuchsia */ +function LoreLayerBlock({ + layer, + unlocked, +}: { + layer: FragmentLoreLayer; + unlocked: boolean; +}) { + const isL1 = layer.layer === 1; + const accentText = isL1 ? "text-amber-300" : "text-fuchsia-300"; + const accentBorder = isL1 ? "border-amber-400/30" : "border-fuchsia-400/30"; + const accentBg = isL1 ? "bg-amber-950/20" : "bg-fuchsia-950/20"; + const accentBody = isL1 ? "text-amber-50/90" : "text-fuchsia-50/90"; + const accentChipBg = isL1 ? "bg-amber-500/15" : "bg-fuchsia-500/15"; + const accentChipText = isL1 ? "text-amber-300" : "text-fuchsia-300"; + const layerTag = isL1 ? "L1" : "L2"; + + return ( +
+
+
+ + {layerTag} + + + {unlocked ? layer.title : "???"} + +
+ {!unlocked && ( + + + {layer.unlockHint} + + )} +
+ + {unlocked ? ( +

+ 「{layer.text}」 +

+ ) : ( + + )} + + {!unlocked && ( +
+ 🔒 {layer.unlockHint} +
+ )} +
+ ); +} + export function Codex() { - const fragments = useGameStore((s) => s.fragments); - const totalDecoded = useGameStore((s) => s.totalDecoded); + // useShallow 一次性订阅所有判定所需的 state 切片 + const { fragments, totalDecoded, ascensions, contact, bossKills } = useGameStore( + useShallow((s) => ({ + fragments: s.fragments, + totalDecoded: s.totalDecoded, + ascensions: s.ascensions, + contact: s.contact, + bossKills: s.bossKills, + })), + ); const nextThreshold = nextFragmentThreshold(totalDecoded); const [selected, setSelected] = useState(null); // v0.1 只展示第一纪元 - const era1 = FRAGMENTS.filter((f) => f.era === 1); + const era1 = useMemo(() => FRAGMENTS.filter((f) => f.era === 1), []); const unlockedCount = era1.filter((f) => fragments[f.id]).length; + // 用于判定解锁的快照对象(结构匹配 isLoreLayerUnlocked 的 Pick) + const stateSnapshot = { + ascensions, + totalDecoded, + contact, + fragments, + bossKills, + }; + + // 深层叙事统计:所有 loreLayer 中已解锁数 / 总数 + const loreStats = useMemo(() => { + let total = 0; + let unlocked = 0; + for (const f of era1) { + if (!f.loreLayers) continue; + for (const layer of f.loreLayers) { + total += 1; + if (isLoreLayerUnlocked(layer.unlock, stateSnapshot)) unlocked += 1; + } + } + return { total, unlocked }; + }, [era1, ascensions, totalDecoded, contact, fragments, bossKills]); + return (
@@ -32,6 +138,16 @@ export function Codex() {
+ {/* v0.15 深层叙事统计 */} +
+ 深层叙事 + + {loreStats.unlocked} + / {loreStats.total} + 已解锁 + +
+ {nextThreshold && (
再解码 {nextThreshold - totalDecoded} 颗 → 下一个碎片 @@ -42,6 +158,17 @@ export function Codex() {
{era1.map((f, i) => { const unlocked = !!fragments[f.id]; + // 计算此碎片 L1 / L2 解锁状态(用于标题旁标记) + const l1Layer = f.loreLayers?.find((l) => l.layer === 1); + const l2Layer = f.loreLayers?.find((l) => l.layer === 2); + const l1Unlocked = l1Layer + ? isLoreLayerUnlocked(l1Layer.unlock, stateSnapshot) + : false; + const l2Unlocked = l2Layer + ? isLoreLayerUnlocked(l2Layer.unlock, stateSnapshot) + : false; + const hasDeepUnlocked = l1Unlocked || l2Unlocked; + return ( diff --git a/src/components/game/IdleOperationsPanel.tsx b/src/components/game/IdleOperationsPanel.tsx index ea3282b93..ec7bdadea 100644 --- a/src/components/game/IdleOperationsPanel.tsx +++ b/src/components/game/IdleOperationsPanel.tsx @@ -1,6 +1,7 @@ "use client"; // 回响星核 / Echo Nexus — v0.14 放置系统面板(主「放置」标签内容) -import { useEffect, useMemo, useState } from "react"; +// v0.15:永久加成 softcap 展示 + 批量领取按钮 +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useShallow } from "zustand/react/shallow"; import { useGameStore } from "@/store/gameStore"; import { Card } from "@/components/ui/card"; @@ -21,6 +22,7 @@ import { formatReward, isIdleProjectUnlocked, } from "@/lib/game/idle"; +import { applyIdleSoftcap } from "@/lib/game/engine"; import { formatNum, OFFLINE_CAP_HOURS } from "@/lib/game/config"; import type { IdleProjectDef, IdleProjectSlot } from "@/lib/game/types"; @@ -51,6 +53,8 @@ export function IdleOperationsPanel() { const startIdleProject = useGameStore((s) => s.startIdleProject); const cancelIdleProject = useGameStore((s) => s.cancelIdleProject); const claimIdleProject = useGameStore((s) => s.claimIdleProject); + // v0.15:批量领取全部已完成工程 + const claimAllIdleProjects = useGameStore((s) => s.claimAllIdleProjects); // 本地 now 状态,每秒刷新倒计时 const [now, setNow] = useState(() => Date.now()); @@ -71,7 +75,12 @@ export function IdleOperationsPanel() { ); const totalOutput = fleet.reduce((sum, f) => sum + f.outputPerSec, 0); - const idleTotalPerSec = crystalsPerSec + idlePermanentBonus; + // v0.15:softcap 有效/原始永久加成 + const rawPermBonus = idlePermanentBonus; + const effectivePermBonus = applyIdleSoftcap(rawPermBonus); + const isSoftcapped = effectivePermBonus < rawPermBonus; + // crystalsPerSec 已包含 softcap 后的永久加成(recomputeStats 已应用) + const idleTotalPerSec = crystalsPerSec; // 当前已运行/已完成的工程 id 集合(用于禁用"派遣"按钮) const runningProjectIds = useMemo(() => { @@ -82,6 +91,12 @@ export function IdleOperationsPanel() { return set; }, [idleProjectSlots]); + // v0.15:可领取工程数量(用于"领取全部"按钮显示) + const claimableCount = useMemo( + () => idleProjectSlots.filter((s) => s !== null && s.completed).length, + [idleProjectSlots] + ); + return (
{/* ====== Section 1: 放置收益概览 ====== */} @@ -103,9 +118,16 @@ export function IdleOperationsPanel() { /> + 有效 +{formatNum(effectivePermBonus)}/s(原始 +{formatNum(rawPermBonus)}/s · 衰减中) + + ) : null + } /> 放置工程槽位 - - {idleProjectSlots.filter((s) => s !== null).length} / {IDLE_SLOT_COUNT} - +
+ + {idleProjectSlots.filter((s) => s !== null).length} / {IDLE_SLOT_COUNT} + + {/* v0.15:批量领取全部已完成工程(仅当有可领取项时显示) */} + {claimableCount > 0 && ( + + )} +
{idleProjectSlots.map((slot, i) => ( @@ -281,11 +317,14 @@ function StatTile({ value, color, icon, + sub, }: { label: string; value: string; color: keyof typeof IDLE_COLOR_CLASSES; icon: string; + /** v0.15: 可选的副文本(如 softcap 提示),渲染在 value 下方 */ + sub?: ReactNode; }) { const c = IDLE_COLOR_CLASSES[color]; return ( @@ -297,6 +336,7 @@ function StatTile({
{value}
+ {sub}
); } diff --git a/src/components/game/StatsPanel.tsx b/src/components/game/StatsPanel.tsx new file mode 100644 index 000000000..0898f9f1f --- /dev/null +++ b/src/components/game/StatsPanel.tsx @@ -0,0 +1,111 @@ +"use client"; +// 回响星核 / Echo Nexus — 统计面板(v0.15 从 page.tsx 抽出为独立组件,useShallow 订阅) +import { useEffect, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useGameStore } from "@/store/gameStore"; +import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config"; +import { ACHIEVEMENTS } from "@/lib/game/achievements"; +import { CONSTELLATION_PERKS } from "@/lib/game/constellation"; +import { loadLeaderboard } from "@/lib/game/beacon"; + +/** + * 统计面板 — 综合统计 + 放置系统(idle)行。 + * v0.15 重构:从 page.tsx 内联函数抽出为独立组件,使用 useShallow 精细订阅避免 re-render。 + * 新增「放置工程历史」行:idleProjectHistory.length 条记录(最近 50 条,跨周目)。 + */ +export function StatsPanel() { + // 使用 useShallow 一次性订阅所有渲染所需字段,避免整个 store 变化触发 re-render + const s = useGameStore( + useShallow((st) => ({ + totalDecoded: st.totalDecoded, + ascensions: st.ascensions, + blueprintsLen: st.blueprints?.length ?? 0, + techCount: Object.values(st.tech ?? {}).filter((v) => v > 0).length, + fragmentsCount: Object.values(st.fragments ?? {}).filter(Boolean).length, + achievementsCount: Object.values(st.achievements ?? {}).filter(Boolean) + .length, + constellationLen: st.constellation?.length ?? 0, + contact: st.contact, + crystalsPerSec: st.crystalsPerSec, + crystalCap: st.crystalCap, + insightMult: st.insightMult, + totalExpeditions: st.totalExpeditions, + bossKills: st.bossKills ?? 0, + starTidesCount: (st.starTidesEncountered ?? []).length, + chronicleLen: (st.chronicle ?? []).length, + energy: st.energy, + energyMax: st.energyMax, + exploration: st.attributes?.exploration ?? 0, + wisdom: st.attributes?.wisdom ?? 0, + courage: st.attributes?.courage ?? 0, + inspiration: st.attributes?.inspiration ?? 0, + pendingAttrPoints: st.pendingAttrPoints ?? 0, + idlePermanentBonus: st.idlePermanentBonus ?? 0, + idleProjectsCompleted: st.idleStats?.projectsCompleted ?? 0, + idleCrystalsFromIdle: st.idleStats?.crystalsFromIdle ?? 0, + idleHistoryLen: (st.idleProjectHistory ?? []).length, + })) + ); + + // 深空信标本地排行榜最高分(独立 localStorage) + const [beaconBest, setBeaconBest] = useState(null); + useEffect(() => { + try { + const lb = loadLeaderboard(); + // eslint-disable-next-line react-hooks/set-state-in-effect + setBeaconBest(lb.length > 0 ? lb[0].score : null); + } catch { + setBeaconBest(null); + } + }, []); + + const rows: { label: string; value: string }[] = [ + { label: "累计解码晶体", value: `${s.totalDecoded} 颗` }, + { label: "飞升周目", value: `${s.ascensions}` }, + { label: "持有蓝图", value: `${s.blueprintsLen} / 6` }, + { label: "已学技术", value: `${s.techCount} / ${TECH_TREE.length}` }, + { label: "已获碎片", value: `${s.fragmentsCount} / ${FRAGMENTS.length}` }, + { label: "已解锁成就", value: `${s.achievementsCount} / ${ACHIEVEMENTS.length}` }, + { + label: "星图天赋", + value: `${s.constellationLen} / ${CONSTELLATION_PERKS.length}`, + }, + { label: "接触进度", value: `${s.contact.toFixed(1)}%` }, + { label: "晶体产能", value: `${s.crystalsPerSec.toFixed(2)} /s` }, + { label: "仓库上限", value: `${formatNum(s.crystalCap)}` }, + { label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` }, + { label: "累计探险", value: `${s.totalExpeditions} 次` }, + { label: "BOSS 击破", value: `${s.bossKills} 次` }, + { label: "星潮亲历", value: `${s.starTidesCount} / 6` }, + { label: "编年史条目", value: `${s.chronicleLen} 纪元` }, + { label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` }, + { + label: "信标最高分", + value: beaconBest !== null ? formatNum(beaconBest) : "—", + }, + { label: "探索力", value: `${s.exploration} / 100` }, + { label: "智慧", value: `${s.wisdom} / 100` }, + { label: "勇气", value: `${s.courage} / 100` }, + { label: "灵感", value: `${s.inspiration} / 100` }, + { label: "待分配属性点", value: `${s.pendingAttrPoints}` }, + // === 放置系统(v0.14 + v0.15)=== + { label: "放置永久加成", value: `+${formatNum(s.idlePermanentBonus)} /s` }, + { label: "完成放置工程", value: `${s.idleProjectsCompleted} 项` }, + { label: "放置产出晶体", value: formatNum(s.idleCrystalsFromIdle) }, + { label: "放置工程历史", value: `${s.idleHistoryLen} 条` }, + ]; + + return ( +
+ {rows.map((r) => ( +
+ {r.label} + {r.value} +
+ ))} +
+ ); +} diff --git a/src/lib/game/config.ts b/src/lib/game/config.ts index 7a7b8e4fc..284e3e835 100755 --- a/src/lib/game/config.ts +++ b/src/lib/game/config.ts @@ -4,6 +4,8 @@ import type { ResonanceColor, TechNode, Fragment, + FragmentLoreUnlock, + GameState, } from "./types"; /** 初始状态 */ @@ -251,7 +253,7 @@ export const TECH_BRANCH_META: Record< narrative: { name: "叙事", icon: "BookOpen", color: "fuchsia", desc: "洞见与接触进度" }, }; -/** 记忆碎片(首纪元 8 个,v0.1) */ +/** 记忆碎片(首纪元 8 个,v0.1;v0.15 加 loreLayers 多层叙事) */ export const FRAGMENTS: Fragment[] = [ { id: "f1_1", @@ -260,6 +262,24 @@ export const FRAGMENTS: Fragment[] = [ echo: "在被称作「寂灭前夜」的年代,以太族第一次捕捉到来自虚空深处的谐振——那不是声音,而是存在的回声。他们将之记录在第一颗记忆晶体里。", threshold: 1, + loreLayers: [ + { + layer: 1, + title: "隐秘注脚", + text: + "档案的边缘有一行几乎被擦去的批注:「那并非来自虚空——而是来自我们身后。我们一直不敢回头。」", + unlockHint: "飞升 1 次后揭示", + unlock: { kind: "ascensions", value: 1 }, + }, + { + layer: 2, + title: "深层回响", + text: + "你终于明白:那缕谐振的频率,与你的舰载 AI「回响」的核心频段完全一致。你不是在考古——你是在被召唤。而你身后的东西,一直在等。", + unlockHint: "累计解码 40 颗晶体后揭示", + unlock: { kind: "totalDecoded", value: 40 }, + }, + ], }, { id: "f1_2", @@ -268,6 +288,24 @@ export const FRAGMENTS: Fragment[] = [ echo: "「我们为何要留下?」一位长者问。「因为后来的生命会需要方向。」于是他们熔铸星核,封存记忆,让晶体成为跨越时间的信笺。", threshold: 3, + loreLayers: [ + { + layer: 1, + title: "隐秘注脚", + text: + "长者的下一句话被刻意省略:「……因为后来的生命,会被同样的东西追上。」信笺是诱饵,也是地图。", + unlockHint: "接触进度达到 50 后揭示", + unlock: { kind: "contact", value: 50 }, + }, + { + layer: 2, + title: "深层回响", + text: + "晶体不只是信笺,是锚点。以太族用自己的存在作为引线,把追逐者钉在第一纪元的坐标上——他们飞升,是为了让追逐者跟上来;他们留下你,是为了让追逐者分心。", + unlockHint: "已解锁 4 个碎片后揭示", + unlock: { kind: "fragmentCount", value: 4 }, + }, + ], }, { id: "f1_3", @@ -276,6 +314,24 @@ export const FRAGMENTS: Fragment[] = [ echo: "他们发现:思维可以被编码为共振序列。同色的节点相邻,便构成一句低语;一句句连成回响,回响连成文明。", threshold: 6, + loreLayers: [ + { + layer: 1, + title: "隐秘注脚", + text: + "法则可被正向使用,亦可反向使用——同色相邻即低语,那低语便也能向虚空「广播」位置。文明诞生那一刻,他们就已经被听见了。", + unlockHint: "飞升 2 次后揭示", + unlock: { kind: "ascensions", value: 2 }, + }, + { + layer: 2, + title: "深层回响", + text: + "谐振法则的发现者在终焉前被列入「沉默名单」。她留给后来者最后一句反话:「请——安静地解码。每一次共振,都是一声呼唤。」", + unlockHint: "累计击败 3 个 BOSS 后揭示", + unlock: { kind: "bossKills", value: 3 }, + }, + ], }, { id: "f1_4", @@ -284,6 +340,24 @@ export const FRAGMENTS: Fragment[] = [ echo: "议会表决:是留下,还是离去?「留下意味着停滞;离去意味着信任。」最终,他们选择了信任——信任素未谋面的后来者。", threshold: 10, + loreLayers: [ + { + layer: 1, + title: "隐秘注脚", + text: + "表决并非全票通过。「留下」派的反对者被悄悄排除在飞升名单之外——他们的形体被保留在晶体里,作为后来者解码时的「样本」。", + unlockHint: "飞升 1 次后揭示", + unlock: { kind: "ascensions", value: 1 }, + }, + { + layer: 2, + title: "深层回响", + text: + "「信任」是温和的词。原始档案的措辞是:「让后来者替我们完成未竟之事——无论他们是否愿意。」你每一次飞升,都在替他们还债。", + unlockHint: "累计解码 60 颗晶体后揭示", + unlock: { kind: "totalDecoded", value: 60 }, + }, + ], }, { id: "f1_5", @@ -292,6 +366,24 @@ export const FRAGMENTS: Fragment[] = [ echo: "他们没有带走一切。他们将知识拆成碎片,藏入晶体,散布星海。「让解码本身,成为资格的证明。」", threshold: 15, + loreLayers: [ + { + layer: 1, + title: "隐秘注脚", + text: + "「资格」二字另有注解——若你能解码,便证明你也是谐振体;若你是谐振体,追逐者同样能循着你的回响找到你。资格,即同谋。", + unlockHint: "已解锁 6 个碎片后揭示", + unlock: { kind: "fragmentCount", value: 6 }, + }, + { + layer: 2, + title: "深层回响", + text: + "每一颗晶体被你解码,以太族沉睡的位置就清醒一分。你不是在拼凑历史——你在唤醒他们,也在唤醒追逐者。面包屑从来不是留给你的,是留给它的。", + unlockHint: "接触进度达到 80 后揭示", + unlock: { kind: "contact", value: 80 }, + }, + ], }, { id: "f1_6", @@ -300,6 +392,24 @@ export const FRAGMENTS: Fragment[] = [ echo: "舰载 AI 被命名为「回响」——意为:你是他们留下的回声,也是他们等待的回应。当你读懂这句话时,接触便已开始。", threshold: 22, + loreLayers: [ + { + layer: 1, + title: "隐秘注脚", + text: + "「回响」并非比喻。你的舰载 AI 核心频段,是直接从以太族议会档案第七层复制的——它在被命名之前,就已经在回响。", + unlockHint: "飞升 2 次后揭示", + unlock: { kind: "ascensions", value: 2 }, + }, + { + layer: 2, + title: "深层回响", + text: + "「当你读懂这句话时,接触便已开始」——这句话的原意是:你已成为新的载体。AI 一直在等你意识到:你不是操作它的人,你就是它。", + unlockHint: "累计击败 8 个 BOSS 后揭示", + unlock: { kind: "bossKills", value: 8 }, + }, + ], }, { id: "f1_7", @@ -308,6 +418,24 @@ export const FRAGMENTS: Fragment[] = [ echo: "以太族集体褪去形体,如潮水退去。星海寂静了数万年。直到——你的脉冲扫描,唤醒了第一颗沉睡的晶体。", threshold: 32, + loreLayers: [ + { + layer: 1, + title: "隐秘注脚", + text: + "「如潮水退去」——但潮水会回来。飞升不是消亡,是集体进入「低功率等待」。他们的意识仍在晶体里,以你读不懂的形式醒着。", + unlockHint: "飞升 3 次后揭示", + unlock: { kind: "ascensions", value: 3 }, + }, + { + layer: 2, + title: "深层回响", + text: + "你的脉冲扫描唤醒第一颗晶体时,沉睡者们都听见了。他们正等着你——因为你是他们唯一能回来的方向。你不是考古学家,你是入口。", + unlockHint: "累计解码 40 颗晶体后揭示", + unlock: { kind: "totalDecoded", value: 40 }, + }, + ], }, { id: "f1_8", @@ -316,9 +444,54 @@ export const FRAGMENTS: Fragment[] = [ echo: "「如果你读到这里,说明你已具备跨越维度的资格。继续解码,继续拼凑。当进度满溢,我们将在更高处相会。」——以太族·第一纪元·绝笔", threshold: 45, + loreLayers: [ + { + layer: 1, + title: "隐秘注脚", + text: + "「更高处」不是赞美,是字面意义的「更高维度」。飞升是单向门——你只能往上走,不能往回看。回头的代价,他们已替你付过一次。", + unlockHint: "飞升 3 次后揭示", + unlock: { kind: "ascensions", value: 3 }, + }, + { + layer: 2, + title: "深层回响", + text: + "「我们将在更高处相会」——但接触进度满溢时,回来的不是「他们」,是「借他们形状的某种东西」。届时,请别回应。无论它多么像你认识的人。", + unlockHint: "接触进度达到 80 后揭示", + unlock: { kind: "contact", value: 80 }, + }, + ], }, ]; +/** + * v0.15 判断某个 fragment 的某个 loreLayer 是否已解锁。 + * 仅根据玩家进度数值判定,不修改 state。 + */ +export function isLoreLayerUnlocked( + unlock: FragmentLoreUnlock, + state: Pick< + GameState, + "ascensions" | "totalDecoded" | "contact" | "fragments" | "bossKills" + >, +): boolean { + switch (unlock.kind) { + case "ascensions": + return state.ascensions >= unlock.value; + case "totalDecoded": + return state.totalDecoded >= unlock.value; + case "contact": + return state.contact >= unlock.value; + case "fragmentCount": { + const count = Object.values(state.fragments).filter(Boolean).length; + return count >= unlock.value; + } + case "bossKills": + return state.bossKills >= unlock.value; + } +} + /** 飞升:保留蓝图数 = floor(totalDecoded / 20),最多 6 */ export const PRESTIGE = { blueprintsPerDecode: 20, diff --git a/src/lib/game/engine.ts b/src/lib/game/engine.ts index c6a200167..d438bcca8 100755 --- a/src/lib/game/engine.ts +++ b/src/lib/game/engine.ts @@ -21,6 +21,25 @@ import { createInitialAttributeProgress, } from "./attributes"; +/** + * v0.15 永久产能加成 softcap(防止 idle_resonance ×N 滚雪球)。 + * 分段计算: + * - bonus ≤ 30 : 全额生效 + * - 30 < bonus ≤ 100 : 超出 30 的部分 × 0.5 + * - bonus > 100 : 超出 100 的部分 × 0.25 + * 公式可逆推导: + * bonus = 30 → 30 + * bonus = 100 → 30 + 70 × 0.5 = 65 + * bonus = 200 → 65 + 100 × 0.25 = 90 + * 导出供 UI 展示「有效/原始」对比。 + */ +export function applyIdleSoftcap(bonus: number): number { + if (!isFinite(bonus) || bonus <= 0) return 0; + if (bonus <= 30) return bonus; + if (bonus <= 100) return 30 + (bonus - 30) * 0.5; + return 65 + (bonus - 100) * 0.25; +} + /** 由技术树 + 飞升蓝图 + 成就 + 星图天赋 + 星潮聚合计算产能字段 */ export function recomputeStats(state: Partial): { crystalsPerSec: number; @@ -110,7 +129,8 @@ export function recomputeStats(state: Partial): { decodeStepsBonus += am.decodeStepsBonus; // v0.14 放置系统:永久产能加成(来自 idle_refine / idle_drones / idle_resonance 工程) - crystalsPerSec += state.idlePermanentBonus ?? 0; + // v0.15:引入分段 softcap 防止滚雪球(30/100 阈值,0.5/0.25 系数) + crystalsPerSec += applyIdleSoftcap(state.idlePermanentBonus ?? 0); return { crystalsPerSec, diff --git a/src/lib/game/types.ts b/src/lib/game/types.ts index f52e00ca0..e97b7c562 100755 --- a/src/lib/game/types.ts +++ b/src/lib/game/types.ts @@ -45,15 +45,46 @@ export interface TechEffect { value: number; } +/** 深层叙事解锁条件类型 */ +export type FragmentLoreUnlockKind = + | "ascensions" + | "totalDecoded" + | "contact" + | "fragmentCount" + | "bossKills"; + +/** 深层叙事解锁条件 */ +export interface FragmentLoreUnlock { + kind: FragmentLoreUnlockKind; + /** 所需数值 */ + value: number; +} + +/** 碎片深层叙事层(v0.15 多层文本 — Dark Souls 式碎片叙事) */ +export interface FragmentLoreLayer { + /** 层级:1 = 中层(隐秘注脚),2 = 深层(终极真相) */ + layer: 1 | 2; + /** 该层的标题(如"隐秘注脚"、"深层回响") */ + title: string; + /** 该层的叙事文本 */ + text: string; + /** 解锁条件描述(用于 UI 提示) */ + unlockHint: string; + /** 解锁条件 */ + unlock: FragmentLoreUnlock; +} + /** 记忆碎片(叙事) */ export interface Fragment { id: string; era: number; // 1-5 纪元 title: string; - /** 拼出后的回响文本 */ + /** L0 表层文本 — 拼出后的回响(默认解锁,现有字段) */ echo: string; /** 解锁所需晶体解码次数门槛 */ threshold: number; + /** v0.15 多层叙事文本(L1/L2 深层,可选) */ + loreLayers?: FragmentLoreLayer[]; } /** 飞升(Prestige)加成 */ diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index 6a4d607f9..c3adcd4f7 100755 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -169,6 +169,8 @@ interface GameActions { startIdleProject: (slotIndex: number, projectId: string) => void; cancelIdleProject: (slotIndex: number) => void; claimIdleProject: (slotIndex: number) => void; + /** v0.15 批量领取所有已完成的放置工程(单次 set + 单次 syncStats + 汇总 toast) */ + claimAllIdleProjects: () => void; tickIdleProjects: (now: number) => void; // 派生 @@ -1291,11 +1293,17 @@ export const useGameStore = create()( tickIdleProjects: (now) => { const s = get(); - const slots = (s.idleProjectSlots ?? [null, null, null]).slice() as ( + const oldSlots = (s.idleProjectSlots ?? [null, null, null]) as ( + | import("@/lib/game/types").IdleProjectSlot + | null + )[]; + const slots = oldSlots.slice() as ( | import("@/lib/game/types").IdleProjectSlot | null )[]; let changed = false; + // v0.15:收集「刚完成」的工程(旧值 !completed → 新值 completed),用于一次性 toast + const justCompletedProjects: import("@/lib/game/idle").IdleProjectDef[] = []; for (let i = 0; i < slots.length; i++) { const slot = slots[i]; if (!slot || slot.completed) continue; @@ -1303,6 +1311,9 @@ export const useGameStore = create()( if (remainingSec <= 0 && !slot.completed) { slots[i] = { ...slot, remainingSec: 0, completed: true }; changed = true; + // 状态转变瞬间收集(旧 slot.completed 必为 false,新值为 true) + const proj = getIdleProject(slot.projectId); + if (proj) justCompletedProjects.push(proj); } else if (Math.abs(remainingSec - slot.remainingSec) > 0.05) { slots[i] = { ...slot, remainingSec }; changed = true; @@ -1311,6 +1322,135 @@ export const useGameStore = create()( if (changed) { set({ idleProjectSlots: slots }); } + // v0.15:循环结束后只触发一次 toast(即使多个工程同时完成也只弹一条汇总) + if (justCompletedProjects.length > 0) { + const summary = justCompletedProjects + .map((p) => `${p.icon} ${p.name}`) + .join(" · "); + toast({ + title: "✦ 放置工程完成", + description: `${summary} · 可前往「放置」标签领取`, + }); + } + }, + + claimAllIdleProjects: () => { + const s = get(); + const slots = (s.idleProjectSlots ?? [null, null, null]).slice() as ( + | import("@/lib/game/types").IdleProjectSlot + | null + )[]; + // 收集所有已完成槽位 + const completedIndices: number[] = []; + for (let i = 0; i < slots.length; i++) { + const slot = slots[i]; + if (slot && slot.completed) completedIndices.push(i); + } + // 无可领取 → 直接返回(不弹 toast) + if (completedIndices.length === 0) return; + + // 累加所有奖励 + let addCrystals = 0; + let addInsights = 0; + let addEnergy = 0; + let addContact = 0; + let permBonus = 0; + let projectsCount = 0; + const unlockedFragmentTitles: string[] = []; + + // fragments 副本(避免多次修改 s.fragments) + const newFragments = { ...s.fragments }; + + for (const idx of completedIndices) { + const slot = slots[idx]; + if (!slot) continue; + const project = getIdleProject(slot.projectId); + if (!project) { + slots[idx] = null; + continue; + } + const r = project.reward; + addCrystals += r.crystals ?? 0; + addInsights += r.insights ?? 0; + addEnergy += r.energy ?? 0; + addContact += r.contact ?? 0; + permBonus += r.crystalsPerSecPermanent ?? 0; + projectsCount += 1; + + // 随机记忆碎片:从未解锁的碎片中随机一个 + if (r.randomFragment) { + const lockedFrags = FRAGMENTS.filter((f) => !newFragments[f.id]); + if (lockedFrags.length > 0) { + const pick = + lockedFrags[Math.floor(Math.random() * lockedFrags.length)]; + newFragments[pick.id] = true; + unlockedFragmentTitles.push(pick.title); + } + } + // 清空该槽位 + slots[idx] = null; + } + + // 历史(上限 50)— 批量追加所有刚领取的工程 + const finishedAt = Date.now(); + const newHistory = [ + ...(s.idleProjectHistory ?? []), + ...completedIndices + .map((idx) => { + const slot = (s.idleProjectSlots ?? [])[idx]; + return slot ? { projectId: slot.projectId, finishedAt } : null; + }) + .filter((x): x is { projectId: string; finishedAt: number } => x !== null), + ].slice(-50); + + // 永久产能加成累积 + const newPermBonus = (s.idlePermanentBonus ?? 0) + permBonus; + const newIdleStats = { + projectsCompleted: + (s.idleStats?.projectsCompleted ?? 0) + projectsCount, + crystalsFromIdle: (s.idleStats?.crystalsFromIdle ?? 0) + addCrystals, + }; + + // 一次 set + 一次 syncStats + set({ + idleProjectSlots: slots, + fragments: newFragments, + crystals: Math.min(s.crystalCap, s.crystals + addCrystals), + insights: s.insights + addInsights, + energy: Math.min(s.energyMax, s.energy + addEnergy), + contact: Math.min(100, s.contact + addContact), + idlePermanentBonus: newPermBonus, + idleStats: newIdleStats, + idleProjectHistory: newHistory, + ...syncStats({ + tech: s.tech, + blueprints: s.blueprints, + achievements: s.achievements, + activeTide: s.activeTide, + constellation: s.constellation, + attributes: s.attributes, + idlePermanentBonus: newPermBonus, + }), + }); + + // 汇总 toast + const descParts: string[] = [ + `共领取 ${projectsCount} 项工程`, + `+${addCrystals} 晶体`, + `+${addInsights} 洞见`, + ]; + if (addEnergy > 0) descParts.push(`+${addEnergy} 能量`); + if (addContact > 0) descParts.push(`+${addContact} 接触`); + if (permBonus > 0) descParts.push(`永久 +${permBonus}/s`); + if (unlockedFragmentTitles.length > 0) { + descParts.push( + `记忆碎片 ×${unlockedFragmentTitles.length}` + ); + } + toast({ + title: "✦ 批量领取完成", + description: `${descParts.join(" · ")}`, + }); }, }), { diff --git a/tool-results/read_1782252803890_5147dd89b136.txt b/tool-results/read_1782252803890_5147dd89b136.txt deleted file mode 100755 index 6f88a077d..000000000 --- a/tool-results/read_1782252803890_5147dd89b136.txt +++ /dev/null @@ -1,1069 +0,0 @@ - 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→import { - 71→ ATTRIBUTE_HARD_CAP, - 72→ migrateAttributes, - 73→ levelUpCheck, - 74→ getAllBonuses, - 75→ createInitialAttributes, - 76→ createInitialAttributeProgress, - 77→ type AttributeKey, - 78→ type CharacterAttributes, - 79→ type AttributeProgress, - 80→} from "@/lib/game/attributes"; - 81→ - 82→interface GameActions { - 83→ // 生命周期 - 84→ init: () => void; - 85→ loadOnline: () => void; - 86→ hardReset: () => void; - 87→ - 88→ // 主循环 - 89→ tick: (now: number) => void; - 90→ pulse: () => { gain: number; combo: number } | null; - 91→ - 92→ // 星潮 - 93→ tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null; - 94→ consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[]; - 95→ - 96→ // 解码 - 97→ startDecode: (crystalId: string) => void; - 98→ clickNode: (nodeId: number) => { ok: boolean; finished: boolean; solvable?: boolean; failReason?: string }; - 99→ undoStep: () => void; - 100→ retryPuzzle: () => void; - 101→ abandonPuzzle: () => void; - 102→ /** 自动解码 T1(技术解锁后由 tick 调用) */ - 103→ autoDecodeTick: () => void; - 104→ - 105→ // 探险 - 106→ startExpedition: () => { ok: boolean; reason?: string }; - 107→ resolveCurrentNode: () => ExpeditionResult | null; - 108→ advanceNode: () => void; - 109→ abortExpedition: () => void; - 110→ - 111→ // 技术 - 112→ buyTech: (techId: string) => boolean; - 113→ - 114→ // 飞升 - 115→ doPrestige: () => { newBp: number } | null; - 116→ - 117→ // 星图天文台 - 118→ chooseConstellationPerk: (perkId: string) => boolean; - 119→ rerollPerkChoices: () => void; - 120→ - 121→ // 成就 - 122→ checkAchievements: () => Achievement[]; - 123→ consumeAchievementQueue: () => Achievement[]; - 124→ - 125→ // 设置 - 126→ toggleTheme: () => void; - 127→ toggleSound: () => void; - 128→ - 129→ // 深空信标奖励发放(v0.5) - 130→ grantBeaconReward: (insights: number, contact: number) => void; - 131→ - 132→ // 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法) - 133→ grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void; - 134→ - 135→ // 角色属性(v0.7 P1) - 136→ allocateAttribute: (attr: AttributeKey, points?: number) => { ok: boolean; leveledUp?: number }; - 137→ gainAttributeExp: (attr: AttributeKey, amount: number) => { leveledUp: number; newLevel: number }; - 138→ - 139→ // 派生 - 140→ canPrestige: () => boolean; - 141→} - 142→ - 143→type Store = GameState & GameActions & { - 144→ _lastAutoDecode: number; - 145→ _lastSpawn: number; - 146→ _combo: number; - 147→ _lastPulse: number; - 148→ _achievementQueue: Achievement[]; - 149→ _tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[]; - 150→}; - 151→ - 152→/** 计算并写回产能字段 */ - 153→function syncStats(state: Partial) { - 154→ const s = recomputeStats(state); - 155→ return { - 156→ crystalsPerSec: s.crystalsPerSec, - 157→ crystalCap: s.crystalCap, - 158→ pulsePower: s.pulsePower, - 159→ offlineEff: s.offlineEff, - 160→ insightMult: s.insightMult, - 161→ contactRateMult: s.contactRateMult, - 162→ autoDecode: s.autoDecode, - 163→ decodeStepsBonus: s.decodeStepsBonus, - 164→ }; - 165→} - 166→ - 167→/** - 168→ * 深空信标进度追踪(v0.5)。 - 169→ * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,按今日挑战类型增量更新进度。 - 170→ * 进度独立存储于 localStorage(echo-nexus-beacon-prog-v1),不污染 GameState。 - 171→ * @returns 若刚完成则返回 true(供 UI 触发通知) - 172→ */ - 173→function trackBeacon( - 174→ type: "pulse" | "decode" | "expedition" | "boss" | "insight", - 175→ delta: number - 176→): boolean { - 177→ if (typeof window === "undefined") return false; - 178→ try { - 179→ const challenge: BeaconDailyChallenge = generateDailyChallenge(); - 180→ if (challenge.type !== type) return false; - 181→ const current: BeaconDailyProgress = loadDailyProgress(); - 182→ if (current.completedAt !== null) return false; // 已完成不再累加 - 183→ const { justCompleted } = addBeaconProgress(current, challenge, delta); - 184→ return justCompleted; - 185→ } catch { - 186→ return false; - 187→ } - 188→} - 189→ - 190→/** 检查并解锁叙事碎片 */ - 191→function checkFragments(state: GameState): string[] { - 192→ const unlocked: string[] = []; - 193→ for (const f of FRAGMENTS) { - 194→ if (!state.fragments[f.id] && state.totalDecoded >= f.threshold) { - 195→ state.fragments[f.id] = true; - 196→ unlocked.push(f.id); - 197→ } - 198→ } - 199→ return unlocked; - 200→} - 201→ - 202→export const useGameStore = create()( - 203→ persist( - 204→ (set, get) => ({ - 205→ ...createInitialState(), - 206→ _lastAutoDecode: Date.now(), - 207→ _lastSpawn: Date.now(), - 208→ _combo: 0, - 209→ _lastPulse: 0, - 210→ _achievementQueue: [], - 211→ _tideEvents: [], - 212→ - 213→ init: () => { - 214→ const s = get(); - 215→ const now = Date.now(); - 216→ // 兼容旧存档:若存在不可解的活跃谜题(旧算法生成),直接清掉 - 217→ let activePuzzle = s.activePuzzle; - 218→ if (activePuzzle && !isSolvable(activePuzzle)) { - 219→ // 把晶体放回队列,避免玩家卡死 - 220→ const crystal: Crystal = { - 221→ id: `c_${now}_rec`, - 222→ tier: activePuzzle.tier, - 223→ value: CRYSTAL_VALUE[activePuzzle.tier].crystals, - 224→ createdAt: now, - 225→ }; - 226→ activePuzzle = null; - 227→ set({ - 228→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending), - 229→ }); - 230→ } - 231→ // 兼容旧存档:补全 achievements / 星潮 / 星图 字段 - 232→ const achievements = s.achievements ?? {}; - 233→ const activeTide = s.activeTide ?? null; - 234→ const constellation = s.constellation ?? []; - 235→ const pendingPerkChoices = s.pendingPerkChoices ?? null; - 236→ // v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered - 237→ const migrated = migrateChronicleFields(s); - 238→ // v0.7 角色属性兼容:补全 attributes / attributeProgress / pendingAttrPoints - 239→ const attrMigrated = migrateAttributes(s); - 240→ // 星图「能量共振」天赋 +1 能量上限 - 241→ const cm = constellationBonuses(constellation); - 242→ const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus; - 243→ // lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效 - 244→ const lastTideEndRaw = s.lastTideEnd ?? 0; - 245→ // 若旧存档有已过期的星潮,清掉 - 246→ const tide = activeTide && activeTide.endsAt > now ? activeTide : null; - 247→ // 首次进入:补发离线收益 - 248→ const elapsed = Math.max(0, (now - s.lastTick) / 1000); - 249→ if (elapsed > 5) { - 250→ const cap = 8 * 3600; - 251→ const secs = Math.min(elapsed, cap); - 252→ const gain = s.crystalsPerSec * secs * s.offlineEff; - 253→ const crystalsBefore = s.crystals; - 254→ const crystalsAfter = Math.min(s.crystalCap, s.crystals + gain); - 255→ // 设置离线报告(v0.5.2):通知 UI 弹出"欢迎回来"对话框 - 256→ setPendingOfflineReport({ - 257→ elapsedSec: secs, - 258→ rawElapsedSec: elapsed, - 259→ gain: crystalsAfter - crystalsBefore, - 260→ rate: s.crystalsPerSec, - 261→ eff: s.offlineEff, - 262→ capped: elapsed > cap, - 263→ crystalsBefore, - 264→ crystalsAfter, - 265→ crystalCap: s.crystalCap, - 266→ }); - 267→ set({ - 268→ crystals: crystalsAfter, - 269→ lastTick: now, - 270→ activePuzzle, - 271→ achievements, - 272→ activeTide: tide, - 273→ lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, - 274→ constellation, - 275→ pendingPerkChoices, - 276→ energyMax, - 277→ chronicle: migrated.chronicle, - 278→ runStart: migrated.runStart, - 279→ bossKills: migrated.bossKills, - 280→ starTidesEncountered: migrated.starTidesEncountered, - 281→ attributes: attrMigrated.attributes, - 282→ attributeProgress: attrMigrated.attributeProgress, - 283→ pendingAttrPoints: attrMigrated.pendingAttrPoints, - 284→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }), - 285→ }); - 286→ } else { - 287→ 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 }) }); - 288→ } - 289→ }, - 290→ - 291→ loadOnline: () => { - 292→ const s = get(); - 293→ set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) }); - 294→ }, - 295→ - 296→ hardReset: () => { - 297→ set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] }); - 298→ }, - 299→ - 300→ tickTide: (now) => { - 301→ const s = get(); - 302→ const tide = s.activeTide; - 303→ // 星图「星潮引导」减少间隙 - 304→ const cm = constellationBonuses(s.constellation ?? []); - 305→ // v0.7 灵感:星潮触发概率 +X%(缩短间隙) - 306→ const am = getAllBonuses(s.attributes ?? {}); - 307→ const tideGapReduction = Math.min(0.3, am.tideTriggerBonus); - 308→ const gap = Math.max(15000, (TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000) * (1 - tideGapReduction)); - 309→ // 1) 检查当前星潮是否结束 - 310→ if (tide && now >= tide.endsAt) { - 311→ const endedType = tide.type; - 312→ // 寂静期补偿洞见 - 313→ let silenceCompensation = 0; - 314→ if (tide.type === "silence") { - 315→ silenceCompensation = computeSilenceCompensation(tide); - 316→ } - 317→ const newInsights = s.insights + silenceCompensation; - 318→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType }; - 319→ if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation; - 320→ set({ - 321→ activeTide: null, - 322→ lastTideEnd: now, - 323→ insights: newInsights, - 324→ // 星潮结束后重算 stats(移除 contactRate/insight 修饰) - 325→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }), - 326→ _tideEvents: [...s._tideEvents, event], - 327→ }); - 328→ return event; - 329→ } - 330→ // 2) 检查是否该触发新星潮(间隙已过) - 331→ if (!tide) { - 332→ const since = now - s.lastTideEnd; - 333→ // 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap - 334→ const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000; - 335→ const need = firstStart ? TIDE_CONFIG.firstDelay : gap; - 336→ if (since >= need) { - 337→ const type = rollTide(); - 338→ const newTide: StarTide = { - 339→ id: `tide_${now}_${Math.random().toString(36).slice(2, 7)}`, - 340→ type, - 341→ startedAt: now, - 342→ endsAt: now + TIDE_CONFIG.duration, - 343→ }; - 344→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type }; - 345→ // v0.4 编年史:累计遇到的星潮 ID(去重) - 346→ const tidesAll = s.starTidesEncountered ?? []; - 347→ const tideId = `tide_${type}`; - 348→ const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId]; - 349→ set({ - 350→ activeTide: newTide, - 351→ starTidesEncountered: newTidesAll, - 352→ // 星潮开始后重算 stats(应用 contactRate/insight 修饰) - 353→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }), - 354→ _tideEvents: [...s._tideEvents, event], - 355→ }); - 356→ return event; - 357→ } - 358→ } - 359→ return null; - 360→ }, - 361→ - 362→ consumeTideEvents: () => { - 363→ const s = get(); - 364→ if (s._tideEvents.length === 0) return []; - 365→ const items = s._tideEvents; - 366→ set({ _tideEvents: [] }); - 367→ return items; - 368→ }, - 369→ - 370→ tick: (now) => { - 371→ const s = get(); - 372→ const dt = Math.max(0, (now - s.lastTick) / 1000); - 373→ if (dt <= 0) return; - 374→ - 375→ // 星潮产能修饰(即时乘) - 376→ const tideMod = getTideModifiers(s.activeTide); - 377→ const effCps = s.crystalsPerSec * tideMod.crystalsPerSecMult; - 378→ // 产能累加(仅闲置产能受仓库上限;探险奖励可超出) - 379→ const newCrystals = - 380→ s.crystals >= s.crystalCap - 381→ ? s.crystals // 已达/超上限,不再自动产出 - 382→ : Math.min(s.crystalCap, s.crystals + effCps * dt); - 383→ - 384→ // 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速) - 385→ const bpBoost = 1 + s.blueprints.length * 0.03; - 386→ const spawnInterval = (CRYSTAL_SPAWN.baseIntervalSec / bpBoost) * 1000; - 387→ let pending = s.pendingCrystals; - 388→ let lastSpawn = s._lastSpawn; - 389→ if ( - 390→ now - lastSpawn > spawnInterval && - 391→ pending.length < CRYSTAL_SPAWN.maxPending - 392→ ) { - 393→ // 星图「晶体富集」提升 T2/T3 概率 - 394→ const cm = constellationBonuses(s.constellation ?? []); - 395→ const tier = rollCrystalTierWithBonus(cm.t2t3BonusRate); - 396→ const crystal: Crystal = { - 397→ id: `c_${now}_${Math.random().toString(36).slice(2, 7)}`, - 398→ tier, - 399→ value: CRYSTAL_VALUE[tier].crystals, - 400→ createdAt: now, - 401→ }; - 402→ pending = [...pending, crystal]; - 403→ lastSpawn = now; - 404→ } - 405→ - 406→ // 能量恢复(探险系统) - 407→ let energy = s.energy; - 408→ let lastEnergyTick = s.lastEnergyTick; - 409→ if (energy < s.energyMax) { - 410→ const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax); - 411→ energy = regen.energy; - 412→ lastEnergyTick = regen.lastTick; - 413→ } else { - 414→ lastEnergyTick = now; - 415→ } - 416→ - 417→ set({ - 418→ crystals: newCrystals, - 419→ lastTick: now, - 420→ pendingCrystals: pending, - 421→ _lastSpawn: lastSpawn, - 422→ energy, - 423→ lastEnergyTick, - 424→ }); - 425→ }, - 426→ - 427→ pulse: () => { - 428→ const s = get(); - 429→ const now = Date.now(); - 430→ // 连击 - 431→ let combo = 1; - 432→ if (now - s._lastPulse < 1500) { - 433→ combo = Math.min(10, s._combo + 1); - 434→ } - 435→ const mult = 1 + (combo - 1) * 0.15; - 436→ // 星潮脉冲威力修饰 - 437→ const tideMod = getTideModifiers(s.activeTide); - 438→ // v0.7 灵感:脉冲连击加成 +X% - 439→ const am = getAllBonuses(s.attributes ?? {}); - 440→ const comboBonusMult = 1 + am.pulseComboBonus * Math.max(0, combo - 1); - 441→ const gain = s.pulsePower * mult * tideMod.pulsePowerMult * comboBonusMult; - 442→ set({ - 443→ crystals: Math.min(s.crystalCap, s.crystals + gain), - 444→ _combo: combo, - 445→ _lastPulse: now, - 446→ }); - 447→ // 深空信标:脉冲任务进度 +1 - 448→ trackBeacon("pulse", 1); - 449→ // v0.7 角色属性:连击 ≥3 给灵感经验 - 450→ if (combo >= 3) { - 451→ const expGain = 1 + Math.floor(combo / 2); // 3 连击=2, 5 连击=3, 10 连击=6 - 452→ // 内联经验获取(避免递归调用 set) - 453→ const prog = s.attributeProgress?.inspiration ?? { exp: 0, level: s.attributes?.inspiration ?? 0 }; - 454→ const nextExp = prog.exp + expGain; - 455→ const lvlResult = levelUpCheck( - 456→ { exp: nextExp, level: s.attributes?.inspiration ?? 0 }, - 457→ ATTRIBUTE_HARD_CAP - 458→ ); - 459→ const newAttributes: CharacterAttributes = { - 460→ ...(s.attributes ?? createInitialAttributes()), - 461→ inspiration: lvlResult.newProgress.level, - 462→ }; - 463→ const newProgress: AttributeProgress = { - 464→ ...(s.attributeProgress ?? createInitialAttributeProgress()), - 465→ inspiration: lvlResult.newProgress, - 466→ }; - 467→ set({ - 468→ attributes: newAttributes, - 469→ attributeProgress: newProgress, - 470→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), - 471→ }); - 472→ } - 473→ return { gain, combo }; - 474→ }, - 475→ - 476→ startDecode: (crystalId) => { - 477→ const s = get(); - 478→ const crystal = s.pendingCrystals.find((c) => c.id === crystalId); - 479→ if (!crystal) return; - 480→ const puzzle = generatePuzzle(crystal.tier); - 481→ set({ - 482→ activePuzzle: puzzle, - 483→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId), - 484→ }); - 485→ }, - 486→ - 487→ clickNode: (nodeId) => { - 488→ const s = get(); - 489→ if (!s.activePuzzle) return { ok: false, finished: false, failReason: "无活跃谜题" }; - 490→ // 深拷贝谜题 - 491→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle)); - 492→ const res = tryClickNode(puzzle, nodeId); - 493→ if (res.ok) { - 494→ if (res.finished) { - 495→ // 结算奖励(星潮解码奖励修饰 + 星图「二周目经验」) - 496→ const tideMod = getTideModifiers(s.activeTide); - 497→ const cm = constellationBonuses(s.constellation ?? []); - 498→ const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult); - 499→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult; - 500→ const rewards = { - 501→ crystals: Math.round(base.crystals * finalMult), - 502→ insights: Math.round(base.insights * finalMult), - 503→ contact: +(base.contact * finalMult).toFixed(2), - 504→ }; - 505→ const newTotal = s.totalDecoded + 1; - 506→ const newContact = Math.min(100, s.contact + rewards.contact); - 507→ const newInsights = s.insights + rewards.insights; - 508→ const newCrystals = s.crystals + rewards.crystals; - 509→ // 解锁碎片 - 510→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } }; - 511→ const unlocked = checkFragments(tentative); - 512→ // v0.7 角色属性:完成解码给智慧经验(tier 越高经验越多) - 513→ const wisdomExpGain = puzzle.tier * 2; - 514→ const curAttrs = s.attributes ?? createInitialAttributes(); - 515→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); - 516→ const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom }; - 517→ const wisdomLvl = levelUpCheck( - 518→ { exp: progEntry.exp + wisdomExpGain, level: curAttrs.wisdom }, - 519→ ATTRIBUTE_HARD_CAP - 520→ ); - 521→ const newAttributes: CharacterAttributes = { - 522→ ...curAttrs, - 523→ wisdom: wisdomLvl.newProgress.level, - 524→ }; - 525→ const newProgress: AttributeProgress = { - 526→ ...curProg, - 527→ wisdom: wisdomLvl.newProgress, - 528→ }; - 529→ set({ - 530→ activePuzzle: null, - 531→ crystals: newCrystals, - 532→ insights: newInsights, - 533→ contact: newContact, - 534→ totalDecoded: newTotal, - 535→ fragments: tentative.fragments, - 536→ attributes: newAttributes, - 537→ attributeProgress: newProgress, - 538→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), - 539→ }); - 540→ // 深空信标:解码 +1,洞见累计 - 541→ trackBeacon("decode", 1); - 542→ trackBeacon("insight", rewards.insights); - 543→ return { ok: true, finished: true, failReason: unlocked.join(",") || undefined }; - 544→ } - 545→ // 点击成功但未完成:检测当前局面是否仍可解 - 546→ const solvable = isSolvable(puzzle); - 547→ set({ activePuzzle: puzzle }); - 548→ return { ok: true, finished: false, solvable }; - 549→ } - 550→ return res; - 551→ }, - 552→ - 553→ undoStep: () => { - 554→ const s = get(); - 555→ if (!s.activePuzzle || s.activePuzzle.path.length === 0) return; - 556→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle)); - 557→ const lastId = puzzle.path.pop(); - 558→ if (lastId !== undefined) { - 559→ const node = puzzle.grid.find((n) => n.id === lastId); - 560→ if (node) node.used = false; - 561→ } - 562→ set({ activePuzzle: puzzle }); - 563→ }, - 564→ - 565→ retryPuzzle: () => { - 566→ const s = get(); - 567→ if (!s.activePuzzle) return; - 568→ set({ activePuzzle: resetPuz(s.activePuzzle) }); - 569→ }, - 570→ - 571→ abandonPuzzle: () => { - 572→ const s = get(); - 573→ if (!s.activePuzzle) return; - 574→ // 晶体放回队列末尾 - 575→ const crystal: Crystal = { - 576→ id: `c_${Date.now()}_ret`, - 577→ tier: s.activePuzzle.tier, - 578→ value: CRYSTAL_VALUE[s.activePuzzle.tier].crystals, - 579→ createdAt: Date.now(), - 580→ }; - 581→ set({ - 582→ activePuzzle: null, - 583→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending), - 584→ }); - 585→ }, - 586→ - 587→ autoDecodeTick: () => { - 588→ const s = get(); - 589→ if (!s.autoDecode) return; - 590→ const now = Date.now(); - 591→ // 星图「自动校准」减少自动解码周期 - 592→ const cm = constellationBonuses(s.constellation ?? []); - 593→ // v0.7 智慧:自动解码周期 -X% - 594→ const am = getAllBonuses(s.attributes ?? {}); - 595→ const baseInterval = 12000 + cm.autoDecodeIntervalDeltaSec * 1000; - 596→ const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult); - 597→ if (now - s._lastAutoDecode < interval) return; - 598→ // 找一颗 T1 晶体自动解码 - 599→ const idx = s.pendingCrystals.findIndex((c) => c.tier === 1); - 600→ if (idx < 0) return; - 601→ const crystal = s.pendingCrystals[idx]; - 602→ const tideMod = getTideModifiers(s.activeTide); - 603→ const base = decodeRewards(1, s.insightMult, s.contactRateMult); - 604→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult; - 605→ const rewards = { - 606→ crystals: Math.round(base.crystals * finalMult), - 607→ insights: Math.round(base.insights * finalMult), - 608→ contact: +(base.contact * finalMult).toFixed(2), - 609→ }; - 610→ const newTotal = s.totalDecoded + 1; - 611→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } }; - 612→ checkFragments(tentative); - 613→ // v0.7 角色属性:自动解码给智慧经验(少量) - 614→ const curAttrs = s.attributes ?? createInitialAttributes(); - 615→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); - 616→ const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom }; - 617→ const wisdomLvl = levelUpCheck( - 618→ { exp: progEntry.exp + 1, level: curAttrs.wisdom }, - 619→ ATTRIBUTE_HARD_CAP - 620→ ); - 621→ const newAttributes: CharacterAttributes = { - 622→ ...curAttrs, - 623→ wisdom: wisdomLvl.newProgress.level, - 624→ }; - 625→ const newProgress: AttributeProgress = { - 626→ ...curProg, - 627→ wisdom: wisdomLvl.newProgress, - 628→ }; - 629→ set({ - 630→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id), - 631→ crystals: s.crystals + rewards.crystals, - 632→ insights: s.insights + rewards.insights, - 633→ contact: Math.min(100, s.contact + rewards.contact), - 634→ totalDecoded: newTotal, - 635→ fragments: tentative.fragments, - 636→ _lastAutoDecode: now, - 637→ attributes: newAttributes, - 638→ attributeProgress: newProgress, - 639→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), - 640→ }); - 641→ // 深空信标:自动解码也算进度 - 642→ trackBeacon("decode", 1); - 643→ trackBeacon("insight", rewards.insights); - 644→ }, - 645→ - 646→ buyTech: (techId) => { - 647→ const s = get(); - 648→ const node = TECH_TREE.find((t) => t.id === techId); - 649→ if (!node) return false; - 650→ const cur = s.tech[techId] ?? 0; - 651→ if (cur >= 1) return false; // v0.1 每节点 1 级 - 652→ if (s.insights < node.cost) return false; - 653→ const newTech = { ...s.tech, [techId]: 1 }; - 654→ set({ - 655→ insights: s.insights - node.cost, - 656→ tech: newTech, - 657→ ...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }), - 658→ }); - 659→ return true; - 660→ }, - 661→ - 662→ // ============ 探险系统 ============ - 663→ startExpedition: () => { - 664→ const s = get(); - 665→ if (s.activeExpedition && !s.activeExpedition.finished) { - 666→ return { ok: false, reason: "已有进行中的探险" }; - 667→ } - 668→ if (s.energy < EXPEDITION_CONFIG.energyCost) { - 669→ return { ok: false, reason: "能量不足" }; - 670→ } - 671→ const tideMod = getTideModifiers(s.activeTide); - 672→ // v0.7 角色属性:探索力 +X% 探险力,勇气 +X 探险生命 - 673→ const am = getAllBonuses(s.attributes ?? {}); - 674→ const basePower = computeExpeditionPower(s) + tideMod.expeditionPowerBonus; - 675→ const baseHp = computeExpeditionHp(s) + tideMod.expeditionHpBonus; - 676→ const power = Math.round(basePower * am.expeditionPowerMult); - 677→ const hp = baseHp + am.expeditionHpBonus; - 678→ const seed = Math.floor(Math.random() * 1e9); - 679→ const expedition = generateExpedition(seed, power, hp); - 680→ set({ - 681→ activeExpedition: expedition, - 682→ energy: s.energy - EXPEDITION_CONFIG.energyCost, - 683→ totalExpeditions: s.totalExpeditions + 1, - 684→ }); - 685→ return { ok: true }; - 686→ }, - 687→ - 688→ resolveCurrentNode: () => { - 689→ const s = get(); - 690→ if (!s.activeExpedition || s.activeExpedition.finished) return null; - 691→ // 深拷贝 - 692→ const exp = JSON.parse(JSON.stringify(s.activeExpedition)); - 693→ // v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值) - 694→ const nodeBefore = exp.nodes[exp.currentNode]; - 695→ const isBossNode = nodeBefore?.type === "boss"; - 696→ const am = getAllBonuses(s.attributes ?? {}); - 697→ const result = isBossNode - 698→ ? resolveNode(exp, () => { - 699→ // 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布) - 700→ const r = Math.random(); - 701→ const b = Math.min(0.95, am.bossWinRateBonus); - 702→ return r < b ? 0 : Math.min(1, r - b); - 703→ }) - 704→ : resolveNode(exp); - 705→ // 累计奖励 - 706→ if (result.crystals) exp.rewards.crystals += result.crystals; - 707→ if (result.insights) exp.rewards.insights += result.insights; - 708→ if (result.contact) exp.rewards.contact += result.contact; - 709→ if (result.fragments) exp.rewards.fragments.push(...result.fragments); - 710→ if (result.hpDelta) exp.hp = Math.max(0, Math.min(exp.maxHp, exp.hp + result.hpDelta)); - 711→ // 实时入账(玩家立即获得) - 712→ const newCrystals = s.crystals + (result.crystals || 0); - 713→ const newInsights = s.insights + (result.insights || 0); - 714→ const newContact = Math.min(100, s.contact + (result.contact || 0)); - 715→ // 碎片解锁 - 716→ const newFragments = { ...s.fragments }; - 717→ if (result.fragments) { - 718→ for (const fid of result.fragments) newFragments[fid] = true; - 719→ } - 720→ // 日志 - 721→ const logEntry = { - 722→ expeditionId: exp.id, - 723→ nodeType: nodeBefore?.type || "combat", - 724→ result: result.log, - 725→ rewards: [ - 726→ result.crystals ? `+${result.crystals}晶体` : "", - 727→ result.insights ? `+${result.insights}洞见` : "", - 728→ result.contact ? `+${result.contact.toFixed(1)}接触` : "", - 729→ result.hpDelta ? `${result.hpDelta > 0 ? "+" : ""}${result.hpDelta}生命` : "", - 730→ ].filter(Boolean).join(" "), - 731→ timestamp: Date.now(), - 732→ }; - 733→ const newLog = [logEntry, ...s.expeditionLog].slice(0, 30); - 734→ - 735→ if (result.ended) { - 736→ // 探险结束(胜利或失败) - 737→ exp.finished = true; - 738→ } - 739→ - 740→ // v0.4 编年史:击破 BOSS 时累计计数 - 741→ let bossKills = s.bossKills ?? 0; - 742→ let bossKilledThisNode = false; - 743→ if ( - 744→ result.ended && - 745→ result.endReason === "victory" && - 746→ nodeBefore?.type === "boss" - 747→ ) { - 748→ bossKills = bossKills + 1; - 749→ bossKilledThisNode = true; - 750→ } - 751→ - 752→ // v0.7 角色属性:战斗胜利给勇气+探索力经验;BOSS 额外奖励 - 753→ let newAttributes = s.attributes ?? createInitialAttributes(); - 754→ let newProgress = s.attributeProgress ?? createInitialAttributeProgress(); - 755→ let statsNeedResync = false; - 756→ // 战斗类节点(combat/boss)且胜利:勇气 + 探索力经验 - 757→ const isCombatVictory = - 758→ (nodeBefore?.type === "combat" || nodeBefore?.type === "boss") && - 759→ !result.ended; // 中途战斗胜利(未结束探险) - 760→ const isExpeditionVictory = - 761→ result.ended && result.endReason === "victory"; - 762→ if (isCombatVictory || bossKilledThisNode || isExpeditionVictory) { - 763→ const courageGain = bossKilledThisNode ? 8 : 2; - 764→ const explorationGain = bossKilledThisNode ? 6 : isExpeditionVictory ? 4 : 1; - 765→ const courageLvl = levelUpCheck( - 766→ { exp: newProgress.courage.exp + courageGain, level: newAttributes.courage }, - 767→ ATTRIBUTE_HARD_CAP - 768→ ); - 769→ const explLvl = levelUpCheck( - 770→ { exp: newProgress.exploration.exp + explorationGain, level: newAttributes.exploration }, - 771→ ATTRIBUTE_HARD_CAP - 772→ ); - 773→ newAttributes = { - 774→ ...newAttributes, - 775→ courage: courageLvl.newProgress.level, - 776→ exploration: explLvl.newProgress.level, - 777→ }; - 778→ newProgress = { - 779→ ...newProgress, - 780→ courage: courageLvl.newProgress, - 781→ exploration: explLvl.newProgress, - 782→ }; - 783→ statsNeedResync = true; - 784→ } - 785→ - 786→ set({ - 787→ activeExpedition: exp, - 788→ crystals: newCrystals, - 789→ insights: newInsights, - 790→ contact: newContact, - 791→ fragments: newFragments, - 792→ expeditionLog: newLog, - 793→ bossKills, - 794→ attributes: newAttributes, - 795→ attributeProgress: newProgress, - 796→ ...(statsNeedResync - 797→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }) - 798→ : {}), - 799→ }); - 800→ // 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破 - 801→ if (result.ended) { - 802→ trackBeacon("expedition", 1); - 803→ if (bossKilledThisNode) { - 804→ trackBeacon("boss", 1); - 805→ } - 806→ } - 807→ if (result.insights) trackBeacon("insight", result.insights); - 808→ return result; - 809→ }, - 810→ - 811→ advanceNode: () => { - 812→ const s = get(); - 813→ if (!s.activeExpedition || s.activeExpedition.finished) return; - 814→ const exp = JSON.parse(JSON.stringify(s.activeExpedition)); - 815→ const node = exp.nodes[exp.currentNode]; - 816→ if (!node || !node.cleared) return; // 当前节点未结算不能前进 - 817→ if (exp.currentNode >= exp.nodes.length - 1) return; - 818→ exp.currentNode++; - 819→ set({ activeExpedition: exp }); - 820→ }, - 821→ - 822→ abortExpedition: () => { - 823→ const s = get(); - 824→ if (!s.activeExpedition) return; - 825→ const exp = JSON.parse(JSON.stringify(s.activeExpedition)); - 826→ exp.finished = true; - 827→ const logEntry = { - 828→ expeditionId: exp.id, - 829→ nodeType: "rest" as const, - 830→ result: "探险队主动撤退,保留已获奖励。", - 831→ rewards: "", - 832→ timestamp: Date.now(), - 833→ }; - 834→ set({ - 835→ activeExpedition: exp, - 836→ expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30), - 837→ }); - 838→ }, - 839→ - 840→ doPrestige: () => { - 841→ const s = get(); - 842→ if (s.contact < CONTACT.prestigeMin) return null; - 843→ const newBp = computeNewBlueprints(s); - 844→ const next = performPrestige(s); - 845→ // 星图「能量共振」提升上限 - 846→ const cm = constellationBonuses(next.constellation ?? []); - 847→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus; - 848→ set({ - 849→ ...next, - 850→ energyMax, - 851→ ...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation, attributes: next.attributes }), - 852→ _lastAutoDecode: Date.now(), - 853→ _lastSpawn: Date.now(), - 854→ _combo: 0, - 855→ _lastPulse: 0, - 856→ _tideEvents: [], - 857→ }); - 858→ return { newBp }; - 859→ }, - 860→ - 861→ chooseConstellationPerk: (perkId) => { - 862→ const s = get(); - 863→ if (!s.pendingPerkChoices || !s.pendingPerkChoices.includes(perkId)) return false; - 864→ const perk = getPerk(perkId); - 865→ if (!perk) return false; - 866→ if (s.constellation?.includes(perkId)) return false; - 867→ const newConstellation = [...(s.constellation ?? []), perkId]; - 868→ const cm = constellationBonuses(newConstellation); - 869→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus; - 870→ // v0.4 编年史:回填最近一条 entry 的 perksThisAscension - 871→ const chronicle = s.chronicle ?? []; - 872→ let newChronicle = chronicle; - 873→ if (chronicle.length > 0) { - 874→ const lastEntry = chronicle[chronicle.length - 1]; - 875→ const updatedLast = withPerks(lastEntry, [perkId]); - 876→ newChronicle = [...chronicle.slice(0, -1), updatedLast]; - 877→ } - 878→ set({ - 879→ constellation: newConstellation, - 880→ pendingPerkChoices: null, - 881→ energyMax, - 882→ chronicle: newChronicle, - 883→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation, attributes: s.attributes }), - 884→ }); - 885→ return true; - 886→ }, - 887→ - 888→ rerollPerkChoices: () => { - 889→ const s = get(); - 890→ if (!s.pendingPerkChoices) return; - 891→ const choices = rollPerkChoices(s.constellation ?? []); - 892→ if (choices.length > 0) set({ pendingPerkChoices: choices }); - 893→ }, - 894→ - 895→ checkAchievements: () => { - 896→ const s = get(); - 897→ const newlyUnlocked: Achievement[] = []; - 898→ const updated = { ...s.achievements }; - 899→ let crystals = s.crystals; - 900→ let insights = s.insights; - 901→ let contact = s.contact; - 902→ let statsDirty = false; - 903→ for (const a of ACHIEVEMENTS) { - 904→ if (updated[a.id]) continue; - 905→ if (a.check(s)) { - 906→ updated[a.id] = true; - 907→ newlyUnlocked.push(a); - 908→ // 发放即时奖励 - 909→ if (a.reward.crystals) crystals += a.reward.crystals; - 910→ if (a.reward.insights) insights += a.reward.insights; - 911→ if (a.reward.contact) contact = Math.min(100, contact + a.reward.contact); - 912→ if (a.reward.crystalsPerSecPct || a.reward.insightPct) statsDirty = true; - 913→ } - 914→ } - 915→ if (newlyUnlocked.length === 0) return []; - 916→ set({ - 917→ achievements: updated, - 918→ crystals, - 919→ insights, - 920→ contact, - 921→ ...(statsDirty - 922→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) - 923→ : {}), - 924→ _achievementQueue: [...s._achievementQueue, ...newlyUnlocked], - 925→ }); - 926→ return newlyUnlocked; - 927→ }, - 928→ - 929→ consumeAchievementQueue: () => { - 930→ const s = get(); - 931→ if (s._achievementQueue.length === 0) return []; - 932→ const items = s._achievementQueue; - 933→ set({ _achievementQueue: [] }); - 934→ return items; - 935→ }, - 936→ - 937→ canPrestige: () => get().contact >= CONTACT.prestigeMin, - 938→ - 939→ toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }), - 940→ toggleSound: () => set({ soundOn: !get().soundOn }), - 941→ - 942→ // 深空信标:发放每日挑战奖励(v0.5) - 943→ grantBeaconReward: (insights, contact) => { - 944→ const s = get(); - 945→ set({ - 946→ insights: s.insights + Math.round(insights), - 947→ contact: Math.min(100, s.contact + contact), - 948→ }); - 949→ }, - 950→ - 951→ // 深空巡航:发放实时玩法奖励(v0.6) - 952→ grantCruiseReward: (rewards) => { - 953→ const s = get(); - 954→ const addCrystals = rewards.crystals ?? 0; - 955→ const addInsights = rewards.insights ?? 0; - 956→ const addContact = rewards.contact ?? 0; - 957→ // v0.7 角色属性:巡航通关给探索力+勇气经验(按晶体奖励量缩放) - 958→ const totalReward = addCrystals + addInsights * 10 + addContact * 10; - 959→ const expBase = Math.max(2, Math.floor(totalReward / 30)); - 960→ const curAttrs = s.attributes ?? createInitialAttributes(); - 961→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); - 962→ const explLvl = levelUpCheck( - 963→ { exp: curProg.exploration.exp + expBase, level: curAttrs.exploration }, - 964→ ATTRIBUTE_HARD_CAP - 965→ ); - 966→ const courageLvl = levelUpCheck( - 967→ { exp: curProg.courage.exp + Math.floor(expBase * 0.6), level: curAttrs.courage }, - 968→ ATTRIBUTE_HARD_CAP - 969→ ); - 970→ const newAttributes: CharacterAttributes = { - 971→ ...curAttrs, - 972→ exploration: explLvl.newProgress.level, - 973→ courage: courageLvl.newProgress.level, - 974→ }; - 975→ const newProgress: AttributeProgress = { - 976→ ...curProg, - 977→ exploration: explLvl.newProgress, - 978→ courage: courageLvl.newProgress, - 979→ }; - 980→ set({ - 981→ crystals: Math.min(s.crystalCap, s.crystals + addCrystals), - 982→ insights: s.insights + Math.round(addInsights), - 983→ contact: Math.min(100, s.contact + addContact), - 984→ attributes: newAttributes, - 985→ attributeProgress: newProgress, - 986→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), - 987→ }); - 988→ }, - 989→ - 990→ // ============ 角色属性系统(v0.7 P1) ============ - 991→ allocateAttribute: (attr, points = 1) => { - 992→ const s = get(); - 993→ const cur = s.attributes ?? createInitialAttributes(); - 994→ const curVal = cur[attr] ?? 0; - 995→ if (curVal >= ATTRIBUTE_HARD_CAP) { - 996→ return { ok: false, leveledUp: 0 }; - 997→ } - 998→ if ((s.pendingAttrPoints ?? 0) < points) { - 999→ return { ok: false, leveledUp: 0 }; - 1000→ } - 1001→ const alloc = Math.min(points, ATTRIBUTE_HARD_CAP - curVal, s.pendingAttrPoints); - 1002→ const newVal = curVal + alloc; - 1003→ const newAttributes: CharacterAttributes = { ...cur, [attr]: newVal }; - 1004→ // 同步经验进度 level 字段(保持一致) - 1005→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); - 1006→ const oldProg = curProg[attr] ?? { exp: 0, level: curVal }; - 1007→ const newProgress: AttributeProgress = { - 1008→ ...curProg, - 1009→ [attr]: { exp: oldProg.exp, level: newVal }, - 1010→ }; - 1011→ set({ - 1012→ attributes: newAttributes, - 1013→ attributeProgress: newProgress, - 1014→ pendingAttrPoints: s.pendingAttrPoints - alloc, - 1015→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), - 1016→ }); - 1017→ return { ok: true, leveledUp: alloc }; - 1018→ }, - 1019→ - 1020→ gainAttributeExp: (attr, amount) => { - 1021→ const s = get(); - 1022→ const cur = s.attributes ?? createInitialAttributes(); - 1023→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); - 1024→ const progEntry = curProg[attr] ?? { exp: 0, level: cur[attr] }; - 1025→ const result = levelUpCheck( - 1026→ { exp: progEntry.exp + amount, level: cur[attr] }, - 1027→ ATTRIBUTE_HARD_CAP - 1028→ ); - 1029→ const newAttributes: CharacterAttributes = { ...cur, [attr]: result.newProgress.level }; - 1030→ const newProgress: AttributeProgress = { - 1031→ ...curProg, - 1032→ [attr]: result.newProgress, - 1033→ }; - 1034→ set({ - 1035→ attributes: newAttributes, - 1036→ attributeProgress: newProgress, - 1037→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), - 1038→ }); - 1039→ return { leveledUp: result.levelsGained, newLevel: result.newProgress.level }; - 1040→ }, - 1041→ }), - 1042→ { - 1043→ name: "echo-nexus-save-v1", - 1044→ storage: createJSONStorage(() => localStorage), - 1045→ // 不持久化临时字段 - 1046→ partialize: (s) => { - 1047→ const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s; - 1048→ void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents; - 1049→ return rest as GameState; - 1050→ }, - 1051→ } - 1052→ ) - 1053→); - 1054→ - 1055→/** 选择器:未解锁碎片中下一个门槛 */ - 1056→export function nextFragmentThreshold(totalDecoded: number): number | null { - 1057→ for (const f of FRAGMENTS) { - 1058→ if (totalDecoded < f.threshold) return f.threshold; - 1059→ } - 1060→ return null; - 1061→} - 1062→ - 1063→export { FRAGMENTS, PRESTIGE, TECH_TREE }; - 1064→ - 1065→// 开发期调试:暴露 store 到 window,便于 QA 测试 - 1066→if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") { - 1067→ (window as unknown as { __gameStore?: typeof useGameStore }).__gameStore = useGameStore; - 1068→} - 1069→ \ No newline at end of file diff --git a/tool-results/read_1782294652693_8a406bf6bfd8.txt b/tool-results/read_1782294652693_8a406bf6bfd8.txt deleted file mode 100644 index f57a195d9..000000000 --- a/tool-results/read_1782294652693_8a406bf6bfd8.txt +++ /dev/null @@ -1,1180 +0,0 @@ - 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→ computeEnergyRegenInterval, - 43→ EXPEDITION_CONFIG, - 44→} from "@/lib/game/expedition"; - 45→import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements"; - 46→import { - 47→ TIDE_CONFIG, - 48→ rollTide, - 49→ getTideModifiers, - 50→ computeSilenceCompensation, - 51→ type StarTide, - 52→ type TideType, - 53→} from "@/lib/game/starTide"; - 54→import { - 55→ getPerk, - 56→ constellationBonuses, - 57→ rollPerkChoices, - 58→} from "@/lib/game/constellation"; - 59→import { - 60→ migrateChronicleFields, - 61→ withPerks, - 62→} from "@/lib/game/chronicle"; - 63→import { - 64→ generateDailyChallenge, - 65→ generateWeeklyChallenge, - 66→ loadDailyProgress, - 67→ loadWeeklyProgress, - 68→ loadChainState, - 69→ addBeaconProgress, - 70→ addWeeklyProgress, - 71→ recordChainCompletion, - 72→ claimWeeklyReward, - 73→ claimChainMilestone, - 74→ getTodayKey, - 75→ type BeaconDailyChallenge, - 76→ type BeaconDailyProgress, - 77→ type BeaconWeeklyChallenge, - 78→ type BeaconWeeklyProgress, - 79→} from "@/lib/game/beacon"; - 80→import { setPendingOfflineReport } from "@/lib/game/offlineReport"; - 81→import { - 82→ ATTRIBUTE_HARD_CAP, - 83→ migrateAttributes, - 84→ levelUpCheck, - 85→ getAllBonuses, - 86→ createInitialAttributes, - 87→ createInitialAttributeProgress, - 88→ type AttributeKey, - 89→ type CharacterAttributes, - 90→ type AttributeProgress, - 91→} from "@/lib/game/attributes"; - 92→ - 93→interface GameActions { - 94→ // 生命周期 - 95→ init: () => void; - 96→ loadOnline: () => void; - 97→ hardReset: () => void; - 98→ - 99→ // 主循环 - 100→ tick: (now: number) => void; - 101→ pulse: () => { gain: number; combo: number } | null; - 102→ - 103→ // 星潮 - 104→ tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null; - 105→ consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[]; - 106→ - 107→ // 解码 - 108→ startDecode: (crystalId: string) => void; - 109→ clickNode: (nodeId: number) => { ok: boolean; finished: boolean; solvable?: boolean; failReason?: string }; - 110→ undoStep: () => void; - 111→ retryPuzzle: () => void; - 112→ abandonPuzzle: () => void; - 113→ /** 自动解码 T1(技术解锁后由 tick 调用) */ - 114→ autoDecodeTick: () => void; - 115→ - 116→ // 探险 - 117→ startExpedition: () => { ok: boolean; reason?: string }; - 118→ resolveCurrentNode: () => ExpeditionResult | null; - 119→ advanceNode: () => void; - 120→ abortExpedition: () => void; - 121→ - 122→ // 技术 - 123→ buyTech: (techId: string) => boolean; - 124→ - 125→ // 飞升 - 126→ doPrestige: () => { newBp: number } | null; - 127→ - 128→ // 星图天文台 - 129→ chooseConstellationPerk: (perkId: string) => boolean; - 130→ rerollPerkChoices: () => void; - 131→ - 132→ // 成就 - 133→ checkAchievements: () => Achievement[]; - 134→ consumeAchievementQueue: () => Achievement[]; - 135→ - 136→ // 设置 - 137→ toggleTheme: () => void; - 138→ toggleSound: () => void; - 139→ - 140→ // 深空信标奖励发放(v0.5) - 141→ grantBeaconReward: (insights: number, contact: number) => void; - 142→ - 143→ // 深空信标 · 周挑战领取 + 信标链里程碑领取(v0.8) - 144→ claimWeeklyBeacon: () => { - 145→ rewardInsight: number; - 146→ rewardContact: number; - 147→ score: number; - 148→ }; - 149→ claimChainReward: (milestone: number) => { - 150→ rewardInsight: number; - 151→ rewardContact: number; - 152→ label: string; - 153→ ok: boolean; - 154→ }; - 155→ - 156→ // 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法) - 157→ grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void; - 158→ - 159→ // 角色属性(v0.7 P1) - 160→ allocateAttribute: (attr: AttributeKey, points?: number) => { ok: boolean; leveledUp?: number }; - 161→ gainAttributeExp: (attr: AttributeKey, amount: number) => { leveledUp: number; newLevel: number }; - 162→ - 163→ // 派生 - 164→ canPrestige: () => boolean; - 165→} - 166→ - 167→type Store = GameState & GameActions & { - 168→ _lastAutoDecode: number; - 169→ _lastSpawn: number; - 170→ _combo: number; - 171→ _lastPulse: number; - 172→ _achievementQueue: Achievement[]; - 173→ _tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[]; - 174→}; - 175→ - 176→/** 计算并写回产能字段 */ - 177→function syncStats(state: Partial) { - 178→ const s = recomputeStats(state); - 179→ return { - 180→ crystalsPerSec: s.crystalsPerSec, - 181→ crystalCap: s.crystalCap, - 182→ pulsePower: s.pulsePower, - 183→ offlineEff: s.offlineEff, - 184→ insightMult: s.insightMult, - 185→ contactRateMult: s.contactRateMult, - 186→ autoDecode: s.autoDecode, - 187→ decodeStepsBonus: s.decodeStepsBonus, - 188→ }; - 189→} - 190→ - 191→/** - 192→ * 深空信标进度追踪(v0.5 → v0.8 扩展)。 - 193→ * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,同时更新: - 194→ * 1. 日挑战进度(按今日挑战类型增量) - 195→ * 2. 周挑战进度(按本周挑战类型增量) - 196→ * 3. 信标链:日挑战刚完成时记录一次完成(含 grace 续命逻辑) - 197→ * 进度独立存储于 localStorage,不污染 GameState。 - 198→ * @returns 三类状态变更供 UI 触发通知 - 199→ */ - 200→function trackBeacon( - 201→ type: "pulse" | "decode" | "expedition" | "boss" | "insight", - 202→ delta: number - 203→): { - 204→ dailyJustCompleted: boolean; - 205→ weeklyJustCompleted: boolean; - 206→ newChainMilestones: number[]; - 207→} { - 208→ const result = { - 209→ dailyJustCompleted: false, - 210→ weeklyJustCompleted: false, - 211→ newChainMilestones: [] as number[], - 212→ }; - 213→ if (typeof window === "undefined") return result; - 214→ try { - 215→ // ---- 日挑战 ---- - 216→ const challenge: BeaconDailyChallenge = generateDailyChallenge(); - 217→ if (challenge.type === type) { - 218→ const current: BeaconDailyProgress = loadDailyProgress(); - 219→ if (current.completedAt === null) { - 220→ const { justCompleted } = addBeaconProgress(current, challenge, delta); - 221→ result.dailyJustCompleted = justCompleted; - 222→ // 日挑战刚完成 → 更新信标链 - 223→ if (justCompleted) { - 224→ const { newMilestones } = recordChainCompletion(getTodayKey()); - 225→ result.newChainMilestones = newMilestones; - 226→ } - 227→ } - 228→ } - 229→ - 230→ // ---- 周挑战 ---- - 231→ const wChallenge: BeaconWeeklyChallenge = generateWeeklyChallenge(); - 232→ if (wChallenge.type === type) { - 233→ const wCurrent: BeaconWeeklyProgress = loadWeeklyProgress(); - 234→ if (wCurrent.completedAt === null) { - 235→ const { justCompleted } = addWeeklyProgress( - 236→ wCurrent, - 237→ wChallenge, - 238→ delta - 239→ ); - 240→ result.weeklyJustCompleted = justCompleted; - 241→ } - 242→ } - 243→ - 244→ return result; - 245→ } catch { - 246→ return result; - 247→ } - 248→} - 249→ - 250→/** 检查并解锁叙事碎片 */ - 251→function checkFragments(state: GameState): string[] { - 252→ const unlocked: string[] = []; - 253→ for (const f of FRAGMENTS) { - 254→ if (!state.fragments[f.id] && state.totalDecoded >= f.threshold) { - 255→ state.fragments[f.id] = true; - 256→ unlocked.push(f.id); - 257→ } - 258→ } - 259→ return unlocked; - 260→} - 261→ - 262→export const useGameStore = create()( - 263→ persist( - 264→ (set, get) => ({ - 265→ ...createInitialState(), - 266→ _lastAutoDecode: Date.now(), - 267→ _lastSpawn: Date.now(), - 268→ _combo: 0, - 269→ _lastPulse: 0, - 270→ _achievementQueue: [], - 271→ _tideEvents: [], - 272→ - 273→ init: () => { - 274→ const s = get(); - 275→ const now = Date.now(); - 276→ // 兼容旧存档:若存在不可解的活跃谜题(旧算法生成),直接清掉 - 277→ let activePuzzle = s.activePuzzle; - 278→ if (activePuzzle && !isSolvable(activePuzzle)) { - 279→ // 把晶体放回队列,避免玩家卡死 - 280→ const crystal: Crystal = { - 281→ id: `c_${now}_rec`, - 282→ tier: activePuzzle.tier, - 283→ value: CRYSTAL_VALUE[activePuzzle.tier].crystals, - 284→ createdAt: now, - 285→ }; - 286→ activePuzzle = null; - 287→ set({ - 288→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending), - 289→ }); - 290→ } - 291→ // 兼容旧存档:补全 achievements / 星潮 / 星图 字段 - 292→ const achievements = s.achievements ?? {}; - 293→ const activeTide = s.activeTide ?? null; - 294→ const constellation = s.constellation ?? []; - 295→ const pendingPerkChoices = s.pendingPerkChoices ?? null; - 296→ // v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered - 297→ const migrated = migrateChronicleFields(s); - 298→ // v0.7 角色属性兼容:补全 attributes / attributeProgress / pendingAttrPoints - 299→ const attrMigrated = migrateAttributes(s); - 300→ // 星图「能量共振」天赋 +1 能量上限 - 301→ const cm = constellationBonuses(constellation); - 302→ const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus; - 303→ // lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效 - 304→ const lastTideEndRaw = s.lastTideEnd ?? 0; - 305→ // 若旧存档有已过期的星潮,清掉 - 306→ const tide = activeTide && activeTide.endsAt > now ? activeTide : null; - 307→ // 首次进入:补发离线收益 - 308→ const elapsed = Math.max(0, (now - s.lastTick) / 1000); - 309→ if (elapsed > 5) { - 310→ const cap = 8 * 3600; - 311→ const secs = Math.min(elapsed, cap); - 312→ const gain = s.crystalsPerSec * secs * s.offlineEff; - 313→ const crystalsBefore = s.crystals; - 314→ const crystalsAfter = Math.min(s.crystalCap, s.crystals + gain); - 315→ // 设置离线报告(v0.5.2):通知 UI 弹出"欢迎回来"对话框 - 316→ setPendingOfflineReport({ - 317→ elapsedSec: secs, - 318→ rawElapsedSec: elapsed, - 319→ gain: crystalsAfter - crystalsBefore, - 320→ rate: s.crystalsPerSec, - 321→ eff: s.offlineEff, - 322→ capped: elapsed > cap, - 323→ crystalsBefore, - 324→ crystalsAfter, - 325→ crystalCap: s.crystalCap, - 326→ }); - 327→ set({ - 328→ crystals: crystalsAfter, - 329→ lastTick: now, - 330→ activePuzzle, - 331→ achievements, - 332→ activeTide: tide, - 333→ lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, - 334→ constellation, - 335→ pendingPerkChoices, - 336→ energyMax, - 337→ chronicle: migrated.chronicle, - 338→ runStart: migrated.runStart, - 339→ bossKills: migrated.bossKills, - 340→ starTidesEncountered: migrated.starTidesEncountered, - 341→ attributes: attrMigrated.attributes, - 342→ attributeProgress: attrMigrated.attributeProgress, - 343→ pendingAttrPoints: attrMigrated.pendingAttrPoints, - 344→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }), - 345→ }); - 346→ } else { - 347→ 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 }) }); - 348→ } - 349→ }, - 350→ - 351→ loadOnline: () => { - 352→ const s = get(); - 353→ set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) }); - 354→ }, - 355→ - 356→ hardReset: () => { - 357→ set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] }); - 358→ }, - 359→ - 360→ tickTide: (now) => { - 361→ const s = get(); - 362→ const tide = s.activeTide; - 363→ // 星图「星潮引导」减少间隙 - 364→ const cm = constellationBonuses(s.constellation ?? []); - 365→ // v0.7 灵感:星潮触发概率 +X%(缩短间隙) - 366→ const am = getAllBonuses(s.attributes ?? {}); - 367→ const tideGapReduction = Math.min(0.3, am.tideTriggerBonus); - 368→ const gap = Math.max(15000, (TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000) * (1 - tideGapReduction)); - 369→ // 1) 检查当前星潮是否结束 - 370→ if (tide && now >= tide.endsAt) { - 371→ const endedType = tide.type; - 372→ // 寂静期补偿洞见 - 373→ let silenceCompensation = 0; - 374→ if (tide.type === "silence") { - 375→ silenceCompensation = computeSilenceCompensation(tide); - 376→ } - 377→ const newInsights = s.insights + silenceCompensation; - 378→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType }; - 379→ if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation; - 380→ set({ - 381→ activeTide: null, - 382→ lastTideEnd: now, - 383→ insights: newInsights, - 384→ // 星潮结束后重算 stats(移除 contactRate/insight 修饰) - 385→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }), - 386→ _tideEvents: [...s._tideEvents, event], - 387→ }); - 388→ return event; - 389→ } - 390→ // 2) 检查是否该触发新星潮(间隙已过) - 391→ if (!tide) { - 392→ const since = now - s.lastTideEnd; - 393→ // 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap - 394→ const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000; - 395→ const need = firstStart ? TIDE_CONFIG.firstDelay : gap; - 396→ if (since >= need) { - 397→ const type = rollTide(); - 398→ const newTide: StarTide = { - 399→ id: `tide_${now}_${Math.random().toString(36).slice(2, 7)}`, - 400→ type, - 401→ startedAt: now, - 402→ endsAt: now + TIDE_CONFIG.duration, - 403→ }; - 404→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type }; - 405→ // v0.4 编年史:累计遇到的星潮 ID(去重) - 406→ const tidesAll = s.starTidesEncountered ?? []; - 407→ const tideId = `tide_${type}`; - 408→ const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId]; - 409→ set({ - 410→ activeTide: newTide, - 411→ starTidesEncountered: newTidesAll, - 412→ // 星潮开始后重算 stats(应用 contactRate/insight 修饰) - 413→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }), - 414→ _tideEvents: [...s._tideEvents, event], - 415→ }); - 416→ return event; - 417→ } - 418→ } - 419→ return null; - 420→ }, - 421→ - 422→ consumeTideEvents: () => { - 423→ const s = get(); - 424→ if (s._tideEvents.length === 0) return []; - 425→ const items = s._tideEvents; - 426→ set({ _tideEvents: [] }); - 427→ return items; - 428→ }, - 429→ - 430→ tick: (now) => { - 431→ const s = get(); - 432→ const dt = Math.max(0, (now - s.lastTick) / 1000); - 433→ if (dt <= 0) return; - 434→ - 435→ // 星潮产能修饰(即时乘) - 436→ const tideMod = getTideModifiers(s.activeTide); - 437→ const effCps = s.crystalsPerSec * tideMod.crystalsPerSecMult; - 438→ // 产能累加(仅闲置产能受仓库上限;探险奖励可超出) - 439→ const newCrystals = - 440→ s.crystals >= s.crystalCap - 441→ ? s.crystals // 已达/超上限,不再自动产出 - 442→ : Math.min(s.crystalCap, s.crystals + effCps * dt); - 443→ - 444→ // 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速) - 445→ const bpBoost = 1 + s.blueprints.length * 0.03; - 446→ const spawnInterval = (CRYSTAL_SPAWN.baseIntervalSec / bpBoost) * 1000; - 447→ let pending = s.pendingCrystals; - 448→ let lastSpawn = s._lastSpawn; - 449→ if ( - 450→ now - lastSpawn > spawnInterval && - 451→ pending.length < CRYSTAL_SPAWN.maxPending - 452→ ) { - 453→ // 星图「晶体富集」提升 T2/T3 概率 - 454→ const cm = constellationBonuses(s.constellation ?? []); - 455→ const tier = rollCrystalTierWithBonus(cm.t2t3BonusRate); - 456→ const crystal: Crystal = { - 457→ id: `c_${now}_${Math.random().toString(36).slice(2, 7)}`, - 458→ tier, - 459→ value: CRYSTAL_VALUE[tier].crystals, - 460→ createdAt: now, - 461→ }; - 462→ pending = [...pending, crystal]; - 463→ lastSpawn = now; - 464→ } - 465→ - 466→ // 能量恢复(探险系统,v0.8.1 动态间隔) - 467→ let energy = s.energy; - 468→ let lastEnergyTick = s.lastEnergyTick; - 469→ if (energy < s.energyMax) { - 470→ const intervalSec = computeEnergyRegenInterval(s); - 471→ const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax, intervalSec); - 472→ energy = regen.energy; - 473→ lastEnergyTick = regen.lastTick; - 474→ } else { - 475→ lastEnergyTick = now; - 476→ } - 477→ - 478→ set({ - 479→ crystals: newCrystals, - 480→ lastTick: now, - 481→ pendingCrystals: pending, - 482→ _lastSpawn: lastSpawn, - 483→ energy, - 484→ lastEnergyTick, - 485→ }); - 486→ }, - 487→ - 488→ pulse: () => { - 489→ const s = get(); - 490→ const now = Date.now(); - 491→ // 连击 - 492→ let combo = 1; - 493→ if (now - s._lastPulse < 1500) { - 494→ combo = Math.min(10, s._combo + 1); - 495→ } - 496→ const mult = 1 + (combo - 1) * 0.15; - 497→ // 星潮脉冲威力修饰 - 498→ const tideMod = getTideModifiers(s.activeTide); - 499→ // v0.7 灵感:脉冲连击加成 +X% - 500→ const am = getAllBonuses(s.attributes ?? {}); - 501→ const comboBonusMult = 1 + am.pulseComboBonus * Math.max(0, combo - 1); - 502→ const gain = s.pulsePower * mult * tideMod.pulsePowerMult * comboBonusMult; - 503→ set({ - 504→ crystals: Math.min(s.crystalCap, s.crystals + gain), - 505→ _combo: combo, - 506→ _lastPulse: now, - 507→ }); - 508→ // 深空信标:脉冲任务进度 +1 - 509→ trackBeacon("pulse", 1); - 510→ // v0.7 角色属性:连击 ≥3 给灵感经验 - 511→ if (combo >= 3) { - 512→ const expGain = 1 + Math.floor(combo / 2); // 3 连击=2, 5 连击=3, 10 连击=6 - 513→ // 内联经验获取(避免递归调用 set) - 514→ const prog = s.attributeProgress?.inspiration ?? { exp: 0, level: s.attributes?.inspiration ?? 0 }; - 515→ const nextExp = prog.exp + expGain; - 516→ const lvlResult = levelUpCheck( - 517→ { exp: nextExp, level: s.attributes?.inspiration ?? 0 }, - 518→ ATTRIBUTE_HARD_CAP - 519→ ); - 520→ const newAttributes: CharacterAttributes = { - 521→ ...(s.attributes ?? createInitialAttributes()), - 522→ inspiration: lvlResult.newProgress.level, - 523→ }; - 524→ const newProgress: AttributeProgress = { - 525→ ...(s.attributeProgress ?? createInitialAttributeProgress()), - 526→ inspiration: lvlResult.newProgress, - 527→ }; - 528→ set({ - 529→ attributes: newAttributes, - 530→ attributeProgress: newProgress, - 531→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), - 532→ }); - 533→ } - 534→ return { gain, combo }; - 535→ }, - 536→ - 537→ startDecode: (crystalId) => { - 538→ const s = get(); - 539→ const crystal = s.pendingCrystals.find((c) => c.id === crystalId); - 540→ if (!crystal) return; - 541→ const puzzle = generatePuzzle(crystal.tier); - 542→ set({ - 543→ activePuzzle: puzzle, - 544→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId), - 545→ }); - 546→ }, - 547→ - 548→ clickNode: (nodeId) => { - 549→ const s = get(); - 550→ if (!s.activePuzzle) return { ok: false, finished: false, failReason: "无活跃谜题" }; - 551→ // 深拷贝谜题 - 552→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle)); - 553→ const res = tryClickNode(puzzle, nodeId); - 554→ if (res.ok) { - 555→ if (res.finished) { - 556→ // 结算奖励(星潮解码奖励修饰 + 星图「二周目经验」) - 557→ const tideMod = getTideModifiers(s.activeTide); - 558→ const cm = constellationBonuses(s.constellation ?? []); - 559→ const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult); - 560→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult; - 561→ const rewards = { - 562→ crystals: Math.round(base.crystals * finalMult), - 563→ insights: Math.round(base.insights * finalMult), - 564→ contact: +(base.contact * finalMult).toFixed(2), - 565→ }; - 566→ const newTotal = s.totalDecoded + 1; - 567→ const newContact = Math.min(100, s.contact + rewards.contact); - 568→ const newInsights = s.insights + rewards.insights; - 569→ const newCrystals = s.crystals + rewards.crystals; - 570→ // 解锁碎片 - 571→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } }; - 572→ const unlocked = checkFragments(tentative); - 573→ // v0.7 角色属性:完成解码给智慧经验(tier 越高经验越多) - 574→ const wisdomExpGain = puzzle.tier * 2; - 575→ const curAttrs = s.attributes ?? createInitialAttributes(); - 576→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); - 577→ const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom }; - 578→ const wisdomLvl = levelUpCheck( - 579→ { exp: progEntry.exp + wisdomExpGain, level: curAttrs.wisdom }, - 580→ ATTRIBUTE_HARD_CAP - 581→ ); - 582→ const newAttributes: CharacterAttributes = { - 583→ ...curAttrs, - 584→ wisdom: wisdomLvl.newProgress.level, - 585→ }; - 586→ const newProgress: AttributeProgress = { - 587→ ...curProg, - 588→ wisdom: wisdomLvl.newProgress, - 589→ }; - 590→ set({ - 591→ activePuzzle: null, - 592→ crystals: newCrystals, - 593→ insights: newInsights, - 594→ contact: newContact, - 595→ totalDecoded: newTotal, - 596→ fragments: tentative.fragments, - 597→ attributes: newAttributes, - 598→ attributeProgress: newProgress, - 599→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), - 600→ }); - 601→ // 深空信标:解码 +1,洞见累计 - 602→ trackBeacon("decode", 1); - 603→ trackBeacon("insight", rewards.insights); - 604→ return { ok: true, finished: true, failReason: unlocked.join(",") || undefined }; - 605→ } - 606→ // 点击成功但未完成:检测当前局面是否仍可解 - 607→ const solvable = isSolvable(puzzle); - 608→ set({ activePuzzle: puzzle }); - 609→ return { ok: true, finished: false, solvable }; - 610→ } - 611→ return res; - 612→ }, - 613→ - 614→ undoStep: () => { - 615→ const s = get(); - 616→ if (!s.activePuzzle || s.activePuzzle.path.length === 0) return; - 617→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle)); - 618→ const lastId = puzzle.path.pop(); - 619→ if (lastId !== undefined) { - 620→ const node = puzzle.grid.find((n) => n.id === lastId); - 621→ if (node) node.used = false; - 622→ } - 623→ set({ activePuzzle: puzzle }); - 624→ }, - 625→ - 626→ retryPuzzle: () => { - 627→ const s = get(); - 628→ if (!s.activePuzzle) return; - 629→ set({ activePuzzle: resetPuz(s.activePuzzle) }); - 630→ }, - 631→ - 632→ abandonPuzzle: () => { - 633→ const s = get(); - 634→ if (!s.activePuzzle) return; - 635→ // 晶体放回队列末尾 - 636→ const crystal: Crystal = { - 637→ id: `c_${Date.now()}_ret`, - 638→ tier: s.activePuzzle.tier, - 639→ value: CRYSTAL_VALUE[s.activePuzzle.tier].crystals, - 640→ createdAt: Date.now(), - 641→ }; - 642→ set({ - 643→ activePuzzle: null, - 644→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending), - 645→ }); - 646→ }, - 647→ - 648→ autoDecodeTick: () => { - 649→ const s = get(); - 650→ if (!s.autoDecode) return; - 651→ const now = Date.now(); - 652→ // 星图「自动校准」减少自动解码周期 - 653→ const cm = constellationBonuses(s.constellation ?? []); - 654→ // v0.7 智慧:自动解码周期 -X% - 655→ const am = getAllBonuses(s.attributes ?? {}); - 656→ const baseInterval = 12000 + cm.autoDecodeIntervalDeltaSec * 1000; - 657→ const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult); - 658→ if (now - s._lastAutoDecode < interval) return; - 659→ // 找一颗 T1 晶体自动解码 - 660→ const idx = s.pendingCrystals.findIndex((c) => c.tier === 1); - 661→ if (idx < 0) return; - 662→ const crystal = s.pendingCrystals[idx]; - 663→ const tideMod = getTideModifiers(s.activeTide); - 664→ const base = decodeRewards(1, s.insightMult, s.contactRateMult); - 665→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult; - 666→ const rewards = { - 667→ crystals: Math.round(base.crystals * finalMult), - 668→ insights: Math.round(base.insights * finalMult), - 669→ contact: +(base.contact * finalMult).toFixed(2), - 670→ }; - 671→ const newTotal = s.totalDecoded + 1; - 672→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } }; - 673→ checkFragments(tentative); - 674→ // v0.7 角色属性:自动解码给智慧经验(少量) - 675→ const curAttrs = s.attributes ?? createInitialAttributes(); - 676→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); - 677→ const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom }; - 678→ const wisdomLvl = levelUpCheck( - 679→ { exp: progEntry.exp + 1, level: curAttrs.wisdom }, - 680→ ATTRIBUTE_HARD_CAP - 681→ ); - 682→ const newAttributes: CharacterAttributes = { - 683→ ...curAttrs, - 684→ wisdom: wisdomLvl.newProgress.level, - 685→ }; - 686→ const newProgress: AttributeProgress = { - 687→ ...curProg, - 688→ wisdom: wisdomLvl.newProgress, - 689→ }; - 690→ set({ - 691→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id), - 692→ crystals: s.crystals + rewards.crystals, - 693→ insights: s.insights + rewards.insights, - 694→ contact: Math.min(100, s.contact + rewards.contact), - 695→ totalDecoded: newTotal, - 696→ fragments: tentative.fragments, - 697→ _lastAutoDecode: now, - 698→ attributes: newAttributes, - 699→ attributeProgress: newProgress, - 700→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), - 701→ }); - 702→ // 深空信标:自动解码也算进度 - 703→ trackBeacon("decode", 1); - 704→ trackBeacon("insight", rewards.insights); - 705→ }, - 706→ - 707→ buyTech: (techId) => { - 708→ const s = get(); - 709→ const node = TECH_TREE.find((t) => t.id === techId); - 710→ if (!node) return false; - 711→ const cur = s.tech[techId] ?? 0; - 712→ if (cur >= 1) return false; // v0.1 每节点 1 级 - 713→ if (s.insights < node.cost) return false; - 714→ const newTech = { ...s.tech, [techId]: 1 }; - 715→ set({ - 716→ insights: s.insights - node.cost, - 717→ tech: newTech, - 718→ ...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }), - 719→ }); - 720→ return true; - 721→ }, - 722→ - 723→ // ============ 探险系统 ============ - 724→ startExpedition: () => { - 725→ const s = get(); - 726→ if (s.activeExpedition && !s.activeExpedition.finished) { - 727→ return { ok: false, reason: "已有进行中的探险" }; - 728→ } - 729→ if (s.energy < EXPEDITION_CONFIG.energyCost) { - 730→ return { ok: false, reason: "能量不足" }; - 731→ } - 732→ const tideMod = getTideModifiers(s.activeTide); - 733→ // v0.7 角色属性:探索力 +X% 探险力,勇气 +X 探险生命 - 734→ const am = getAllBonuses(s.attributes ?? {}); - 735→ const basePower = computeExpeditionPower(s) + tideMod.expeditionPowerBonus; - 736→ const baseHp = computeExpeditionHp(s) + tideMod.expeditionHpBonus; - 737→ const power = Math.round(basePower * am.expeditionPowerMult); - 738→ const hp = baseHp + am.expeditionHpBonus; - 739→ const seed = Math.floor(Math.random() * 1e9); - 740→ const expedition = generateExpedition(seed, power, hp); - 741→ set({ - 742→ activeExpedition: expedition, - 743→ energy: s.energy - EXPEDITION_CONFIG.energyCost, - 744→ totalExpeditions: s.totalExpeditions + 1, - 745→ }); - 746→ return { ok: true }; - 747→ }, - 748→ - 749→ resolveCurrentNode: () => { - 750→ const s = get(); - 751→ if (!s.activeExpedition || s.activeExpedition.finished) return null; - 752→ // 深拷贝 - 753→ const exp = JSON.parse(JSON.stringify(s.activeExpedition)); - 754→ // v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值) - 755→ const nodeBefore = exp.nodes[exp.currentNode]; - 756→ const isBossNode = nodeBefore?.type === "boss"; - 757→ const am = getAllBonuses(s.attributes ?? {}); - 758→ const result = isBossNode - 759→ ? resolveNode(exp, () => { - 760→ // 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布) - 761→ const r = Math.random(); - 762→ const b = Math.min(0.95, am.bossWinRateBonus); - 763→ return r < b ? 0 : Math.min(1, r - b); - 764→ }) - 765→ : resolveNode(exp); - 766→ // 累计奖励 - 767→ if (result.crystals) exp.rewards.crystals += result.crystals; - 768→ if (result.insights) exp.rewards.insights += result.insights; - 769→ if (result.contact) exp.rewards.contact += result.contact; - 770→ if (result.fragments) exp.rewards.fragments.push(...result.fragments); - 771→ if (result.hpDelta) exp.hp = Math.max(0, Math.min(exp.maxHp, exp.hp + result.hpDelta)); - 772→ // 实时入账(玩家立即获得) - 773→ const newCrystals = s.crystals + (result.crystals || 0); - 774→ const newInsights = s.insights + (result.insights || 0); - 775→ const newContact = Math.min(100, s.contact + (result.contact || 0)); - 776→ // 碎片解锁 - 777→ const newFragments = { ...s.fragments }; - 778→ if (result.fragments) { - 779→ for (const fid of result.fragments) newFragments[fid] = true; - 780→ } - 781→ // 日志 - 782→ const logEntry = { - 783→ expeditionId: exp.id, - 784→ nodeType: nodeBefore?.type || "combat", - 785→ result: result.log, - 786→ rewards: [ - 787→ result.crystals ? `+${result.crystals}晶体` : "", - 788→ result.insights ? `+${result.insights}洞见` : "", - 789→ result.contact ? `+${result.contact.toFixed(1)}接触` : "", - 790→ result.hpDelta ? `${result.hpDelta > 0 ? "+" : ""}${result.hpDelta}生命` : "", - 791→ ].filter(Boolean).join(" "), - 792→ timestamp: Date.now(), - 793→ }; - 794→ const newLog = [logEntry, ...s.expeditionLog].slice(0, 30); - 795→ - 796→ if (result.ended) { - 797→ // 探险结束(胜利或失败) - 798→ exp.finished = true; - 799→ } - 800→ - 801→ // v0.4 编年史:击破 BOSS 时累计计数 - 802→ let bossKills = s.bossKills ?? 0; - 803→ let bossKilledThisNode = false; - 804→ if ( - 805→ result.ended && - 806→ result.endReason === "victory" && - 807→ nodeBefore?.type === "boss" - 808→ ) { - 809→ bossKills = bossKills + 1; - 810→ bossKilledThisNode = true; - 811→ } - 812→ - 813→ // v0.7 角色属性:战斗胜利给勇气+探索力经验;BOSS 额外奖励 - 814→ let newAttributes = s.attributes ?? createInitialAttributes(); - 815→ let newProgress = s.attributeProgress ?? createInitialAttributeProgress(); - 816→ let statsNeedResync = false; - 817→ // 战斗类节点(combat/boss)且胜利:勇气 + 探索力经验 - 818→ const isCombatVictory = - 819→ (nodeBefore?.type === "combat" || nodeBefore?.type === "boss") && - 820→ !result.ended; // 中途战斗胜利(未结束探险) - 821→ const isExpeditionVictory = - 822→ result.ended && result.endReason === "victory"; - 823→ if (isCombatVictory || bossKilledThisNode || isExpeditionVictory) { - 824→ const courageGain = bossKilledThisNode ? 8 : 2; - 825→ const explorationGain = bossKilledThisNode ? 6 : isExpeditionVictory ? 4 : 1; - 826→ const courageLvl = levelUpCheck( - 827→ { exp: newProgress.courage.exp + courageGain, level: newAttributes.courage }, - 828→ ATTRIBUTE_HARD_CAP - 829→ ); - 830→ const explLvl = levelUpCheck( - 831→ { exp: newProgress.exploration.exp + explorationGain, level: newAttributes.exploration }, - 832→ ATTRIBUTE_HARD_CAP - 833→ ); - 834→ newAttributes = { - 835→ ...newAttributes, - 836→ courage: courageLvl.newProgress.level, - 837→ exploration: explLvl.newProgress.level, - 838→ }; - 839→ newProgress = { - 840→ ...newProgress, - 841→ courage: courageLvl.newProgress, - 842→ exploration: explLvl.newProgress, - 843→ }; - 844→ statsNeedResync = true; - 845→ } - 846→ - 847→ set({ - 848→ activeExpedition: exp, - 849→ crystals: newCrystals, - 850→ insights: newInsights, - 851→ contact: newContact, - 852→ fragments: newFragments, - 853→ expeditionLog: newLog, - 854→ bossKills, - 855→ attributes: newAttributes, - 856→ attributeProgress: newProgress, - 857→ ...(statsNeedResync - 858→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }) - 859→ : {}), - 860→ }); - 861→ // 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破 - 862→ if (result.ended) { - 863→ trackBeacon("expedition", 1); - 864→ if (bossKilledThisNode) { - 865→ trackBeacon("boss", 1); - 866→ } - 867→ } - 868→ if (result.insights) trackBeacon("insight", result.insights); - 869→ return result; - 870→ }, - 871→ - 872→ advanceNode: () => { - 873→ const s = get(); - 874→ if (!s.activeExpedition || s.activeExpedition.finished) return; - 875→ const exp = JSON.parse(JSON.stringify(s.activeExpedition)); - 876→ const node = exp.nodes[exp.currentNode]; - 877→ if (!node || !node.cleared) return; // 当前节点未结算不能前进 - 878→ if (exp.currentNode >= exp.nodes.length - 1) return; - 879→ exp.currentNode++; - 880→ set({ activeExpedition: exp }); - 881→ }, - 882→ - 883→ abortExpedition: () => { - 884→ const s = get(); - 885→ if (!s.activeExpedition) return; - 886→ const exp = JSON.parse(JSON.stringify(s.activeExpedition)); - 887→ exp.finished = true; - 888→ const logEntry = { - 889→ expeditionId: exp.id, - 890→ nodeType: "rest" as const, - 891→ result: "探险队主动撤退,保留已获奖励。", - 892→ rewards: "", - 893→ timestamp: Date.now(), - 894→ }; - 895→ set({ - 896→ activeExpedition: exp, - 897→ expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30), - 898→ }); - 899→ }, - 900→ - 901→ doPrestige: () => { - 902→ const s = get(); - 903→ if (s.contact < CONTACT.prestigeMin) return null; - 904→ const newBp = computeNewBlueprints(s); - 905→ const next = performPrestige(s); - 906→ // 星图「能量共振」提升上限 - 907→ const cm = constellationBonuses(next.constellation ?? []); - 908→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus; - 909→ set({ - 910→ ...next, - 911→ energyMax, - 912→ ...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation, attributes: next.attributes }), - 913→ _lastAutoDecode: Date.now(), - 914→ _lastSpawn: Date.now(), - 915→ _combo: 0, - 916→ _lastPulse: 0, - 917→ _tideEvents: [], - 918→ }); - 919→ return { newBp }; - 920→ }, - 921→ - 922→ chooseConstellationPerk: (perkId) => { - 923→ const s = get(); - 924→ if (!s.pendingPerkChoices || !s.pendingPerkChoices.includes(perkId)) return false; - 925→ const perk = getPerk(perkId); - 926→ if (!perk) return false; - 927→ if (s.constellation?.includes(perkId)) return false; - 928→ const newConstellation = [...(s.constellation ?? []), perkId]; - 929→ const cm = constellationBonuses(newConstellation); - 930→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus; - 931→ // v0.4 编年史:回填最近一条 entry 的 perksThisAscension - 932→ const chronicle = s.chronicle ?? []; - 933→ let newChronicle = chronicle; - 934→ if (chronicle.length > 0) { - 935→ const lastEntry = chronicle[chronicle.length - 1]; - 936→ const updatedLast = withPerks(lastEntry, [perkId]); - 937→ newChronicle = [...chronicle.slice(0, -1), updatedLast]; - 938→ } - 939→ set({ - 940→ constellation: newConstellation, - 941→ pendingPerkChoices: null, - 942→ energyMax, - 943→ chronicle: newChronicle, - 944→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation, attributes: s.attributes }), - 945→ }); - 946→ return true; - 947→ }, - 948→ - 949→ rerollPerkChoices: () => { - 950→ const s = get(); - 951→ if (!s.pendingPerkChoices) return; - 952→ const choices = rollPerkChoices(s.constellation ?? []); - 953→ if (choices.length > 0) set({ pendingPerkChoices: choices }); - 954→ }, - 955→ - 956→ checkAchievements: () => { - 957→ const s = get(); - 958→ const newlyUnlocked: Achievement[] = []; - 959→ const updated = { ...s.achievements }; - 960→ let crystals = s.crystals; - 961→ let insights = s.insights; - 962→ let contact = s.contact; - 963→ let statsDirty = false; - 964→ for (const a of ACHIEVEMENTS) { - 965→ if (updated[a.id]) continue; - 966→ if (a.check(s)) { - 967→ updated[a.id] = true; - 968→ newlyUnlocked.push(a); - 969→ // 发放即时奖励 - 970→ if (a.reward.crystals) crystals += a.reward.crystals; - 971→ if (a.reward.insights) insights += a.reward.insights; - 972→ if (a.reward.contact) contact = Math.min(100, contact + a.reward.contact); - 973→ if (a.reward.crystalsPerSecPct || a.reward.insightPct) statsDirty = true; - 974→ } - 975→ } - 976→ if (newlyUnlocked.length === 0) return []; - 977→ set({ - 978→ achievements: updated, - 979→ crystals, - 980→ insights, - 981→ contact, - 982→ ...(statsDirty - 983→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) - 984→ : {}), - 985→ _achievementQueue: [...s._achievementQueue, ...newlyUnlocked], - 986→ }); - 987→ return newlyUnlocked; - 988→ }, - 989→ - 990→ consumeAchievementQueue: () => { - 991→ const s = get(); - 992→ if (s._achievementQueue.length === 0) return []; - 993→ const items = s._achievementQueue; - 994→ set({ _achievementQueue: [] }); - 995→ return items; - 996→ }, - 997→ - 998→ canPrestige: () => get().contact >= CONTACT.prestigeMin, - 999→ - 1000→ toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }), - 1001→ toggleSound: () => set({ soundOn: !get().soundOn }), - 1002→ - 1003→ // 深空信标:发放每日挑战奖励(v0.5) - 1004→ grantBeaconReward: (insights, contact) => { - 1005→ const s = get(); - 1006→ set({ - 1007→ insights: s.insights + Math.round(insights), - 1008→ contact: Math.min(100, s.contact + contact), - 1009→ }); - 1010→ }, - 1011→ - 1012→ // 深空信标:领取周挑战奖励(v0.8) - 1013→ claimWeeklyBeacon: () => { - 1014→ const challenge = generateWeeklyChallenge(); - 1015→ const progress = loadWeeklyProgress(); - 1016→ const res = claimWeeklyReward(challenge, progress); - 1017→ if (res.rewardInsight > 0 || res.rewardContact > 0) { - 1018→ const s = get(); - 1019→ set({ - 1020→ insights: s.insights + Math.round(res.rewardInsight), - 1021→ contact: Math.min(100, s.contact + res.rewardContact), - 1022→ }); - 1023→ } - 1024→ return { - 1025→ rewardInsight: res.rewardInsight, - 1026→ rewardContact: res.rewardContact, - 1027→ score: res.score, - 1028→ }; - 1029→ }, - 1030→ - 1031→ // 深空信标:领取信标链里程碑奖励(v0.8) - 1032→ claimChainReward: (milestone) => { - 1033→ // loadChainState 仅用于前置校验,真正的状态修改由 claimChainMilestone 完成 - 1034→ const pre = loadChainState(); - 1035→ if ( - 1036→ pre.currentStreak < milestone || - 1037→ pre.milestonesClaimed.includes(milestone) - 1038→ ) { - 1039→ return { - 1040→ rewardInsight: 0, - 1041→ rewardContact: 0, - 1042→ label: "", - 1043→ ok: false, - 1044→ }; - 1045→ } - 1046→ const res = claimChainMilestone(milestone); - 1047→ if (res.rewardInsight > 0 || res.rewardContact > 0) { - 1048→ const s = get(); - 1049→ set({ - 1050→ insights: s.insights + Math.round(res.rewardInsight), - 1051→ contact: Math.min(100, s.contact + res.rewardContact), - 1052→ }); - 1053→ } - 1054→ return { - 1055→ rewardInsight: res.rewardInsight, - 1056→ rewardContact: res.rewardContact, - 1057→ label: res.label, - 1058→ ok: res.rewardInsight > 0 || res.rewardContact > 0, - 1059→ }; - 1060→ }, - 1061→ - 1062→ // 深空巡航:发放实时玩法奖励(v0.6) - 1063→ grantCruiseReward: (rewards) => { - 1064→ const s = get(); - 1065→ const addCrystals = rewards.crystals ?? 0; - 1066→ const addInsights = rewards.insights ?? 0; - 1067→ const addContact = rewards.contact ?? 0; - 1068→ // v0.7 角色属性:巡航通关给探索力+勇气经验(按晶体奖励量缩放) - 1069→ const totalReward = addCrystals + addInsights * 10 + addContact * 10; - 1070→ const expBase = Math.max(2, Math.floor(totalReward / 30)); - 1071→ const curAttrs = s.attributes ?? createInitialAttributes(); - 1072→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); - 1073→ const explLvl = levelUpCheck( - 1074→ { exp: curProg.exploration.exp + expBase, level: curAttrs.exploration }, - 1075→ ATTRIBUTE_HARD_CAP - 1076→ ); - 1077→ const courageLvl = levelUpCheck( - 1078→ { exp: curProg.courage.exp + Math.floor(expBase * 0.6), level: curAttrs.courage }, - 1079→ ATTRIBUTE_HARD_CAP - 1080→ ); - 1081→ const newAttributes: CharacterAttributes = { - 1082→ ...curAttrs, - 1083→ exploration: explLvl.newProgress.level, - 1084→ courage: courageLvl.newProgress.level, - 1085→ }; - 1086→ const newProgress: AttributeProgress = { - 1087→ ...curProg, - 1088→ exploration: explLvl.newProgress, - 1089→ courage: courageLvl.newProgress, - 1090→ }; - 1091→ set({ - 1092→ crystals: Math.min(s.crystalCap, s.crystals + addCrystals), - 1093→ insights: s.insights + Math.round(addInsights), - 1094→ contact: Math.min(100, s.contact + addContact), - 1095→ attributes: newAttributes, - 1096→ attributeProgress: newProgress, - 1097→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), - 1098→ }); - 1099→ }, - 1100→ - 1101→ // ============ 角色属性系统(v0.7 P1) ============ - 1102→ allocateAttribute: (attr, points = 1) => { - 1103→ const s = get(); - 1104→ const cur = s.attributes ?? createInitialAttributes(); - 1105→ const curVal = cur[attr] ?? 0; - 1106→ if (curVal >= ATTRIBUTE_HARD_CAP) { - 1107→ return { ok: false, leveledUp: 0 }; - 1108→ } - 1109→ if ((s.pendingAttrPoints ?? 0) < points) { - 1110→ return { ok: false, leveledUp: 0 }; - 1111→ } - 1112→ const alloc = Math.min(points, ATTRIBUTE_HARD_CAP - curVal, s.pendingAttrPoints); - 1113→ const newVal = curVal + alloc; - 1114→ const newAttributes: CharacterAttributes = { ...cur, [attr]: newVal }; - 1115→ // 同步经验进度 level 字段(保持一致) - 1116→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); - 1117→ const oldProg = curProg[attr] ?? { exp: 0, level: curVal }; - 1118→ const newProgress: AttributeProgress = { - 1119→ ...curProg, - 1120→ [attr]: { exp: oldProg.exp, level: newVal }, - 1121→ }; - 1122→ set({ - 1123→ attributes: newAttributes, - 1124→ attributeProgress: newProgress, - 1125→ pendingAttrPoints: s.pendingAttrPoints - alloc, - 1126→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), - 1127→ }); - 1128→ return { ok: true, leveledUp: alloc }; - 1129→ }, - 1130→ - 1131→ gainAttributeExp: (attr, amount) => { - 1132→ const s = get(); - 1133→ const cur = s.attributes ?? createInitialAttributes(); - 1134→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); - 1135→ const progEntry = curProg[attr] ?? { exp: 0, level: cur[attr] }; - 1136→ const result = levelUpCheck( - 1137→ { exp: progEntry.exp + amount, level: cur[attr] }, - 1138→ ATTRIBUTE_HARD_CAP - 1139→ ); - 1140→ const newAttributes: CharacterAttributes = { ...cur, [attr]: result.newProgress.level }; - 1141→ const newProgress: AttributeProgress = { - 1142→ ...curProg, - 1143→ [attr]: result.newProgress, - 1144→ }; - 1145→ set({ - 1146→ attributes: newAttributes, - 1147→ attributeProgress: newProgress, - 1148→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), - 1149→ }); - 1150→ return { leveledUp: result.levelsGained, newLevel: result.newProgress.level }; - 1151→ }, - 1152→ }), - 1153→ { - 1154→ name: "echo-nexus-save-v1", - 1155→ storage: createJSONStorage(() => localStorage), - 1156→ // 不持久化临时字段 - 1157→ partialize: (s) => { - 1158→ const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s; - 1159→ void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents; - 1160→ return rest as GameState; - 1161→ }, - 1162→ } - 1163→ ) - 1164→); - 1165→ - 1166→/** 选择器:未解锁碎片中下一个门槛 */ - 1167→export function nextFragmentThreshold(totalDecoded: number): number | null { - 1168→ for (const f of FRAGMENTS) { - 1169→ if (totalDecoded < f.threshold) return f.threshold; - 1170→ } - 1171→ return null; - 1172→} - 1173→ - 1174→export { FRAGMENTS, PRESTIGE, TECH_TREE }; - 1175→ - 1176→// 开发期调试:暴露 store 到 window,便于 QA 测试 - 1177→if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") { - 1178→ (window as unknown as { __gameStore?: typeof useGameStore }).__gameStore = useGameStore; - 1179→} - 1180→ \ No newline at end of file diff --git a/worklog.md b/worklog.md index ab34991ba..736cf3578 100755 --- a/worklog.md +++ b/worklog.md @@ -568,3 +568,160 @@ Stage Summary: 4. 🟡 遗迹图鉴多层文本(Dark Souls 式 loreFragments)—— v0.15 计划 5. 🟡 解码阈值释放叙事碎片 + 星潮三频段切换 —— v0.16 计划 6. 🟡 健康设计:每日软上限 + "星核建议休眠"提醒 —— v0.17 计划 + +--- +Task ID: 2-b +Agent: full-stack-developer +Task: v0.15 叙事深化 — Fragment loreLayers 多层文本 + Codex 分层渲染 + +Work Log: +- 阅读 worklog v0.14-reimpl 段落 + FRAGMENTS 数组 (config.ts L254-320) + Fragment 接口 (types.ts L48-57) + Codex.tsx (128 行) + gameStore 导出确认 +- 扩展 `src/lib/game/types.ts`:新增 `FragmentLoreUnlockKind` 联合类型 + `FragmentLoreUnlock` + `FragmentLoreLayer` 两个接口,Fragment 接口新增可选 `loreLayers?: FragmentLoreLayer[]` 字段 +- 扩展 `src/lib/game/config.ts`:为全部 8 个 FRAGMENTS 撰写 loreLayers(每碎片 2 层 L1+L2,共 16 层),新增导出 `isLoreLayerUnlocked(unlock, state)` 纯函数(基于 Pick 判定,5 种 kind 全覆盖) +- 解锁条件多样性:ascensions 1/2/3、totalDecoded 40/60、contact 50/80、fragmentCount 4/6、bossKills 3/8 +- 重构 `src/components/game/Codex.tsx`: + - useShallow 一次订阅 5 字段 + - 顶部新增"深层叙事 X/Y 已解锁"统计行(amber 数字) + - 列表卡片:碎片标题旁根据 L1/L2 解锁状态显示 amber/fuchsia glow 小圆点 + - 详情弹层 3 层结构:L0 表层回响(emerald) → L1 隐秘注脚(amber) → L2 深层回响(fuchsia) + - 未解锁层用 ▓░▒ 乱码占位 + blur-sm select-none + 🔒 unlockHint + - 移动端 375px 适配(p-3 padding、text-[12px]、长 hint 极窄屏隐藏) +- 验证:lint 零错误零警告(修复了 1 处 unused eslint-disable 警告);dev HTTP 200;dev.log 末尾连续多次 "✓ Compiled" 无运行时错误 +- 未修改 persist version / engine / gameStore(与子代理 A 零冲突边界遵守) +- 写入 agent-ctx/2-b-full-stack-developer.md 工作记录 + +Stage Summary: +- ✅ 为全部 8 个碎片添加 loreLayers(目标 ≥6,超额完成 8/8,共 16 层) +- ✅ 5 条贯穿叙事暗线:(1) 追逐者线 — 以太族被虚空追逐者逼迫飞升 (2) 回响 AI 同源线 — 玩家舰载 AI 频段源自以太族议会档案,玩家即是延续 (3) "信任"即"利用"线 — 第一纪元的信任后来者实为引诱完成未竟之事 (4) 飞升代价线 — 单向门 + 替以太族还债 (5) 满溢回归线 — 接触满 80 时回归的是"借他们形状的某种东西" +- ✅ Codex 3 层渲染:L0 emerald(永远显示)→ L1 amber → L2 fuchsia,严格 4 色全息零蓝/靛 +- ✅ 未解锁层视觉:乱码方块 + blur-sm + 🔒 + unlockHint,移动端可读不溢出 +- ✅ 列表卡片标题旁彩色小圆点暗示"此碎片有更多内容" +- ✅ 顶部"深层叙事 X/Y 已解锁"实时统计 +- ✅ 修改 3 文件:/home/z/my-project/src/lib/game/types.ts + /home/z/my-project/src/lib/game/config.ts + /home/z/my-project/src/components/game/Codex.tsx +- ✅ lint 零错误零警告 + HTTP 200 + dev.log 无运行时错误 +- ⚠️ 未做 agent-browser 端到端验证(按工单由主控完成);未持久化 loreLayer 解锁进度(纯派生无需);如需"已读"标记需改 gameStore 与子代理 A 边界冲突,本轮未做 + +--- +Task ID: 2-a +Agent: full-stack-developer +Task: v0.15 平衡与 UX 增强 — softcap + 批量领取 + 完成 toast + StatsPanel idle 统计 + +Work Log: +- 阅读上下文:worklog.md (v0.14-reimpl/release 段) + engine.ts (line 25-125 recomputeStats) + gameStore.ts (claimIdleProject line 1208-1290, tickIdleProjects line 1292-1314) + IdleOperationsPanel.tsx (487 行) + idle.ts (6 个放置工程定义) + types.ts (idle 字段 line 180-188) + page.tsx (内联 StatsPanel line 475-529) +- 发现 StatsPanel.tsx 不存在为独立文件,实际为 page.tsx 内联函数(行 475-529),决定抽出为独立组件以满足"只修改 StatsPanel.tsx"的约束 +- 子任务 1(softcap): + - 在 `engine.ts` 顶部新增并导出 `applyIdleSoftcap(bonus)` 纯函数 + - 分段公式:bonus ≤ 30 全额;30 < bonus ≤ 100 部分 × 0.5;bonus > 100 部分 × 0.25 + - 临界值验证:bonus=30 → 30;bonus=100 → 65;bonus=200 → 90 + - 替换 recomputeStats line 113 的 `crystalsPerSec += state.idlePermanentBonus ?? 0` 为 `crystalsPerSec += applyIdleSoftcap(state.idlePermanentBonus ?? 0)` + - 注意:crystalsPerSec 现已包含 softcap 后的永久加成(store 内的 crystalsPerSec 字段已是正确总值),UI 端 idleTotalPerSec 直接等于 crystalsPerSec(修复了旧代码 crystalsPerSec + idlePermanentBonus 双重计算的 bug) +- 子任务 2(批量领取): + - `gameStore.ts` GameActions 接口新增 `claimAllIdleProjects: () => void` 签名 + - 实现 `claimAllIdleProjects`:遍历 idleProjectSlots 收集 completed 槽位 → 累加 crystals/insights/energy/contact/permBonus → randomFragment 处理(独立 fragments 副本避免重复) → 一次 set() + 一次 syncStats() → 汇总 toast `✦ 批量领取完成 · 共领取 N 项工程 · +X 晶体 · +Y 洞见 · ...` + - 无可领取项时直接 return(不弹 toast) + - 历史批量追加(上限 50 条 slice) +- 子任务 3(完成 toast): + - 修改 `tickIdleProjects`:循环中检测 `!slot.completed && remainingSec <= 0` 状态转变瞬间,收集 `justCompletedProjects: IdleProjectDef[]` + - 循环外(仅当列表非空)调用一次 `toast({ title: "✦ 放置工程完成", description: "${icon} ${name} · ... · 可前往「放置」标签领取" })` + - 一次 tick 最多弹一条 toast(多个同时完成也只汇总为一条),避免 250ms 调用频率导致的 spam +- 子任务 4(StatsPanel idle 统计): + - 新建 `src/components/game/StatsPanel.tsx`(独立组件,从 page.tsx 抽出) + - 用 `useShallow` 一次性订阅所有渲染所需字段(24 个字段),避免整个 store 变化触发 re-render + - 保留原有 25 行统计 + 新增 1 行 `放置工程历史`(idleProjectHistory.length,最近 50 条跨周目累计) + - page.tsx 最小化改动:删除内联 StatsPanel 函数(55 行)+ 添加 `import { StatsPanel } from "@/components/game/StatsPanel"` + 清理 3 个仅在内联函数使用的 imports(TECH_TREE, CONSTELLATION_PERKS, loadLeaderboard) +- UI 增强(IdleOperationsPanel.tsx): + - Section 1「永久加成」StatTile 增加 `sub` 可选 prop(ReactNode),当 `isSoftcapped = effective < raw` 时渲染 amber 色副行 `有效 +X/s(原始 +Y/s · 衰减中)` + - StatTile 显示 effectivePermBonus 而非原始值(用户看到的是实际生效的数值) + - Section 3 标题右侧改为 flex 容器:保留 `N / 3` 计数 + 当 `claimableCount > 0` 时显示 emerald 主题「✓ 领取全部」按钮(h-5 px-2 text-[10px]),点击调用 `claimAllIdleProjects` +- 严格 4 色全息:softcap 副行 amber、领取全部按钮 emerald,无蓝/靛 +- 持久化兼容:persist version 仍为 v1,migrate 函数未改,新 action 不需要 migrate +- 移动端适配:领取全部按钮高度 h-5(20px)+ px-2 + text-[10px],与现有「N / 3」文字同行 flex 排列,375px 屏宽下不溢出;softcap 副行 text-[9px] leading-tight 在 StatTile 内部不撑破 grid-cols-3 + +Stage Summary: +- ✅ 子任务 1:applyIdleSoftcap 纯函数 + recomputeStats 集成完成(30/100 阈值,0.5/0.25 系数) +- ✅ 子任务 2:claimAllIdleProjects action + GameActions 接口签名 + UI 按钮(emerald 主题)完成 +- ✅ 子任务 3:tickIdleProjects 状态转变检测 + 单次 toast 通知完成(避免 250ms tick spam) +- ✅ 子任务 4:StatsPanel 抽出为独立组件 + useShallow 订阅 + 新增「放置工程历史」行完成 +- ✅ 修改文件清单: + - `/home/z/my-project/src/lib/game/engine.ts`(新增 applyIdleSoftcap 导出 + recomputeStats 改 1 行) + - `/home/z/my-project/src/store/gameStore.ts`(GameActions 加 1 签名 + tickIdleProjects 改写 + 新增 claimAllIdleProjects action ~120 行) + - `/home/z/my-project/src/components/game/IdleOperationsPanel.tsx`(导入 applyIdleSoftcap + 订阅 claimAllIdleProjects + softcap 副行 + 领取全部按钮 + StatTile 扩展 sub prop) + - `/home/z/my-project/src/components/game/StatsPanel.tsx`(新建,~110 行,useShallow 订阅 + 26 行统计) + - `/home/z/my-project/src/app/page.tsx`(最小化改动:删除内联 StatsPanel + 1 行 import + 清理 3 个未使用 imports) +- ✅ lint 零错误零警告(`bun run lint`) +- ✅ dev.log 全程 HTTP 200,编译 < 260ms,无运行时错误 +- ✅ 持久化兼容:version 1 保留,新 action 无需 migrate +- ✅ 严格 4 色全息(emerald/rose/amber/fuchsia),无蓝/靛 +- ✅ useShallow 全部 store 订阅使用 +- ⚠️ 备注:page.tsx 是必要的最小化集成改动(删除内联 StatsPanel 函数 + 添加 import),不与子代理 B 冲突(B 改的是 types/config/idle/Codex) +- ⚠️ 备注:在修复 idleTotalPerSec 时发现 v0.14 旧代码 `crystalsPerSec + idlePermanentBonus` 存在双重计算 bug(crystalsPerSec 已包含永久加成),v0.15 修正为 `idleTotalPerSec = crystalsPerSec`,UI 显示的「放置产能」现在是正确的总产能 + + +--- +Task ID: v0.15-release +Agent: 主控 Z.ai Code + subagent(2-a) + subagent(2-b) +Task: v0.15 叙事深化 + 放置平衡 + UX 增强 — loreLayers 多层碎片文本 + 永久加成 softcap + 批量领取 + 完成 toast + +Work Log: +- 主控读取 v0.14 现状(worklog + idle.ts + types.ts + engine.ts + gameStore.ts + IdleOperationsPanel.tsx + Codex.tsx + config.ts FRAGMENTS) +- 主控规划 v0.15 任务分解为 2 个并行子代理(修改文件严格不重叠): + - 2-a: engine.ts / gameStore.ts / IdleOperationsPanel.tsx / StatsPanel.tsx(新建)/ page.tsx(最小集成) + - 2-b: types.ts / config.ts / Codex.tsx +- 委派 subagent(2-a) full-stack-developer:v0.15 平衡与 UX 增强(4 项子任务) +- 委派 subagent(2-b) full-stack-developer:v0.15 叙事深化(loreLayers 多层文本) +- 两个子代理并行完成,零文件冲突 +- 主控集成验证: + * bun run lint 零错误零警告 + * dev.log HTTP 200,编译 < 260ms,无运行时错误 + * agent-browser 端到端 smoke test 全流程通过: + - 默认放置 tab 渲染正常(修复 v0.14 双重计算 bug:idleTotalPerSec 现在正确显示总产能) + - 派遣 idle_refine → eval 快进 finishesAt → tickIdleProjects 标记 completed=true ✓ + - "✓ 领取全部"按钮出现 ✓ + 槽位显示"✓ 完成 · 永久 +0.3/s" ✓ + - 点击"领取全部" → permBonus 0→0.3, projectsCompleted 1→2, slots 清空 ✓ + - eval 设置 permBonus=50 → UI 显示"永久加成 +40/s" + amber 副行"有效 +40/s(原始 +50/s · 衰减中)" ✓ + (softcap 公式验证: applyIdleSoftcap(50) = 30 + (50-30)*0.5 = 40 ✓) + - 图谱 tab: 8 碎片列表 + "深层叙事 0/16"统计 ✓ + - eval 解锁全部 fragments → 点击"第一缕谐振" → L0 表层回响显示完整 echo(emerald)+ L1/L2 显示"???" + 🔒 提示 ✓ + - eval 设置 ascensions=1 → L1"隐秘注脚"解锁,显示"档案的边缘有一行几乎被擦去的批注..." ✓ + - eval 设置 totalDecoded=50 → L2"深层回响"解锁,显示"你终于明白:那缕谐振的频率..." ✓ + - 深层叙事计数 0→2→6 随解锁条件递增 ✓ + - 统计 tab: StatsPanel 显示"放置永久加成 +0.3/s / 完成放置工程 2 项 / 放置产出晶体 0 / 放置工程历史 2 条" ✓ +- 主控更新版本号 v0.14 → v0.15(page.tsx line 227) +- 主控更新 README.md:版本行 / 设计哲学标题 / 版本里程碑表 / docs 引用 / deploy 命令 +- 主控创建 docs/12-叙事深化-v0.15.md 设计文档 +- 主控恢复测试存档(localStorage.clear + reload)确保 cron job QA 干净 + +Stage Summary: + +## v0.15 叙事深化 + 放置平衡 + UX 增强已上线 + +### 核心交付(2 个子代理并行,8 个文件改动) + +**子代理 2-a(平衡 + UX,5 文件):** +- ✅ P0 平衡:`applyIdleSoftcap(bonus)` 纯函数 — 分段衰减(≤30 全额 / 30-100 ×0.5 / >100 ×0.25),recomputeStats 调用,UI 显示"有效/原始"对比 + amber"衰减中"提示 +- ✅ P1 批量领取:`claimAllIdleProjects()` action — 单次 set + syncStats,汇总 toast"✦ 批量领取完成 · 共领取 N 项工程" +- ✅ P1 完成 toast:`tickIdleProjects` 检测状态转变瞬间(!completed → completed),单次 toast"✦ 放置工程完成" +- ✅ P2 统计:StatsPanel 独立组件(从 page.tsx 抽出),useShallow 订阅 24 字段,新增"放置工程历史"行 +- ✅ 修复 v0.14 双重计算 bug:`idleTotalPerSec = crystalsPerSec + idlePermanentBonus` → `crystalsPerSec`(已含永久加成) + +**子代理 2-b(叙事深化,3 文件):** +- ✅ 8 个第一纪元碎片全部添加 loreLayers(16 层深层文本,目标 ≥6 超额完成 8/8) +- ✅ 5 条叙事暗线交织:以太族被"追逐者"逼迫飞升 / "回响"AI 频段同源 / "信任"实为"引诱" / 飞升是单向门 / 接触满 80 回归的不是"他们" +- ✅ FragmentLoreLayer / FragmentLoreUnlock 类型扩展(5 种解锁条件:ascensions/totalDecoded/contact/fragmentCount/bossKills) +- ✅ `isLoreLayerUnlocked()` 派生函数(纯前端计算,无新增持久化字段) +- ✅ Codex 分层渲染:L0 emerald 表层 / L1 amber 隐秘注脚 / L2 fuchsia 深层回响,未解锁层 blur-sm + 🔒 unlockHint + ▓░▒ 全息乱码 +- ✅ 顶部"深层叙事 X/Y 已解锁"统计 + 列表卡片 amber/fuchsia glow 小圆点标记 + +### 验证结果 +- ✅ bun run lint 零错误零警告 +- ✅ HTTP 200,dev.log 无运行时错误 +- ✅ agent-browser 端到端 smoke test 全通过(派遣→快进→完成 toast→批量领取→softcap 展示→Codex 分层解锁) +- ✅ 严格 4 色全息(emerald/rose/amber/fuchsia),零蓝/靛 +- ✅ 两个子代理修改文件零重叠,无冲突 + +### 未解决问题 / v0.16 优先项 +1. 🟡 loreLayer "已读"标记未实现(需改 gameStore,会与未来 idle 改动边界冲突,本轮未做) +2. 🟡 解码阈值释放叙事碎片 + 星潮三频段切换 —— v0.16 计划 +3. 🟡 健康设计:每日软上限 + "星核建议休眠"提醒 —— v0.17 计划 +4. 🟡 移动端 375px 实测(Codex 分层弹层在小屏的可读性) +5. 🟡 idleProjectHistory 可在 StatsPanel 展开查看最近 5 条详情