Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bed2599f5 | ||
|
|
174d1227c0 | ||
|
|
774ddaf9d1 | ||
|
|
22588425cf | ||
|
|
96748d726f | ||
|
|
20dc1f252f | ||
|
|
17820b1f28 |
@@ -1 +1 @@
|
|||||||
6708
|
1061
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# 回响星核 / Echo Nexus
|
# 回响星核 / 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.16** — 解码阈值叙事事件(全屏碎片释放叠层)+ loreLayer 已读标记 + idleHistory 详情展开 + 移动端 375px 适配审计
|
||||||
- **栈**:Next.js 16 (App Router) · TypeScript · Tailwind CSS 4 · shadcn/ui · Canvas 2D · Zustand · Web Audio API
|
- **栈**: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://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/)
|
- **在线版**:[https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/](https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/)
|
||||||
@@ -26,29 +28,60 @@
|
|||||||
|
|
||||||
## 核心特色
|
## 核心特色
|
||||||
|
|
||||||
- **解码共振谜题**:把放置游戏的「等待」变成有节奏的主动小谜题(路径构造法生成器,100% 可解)
|
- **📡 采矿无人机舰队**:每解锁一个采矿分支技术,就有一台自动采矿无人机加入舰队,24h 持续产出晶体(无需点击)
|
||||||
- **碎片涌现叙事**:故事从解码中自然浮现,5 纪元循环命名 + 模板化 lore,不打断玩法
|
- **⚙️ 放置工程项目**:6 种工程,派遣后**离线/在线自动推进**,完成领取奖励(晶体/洞见/能量/永久产能加成/随机碎片)
|
||||||
- **深空全息美学**:晶体 + Canvas 粒子 + 共振波纹的差异化视觉(严格 emerald/rose/amber/fuchsia 四色全息色谱,零蓝色/靛色)
|
- **🔓 离线收益**:关闭浏览器后产能按 `offlineEff × crystalsPerSec` 持续累积(上限 8h),重新打开弹窗结算「沉睡期间」收益
|
||||||
- **飞升多周目**:终局「接触」后保留蓝图开启新周目,星图天文台 18 个天赋跨周目永久生效
|
- **🎯 始终可见的放置徽章**:Header 永远显示「放置中 +X/s」徽章,悬停查看产能构成明细——一眼就知道这是个 idle game
|
||||||
- **实时巡航玩法**:Canvas 2D 飞船操控 + 8 种实体 + 3 层视差星空,打破纯放置节奏
|
- **🔍 解码共振谜题**:把放置游戏的「等待」变成有节奏的主动小谜题(路径构造法生成器,100% 可解)
|
||||||
- **角色 RPG 深度**:四维属性(探索力/智慧/勇气/灵感)影响所有游戏系统
|
- **📜 碎片涌现叙事**:故事从解码中自然浮现,5 纪元循环命名 + 模板化 lore,不打断玩法
|
||||||
|
- **💎 深空全息美学**:晶体 + Canvas 粒子 + 共振波纹的差异化视觉(严格 emerald/rose/amber/fuchsia 四色全息色谱,零蓝色/靛色)
|
||||||
|
- **🔄 飞升多周目**:终局「接触」后保留蓝图开启新周目,星图天文台 18 个天赋跨周目永久生效
|
||||||
|
- **🚀 实时巡航玩法**:Canvas 2D 飞船操控 + 8 种实体 + 3 层视差星空,打破纯放置节奏
|
||||||
|
- **🧬 角色 RPG 深度**:四维属性(探索力/智慧/勇气/灵感)影响所有游戏系统
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 游戏系统(10 大系统)
|
## 游戏系统(11 大系统)
|
||||||
|
|
||||||
| # | 系统 | 简介 |
|
| # | 系统 | 简介 | 类型 |
|
||||||
|---|------|------|
|
|---|------|------|------|
|
||||||
| 1 | 放置采矿 | 中央晶体球 Canvas 粒子系统 + 主动脉冲 + 连击加成 |
|
| 1 | **📡 放置采矿** | 中央晶体球 Canvas 粒子系统 + 主动脉冲 + 连击加成 + **自动采矿无人机舰队** | 放置 |
|
||||||
| 2 | 谐振解码 | 原创路径谜题,100% 可解率 |
|
| 2 | **⚙️ 放置工程** | 6 种工程 × 3 槽位,**离线/在线自动推进**,完成领取奖励 | **放置** |
|
||||||
| 3 | 技术树 | 三层科技解锁,提升产能/解码/探险 |
|
| 3 | **🔓 离线收益** | 关闭浏览器后产能按 `eff × cps` 累积(8h 上限),重新打开结算 | **放置** |
|
||||||
| 4 | 遗迹探险 | 肉鸽系统,6 种节点 + 程序化路径 + BOSS |
|
| 4 | 谐振解码 | 原创路径谜题,100% 可解率 | 主动 |
|
||||||
| 5 | 星潮事件 | 6 种动态事件,全屏叠层 + 程序化音效 |
|
| 5 | 技术树 | 三层科技解锁,提升产能/解码/探险 | 主动 |
|
||||||
| 6 | 星图天文台 | 飞升后 3 选 1 天赋 draft,18 个跨周目天赋 |
|
| 6 | 遗迹探险 | 肉鸽系统,6 种节点 + 程序化路径 + BOSS | 主动 |
|
||||||
| 7 | 回响编年史 | 跨周目叙事时间轴,5 纪元循环命名 |
|
| 7 | 星潮事件 | 6 种动态事件,全屏叠层 + 程序化音效 | 被动 |
|
||||||
| 8 | 深空信标 | 每日挑战 + 本地排行榜 Top20 |
|
| 8 | 星图天文台 | 飞升后 3 选 1 天赋 draft,18 个跨周目天赋 | 元进程 |
|
||||||
| 9 | 深空巡航 | Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关 |
|
| 9 | 回响编年史 | 跨周目叙事时间轴,5 纪元循环命名 | 元进程 |
|
||||||
| 10 | 角色属性 | 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点 |
|
| 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.1 | Gitea Pages 静态部署 + 新手教程 | ✅ |
|
||||||
| v0.5.2 | 离线收益报告 + 标签页 UI 重设计 | ✅ |
|
| v0.5.2 | 离线收益报告 + 标签页 UI 重设计 | ✅ |
|
||||||
| v0.6 | 深空巡航 Canvas 2D 实时玩法 | ✅ |
|
| v0.6 | 深空巡航 Canvas 2D 实时玩法 | ✅ |
|
||||||
| **v0.7** | **CrystalOrb Canvas 粒子系统 + 四维角色属性** | ✅ |
|
| v0.7 | CrystalOrb Canvas 粒子系统 + 四维角色属性 | ✅ |
|
||||||
| v0.8 | 巡航玩法增强 / BOSS 关卡 / 道具掉落 | 🔜 |
|
| v0.8 | 巡航玩法增强 / BOSS 关卡 / 道具掉落 | ✅ |
|
||||||
| v0.9 | 云存档 + 云排行榜 | 🔜 |
|
| v0.8.1 | 探险平衡 + 信标系统扩展(周挑战 + 信标链) | ✅ |
|
||||||
|
| **v0.14** | **放置系统:采矿无人机舰队 + 6 种放置工程 + 永久产能加成 + 始终可见 idle 徽章** | ✅ |
|
||||||
|
| **v0.15** | **叙事深化(8 碎片 × 2 层 = 16 层 loreLayers)+ 永久加成 softcap + 批量领取 + 完成 toast + StatsPanel idle 统计** | ✅ |
|
||||||
|
| **v0.16** | **解码阈值叙事事件(5 里程碑全屏叠层)+ loreLayer 已读标记 + idleHistory 详情展开 + 移动端 375px 适配审计** | ✅ |
|
||||||
|
| v0.17 | 星潮三频段切换 + 健康设计(每日软上限 + 星核建议休眠) | 🔜 |
|
||||||
| v1.0 | 全 5 纪元手写叙事 + 多人同步星潮 | 🔜 |
|
| v1.0 | 全 5 纪元手写叙事 + 多人同步星潮 | 🔜 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -86,6 +123,9 @@
|
|||||||
- [`docs/08-星图天文台系统-v0.3.1.md`](docs/08-星图天文台系统-v0.3.1.md) — v0.3.1 天文台
|
- [`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/09-回响编年史系统-v0.4.md`](docs/09-回响编年史系统-v0.4.md) — v0.4 编年史
|
||||||
- [`docs/10-深空信标系统-v0.5.md`](docs/10-深空信标系统-v0.5.md) — v0.5 信标
|
- [`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 + 批量领取**
|
||||||
|
- [`docs/13-叙事事件-v0.16.md`](docs/13-叙事事件-v0.16.md) — **v0.16 解码阈值里程碑叠层 + loreLayer 已读 + 移动端适配**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -118,7 +158,7 @@ BUILD_EXPORT=true bun run build:static
|
|||||||
cd out
|
cd out
|
||||||
git init && git checkout -b gh-pages
|
git init && git checkout -b gh-pages
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "deploy: v0.7"
|
git commit -m "deploy: v0.16"
|
||||||
git remote add origin https://git.atdunbg.xyz/Super_Z/echo-nexus.git
|
git remote add origin https://git.atdunbg.xyz/Super_Z/echo-nexus.git
|
||||||
git push -f origin gh-pages
|
git push -f origin gh-pages
|
||||||
```
|
```
|
||||||
@@ -127,4 +167,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 · 持续开发中
|
© Super_Z · 持续开发中
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
Task ID: 2-b
|
||||||
|
Agent: full-stack-developer
|
||||||
|
Task: v0.16 完善 — loreLayer 已读标记 + idleProjectHistory 详情展开 + 移动端 375px 适配审计
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- 必读上下文:worklog v0.15-release 段 + Codex.tsx(v0.15 loreLayers 分层渲染)+ StatsPanel.tsx(v0.15 抽出独立组件)+ types.ts GameState + config.ts FRAGMENTS/loreLayers/isLoreLayerUnlocked + gameStore.ts(actions + persist migrate)+ IdleOperationsPanel.tsx + BeaconPanel.tsx + ExpeditionPanel.tsx
|
||||||
|
- 子任务 1:loreLayer 已读标记
|
||||||
|
- 新建 `src/lib/game/format.ts` 导出 `formatRelativeTime(ts)` 工具函数(<60s "刚刚" / <60m "X 分钟前" / <24h "X 小时前" / <7d "X 天前" / 否则具体日期)
|
||||||
|
- `types.ts`:GameState 接口新增 `readLoreLayers: string[]`(位置:v0.14 idle 字段之后、元字段之前,注释标 v0.16 loreLayer 已读)
|
||||||
|
- `engine.ts`:
|
||||||
|
* `createInitialState` 末尾加 `readLoreLayers: []`(在 idleStats 之后)
|
||||||
|
* `performPrestige` 返回对象加 `readLoreLayers: Array.isArray(state.readLoreLayers) ? state.readLoreLayers : []`(跨周目永久保留,与 idle 永久字段同一区域)
|
||||||
|
- `gameStore.ts`:
|
||||||
|
* GameActions 接口加 `markLoreLayerRead: (fragmentId: string, layer: 1 | 2) => void`(在 tickIdleProjects 之后)
|
||||||
|
* 实现该 action:构造 key `${fragmentId}_L${layer}`,已存在则 no-op,否则 set 新数组
|
||||||
|
* persist migrate 函数末尾加 `readLoreLayers: Array.isArray(s.readLoreLayers) ? s.readLoreLayers : []`(不改 version,仍是 1)
|
||||||
|
- `Codex.tsx`:
|
||||||
|
* useShallow 订阅新增 `readLoreLayers` 与 `markLoreLayerRead`
|
||||||
|
* LoreLayerBlock 接收 `isRead` + `onMarkRead` props:
|
||||||
|
- 已读:层标题旁加 muted "✓ 已读" 标记 + 边框从 amber/fuchsia 高亮降级为 `border-white/10`
|
||||||
|
- 未读但已解锁:保持 v0.15 高亮 + 标题旁加 `animate-pulse` 脉冲小圆点 + "新" 字提示
|
||||||
|
- 文本区域包成 `<button>` 元素,点击调用 `onMarkRead`(自然交互,不另设按钮)
|
||||||
|
- 未读时下方加一行 muted "▸ 点击文本标记已读" 引导文字
|
||||||
|
* 列表卡片标题旁小圆点:未读时 `animate-pulse` + glow,已读时 `bg-*-400/30` 静态
|
||||||
|
* 顶部统计从单行"深层叙事 X/Y 已解锁"扩展为两行(移动端 stack / 桌面并排):深层叙事 X/Y 已解锁 + 已读 A/B(A=已读数,B=已解锁层数)
|
||||||
|
* 弹层 padding 从 `p-5` 改 `p-4 sm:p-5`,标题 `text-lg` 改 `text-base sm:text-lg`,外层 backdrop padding 从 `p-4` 改 `p-3 sm:p-4`(375px 适配)
|
||||||
|
|
||||||
|
- 子任务 2:idleProjectHistory 详情展开
|
||||||
|
- `StatsPanel.tsx`:
|
||||||
|
* useShallow 订阅新增 `idleProjectHistory` 数组与 `idleProjectSlots`(用于详情中潜在"进行中"状态参考)
|
||||||
|
* 删除原"放置工程历史 N 条"的简单行,改为 `col-span-2` 可展开按钮:
|
||||||
|
- 左侧 label + "(最近 5 条可展开)" 提示(仅 historyLen > 0 时显示)
|
||||||
|
- 右侧 "N 条" + ChevronDown/ChevronUp 图标(lucide-react)
|
||||||
|
* 展开后渲染 `col-span-2` 卡片:
|
||||||
|
- 空历史 → "暂无放置工程完成记录 · 前往「放置」标签派遣工程"
|
||||||
|
- 非空 → 倒序最近 5 条:每行 = 工程图标 + 工程名(truncate)+ 相对时间(formatRelativeTime)
|
||||||
|
- 最近一条用 `bg-fuchsia-500/5` 高亮
|
||||||
|
- 历史超 5 条时底部加 "仅展示最近 5 条 · 共 N 条历史" 提示
|
||||||
|
* 展开状态用本地 `useState<boolean>`
|
||||||
|
* useMemo 派生 `recentHistory`(避免每次渲染重切)
|
||||||
|
|
||||||
|
- 子任务 3:移动端 375px 适配审计
|
||||||
|
- 审计范围:Codex / IdleOperationsPanel / StatsPanel / BeaconPanel / ExpeditionPanel
|
||||||
|
- 修复点:
|
||||||
|
* `IdleOperationsPanel.tsx`:所有 `text-[9px]` → `text-[10px]`(11 处,符合"移动端字号最小 10px"规范)
|
||||||
|
* `BeaconPanel.tsx`:所有 `text-[9px]` → `text-[10px]`(7 处);信标链底部统计行 `flex items-center justify-between gap-2` → `flex flex-wrap items-center justify-between gap-x-2 gap-y-1`(防 3 组数据在 375px 溢出)
|
||||||
|
* `Codex.tsx`:弹层 padding + 标题字号响应式(见子任务 1);顶部统计区 `flex flex-col sm:flex-row`(移动端纵向堆叠)
|
||||||
|
* `StatsPanel.tsx`:detail 区 `text-[9px]` → `text-[10px]`(2 处)
|
||||||
|
- 审计通过(无需改):
|
||||||
|
* `ExpeditionPanel.tsx`:节点路径已用 `overflow-x-auto` 自适应窄屏;事件卡 p-3 padding 合理;按钮组 `flex-1` + abort 收起;能量条文字 `text-[10px]` 已达标;路径节点 `h-9 w-9` 36px 足够小且可横向滚动
|
||||||
|
* `BeaconPanel.tsx`:4 个里程碑节点 `w-12 h-12`(48px)× 4 = 192px + flex-1 分布,375px 下可容纳;日/周挑战卡 padding p-3 合理;周挑战+信标链已用 `grid-cols-1 lg:grid-cols-2` 移动单列
|
||||||
|
* `IdleOperationsPanel.tsx`:4 个 section 已用 `grid-cols-1 sm:grid-cols-N` / `grid-cols-2 sm:grid-cols-3` 响应式降级;DispatchCard 槽位按钮 `h-5 w-7` × 3 在单列布局下不溢出
|
||||||
|
* `Codex.tsx` 列表 `grid-cols-2 gap-2`:每卡片约 165px 宽,aspect-[4/3] + p-2 + text-[11px]/text-[9px](已升 text-[10px])在 375px 下可读
|
||||||
|
|
||||||
|
- 严格 4 色全息:emerald / rose / amber / fuchsia,未引入 blue / indigo
|
||||||
|
- useShallow:所有 store 订阅用 useShallow(Codex / StatsPanel 均已用)
|
||||||
|
- 持久化兼容:persist version 仍为 1,新字段 `readLoreLayers` 靠 migrate 兜底,旧存档加载时自动补 `[]`
|
||||||
|
- 与子代理 A 文件边界:types.ts/engine.ts/gameStore.ts 三个共享文件,A 的 `triggeredMilestones` / `pendingMilestone` / `dismissMilestone` 字段已落地,与本子代理的 `readLoreLayers` / `markLoreLayerRead` 完全无冲突(不同字段、不同 action、不同 migrate 行)
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- ✅ 子任务 1:loreLayer 已读标记完整实现
|
||||||
|
- 类型 + store + UI 三层联动;点击层文本即可标记已读
|
||||||
|
- 视觉区分:已读 = muted "✓ 已读" + 边框降级为 border-white/10;未读 = 高亮 + 脉冲小圆点 + "新" 字
|
||||||
|
- 顶部统计新增"已读 A/B"行(A=已读数 / B=已解锁层数)
|
||||||
|
- 跨周目永久保留(performPrestige 不重置)
|
||||||
|
- ✅ 子任务 2:idleProjectHistory 详情展开完整实现
|
||||||
|
- "放置工程历史" 行可点击展开/收起
|
||||||
|
- 展开后显示最近 5 条倒序历史(图标 + 工程名 + 相对时间)
|
||||||
|
- 空态友好提示,超 5 条底部加"仅展示最近 5 条"说明
|
||||||
|
- ✅ 子任务 3:移动端 375px 适配审计完成
|
||||||
|
- 审 5 个组件,修 4 个(Codex / IdleOps / StatsPanel / Beacon),ExpeditionPanel 无需改
|
||||||
|
- 主要修复:text-[9px] → text-[10px](共 20 处,符合"移动端字号最小 10px"规范)
|
||||||
|
- 信标链底部统计加 flex-wrap 防 3 组数据溢出
|
||||||
|
- Codex 弹层 padding + 标题字号响应式
|
||||||
|
- Codex 顶部统计区移动端纵向堆叠
|
||||||
|
- ✅ 新建文件:`/home/z/my-project/src/lib/game/format.ts`
|
||||||
|
- ✅ 修改文件清单(绝对路径):
|
||||||
|
- `/home/z/my-project/src/lib/game/types.ts`(+readLoreLayers 字段)
|
||||||
|
- `/home/z/my-project/src/lib/game/engine.ts`(createInitialState 默认 + performPrestige 保留)
|
||||||
|
- `/home/z/my-project/src/store/gameStore.ts`(GameActions 签名 + action 实现 + migrate 补全)
|
||||||
|
- `/home/z/my-project/src/components/game/Codex.tsx`(已读标记 + 点击逻辑 + 顶部统计 + 弹层响应式)
|
||||||
|
- `/home/z/my-project/src/components/game/StatsPanel.tsx`(idleHistory 展开 + 相对时间 + 字号升级)
|
||||||
|
- `/home/z/my-project/src/components/game/IdleOperationsPanel.tsx`(字号 9→10px 升级)
|
||||||
|
- `/home/z/my-project/src/components/game/BeaconPanel.tsx`(字号 9→10px 升级 + 信标链底部 flex-wrap)
|
||||||
|
- ✅ bun run lint 零错误零警告
|
||||||
|
- ✅ HTTP 200,dev.log 无运行时错误(compile 11ms / render 33ms)
|
||||||
|
- ✅ 与子代理 A 文件边界完全无冲突(不同字段/action/migrate 行)
|
||||||
|
- ⚠️ 未做 agent-browser 端到端验证(按工单由主控完成)
|
||||||
|
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 弹层可读性实测
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
# v0.16 — 解码阈值叙事事件 + loreLayer 已读 + idleHistory 详情 + 移动端适配
|
||||||
|
|
||||||
|
> 回响星核 / Echo Nexus · v0.16 设计文档
|
||||||
|
> 对应 worklog `Task ID: 2-a` + `2-b` + `v0.16-release`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、动机
|
||||||
|
|
||||||
|
v0.15 上线了 loreLayers 多层碎片文本,但存在三个未完善点:
|
||||||
|
|
||||||
|
1. **解锁瞬间缺乏仪式感** — 碎片静默出现在图谱,没有"叙事涌现"的高光时刻
|
||||||
|
2. **loreLayer 没有已读标记** — 玩家无法区分"新解锁未读"和"已读",缺乏进度感
|
||||||
|
3. **idleProjectHistory 只显示计数** — 无法查看具体完成了哪些工程、何时完成
|
||||||
|
4. **移动端 375px 未实测** — v0.15 的 Codex 分层弹层在小屏可读性未知
|
||||||
|
|
||||||
|
v0.16 用两个并行子代理一次性解决:
|
||||||
|
|
||||||
|
- **2-a**:5 个解码阈值叙事里程碑 + 全屏"碎片释放"叠层
|
||||||
|
- **2-b**:loreLayer 已读标记 + idleHistory 详情展开 + 移动端适配审计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、解码阈值叙事里程碑(2-a)
|
||||||
|
|
||||||
|
### 设计哲学
|
||||||
|
|
||||||
|
回响星核的核心卖点是**"解码驱动叙事涌现"**。v0.15 的 loreLayers 让每个碎片有 3 层文本,但解锁是静默的。v0.16 在 `totalDecoded` 达到 5/12/25/40/60 时触发**全屏叙事叠层**——不是解锁新碎片,而是对已解锁碎片的**回顾性咏叹**,类似游戏中的"过场回忆"。
|
||||||
|
|
||||||
|
5 个阈值与 FRAGMENTS 的 threshold(1/3/6/10/15/22/32/45)**错开**,是额外的叙事节奏点。
|
||||||
|
|
||||||
|
### 5 个里程碑
|
||||||
|
|
||||||
|
| threshold | id | title | color | 主题 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 5 | ms_5 | 回响 · 初鸣 | emerald | 身份觉醒(你是回响) |
|
||||||
|
| 12 | ms_12 | 代价 · 锚痕渐浅 | rose | 代价感知(解码在消耗锚痕) |
|
||||||
|
| 25 | ms_25 | 信任 · 钩上的光 | amber | 信任的重量("信任"实为引诱) |
|
||||||
|
| 40 | ms_40 | 身后 · 整齐的虚空 | fuchsia | 身后的东西(追逐者逼近) |
|
||||||
|
| 60 | ms_60 | 回归 · 借形之物 | emerald | 回归的预兆(接触满溢借形回归) |
|
||||||
|
|
||||||
|
每段 body 文本 150-250 字,与 v0.15 loreLayers 的 5 条暗线呼应:
|
||||||
|
1. 追逐者(以太族被虚空追逐者逼迫飞升)
|
||||||
|
2. 回响同源(玩家 AI 频段与以太族议会档案同源)
|
||||||
|
3. 信任即引诱("信任后来者"实为"引诱替他们完成未竟之事")
|
||||||
|
4. 飞升单向门(每次飞升都在消耗什么)
|
||||||
|
5. 回归非他们(接触满 80 时回归的是"借形之物")
|
||||||
|
|
||||||
|
### 技术实现
|
||||||
|
|
||||||
|
**新建 `src/lib/game/narrativeMilestones.ts`**:
|
||||||
|
- `NarrativeMilestone` 接口(threshold/id/title/subtitle/body/color)
|
||||||
|
- `NARRATIVE_MILESTONES` 数组(5 个里程碑)
|
||||||
|
- `findMilestoneForTotal(total, triggered)` 纯函数(返回未触发的里程碑)
|
||||||
|
|
||||||
|
**GameState 扩展**:
|
||||||
|
- `triggeredMilestones: string[]` — 已触发的 milestone id 集合(跨周目永久)
|
||||||
|
- `pendingMilestone: NarrativeMilestone | null` — 待展示的里程碑(叠层读取)
|
||||||
|
|
||||||
|
**Store 集成**:
|
||||||
|
- 两个解码完成点(`clickNode` line 596 + `autoDecodeTick` line 711)检测 `findMilestoneForTotal(newTotal, triggered)`
|
||||||
|
- 命中则加入 `triggeredMilestones` + 设置 `pendingMilestone`
|
||||||
|
- `dismissMilestone()` action 清空 `pendingMilestone`
|
||||||
|
- persist migrate 补全两字段(version 仍为 1)
|
||||||
|
- `performPrestige` 保留 `triggeredMilestones`(不重复看过场)+ 清空 `pendingMilestone`
|
||||||
|
|
||||||
|
### 全屏叠层组件
|
||||||
|
|
||||||
|
**新建 `src/components/game/NarrativeMilestoneOverlay.tsx`**:
|
||||||
|
|
||||||
|
- `fixed inset-0 z-50` + `bg-black/80 backdrop-blur-sm`(高于 StarTideOverlay 的 z-0)
|
||||||
|
- **粒子边框**:全屏 4 色主题色 inset box-shadow(120px + 240px 双层)+ `narrative-breathe` 3.5s 呼吸循环
|
||||||
|
- **中央卡片**:max-w-2xl,4 色主题渐变背景 + 主题色 border + 60px glow
|
||||||
|
- **排版层级**:
|
||||||
|
- 副标题 `text-[10px] sm:text-xs font-mono tracking-[0.3em] uppercase`
|
||||||
|
- 大标题 `text-3xl sm:text-4xl md:text-5xl font-serif font-bold` + textShadow glow
|
||||||
|
- 装饰分隔线 `linear-gradient`
|
||||||
|
- 主体文本 `text-sm sm:text-base md:text-lg leading-relaxed whitespace-pre-line`
|
||||||
|
- **入场动画**:`narrative-fade-in` 300ms(opacity 0→1)+ `narrative-scale-in` 300ms cubic-bezier(scale 1.05→1)
|
||||||
|
- **逐字浮现**:30ms/字 setTimeout 推进 + inline-block 光标 `narrative-cursor` 1s steps(2) 闪烁
|
||||||
|
- **交互**:
|
||||||
|
- 未完全显示时:点击 → skip 到全文(底部提示"点击跳过逐字显示")
|
||||||
|
- 完全显示后:点击/Esc → `dismissMilestone()`(底部提示"点击任意处或按 Esc 继续")
|
||||||
|
- **移动端 375px**:卡片 px-4 py-6 / 标题 text-3xl / 正文 text-sm / 光标 h-4
|
||||||
|
- **结构巧思**:外层订阅 + 内层 `key={milestone.id}` 重挂,state lazy init 为 0,规避 React 19 `react-hooks/set-state-in-effect` 规则
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、loreLayer 已读标记(2-b)
|
||||||
|
|
||||||
|
### 类型扩展
|
||||||
|
|
||||||
|
`GameState.readLoreLayers: string[]` — 格式 `["f1_1_L1", "f1_1_L2", ...]`
|
||||||
|
|
||||||
|
### Store
|
||||||
|
|
||||||
|
- `markLoreLayerRead(fragmentId, layer)` action:构造 key `${fragmentId}_L${layer}`,已存在则 no-op
|
||||||
|
- persist migrate 补全 `readLoreLayers: []`
|
||||||
|
- `performPrestige` 保留(跨周目永久,已读不重置)
|
||||||
|
|
||||||
|
### Codex 分层渲染增强
|
||||||
|
|
||||||
|
- **已读层**:标题旁 muted "✓ 已读" + 边框降级为 `border-white/10`
|
||||||
|
- **未读但已解锁层**:保持 v0.15 高亮(amber/fuchsia 边框 + glow)+ 标题旁 `animate-pulse` 脉冲小圆点 + "新" 字 + "▸ 点击文本标记已读" 引导
|
||||||
|
- **触发方式**:点击层文本区域(`<button>` 元素)即调用 `markLoreLayerRead`,无需额外按钮
|
||||||
|
- **顶部统计**:从单行扩展为两行(移动端 stack / 桌面并排):`深层叙事 X/Y 已解锁` + `已读 A/B`
|
||||||
|
- **列表卡片小圆点**:未读 pulse + glow,已读静态降亮
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、idleProjectHistory 详情展开(2-b)
|
||||||
|
|
||||||
|
### StatsPanel 增强
|
||||||
|
|
||||||
|
- "放置工程历史" 行改为 `col-span-2` 可展开按钮:左侧 label + "(最近 N 条可展开)",右侧 "N 条" + ChevronDown/ChevronUp
|
||||||
|
- 展开后渲染倒序最近 5 条:每行 = 工程 icon + 工程名(truncate)+ 相对时间
|
||||||
|
- 最近一条用 `bg-fuchsia-500/5` 高亮
|
||||||
|
- 超 5 条时底部加"仅展示最近 5 条 · 共 N 条历史"
|
||||||
|
- 空态友好文案
|
||||||
|
|
||||||
|
### 新建 `src/lib/game/format.ts`
|
||||||
|
|
||||||
|
`formatRelativeTime(ts: number): string`:
|
||||||
|
- `< 60s` → "刚刚"
|
||||||
|
- `< 60m` → "X 分钟前"
|
||||||
|
- `< 24h` → "X 小时前"
|
||||||
|
- `< 7d` → "X 天前"
|
||||||
|
- 否则 → 具体日期
|
||||||
|
|
||||||
|
独立文件便于后续复用(编年史时间轴、探险日志等)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、移动端 375px 适配审计(2-b)
|
||||||
|
|
||||||
|
### 审计结果
|
||||||
|
|
||||||
|
审了 5 个组件,修了 4 个:
|
||||||
|
|
||||||
|
| 组件 | 问题 | 修复 |
|
||||||
|
|------|------|------|
|
||||||
|
| `IdleOperationsPanel.tsx` | 11 处 `text-[9px]` 低于最小字号 | `text-[9px]` → `text-[10px]` |
|
||||||
|
| `BeaconPanel.tsx` | 7 处 `text-[9px]` + 信标链底部统计行 375px 溢出 | 字号升级 + `flex-wrap + gap-y-1` |
|
||||||
|
| `Codex.tsx` | 弹层 padding + 标题 + 顶部统计在小屏拥挤 | `p-5` → `p-4 sm:p-5` / 标题 `text-lg` → `text-base sm:text-lg` / 顶部统计 `flex flex-col sm:flex-row` |
|
||||||
|
| `StatsPanel.tsx` | 2 处 `text-[9px]` | `text-[10px]` |
|
||||||
|
|
||||||
|
**无需改**:
|
||||||
|
- `ExpeditionPanel.tsx` — 节点路径已用 `overflow-x-auto` 自适应,按钮 `flex-1`,路径节点 36px 足够小
|
||||||
|
- BeaconPanel 4 里程碑节点 `w-12 h-12` × 4 = 192px 在 375px 可容纳
|
||||||
|
- IdleOperationsPanel 4 section 已用 `grid-cols-1 sm:grid-cols-N` 响应式降级
|
||||||
|
|
||||||
|
### 移动端字号规范
|
||||||
|
|
||||||
|
- 最小 `text-[10px]`(9px 在高 DPI 屏可读性差)
|
||||||
|
- 标题最大 `text-2xl`(移动端)/ `text-3xl`(桌面)
|
||||||
|
- 优先用 `sm:` `md:` 响应式前缀,不删桌面端样式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、技术实现
|
||||||
|
|
||||||
|
### 修改文件清单(11 个)
|
||||||
|
|
||||||
|
**子代理 2-a(6 文件):**
|
||||||
|
- 新建 `src/lib/game/narrativeMilestones.ts`
|
||||||
|
- 新建 `src/components/game/NarrativeMilestoneOverlay.tsx`
|
||||||
|
- 修改 `src/lib/game/types.ts`(加 triggeredMilestones + pendingMilestone)
|
||||||
|
- 修改 `src/lib/game/engine.ts`(createInitialState + performPrestige)
|
||||||
|
- 修改 `src/store/gameStore.ts`(2 解码点检测 + dismissMilestone + migrate)
|
||||||
|
- 修改 `src/app/page.tsx`(挂载叠层 + 版本号)
|
||||||
|
|
||||||
|
**子代理 2-b(8 文件,2 个与 2-a 共享):**
|
||||||
|
- 新建 `src/lib/game/format.ts`
|
||||||
|
- 修改 `src/lib/game/types.ts`(加 readLoreLayers,与 2-a 共存)
|
||||||
|
- 修改 `src/lib/game/engine.ts`(createInitialState + performPrestige,与 2-a 共存)
|
||||||
|
- 修改 `src/store/gameStore.ts`(markLoreLayerRead + migrate,与 2-a 共存)
|
||||||
|
- 修改 `src/components/game/Codex.tsx`(已读标记 + 点击逻辑 + 顶部统计)
|
||||||
|
- 修改 `src/components/game/StatsPanel.tsx`(idleHistory 展开 + 字号)
|
||||||
|
- 修改 `src/components/game/IdleOperationsPanel.tsx`(字号 9→10px)
|
||||||
|
- 修改 `src/components/game/BeaconPanel.tsx`(字号 + flex-wrap)
|
||||||
|
|
||||||
|
### 并行零冲突
|
||||||
|
|
||||||
|
两个子代理在 3 个共享文件(types.ts / engine.ts / gameStore.ts)中:
|
||||||
|
- 字段名不冲突(`triggeredMilestones`/`pendingMilestone` vs `readLoreLayers`)
|
||||||
|
- action 不冲突(`dismissMilestone` vs `markLoreLayerRead`)
|
||||||
|
- migrate 补全各行独立
|
||||||
|
- 2-b 不动解码逻辑(line 575-720),那是 2-a 的领地
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、验证
|
||||||
|
|
||||||
|
- ✅ `bun run lint` 零错误零警告
|
||||||
|
- ✅ HTTP 200,dev.log 无运行时错误
|
||||||
|
- ✅ agent-browser 端到端 smoke test:
|
||||||
|
- 里程碑叠层:setState pendingMilestone=ms_5 → 全屏 dialog "回响 · 初鸣" + "点击任意处或按 Esc 继续" → 点击 → pending 清空 + triggered 保留 ✓
|
||||||
|
- loreLayer 已读:解锁 f1_1 + ascensions=1 → L1 "隐秘注脚" 显示 "新" + "标记为已读" 按钮 → 点击 → readLoreLayers=["f1_1_L1"] + L1 显示 "已读" + 顶部统计更新 ✓
|
||||||
|
- idleHistory 展开:设置 3 条历史 → 统计 tab "放置工程历史(最近 3 条可展开)" → 点击展开 → 倒序显示 3 行(icon + 工程名 + 相对时间)✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、哲学
|
||||||
|
|
||||||
|
### 仪式感与节奏
|
||||||
|
|
||||||
|
v0.16 的里程碑叠层不是"奖励",而是**节奏标记**。放置游戏容易陷入"数字增长麻木",5 个阈值(5/12/25/40/60)在玩家的解码旅程中设置了 5 个"停下来看一看"的节点:
|
||||||
|
|
||||||
|
- **ms_5**:新手期结束,确认身份
|
||||||
|
- **ms_12**:开始感知代价
|
||||||
|
- **ms_25**:中期转折,信任的重量
|
||||||
|
- **ms_40**:后期紧迫,追逐者逼近
|
||||||
|
- **ms_60**:终局预兆,回归的借形之物
|
||||||
|
|
||||||
|
每个里程碑的 body 文本都是**回顾性咏叹**——不是新剧情,而是对已解锁碎片的重新诠释。这让玩家在数字增长中保持对叙事的敏感。
|
||||||
|
|
||||||
|
### 已读标记的心理学
|
||||||
|
|
||||||
|
loreLayer 已读标记解决了一个细微的 UX 问题:玩家解锁深层文本后,如果不标记"已读",每次打开图谱都被高亮提示淹没,反而会**忽略真正的新内容**。已读标记让"新"保持稀缺,让玩家的注意力聚焦在真正未读的层上。
|
||||||
|
|
||||||
|
这与健康游戏设计呼应——**进度感不等于信息过载**。标记已读是一种"我处理过了"的仪式,让玩家心安。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、下一步(v0.17+)
|
||||||
|
|
||||||
|
- 🟡 星潮三频段切换(星潮系统增强)
|
||||||
|
- 🟡 健康设计:每日软上限 + "星核建议休眠"提醒
|
||||||
|
- 🟡 loreLayer 已读标记可扩展到碎片本身(fragment 级别的已读)
|
||||||
|
- 🟡 里程碑叠层的音效(程序化生成,与星潮音效区分)
|
||||||
|
- 🟡 format.ts 复用到编年史时间轴 + 探险日志
|
||||||
|
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";
|
"use client";
|
||||||
// 回响星核 / Echo Nexus — 游戏主入口
|
// 回响星核 / Echo Nexus — 游戏主入口
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { StarfieldCanvas } from "@/components/game/StarfieldCanvas";
|
import { StarfieldCanvas } from "@/components/game/StarfieldCanvas";
|
||||||
import { ResourceBar } from "@/components/game/ResourceBar";
|
import { ResourceBar } from "@/components/game/ResourceBar";
|
||||||
import { CrystalOrb } from "@/components/game/CrystalOrb";
|
import { CrystalOrb } from "@/components/game/CrystalOrb";
|
||||||
@@ -20,6 +20,11 @@ import { TutorialOverlay } from "@/components/game/TutorialOverlay";
|
|||||||
import { OfflineReportDialog } from "@/components/game/OfflineReportDialog";
|
import { OfflineReportDialog } from "@/components/game/OfflineReportDialog";
|
||||||
import { CruiseMode } from "@/components/game/CruiseMode";
|
import { CruiseMode } from "@/components/game/CruiseMode";
|
||||||
import { AttributesPanel } from "@/components/game/AttributesPanel";
|
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 { NarrativeMilestoneOverlay } from "@/components/game/NarrativeMilestoneOverlay";
|
||||||
import {
|
import {
|
||||||
StarTideNotifier,
|
StarTideNotifier,
|
||||||
StarTideIndicator,
|
StarTideIndicator,
|
||||||
@@ -44,12 +49,13 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
Navigation,
|
Navigation,
|
||||||
User,
|
User,
|
||||||
|
Clock,
|
||||||
} from "lucide-react";
|
} 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 { ACHIEVEMENTS } from "@/lib/game/achievements";
|
||||||
import { TIDE_EVENTS } from "@/lib/game/starTide";
|
import { TIDE_EVENTS } from "@/lib/game/starTide";
|
||||||
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
|
import { generateDailyChallenge, loadDailyProgress } from "@/lib/game/beacon";
|
||||||
import { generateDailyChallenge, loadDailyProgress, loadLeaderboard } from "@/lib/game/beacon";
|
import { getUnlockedIdleProjects } from "@/lib/game/idle";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
useGameLoop();
|
useGameLoop();
|
||||||
@@ -77,6 +83,14 @@ export default function Page() {
|
|||||||
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
|
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
|
||||||
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
|
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
|
||||||
const pendingAttrPoints = useGameStore((s) => s.pendingAttrPoints ?? 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)
|
// 深空信标:检测是否有可领取的奖励(独立 localStorage)
|
||||||
const [beaconClaimable, setBeaconClaimable] = useState(false);
|
const [beaconClaimable, setBeaconClaimable] = useState(false);
|
||||||
@@ -103,10 +117,26 @@ export default function Page() {
|
|||||||
|
|
||||||
// 挂载检测:避免持久化 store 在 SSR/CSR 间产生 hydration 不一致
|
// 挂载检测:避免持久化 store 在 SSR/CSR 间产生 hydration 不一致
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setMounted(true);
|
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 不一致
|
// 防止 SSR/CSR 不一致
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return (
|
return (
|
||||||
@@ -125,6 +155,19 @@ export default function Page() {
|
|||||||
// 仓库满仓警告
|
// 仓库满仓警告
|
||||||
const warehouseFull = crystals >= crystalCap * 0.98;
|
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 = "点击中央晶体发起脉冲,累积记忆晶体";
|
let goal = "点击中央晶体发起脉冲,累积记忆晶体";
|
||||||
if (totalDecoded === 0 && crystals >= 5) {
|
if (totalDecoded === 0 && crystals >= 5) {
|
||||||
@@ -151,6 +194,16 @@ export default function Page() {
|
|||||||
goal = `${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
|
goal = `${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
|
||||||
}
|
}
|
||||||
if (canPrestige) goal = "✦ 接触进度已满,可发起飞升进入新周目";
|
if (canPrestige) goal = "✦ 接触进度已满,可发起飞升进入新周目";
|
||||||
|
// v0.14 放置工程 goal 提示(高优先级,覆盖默认)
|
||||||
|
if (idleClaimableCount > 0) {
|
||||||
|
goal = `✦ 放置工程已完成 ${idleClaimableCount} 项,请前往「放置」标签领取奖励`;
|
||||||
|
} else if (
|
||||||
|
idleRunningCount === 0 &&
|
||||||
|
unlockedIdleCount > 0 &&
|
||||||
|
playtimeSec > 60
|
||||||
|
) {
|
||||||
|
goal = "「放置」标签可派遣工程项目,离线自动产出";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen flex flex-col bg-[#050410] text-foreground overflow-x-hidden">
|
<div className="relative min-h-screen flex flex-col bg-[#050410] text-foreground overflow-x-hidden">
|
||||||
@@ -172,12 +225,13 @@ export default function Page() {
|
|||||||
<div className="leading-tight">
|
<div className="leading-tight">
|
||||||
<h1 className="text-base sm:text-lg font-bold text-gradient">回响星核</h1>
|
<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">
|
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider">
|
||||||
ECHO NEXUS · v0.8
|
ECHO NEXUS · v0.16
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
<StarTideIndicator />
|
<StarTideIndicator />
|
||||||
|
<IdleStatusBadge onClick={() => setActiveTab("idle")} />
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -252,6 +306,8 @@ export default function Page() {
|
|||||||
<div data-tut="crystal-orb" className="contents">
|
<div data-tut="crystal-orb" className="contents">
|
||||||
<CrystalOrb />
|
<CrystalOrb />
|
||||||
</div>
|
</div>
|
||||||
|
{/* v0.14 放置工程进度条(晶体球下方) */}
|
||||||
|
<IdleProjectBar />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 右侧:解码 + 标签面板 */}
|
{/* 右侧:解码 + 标签面板 */}
|
||||||
@@ -260,10 +316,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]">
|
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[300px] sm:min-h-[360px] max-h-[560px]">
|
||||||
<DecodePanel />
|
<DecodePanel />
|
||||||
</div>
|
</div>
|
||||||
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
|
{/* 标签面板:放置 / 探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
|
||||||
<div className="glass rounded-2xl p-3 min-h-[280px] sm:min-h-[320px] max-h-[440px] flex flex-col">
|
<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">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
|
||||||
<TabsList className="grid grid-cols-8 h-9 bg-black/30 gap-0.5 p-1">
|
<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">
|
<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" />
|
<Rocket className="h-3.5 w-3.5" />
|
||||||
<span className="leading-none">探险</span>
|
<span className="leading-none">探险</span>
|
||||||
@@ -315,6 +380,9 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</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">
|
<TabsContent value="expedition" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||||
<ExpeditionPanel />
|
<ExpeditionPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -401,59 +469,10 @@ export default function Page() {
|
|||||||
<TutorialOverlay />
|
<TutorialOverlay />
|
||||||
<OfflineReportDialog />
|
<OfflineReportDialog />
|
||||||
{cruiseOpen && <CruiseMode onClose={() => setCruiseOpen(false)} />}
|
{cruiseOpen && <CruiseMode onClose={() => setCruiseOpen(false)} />}
|
||||||
|
{/* v0.16 叙事里程碑全屏叠层(pendingMilestone 非空时渲染,模态盖住一切) */}
|
||||||
|
<NarrativeMilestoneOverlay />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ export function BeaconPanel() {
|
|||||||
周挑战 · WEEKLY
|
周挑战 · WEEKLY
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-[9px] text-muted-foreground/70">
|
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||||
<span className="font-mono text-fuchsia-300/80">
|
<span className="font-mono text-fuchsia-300/80">
|
||||||
{weeklyChallenge.weekKey}
|
{weeklyChallenge.weekKey}
|
||||||
</span>
|
</span>
|
||||||
@@ -591,20 +591,20 @@ export function BeaconPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className="text-[9px] text-center leading-tight"
|
className="text-[10px] text-center leading-tight"
|
||||||
style={{ color: textColor }}
|
style={{ color: textColor }}
|
||||||
>
|
>
|
||||||
{reward?.label}
|
{reward?.label}
|
||||||
</span>
|
</span>
|
||||||
{claimed ? (
|
{claimed ? (
|
||||||
<span className="text-[9px] text-emerald-400/80 font-mono">
|
<span className="text-[10px] text-emerald-400/80 font-mono">
|
||||||
已领
|
已领
|
||||||
</span>
|
</span>
|
||||||
) : reachable ? (
|
) : reachable ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleClaimChain(m)}
|
onClick={() => handleClaimChain(m)}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-5 px-2 text-[9px] py-0 border-0"
|
className="h-5 px-2 text-[10px] py-0 border-0"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"linear-gradient(90deg, #fb7185, #f43f5e)",
|
"linear-gradient(90deg, #fb7185, #f43f5e)",
|
||||||
@@ -614,7 +614,7 @@ export function BeaconPanel() {
|
|||||||
领取
|
领取
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[9px] text-muted-foreground/40 font-mono">
|
<span className="text-[10px] text-muted-foreground/40 font-mono">
|
||||||
+{reward?.rewardInsight}洞
|
+{reward?.rewardInsight}洞
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -646,7 +646,7 @@ export function BeaconPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部统计 */}
|
{/* 底部统计 */}
|
||||||
<div className="flex items-center justify-between gap-2 text-[10px]">
|
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 text-[10px]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground/60">最长</span>
|
<span className="text-muted-foreground/60">最长</span>
|
||||||
<span className="font-mono text-amber-300">
|
<span className="font-mono text-amber-300">
|
||||||
@@ -715,7 +715,7 @@ export function BeaconPanel() {
|
|||||||
<span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
|
<span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
|
||||||
<span className="text-muted-foreground/80 truncate">{eType.label}</span>
|
<span className="text-muted-foreground/80 truncate">{eType.label}</span>
|
||||||
{entry.isWeekly && (
|
{entry.isWeekly && (
|
||||||
<span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
|
<span className="text-[10px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
|
||||||
WEEK
|
WEEK
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -737,7 +737,7 @@ export function BeaconPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 难度图例 */}
|
{/* 难度图例 */}
|
||||||
<div className="flex items-center justify-center gap-3 text-[9px] text-muted-foreground/50">
|
<div className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground/50">
|
||||||
{DIFFICULTY_ORDER.map((d) => {
|
{DIFFICULTY_ORDER.map((d) => {
|
||||||
const m = BEACON_DIFFICULTY[d];
|
const m = BEACON_DIFFICULTY[d];
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,25 +1,207 @@
|
|||||||
"use client";
|
"use client";
|
||||||
// 回响星核 / Echo Nexus — 记忆图谱(碎片叙事)
|
// 回响星核 / Echo Nexus — 记忆图谱(碎片叙事 + v0.15 多层文本 + v0.16 已读标记)
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useGameStore, FRAGMENTS } from "@/store/gameStore";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { nextFragmentThreshold } from "@/store/gameStore";
|
import { useGameStore, FRAGMENTS, nextFragmentThreshold } from "@/store/gameStore";
|
||||||
|
import { isLoreLayerUnlocked } from "@/lib/game/config";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { BookOpen, Lock, Sparkles } from "lucide-react";
|
import { BookOpen, Lock, Sparkles, Check } from "lucide-react";
|
||||||
import type { Fragment } from "@/lib/game/types";
|
import type { Fragment, FragmentLoreLayer } from "@/lib/game/types";
|
||||||
|
|
||||||
const ERA_NAMES = ["", "寂灭前夜", "谐振纪", "飞升潮", "回响纪", "接触纪"];
|
const ERA_NAMES = ["", "寂灭前夜", "谐振纪", "飞升潮", "回响纪", "接触纪"];
|
||||||
|
|
||||||
|
/** 锁定层用的乱码占位文本(视觉上像被加密的全息碎片) */
|
||||||
|
const LOCKED_PLACEHOLDER =
|
||||||
|
"▓░▒░ ▓▒░▒▓ ░▒▓░ ▒▓░▒░ ▓▒░ ▓░▒░▒ ░▒▓░▒ ▓░▒ ░▒▓░ ▒▓░▒▓░ ▓▒░░▒▓ ░▒▓ ▓░▒▒░ ▓▒░▓░▒ ░▒▓░ ▒▓░▒░ ▓▒░ ░▒▓░";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染单层叙事文本(已解锁 / 未解锁 / 已读 三态)。
|
||||||
|
* v0.15:L1=amber / L2=fuchsia
|
||||||
|
* v0.16:
|
||||||
|
* - 已读 → 标题旁加 ✓ muted 标记,边框降级为 muted
|
||||||
|
* - 未读但已解锁 → 保持 v0.15 高亮,标题旁加脉冲小点(animate-pulse)提示"新"
|
||||||
|
* - 点击文本区域即标记已读(自然交互,不另设按钮)
|
||||||
|
*/
|
||||||
|
function LoreLayerBlock({
|
||||||
|
layer,
|
||||||
|
unlocked,
|
||||||
|
isRead,
|
||||||
|
onMarkRead,
|
||||||
|
}: {
|
||||||
|
layer: FragmentLoreLayer;
|
||||||
|
unlocked: boolean;
|
||||||
|
isRead: boolean;
|
||||||
|
onMarkRead: () => void;
|
||||||
|
}) {
|
||||||
|
const isL1 = layer.layer === 1;
|
||||||
|
const accentText = isL1 ? "text-amber-300" : "text-fuchsia-300";
|
||||||
|
// v0.16:已读时降级为 muted 边框,未读时保持高亮
|
||||||
|
const accentBorder =
|
||||||
|
unlocked && !isRead
|
||||||
|
? isL1
|
||||||
|
? "border-amber-400/30"
|
||||||
|
: "border-fuchsia-400/30"
|
||||||
|
: "border-white/10";
|
||||||
|
const accentBg =
|
||||||
|
unlocked && !isRead
|
||||||
|
? isL1
|
||||||
|
? "bg-amber-950/20"
|
||||||
|
: "bg-fuchsia-950/20"
|
||||||
|
: isL1
|
||||||
|
? "bg-amber-950/10"
|
||||||
|
: "bg-fuchsia-950/10";
|
||||||
|
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 ${accentBorder} ${
|
||||||
|
unlocked ? accentBg : "bg-black/20"
|
||||||
|
} p-3 transition-colors duration-300`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* v0.16 已读 / 未读指示 */}
|
||||||
|
{unlocked && isRead && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-0.5 text-[9px] text-muted-foreground/70 shrink-0"
|
||||||
|
title="已读"
|
||||||
|
>
|
||||||
|
<Check className="h-2.5 w-2.5" />
|
||||||
|
已读
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{unlocked && !isRead && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-0.5 shrink-0"
|
||||||
|
title="新解锁 · 点击文本可标记已读"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-1.5 w-1.5 rounded-full animate-pulse ${
|
||||||
|
isL1 ? "bg-amber-400" : "bg-fuchsia-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] text-muted-foreground/80">新</span>
|
||||||
|
</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 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMarkRead}
|
||||||
|
className="block text-left w-full focus:outline-none focus-visible:ring-1 focus-visible:ring-white/30 rounded"
|
||||||
|
title={isRead ? "已读" : "点击标记为已读"}
|
||||||
|
aria-label={isRead ? "已读" : "标记为已读"}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={`text-[12px] leading-relaxed italic ${accentBody} ${
|
||||||
|
isRead ? "opacity-80" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
「{layer.text}」
|
||||||
|
</p>
|
||||||
|
{!isRead && (
|
||||||
|
<span className="block mt-1.5 text-[9px] text-muted-foreground/40">
|
||||||
|
▸ 点击文本标记已读
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<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() {
|
export function Codex() {
|
||||||
const fragments = useGameStore((s) => s.fragments);
|
// useShallow 一次性订阅所有判定所需的 state 切片(v0.16 新增 readLoreLayers + markLoreLayerRead)
|
||||||
const totalDecoded = useGameStore((s) => s.totalDecoded);
|
const { fragments, totalDecoded, ascensions, contact, bossKills, readLoreLayers, markLoreLayerRead } =
|
||||||
|
useGameStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
fragments: s.fragments,
|
||||||
|
totalDecoded: s.totalDecoded,
|
||||||
|
ascensions: s.ascensions,
|
||||||
|
contact: s.contact,
|
||||||
|
bossKills: s.bossKills,
|
||||||
|
readLoreLayers: Array.isArray(s.readLoreLayers) ? s.readLoreLayers : [],
|
||||||
|
markLoreLayerRead: s.markLoreLayerRead,
|
||||||
|
})),
|
||||||
|
);
|
||||||
const nextThreshold = nextFragmentThreshold(totalDecoded);
|
const nextThreshold = nextFragmentThreshold(totalDecoded);
|
||||||
const [selected, setSelected] = useState<Fragment | null>(null);
|
const [selected, setSelected] = useState<Fragment | null>(null);
|
||||||
|
|
||||||
// v0.1 只展示第一纪元
|
// 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;
|
const unlockedCount = era1.filter((f) => fragments[f.id]).length;
|
||||||
|
|
||||||
|
// 已读集合(Set 加速查询)
|
||||||
|
const readSet = useMemo(() => new Set(readLoreLayers), [readLoreLayers]);
|
||||||
|
|
||||||
|
// 用于判定解锁的快照对象(结构匹配 isLoreLayerUnlocked 的 Pick)
|
||||||
|
const stateSnapshot = {
|
||||||
|
ascensions,
|
||||||
|
totalDecoded,
|
||||||
|
contact,
|
||||||
|
fragments,
|
||||||
|
bossKills,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 深层叙事统计:所有 loreLayer 中已解锁数 / 总数 + 已读数(v0.16)
|
||||||
|
const loreStats = useMemo(() => {
|
||||||
|
let total = 0;
|
||||||
|
let unlocked = 0;
|
||||||
|
let read = 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;
|
||||||
|
// 仅统计已解锁层的已读数(未解锁的层不算入已读分母)
|
||||||
|
if (readSet.has(`${f.id}_L${layer.layer}`)) read += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { total, unlocked, read };
|
||||||
|
}, [era1, ascensions, totalDecoded, contact, fragments, bossKills, readSet]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 h-full">
|
<div className="flex flex-col gap-2 h-full">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -32,6 +214,24 @@ export function Codex() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* v0.15 深层叙事统计 + v0.16 已读统计(移动端 stack,桌面并排) */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 text-[10px] px-1">
|
||||||
|
<span className="text-muted-foreground/80">
|
||||||
|
深层叙事
|
||||||
|
<span className="font-mono ml-1">
|
||||||
|
<span className="text-amber-300">{loreStats.unlocked}</span>
|
||||||
|
<span className="text-muted-foreground/60"> / {loreStats.total} 已解锁</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground/80">
|
||||||
|
已读
|
||||||
|
<span className="font-mono ml-1">
|
||||||
|
<span className="text-emerald-300">{loreStats.read}</span>
|
||||||
|
<span className="text-muted-foreground/60"> / {loreStats.unlocked} 已解锁层</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{nextThreshold && (
|
{nextThreshold && (
|
||||||
<div className="text-[10px] text-muted-foreground/80 px-1">
|
<div className="text-[10px] text-muted-foreground/80 px-1">
|
||||||
再解码 <span className="text-fuchsia-300 font-mono">{nextThreshold - totalDecoded}</span> 颗 → 下一个碎片
|
再解码 <span className="text-fuchsia-300 font-mono">{nextThreshold - totalDecoded}</span> 颗 → 下一个碎片
|
||||||
@@ -42,6 +242,21 @@ export function Codex() {
|
|||||||
<div className="grid grid-cols-2 gap-2 pr-2">
|
<div className="grid grid-cols-2 gap-2 pr-2">
|
||||||
{era1.map((f, i) => {
|
{era1.map((f, i) => {
|
||||||
const unlocked = !!fragments[f.id];
|
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;
|
||||||
|
// v0.16:是否有未读的已解锁层(用于卡片标题旁小圆点变色)
|
||||||
|
const hasUnreadLayer =
|
||||||
|
(l1Unlocked && !readSet.has(`${f.id}_L1`)) ||
|
||||||
|
(l2Unlocked && !readSet.has(`${f.id}_L2`));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={f.id}
|
key={f.id}
|
||||||
@@ -69,7 +284,38 @@ export function Codex() {
|
|||||||
{unlocked ? (
|
{unlocked ? (
|
||||||
<>
|
<>
|
||||||
<div className="absolute -top-3 -right-3 h-12 w-12 rounded-full bg-fuchsia-500/20 blur-xl" />
|
<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={
|
||||||
|
hasUnreadLayer
|
||||||
|
? "此碎片含未读的深层文本"
|
||||||
|
: "此碎片的深层文本均已读"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{l1Unlocked && (
|
||||||
|
<span
|
||||||
|
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||||
|
l1Unlocked && !readSet.has(`${f.id}_L1`)
|
||||||
|
? "bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.8)] animate-pulse"
|
||||||
|
: "bg-amber-400/30"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{l2Unlocked && (
|
||||||
|
<span
|
||||||
|
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||||
|
l2Unlocked && !readSet.has(`${f.id}_L2`)
|
||||||
|
? "bg-fuchsia-400 shadow-[0_0_4px_rgba(232,121,249,0.8)] animate-pulse"
|
||||||
|
: "bg-fuchsia-400/30"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="text-[11px] font-semibold text-fuchsia-100 leading-tight">
|
<div className="text-[11px] font-semibold text-fuchsia-100 leading-tight">
|
||||||
{f.title}
|
{f.title}
|
||||||
</div>
|
</div>
|
||||||
@@ -91,32 +337,75 @@ export function Codex() {
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* 详情弹层 */}
|
{/* 详情弹层 — v0.15 分层渲染 + v0.16 已读标记 / 移动端 375px 适配 */}
|
||||||
{selected && (
|
{selected && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-black/70 backdrop-blur-sm"
|
||||||
onClick={() => setSelected(null)}
|
onClick={() => setSelected(null)}
|
||||||
>
|
>
|
||||||
<Card
|
<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-4 sm: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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ boxShadow: "0 0 40px rgba(232,121,249,0.25)" }}
|
style={{ boxShadow: "0 0 40px rgba(232,121,249,0.25)" }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Sparkles className="h-4 w-4 text-fuchsia-300" />
|
<Sparkles className="h-4 w-4 text-fuchsia-300 shrink-0" />
|
||||||
<span className="text-[10px] text-fuchsia-300/70 font-mono">
|
<span className="text-[10px] text-fuchsia-300/70 font-mono truncate">
|
||||||
第 {selected.era} 纪元 · {ERA_NAMES[selected.era] || ""}
|
第 {selected.era} 纪元 · {ERA_NAMES[selected.era] || ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-lg font-bold text-fuchsia-100 mb-3">
|
<h4 className="text-base sm:text-lg font-bold text-fuchsia-100 mb-4 break-words">
|
||||||
{selected.title}
|
{selected.title}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-foreground/90 leading-relaxed italic">
|
|
||||||
|
{/* 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}」
|
「{selected.echo}」
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* L1 / L2 深层叙事(条件渲染,按 layer 升序) */}
|
||||||
|
{selected.loreLayers
|
||||||
|
?.slice()
|
||||||
|
.sort((a, b) => a.layer - b.layer)
|
||||||
|
.map((layer) => {
|
||||||
|
const unlocked = isLoreLayerUnlocked(layer.unlock, stateSnapshot);
|
||||||
|
const isRead = unlocked && readSet.has(`${selected.id}_L${layer.layer}`);
|
||||||
|
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}
|
||||||
|
isRead={isRead}
|
||||||
|
onMarkRead={() => markLoreLayerRead(selected.id, layer.layer)}
|
||||||
|
/>
|
||||||
|
</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
|
<button
|
||||||
onClick={() => setSelected(null)}
|
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>
|
</button>
|
||||||
|
|||||||