Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
174d1227c0 | ||
|
|
774ddaf9d1 | ||
|
|
22588425cf | ||
|
|
96748d726f | ||
|
|
20dc1f252f | ||
|
|
17820b1f28 |
@@ -1 +1 @@
|
||||
6708
|
||||
1061
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# 回响星核 / Echo Nexus
|
||||
|
||||
> 原创深空考古放置策略游戏 · Original deep-space archaeology idle strategy game
|
||||
> 原创深空考古 **放置策略**游戏 · Original deep-space archaeology **idle strategy** game
|
||||
|
||||
你是一名深空考古指挥官,驾驶自治勘探舰回收已「飞升」的以太文明遗留的记忆晶体,解码碎片拼凑出他们的故事,并最终跨越维度发起「接触」。
|
||||
|
||||
**关掉浏览器,星核仍在为你工作**——采矿无人机舰队 24h 不间断采矿,放置工程在离线时持续推进,回来后一键领取积累的奖励。
|
||||
|
||||
---
|
||||
|
||||
## 🎮 在线游玩
|
||||
@@ -16,7 +18,7 @@
|
||||
|
||||
## 项目状态
|
||||
|
||||
- **版本**:**v0.7** — CrystalOrb Canvas 粒子系统 + 四维角色属性
|
||||
- **版本**:**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/)
|
||||
@@ -26,29 +28,60 @@
|
||||
|
||||
## 核心特色
|
||||
|
||||
- **解码共振谜题**:把放置游戏的「等待」变成有节奏的主动小谜题(路径构造法生成器,100% 可解)
|
||||
- **碎片涌现叙事**:故事从解码中自然浮现,5 纪元循环命名 + 模板化 lore,不打断玩法
|
||||
- **深空全息美学**:晶体 + Canvas 粒子 + 共振波纹的差异化视觉(严格 emerald/rose/amber/fuchsia 四色全息色谱,零蓝色/靛色)
|
||||
- **飞升多周目**:终局「接触」后保留蓝图开启新周目,星图天文台 18 个天赋跨周目永久生效
|
||||
- **实时巡航玩法**:Canvas 2D 飞船操控 + 8 种实体 + 3 层视差星空,打破纯放置节奏
|
||||
- **角色 RPG 深度**:四维属性(探索力/智慧/勇气/灵感)影响所有游戏系统
|
||||
- **📡 采矿无人机舰队**:每解锁一个采矿分支技术,就有一台自动采矿无人机加入舰队,24h 持续产出晶体(无需点击)
|
||||
- **⚙️ 放置工程项目**:6 种工程,派遣后**离线/在线自动推进**,完成领取奖励(晶体/洞见/能量/永久产能加成/随机碎片)
|
||||
- **🔓 离线收益**:关闭浏览器后产能按 `offlineEff × crystalsPerSec` 持续累积(上限 8h),重新打开弹窗结算「沉睡期间」收益
|
||||
- **🎯 始终可见的放置徽章**:Header 永远显示「放置中 +X/s」徽章,悬停查看产能构成明细——一眼就知道这是个 idle game
|
||||
- **🔍 解码共振谜题**:把放置游戏的「等待」变成有节奏的主动小谜题(路径构造法生成器,100% 可解)
|
||||
- **📜 碎片涌现叙事**:故事从解码中自然浮现,5 纪元循环命名 + 模板化 lore,不打断玩法
|
||||
- **💎 深空全息美学**:晶体 + Canvas 粒子 + 共振波纹的差异化视觉(严格 emerald/rose/amber/fuchsia 四色全息色谱,零蓝色/靛色)
|
||||
- **🔄 飞升多周目**:终局「接触」后保留蓝图开启新周目,星图天文台 18 个天赋跨周目永久生效
|
||||
- **🚀 实时巡航玩法**:Canvas 2D 飞船操控 + 8 种实体 + 3 层视差星空,打破纯放置节奏
|
||||
- **🧬 角色 RPG 深度**:四维属性(探索力/智慧/勇气/灵感)影响所有游戏系统
|
||||
|
||||
---
|
||||
|
||||
## 游戏系统(10 大系统)
|
||||
## 游戏系统(11 大系统)
|
||||
|
||||
| # | 系统 | 简介 |
|
||||
|---|------|------|
|
||||
| 1 | 放置采矿 | 中央晶体球 Canvas 粒子系统 + 主动脉冲 + 连击加成 |
|
||||
| 2 | 谐振解码 | 原创路径谜题,100% 可解率 |
|
||||
| 3 | 技术树 | 三层科技解锁,提升产能/解码/探险 |
|
||||
| 4 | 遗迹探险 | 肉鸽系统,6 种节点 + 程序化路径 + BOSS |
|
||||
| 5 | 星潮事件 | 6 种动态事件,全屏叠层 + 程序化音效 |
|
||||
| 6 | 星图天文台 | 飞升后 3 选 1 天赋 draft,18 个跨周目天赋 |
|
||||
| 7 | 回响编年史 | 跨周目叙事时间轴,5 纪元循环命名 |
|
||||
| 8 | 深空信标 | 每日挑战 + 本地排行榜 Top20 |
|
||||
| 9 | 深空巡航 | Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关 |
|
||||
| 10 | 角色属性 | 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点 |
|
||||
| # | 系统 | 简介 | 类型 |
|
||||
|---|------|------|------|
|
||||
| 1 | **📡 放置采矿** | 中央晶体球 Canvas 粒子系统 + 主动脉冲 + 连击加成 + **自动采矿无人机舰队** | 放置 |
|
||||
| 2 | **⚙️ 放置工程** | 6 种工程 × 3 槽位,**离线/在线自动推进**,完成领取奖励 | **放置** |
|
||||
| 3 | **🔓 离线收益** | 关闭浏览器后产能按 `eff × cps` 累积(8h 上限),重新打开结算 | **放置** |
|
||||
| 4 | 谐振解码 | 原创路径谜题,100% 可解率 | 主动 |
|
||||
| 5 | 技术树 | 三层科技解锁,提升产能/解码/探险 | 主动 |
|
||||
| 6 | 遗迹探险 | 肉鸽系统,6 种节点 + 程序化路径 + BOSS | 主动 |
|
||||
| 7 | 星潮事件 | 6 种动态事件,全屏叠层 + 程序化音效 | 被动 |
|
||||
| 8 | 星图天文台 | 飞升后 3 选 1 天赋 draft,18 个跨周目天赋 | 元进程 |
|
||||
| 9 | 回响编年史 | 跨周目叙事时间轴,5 纪元循环命名 | 元进程 |
|
||||
| 10 | 深空信标 | 每日挑战 + 本地排行榜 Top20 | 每日 |
|
||||
| 11 | 深空巡航 | Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关 | 实时 |
|
||||
| 12 | 角色属性 | 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点 | 元进程 |
|
||||
|
||||
---
|
||||
|
||||
## 放置系统设计哲学(v0.14 + v0.15 平衡)
|
||||
|
||||
回响星核的「放置」不是简单地把数字挂机增长,而是分层呈现 **3 种 idle 节奏**:
|
||||
|
||||
### 节奏 1:被动产能(最慢,最持续)
|
||||
- 采矿无人机舰队 = 你已解锁的采矿技术节点
|
||||
- 每台无人机 24h 持续产出晶体,无需任何操作
|
||||
- 晶体进入仓库(受 `crystalCap` 限制),仓库满后停止产出(防溢出)
|
||||
- **永远在线,离线也累积**
|
||||
|
||||
### 节奏 2:放置工程(中等周期,需要决策)
|
||||
- 6 种工程,时长 1min ~ 1h 不等
|
||||
- 玩家选择派遣到 3 个槽位之一 → 启动后**离线/在线自动推进**
|
||||
- 完成后必须**手动领取**(防玩家错过奖励时机)
|
||||
- 奖励类型多样:晶体 / 洞见 / 能量 / 永久产能加成 / 随机碎片
|
||||
- **核心 idle loop:派遣 → 离开 → 回来 → 领取 → 再派遣**
|
||||
|
||||
### 节奏 3:离线收益(最长周期,回归奖励)
|
||||
- 关闭浏览器后,被动产能按 `offlineEff`(默认 50%,可通过技术提升至 100%)继续累积
|
||||
- 上限 8 小时(防极端挂机)
|
||||
- 重新打开页面弹窗结算:「你离开了 X 小时,星核为你产出 Y 颗晶体」
|
||||
- **回归玩家的"啊我回来了"瞬间**
|
||||
|
||||
---
|
||||
|
||||
@@ -67,9 +100,13 @@
|
||||
| v0.5.1 | Gitea Pages 静态部署 + 新手教程 | ✅ |
|
||||
| v0.5.2 | 离线收益报告 + 标签页 UI 重设计 | ✅ |
|
||||
| v0.6 | 深空巡航 Canvas 2D 实时玩法 | ✅ |
|
||||
| **v0.7** | **CrystalOrb Canvas 粒子系统 + 四维角色属性** | ✅ |
|
||||
| v0.8 | 巡航玩法增强 / BOSS 关卡 / 道具掉落 | 🔜 |
|
||||
| v0.9 | 云存档 + 云排行榜 | 🔜 |
|
||||
| v0.7 | CrystalOrb Canvas 粒子系统 + 四维角色属性 | ✅ |
|
||||
| v0.8 | 巡航玩法增强 / BOSS 关卡 / 道具掉落 | ✅ |
|
||||
| v0.8.1 | 探险平衡 + 信标系统扩展(周挑战 + 信标链) | ✅ |
|
||||
| **v0.14** | **放置系统:采矿无人机舰队 + 6 种放置工程 + 永久产能加成 + 始终可见 idle 徽章** | ✅ |
|
||||
| **v0.15** | **叙事深化(8 碎片 × 2 层 = 16 层 loreLayers)+ 永久加成 softcap + 批量领取 + 完成 toast + StatsPanel idle 统计** | ✅ |
|
||||
| v0.16 | 解码阈值释放叙事碎片 + 星潮三频段切换 | 🔜 |
|
||||
| v0.17 | 健康设计:每日软上限 + "星核建议休眠"提醒 | 🔜 |
|
||||
| v1.0 | 全 5 纪元手写叙事 + 多人同步星潮 | 🔜 |
|
||||
|
||||
---
|
||||
@@ -86,6 +123,8 @@
|
||||
- [`docs/08-星图天文台系统-v0.3.1.md`](docs/08-星图天文台系统-v0.3.1.md) — v0.3.1 天文台
|
||||
- [`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 + 批量领取**
|
||||
|
||||
---
|
||||
|
||||
@@ -118,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.7"
|
||||
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
|
||||
```
|
||||
@@ -127,4 +166,31 @@ Gitea Pages 会自动从 `gh-pages` 分支提供静态网站服务。
|
||||
|
||||
---
|
||||
|
||||
## 灵感来源(Inspired by)
|
||||
|
||||
- **Antimatter Dimensions** — 数值膨胀美学 + 终端 UI
|
||||
- **Universal Paperclips** — 叙事驱动的放置游戏(Frank Lantz)
|
||||
- **A Dark Room** — 极简文字放置游戏(Michael Townsend)
|
||||
- **Melvor Idle** — 离线收益 + 多技能树
|
||||
- **Outer Wilds** — 知识解锁式考古
|
||||
- **Cookie Clicker** — Orteil 的放置游戏范式定义
|
||||
|
||||
---
|
||||
|
||||
## 健康游戏设计声明
|
||||
|
||||
放置游戏天然具有"低投入高回报"的成瘾性,可能对学业/工作压力大的青年造成时间碎片化风险。回响星核主动采取以下健康设计:
|
||||
|
||||
- ✅ **离线收益上限 8h**——防止 24h 挂机
|
||||
- ✅ **工程完成需手动领取**——避免"打开就有奖励"的条件反射
|
||||
- ✅ **无广告、无内购**——纯粹的单机体验
|
||||
- 🔜 **v0.17 计划**:每日软上限 + "星核建议休眠"提醒(每 1 小时主动提示休息)
|
||||
|
||||
参考学术研究:
|
||||
- Frank Lantz GDC 2018 "This Is Your Brain on Games"
|
||||
- Reuters 2025 "Cozy video games can quell stress and anxiety"
|
||||
- DOI:10.3389/fpsyg.2026.1774030 TikTok 时代学术倦怠研究
|
||||
|
||||
---
|
||||
|
||||
© Super_Z · 持续开发中
|
||||
|
||||
@@ -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<string, boolean>`,但需要改 gameStore(与子代理 A 边界冲突),故本轮未做。
|
||||
4. 叙事文本长度 60-130 字,符合"碎片感 + 留白"美学要求;没有说教,多用引号 + 反转 + 留白。
|
||||
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 380 KiB After Width: | Height: | Size: 380 KiB |
|
Before Width: | Height: | Size: 386 KiB After Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 383 KiB After Width: | Height: | Size: 383 KiB |
|
Before Width: | Height: | Size: 387 KiB After Width: | Height: | Size: 387 KiB |
@@ -0,0 +1,139 @@
|
||||
# Task ID: v0.14-reimpl
|
||||
# Agent: full-stack-developer
|
||||
# Task: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章
|
||||
|
||||
## Work Log
|
||||
|
||||
### 1. 类型扩展 (`src/lib/game/types.ts`)
|
||||
- 新增 `IdleProjectDef` 接口(id/name/desc/durationSec/reward/minAscensions/minCrystalsPerSec/icon/color/order)
|
||||
- 新增 `IdleProjectSlot` 接口(projectId/startedAt/finishesAt/remainingSec/completed)
|
||||
- 在 `GameState` 接口 `pendingAttrPoints` 后新增 4 个字段:
|
||||
- `idleProjectSlots: (IdleProjectSlot | null)[]`
|
||||
- `idleProjectHistory: { projectId: string; finishedAt: number }[]`
|
||||
- `idlePermanentBonus: number`
|
||||
- `idleStats: { projectsCompleted: number; crystalsFromIdle: number }`
|
||||
|
||||
### 2. 新建 `src/lib/game/idle.ts` (~290 行)
|
||||
- 定义 6 个放置工程(按 order 排序):
|
||||
| id | name | duration | reward | icon | color | unlock |
|
||||
|----|------|----------|--------|------|-------|--------|
|
||||
| idle_scan | 深空勘探扫描 | 60s | insights +8 | 🛰️ | emerald | cps≥0.5 |
|
||||
| idle_refine | 晶体精炼阵列校准 | 180s | permBonus +0.3/s | ⚙️ | rose | cps≥1 |
|
||||
| idle_archive | 遗迹碎片整理 | 600s | randomFragment + insights +20 | 📜 | amber | asc≥1 OR cps≥5 |
|
||||
| idle_anchor | 维度锚点部署 | 1200s | energy +2 + contact +5 | ⚓ | fuchsia | asc≥1 |
|
||||
| idle_drones | 无人机群扩编 | 1800s | permBonus +2/s + crystals +500 | 🛸 | emerald | cps≥10 |
|
||||
| idle_resonance | 跨维度谐振标定 | 3600s | permBonus +8/s + contact +15 + insights +100 | 🌌 | fuchsia | asc≥2 |
|
||||
- 导出:`IDLE_PROJECTS`, `IDLE_SLOT_COUNT=3`, `getIdleProject`, `getUnlockedIdleProjects`, `getLockedIdleProjects`, `isIdleProjectUnlocked` (OR 逻辑:满足任一已设置条件即可解锁), `formatRemaining` (12s/3m 45s/1h 12m), `formatDuration`, `formatReward`, `deriveMinerFleet` (派生 min_1..min_5 采矿无人机,无任何采矿技术时返回休眠 min_1)
|
||||
- 导出颜色映射 `IDLE_COLOR_CLASSES` (text/border/bg/bgSoft/glow/ring/dot) + `COLOR_HEX` (用于 inline style,绕过 Tailwind 动态类限制)
|
||||
|
||||
### 3. 引擎集成 (`src/lib/game/engine.ts`)
|
||||
- `recomputeStats()` 末尾 `crystalsPerSec += state.idlePermanentBonus ?? 0` (放置永久加成叠加到产能)
|
||||
- `performPrestige()` 在返回对象中:
|
||||
- 清空 `idleProjectSlots: [null, null, null]` (飞升中断当前周目放置工程)
|
||||
- 保留 `idleProjectHistory`, `idlePermanentBonus`, `idleStats` (跨周目永久)
|
||||
- `recomputeStats` 调用传入 `idlePermanentBonus` 以确保飞升后立即应用永久加成
|
||||
- `createInitialState()` 新增 4 字段默认值
|
||||
|
||||
### 4. Store actions (`src/store/gameStore.ts`)
|
||||
- 新增 4 个 action:
|
||||
- `startIdleProject(slotIndex, projectId)`: 校验槽位空 + 项目已解锁 + 未在他处运行;写入 `{ projectId, startedAt, finishesAt, remainingSec, completed:false }`
|
||||
- `cancelIdleProject(slotIndex)`: 置槽位为 null
|
||||
- `claimIdleProject(slotIndex)`: 校验已完成 → 应用奖励(crystals 限 crystalCap,energy 限 energyMax,contact 限 100;permBonus 累加到 idlePermanentBonus + 重算 stats;randomFragment 从未解锁碎片中随机一个)→ 追加 history (slice -50) → idleStats.projectsCompleted++ → Toast 提示
|
||||
- `tickIdleProjects(now)`: 遍历非空非完成槽位,更新 remainingSec,归零时标记 completed
|
||||
- **persist migrate 函数**:persist 配置增加 `version: 1` + `migrate` 函数,自动补全 idleProjectSlots/idleProjectHistory/idlePermanentBonus/idleStats 4 字段,旧存档加载不崩溃
|
||||
- 全部 14 处 syncStats 调用更新为传入 `idlePermanentBonus: s.idlePermanentBonus ?? 0`,确保 recomputeStats 计算时纳入永久加成
|
||||
- `init()` 中也补全 4 个 idle 字段(defense in depth)
|
||||
|
||||
### 5. 主循环 (`src/hooks/useGameLoop.ts`)
|
||||
- 新增 `tickIdleProjects` 选择器
|
||||
- setInterval 回调中 `autoDecodeTick()` 后调用 `tickIdleProjects(now)`
|
||||
- visibilitychange 回调中也调用 `tickIdleProjects(now)`
|
||||
|
||||
### 6. 新建 `src/components/game/IdleOperationsPanel.tsx` (~490 行)
|
||||
- 主「放置」标签内容,4 个 section 在 `max-h-[520px] overflow-y-auto` 容器中:
|
||||
1. **放置收益概览**:3 stat tiles (放置产能 / 永久加成 / 完成工程) + 离线效率 progress bar + 累计放置晶体统计
|
||||
2. **采矿无人机舰队**:grid 展示 deriveMinerFleet(state),每张卡:emoji + name + Lv + output/s + 状态点(active=emerald ping 脉冲,dormant=灰)
|
||||
3. **放置工程槽位**:3 张槽位卡(空槽=虚线占位,运行中=大字号倒计时+自定义进度条+取消按钮,已完成=奖励预览+领取按钮带 glow 辉光)
|
||||
4. **可派遣工程**:2 列 grid 展示已解锁工程(icon+name+duration+desc+reward+3 个数字派遣按钮 1/2/3),下方列出未解锁工程及解锁条件
|
||||
- 使用 `useShallow` 订阅多个 store 字段
|
||||
- 本地 `now` state 每 1s 刷新倒计时
|
||||
- 严格 4 色全息(emerald/rose/amber/fuchsia),无蓝/靛
|
||||
- 自定义滚动条样式 (fuchsia 主题)
|
||||
|
||||
### 7. 新建 `src/components/game/IdleStatusBadge.tsx` (~100 行)
|
||||
- 紧凑徽章:脉冲点 (emerald 若 cps>0,否则灰) + "放置中 +X/s" (或 "休眠中")
|
||||
- Tooltip 悬停展示分解:基础产能 / 永久加成 / 运行中工程 / 待领取工程数
|
||||
- `h-8 px-2.5 text-[11px]`,点击切换到放置标签
|
||||
- `data-tut="idle-status-badge"` 锚点
|
||||
|
||||
### 8. 新建 `src/components/game/IdleProjectBar.tsx` (~90 行)
|
||||
- 晶体球下方的细长进度条
|
||||
- 显示最多 3 个运行/已完成工程为 mini pill:`[icon] name 12s ▓▓▓░░`
|
||||
- 完成时显示 "✓ 待领取" + glow 辉光
|
||||
- 无工程时返回 null
|
||||
- 本地 `now` state 每 1s 刷新倒计时
|
||||
- 进度条颜色用 inline style 控制(COLOR_HEX 映射)
|
||||
|
||||
### 9. 主页面集成 (`src/app/page.tsx`)
|
||||
- 引入 3 个新组件 + `Clock` 图标 + `getUnlockedIdleProjects`
|
||||
- 新增 `idleProjectSlots` / `createdAt` store 订阅
|
||||
- 新增 `activeTab` / `tabInited` state,controlled Tabs:`<Tabs value={activeTab} onValueChange={setActiveTab}>`
|
||||
- 挂载后 useEffect 一次性设置默认 tab:hasActiveExpedition → "expedition",hasPendingPerk → "constellation",否则 → "idle"
|
||||
- 版本号 v0.8 → v0.14
|
||||
- Header 在 StarTideIndicator 后加 IdleStatusBadge
|
||||
- 左侧 CrystalOrb 后加 IdleProjectBar
|
||||
- TabsList grid-cols-8 → grid-cols-9,新增 `value="idle"` 的 TabsTrigger 作为第一个 tab(Clock 图标,emerald 主题,待领取时显示数量红点)
|
||||
- 新增 TabsContent value="idle" 渲染 IdleOperationsPanel
|
||||
- 新增 idle 相关 goal 提示(高优先级):
|
||||
- 待领取 > 0 → "✦ 放置工程已完成 X 项,请前往「放置」标签领取奖励"
|
||||
- 全空 + 有解锁 + 时长 > 60s → "「放置」标签可派遣工程项目,离线自动产出"
|
||||
- StatsPanel 新增 3 行:放置永久加成 / 完成放置工程 / 放置产出晶体
|
||||
|
||||
### 10. 教程更新 (`src/lib/game/tutorial.ts`)
|
||||
- TUTORIAL_STEPS 在 decode 与 tech 之间插入新步骤:
|
||||
- id: "idle", target: "tab-idle", placement: "top"
|
||||
- 标题 "③ 放置工程 · 离线产出"
|
||||
- 介绍放置标签的工程派遣 + 顶部徽章 + 晶体球下方进度条
|
||||
- 原 tech/expedition/prestige 步骤编号顺延为 ④⑤⑥
|
||||
|
||||
## QA 验证
|
||||
|
||||
### Lint
|
||||
- `bun run lint` → 零错误零警告 ✅
|
||||
|
||||
### HTTP
|
||||
- `curl http://localhost:3000/` → 200 OK ✅
|
||||
|
||||
### Dev log
|
||||
- 全程无 runtime error,所有请求 200 OK,编译 < 250ms ✅
|
||||
|
||||
### agent-browser 烟雾测试
|
||||
1. ✅ 版本标签 "v0.14" 可见
|
||||
2. ✅ 默认 tab 是 "放置"(data-state="active" 在第一个 tab)
|
||||
3. ✅ 头部 idle 徽章可见("放置中 +0.4/s" 或 "休眠中")
|
||||
4. ✅ IdleOperationsPanel 4 个 section 全部可见(放置收益概览 / 采矿无人机舰队 / 放置工程槽位 / 可派遣工程 / 未解锁工程)
|
||||
5. ✅ 采矿无人机舰队在无采矿技术时显示休眠 min_1(emoji ⛏️ + 灰色状态点)
|
||||
6. ✅ 设置 insights=50 → buyTech('min_1') → crystalsPerSec 0.4→1.0 → 解锁 idle_scan + idle_refine
|
||||
7. ✅ 点击 idle_scan 派遣按钮 → 槽位 0 写入 { projectId: "idle_scan", finishesAt: now+60000 } → 倒计时正确递减
|
||||
8. ✅ 60s 后自动标记 completed=true → 槽位卡显示 "✓ 完成" + 领取按钮
|
||||
9. ✅ 点击领取 → Toast "✦ 工程奖励已领取 · 深空勘探扫描 · +8 洞见" → insights 8→16 → history 追加 → idleStats.projectsCompleted 0→1
|
||||
10. ✅ 派遣 idle_refine (180s) → 强制完成 → 领取 → crystalsPerSec 1.0→1.3 → idlePermanentBonus 0→0.3 ✅ 永久产能加成正确应用
|
||||
11. ✅ IdleProjectBar 在晶体球下方显示:`🛰️ 深空勘探扫描 55s ▓▓▓░░`
|
||||
12. ✅ Footer goal 文字根据状态动态切换为放置相关提示
|
||||
13. ✅ Footer 产能显示 "产能 1.3/s" (含 idle 永久加成)
|
||||
|
||||
### Migration 测试
|
||||
- 旧存档(无 idle 字段)通过 migrate 函数自动补全为默认值,加载不崩溃 ✅
|
||||
|
||||
## Stage Summary
|
||||
- ✅ 类型层:新增 IdleProjectDef + IdleProjectSlot + 4 GameState 字段
|
||||
- ✅ 逻辑层:新建 idle.ts (~290 行),6 工程定义 + 派生函数 + 颜色映射
|
||||
- ✅ 引擎层:recomputeStats / performPrestige / createInitialState 全部接入 idle 永久加成
|
||||
- ✅ Store 层:4 新 action + persist migrate (version 1) + 14 处 syncStats 调用全部传 idlePermanentBonus
|
||||
- ✅ 主循环:tickIdleProjects 接入 setInterval + visibilitychange
|
||||
- ✅ UI 层:3 新组件 (IdleOperationsPanel ~490 行 / IdleStatusBadge ~100 行 / IdleProjectBar ~90 行)
|
||||
- ✅ 主页面:默认放置 tab + 头部徽章 + 晶体球下方进度条 + 版本 v0.14 + 9 标签页
|
||||
- ✅ 教程:新增 idle 步骤 (③ 放置工程)
|
||||
- ✅ 严格 4 色全息 (emerald/rose/amber/fuchsia),零蓝/靛
|
||||
- ✅ lint 零错误 + HTTP 200 + agent-browser 全流程烟雾测试通过
|
||||
- ✅ 5 秒内可见三要素:头部徽章 + 默认放置 tab + 晶体球下方进度条(派遣后)
|
||||
@@ -0,0 +1,309 @@
|
||||
# 回响星核 v0.14 — 放置系统(Idle Operations)设计文档
|
||||
|
||||
> 版本:v0.14 · 日期:2026-01-25
|
||||
> 核心目标:让玩家打开游戏 **5 秒内** 就能看出这是个 idle game
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计动机
|
||||
|
||||
### 1.1 用户反馈
|
||||
> "你的这个我没有看到任何可放置的功能" —— 玩家反馈
|
||||
|
||||
虽然 v0.8 已有 `crystalsPerSec` 在后台自动累积,但 **没有任何 UI 元素明示这一事实**:
|
||||
- 没有「放置中」徽章
|
||||
- 没有可视化自动采矿机
|
||||
- 没有定时工程项目
|
||||
- 离线收益弹窗只在重新加载页面时触发,玩家日常游玩看不到
|
||||
|
||||
放置游戏的灵魂是「关掉也在进步」。如果玩家看不到这一点,就等于没有放置玩法。
|
||||
|
||||
### 1.2 设计目标
|
||||
1. **可见性**:5 秒内让玩家意识到 idle 属性
|
||||
2. **决策性**:放置不只是数字增长,要有玩家选择
|
||||
3. **节奏感**:3 层 idle 节奏(被动产能 / 放置工程 / 离线收益)协同
|
||||
4. **健康性**:避免 24h 挂机的极端沉迷
|
||||
|
||||
---
|
||||
|
||||
## 2. 三层 Idle 节奏
|
||||
|
||||
### 节奏 1:被动产能(Passive Generation)
|
||||
- **载体**:采矿无人机舰队(Miner Fleet)
|
||||
- **来源**:每解锁一个采矿分支技术节点 → 加入一架无人机
|
||||
- **行为**:24h 持续产出晶体,无需任何操作
|
||||
- **限制**:受 `crystalCap` 限制,仓库满后停止
|
||||
- **离线**:按 `offlineEff`(默认 50%)持续累积,上限 8h
|
||||
|
||||
#### 采矿分支技术 → 无人机映射
|
||||
|
||||
| 技术节点 | 无人机名 | 输出/s | 解锁成本(洞见) |
|
||||
|---------|---------|--------|-----------------|
|
||||
| `min_1` | ⛏️ 谐振钻头 | +0.6 | 5 |
|
||||
| `min_2` | 🌋 深层矿脉 | +1.6 | 24 |
|
||||
| `min_3` | 🤖 自治机群 | +5 | 120 |
|
||||
| `min_4` | 💠 量子采掘矩阵 | +12 | 400 |
|
||||
| `min_5` | ☀️ 戴森星核 | +30 | 1200 |
|
||||
|
||||
无采矿技术时显示「谐振钻头(休眠中)」占位卡片,引导玩家解锁采矿分支。
|
||||
|
||||
---
|
||||
|
||||
### 节奏 2:放置工程(Idle Projects)
|
||||
|
||||
#### 2.1 工程列表
|
||||
|
||||
| ID | 名称 | 时长 | 奖励 | 解锁条件 | 图标 | 色 |
|
||||
|----|------|------|------|---------|------|-----|
|
||||
| `idle_scan` | 深空勘探扫描 | 60s | ✦ +8 洞见 | 产能 ≥ 0.5/s | 🛰️ | emerald |
|
||||
| `idle_refine` | 晶体精炼阵列校准 | 180s | ⇪ +0.3/s 永久 | 产能 ≥ 1/s | ⚙️ | rose |
|
||||
| `idle_archive` | 遗迹碎片整理 | 600s | ✦ +20 洞见 + 📜 随机碎片 | 飞升 ≥ 1 OR 产能 ≥ 5/s | 📜 | amber |
|
||||
| `idle_anchor` | 维度锚点部署 | 1200s | ⚡ +2 能量 + ☄ +5 接触进度 | 飞升 ≥ 1 | ⚓ | fuchsia |
|
||||
| `idle_drones` | 无人机群扩编 | 1800s | 💎 +500 晶体 + ⇪ +2/s 永久 | 产能 ≥ 10/s | 🛸 | emerald |
|
||||
| `idle_resonance` | 跨维度谐振标定 | 3600s | ✦ +100 洞见 + ☄ +15 接触 + ⇪ +8/s 永久 | 飞升 ≥ 2 | 🌌 | fuchsia |
|
||||
|
||||
#### 2.2 槽位机制
|
||||
- **3 个并行槽位**:可同时运行 3 个工程
|
||||
- **不重复**:同一工程不能在多个槽位同时运行
|
||||
- **完成需手动领取**:避免"打开就有奖励"的条件反射
|
||||
- **取消不退时间**:误取消的代价
|
||||
- **离线推进**:关闭浏览器后工程继续倒计时
|
||||
|
||||
#### 2.3 奖励类型说明
|
||||
|
||||
| 奖励 | 含义 | 设计意图 |
|
||||
|------|------|---------|
|
||||
| 晶体 | 立即加到 `crystals` | 短期反馈 |
|
||||
| 洞见 | 立即加到 `insights` | 用于解锁技术 |
|
||||
| 能量 | 加到 `energy`(受 `energyMax` 上限) | 探险资源 |
|
||||
| 接触进度 | 加到 `contact`(0-100) | 推进飞升 |
|
||||
| **永久产能加成** | 加到 `idlePermanentBonus`,影响 `recomputeStats` | 长期投资 |
|
||||
| 随机碎片 | 从 `FRAGMENTS` 中随机解锁一个未拥有 | 叙事推进 |
|
||||
|
||||
---
|
||||
|
||||
### 节奏 3:离线收益(Offline Gains)
|
||||
|
||||
#### 3.1 公式
|
||||
```
|
||||
gain = crystalsPerSec × elapsed × offlineEff
|
||||
elapsed = min(realElapsed, 8h)
|
||||
```
|
||||
|
||||
#### 3.2 解锁路径
|
||||
- 初始 `offlineEff = 0.5`(50% 离线效率)
|
||||
- 通过技术树 `offlineEff` 类效果提升
|
||||
- 通过星图天赋 `offlineEffBonus` 提升
|
||||
- 上限 100%
|
||||
|
||||
#### 3.3 8h 上限理由
|
||||
- 参考 GDC 2016 Anthony Pecorella "Quest for Progress":Egg Inc 2h 硬 cap 导致玩家流失
|
||||
- 但完全无 cap 会导致 24h 挂机成为最优策略,破坏短会话体验
|
||||
- 8h 折中:覆盖一个工作日/一夜,但不鼓励全天挂机
|
||||
|
||||
#### 3.4 离线报告弹窗
|
||||
重新加载页面时,若 `elapsed > 60s`,弹出 `OfflineReportDialog`:
|
||||
- 「你离开了 X 小时 Y 分」
|
||||
- 「星核为你产出 Z 颗晶体」
|
||||
- 「产能 /s · 离线效率 N%」
|
||||
- 「是否触达 8h 上限」标志
|
||||
|
||||
---
|
||||
|
||||
## 3. UI 设计
|
||||
|
||||
### 3.1 始终可见的 Idle 徽章(Header)
|
||||
位置:顶部 Header,紧邻「巡航」按钮左侧
|
||||
状态:
|
||||
- 产能 > 0:`● 放置中 +X.X/s`(绿点脉动)
|
||||
- 产能 = 0:`○ 休眠中`(灰点静止)
|
||||
|
||||
悬停 Tooltip 显示明细:
|
||||
```
|
||||
基础产能 8.2/s
|
||||
永久加成 +1.5/s
|
||||
离线效率 75%
|
||||
完成工程 12 次
|
||||
```
|
||||
|
||||
点击徽章 → 切换到「放置」标签。
|
||||
|
||||
### 3.2 默认标签页 = 放置
|
||||
首次打开游戏,默认 tab 是「放置」(v0.13 前是「探险」或「技术」)。
|
||||
返回玩家若有进行中探险或待选天赋,自动切回原默认。
|
||||
|
||||
### 3.3 放置标签页四段式布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Section 1: 放置收益概览 │
|
||||
│ [放置产能] [永久加成] [完成工程] │
|
||||
│ [━━━━━━━ 离线效率 50% · 上限 8h ━━━━━━] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Section 2: 采矿无人机舰队 │
|
||||
│ [⛏️ 钻头] [🌋 矿脉] [🤖 机群] ... │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Section 3: 放置工程槽位 │
|
||||
│ [槽位1: 运行中 47s ▓▓▓░] [槽位2: 空] [槽位3: 空] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Section 4: 可派遣工程 │
|
||||
│ [🛰️ 深空勘探扫描 1m +8] [⚙️ 精炼校准 3m +0.3/s] │
|
||||
│ [📜 碎片整理 10m +20] │
|
||||
│ │
|
||||
│ 未解锁工程(3): │
|
||||
│ ⚓ 维度锚点部署 · 需 飞升≥1 │
|
||||
│ 🛸 无人机群扩编 · 需 产能≥10/s │
|
||||
│ 🌌 跨维度谐振标定 · 需 飞升≥2 │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.4 IdleProjectBar(晶体球下方)
|
||||
位置:左侧晶体球区,`CrystalYieldPanel` 下方
|
||||
显示:当前运行的工程迷你 pill
|
||||
```
|
||||
🛰️ 深空勘探扫描 47s ▓▓▓░░░░░
|
||||
⚙️ 晶体精炼校准 2m 13s ▓▓░░░░░░
|
||||
```
|
||||
无运行工程时返回 `null`(不占位)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 技术实现
|
||||
|
||||
### 4.1 数据结构
|
||||
|
||||
```typescript
|
||||
// types.ts
|
||||
interface IdleProjectDef {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
durationSec: number;
|
||||
reward: { crystals?; insights?; energy?; contact?; crystalsPerSecPermanent?; randomFragment?; };
|
||||
minAscensions?: number;
|
||||
minCrystalsPerSec?: number;
|
||||
icon: string;
|
||||
color: "emerald" | "rose" | "amber" | "fuchsia";
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface IdleProjectSlot {
|
||||
projectId: string;
|
||||
startedAt: number;
|
||||
finishesAt: number;
|
||||
remainingSec: number;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
// GameState 新增 4 字段
|
||||
idleProjectSlots: (IdleProjectSlot | null)[]; // 3 槽
|
||||
idleProjectHistory: { projectId: string; finishedAt: number }[]; // 最近 50 条
|
||||
idlePermanentBonus: number;
|
||||
idleStats: { projectsCompleted: number; crystalsFromIdle: number };
|
||||
```
|
||||
|
||||
### 4.2 Store actions
|
||||
- `startIdleProject(slotIndex, projectId)` — 校验 + 创建槽位
|
||||
- `cancelIdleProject(slotIndex)` — 置空槽位
|
||||
- `claimIdleProject(slotIndex)` — 应用奖励 + toast + 置空槽位
|
||||
- `tickIdleProjects(now)` — 推进剩余时间,标记完成
|
||||
|
||||
### 4.3 引擎集成
|
||||
`recomputeStats()` 末尾添加:
|
||||
```typescript
|
||||
crystalsPerSec += state.idlePermanentBonus ?? 0;
|
||||
```
|
||||
这让永久产能加成真正生效。
|
||||
|
||||
### 4.4 飞升处理
|
||||
`performPrestige()` 重置 `idleProjectSlots = [null, null, null]`(不跨周目继承进行中工程),但保留 `idlePermanentBonus` / `idleProjectHistory` / `idleStats`(跨周目永久)。
|
||||
|
||||
### 4.5 存档迁移
|
||||
persist 添加 `version: 1` + `migrate` 函数,为旧存档补全 4 个新字段:
|
||||
```typescript
|
||||
idleProjectSlots: (s.idleProjectSlots as unknown[]) ?? [null, null, null],
|
||||
idleProjectHistory: (s.idleProjectHistory as unknown[]) ?? [],
|
||||
idlePermanentBonus: (s.idlePermanentBonus as number) ?? 0,
|
||||
idleStats: (s.idleStats as unknown) ?? { projectsCompleted: 0, crystalsFromIdle: 0 },
|
||||
```
|
||||
旧存档零崩溃风险。
|
||||
|
||||
### 4.6 主循环集成
|
||||
`useGameLoop` 每 250ms tick:
|
||||
1. `tick(now)` — 产能 + 自动产晶体
|
||||
2. `autoDecodeTick()` — 自动解码
|
||||
3. `tickTide(now)` — 星潮事件
|
||||
4. **`tickIdleProjects(now)` — v0.14 放置工程进度**(新增)
|
||||
5. `checkAchievements()` — 每秒一次
|
||||
|
||||
页面可见性切回时也调用 `tickIdleProjects(now)`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 平衡考虑
|
||||
|
||||
### 5.1 永久产能加成可能过强
|
||||
- `idle_resonance` +8/s 永久,多次完成可能让玩家飞快积累
|
||||
- **v0.15 待办**:加飞升次数 softcap,例如 `effectiveBonus = idlePermanentBonus × sqrt(ascensions + 1) / (ascensions + 1)`
|
||||
|
||||
### 5.2 工程时长曲线
|
||||
- 60s / 180s / 600s / 1200s / 1800s / 3600s
|
||||
- 短工程奖励低但灵活(适合短会话)
|
||||
- 长工程奖励高但需等待(适合过夜挂机)
|
||||
- 玩家自主选择节奏
|
||||
|
||||
### 5.3 解锁条件
|
||||
- 0 飞升 + 0.5/s:新手 5 分钟内可解锁第一个
|
||||
- 1 飞升:玩家完成首次飞升后解锁中段
|
||||
- 2 飞升 + 10/s:长线目标
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证记录
|
||||
|
||||
### 6.1 Lint
|
||||
- ✅ 零错误零警告
|
||||
|
||||
### 6.2 Dev 服务器
|
||||
- ✅ HTTP 200
|
||||
- ✅ 无运行时错误
|
||||
|
||||
### 6.3 Agent Browser 端到端测试
|
||||
1. ✅ 默认 tab 是「放置」
|
||||
2. ✅ Header 显示「放置中 +0.4/s」徽章
|
||||
3. ✅ 4 段面板完整渲染
|
||||
4. ✅ 买 min_1+min_2 后产能 2.6/s,解锁 idle_scan + idle_refine
|
||||
5. ✅ 点击「派遣至槽位 1」→ 槽位显示运行中 60s + 进度条 + 取消按钮
|
||||
6. ✅ IdleProjectBar 同步显示迷你 pill
|
||||
7. ✅ 快进完成后标记 completed=true
|
||||
8. ✅ 领取后 insights +8、projectsCompleted 0→1、槽位清空
|
||||
|
||||
### 6.4 Sticky Footer 验证
|
||||
- ✅ Header `position: sticky; top: 0`
|
||||
- ✅ Footer `position: sticky; bottom: 0; margin-top: auto`
|
||||
- ✅ 内容超出视口时 footer 被自然推下(无浮动空隙)
|
||||
|
||||
---
|
||||
|
||||
## 7. 未解决问题 / 后续
|
||||
|
||||
- 🟡 `crystalsFromIdle` / `idleProjectHistory` 已在 store 累加但 UI 未展示——v0.15 可在统计 tab 补
|
||||
- 🟡 永久产能加成可能需要飞升次数 softcap
|
||||
- 🟡 缺「批量领取全部」按钮(玩家有 3 个槽位都完成时方便)
|
||||
- 🟡 缺工程完成 toast 通知(目前只能在面板内看到「待领取」标志)
|
||||
- 🟢 v0.15 可加 Dark Souls 式 loreFragments 多层文本,让"遗迹碎片整理"工程的随机碎片奖励更有叙事感
|
||||
|
||||
---
|
||||
|
||||
## 8. 设计哲学总结
|
||||
|
||||
> "Incremental games are very raw. They're about relating to a process and becoming obsessed with its growth, like watching something you planted in the ground, through a time-lapse camera, grow into a tree." —— Clark (via Frank Lantz, New Yorker 2019)
|
||||
|
||||
回响星核 v0.14 的放置系统不只是把数字挂机增长,而是让玩家**看到**这个过程:
|
||||
- 采矿无人机像植物在土里生长
|
||||
- 放置工程像种子在发芽
|
||||
- 离线收益像收获时节
|
||||
|
||||
玩家不再只是「数字变大」,而是「亲手布置了一个会自动运转的考古基地,然后离开,让它在自己不在的时候继续工作」。
|
||||
|
||||
这是 idle game 的本质:**让玩家信任系统会在他不在时为他工作**。
|
||||
@@ -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 弹层可读性实测
|
||||
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 游戏主入口
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { StarfieldCanvas } from "@/components/game/StarfieldCanvas";
|
||||
import { ResourceBar } from "@/components/game/ResourceBar";
|
||||
import { CrystalOrb } from "@/components/game/CrystalOrb";
|
||||
@@ -20,6 +20,10 @@ import { TutorialOverlay } from "@/components/game/TutorialOverlay";
|
||||
import { OfflineReportDialog } from "@/components/game/OfflineReportDialog";
|
||||
import { CruiseMode } from "@/components/game/CruiseMode";
|
||||
import { AttributesPanel } from "@/components/game/AttributesPanel";
|
||||
import { 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,
|
||||
@@ -44,12 +48,13 @@ import {
|
||||
Radio,
|
||||
Navigation,
|
||||
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() {
|
||||
useGameLoop();
|
||||
@@ -77,6 +82,14 @@ export default function Page() {
|
||||
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
|
||||
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
|
||||
const pendingAttrPoints = useGameStore((s) => s.pendingAttrPoints ?? 0);
|
||||
// v0.14 放置系统:用于驱动 goal 提示 + 标签默认值
|
||||
const idleProjectSlots = useGameStore(
|
||||
(s) => (s.idleProjectSlots ?? [null, null, null]) as (
|
||||
| { projectId: string; completed: boolean }
|
||||
| null
|
||||
)[]
|
||||
);
|
||||
const createdAt = useGameStore((s) => s.createdAt);
|
||||
|
||||
// 深空信标:检测是否有可领取的奖励(独立 localStorage)
|
||||
const [beaconClaimable, setBeaconClaimable] = useState(false);
|
||||
@@ -103,10 +116,26 @@ export default function Page() {
|
||||
|
||||
// 挂载检测:避免持久化 store 在 SSR/CSR 间产生 hydration 不一致
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// v0.14 放置系统:默认放置标签(在挂载后按优先级切换一次)
|
||||
// 注意:所有 hooks 必须在早期 return 之前调用,保持调用顺序一致
|
||||
const [activeTab, setActiveTab] = useState("idle");
|
||||
const tabInited = useRef(false);
|
||||
const hasPendingPerkEarly = !!(pendingPerkChoices && pendingPerkChoices.length > 0);
|
||||
useEffect(() => {
|
||||
if (tabInited.current) return;
|
||||
tabInited.current = true;
|
||||
if (hasActiveExpedition) {
|
||||
setActiveTab("expedition");
|
||||
} else if (hasPendingPerkEarly) {
|
||||
setActiveTab("constellation");
|
||||
} else {
|
||||
setActiveTab("idle");
|
||||
}
|
||||
}, [hasActiveExpedition, hasPendingPerkEarly]);
|
||||
|
||||
// 防止 SSR/CSR 不一致
|
||||
if (!mounted) {
|
||||
return (
|
||||
@@ -125,6 +154,19 @@ export default function Page() {
|
||||
// 仓库满仓警告
|
||||
const warehouseFull = crystals >= crystalCap * 0.98;
|
||||
|
||||
// v0.14 放置系统相关 goal 提示
|
||||
const idleClaimableCount = idleProjectSlots.filter(
|
||||
(s) => s !== null && s.completed
|
||||
).length;
|
||||
const idleRunningCount = idleProjectSlots.filter(
|
||||
(s) => s !== null && !s.completed
|
||||
).length;
|
||||
const playtimeSec = (Date.now() - (createdAt ?? Date.now())) / 1000;
|
||||
const unlockedIdleCount = getUnlockedIdleProjects({
|
||||
ascensions,
|
||||
crystalsPerSec,
|
||||
}).length;
|
||||
|
||||
// 目标提示
|
||||
let goal = "点击中央晶体发起脉冲,累积记忆晶体";
|
||||
if (totalDecoded === 0 && crystals >= 5) {
|
||||
@@ -151,6 +193,16 @@ export default function Page() {
|
||||
goal = `${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
|
||||
}
|
||||
if (canPrestige) goal = "✦ 接触进度已满,可发起飞升进入新周目";
|
||||
// v0.14 放置工程 goal 提示(高优先级,覆盖默认)
|
||||
if (idleClaimableCount > 0) {
|
||||
goal = `✦ 放置工程已完成 ${idleClaimableCount} 项,请前往「放置」标签领取奖励`;
|
||||
} else if (
|
||||
idleRunningCount === 0 &&
|
||||
unlockedIdleCount > 0 &&
|
||||
playtimeSec > 60
|
||||
) {
|
||||
goal = "「放置」标签可派遣工程项目,离线自动产出";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col bg-[#050410] text-foreground overflow-x-hidden">
|
||||
@@ -172,12 +224,13 @@ export default function Page() {
|
||||
<div className="leading-tight">
|
||||
<h1 className="text-base sm:text-lg font-bold text-gradient">回响星核</h1>
|
||||
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider">
|
||||
ECHO NEXUS · v0.8
|
||||
ECHO NEXUS · v0.15
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<StarTideIndicator />
|
||||
<IdleStatusBadge onClick={() => setActiveTab("idle")} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -252,6 +305,8 @@ export default function Page() {
|
||||
<div data-tut="crystal-orb" className="contents">
|
||||
<CrystalOrb />
|
||||
</div>
|
||||
{/* v0.14 放置工程进度条(晶体球下方) */}
|
||||
<IdleProjectBar />
|
||||
</section>
|
||||
|
||||
{/* 右侧:解码 + 标签面板 */}
|
||||
@@ -260,10 +315,19 @@ export default function Page() {
|
||||
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[300px] sm:min-h-[360px] max-h-[560px]">
|
||||
<DecodePanel />
|
||||
</div>
|
||||
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
|
||||
{/* 标签面板:放置 / 探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
|
||||
<div className="glass rounded-2xl p-3 min-h-[280px] sm:min-h-[320px] max-h-[440px] flex flex-col">
|
||||
<Tabs defaultValue={hasActiveExpedition ? "expedition" : hasPendingPerk ? "constellation" : "tech"} className="h-full flex flex-col">
|
||||
<TabsList className="grid grid-cols-8 h-9 bg-black/30 gap-0.5 p-1">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
|
||||
<TabsList className="grid grid-cols-9 h-9 bg-black/30 gap-0.5 p-1">
|
||||
<TabsTrigger value="idle" data-tut="tab-idle" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-emerald-500/15 data-[state=active]:shadow-[0_0_12px_rgba(52,211,153,0.3)] transition-all">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span className="leading-none">放置</span>
|
||||
{idleClaimableCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-[14px] h-[14px] px-1 rounded-full bg-emerald-500 text-[9px] font-mono font-bold text-white flex items-center justify-center border border-emerald-300/50 animate-pulse">
|
||||
{idleClaimableCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="expedition" data-tut="tab-expedition" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-amber-500/15 data-[state=active]:shadow-[0_0_12px_rgba(251,191,36,0.3)] transition-all">
|
||||
<Rocket className="h-3.5 w-3.5" />
|
||||
<span className="leading-none">探险</span>
|
||||
@@ -315,6 +379,9 @@ export default function Page() {
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="idle" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||
<IdleOperationsPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="expedition" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||
<ExpeditionPanel />
|
||||
</TabsContent>
|
||||
@@ -405,55 +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<number | null>(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}` },
|
||||
];
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5 text-xs">
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.label}
|
||||
className="flex items-center justify-between rounded-md bg-black/25 border border-white/5 px-2 py-1.5"
|
||||
>
|
||||
<span className="text-muted-foreground">{r.label}</span>
|
||||
<span className="font-mono text-foreground">{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`rounded-lg border ${
|
||||
unlocked ? accentBorder : "border-white/5"
|
||||
} ${unlocked ? accentBg : "bg-black/20"} p-3`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2 gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className={`text-[9px] font-mono px-1.5 py-0.5 rounded shrink-0 ${
|
||||
unlocked ? `${accentChipBg} ${accentChipText}` : "bg-white/5 text-muted-foreground/60"
|
||||
}`}
|
||||
>
|
||||
{layerTag}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-semibold truncate ${
|
||||
unlocked ? accentText : "text-muted-foreground/70"
|
||||
}`}
|
||||
>
|
||||
{unlocked ? layer.title : "???"}
|
||||
</span>
|
||||
</div>
|
||||
{!unlocked && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-amber-200/50 shrink-0">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
<span className="font-mono hidden xs:inline sm:inline">{layer.unlockHint}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unlocked ? (
|
||||
<p className={`text-[12px] leading-relaxed italic ${accentBody}`}>
|
||||
「{layer.text}」
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
className="text-[12px] leading-relaxed blur-sm select-none text-muted-foreground/40 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
「{LOCKED_PLACEHOLDER}」
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!unlocked && (
|
||||
<div className="mt-2 pt-2 border-t border-white/5 text-[10px] text-amber-200/50">
|
||||
<span className="font-mono">🔒 {layer.unlockHint}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<Fragment | null>(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 (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -32,6 +138,16 @@ export function Codex() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* v0.15 深层叙事统计 */}
|
||||
<div className="flex items-center justify-between text-[10px] px-1">
|
||||
<span className="text-muted-foreground/80">深层叙事</span>
|
||||
<span className="font-mono">
|
||||
<span className="text-amber-300">{loreStats.unlocked}</span>
|
||||
<span className="text-muted-foreground/60"> / {loreStats.total} </span>
|
||||
<span className="text-muted-foreground/60">已解锁</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{nextThreshold && (
|
||||
<div className="text-[10px] text-muted-foreground/80 px-1">
|
||||
再解码 <span className="text-fuchsia-300 font-mono">{nextThreshold - totalDecoded}</span> 颗 → 下一个碎片
|
||||
@@ -42,6 +158,17 @@ export function Codex() {
|
||||
<div className="grid grid-cols-2 gap-2 pr-2">
|
||||
{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 (
|
||||
<button
|
||||
key={f.id}
|
||||
@@ -69,7 +196,22 @@ export function Codex() {
|
||||
{unlocked ? (
|
||||
<>
|
||||
<div className="absolute -top-3 -right-3 h-12 w-12 rounded-full bg-fuchsia-500/20 blur-xl" />
|
||||
<Sparkles className="h-3 w-3 text-fuchsia-300 mb-1" />
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Sparkles className="h-3 w-3 text-fuchsia-300" />
|
||||
{hasDeepUnlocked && (
|
||||
<span
|
||||
className="flex items-center gap-0.5"
|
||||
title="此碎片含已解锁的深层文本"
|
||||
>
|
||||
{l1Unlocked && (
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.8)]" />
|
||||
)}
|
||||
{l2Unlocked && (
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-fuchsia-400 shadow-[0_0_4px_rgba(232,121,249,0.8)]" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold text-fuchsia-100 leading-tight">
|
||||
{f.title}
|
||||
</div>
|
||||
@@ -91,14 +233,14 @@ export function Codex() {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 详情弹层 */}
|
||||
{/* 详情弹层 — v0.15 分层渲染 */}
|
||||
{selected && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<Card
|
||||
className="max-w-md w-full p-5 bg-gradient-to-br from-fuchsia-950/60 to-black/80 border-fuchsia-400/40"
|
||||
className="max-w-md w-full max-h-[88vh] overflow-y-auto p-5 bg-gradient-to-br from-fuchsia-950/40 to-black/80 border-fuchsia-400/40 [scrollbar-width:thin] [scrollbar-color:rgba(232,121,249,0.4)_transparent]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ boxShadow: "0 0 40px rgba(232,121,249,0.25)" }}
|
||||
>
|
||||
@@ -108,15 +250,52 @@ export function Codex() {
|
||||
第 {selected.era} 纪元 · {ERA_NAMES[selected.era] || ""}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="text-lg font-bold text-fuchsia-100 mb-3">
|
||||
<h4 className="text-lg font-bold text-fuchsia-100 mb-4">
|
||||
{selected.title}
|
||||
</h4>
|
||||
<p className="text-sm text-foreground/90 leading-relaxed italic">
|
||||
「{selected.echo}」
|
||||
</p>
|
||||
|
||||
{/* L0 表层回响(永远显示,emerald 主题) */}
|
||||
<div className="rounded-lg border border-emerald-400/30 bg-emerald-950/20 p-3 mb-3">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-300">
|
||||
L0
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold text-emerald-300">
|
||||
表层回响
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed italic text-emerald-50/90">
|
||||
「{selected.echo}」
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* L1 / L2 深层叙事(条件渲染,按 layer 升序) */}
|
||||
{selected.loreLayers
|
||||
?.slice()
|
||||
.sort((a, b) => a.layer - b.layer)
|
||||
.map((layer) => {
|
||||
const unlocked = isLoreLayerUnlocked(layer.unlock, stateSnapshot);
|
||||
return (
|
||||
<div
|
||||
key={`${selected.id}-L${layer.layer}`}
|
||||
className="mt-3 first:mt-0"
|
||||
>
|
||||
<div className="border-t border-white/5 mb-3" />
|
||||
<LoreLayerBlock layer={layer} unlocked={unlocked} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 若该碎片没有 loreLayers */}
|
||||
{!selected.loreLayers?.length && (
|
||||
<div className="mt-4 pt-3 border-t border-white/5 text-[10px] text-muted-foreground/50 italic">
|
||||
此碎片暂无更深层叙事。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="mt-4 text-xs text-muted-foreground hover:text-foreground transition"
|
||||
className="mt-5 text-xs text-muted-foreground hover:text-foreground transition"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
|
||||