Compare commits

..
6 Commits
Author SHA1 Message Date
Super_Z 174d1227c0 68f373b9-921a-4acf-844c-ab7e89ffb727 2026-06-24 22:50:18 +00:00
Z.ai Code 774ddaf9d1 docs: v0.15 部署记录 + main 分叉说明 2026-06-24 22:47:11 +00:00
Super_Z 22588425cf 48c8e466-e804-444f-9f6d-4a2d34b3379a 2026-06-24 12:34:05 +00:00
Super_Z 96748d726f v0.15: 叙事深化 + 放置平衡 + UX 增强
- loreLayers 多层碎片文本(8 碎片 × 2 层 = 16 层深层叙事,5 条暗线交织)
- 永久加成 softcap(≤30 全额 / 30-100 ×0.5 / >100 ×0.25,防滚雪球)
- 批量领取全部工程 + 工程完成 toast 通知
- StatsPanel 独立组件 + idle 统计行(永久加成/完成工程/产出晶体/历史)
- Codex 分层渲染(L0 emerald 表层 / L1 amber 隐秘注脚 / L2 fuchsia 深层回响)
- 修复 v0.14 双重计算 bug(idleTotalPerSec)
- 严格 4 色全息,lint 零错误,agent-browser 端到端验证通过
2026-06-24 12:32:56 +00:00
Super_Z 20dc1f252f docs: v0.14 README + 放置系统设计文档 + worklog 更新 2026-06-24 10:23:08 +00:00
Super_Z 17820b1f28 v0.14: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章 2026-06-24 10:19:40 +00:00
87 changed files with 8347 additions and 5308 deletions
+1 -1
View File
@@ -1 +1 @@
12512
1061
+91 -25
View File
@@ -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 · 持续开发中
-91
View File
@@ -1,91 +0,0 @@
# Task ID: 10-a — 限时挑战 + 星潮类型深化
**Agent**: full-stack-developer
**Task**: 工单 #5 限时挑战 + 工单 #3 星潮类型深化
## Work Log
### 前置准备
- 阅读 `worklog.md` 了解 v0.8.1 项目状态(10 大系统,四色全息 emerald/rose/amber/fuchsia
- 精读 `beacon.ts`(873 行,日挑战+周挑战+信标链)、`BeaconPanel.tsx`753 行)、`starTide.ts`209 行,6 种星潮)、`gameStore.ts` trackBeacon~170 行)+ tickTide~440 行)
- 确认现有 UI 均动态读取 `TIDE_EVENTS[type]`,新增类型自动生效
### 工单 #5:限时挑战(Timed Challenge
**beacon.ts 扩展**873 → 1162 行,+289 行):
- `BeaconScoreEntry` 新增 `isTimed?: boolean` 字段
- `BeaconTimedChallenge` 接口:slotKey / type / difficulty(固定 routine) / goal / rewardInsight / rewardContact / seed / title / desc / expiresAt
- `BeaconTimedProgress` 接口:slotKey / progress / startedAt / completedAt / claimed / durationSec
- `TIMED_SLOT_MS = 4h``BEACON_TIMED_KEY = "echo-nexus-beacon-timed-v1"`
- `getTimedSlotKey(now)`UTC 0/4/8/12/16/20 点切分,格式 "timed_YYYY-MM-DD_HH"
- `timedSlotKeyToSeed`FNV-1a 哈希
- `generateTimedChallenge(now)`:确定性生成,难度 routinegoal = 日基准 ×0.3-0.55 种类型各有公式
- `msUntilNextTimedSlot(now)`:距离下个 4 小时时段的毫秒数
- `loadTimedProgress` / `saveTimedProgress` / `addTimedProgress` / `claimTimedReward`(推送排行榜,entry 加 isTimed: true
**gameStore.ts 集成**
- import 扩展:generateTimedChallenge / loadTimedProgress / addTimedProgress / claimTimedReward + 类型
- `trackBeacon` 返回值新增 `timedJustCompleted`:日/限时/周三类进度同时更新
- 新增 `claimTimedBeacon` actionGameActions 接口同步)
- 新增 `timedJustCompleted` 到 trackBeacon 返回类型
**BeaconPanel.tsx UI**753 → 944 行):
- import 扩展:generateTimedChallenge / loadTimedProgress / getTimedSlotKey / msUntilNextTimedSlot / Hourglass 图标
- 新增 timedChallenge/timedProgress/timedCountdown state
- 每秒刷新 timedProgress + timedCountdown
- 新增 handleClaimTimed 回调
- **限时挑战区块**(amber 主题,插入日挑战与周挑战之间):
- Hourglass 图标 + "限时挑战 · TIMED" 标题 + slotKey + 倒计时
- 难度/类型标签 + 标题 + 描述 + 进度条 + 奖励 + 领取按钮(emerald 主题)
- **紧急状态**:距时段结束 <30min 时切换 rose 脉冲动画 + "即将结束"徽章
- 2 个新动画:timed-glowamber 呼吸)/ timed-urgentrose 急促脉冲)
- 排行榜 entry 区分 TIMEDamber 徽章)/ WEEKfuchsia 徽章)
- 空状态提示更新为"完成限时、每日或本周信标即可登榜"
### 工单 #3:星潮类型深化
**starTide.ts 扩展**209 → 289 行):
- `TideType` 新增 3 种:surge / eclipse / prism
- `TideModifiers` 新增 3 个可选字段:targetLenBonus / bossWinRateBonus / autoDecodeIntervalMult
- `TIDE_EVENTS` 新增 3 种元信息(name/desc/color/glow/icon/negative):
- surge(涌动星潮,emerald #34d399,⇈):产能 ×2.5 但解码目标 +2
- eclipse(蚀相星潮,rose #fb7185,◐):BOSS 胜率 +20% 但产能 -30%
- prism(棱镜星潮,fuchsia #e879f9,◬):洞见 ×2 + 自动解码周期 -30%
- `TIDE_WEIGHTS` 新增 surge/eclipse/prism 各 12(总权重 136
- `getTideModifiers` 新增 3 个 case + null 分支补全新字段
- 注释更新:"6 种类型" → "9 种类型"
**decode.ts 扩展**
- `generatePuzzle` 新增第 3 参数 `targetLenBonus`(默认 0
- targetLen = cfg.targetLen + targetLenBonusstepLimitBase 同步增加
- 路径构造法自动适配更长目标序列(300 次尝试 + 线性兜底)
**gameStore.ts 集成**3 处修饰器应用):
- `startDecode`surge 星潮时传入 `tideMod.targetLenBonus` → 谜题更长
- `autoDecodeTick`prism 星潮时 `interval *= tideMod.autoDecodeIntervalMult`(×0.7
- `resolveCurrentNode`eclipse 星潮时 `b = am.bossWinRateBonus + tideMod.bossWinRateBonus`+20%
**achievements.ts**`ach_tides_all` 阈值 6 → 9,描述"经历全部 9 种星潮事件"
**page.tsx**StatsPanel "星潮亲历" 显示 `/ 9`
### QA 验证
1. **lint**`bun run lint` 零错误(exit 0
2. **dev 服务器**HTTP 200,编译 < 30ms
3. **BeaconPanel VLM 评分**
- 首屏 8.5/10:限时挑战 TIMED 区块可见、四色一致、倒计时/进度/标题/奖励/按钮清晰
- 最终截图 9/10:4 个挑战区块齐全、限时区块带倒计时、无蓝色
4. **新星潮 VLM 评分**localStorage 注入 + reload 触发):
- surgeemerald 绿):8/10,横幅 + 全屏绿色微光
- prismfuchsia 紫):7/10,横幅 + 全屏紫色微光
- eclipserose 粉):8/10(单图),横幅 + 全屏粉色微光
5. **不破坏现有功能**:日挑战/周挑战/信标链/6 旧星潮全部保留,排行榜正常
## Stage Summary
- ✅ 工单 #5 限时挑战完成:beacon.ts +289 行 + gameStore claimTimedBeacon + BeaconPanel amber 区块
- ✅ 工单 #3 星潮深化完成:starTide.ts +80 行(3 新类型)+ decode.ts targetLenBonus + gameStore 3 处修饰器
- ✅ 确定性生成:限时挑战同时段同种子同结果(FNV-1a slotKey 哈希)
- ✅ 严格四色规范:限时 amber、surge emerald、eclipse rose、prism fuchsia,零蓝色/靛色
- ✅ 紧急状态设计:限时挑战 <30min 时 rose 脉冲 + "即将结束"徽章
- ✅ lint 零错误 + dev HTTP 200 + VLM ≥7/10(信标 9/10、surge 8/10、prism 7/10、eclipse 8/10
- ✅ 现有日/周/信标链/6 旧星潮全部保留,无回归
-207
View File
@@ -1,207 +0,0 @@
# Task 10-b · full-stack-developer · P2/#4 云排行榜 mini-service
> 本文件为本 agent 在 Task 10-b 的工作记录,供后续 agent 查阅。
## 任务概述
为「回响星核 / Echo Nexus」v0.8.2 实现 P2/#4 工单:把信标本机排行榜(纯 localStorage)升级为云端 Top100,让玩家间可以比较分数。
## 阅读的前置工作
- `/home/z/my-project/worklog.md`(v0.8.1 项目状态,10 大系统,四色全息规范)
- `/home/z/my-project/src/lib/game/beacon.ts`(实际 1161 行 — worklog 信息滞后,v0.8.2 已加限时挑战 `claimTimedReward`
- `/home/z/my-project/src/components/game/BeaconPanel.tsx`(实际 953 行 — 已含限时挑战 amber 主题)
- `/home/z/my-project/src/store/gameStore.ts`1241 行 — trackBeacon + 3 个 claim actiongrantBeaconReward/claimWeeklyBeacon/claimTimedBeacon/claimChainReward
- `/home/z/my-project/Caddyfile`:81 网关,按 ?XTransformPort=* 转发到对应端口)
- `/home/z/my-project/examples/websocket/server.ts` + `frontend.tsx`mini-service + 前端连接参考)
- `/home/z/my-project/.zscripts/dev.sh`mini-service 自动扫描启动逻辑,发现 mini-services 目录下子服务自动 `bun run dev`
- `/home/z/my-project/agent-ctx/9-b-full-stack-developer.md`(前一个 agent 的信标系统扩展记录)
- `/home/z/my-project/mini-services/star-tide-service/`(已存在的 P3 #10-c 服务,端口 3031
## 实现细节
### 1. mini-service`mini-services/leaderboard-service/`
**目录结构**
```
mini-services/leaderboard-service/
├── package.json # 独立 bun 项目,type=module
├── index.ts # Hono 入口(182 行)
├── bun.lock # 自动生成
├── node_modules/ # hono 独立安装
└── README.md # API 文档
```
**package.json**
- `"dev": "bun --hot index.ts"`(规范要求的热重载脚本)
- `"start": "bun index.ts"`(无热重载,用于稳定运行)
- 依赖:hono ^4.6.14
**index.ts 设计要点**
- Hono + `cors()` 中间件,origin: * 全开放(前端跨端口必须)
- 内存数组 `entries: BeaconScoreEntry[]`,最多 1000 条,按 score 降序
- 同分排序:先看用时短,再看提交早
- 防刷:`lastSubmitAt: Map<dateKey|challenge, ts>`,10 秒内只接受 1 次,返回 429 + retryAfterMs
- 4 个路由:
- `GET /` → 健康检查 `{service, version, ok, uptime}`
- `GET /api/leaderboard` → Top100 `{entries, total}`
- `POST /api/leaderboard` body `{entry}` → schema 校验 → 推入 → 排序 → `{entries, total, rank}`
- `GET /api/leaderboard/stats``{totalSubmissions, uniquePlayers, topScore}`
- OPTIONS 预检由 hono/cors 自动处理,返回 204 + 完整 CORS 头
- 端口固定 3030`export default { port, fetch }` 标准 bun 模式
**启动调试经验**
- `bun --hot` 在本沙盒环境下不稳定,多次启动后约 5-10s 内被 kill(即使 setsid + nohup
- 解决方案:用 `bun index.ts`(无 --hot+ 双 fork 模式:`( bun index.ts >/tmp/log 2>&1 & )`
- 双 fork 后 PPID=1reparent 到 init),进程脱离原 bash sessionuptime 4+ 分钟稳定
### 2. beacon.ts 扩展(1161 → 1290 行,+129 行)
**三个 claim 函数返回值扩展(非破坏性)**
- `claimBeaconReward` 返回值新增 `entry: BeaconScoreEntry`
- `claimWeeklyReward` 返回值新增 `entry: BeaconScoreEntry`
- `claimTimedReward` 返回值新增 `entry: BeaconScoreEntry`
- 用途:让前端 / gameStore 能拿到完整 entry 提交到云端
**新增云排行榜模块**
- `BEACON_CLOUD_PORT = 3030` 常量
- `BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1"` localStorage key
- 3 个响应接口:`CloudLeaderboardResponse` / `CloudSubmitResponse` / `CloudStatsResponse`
- `loadLastCloudSubmitTimestamp()` / `saveLastCloudSubmitTimestamp(ts)`
- `fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]>` — GET `/api/leaderboard?XTransformPort=3030`
- `submitCloudScore(entry): Promise<number>` — POST,成功返回 rank ≥ 1,失败返回 -1,自动记录 ts
- `fetchCloudStats(): Promise<CloudStatsResponse | null>`
- **所有 fetch 用相对路径 + ?XTransformPort=3030,禁止 localhost:3030**
- try-catch 包裹,失败时静默返回空/null,不抛异常
### 3. gameStore.ts 集成
- import 新增 `submitCloudScore`
- `claimWeeklyBeacon` action:领奖成功后 `void submitCloudScore(res.entry)`fire-and-forget,不 await,不阻塞)
- `claimTimedBeacon` action:同上
- `claimBeaconReward` (daily) 由 BeaconPanel 直接调用,不通过 gameStore
### 4. BeaconPanel.tsx 重写(953 → 1170 行,+217 行)
**新增 import**
- `fetchCloudLeaderboard` / `submitCloudScore` / `loadLastCloudSubmitTimestamp` from beacon
- 4 个 lucide 图标:`RefreshCw` / `Globe` / `WifiOff` / `Loader2`
- `useRef` from react
**新增状态**
- `lbTab: 'local' | 'global'`(默认 local
- `cloudEntries: BeaconScoreEntry[]`
- `cloudLoading: boolean`
- `cloudError: string | null`
- `cloudFetched: boolean`
- `mySubmitTs: number`
- `cloudFetchingRef` (防并发)
**新增逻辑**
- `refreshCloudLeaderboard` callback:拉取云端榜 + 防并发 + 空 entries 时显示"成为首位登顶者"
- useEffect:切到全球 tab 自动拉取(仅首次)+ 初始化读取 mySubmitTs
- `handleClaimDaily`:领奖后 `void submitCloudScore(res.entry).then(rank => ...)` — 若用户在全球 tab 自动刷新
- `handleClaimTimed` / `handleClaimWeekly`:领奖后更新 mySubmitTs + 若在全球 tab 自动刷新
**排行榜 UI 重写为双 tab**
- 顶部 tab 切换:「本地」(amber 主题) / 「全球」(emerald 主题) — 用 `<button aria-pressed>` 实现可访问性
- 全球 tab 专属刷新按钮(RefreshCwloading 时 animate-spin
- 子标题:本地"本机 Top N · 离线可用" / 全球"全球 Top N · 已同步 / 同步中…"
- 本地榜:保留原 v0.8.2 渲染(max-h-200px overflow-y-auto
- 全球榜:
- 加载中:Loader2 spinner + "正在拉取全球榜…"
- 空榜 + 错误:WifiOff 图标 + 错误文案 + 重试按钮
- 空榜 + 无错:Globe 图标 + "尚无全球记录。完成挑战并领取即可登顶全球榜!"
- 有数据:渲染前 100 条,每条带排名(1-3 名用 Crown/Medal/Award 图标)/ 难度色 / WEEK·TIMED 徽章 / 用时 / 分数
- **玩家自己的记录高亮**:通过 `entry.timestamp === mySubmitTs` 匹配
- emerald 边框 (`border-emerald-500/40`)
- emerald 阴影 (`shadow-[0_0_12px_rgba(52,211,153,0.25)]`)
- YOU 徽章 (`bg-emerald-500/25 text-emerald-200 border-emerald-500/50`)
- emerald 分数色 (`color: #34d399`)
- 加粗排名
### 5. eslint 配置
- `eslint.config.mjs` 新增 ignores `"mini-services/**"`
- 原因:mini-service 是独立 bun 项目,有自己的 TypeScript 配置和 lint 规范,不应参与主项目 lint
## QA 验证结果
### 1. lint
- `bun run lint` 零错误零警告
### 2. dev 服务器
- dev.log 全程无错误,所有编译 < 300ms
- 注:dev.log 中显示 `/api/leaderboard?XTransformPort=3030 404` 是预期行为 — 通过 :3000 直接访问时 Next.js 没有 /api/leaderboard 路由;通过 :81 网关访问时 Caddy 转发到 :3030 正常返回 200
### 3. mini-service curl 全通过
- `GET /` → 200 `{"service":"echo-nexus-leaderboard","version":"v0.8.2","ok":true,"uptime":N}`
- `GET /api/leaderboard` (空) → 200 `{"entries":[],"total":0}`
- `POST /api/leaderboard` → 200 `{"entries":[...],"total":1,"rank":1}`
- `GET /api/leaderboard` (有数据) → 200 `{"entries":[...],"total":N}`
- `GET /api/leaderboard/stats` → 200 `{"totalSubmissions":N,"uniquePlayers":M,"topScore":X}`
- `OPTIONS /api/leaderboard` 预检 → 204 + 完整 CORS 头(Allow-Origin: *
- 重复 POST(同 dateKey+challenge 10s 内)→ 429 `{"error":"Rate limited","retryAfterMs":N}`
### 4. 网关路由验证
- Caddy :81 → localhost:3030 通过 `?XTransformPort=3030` 正确转发,返回 JSON 200
### 5. agent-browser 集成测试
- 通过 :81 端口访问(绕过 Next.js :3000 的 404
- 信标 tab 选中(用 KeyboardEvent Enter 激活 Radix Tabs — 因 pointerdown 被覆盖层拦截)
- 切到「全球」tab → 自动拉取 → 显示"全球 Top 6 · 已同步" → 渲染 6 条记录
- 刷新按钮可点击,旋转动画正常
- POST 测试:浏览器 fetch 提交 score=9999 → 返回 rank=1 → 刷新后榜首显示 10.00K
- YOU 高亮:手动设置 localStorage[BEACON_CLOUD_LAST_SUBMIT_KEY] 后刷新 → 榜首显示 emerald 边框 + YOU 徽章
### 6. VLM 视觉评分(目标 ≥7/10
- 全球榜含 YOU 高亮:**8/10**
- VLM 评语:"双tab切换清晰,全球榜记录完整,难度色与标签规范,视觉层次佳,但「YOU」徽章未突出显示"
- 本地榜:**8/10**
- VLM 评语:"双tab切换逻辑清晰,本地tab内容完整呈现;四色规范应用,视觉区分度高;信息层级合理"
## 修改的文件
1. `mini-services/leaderboard-service/package.json`(新建)
2. `mini-services/leaderboard-service/index.ts`(新建,182 行)
3. `mini-services/leaderboard-service/README.md`(新建)
4. `src/lib/game/beacon.ts` — 1161 → 1290 行(+129 行:3 个 claim 返回 entry + 云排行榜模块)
5. `src/store/gameStore.ts` — 1235 → 1252 行(+17 行:import + 2 个 claim action 加 submitCloudScore
6. `src/components/game/BeaconPanel.tsx` — 953 → 1170 行(+217 行:双 tab UI + 云端拉取 + 提交 + YOU 高亮)
7. `eslint.config.mjs` — ignores 新增 "mini-services/**"
## 不破坏的现有功能
- 本地排行榜(`loadLeaderboard` / `pushLeaderboardEntry`)完整保留作为离线 fallback
- 日挑战 / 周挑战 / 限时挑战 / 信标链流程全部保留
- 现有 localStorage keysecho-nexus-beacon-lb-v1 / -prog-v1 / -weekly-v1 / -chain-v1 / -timed-v1)全部保留
- 主项目 package.json 不变(hono 装在 mini-service 子项目内)
## 新增的 localStorage keys
- `echo-nexus-beacon-cloud-last-submit-v1`(上次成功提交到云端的 entry timestamp,用于全球榜高亮)
## 四色全息规范遵循
- 本地 tabamber (#fbbf24)
- 全球 tabemerald (#34d399) — 与"在线/同步"语义一致
- 玩家高亮:emerald 边框 + emerald YOU 徽章 + emerald 分数色
- 难度色保留:routine emerald / anomaly amber / singular rose
- 周挑战徽章:fuchsia
- 限时挑战徽章:amber
- 错误状态:rose (#fb7185)
- **零蓝色/靛色违规**
## 截图资产
- `/home/z/my-project/agent-ctx/beacon-global-leaderboard.png` — 全球榜初始视图(6 条记录)
- `/home/z/my-project/agent-ctx/beacon-global-with-you.png` — 全球榜含 YOU 高亮(7 条记录,榜首是玩家)
- `/home/z/my-project/agent-ctx/beacon-local-leaderboard.png` — 本地榜视图
## 重要注意事项(供后续 agent)
1. **前端访问端口**:用户通过 Preview Panel 看到的是 Caddy 网关 :81,不是 Next.js :3000。所以 `fetch('/api/leaderboard?XTransformPort=3030')` 在用户视角下能正常路由。如果直接访问 :3000 会 404(这是预期行为)。
2. **mini-service 启动方式**:用 `bun index.ts`(无 --hot+ 双 fork `( ... & )` 模式启动。`bun --hot` 在沙盒下不稳定。
3. **mini-service 已被 .zscripts/dev.sh 自动扫描**:下次容器重启会自动启动。但当前会话需手动启动一次。
4. **localStorage key 命名**`echo-nexus-beacon-cloud-last-submit-v1` 而非 `echo-nexus-beacon-cloud-lb-v1`,前者存的是单个 timestamp(用于高亮),后者若需要存全球榜快照可用后者(目前未实现,按需拉取)。
5. **star-tide-service 端口 3031**:另一个 P3 工单已存在的服务,本服务的端口 3030 与之互不冲突。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

+67
View File
@@ -0,0 +1,67 @@
# Task 2-b · full-stack-developer · v0.15 叙事深化 — Fragment loreLayers 多层文本
## 上下文 / 接收到的任务
- Task ID: **2-b**
- 任务: 为 Fragment 增加 loreLayersL1 中层 / 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 / roserose 仅在背景 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 字,符合"碎片感 + 留白"美学要求;没有说教,多用引号 + 反转 + 留白。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

+139
View File
@@ -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 限 crystalCapenergy 限 energyMaxcontact 限 100permBonus 累加到 idlePermanentBonus + 重算 statsrandomFragment 从未解锁碎片中随机一个)→ 追加 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` statecontrolled Tabs`<Tabs value={activeTab} onValueChange={setActiveTab}>`
- 挂载后 useEffect 一次性设置默认 tabhasActiveExpedition → "expedition"hasPendingPerk → "constellation",否则 → "idle"
- 版本号 v0.8 → v0.14
- Header 在 StarTideIndicator 后加 IdleStatusBadge
- 左侧 CrystalOrb 后加 IdleProjectBar
- TabsList grid-cols-8 → grid-cols-9,新增 `value="idle"` 的 TabsTrigger 作为第一个 tabClock 图标,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 + 晶体球下方进度条(派遣后)
Regular → Executable
-40
View File
@@ -63,8 +63,6 @@
"react-syntax-highlighter": "^15.6.1",
"recharts": "^2.15.4",
"sharp": "^0.34.3",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
@@ -602,8 +600,6 @@
"@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "https://registry.npmjs.com/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="],
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmjs.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmjs.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -678,8 +674,6 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmjs.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "https://registry.npmjs.com/@types/d3-array/-/d3-array-3.2.2.tgz", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "https://registry.npmjs.com/@types/d3-color/-/d3-color-3.1.3.tgz", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
@@ -722,8 +716,6 @@
"@types/unist": ["@types/unist@3.0.3", "https://registry.npmjs.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.0", "https://registry.npmjs.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/type-utils": "8.53.0", "@typescript-eslint/utils": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.0", "https://registry.npmjs.com/@typescript-eslint/parser/-/parser-8.53.0.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg=="],
@@ -784,8 +776,6 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "https://registry.npmjs.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"acorn": ["acorn@8.15.0", "https://registry.npmjs.com/acorn/-/acorn-8.15.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmjs.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -834,8 +824,6 @@
"base64-js": ["base64-js@1.5.1", "https://registry.npmjs.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.15", "https://registry.npmjs.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg=="],
"brace-expansion": ["brace-expansion@1.1.12", "https://registry.npmjs.com/brace-expansion/-/brace-expansion-1.1.12.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
@@ -910,8 +898,6 @@
"cookie": ["cookie@0.7.2", "https://registry.npmjs.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"crelt": ["crelt@1.0.6", "https://registry.npmjs.com/crelt/-/crelt-1.0.6.tgz", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmjs.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -1008,12 +994,6 @@
"empathic": ["empathic@2.0.0", "https://registry.npmjs.com/empathic/-/empathic-2.0.0.tgz", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"engine.io": ["engine.io@6.6.9", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0" } }, "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg=="],
"engine.io-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="],
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "https://registry.npmjs.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"es-abstract": ["es-abstract@1.24.1", "https://registry.npmjs.com/es-abstract/-/es-abstract-1.24.1.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
@@ -1472,8 +1452,6 @@
"mime-db": ["mime-db@1.54.0", "https://registry.npmjs.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@3.1.2", "https://registry.npmjs.com/minimatch/-/minimatch-3.1.2.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minimist": ["minimist@1.2.8", "https://registry.npmjs.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1690,14 +1668,6 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmjs.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="],
"socket.io-adapter": ["socket.io-adapter@2.5.8", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.21.0" } }, "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw=="],
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
"socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
"sonner": ["sonner@2.0.7", "https://registry.npmjs.com/sonner/-/sonner-2.0.7.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmjs.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -1826,8 +1796,6 @@
"uvu": ["uvu@0.5.6", "https://registry.npmjs.com/uvu/-/uvu-0.5.6.tgz", { "dependencies": { "dequal": "^2.0.0", "diff": "^5.0.0", "kleur": "^4.0.3", "sade": "^1.7.3" }, "bin": { "uvu": "bin.js" } }, "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vaul": ["vaul@1.1.2", "https://registry.npmjs.com/vaul/-/vaul-1.1.2.tgz", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"vfile": ["vfile@6.0.3", "https://registry.npmjs.com/vfile/-/vfile-6.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
@@ -1850,10 +1818,6 @@
"word-wrap": ["word-wrap@1.2.5", "https://registry.npmjs.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
"xtend": ["xtend@4.0.2", "https://registry.npmjs.com/xtend/-/xtend-4.0.2.tgz", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"yallist": ["yallist@4.0.0", "https://registry.npmjs.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
@@ -1936,8 +1900,6 @@
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "https://registry.npmjs.com/minimatch/-/minimatch-9.0.5.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"downshift/react-is": ["react-is@17.0.2", "https://registry.npmjs.com/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmjs.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -1968,8 +1930,6 @@
"micromatch/picomatch": ["picomatch@2.3.1", "https://registry.npmjs.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"next/postcss": ["postcss@8.4.31", "https://registry.npmjs.com/postcss/-/postcss-8.4.31.tgz", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"next-auth/uuid": ["uuid@8.3.2", "https://registry.npmjs.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
+309
View File
@@ -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 的本质:**让玩家信任系统会在他不在时为他工作**。
+214
View File
@@ -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 洞见 · ...`
UISection 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-a5 文件):**
- `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-b3 文件):**
- `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 200dev.log 无运行时错误
- ✅ agent-browser 端到端 smoke test
- softcappermBonus=50 → 有效 40UI 显示"衰减中" ✓
- 批量领取:permBonus 0→0.3, projectsCompleted 1→2 ✓
- 完成 toasttickIdleProjects 触发 ✓
- 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 弹层可读性实测
+1 -1
View File
@@ -44,7 +44,7 @@ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
"no-useless-escape": "off",
},
}, {
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills", "mini-services/**"]
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills"]
}];
export default eslintConfig;
@@ -1,93 +0,0 @@
# Echo Nexus · 云排行榜 mini-service
回响星核 / Echo Nexus 的 P2 云排行榜服务(v0.8.2 / Task 10-b)。
把信标本机排行榜(`localStorage`)升级为云端 Top100,玩家间可比。
## 技术栈
- **运行时**Bun(原生 TypeScript,无需编译)
- **框架**Hono(轻量 web 框架,bun 原生支持)
- **存储**:内存数组,重启清空(够用,符合 P2 规格)
- **端口**:固定 `3030`Caddy 通过 `?XTransformPort=3030` 转发)
## 目录结构
```
mini-services/leaderboard-service/
├── package.json # 独立 bun 项目
├── index.ts # Hono 入口
└── README.md
```
## API
所有响应自动 CORS 开放,OPTIONS 预检由 Hono 自动处理。
| 方法 | 路径 | 入参 | 返回 |
|---|---|---|---|
| GET | `/` | — | `{ service, version, ok, uptime }` 健康检查 |
| GET | `/api/leaderboard` | — | `{ entries: BeaconScoreEntry[], total }` Top100 |
| POST | `/api/leaderboard` | body `{ entry: BeaconScoreEntry }` | `{ entries, total, rank }` rank=1..N 或 -1 |
| GET | `/api/leaderboard/stats` | — | `{ totalSubmissions, uniquePlayers, topScore }` |
### `BeaconScoreEntry` 结构
```ts
{
timestamp: number, // 提交时间戳
dateKey: string, // YYYY-MM-DD 或 YYYY-Www
challenge: string, // decode | expedition | pulse | boss | insight
difficulty: string, // routine | anomaly | singular
progress: number, // 0-11 = 完成
score: number, // 最终得分
durationSec: number, // 完成时长(秒)
isWeekly?: boolean, // 周挑战记录
isTimed?: boolean // 限时挑战记录(预留)
}
```
## 存储 & 防刷规则
- **容量**:最多 1000 条,按分数降序;超过自动丢弃尾部。
- **Top 榜**API 返回前 100 条。
- **同分排序**:先看用时短,再看提交早。
- **防刷**:同 `dateKey + challenge` 10 秒内只接受 1 次提交,返回 `429`
## 启动
```bash
cd mini-services/leaderboard-service
bun run dev
# 等价于 bun --hot index.ts,文件变更自动重启
```
## 联调(前端请求规范)
前端必须用相对路径 + `?XTransformPort=3030`
```ts
// 拉取全球 Top100
const res = await fetch("/api/leaderboard?XTransformPort=3030");
const { entries, total } = await res.json();
// 提交一条记录
const res = await fetch("/api/leaderboard?XTransformPort=3030", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entry }),
});
const { entries, total, rank } = await res.json();
```
**禁止** `fetch("http://localhost:3030/...")` —— 不通过 Caddy 网关,会被浏览器同源策略拦截。
## 部署形态
独立 bun 项目,与主 Next.js 项目解耦:
- 主项目端口 3000Next.js dev
- 本服务端口 3030Hono + Bun
- Caddy 网关端口 81(按 `?XTransformPort=3030` 转发到本服务)
服务重启会清空数据,符合 P2 阶段规格(不需要持久化)。
@@ -1,15 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "echo-nexus-leaderboard-service",
"dependencies": {
"hono": "^4.6.14",
},
},
},
"packages": {
"hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="],
}
}
-182
View File
@@ -1,182 +0,0 @@
// 回响星核 / Echo Nexus — 云排行榜 mini-service
// P2/#4:把信标本机排行榜升级为云排行榜。
// 端口固定 3030Caddy 通过 ?XTransformPort=3030 转发。
// 内存存储(重启清空),Hono + Bun 原生。
//
// API
// GET /api/leaderboard → { entries: BeaconScoreEntry[], total }
// POST /api/leaderboard body { entry } → { entries, total, rank }
// GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore }
// GET / → 健康检查
//
// 所有响应自动 CORS 开放(hono/cors),OPTIONS 预检 hono 自动处理。
import { Hono } from "hono";
import { cors } from "hono/cors";
// ---------------------------------------------------------------------------
// 类型(与前端 BeaconScoreEntry 一致,但这里宽松化以接受历史数据)
// ---------------------------------------------------------------------------
interface BeaconScoreEntry {
timestamp: number;
dateKey: string;
challenge: string;
difficulty: string;
progress: number;
score: number;
durationSec: number;
isWeekly?: boolean;
isTimed?: boolean;
}
// ---------------------------------------------------------------------------
// 内存存储
// ---------------------------------------------------------------------------
const entries: BeaconScoreEntry[] = [];
const MAX_ENTRIES = 1000;
const TOP_N = 100;
// 简单防刷:同 dateKey+challenge 10 秒内只接受 1 次提交
const lastSubmitAt = new Map<string, number>();
const RATE_LIMIT_MS = 10_000;
function sortEntries() {
entries.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
// 同分情况下,用时短者靠前
if (a.durationSec !== b.durationSec) return a.durationSec - b.durationSec;
// 仍相同,按时间戳升序(先提交靠前)
return a.timestamp - b.timestamp;
});
while (entries.length > MAX_ENTRIES) entries.pop();
}
function findRank(entry: BeaconScoreEntry): number {
const idx = entries.findIndex(
(e) =>
e.timestamp === entry.timestamp &&
e.dateKey === entry.dateKey &&
e.score === entry.score
);
return idx >= 0 ? idx + 1 : -1;
}
// ---------------------------------------------------------------------------
// Hono 实例
// ---------------------------------------------------------------------------
const app = new Hono();
// 全局 CORS 开放(前端跨端口访问)
app.use(
"*",
cors({
origin: "*",
allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ["Content-Type", "X-Requested-With"],
exposeHeaders: ["Content-Length"],
maxAge: 600,
})
);
// 健康检查
app.get("/", (c) =>
c.json({
service: "echo-nexus-leaderboard",
version: "v0.8.2",
ok: true,
uptime: Math.floor(process.uptime?.() ?? 0),
})
);
// GET /api/leaderboard — 返回 Top100
app.get("/api/leaderboard", (c) => {
sortEntries();
const top = entries.slice(0, TOP_N);
return c.json({ entries: top, total: entries.length });
});
// POST /api/leaderboard — 插入一条记录,返回新榜 + 排名
app.post("/api/leaderboard", async (c) => {
let body: { entry?: unknown };
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid JSON body" }, 400);
}
if (!body || !body.entry || typeof body.entry !== "object") {
return c.json({ error: "Missing entry in body" }, 400);
}
const e = body.entry as Partial<BeaconScoreEntry>;
if (
typeof e.timestamp !== "number" ||
typeof e.dateKey !== "string" ||
typeof e.challenge !== "string" ||
typeof e.difficulty !== "string" ||
typeof e.score !== "number"
) {
return c.json({ error: "Entry schema mismatch" }, 400);
}
// 防刷:同 dateKey+challenge 10 秒内只接受 1 次
const key = `${e.dateKey}|${e.challenge}`;
const now = Date.now();
const last = lastSubmitAt.get(key) ?? 0;
if (now - last < RATE_LIMIT_MS) {
return c.json(
{
error: "Rate limited",
retryAfterMs: RATE_LIMIT_MS - (now - last),
rank: -1,
},
429
);
}
lastSubmitAt.set(key, now);
// 规范化 entry(去多余字段,补默认值)
const entry: BeaconScoreEntry = {
timestamp: e.timestamp,
dateKey: e.dateKey,
challenge: e.challenge,
difficulty: e.difficulty,
progress: typeof e.progress === "number" ? e.progress : 0,
score: Math.max(0, Math.floor(e.score)),
durationSec: typeof e.durationSec === "number" ? e.durationSec : 0,
isWeekly: e.isWeekly === true,
isTimed: e.isTimed === true,
};
entries.push(entry);
sortEntries();
const rank = findRank(entry);
return c.json({
entries: entries.slice(0, TOP_N),
total: entries.length,
rank,
});
});
// GET /api/leaderboard/stats — 全局统计
app.get("/api/leaderboard/stats", (c) => {
sortEntries();
const uniqueDateKeys = new Set(entries.map((e) => e.dateKey));
return c.json({
totalSubmissions: entries.length,
uniquePlayers: uniqueDateKeys.size,
topScore: entries.length > 0 ? entries[0].score : 0,
});
});
// ---------------------------------------------------------------------------
// 启动
// ---------------------------------------------------------------------------
const PORT = 3030;
console.log(`[leaderboard-service] listening on :${PORT}`);
const server = {
port: PORT,
fetch: app.fetch,
};
export default server;
@@ -1,13 +0,0 @@
{
"name": "echo-nexus-leaderboard-service",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot index.ts",
"start": "bun index.ts"
},
"dependencies": {
"hono": "^4.6.14"
}
}
-75
View File
@@ -1,75 +0,0 @@
# star-tide-service · 全球星潮同步 mini-service
> 回响星核 / Echo Nexus · P3 工单 #10-c
>
> socket.io 多人同步星潮 —— 所有在线玩家同时经历同一道全球星潮 60 秒。
## 设计
- **端口**:固定 `3031`
- **网关**Caddy 通过 `?XTransformPort=3031` 转发;前端用 `io("/?XTransformPort=3031")`
- **运行时**bun + socket.io 4
- **存储**:纯内存(无状态,重启不影响前端游戏)
## 星潮节奏
- 首次星潮:服务端启动后 **60 秒**(可由 env `FIRST_TIDE_DELAY_MS` 覆盖,便于 QA
- 后续星潮:**10-15 分钟随机间隔**
- 每次持续:**60 秒**
- 类型:从 6 种星潮按权重随机抽取(与前端 `TIDE_EVENTS` 一致)
- 晶体潮 22 / 谐振风暴 20 / 遗迹共振 16 / 虚空低语 18 / 星核悸动 14 / 寂静期 10
## socket.io 事件
### 服务端 → 客户端
| 事件 | 载荷 | 触发时机 |
|------|------|---------|
| `tide-state` | `{ current: GlobalTideState \| null, nextTideAt, serverTime }` | 客户端连接时立即推送 |
| `global-tide` | `{ type, name, startedAt, endsAt, durationSec }` | 全球星潮开始 |
| `tide-ended` | `{ endedAt, nextTideAt }` | 全球星潮结束 |
| `next-tide-info` | `{ current, nextTideAt, serverTime, remainingMs }` | 响应客户端 `get-next-tide` |
### 客户端 → 服务端
| 事件 | 载荷 | 行为 |
|------|------|------|
| `get-next-tide` | — | 查询下次星潮剩余时间 |
| `admin-trigger-tide` | — | **dev/QA 用**:立即触发一次全球星潮 |
## HTTP 端点
socket.io `path: '/'` 拦截所有 HTTP 请求,因此本服务不提供独立 HTTP 健康检查端点。
- `GET /` —— socket.io 默认握手响应(`{"code":0,"message":"Transport unknown"}` 表示服务在线)
## 启动
```bash
cd mini-services/star-tide-service
bun run dev # bun --hot index.ts,热重载
# 或
bun run start # bun index.ts,无热重载
```
## QA 验证
```bash
# socket.io 在线验证(收到 JSON 响应即表示服务在线)
curl http://localhost:3031/
# 预期:{"code":0,"message":"Transport unknown"}
```
前端可在浏览器 console 触发全球星潮(QA 用):
```js
const sock = io("/?XTransformPort=3031");
sock.on("tide-state", (s) => console.log("当前状态:", s));
sock.emit("admin-trigger-tide"); // 立即触发一次全球星潮
```
## 离线容错
- 服务端宕机 → 前端 `useGlobalTide` 自动重连(socket.io 默认行为)
- 断连期间 → 前端本地 `tickTide` 继续按原节奏工作,玩家不会卡住
- 全球星潮进行中 → 前端本地 `tickTide` 暂停触发(避免冲突)
-57
View File
@@ -1,57 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "echo-nexus-star-tide-service",
"dependencies": {
"socket.io": "^4.8.3",
},
},
},
"packages": {
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/node": ["@types/node@26.0.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"engine.io": ["engine.io@6.6.9", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0" } }, "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg=="],
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="],
"socket.io-adapter": ["socket.io-adapter@2.5.8", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.21.0" } }, "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw=="],
"socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
}
}
-249
View File
@@ -1,249 +0,0 @@
// 回响星核 / Echo Nexus — 全球星潮同步 mini-service
// P3/工单 #10-csocket.io 多人同步星潮
// 端口固定 3031Caddy 通过 ?XTransformPort=3031 转发。
// 前端连接:io("/?XTransformPort=3031")path 默认 "/"。
//
// 逻辑:
// - 服务端每 10-15 分钟(随机间隔)随机选一个星潮类型,
// 广播 `global-tide` 事件 { type, name, startedAt, endsAt, durationSec: 60 }
// - 客户端连接后立即收到 `tide-state` 事件,包含当前进行中的星潮(如有)
// + 下次星潮时间戳 + 服务器时间(用于时钟同步)
// - 客户端可随时 emit `get-next-tide`,服务端响应 `next-tide-info`
// { current, nextTideAt, serverTime, remainingMs }
// - 全球星潮结束时广播 `tide-ended` { endedAt, nextTideAt }
// - `admin-trigger-tide`dev 用):立即触发一次全球星潮,便于 QA 验证
//
// 离线容错:服务端宕机时,前端 useGlobalTide hook 会自动重连;
// 在断连期间,本地 tickTide 继续按原节奏工作,玩家不会被卡住。
import { createServer } from "http";
import { Server } from "socket.io";
// ---------------------------------------------------------------------------
// 配置
// ---------------------------------------------------------------------------
/** 全球星潮持续秒数(与前端 triggerGlobalTide 保持一致) */
const DURATION_SEC = 60;
/** 首次星潮延迟(毫秒)—— 默认 60s 便于 QA 验证;可通过 env FIRST_TIDE_DELAY_MS 覆盖 */
const FIRST_TIDE_DELAY_MS = Number(process.env.FIRST_TIDE_DELAY_MS) || 60 * 1000;
/** 后续星潮最小间隔(10 分钟) */
const MIN_GAP_MS = 10 * 60 * 1000;
/** 后续星潮最大间隔(15 分钟) */
const MAX_GAP_MS = 15 * 60 * 1000;
/** 固定端口 3031 */
const PORT = 3031;
// ---------------------------------------------------------------------------
// 星潮类型表(与前端 TIDE_EVENTS 一致:crystal/resonance/ruins/void/core/silence
// ---------------------------------------------------------------------------
type TideType = "crystal" | "resonance" | "ruins" | "void" | "core" | "silence";
const TIDE_META: Record<TideType, { name: string; weight: number }> = {
crystal: { name: "晶体潮", weight: 22 },
resonance: { name: "谐振风暴", weight: 20 },
ruins: { name: "遗迹共振", weight: 16 },
void: { name: "虚空低语", weight: 18 },
core: { name: "星核悸动", weight: 14 },
silence: { name: "寂静期", weight: 10 },
};
const TIDE_TYPES = Object.keys(TIDE_META) as TideType[];
const TIDE_TOTAL_WEIGHT = TIDE_TYPES.reduce((s, t) => s + TIDE_META[t].weight, 0);
function rollTide(): TideType {
let r = Math.random() * TIDE_TOTAL_WEIGHT;
for (const t of TIDE_TYPES) {
r -= TIDE_META[t].weight;
if (r <= 0) return t;
}
return "crystal";
}
function randomGap(): number {
return MIN_GAP_MS + Math.floor(Math.random() * (MAX_GAP_MS - MIN_GAP_MS));
}
// ---------------------------------------------------------------------------
// 全局状态
// ---------------------------------------------------------------------------
interface GlobalTideState {
type: TideType;
name: string;
startedAt: number;
endsAt: number;
durationSec: number;
}
let currentTide: GlobalTideState | null = null;
let nextTideAt: number = Date.now() + FIRST_TIDE_DELAY_MS;
let tideStartTimer: ReturnType<typeof setTimeout> | null = null;
let tideEndTimer: ReturnType<typeof setTimeout> | null = null;
// ---------------------------------------------------------------------------
// 星潮调度
// ---------------------------------------------------------------------------
function startTide(): void {
// 若已有进行中的星潮,先结束再开(理论上不应发生,防御性)
if (currentTide) {
endTide();
}
// 清掉残留的 tideEndTimer,避免泄漏
if (tideEndTimer) {
clearTimeout(tideEndTimer);
tideEndTimer = null;
}
const type = rollTide();
const now = Date.now();
currentTide = {
type,
name: TIDE_META[type].name,
startedAt: now,
endsAt: now + DURATION_SEC * 1000,
durationSec: DURATION_SEC,
};
console.log(
`[global-tide] ✦ ${currentTide.name} (${type}) 已降临 · 持续 ${DURATION_SEC}s · 结束于 ${new Date(currentTide.endsAt).toLocaleTimeString()}`
);
io.emit("global-tide", currentTide);
// 调度结束 → endTide 会自己 clearTimeout(tideEndTimer)
// 结束后自动 scheduleNextTide
tideEndTimer = setTimeout(() => {
endTide();
scheduleNextTide();
}, DURATION_SEC * 1000);
}
function endTide(): void {
if (!currentTide) return;
console.log(`[global-tide] ◌ ${currentTide.name} 已退去`);
currentTide = null;
io.emit("tide-ended", {
endedAt: Date.now(),
nextTideAt,
});
if (tideEndTimer) {
clearTimeout(tideEndTimer);
tideEndTimer = null;
}
}
function scheduleNextTide(): void {
// 清掉残留的 tideStartTimer,避免泄漏
if (tideStartTimer) {
clearTimeout(tideStartTimer);
tideStartTimer = null;
}
const gap = randomGap();
nextTideAt = Date.now() + gap;
console.log(
`[global-tide] 下次星潮将在 ${Math.round(gap / 1000)}s 后 · ${new Date(nextTideAt).toLocaleTimeString()}`
);
tideStartTimer = setTimeout(() => {
startTide();
}, gap);
}
// ---------------------------------------------------------------------------
// socket.io
// ---------------------------------------------------------------------------
// 注意:socket.io `path: '/'` 会拦截所有 HTTP 请求(包括 /health),
// 因此本服务不提供独立 HTTP 健康检查端点;QA 用 `curl http://localhost:3031/`
// 收到 `{"code":0,"message":"Transport unknown"}` 即表示 socket.io 在线。
const httpServer = createServer();
const io = new Server(httpServer, {
// 路径必须为 "/"Caddy 据此转发;同时这也是 catch-all,
// 任何 URL 都会被 socket.io 接管(前端默认 /socket.io/ 也会被捕获)
path: "/",
cors: { origin: "*", methods: ["GET", "POST"] },
pingTimeout: 60000,
pingInterval: 25000,
});
io.on("connection", (socket) => {
console.log(`[socket] 客户端连接: ${socket.id}(当前在线 ${io.engine.clientsCount}`);
// 立即推送当前状态
socket.emit("tide-state", {
current: currentTide,
nextTideAt,
serverTime: Date.now(),
});
// 客户端查询下次星潮剩余时间
socket.on("get-next-tide", () => {
socket.emit("next-tide-info", {
current: currentTide,
nextTideAt,
serverTime: Date.now(),
remainingMs: Math.max(0, nextTideAt - Date.now()),
});
});
// dev/QA 用:手动触发一次全球星潮
socket.on("admin-trigger-tide", () => {
console.log(`[socket] admin-trigger-tide by ${socket.id}`);
// 取消尚未触发的下次星潮定时器(startTide 会负责调度本次星潮的结束 + 下次开始)
if (tideStartTimer) {
clearTimeout(tideStartTimer);
tideStartTimer = null;
}
startTide();
// startTide 内部已 setTimeout(DURATION_SEC) → endTide + scheduleNextTide
});
socket.on("disconnect", (reason) => {
console.log(`[socket] 客户端断开: ${socket.id}${reason}`);
});
socket.on("error", (err) => {
console.error(`[socket] 错误 (${socket.id}):`, err);
});
});
// ---------------------------------------------------------------------------
// 启动
// ---------------------------------------------------------------------------
httpServer.listen(PORT, () => {
console.log(`[star-tide-service] 监听端口 ${PORT}`);
console.log(
`[star-tide-service] 首次星潮将在 ${Math.round(FIRST_TIDE_DELAY_MS / 1000)}s 后 · ${new Date(nextTideAt).toLocaleTimeString()}`
);
console.log(`[star-tide-service] 后续星潮间隔:${MIN_GAP_MS / 1000 / 60}-${MAX_GAP_MS / 1000 / 60} 分钟随机`);
console.log(`[star-tide-service] QA 验证:curl http://localhost:${PORT}/ → {"code":0,"message":"Transport unknown"}`);
// 调度首次星潮(startTide 内部会在 60s 后自动 endTide + scheduleNextTide,无需在此重复调度)
tideStartTimer = setTimeout(() => {
startTide();
}, FIRST_TIDE_DELAY_MS);
});
// ---------------------------------------------------------------------------
// 优雅退出
// ---------------------------------------------------------------------------
function shutdown(signal: string) {
console.log(`[star-tide-service] 收到 ${signal},正在关闭…`);
if (tideStartTimer) clearTimeout(tideStartTimer);
if (tideEndTimer) clearTimeout(tideEndTimer);
io.close(() => {
httpServer.close(() => {
console.log("[star-tide-service] 已关闭");
process.exit(0);
});
});
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
@@ -1,13 +0,0 @@
{
"name": "echo-nexus-star-tide-service",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot index.ts",
"start": "bun index.ts"
},
"dependencies": {
"socket.io": "^4.8.3"
}
}
-2
View File
@@ -72,8 +72,6 @@
"react-syntax-highlighter": "^15.6.1",
"recharts": "^2.15.4",
"sharp": "^0.34.3",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
-44
View File
@@ -8,7 +8,6 @@
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-display: var(--font-display);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -145,17 +144,6 @@
-webkit-text-fill-color: transparent;
}
/* v0.10.1 调研落地:Orbitron 科幻显示字体工具类 */
.font-display {
font-family: var(--font-display), var(--font-geist-sans), sans-serif;
letter-spacing: 0.02em;
}
/* 数值/技术文本使用 Orbitron 增强科幻感 */
.font-tech {
font-family: var(--font-display), var(--font-geist-mono), monospace;
letter-spacing: 0.04em;
}
/* 通用脉冲 */
@keyframes echo-glow {
0%, 100% { opacity: 0.6; }
@@ -177,35 +165,3 @@
0% { opacity: 0; transform: scale(0.92) translateY(8px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
/* v0.11 晶体卡片 hover 扫描线 */
@keyframes echo-scan {
0% { top: 0%; opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
100% { top: 100%; opacity: 0; }
}
/* v0.11 晶体卡片 hover 边框流光 */
@keyframes echo-border-flow {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
/* v0.12 考古日志滚动条 */
@keyframes echo-ticker-marquee {
0% { transform: translateX(100%); }
100% { transform: translateX(-100%); }
}
@keyframes echo-ticker-fade {
0% { opacity: 0; }
100% { opacity: 1; }
}
.echo-ticker-anim {
animation: echo-ticker-marquee 28s linear forwards, echo-ticker-fade 0.5s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.echo-ticker-anim {
animation: echo-ticker-fade 0.5s ease-out;
}
}
+2 -10
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, Orbitron } from "next/font/google";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
@@ -13,14 +13,6 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
// v0.10.1 调研落地:Orbitron 科幻显示字体(用于标题/数值/全息UI)
const orbitron = Orbitron({
variable: "--font-display",
subsets: ["latin"],
weight: ["400", "500", "600", "700", "900"],
display: "swap",
});
export const metadata: Metadata = {
title: "回响星核 · Echo Nexus",
description: "原创深空考古放置策略游戏 — 解码记忆晶体,拼凑以太文明的回响,跨越维度发起接触。",
@@ -50,7 +42,7 @@ export default function RootLayout({
return (
<html lang="zh-CN" suppressHydrationWarning className="dark">
<body
className={`${geistSans.variable} ${geistMono.variable} ${orbitron.variable} antialiased bg-background text-foreground`}
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
>
{children}
<Toaster />
+93 -270
View File
@@ -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";
@@ -13,18 +13,17 @@ import { ExpeditionPanel } from "@/components/game/ExpeditionPanel";
import { AchievementsPanel } from "@/components/game/AchievementsPanel";
import { AchievementNotifier } from "@/components/game/AchievementNotifier";
import { ConstellationPanel } from "@/components/game/ConstellationPanel";
import { ConstellationDialog } from "@/components/game/ConstellationDialog";
import { ChronicleDialog } from "@/components/game/ChronicleDialog";
import { BeaconPanel } from "@/components/game/BeaconPanel";
import { TutorialOverlay } from "@/components/game/TutorialOverlay";
// v0.10.1 性能优化:重型对话框/全屏组件动态导入,减少首屏 bundle
import dynamic from "next/dynamic";
const ChronicleDialog = dynamic(() => import("@/components/game/ChronicleDialog").then(m => ({ default: m.ChronicleDialog })), { ssr: false });
const ConstellationDialog = dynamic(() => import("@/components/game/ConstellationDialog").then(m => ({ default: m.ConstellationDialog })), { ssr: false });
const OfflineReportDialog = dynamic(() => import("@/components/game/OfflineReportDialog").then(m => ({ default: m.OfflineReportDialog })), { ssr: false });
const CruiseMode = dynamic(() => import("@/components/game/CruiseMode").then(m => ({ default: m.CruiseMode })), { ssr: false });
import { OfflineReportDialog } from "@/components/game/OfflineReportDialog";
import { CruiseMode } from "@/components/game/CruiseMode";
import { AttributesPanel } from "@/components/game/AttributesPanel";
import { ExplorationRadar } from "@/components/game/ExplorationRadar";
import { RelicCodex } from "@/components/game/RelicCodex";
import { ArchaeoLogTicker } from "@/components/game/ArchaeoLogTicker";
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,
@@ -32,9 +31,7 @@ import {
} from "@/components/game/StarTide";
import { useGameLoop } from "@/hooks/useGameLoop";
import { useAudioSync } from "@/hooks/useAudio";
import { useGlobalTide } from "@/hooks/useGlobalTide";
import { useGameStore } from "@/store/gameStore";
import { shallow } from "zustand/shallow";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
@@ -51,22 +48,17 @@ import {
Radio,
Navigation,
User,
Gem,
Zap,
Flame,
Database,
BookMarked,
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();
useAudioSync();
useGlobalTide();
const [prestigeOpen, setPrestigeOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [constellationOpen, setConstellationOpen] = useState(false);
@@ -86,11 +78,18 @@ export default function Page() {
const crystals = useGameStore((s) => s.crystals);
const crystalCap = useGameStore((s) => s.crystalCap);
const activeTide = useGameStore((s) => s.activeTide);
const globalTide = useGameStore((s) => s.globalTide);
const energy = useGameStore((s) => s.energy);
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
const pendingAttrPoints = useGameStore((s) => s.pendingAttrPoints ?? 0);
// 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);
@@ -117,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 (
@@ -139,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) {
@@ -162,10 +190,19 @@ export default function Page() {
}
if (activeTide) {
const tm = TIDE_EVENTS[activeTide.type];
const prefix = globalTide ? "🌐 全球星潮 · " : "";
goal = `${prefix}${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
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">
@@ -185,14 +222,15 @@ export default function Page() {
</div>
</div>
<div className="leading-tight">
<h1 className="text-base sm:text-lg font-bold text-gradient font-display"></h1>
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider font-tech">
ECHO NEXUS · v0.12
<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.15
</p>
</div>
</div>
<div className="ml-auto flex items-center gap-1.5">
<StarTideIndicator />
<IdleStatusBadge onClick={() => setActiveTab("idle")} />
<Button
size="sm"
variant="outline"
@@ -256,52 +294,40 @@ export default function Page() {
<ResourceBar onPrestige={() => setPrestigeOpen(true)} />
</header>
{/* 主体 — v0.10 工单 #2 布局重构:反转左右比例,左栏紧凑晶体球,右栏扩展核心玩法 */}
{/* 主体 — 小屏允许自然滚动,避免 min-h 总和超过视口导致挤压重叠 */}
<main className="flex-1 px-3 sm:px-5 pb-3 min-h-0">
<div className="grid grid-cols-1 lg:grid-cols-[minmax(300px,360px)_1fr] gap-3 h-full">
{/* 左侧:脉冲晶体(紧凑模式) + 收益 + 探索雷达 */}
<section className="glass rounded-2xl p-3 sm:p-4 flex flex-col gap-3 min-h-[340px] sm:min-h-[420px] relative overflow-hidden">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_minmax(360px,420px)] gap-3 h-full">
{/* 左侧:脉冲晶体 */}
<section className="glass rounded-2xl p-4 sm:p-6 flex flex-col items-center justify-center min-h-[340px] sm:min-h-[420px] relative overflow-hidden">
{/* 装饰光圈 */}
<div className="pointer-events-none absolute -top-16 -left-16 h-40 w-40 rounded-full bg-emerald-500/10 blur-3xl" />
<div className="pointer-events-none absolute -bottom-16 -right-16 h-40 w-40 rounded-full bg-fuchsia-500/10 blur-3xl" />
{/* v0.10 装饰全息环(缩小适应紧凑布局) */}
<div className="pointer-events-none absolute inset-4 rounded-full border border-emerald-400/10 animate-[spin_60s_linear_infinite]" />
<div className="pointer-events-none absolute inset-8 rounded-full border border-dashed border-fuchsia-400/10 animate-[spin_90s_linear_infinite_reverse]" />
{/* 四角全息标记 */}
<div className="pointer-events-none absolute top-2 left-2 h-3 w-3 border-l border-t border-emerald-400/30 rounded-tl" />
<div className="pointer-events-none absolute top-2 right-2 h-3 w-3 border-r border-t border-fuchsia-400/30 rounded-tr" />
<div className="pointer-events-none absolute bottom-2 left-2 h-3 w-3 border-l border-b border-amber-400/30 rounded-bl" />
<div className="pointer-events-none absolute bottom-2 right-2 h-3 w-3 border-r border-b border-rose-400/30 rounded-br" />
{/* 顶部状态条 */}
<div className="pointer-events-none absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1.5 text-[8px] font-mono text-muted-foreground/60 z-10">
<span className="h-1 w-1 rounded-full bg-emerald-400 animate-pulse" />
<span className="font-tech">CRYSTAL CORE · ONLINE</span>
<span className="h-1 w-1 rounded-full bg-fuchsia-400 animate-pulse" />
</div>
{/* 晶体球区域(紧凑居中) */}
<div data-tut="crystal-orb" className="relative flex justify-center pt-3">
<div className="pointer-events-none absolute -top-20 -left-20 h-60 w-60 rounded-full bg-emerald-500/10 blur-3xl" />
<div className="pointer-events-none absolute -bottom-20 -right-20 h-60 w-60 rounded-full bg-fuchsia-500/10 blur-3xl" />
<div data-tut="crystal-orb" className="contents">
<CrystalOrb />
</div>
{/* v0.9 全息收益信息面板 */}
<CrystalYieldPanel />
{/* v0.10 探索目标雷达(新增)— 填充留白,让晶体球区有价值 */}
<ExplorationRadar />
{/* v0.14 放置工程进度条(晶体球下方) */}
<IdleProjectBar />
</section>
{/* 右侧:解码 + 标签面板v0.10 扩展,从 420px 扩到 1fr */}
{/* 右侧:解码 + 标签面板 */}
<section className="flex flex-col gap-3 min-h-0">
{/* 解码面板 */}
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[280px] sm:min-h-[320px] max-h-[480px]">
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[300px] sm:min-h-[360px] max-h-[560px]">
<DecodePanel />
</div>
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 / 遗迹图鉴 */}
<div className="glass rounded-2xl p-3 min-h-[320px] sm:min-h-[380px] max-h-[560px] flex flex-col">
<Tabs defaultValue={hasActiveExpedition ? "expedition" : hasPendingPerk ? "constellation" : "tech"} className="h-full flex flex-col">
{/* v0.10 标签页响应式布局,小屏 3 列 3 行避免拥挤遮挡,大屏 9 列 */}
<TabsList className="grid grid-cols-3 sm:grid-cols-9 h-auto sm:h-9 bg-black/30 gap-0.5 p-1">
{/* 标签面板:放置 / 探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
<div className="glass rounded-2xl p-3 min-h-[280px] sm:min-h-[320px] max-h-[440px] flex flex-col">
<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>
@@ -352,11 +378,10 @@ export default function Page() {
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-rose-400 animate-pulse ring-1 ring-black/50" />
)}
</TabsTrigger>
<TabsTrigger value="relic" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-fuchsia-500/15 data-[state=active]:shadow-[0_0_12px_rgba(232,121,249,0.3)] transition-all">
<BookMarked className="h-3.5 w-3.5" />
<span className="leading-none"></span>
</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>
@@ -381,20 +406,12 @@ export default function Page() {
<TabsContent value="attributes" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<AttributesPanel />
</TabsContent>
<TabsContent value="relic" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<RelicCodex />
</TabsContent>
</Tabs>
</div>
</section>
</div>
</main>
{/* v0.12 考古日志滚动条 — footer 上方的叙事层 */}
<div className="px-3 sm:px-5 pb-1 shrink-0">
<ArchaeoLogTicker />
</div>
{/* 底部 Footer */}
<footer className="sticky bottom-0 z-20 mt-auto px-3 sm:px-5 pb-2 pt-1">
<div
@@ -455,198 +472,4 @@ export default function Page() {
);
}
/**
* v0.9 工单 #2:全息收益信息面板
* 在中央晶体球下方展示 4 项核心收益指标(产能 / 脉冲 / 连击 / 仓库),
* 让玩家明确感知晶体球的价值,缓解"占地方又没用"的反馈。
* 严格四色全息配色:emerald / rose / amber / fuchsia(禁止蓝色/靛色)。
*/
function CrystalYieldPanel() {
const crystalsPerSec = useGameStore((s) => s.crystalsPerSec);
const pulsePower = useGameStore((s) => s.pulsePower);
const combo = useGameStore((s) => s._combo);
const crystals = useGameStore((s) => s.crystals);
const crystalCap = useGameStore((s) => s.crystalCap);
// 连击倍率:1 + (combo - 1) * 0.15(仅 combo≥1 时生效)
const comboMult = combo >= 1 ? 1 + (combo - 1) * 0.15 : 1;
const comboHot = combo >= 3; // 连击≥3 高亮闪烁
// 仓库容量百分比
const fillPct = crystalCap > 0 ? (crystals / crystalCap) * 100 : 0;
const warehouseWarn = fillPct >= 90; // ≥90% 警告色 + pulse
return (
<div className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 w-full max-w-md pointer-events-none">
{/* 晶体产能 — emerald */}
<div className="rounded-lg border border-emerald-400/30 bg-black/30 backdrop-blur p-2 flex items-center gap-1.5">
<Gem className="h-3.5 w-3.5 text-emerald-400 shrink-0" />
<div className="min-w-0 leading-tight">
<div className="text-[9px] uppercase tracking-wider text-emerald-300/70 truncate">
</div>
<div className="text-sm font-mono tabular-nums text-emerald-200 truncate">
{formatNum(crystalsPerSec)}
<span className="text-[9px] text-emerald-300/60">/s</span>
</div>
</div>
</div>
{/* 主动脉冲 — rose */}
<div className="rounded-lg border border-rose-400/30 bg-black/30 backdrop-blur p-2 flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-rose-400 shrink-0" />
<div className="min-w-0 leading-tight">
<div className="text-[9px] uppercase tracking-wider text-rose-300/70 truncate">
</div>
<div className="text-sm font-mono tabular-nums text-rose-200 truncate">
{formatNum(pulsePower)}
{combo >= 1 && (
<span className="text-[9px] text-rose-300/80"> ×{comboMult.toFixed(2)}</span>
)}
</div>
</div>
</div>
{/* 当前连击 — amber,≥3 时高亮闪烁 */}
<div
className={`rounded-lg border bg-black/30 backdrop-blur p-2 flex items-center gap-1.5 ${
comboHot ? "border-amber-400/70 animate-pulse" : "border-amber-400/30"
}`}
>
<Flame
className={`h-3.5 w-3.5 text-amber-400 shrink-0 ${
comboHot ? "drop-shadow-[0_0_4px_rgba(251,191,36,0.7)]" : ""
}`}
/>
<div className="min-w-0 leading-tight">
<div className="text-[9px] uppercase tracking-wider text-amber-300/70 truncate">
</div>
<div
className={`text-sm font-mono tabular-nums truncate ${
comboHot ? "text-amber-200" : "text-amber-300/80"
}`}
>
{combo}
<span className="text-[9px] text-amber-300/60">/10</span>
</div>
</div>
</div>
{/* 仓库容量 — fuchsia,≥90% 切 rose 警告色 + pulse */}
<div
className={`rounded-lg border bg-black/30 backdrop-blur p-2 flex items-center gap-1.5 ${
warehouseWarn ? "border-rose-400/70 animate-pulse" : "border-fuchsia-400/30"
}`}
>
<Database
className={`h-3.5 w-3.5 shrink-0 ${
warehouseWarn ? "text-rose-400" : "text-fuchsia-400"
}`}
/>
<div className="min-w-0 leading-tight">
<div
className={`text-[9px] uppercase tracking-wider truncate ${
warehouseWarn ? "text-rose-300/80" : "text-fuchsia-300/70"
}`}
>
</div>
<div
className={`text-sm font-mono tabular-nums truncate ${
warehouseWarn ? "text-rose-200" : "text-fuchsia-200"
}`}
>
{formatNum(crystals)}
<span
className={`text-[9px] ${
warehouseWarn ? "text-rose-300/70" : "text-fuchsia-300/60"
}`}
>
/{formatNum(crystalCap)}
</span>
</div>
</div>
</div>
</div>
);
}
function StatsPanel() {
// v0.11 性能优化:用 shallow 聚合 selector 替代全量订阅 useGameStore()
// 避免每 tick(250ms)因无关字段变化触发重渲染。仅当所列字段引用变化才重渲染。
const s = useGameStore(
(st) => ({
totalDecoded: st.totalDecoded,
ascensions: st.ascensions,
blueprints: st.blueprints,
tech: st.tech,
fragments: st.fragments,
achievements: st.achievements,
constellation: st.constellation,
contact: st.contact,
crystalsPerSec: st.crystalsPerSec,
crystalCap: st.crystalCap,
insightMult: st.insightMult,
totalExpeditions: st.totalExpeditions,
bossKills: st.bossKills,
starTidesEncountered: st.starTidesEncountered,
chronicle: st.chronicle,
energy: st.energy,
energyMax: st.energyMax,
attributes: st.attributes,
pendingAttrPoints: st.pendingAttrPoints,
}),
shallow
);
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} / 9` },
{ 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>
);
}
+4 -146
View File
@@ -1,168 +1,26 @@
"use client";
// 回响星核 / Echo Nexus — 成就解锁通知v0.9 全屏特效增强)
// 工单 Issue #2 反馈"成就系统没看出来有什么用",改为居中全息弹窗 + 奖励数字弹出
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
// 回响星核 / Echo Nexus — 成就解锁通知消费者
import { useEffect } from "react";
import { useGameStore } from "@/store/gameStore";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
import type { Achievement } from "@/lib/game/achievements";
interface NotificationItem {
achievement: Achievement;
id: number;
}
export function AchievementNotifier() {
const queue = useGameStore((s) => s._achievementQueue);
const consume = useGameStore((s) => s.consumeAchievementQueue);
const { toast } = useToast();
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
useEffect(() => {
if (queue.length === 0) return;
const items = consume();
for (const a of items) {
sfx("achievement");
// v0.9:全屏居中特效通知
const id = Date.now() + Math.random();
// eslint-disable-next-line react-hooks/set-state-in-effect
setNotifications((prev) => [...prev, { achievement: a, id }]);
// 4 秒后自动移除
setTimeout(() => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, 4000);
// 同时保留底部 toast 作为日志
toast({
title: `成就解锁:${a.name}`,
title: `🏆 成就解锁:${a.name}`,
description: `${a.desc} 奖励:${a.rewardText}`,
});
}
}, [queue, consume, toast]);
if (notifications.length === 0) return null;
return (
<div className="fixed inset-0 z-[120] pointer-events-none flex items-center justify-center">
<AnimatePresence>
{notifications.map((n, idx) => {
const a = n.achievement;
// 多个同时出现时垂直堆叠偏移
const offset = (idx - (notifications.length - 1) / 2) * 140;
return (
<motion.div
key={n.id}
initial={{ opacity: 0, scale: 0.4, y: 40, rotateX: -30 }}
animate={{ opacity: 1, scale: 1, y: offset, rotateX: 0 }}
exit={{ opacity: 0, scale: 0.6, y: -60 }}
transition={{ type: "spring", stiffness: 280, damping: 22 }}
className="absolute"
style={{ transformOrigin: "center center" }}
>
<div
className="relative rounded-2xl border-2 px-8 py-5 backdrop-blur-xl overflow-hidden"
style={{
borderColor: a.color,
background: `linear-gradient(135deg, ${a.color}22, rgba(5,4,16,0.92))`,
boxShadow: `0 0 60px ${a.color}66, 0 0 120px ${a.color}33, inset 0 0 30px ${a.color}11`,
minWidth: 320,
maxWidth: 420,
}}
>
{/* 背景径向辉光 */}
<div
className="absolute -top-20 -right-20 h-40 w-40 rounded-full blur-3xl opacity-50"
style={{ background: a.color }}
/>
{/* 旋转光线 */}
<motion.div
className="absolute inset-0 rounded-2xl opacity-20"
style={{
background: `conic-gradient(from 0deg, transparent, ${a.color}, transparent, ${a.color}, transparent)`,
}}
animate={{ rotate: 360 }}
transition={{ duration: 8, repeat: Infinity, ease: "linear" }}
/>
<div className="relative flex items-center gap-4">
{/* 图标徽章 */}
<motion.div
className="shrink-0 h-16 w-16 rounded-2xl flex items-center justify-center text-3xl"
style={{
background: `${a.color}33`,
border: `2px solid ${a.color}`,
boxShadow: `0 0 20px ${a.color}88, inset 0 0 12px ${a.color}44`,
}}
animate={{
boxShadow: [
`0 0 20px ${a.color}88, inset 0 0 12px ${a.color}44`,
`0 0 40px ${a.color}cc, inset 0 0 20px ${a.color}66`,
`0 0 20px ${a.color}88, inset 0 0 12px ${a.color}44`,
],
}}
transition={{ duration: 2, repeat: Infinity }}
>
{a.icon}
</motion.div>
{/* 文本 */}
<div className="min-w-0 flex-1">
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.15 }}
className="text-[10px] font-mono uppercase tracking-[0.2em] mb-0.5"
style={{ color: a.color }}
>
Achievement Unlocked
</motion.div>
<motion.h3
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="text-lg font-bold truncate"
style={{ color: a.color, textShadow: `0 0 12px ${a.color}66` }}
>
{a.name}
</motion.h3>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="text-[11px] text-muted-foreground/90 truncate"
>
{a.desc}
</motion.p>
{/* 奖励条 */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-1.5 inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-mono font-semibold"
style={{
background: `${a.color}22`,
border: `1px solid ${a.color}55`,
color: a.color,
}}
>
{a.rewardText}
</motion.div>
</div>
</div>
{/* 进度条(倒计时) */}
<motion.div
className="absolute bottom-0 left-0 h-0.5"
style={{ background: a.color }}
initial={{ width: "100%" }}
animate={{ width: "0%" }}
transition={{ duration: 4, ease: "linear" }}
/>
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
return null;
}
+29 -111
View File
@@ -1,64 +1,30 @@
"use client";
// 回响星核 / Echo Nexus — 成就系统面板(v0.10 工单 #2 重写:单列 + 分类过滤 + 防溢出)
import { useState, useMemo } from "react";
// 回响星核 / Echo Nexus — 成就系统面板
import { useGameStore } from "@/store/gameStore";
import { ACHIEVEMENTS, achievementBonuses } from "@/lib/game/achievements";
import { Trophy, Lock, Sparkles, Filter } from "lucide-react";
type FilterKey = "all" | "unlocked" | "locked";
const FILTER_TABS: { key: FilterKey; label: string; color: string }[] = [
{ key: "all", label: "全部", color: "#a78bfa" },
{ key: "unlocked", label: "已解锁", color: "#34d399" },
{ key: "locked", label: "未解锁", color: "#fb7185" },
];
import { ACHIEVEMENTS } from "@/lib/game/achievements";
import { achievementBonuses } from "@/lib/game/achievements";
import { Trophy, Lock } from "lucide-react";
export function AchievementsPanel() {
const achievements = useGameStore((s) => s.achievements);
const [filter, setFilter] = useState<FilterKey>("all");
const unlockedCount = Object.values(achievements).filter(Boolean).length;
const bonus = achievementBonuses(achievements);
const totalCount = ACHIEVEMENTS.length;
const unlockPct = totalCount > 0 ? (unlockedCount / totalCount) * 100 : 0;
// 按筛选条件过滤成就列表
const filtered = useMemo(() => {
return ACHIEVEMENTS.filter((a) => {
const unlocked = !!achievements[a.id];
if (filter === "unlocked") return unlocked;
if (filter === "locked") return !unlocked;
return true;
});
}, [achievements, filter]);
return (
<div className="flex flex-col gap-2.5 h-full min-h-0">
{/* 顶部:标题 + 进度 */}
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold flex items-center gap-1.5 shrink-0">
<div className="flex flex-col gap-2.5 h-full">
{/* 顶部:进度 + 加成总览 */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<Trophy className="h-4 w-4 text-amber-400" />
</h3>
<div className="flex items-center gap-2 text-[10px] font-mono">
<span className="text-amber-300">{unlockedCount}</span>
<span className="text-muted-foreground/50">/</span>
<span className="text-muted-foreground">{totalCount}</span>
<span className="text-muted-foreground/60">({unlockPct.toFixed(0)}%)</span>
</div>
</div>
{/* 进度条 */}
<div className="h-1 rounded-full bg-black/40 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-amber-500 via-rose-400 to-fuchsia-400 transition-all duration-500"
style={{ width: `${unlockPct}%`, boxShadow: "0 0 8px rgba(251,191,36,0.5)" }}
/>
<span className="text-[10px] text-muted-foreground font-mono">
{unlockedCount} / {ACHIEVEMENTS.length}
</span>
</div>
{/* 永久加成条 */}
<div className="rounded-lg border border-amber-400/20 bg-amber-950/20 px-2.5 py-1.5 flex items-center gap-2 text-[10px] flex-wrap">
<Sparkles className="h-3 w-3 text-amber-400 shrink-0" />
<div className="rounded-lg border border-amber-400/20 bg-amber-950/20 px-2.5 py-1.5 flex items-center gap-3 text-[10px]">
<span className="text-amber-300/80"></span>
{bonus.crystalsPerSecPct > 0 && (
<span className="text-emerald-300"> +{bonus.crystalsPerSecPct}%</span>
@@ -67,71 +33,38 @@ export function AchievementsPanel() {
<span className="text-fuchsia-300"> +{bonus.insightPct}%</span>
)}
{bonus.crystalsPerSecPct === 0 && bonus.insightPct === 0 && (
<span className="text-muted-foreground/60"></span>
<span className="text-muted-foreground/60"></span>
)}
</div>
{/* 分类过滤标签 */}
<div className="flex items-center gap-1 text-[10px]">
<Filter className="h-3 w-3 text-muted-foreground/60 shrink-0" />
{FILTER_TABS.map((t) => {
const active = filter === t.key;
const count =
t.key === "all"
? totalCount
: t.key === "unlocked"
? unlockedCount
: totalCount - unlockedCount;
return (
<button
key={t.key}
onClick={() => setFilter(t.key)}
className={`px-2 py-0.5 rounded-md transition-all ${
active
? "bg-white/10 text-foreground"
: "text-muted-foreground/70 hover:text-foreground hover:bg-white/5"
}`}
style={active ? { color: t.color, boxShadow: `inset 0 0 0 1px ${t.color}55` } : undefined}
>
{t.label}
<span className="ml-1 font-mono opacity-70">{count}</span>
</button>
);
})}
</div>
{/* 成就列表 — 单列展示,每个卡片 min-h 防止内容溢出被裁切 */}
<div className="flex-1 min-h-0 overflow-y-auto echo-scroll pr-1 space-y-1.5">
{filtered.length === 0 && (
<div className="text-center text-[11px] text-muted-foreground/60 py-6">
</div>
)}
{filtered.map((a) => {
{/* 成就网格 — 移除固定 max-h,用 flex-1 min-h-0 自适应父容器,避免小屏挤压重叠 */}
<div className="flex-1 min-h-0 grid grid-cols-1 sm:grid-cols-2 gap-1.5 content-start overflow-y-auto echo-scroll pr-1">
{ACHIEVEMENTS.map((a) => {
const unlocked = !!achievements[a.id];
return (
<div
key={a.id}
className={`relative rounded-lg border px-2.5 py-2 transition-all duration-300 min-h-[68px] flex items-center ${
className={`relative rounded-lg border px-2.5 py-2 transition-all duration-300 overflow-hidden ${
unlocked
? "border-white/15 bg-black/30"
: "border-white/5 bg-black/15 opacity-70"
: "border-white/5 bg-black/15 opacity-60"
}`}
style={
unlocked
? { boxShadow: `inset 0 0 14px ${a.color}10` }
? { boxShadow: `inset 0 0 16px ${a.color}11` }
: undefined
}
>
{unlocked && (
<div
className="absolute -top-6 -right-6 h-16 w-16 rounded-full blur-2xl opacity-40 pointer-events-none"
className="absolute -top-6 -right-6 h-16 w-16 rounded-full blur-2xl opacity-40"
style={{ background: a.color }}
/>
)}
<div className="relative flex items-start gap-2">
{/* 图标徽章 */}
<div
className="shrink-0 h-10 w-10 rounded-lg flex items-center justify-center text-lg mr-2.5"
className="shrink-0 h-8 w-8 rounded-lg flex items-center justify-center text-base"
style={{
background: unlocked ? `${a.color}22` : "rgba(255,255,255,0.04)",
border: `1px solid ${unlocked ? a.color + "55" : "rgba(255,255,255,0.08)"}`,
@@ -139,36 +72,27 @@ export function AchievementsPanel() {
boxShadow: unlocked ? `0 0 10px ${a.color}33` : "none",
}}
>
{unlocked ? a.icon : <Lock className="h-4 w-4" />}
{unlocked ? a.icon : <Lock className="h-3.5 w-3.5" />}
</div>
{/* 文本 — 使用 flex-1 + min-w-0 防止文本撑爆容器 */}
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<span
{/* 文本 */}
<div className="min-w-0 flex-1">
<div
className="text-[11px] font-semibold truncate"
style={{ color: unlocked ? a.color : "rgba(255,255,255,0.5)" }}
>
{a.name}
</span>
{unlocked && (
<span
className="text-[8px] px-1 py-0 rounded shrink-0 font-mono"
style={{ background: `${a.color}22`, color: a.color }}
>
</span>
)}
</div>
<div className="text-[10px] text-muted-foreground/80 leading-tight line-clamp-2">
<div className="text-[10px] text-muted-foreground/80 leading-tight mt-0.5">
{a.desc}
</div>
{unlocked && (
<div className="text-[9px] mt-0.5 font-mono truncate" style={{ color: a.color + "cc" }}>
<div className="text-[9px] mt-1 font-mono" style={{ color: a.color + "cc" }}>
{a.rewardText}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
@@ -177,12 +101,6 @@ export function AchievementsPanel() {
.echo-scroll::-webkit-scrollbar { width: 5px; }
.echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.14); border-radius: 3px; }
.echo-scroll::-webkit-scrollbar-track { background: transparent; }
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
`}</style>
</div>
);
-89
View File
@@ -1,89 +0,0 @@
"use client";
// 回响星核 / Echo Nexus — 考古日志滚动条(v0.12 差异化叙事)
// 参考 Antimatter Dimensions news ticker
// 固定在 footer 上方,marquee 横向滚动,每 18-30s 推送新条目
// 让"等待"承载叙事——星核沉睡时,回响仍在被翻译
/* eslint-disable react-hooks/set-state-in-effect -- 定时刷新日志是放置游戏的合理副作用,effect 内 setState 不可避免 */
import { useEffect, useRef, useState, useCallback } from "react";
import { useGameStore } from "@/store/gameStore";
import { pickLogEntry, type ArchaeoLogEntry, type LogTier } from "@/lib/game/archaeoLog";
import { ScrollText } from "lucide-react";
const TIER_META: Record<LogTier, { label: string; color: string; glow: string }> = {
dawn: { label: "初醒", color: "text-emerald-300", glow: "rgba(52,211,153,0.5)" },
awakening: { label: "觉醒", color: "text-amber-300", glow: "rgba(251,191,36,0.5)" },
contact: { label: "接触", color: "text-fuchsia-300", glow: "rgba(232,121,249,0.5)" },
ascension: { label: "飞升", color: "text-rose-300", glow: "rgba(251,113,133,0.5)" },
deep: { label: "深度", color: "text-fuchsia-200", glow: "rgba(232,121,249,0.7)" },
};
export function ArchaeoLogTicker() {
const ascensions = useGameStore((s) => s.ascensions);
const contact = useGameStore((s) => s.contact);
const [entry, setEntry] = useState<ArchaeoLogEntry | null>(null);
const [recentIds, setRecentIds] = useState<string[]>([]);
const [fadeKey, setFadeKey] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const refresh = useCallback(() => {
const next = pickLogEntry(ascensions, contact, recentIds);
setEntry(next);
setRecentIds((prev) => [next.text, ...prev].slice(0, 8));
setFadeKey((k) => k + 1);
}, [ascensions, contact, recentIds]);
// 初始 + 进度变化时刷新一次(解锁新阶段)
useEffect(() => {
refresh();
}, [ascensions >= 1, ascensions >= 3, ascensions >= 5, ascensions >= 10, contact >= 5, contact >= 25, contact >= 80]);
// 定时刷新(18-30s 随机间隔,避免机械感)
useEffect(() => {
const schedule = () => {
const delay = 18000 + Math.random() * 12000;
timerRef.current = setTimeout(() => {
refresh();
schedule();
}, delay);
};
schedule();
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [refresh]);
if (!entry) return null;
const meta = TIER_META[entry.tier];
return (
<div
className="relative w-full overflow-hidden rounded-lg border border-white/10 bg-black/40 backdrop-blur-sm px-3 py-1.5 flex items-center gap-2.5 group"
style={{ boxShadow: `inset 0 0 12px ${meta.glow}` }}
role="status"
aria-live="polite"
aria-label="考古日志"
>
{/* 左侧标签 */}
<div className="flex items-center gap-1.5 shrink-0 z-10 bg-black/40 backdrop-blur-sm pr-2 -ml-1 pl-1 rounded">
<ScrollText className={`h-3 w-3 ${meta.color}`} />
<span className={`text-[9px] font-mono uppercase tracking-[0.15em] ${meta.color}`}>
{meta.label}
</span>
</div>
{/* 滚动文本区 */}
<div className="flex-1 overflow-hidden relative">
<div
key={fadeKey}
className="whitespace-nowrap text-[11px] text-muted-foreground/90 font-mono echo-ticker-anim"
>
<span className="text-white/60 mr-2"></span>
{entry.text}
</div>
</div>
{/* 右侧渐隐遮罩 */}
<div className="pointer-events-none absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-black/60 to-transparent" />
</div>
);
}
+16 -448
View File
@@ -2,44 +2,33 @@
// 回响星核 / Echo Nexus — 深空信标面板
// v0.5:每日挑战 + 本地排行榜
// v0.8:周挑战 + 信标链(连续完成奖励)
// v0.8.2:限时挑战(每 4 小时刷新,amber 主题)
// v0.8.2 P2/#4:本地/全球双榜(云排行榜 mini-service 端口 3030
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback } from "react";
import { useGameStore } from "@/store/gameStore";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
import {
generateDailyChallenge,
generateWeeklyChallenge,
generateTimedChallenge,
loadDailyProgress,
loadWeeklyProgress,
loadTimedProgress,
loadLeaderboard,
loadChainState,
claimBeaconReward,
fetchCloudLeaderboard,
submitCloudScore,
loadLastCloudSubmitTimestamp,
BEACON_DIFFICULTY,
BEACON_TYPE_META,
BEACON_CHAIN_MILESTONES,
BEACON_CHAIN_REWARDS,
getWeekKey,
getTodayKey,
getTimedSlotKey,
getNextMilestone,
getChainProgress,
msUntilNextDay,
msUntilNextWeek,
msUntilNextTimedSlot,
formatCountdown,
type BeaconDailyChallenge,
type BeaconDailyProgress,
type BeaconWeeklyChallenge,
type BeaconWeeklyProgress,
type BeaconTimedChallenge,
type BeaconTimedProgress,
type BeaconScoreEntry,
type BeaconChallengeType,
type BeaconDifficulty,
@@ -59,11 +48,6 @@ import {
Link2,
Flame,
Zap,
Hourglass,
RefreshCw,
Globe,
WifiOff,
Loader2,
} from "lucide-react";
const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
@@ -78,16 +62,11 @@ function rankBadge(rank: number) {
export function BeaconPanel() {
const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon);
const claimTimedBeacon = useGameStore((s) => s.claimTimedBeacon);
const claimChainReward = useGameStore((s) => s.claimChainReward);
const { toast } = useToast();
const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null);
const [progress, setProgress] = useState<BeaconDailyProgress | null>(null);
const [timedChallenge, setTimedChallenge] =
useState<BeaconTimedChallenge | null>(null);
const [timedProgress, setTimedProgress] =
useState<BeaconTimedProgress | null>(null);
const [weeklyChallenge, setWeeklyChallenge] =
useState<BeaconWeeklyChallenge | null>(null);
const [weeklyProgress, setWeeklyProgress] =
@@ -95,25 +74,13 @@ export function BeaconPanel() {
const [chainState, setChainState] = useState<BeaconChainState | null>(null);
const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
const [dayCountdown, setDayCountdown] = useState("00:00:00");
const [timedCountdown, setTimedCountdown] = useState("00:00:00");
const [weekCountdown, setWeekCountdown] = useState("00:00:00");
const [now, setNow] = useState(Date.now());
// 云排行榜状态(v0.8.2 P2/#4
const [lbTab, setLbTab] = useState<"local" | "global">("local");
const [cloudEntries, setCloudEntries] = useState<BeaconScoreEntry[]>([]);
const [cloudLoading, setCloudLoading] = useState(false);
const [cloudError, setCloudError] = useState<string | null>(null);
const [cloudFetched, setCloudFetched] = useState(false);
const [mySubmitTs, setMySubmitTs] = useState<number>(0);
const cloudFetchingRef = useRef(false);
// 初始化 + 每秒刷新(进度 + 倒计时)
useEffect(() => {
setChallenge(generateDailyChallenge());
setProgress(loadDailyProgress());
setTimedChallenge(generateTimedChallenge());
setTimedProgress(loadTimedProgress());
setWeeklyChallenge(generateWeeklyChallenge());
setWeeklyProgress(loadWeeklyProgress());
setChainState(loadChainState());
@@ -121,11 +88,9 @@ export function BeaconPanel() {
const id = setInterval(() => {
setNow(Date.now());
setProgress(loadDailyProgress());
setTimedProgress(loadTimedProgress());
setWeeklyProgress(loadWeeklyProgress());
setChainState(loadChainState());
setChallenge((c) => c ?? generateDailyChallenge());
setTimedChallenge((c) => c ?? generateTimedChallenge());
setWeeklyChallenge((c) => c ?? generateWeeklyChallenge());
}, 1000);
return () => clearInterval(id);
@@ -133,44 +98,9 @@ export function BeaconPanel() {
useEffect(() => {
setDayCountdown(formatCountdown(msUntilNextDay(new Date(now))));
setTimedCountdown(formatCountdown(msUntilNextTimedSlot(new Date(now))));
setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now))));
}, [now]);
// 初始化:读取本地保存的"上次提交时间戳"(用于全球榜高亮我的记录)
useEffect(() => {
setMySubmitTs(loadLastCloudSubmitTimestamp());
}, []);
// 拉取全球排行榜
const refreshCloudLeaderboard = useCallback(async () => {
if (cloudFetchingRef.current) return;
cloudFetchingRef.current = true;
setCloudLoading(true);
setCloudError(null);
try {
const entries = await fetchCloudLeaderboard();
setCloudEntries(entries);
setCloudFetched(true);
setMySubmitTs(loadLastCloudSubmitTimestamp());
if (entries.length === 0) {
setCloudError("尚无全球记录,成为首位登顶者!");
}
} catch {
setCloudError("全球榜连接失败,请稍后重试");
} finally {
setCloudLoading(false);
cloudFetchingRef.current = false;
}
}, []);
// 切到全球 tab 时自动拉取一次(如已拉取过则不强制)
useEffect(() => {
if (lbTab === "global" && !cloudFetched && !cloudLoading) {
void refreshCloudLeaderboard();
}
}, [lbTab, cloudFetched, cloudLoading, refreshCloudLeaderboard]);
const handleClaimDaily = useCallback(() => {
if (!challenge || !progress) return;
if (progress.completedAt === null || progress.claimed) return;
@@ -186,41 +116,7 @@ export function BeaconPanel() {
1
)} 接触 · 得分 ${res.score}`,
});
// 异步提交到云排行榜(失败静默,不影响本地)
if (res.entry) {
void submitCloudScore(res.entry).then((rank) => {
if (rank >= 1) {
setMySubmitTs(res.entry!.timestamp);
// 若用户在全球 tab,自动刷新
if (lbTab === "global") {
void refreshCloudLeaderboard();
}
}
});
}
}, [challenge, progress, toast, grantBeaconReward, lbTab, refreshCloudLeaderboard]);
const handleClaimTimed = useCallback(() => {
if (!timedChallenge || !timedProgress) return;
if (timedProgress.completedAt === null || timedProgress.claimed) return;
const res = claimTimedBeacon();
setLeaderboard(loadLeaderboard());
setTimedProgress(loadTimedProgress());
if (res.rewardInsight > 0 || res.rewardContact > 0) {
sfx("achievement");
toast({
title: "✦ 限时挑战奖励已领取",
description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
1
)} 接触 · 得分 ${res.score}`,
});
// 异步刷新云榜(claimTimedBeacon 已在 gameStore 内异步提交到云端)
setMySubmitTs(loadLastCloudSubmitTimestamp());
if (lbTab === "global") {
void refreshCloudLeaderboard();
}
}
}, [timedChallenge, timedProgress, toast, claimTimedBeacon, lbTab, refreshCloudLeaderboard]);
}, [challenge, progress, toast, grantBeaconReward]);
const handleClaimWeekly = useCallback(() => {
if (!weeklyChallenge || !weeklyProgress) return;
@@ -236,13 +132,8 @@ export function BeaconPanel() {
1
)} 接触 · 得分 ${res.score}`,
});
// 异步刷新云榜(claimWeeklyBeacon 已在 gameStore 内异步提交到云端)
setMySubmitTs(loadLastCloudSubmitTimestamp());
if (lbTab === "global") {
void refreshCloudLeaderboard();
}
}
}, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon, lbTab, refreshCloudLeaderboard]);
}, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]);
const handleClaimChain = useCallback(
(milestone: number) => {
@@ -264,7 +155,7 @@ export function BeaconPanel() {
[toast, claimChainReward]
);
if (!challenge || !progress || !timedChallenge || !timedProgress || !weeklyChallenge || !weeklyProgress || !chainState) {
if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) {
return (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
@@ -279,14 +170,6 @@ export function BeaconPanel() {
const isClaimed = progress.claimed;
const canClaim = isCompleted && !isClaimed;
// 限时挑战派生量
const tDiffMeta = BEACON_DIFFICULTY[timedChallenge.difficulty];
const tTypeMeta = BEACON_TYPE_META[timedChallenge.type];
const tPct = Math.min(100, (timedProgress.progress / timedChallenge.goal) * 100);
const tCompleted = timedProgress.completedAt !== null;
const tClaimed = timedProgress.claimed;
const tCanClaim = tCompleted && !tClaimed;
// 周挑战派生量
const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty];
const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type];
@@ -319,14 +202,6 @@ export function BeaconPanel() {
0%, 100% { box-shadow: 0 0 18px rgba(232,121,249,0.35), inset 0 0 12px rgba(232,121,249,0.25); }
50% { box-shadow: 0 0 32px rgba(232,121,249,0.5), inset 0 0 20px rgba(232,121,249,0.35); }
}
@keyframes timed-glow {
0%, 100% { box-shadow: 0 0 18px rgba(251,191,36,0.4), inset 0 0 12px rgba(251,191,36,0.25); }
50% { box-shadow: 0 0 30px rgba(251,191,36,0.55), inset 0 0 20px rgba(251,191,36,0.35); }
}
@keyframes timed-urgent {
0%, 100% { box-shadow: 0 0 18px rgba(244,63,94,0.5), inset 0 0 12px rgba(244,63,94,0.3); }
50% { box-shadow: 0 0 34px rgba(244,63,94,0.7), inset 0 0 22px rgba(244,63,94,0.4); }
}
@keyframes chain-milestone-pulse {
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(251,113,133,0.55); }
50% { transform: scale(1.06); box-shadow: 0 0 0 8px rgba(251,113,133,0); }
@@ -469,144 +344,6 @@ export function BeaconPanel() {
</div>
</div>
{/* ===== 限时挑战卡片(amber 主题,每 4 小时刷新) ===== */}
{(() => {
// 距离时段结束 < 30 分钟时进入"紧急"状态(rose 脉冲)
const msLeft = msUntilNextTimedSlot(new Date(now));
const urgent = msLeft < 30 * 60 * 1000 && !tCompleted;
const glowAnim = tCompleted
? "none"
: urgent
? "timed-urgent 1.5s ease-in-out infinite"
: "timed-glow 3s ease-in-out infinite";
return (
<div
className="relative rounded-xl border p-3 overflow-hidden"
style={{
borderColor: urgent ? "rgba(244,63,94,0.5)" : "rgba(251,191,36,0.4)",
background: urgent
? "linear-gradient(135deg, rgba(244,63,94,0.12), rgba(251,191,36,0.08), rgba(0,0,0,0.45))"
: "linear-gradient(135deg, rgba(251,191,36,0.14), rgba(0,0,0,0.45))",
animation: glowAnim,
}}
>
{/* 头部:标题 + slotKey + 倒计时 */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<Hourglass className={`h-3.5 w-3.5 ${urgent ? "text-rose-400" : "text-amber-400"}`} />
<span className={`text-xs font-semibold ${urgent ? "text-rose-200" : "text-amber-200"}`}>
· TIMED
</span>
</div>
<div className="flex items-center gap-1 text-[9px] text-muted-foreground/70">
<span className="font-mono text-amber-300/70">
{getTimedSlotKey().split("_").slice(1).join(" ")}
</span>
<Clock className="h-2.5 w-2.5" />
<span className={`font-mono ${urgent ? "text-rose-300" : "text-amber-300/90"}`}>
{timedCountdown}
</span>
</div>
</div>
{/* 难度 + 类型 标签 */}
<div className="flex items-center gap-1.5 mb-2">
<span
className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
style={{ background: `${tDiffMeta.color}22`, color: tDiffMeta.color, border: `1px solid ${tDiffMeta.color}55` }}
>
<span>{tDiffMeta.icon}</span>
{tDiffMeta.label}
</span>
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
{tTypeMeta.icon} {tTypeMeta.label}
</span>
{tCompleted && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
<Sparkles className="h-2.5 w-2.5" />
</span>
)}
{urgent && !tCompleted && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-rose-500/20 text-rose-300 border border-rose-500/40 flex items-center gap-1 animate-pulse">
</span>
)}
</div>
{/* 标题 */}
<h4
className="text-sm font-semibold mb-1"
style={{ color: tDiffMeta.color, textShadow: `0 0 10px ${tDiffMeta.glow}` }}
>
{timedChallenge.title}
</h4>
<p className="text-[10px] text-muted-foreground/80 leading-relaxed mb-2">
{timedChallenge.desc}
</p>
{/* 进度条 */}
<div className="mb-2">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground/70"></span>
<span className="text-[11px] font-mono font-semibold" style={{ color: tDiffMeta.color }}>
{Math.min(timedProgress.progress, timedChallenge.goal)} / {timedChallenge.goal} {tTypeMeta.unit}
</span>
</div>
<Progress
value={tPct}
className="h-2 bg-black/40"
style={{
["--progress-color" as string]: urgent ? "#f43f5e" : tDiffMeta.color,
}}
/>
</div>
{/* 奖励 + 领取按钮 */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-[10px]">
<span className="text-muted-foreground/60"></span>
{timedChallenge.rewardInsight > 0 && (
<span className="text-amber-300 font-mono">
+{formatNum(timedChallenge.rewardInsight)}
</span>
)}
<span className="text-fuchsia-300 font-mono">
+{timedChallenge.rewardContact.toFixed(1)}
</span>
</div>
<Button
onClick={handleClaimTimed}
disabled={!tCanClaim}
size="sm"
className="h-7 px-3 text-[11px] border-0"
style={{
background: tCanClaim
? `linear-gradient(90deg, #34d399, #34d399cc)`
: "rgba(251,191,36,0.10)",
color: tCanClaim
? "#022c22"
: "rgba(251,191,36,0.55)",
}}
>
{tClaimed
? "已领取"
: tCanClaim
? "领取奖励"
: tCompleted
? "已领取"
: "进行中…"}
</Button>
</div>
{tCompleted && timedProgress.durationSec > 0 && (
<div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
{Math.floor(timedProgress.durationSec / 60)}{timedProgress.durationSec % 60}
</div>
)}
</div>
);
})()}
{/* 周挑战 + 信标链(桌面端并排,移动端单列) */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
{/* ===== 周挑战卡片(fuchsia 主题) ===== */}
@@ -934,112 +671,38 @@ export function BeaconPanel() {
</div>
</div>
{/* 排行榜(本地 Top20 / 全球 Top100 双 tab — v0.8.2 P2/#4 */}
{/* 本地排行榜 */}
<div className="rounded-xl border border-white/10 bg-black/30 p-2.5">
{/* 头部:标题 + tab 切换 + (全球 tab 时)刷新按钮 */}
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 mb-2">
<Trophy className="h-3.5 w-3.5 text-amber-400" />
<span className="text-xs font-semibold"></span>
</div>
<div className="flex items-center gap-1">
{/* tab 切换 */}
<div className="flex items-center gap-0.5 p-0.5 rounded-lg bg-black/40 border border-white/10">
<button
type="button"
onClick={() => setLbTab("local")}
className={`flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-mono transition-colors ${
lbTab === "local"
? "bg-amber-500/25 text-amber-300 border border-amber-500/40"
: "text-muted-foreground/60 hover:text-muted-foreground border border-transparent"
}`}
aria-pressed={lbTab === "local"}
>
<Trophy className="h-2.5 w-2.5" />
</button>
<button
type="button"
onClick={() => setLbTab("global")}
className={`flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-mono transition-colors ${
lbTab === "global"
? "bg-emerald-500/25 text-emerald-300 border border-emerald-500/40"
: "text-muted-foreground/60 hover:text-muted-foreground border border-transparent"
}`}
aria-pressed={lbTab === "global"}
>
<Globe className="h-2.5 w-2.5" />
</button>
</div>
{/* 刷新按钮(仅全球 tab */}
{lbTab === "global" && (
<button
type="button"
onClick={() => void refreshCloudLeaderboard()}
disabled={cloudLoading}
className="p-1 rounded-md text-muted-foreground/70 hover:text-emerald-300 hover:bg-emerald-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
aria-label="刷新全球榜"
title="刷新全球榜"
>
<RefreshCw
className={`h-3 w-3 ${cloudLoading ? "animate-spin" : ""}`}
/>
</button>
)}
</div>
<span className="text-[10px] text-muted-foreground/50"> · Top {leaderboard.length || 0}</span>
</div>
{/* 子标题:榜量信息 */}
<div className="mb-1.5 text-[10px] text-muted-foreground/50 flex items-center gap-2">
{lbTab === "local" ? (
<span> Top {leaderboard.length || 0} · 线</span>
) : (
<>
<span> Top {cloudEntries.length || 0}</span>
{cloudFetched && !cloudLoading && cloudEntries.length > 0 && (
<span className="text-emerald-400/60">· </span>
)}
{cloudLoading && (
<span className="text-amber-400/70 flex items-center gap-1">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
</span>
)}
</>
)}
</div>
{/* ===== 本地榜 ===== */}
{lbTab === "local" &&
(leaderboard.length === 0 ? (
{leaderboard.length === 0 ? (
<div className="text-center py-4 text-[11px] text-muted-foreground/40">
<Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
</div>
) : (
<div className="space-y-0.5 max-h-[200px] overflow-y-auto echo-scroll">
<div className="space-y-0.5 max-h-[180px] overflow-y-auto echo-scroll">
{leaderboard.map((entry, i) => {
const rb = rankBadge(i);
const eDiff = BEACON_DIFFICULTY[entry.difficulty];
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
const isMine =
entry.dateKey === getTodayKey() ||
entry.dateKey === getWeekKey() ||
entry.dateKey === getTimedSlotKey();
const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey();
return (
<div
key={`local-${entry.timestamp}-${i}`}
key={`${entry.timestamp}-${i}`}
className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
entry.isWeekly
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
: entry.isTimed
? "bg-amber-500/10 border border-amber-500/20"
: isMine
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
: "hover:bg-white/5"
}`}
>
{/* 排名 */}
<span className="w-6 flex items-center justify-center">
{rb ? (
<rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
@@ -1047,6 +710,7 @@ export function BeaconPanel() {
<span className="text-muted-foreground/50 font-mono">{i + 1}</span>
)}
</span>
{/* 类型 + 难度 + 周挑战标记 */}
<span className="flex items-center gap-1 flex-1 min-w-0">
<span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
<span className="text-muted-foreground/80 truncate">{eType.label}</span>
@@ -1055,16 +719,13 @@ export function BeaconPanel() {
WEEK
</span>
)}
{entry.isTimed && (
<span className="text-[9px] font-mono px-1 rounded bg-amber-500/20 text-amber-300 border border-amber-500/40">
TIMED
</span>
)}
{entry.progress >= 1 && <span className="text-emerald-400/70"></span>}
</span>
{/* 用时 */}
<span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
{Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
</span>
{/* 分数 */}
<span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
{formatNum(entry.score)}
</span>
@@ -1072,99 +733,6 @@ export function BeaconPanel() {
);
})}
</div>
))}
{/* ===== 全球榜 ===== */}
{lbTab === "global" && (
<>
{cloudLoading && cloudEntries.length === 0 ? (
<div className="text-center py-4 text-[11px] text-muted-foreground/60 flex items-center justify-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-400" />
</div>
) : cloudEntries.length === 0 ? (
<div className="text-center py-4 text-[11px] text-muted-foreground/40">
{cloudError ? (
<>
<WifiOff className="h-6 w-6 mx-auto mb-1 opacity-40 text-rose-400" />
<span className="text-rose-300/70">{cloudError}</span>
<button
type="button"
onClick={() => void refreshCloudLeaderboard()}
className="block mx-auto mt-2 text-[10px] text-emerald-400/80 hover:text-emerald-300 underline-offset-2 hover:underline"
>
</button>
</>
) : (
<>
<Globe className="h-6 w-6 mx-auto mb-1 opacity-30 text-emerald-400" />
</>
)}
</div>
) : (
<div className="space-y-0.5 max-h-[200px] overflow-y-auto echo-scroll">
{cloudEntries.map((entry, i) => {
const rb = rankBadge(i);
const eDiff = BEACON_DIFFICULTY[entry.difficulty as BeaconDifficulty] ?? BEACON_DIFFICULTY.routine;
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType] ?? { label: entry.challenge, icon: "·", unit: "" };
// 高亮"我的"记录:通过 timestamp 匹配本机最后一次成功提交
const isMine = mySubmitTs > 0 && entry.timestamp === mySubmitTs;
return (
<div
key={`global-${entry.timestamp}-${i}`}
className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
isMine
? "bg-emerald-500/15 border border-emerald-500/40 shadow-[0_0_12px_rgba(52,211,153,0.25)]"
: entry.isWeekly
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
: entry.isTimed
? "bg-amber-500/10 border border-amber-500/20"
: "hover:bg-white/5"
}`}
>
<span className="w-6 flex items-center justify-center">
{rb ? (
<rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
) : (
<span className={`font-mono ${isMine ? "text-emerald-300 font-bold" : "text-muted-foreground/50"}`}>
{i + 1}
</span>
)}
</span>
<span className="flex items-center gap-1 flex-1 min-w-0">
<span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
<span className="text-muted-foreground/80 truncate">{eType.label}</span>
{entry.isWeekly && (
<span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
WEEK
</span>
)}
{entry.isTimed && (
<span className="text-[9px] font-mono px-1 rounded bg-amber-500/20 text-amber-300 border border-amber-500/40">
TIMED
</span>
)}
{isMine && (
<span className="text-[9px] font-mono px-1 rounded bg-emerald-500/25 text-emerald-200 border border-emerald-500/50">
YOU
</span>
)}
{entry.progress >= 1 && <span className="text-emerald-400/70"></span>}
</span>
<span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
{Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
</span>
<span className="font-mono font-semibold w-12 text-right" style={{ color: isMine ? "#34d399" : eDiff.color }}>
{formatNum(entry.score)}
</span>
</div>
);
})}
</div>
)}
</>
)}
</div>
+9 -55
View File
@@ -1,6 +1,6 @@
"use client";
// 回响星核 / Echo Nexus — 回响编年史对话框(v0.4 跨周目叙事时间轴)
import { useEffect, useState, useMemo } from "react";
import { useEffect } from "react";
import {
Dialog,
DialogContent,
@@ -20,8 +20,7 @@ import { getPerk } from "@/lib/game/constellation";
import { TIDE_EVENTS } from "@/lib/game/starTide";
import { sfx } from "@/hooks/useAudio";
import type { ChronicleEntry } from "@/lib/game/types";
import { BookOpen, Clock, Swords, Trophy, Sparkles, Star, Waves, Cpu, Zap, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { BookOpen, Clock, Swords, Trophy, Sparkles, Star, Waves, Cpu, Zap } from "lucide-react";
interface Props {
open: boolean;
@@ -60,26 +59,12 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
const bossKills = useGameStore((s) => s.bossKills ?? 0);
const tidesAll = useGameStore((s) => s.starTidesEncountered ?? []);
// 倒序展示(最新的在前)
const entries: ChronicleEntry[] = useMemo(() => [...chronicle].reverse(), [chronicle]);
const entries: ChronicleEntry[] = [...chronicle].reverse();
// v0.8.2 分页(工单 #8):编年史上限 200 条,每页 10 条
const PAGE_SIZE = 10;
const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
const [page, setPage] = useState(1);
// 当 entries 变化(新飞升)且当前页超出范围时,回到第 1 页(最新)
// 打开时播放翻页音效
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (page > totalPages) setPage(1);
}, [page, totalPages]);
// 打开时默认回到第 1 页(最新)
useEffect(() => {
if (open) {
sfx("chronicleOpen");
// eslint-disable-next-line react-hooks/set-state-in-effect
setPage(1);
}
if (open) sfx("chronicleOpen");
}, [open]);
const pagedEntries = entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -123,7 +108,7 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
</DialogHeader>
{/* 时间轴滚动区 */}
<div className="flex-1 overflow-y-auto px-6 pb-3 echo-scroll">
<div className="flex-1 overflow-y-auto px-6 pb-6 echo-scroll">
{entries.length === 0 ? (
<EmptyState ascensions={ascensions} />
) : (
@@ -132,49 +117,18 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
<div className="absolute left-[27px] top-2 bottom-2 w-px bg-gradient-to-b from-fuchsia-400/40 via-violet-400/30 to-transparent" />
<div className="space-y-4">
{pagedEntries.map((entry, idx) => (
{entries.map((entry, idx) => (
<ChronicleCard
key={`${entry.ascensionNumber}-${entry.timestamp}`}
entry={entry}
isLatest={(page - 1) * PAGE_SIZE + idx === 0}
index={(page - 1) * PAGE_SIZE + idx}
isLatest={idx === 0}
index={idx}
/>
))}
</div>
</div>
)}
</div>
{/* v0.8.2 分页控制栏(工单 #8 */}
{entries.length > PAGE_SIZE && (
<div className="px-6 py-2.5 border-t border-fuchsia-400/15 bg-black/40 flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground font-mono">
{page} / {totalPages} · {entries.length}
</span>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
disabled={page <= 1}
onClick={() => { sfx("tabSwitch"); setPage((p) => Math.max(1, p - 1)); }}
className="h-7 px-2 text-fuchsia-300 hover:bg-fuchsia-500/15 disabled:opacity-30"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={page >= totalPages}
onClick={() => { sfx("tabSwitch"); setPage((p) => Math.min(totalPages, p + 1)); }}
className="h-7 px-2 text-fuchsia-300 hover:bg-fuchsia-500/15 disabled:opacity-30"
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
+193 -14
View File
@@ -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">
{/* 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>
+1 -10
View File
@@ -5,7 +5,6 @@ import { useGameStore } from "@/store/gameStore";
import { formatNum } from "@/lib/game/config";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
import { PULSE_WHISPERS, maybeWhisper } from "@/lib/game/narrative";
interface FloatNum {
id: number;
@@ -243,18 +242,10 @@ export function CrystalOrb() {
setFloats((f) => f.filter((it) => it.id !== id));
}, 900);
if (res.combo >= 5 && res.combo % 5 === 0) {
// v0.9 叙事融入:高连击时注入世界观低语
const whisper = maybeWhisper(PULSE_WHISPERS, 0.6);
toast({
title: `×${res.combo} 连击!`,
description: whisper ? `${whisper}` : "谐振叠加,产能飙升。",
description: "谐振叠加,产能飙升。",
});
} else if (res.combo >= 3 && res.combo % 3 === 0) {
// 3 连击时也有概率出现叙事
const whisper = maybeWhisper(PULSE_WHISPERS, 0.3);
if (whisper) {
toast({ title: "低语", description: `${whisper}` });
}
}
},
[pulse, toast, triggerPulseEffect]
+7 -34
View File
@@ -408,33 +408,13 @@ function CrystalCard({
return (
<button
onClick={onClick}
className="group relative aspect-square rounded-2xl border border-white/10 bg-black/40 hover:border-white/40 transition-all duration-300 hover:scale-[1.04] focus:outline-none overflow-hidden"
className="group relative aspect-square rounded-2xl border border-white/10 bg-black/40 hover:border-white/30 transition-all duration-300 hover:scale-[1.03] focus:outline-none overflow-hidden"
style={{ boxShadow: `inset 0 0 24px ${tierColor}22` }}
>
{/* v0.11 hover 流光边框 */}
<div
className="absolute inset-0 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{
background: `linear-gradient(120deg, transparent 30%, ${tierColor}40 50%, transparent 70%)`,
backgroundSize: "200% 100%",
animation: "echo-border-flow 2s linear infinite",
mixBlendMode: "screen",
}}
/>
{/* v0.11 hover 扫描线 */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none overflow-hidden">
<div
className="absolute inset-x-0 h-px"
style={{
background: `linear-gradient(90deg, transparent, ${tierColor}, transparent)`,
animation: "echo-scan 1.8s ease-in-out infinite",
}}
/>
</div>
{/* 晶体图形 */}
<div className="absolute inset-0 flex items-center justify-center">
<div
className="relative h-12 w-12 transition-transform duration-500 group-hover:rotate-[200deg] group-hover:scale-110"
className="relative h-12 w-12 transition-transform duration-500 group-hover:rotate-180"
style={{ filter: `drop-shadow(0 0 12px ${tierColor}88)` }}
>
<div
@@ -450,22 +430,15 @@ function CrystalCard({
/>
</div>
</div>
{/* v0.11 标签上移至顶部,z-10 避免被底部覆盖层遮挡 */}
<div
className="absolute top-1.5 left-1.5 z-10 text-[10px] font-mono font-semibold px-1.5 py-0.5 rounded bg-black/60 backdrop-blur-sm border"
style={{ color: tierColor, borderColor: `${tierColor}40` }}
>
{/* 标签 */}
<div className="absolute top-1.5 left-1.5 text-[10px] font-mono px-1.5 py-0.5 rounded bg-black/50" style={{ color: tierColor }}>
T{crystal.tier}
</div>
<div className="absolute top-1.5 right-1.5 z-10 text-[9px] text-white/60 font-mono tracking-wider">
<div className="absolute bottom-1.5 right-1.5 text-[10px] text-white/70">
{tierLabel}
</div>
{/* v0.11 hover 底部显示晶体数值 + 解码提示 */}
<div className="absolute inset-x-0 bottom-0 z-10 bg-gradient-to-t from-black/85 via-black/50 to-transparent pt-4 pb-1.5 px-1 text-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="text-[12px] font-mono font-bold leading-none" style={{ color: tierColor }}>
+{crystal.value}
</div>
<div className="text-[8px] text-white/60 uppercase tracking-[0.15em] mt-0.5"> </div>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-2">
<span className="text-[11px] font-medium text-white"> </span>
</div>
</button>
);
+17 -60
View File
@@ -1,6 +1,6 @@
"use client";
// 回响星核 / Echo Nexus — 遗迹探险面板(v0.2 肉鸽系统v0.9 工单修复
import { useState, useEffect } from "react";
// 回响星核 / Echo Nexus — 遗迹探险面板(v0.2 肉鸽系统)
import { useState } from "react";
import { useGameStore } from "@/store/gameStore";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
@@ -42,7 +42,6 @@ export function ExpeditionPanel() {
const lastEnergyTick = useGameStore((s) => s.lastEnergyTick);
const expeditionLog = useGameStore((s) => s.expeditionLog);
const totalExpeditions = useGameStore((s) => s.totalExpeditions);
const lastExpeditionSummary = useGameStore((s) => s.lastExpeditionSummary);
// v0.8.1 动态能量恢复间隔(技术 + 探索力属性缩短)
const regenIntervalSec = useGameStore((s) => computeEnergyRegenInterval(s));
const startExpedition = useGameStore((s) => s.startExpedition);
@@ -51,20 +50,12 @@ export function ExpeditionPanel() {
const abortExpedition = useGameStore((s) => s.abortExpedition);
const { toast } = useToast();
const [lastLog, setLastLog] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
// v0.9 工单修复:能量恢复实时倒计时(每秒刷新)
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// 能量恢复进度(v0.8.1 动态间隔v0.9 实时刷新
// 能量恢复进度(v0.8.1 动态间隔)
const now = Date.now();
const regenMs = regenIntervalSec * 1000;
const elapsed = now - lastEnergyTick;
const regenProgress = energy >= energyMax ? 100 : Math.min(100, (elapsed / regenMs) * 100);
const regenProgress = energy >= energyMax ? 100 : Math.min(100, ((now - lastEnergyTick) / regenMs) * 100);
const regenBoosted = regenIntervalSec < EXPEDITION_CONFIG.energyRegenSec;
const nextEnergyInSec = energy >= energyMax ? 0 : Math.max(0, Math.ceil((regenMs - elapsed) / 1000));
const canStart = energy >= EXPEDITION_CONFIG.energyCost;
const handleStart = () => {
const res = startExpedition();
@@ -108,7 +99,6 @@ export function ExpeditionPanel() {
};
// ===== 无活跃探险:展示入口 =====
// v0.10 工单 #2 修复:探险结束自动清空 activeExpedition,无需 justFinished 判断
if (!activeExpedition || activeExpedition.finished) {
return (
<div className="flex flex-col gap-3 h-full">
@@ -120,40 +110,6 @@ export function ExpeditionPanel() {
<span className="text-[10px] text-muted-foreground"> {totalExpeditions} </span>
</div>
{/* v0.9 工单修复:上次探险结果摘要(不限时间,常驻展示) */}
{lastExpeditionSummary && (
<div
className={`rounded-xl border p-2.5 relative overflow-hidden ${
lastExpeditionSummary.result === "victory"
? "border-emerald-400/40 bg-emerald-950/20"
: lastExpeditionSummary.result === "defeat"
? "border-rose-400/40 bg-rose-950/20"
: "border-amber-400/30 bg-amber-950/10"
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] font-semibold flex items-center gap-1">
{lastExpeditionSummary.result === "victory" ? "✦ 上次探险:胜利" : lastExpeditionSummary.result === "defeat" ? "✕ 上次探险:失败" : "→ 上次探险:撤退"}
</span>
<span className="text-[9px] text-muted-foreground font-mono">{lastExpeditionSummary.nodeCount} </span>
</div>
<div className="flex items-center gap-2.5 text-[10px]">
{lastExpeditionSummary.rewards.crystals > 0 && (
<span className="text-emerald-300">+{formatNum(lastExpeditionSummary.rewards.crystals)} </span>
)}
{lastExpeditionSummary.rewards.insights > 0 && (
<span className="text-amber-300">+{lastExpeditionSummary.rewards.insights} </span>
)}
{lastExpeditionSummary.rewards.contact > 0 && (
<span className="text-fuchsia-300">+{lastExpeditionSummary.rewards.contact.toFixed(1)} </span>
)}
{lastExpeditionSummary.rewards.crystals === 0 && lastExpeditionSummary.rewards.insights === 0 && (
<span className="text-muted-foreground/60"></span>
)}
</div>
</div>
)}
{/* 能量条 */}
<div className="rounded-xl border border-amber-400/20 bg-amber-950/20 p-3">
<div className="flex items-center justify-between mb-1.5">
@@ -171,10 +127,8 @@ export function ExpeditionPanel() {
value={regenProgress}
className="h-1.5 bg-black/40 [&>div]:bg-gradient-to-r [&>div]:from-amber-500 [&>div]:to-yellow-300"
/>
<p className="text-[10px] text-muted-foreground/70 mt-1 flex items-center gap-1">
<span></span>
<span className="text-amber-300 font-mono font-semibold">{nextEnergyInSec}s</span>
<span></span>
<p className="text-[10px] text-muted-foreground/70 mt-1">
{Math.ceil((regenMs - (now - lastEnergyTick)) / 1000)}s
{regenBoosted && (
<span className="text-emerald-400/80 ml-1"> {regenIntervalSec.toFixed(0)}s/ ()</span>
)}
@@ -186,32 +140,35 @@ export function ExpeditionPanel() {
</div>
{/* 出发按钮 */}
<div className="flex-1 flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-amber-400/20 bg-black/30 p-4 relative overflow-hidden">
<div className="flex-1 flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-amber-400/20 bg-black/30 p-6 relative overflow-hidden">
<div className="absolute -top-10 -right-10 h-32 w-32 rounded-full bg-amber-500/10 blur-2xl" />
<div className="relative">
<div className={`h-16 w-16 rounded-full bg-gradient-to-br from-amber-500/30 to-rose-500/20 flex items-center justify-center mx-auto ${canStart ? "animate-pulse" : ""}`} style={{ boxShadow: "0 0 30px rgba(251,191,36,0.3)" }}>
<Rocket className="h-7 w-7 text-amber-300" />
<div className="h-20 w-20 rounded-full bg-gradient-to-br from-amber-500/30 to-rose-500/20 flex items-center justify-center mx-auto" style={{ boxShadow: "0 0 30px rgba(251,191,36,0.3)" }}>
<Rocket className="h-9 w-9 text-amber-300" />
</div>
</div>
<div className="text-center space-y-1">
<p className="text-sm font-medium text-amber-100"></p>
<p className="text-[11px] text-muted-foreground/80 max-w-[220px]">
沿 BOSS
沿 BOSS
</p>
</div>
<Button
onClick={handleStart}
disabled={!canStart}
disabled={energy < EXPEDITION_CONFIG.energyCost}
className="bg-gradient-to-r from-amber-600 to-rose-500 hover:from-amber-500 hover:to-rose-400 text-white border-0"
>
<Rocket className="h-4 w-4 mr-1.5" />
{canStart ? `出发(消耗 ${EXPEDITION_CONFIG.energyCost} 能量)` : `能量不足(${nextEnergyInSec}s 后恢复)`}
{EXPEDITION_CONFIG.energyCost}
</Button>
{energy < EXPEDITION_CONFIG.energyCost && (
<p className="text-[10px] text-rose-300/80"></p>
)}
</div>
{/* 探险日志 */}
{expeditionLog.length > 0 && (
<div className="rounded-xl border border-white/5 bg-black/20 p-2 max-h-28 overflow-y-auto echo-scroll">
<div className="rounded-xl border border-white/5 bg-black/20 p-2 max-h-32 overflow-y-auto echo-scroll">
<div className="text-[10px] text-muted-foreground/60 mb-1 px-1"></div>
{expeditionLog.slice(0, 4).map((entry, i) => (
<div key={i} className="text-[10px] text-muted-foreground/70 px-1 py-0.5 border-b border-white/5 last:border-0">
-215
View File
@@ -1,215 +0,0 @@
"use client";
// 回响星核 / Echo Nexus — 探索目标雷达(v0.10 新增)
// 动态展示当前可解锁/可探索的下一目标,让"等待"变"期待"
// 缓解工单 #2 "占地方没用" 反馈 — 晶体球下方提供持续探索动力
import { useGameStore } from "@/store/gameStore";
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
import { EXPEDITION_CONFIG } from "@/lib/game/expedition";
import {
Target,
Cpu,
Trophy,
BookOpen,
Rocket,
Star,
TrendingDown,
CheckCircle2,
Sparkles,
} from "lucide-react";
interface TargetItem {
id: string;
icon: React.ComponentType<{ className?: string }>;
color: string;
label: string;
detail: string;
progress: number; // 0-100
hint: string;
}
export function ExplorationRadar() {
const s = useGameStore();
// ===== 计算各系统的下一个目标 =====
const targets: TargetItem[] = [];
// 1. 下一个技术节点(按洞见排序)
const nextTech = TECH_TREE.filter((t) => !s.tech[t.id]).sort((a, b) => a.cost - b.cost)[0];
if (nextTech) {
const insightProgress = Math.min(100, (s.insights / nextTech.cost) * 100);
targets.push({
id: "tech_" + nextTech.id,
icon: Cpu,
color: "#34d399",
label: nextTech.name,
detail: `${nextTech.cost} 洞见 · ${nextTech.desc}`,
progress: insightProgress,
hint: s.insights >= nextTech.cost ? "可解锁" : `还差 ${nextTech.cost - s.insights} 洞见`,
});
}
// 2. 下一个成就(按达成难度近似)
const nextAch = ACHIEVEMENTS.find((a) => !s.achievements[a.id]);
if (nextAch) {
targets.push({
id: "ach_" + nextAch.id,
icon: Trophy,
color: "#fbbf24",
label: nextAch.name,
detail: nextAch.desc,
progress: 30, // 成就进度难以精确计算,给固定视觉提示
hint: nextAch.rewardText,
});
}
// 3. 下一个记忆碎片
const nextFrag = FRAGMENTS.find((f) => !s.fragments[f.id]);
if (nextFrag) {
const ownedFrag = FRAGMENTS.filter((f) => s.fragments[f.id]).length;
targets.push({
id: "frag_" + nextFrag.id,
icon: BookOpen,
color: "#e879f9",
label: nextFrag.title,
detail: "解码晶体有概率获取",
progress: (ownedFrag / FRAGMENTS.length) * 100,
hint: `图谱 ${ownedFrag}/${FRAGMENTS.length}`,
});
}
// 4. 探险能量
if (s.energy < EXPEDITION_CONFIG.energyCost) {
targets.push({
id: "expedition_energy",
icon: Rocket,
color: "#fb7185",
label: "探险能量恢复中",
detail: `满能量可深入遗迹`,
progress: (s.energy / EXPEDITION_CONFIG.energyCost) * 100,
hint: `${s.energy}/${EXPEDITION_CONFIG.energyCost}`,
});
}
// 5. 飞升进度(接触<100 时)
if (s.contact < 100) {
targets.push({
id: "ascension",
icon: Star,
color: "#a78bfa",
label: "接触进度",
detail: "满 100 可飞升新周目",
progress: s.contact,
hint: `${s.contact.toFixed(1)}/100`,
});
}
// 6. 星图天赋(飞升后未选择)
const ownedConstellation = s.constellation ?? [];
if (s.pendingPerkChoices && s.pendingPerkChoices.length > 0) {
targets.push({
id: "constellation_pending",
icon: Sparkles,
color: "#e879f9",
label: "星图觉醒待选择",
detail: "3 选 1 永久天赋",
progress: 100,
hint: "立即选择",
});
} else if (ownedConstellation.length < CONSTELLATION_PERKS.length && s.ascensions > 0) {
targets.push({
id: "constellation_next",
icon: Star,
color: "#e879f9",
label: "下一道星图天赋",
detail: `已觉醒 ${ownedConstellation.length}/${CONSTELLATION_PERKS.length}`,
progress: (ownedConstellation.length / CONSTELLATION_PERKS.length) * 100,
hint: "飞升后解锁",
});
}
// 取前 3 个最相关目标展示
const topTargets = targets.slice(0, 3);
if (topTargets.length === 0) {
return (
<div className="rounded-xl border border-emerald-400/20 bg-emerald-950/10 p-3 text-center">
<CheckCircle2 className="h-5 w-5 text-emerald-400 mx-auto mb-1" />
<p className="text-[11px] text-emerald-200/80"></p>
<p className="text-[9px] text-muted-foreground/60 mt-0.5"></p>
</div>
);
}
return (
<div className="rounded-xl border border-white/10 bg-black/30 p-2.5 space-y-2">
{/* 标题 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Target className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-[11px] font-semibold text-emerald-200"></span>
</div>
<span className="text-[9px] text-muted-foreground/50 font-mono">RADAR</span>
</div>
{/* 目标列表 */}
<div className="space-y-1.5">
{topTargets.map((t) => {
const Icon = t.icon;
const isReady = t.progress >= 100;
return (
<div
key={t.id}
className={`rounded-lg p-1.5 transition-all ${
isReady
? "bg-white/8 border border-white/15"
: "bg-black/20 border border-white/5"
}`}
style={
isReady
? { boxShadow: `0 0 10px ${t.color}33` }
: undefined
}
>
<div className="flex items-center gap-1.5 mb-1">
<Icon
className={`h-3 w-3 shrink-0 ${isReady ? "animate-pulse" : ""}`}
style={{ color: t.color }}
/>
<span
className="text-[10px] font-medium truncate flex-1"
style={{ color: isReady ? t.color : "rgba(255,255,255,0.75)" }}
>
{t.label}
</span>
<span className="text-[8px] font-mono text-muted-foreground/70 shrink-0">
{t.hint}
</span>
</div>
{/* 进度条 */}
<div className="h-0.5 rounded-full bg-black/50 overflow-hidden">
<div
className="h-full transition-all duration-500"
style={{
width: `${Math.min(100, t.progress)}%`,
background: t.color,
boxShadow: isReady ? `0 0 6px ${t.color}` : "none",
}}
/>
</div>
{/* 详情 */}
<p className="text-[8px] text-muted-foreground/60 mt-0.5 truncate">{t.detail}</p>
</div>
);
})}
</div>
{/* 底部提示 */}
<div className="flex items-center gap-1 text-[8px] text-muted-foreground/50 pt-0.5 border-t border-white/5">
<TrendingDown className="h-2.5 w-2.5" />
<span> · </span>
</div>
</div>
);
}
+526
View File
@@ -0,0 +1,526 @@
"use client";
// 回响星核 / Echo Nexus — v0.14 放置系统面板(主「放置」标签内容)
// v0.15:永久加成 softcap 展示 + 批量领取按钮
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useShallow } from "zustand/react/shallow";
import { useGameStore } from "@/store/gameStore";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import {
IDLE_PROJECTS,
IDLE_SLOT_COUNT,
IDLE_COLOR_CLASSES,
COLOR_HEX,
getIdleProject,
getUnlockedIdleProjects,
getLockedIdleProjects,
deriveMinerFleet,
formatRemaining,
formatDuration,
formatReward,
isIdleProjectUnlocked,
} from "@/lib/game/idle";
import { applyIdleSoftcap } from "@/lib/game/engine";
import { formatNum, OFFLINE_CAP_HOURS } from "@/lib/game/config";
import type { IdleProjectDef, IdleProjectSlot } from "@/lib/game/types";
/** 滚动条样式 */
const SCROLLBAR_CLS =
"[scrollbar-width:thin] [scrollbar-color:rgba(232,121,249,0.4)_transparent] " +
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full " +
"[&::-webkit-scrollbar-thumb]:bg-fuchsia-500/30 [&::-webkit-scrollbar-track]:bg-transparent";
export function IdleOperationsPanel() {
// 使用 useShallow 订阅多个字段,避免不必要的 re-render
const { crystalsPerSec, idlePermanentBonus, idleStats, idleProjectSlots, tech, ascensions, offlineEff } =
useGameStore(
useShallow((s) => ({
crystalsPerSec: s.crystalsPerSec,
idlePermanentBonus: s.idlePermanentBonus ?? 0,
idleStats: s.idleStats ?? { projectsCompleted: 0, crystalsFromIdle: 0 },
idleProjectSlots: (s.idleProjectSlots ?? [null, null, null]) as (
| IdleProjectSlot
| null
)[],
tech: s.tech,
ascensions: s.ascensions ?? 0,
offlineEff: s.offlineEff ?? 0.5,
}))
);
const startIdleProject = useGameStore((s) => s.startIdleProject);
const cancelIdleProject = useGameStore((s) => s.cancelIdleProject);
const claimIdleProject = useGameStore((s) => s.claimIdleProject);
// v0.15:批量领取全部已完成工程
const claimAllIdleProjects = useGameStore((s) => s.claimAllIdleProjects);
// 本地 now 状态,每秒刷新倒计时
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// 派生数据
const fleet = useMemo(() => deriveMinerFleet({ tech }), [tech]);
const unlockedProjects = useMemo(
() => getUnlockedIdleProjects({ ascensions, crystalsPerSec }),
[ascensions, crystalsPerSec]
);
const lockedProjects = useMemo(
() => getLockedIdleProjects({ ascensions, crystalsPerSec }),
[ascensions, crystalsPerSec]
);
const totalOutput = fleet.reduce((sum, f) => sum + f.outputPerSec, 0);
// v0.15softcap 有效/原始永久加成
const rawPermBonus = idlePermanentBonus;
const effectivePermBonus = applyIdleSoftcap(rawPermBonus);
const isSoftcapped = effectivePermBonus < rawPermBonus;
// crystalsPerSec 已包含 softcap 后的永久加成(recomputeStats 已应用)
const idleTotalPerSec = crystalsPerSec;
// 当前已运行/已完成的工程 id 集合(用于禁用"派遣"按钮)
const runningProjectIds = useMemo(() => {
const set = new Set<string>();
for (const slot of idleProjectSlots) {
if (slot && !slot.completed) set.add(slot.projectId);
}
return set;
}, [idleProjectSlots]);
// v0.15:可领取工程数量(用于"领取全部"按钮显示)
const claimableCount = useMemo(
() => idleProjectSlots.filter((s) => s !== null && s.completed).length,
[idleProjectSlots]
);
return (
<div className={`max-h-[520px] overflow-y-auto pr-1 ${SCROLLBAR_CLS} space-y-3`}>
{/* ====== Section 1: 放置收益概览 ====== */}
<Card className="gap-0 p-3 bg-black/25 border-white/5 rounded-xl">
<div className="flex items-center justify-between mb-2">
<h3 className="text-[11px] font-semibold text-emerald-300 tracking-wide flex items-center gap-1">
<span className="text-sm"></span>
</h3>
<Badge variant="outline" className="h-4 px-1.5 text-[9px] border-emerald-400/30 text-emerald-200/80">
IDLE OPS
</Badge>
</div>
<div className="grid grid-cols-3 gap-1.5">
<StatTile
label="放置产能"
value={`+${formatNum(idleTotalPerSec)}/s`}
color="emerald"
icon="⛏"
/>
<StatTile
label="永久加成"
value={`+${formatNum(effectivePermBonus)}/s`}
color="fuchsia"
icon="✦"
sub={
isSoftcapped ? (
<span className="text-[9px] text-amber-300/90 leading-tight mt-0.5 block">
+{formatNum(effectivePermBonus)}/s +{formatNum(rawPermBonus)}/s ·
</span>
) : null
}
/>
<StatTile
label="完成工程"
value={`${idleStats.projectsCompleted}`}
color="amber"
icon="✓"
/>
</div>
{/* 离线效率 */}
<div className="mt-2 px-1">
<div className="flex items-center justify-between text-[10px] text-muted-foreground mb-0.5">
<span>线 {OFFLINE_CAP_HOURS}h</span>
<span className="font-mono text-emerald-300">
{Math.round(offlineEff * 100)}%
</span>
</div>
<Progress
value={offlineEff * 100}
className="h-1.5 bg-black/40 [&>div]:bg-gradient-to-r [&>div]:from-emerald-400 [&>div]:via-fuchsia-400 [&>div]:to-rose-400"
/>
</div>
{idleStats.crystalsFromIdle > 0 && (
<div className="mt-1.5 text-[10px] text-muted-foreground/70 px-1">
<span className="text-emerald-300 font-mono">{formatNum(idleStats.crystalsFromIdle)}</span>
</div>
)}
</Card>
{/* ====== Section 2: 采矿无人机舰队 ====== */}
<Card className="gap-0 p-3 bg-black/25 border-white/5 rounded-xl">
<div className="flex items-center justify-between mb-2">
<h3 className="text-[11px] font-semibold text-emerald-300 tracking-wide flex items-center gap-1">
<span className="text-sm">🛰</span>
</h3>
<span className="text-[10px] text-muted-foreground/70 font-mono">
+{formatNum(totalOutput)}/s
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
{fleet.map((m) => {
const isActive = m.status === "active";
return (
<div
key={m.techId}
className={`relative rounded-lg border px-2 py-1.5 ${
isActive
? "border-emerald-400/40 bg-emerald-500/5"
: "border-white/10 bg-black/30 opacity-60"
}`}
>
<div className="flex items-center gap-1.5">
<span className="text-base leading-none">{m.icon}</span>
<div className="flex-1 min-w-0">
<div className="text-[10px] text-foreground truncate">{m.name}</div>
<div className="text-[9px] text-muted-foreground/70">
{isActive ? `Lv.${m.level}` : "休眠中"}
</div>
</div>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] font-mono text-emerald-300">
+{formatNum(m.outputPerSec)}/s
</span>
<span className="relative flex h-2 w-2">
{isActive && (
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-60 animate-ping" />
)}
<span
className={`relative inline-flex h-2 w-2 rounded-full ${
isActive ? "bg-emerald-400" : "bg-muted-foreground/40"
}`}
/>
</span>
</div>
</div>
);
})}
</div>
</Card>
{/* ====== Section 3: 放置工程槽位 ====== */}
<Card className="gap-0 p-3 bg-black/25 border-white/5 rounded-xl">
<div className="flex items-center justify-between mb-2">
<h3 className="text-[11px] font-semibold text-fuchsia-300 tracking-wide flex items-center gap-1">
<span className="text-sm"></span>
</h3>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/70 font-mono">
{idleProjectSlots.filter((s) => s !== null).length} / {IDLE_SLOT_COUNT}
</span>
{/* v0.15:批量领取全部已完成工程(仅当有可领取项时显示) */}
{claimableCount > 0 && (
<Button
size="sm"
onClick={claimAllIdleProjects}
className="h-5 px-2 text-[10px] gap-0.5 bg-emerald-500/15 border border-emerald-400/40 text-emerald-200 hover:bg-emerald-500/25 hover:text-emerald-100"
title={`批量领取 ${claimableCount} 项已完成工程`}
>
<span className="text-[11px] leading-none"></span>
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-1.5">
{idleProjectSlots.map((slot, i) => (
<SlotCard
key={i}
slotIndex={i}
slot={slot}
now={now}
onCancel={() => cancelIdleProject(i)}
onClaim={() => claimIdleProject(i)}
/>
))}
</div>
</Card>
{/* ====== Section 4: 可派遣工程 ====== */}
<Card className="gap-0 p-3 bg-black/25 border-white/5 rounded-xl">
<div className="flex items-center justify-between mb-2">
<h3 className="text-[11px] font-semibold text-amber-300 tracking-wide flex items-center gap-1">
<span className="text-sm">🛰</span>
</h3>
<span className="text-[10px] text-muted-foreground/70 font-mono">
{unlockedProjects.length} / {IDLE_PROJECTS.length}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{unlockedProjects.map((p) => (
<DispatchCard
key={p.id}
project={p}
slots={idleProjectSlots}
runningProjectIds={runningProjectIds}
onDispatch={(slotIndex) => startIdleProject(slotIndex, p.id)}
/>
))}
{unlockedProjects.length === 0 && (
<div className="col-span-2 text-center py-3 text-[10px] text-muted-foreground/60">
·
</div>
)}
</div>
{/* 锁定工程列表 */}
{lockedProjects.length > 0 && (
<div className="mt-2 pt-2 border-t border-white/5">
<div className="text-[10px] text-muted-foreground/60 mb-1.5"></div>
<div className="space-y-1">
{lockedProjects.map((p) => {
const ascReq =
p.minAscensions !== undefined
? `飞升 ${p.minAscensions}`
: null;
const cpsReq =
p.minCrystalsPerSec !== undefined
? `产能 ${p.minCrystalsPerSec}/s`
: null;
const reqText = [ascReq, cpsReq].filter(Boolean).join(" 或 ");
return (
<div
key={p.id}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-black/20 border border-white/5 opacity-60"
>
<span className="text-xs grayscale">{p.icon}</span>
<span className="text-[10px] text-muted-foreground truncate flex-1">
{p.name}
</span>
<span className="text-[9px] text-amber-300/80 font-mono shrink-0">
🔒 {reqText}
</span>
</div>
);
})}
</div>
</div>
)}
</Card>
</div>
);
}
// ============ 子组件 ============
function StatTile({
label,
value,
color,
icon,
sub,
}: {
label: string;
value: string;
color: keyof typeof IDLE_COLOR_CLASSES;
icon: string;
/** v0.15: 可选的副文本(如 softcap 提示),渲染在 value 下方 */
sub?: ReactNode;
}) {
const c = IDLE_COLOR_CLASSES[color];
return (
<div className={`rounded-lg border ${c.border} ${c.bgSoft} px-2 py-1.5`}>
<div className="flex items-center gap-1 text-[9px] text-muted-foreground/80">
<span>{icon}</span>
<span>{label}</span>
</div>
<div className={`text-[13px] font-mono font-bold ${c.text} mt-0.5`}>
{value}
</div>
{sub}
</div>
);
}
function SlotCard({
slotIndex,
slot,
now,
onCancel,
onClaim,
}: {
slotIndex: number;
slot: IdleProjectSlot | null;
now: number;
onCancel: () => void;
onClaim: () => void;
}) {
if (!slot) {
return (
<div className="rounded-lg border border-dashed border-white/15 bg-black/20 px-2 py-3 flex flex-col items-center justify-center min-h-[110px]">
<div className="text-[10px] text-muted-foreground/60"> {slotIndex + 1}</div>
<div className="text-[10px] text-muted-foreground/40 mt-1"> </div>
</div>
);
}
const project = getIdleProject(slot.projectId);
if (!project) {
return (
<div className="rounded-lg border border-rose-400/40 bg-rose-500/10 px-2 py-3 min-h-[110px]">
<div className="text-[10px] text-rose-200"></div>
</div>
);
}
const c = IDLE_COLOR_CLASSES[project.color];
const remainingMs = Math.max(0, slot.finishesAt - now);
const remainingSec = remainingMs / 1000;
const elapsedSec = Math.max(0, (now - slot.startedAt) / 1000);
const progressPct = Math.min(
100,
Math.max(0, (elapsedSec / project.durationSec) * 100)
);
if (slot.completed) {
return (
<div
className={`relative rounded-lg border ${c.border} ${c.bg} px-2 py-2 min-h-[110px] flex flex-col ${c.glow}`}
>
<div className="flex items-center gap-1">
<span className="text-base leading-none">{project.icon}</span>
<span className={`text-[10px] font-semibold ${c.text} truncate flex-1`}>
{project.name}
</span>
</div>
<div className={`text-[11px] font-bold ${c.text} mt-1 flex items-center gap-1`}>
<span> </span>
</div>
<div className="text-[9px] text-muted-foreground/80 mt-1 leading-tight">
{formatReward(project.reward)}
</div>
<Button
size="sm"
onClick={onClaim}
className={`h-6 mt-2 text-[10px] ${c.bg} ${c.text} border ${c.border} hover:opacity-80`}
>
</Button>
</div>
);
}
// 进行中
return (
<div
className={`relative rounded-lg border ${c.border} ${c.bgSoft} px-2 py-2 min-h-[110px] flex flex-col`}
>
<div className="flex items-center gap-1">
<span className="text-base leading-none">{project.icon}</span>
<span className={`text-[10px] font-semibold ${c.text} truncate flex-1`}>
{project.name}
</span>
</div>
<div className={`text-[18px] font-mono font-bold ${c.text} mt-1 leading-none tabular-nums`}>
{formatRemaining(remainingSec)}
</div>
{/* 自定义进度条:用 inline style 控制颜色,避免 Tailwind 动态类限制 */}
<div className="h-1.5 mt-1.5 bg-black/40 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progressPct}%`,
backgroundColor: COLOR_HEX[project.color],
boxShadow: `0 0 8px ${COLOR_HEX[project.color]}80`,
}}
/>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-[9px] text-muted-foreground/70">
{slotIndex + 1} · {Math.round(progressPct)}%
</span>
<Button
size="sm"
variant="ghost"
onClick={onCancel}
className="h-5 px-1.5 text-[9px] text-muted-foreground/60 hover:text-rose-300 hover:bg-rose-500/10"
>
</Button>
</div>
</div>
);
}
function DispatchCard({
project,
slots,
runningProjectIds,
onDispatch,
}: {
project: IdleProjectDef;
slots: (IdleProjectSlot | null)[];
runningProjectIds: Set<string>;
onDispatch: (slotIndex: number) => void;
}) {
const c = IDLE_COLOR_CLASSES[project.color];
const isRunning = runningProjectIds.has(project.id);
return (
<div
className={`relative rounded-lg border ${c.border} ${c.bgSoft} px-2 py-2 flex flex-col`}
>
<div className="flex items-center gap-1.5">
<span className="text-base leading-none">{project.icon}</span>
<div className="flex-1 min-w-0">
<div className={`text-[11px] font-semibold ${c.text} truncate`}>
{project.name}
</div>
<div className="text-[9px] text-muted-foreground/70">
{formatDuration(project.durationSec)}
</div>
</div>
</div>
<div className="text-[9px] text-muted-foreground/80 mt-1 leading-tight line-clamp-2 min-h-[24px]">
{project.desc}
</div>
<div className={`text-[10px] font-mono ${c.text} mt-1`}>
🎁 {formatReward(project.reward)}
</div>
<div className="flex items-center gap-1 mt-1.5">
{[0, 1, 2].map((idx) => {
const slotFree = slots[idx] === null;
const disabled = isRunning || !slotFree;
return (
<Button
key={idx}
size="sm"
variant="outline"
disabled={disabled}
onClick={() => onDispatch(idx)}
className={`h-5 w-7 p-0 text-[10px] font-mono ${
disabled
? "opacity-30 border-white/10 text-muted-foreground"
: `${c.border} ${c.text} ${c.bg} hover:opacity-80`
}`}
title={
isRunning
? "该工程已在运行"
: !slotFree
? `槽位 ${idx + 1} 已占用`
: `派遣到槽位 ${idx + 1}`
}
>
{idx + 1}
</Button>
);
})}
{isRunning && (
<span className="text-[9px] text-amber-300/80 ml-1"></span>
)}
</div>
</div>
);
}
// 静默导出,便于其他模块使用
export { isIdleProjectUnlocked };
+90
View File
@@ -0,0 +1,90 @@
"use client";
// 回响星核 / Echo Nexus — v0.14 放置工程进度条(晶体球下方的细长条)
import { useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useGameStore } from "@/store/gameStore";
import {
COLOR_HEX,
IDLE_COLOR_CLASSES,
getIdleProject,
formatRemaining,
} from "@/lib/game/idle";
import type { IdleProjectSlot } from "@/lib/game/types";
/**
* 晶体球下方的细长进度条:显示最多 3 个进行中/待领取的放置工程
* 无工程时返回 null(不占用空间)
*/
export function IdleProjectBar() {
const slots = useGameStore(
useShallow((s) => (s.idleProjectSlots ?? [null, null, null]) as (
| IdleProjectSlot
| null
)[])
);
// 本地 now 状态:每秒刷新倒计时
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// 过滤出活跃的槽位(运行中或已完成待领取)
const activeSlots = slots
.map((slot, idx) => ({ slot, idx }))
.filter((x) => x.slot !== null);
if (activeSlots.length === 0) return null;
return (
<div className="w-full max-w-[280px] mx-auto flex flex-col gap-1 px-2">
{activeSlots.map(({ slot, idx }) => {
if (!slot) return null;
const project = getIdleProject(slot.projectId);
if (!project) return null;
const c = IDLE_COLOR_CLASSES[project.color];
const hex = COLOR_HEX[project.color];
const remainingSec = Math.max(0, (slot.finishesAt - now) / 1000);
const elapsedSec = Math.max(0, (now - slot.startedAt) / 1000);
const progressPct = slot.completed
? 100
: Math.min(100, Math.max(0, (elapsedSec / project.durationSec) * 100));
return (
<div
key={idx}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md border ${c.border} ${c.bgSoft} text-[10px] ${
slot.completed ? c.glow : ""
}`}
>
<span className="text-[11px] leading-none">{project.icon}</span>
<span className={`${c.text} font-semibold truncate flex-shrink-0 max-w-[80px]`}>
{project.name}
</span>
{slot.completed ? (
<span className={`${c.text} font-bold ml-auto`}> </span>
) : (
<>
<span className={`${c.text} font-mono tabular-nums ml-auto`}>
{formatRemaining(remainingSec)}
</span>
{/* 迷你进度条 ▓▓▓░░ */}
<div className="w-12 h-1.5 bg-black/40 rounded-full overflow-hidden flex-shrink-0">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progressPct}%`,
backgroundColor: hex,
boxShadow: `0 0 6px ${hex}80`,
}}
/>
</div>
</>
)}
</div>
);
})}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
// 回响星核 / Echo Nexus — v0.14 放置系统头部徽章(放置中 +X/s)
import { useShallow } from "zustand/react/shallow";
import { useGameStore } from "@/store/gameStore";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatNum } from "@/lib/game/config";
interface Props {
onClick?: () => void;
}
/** 紧凑徽章:脉冲点 + "放置中 +X/s"(或休眠中) */
export function IdleStatusBadge({ onClick }: Props) {
const { crystalsPerSec, idlePermanentBonus, idleProjectSlots } = useGameStore(
useShallow((s) => ({
crystalsPerSec: s.crystalsPerSec,
idlePermanentBonus: s.idlePermanentBonus ?? 0,
idleProjectSlots: (s.idleProjectSlots ?? [null, null, null]) as (
| { projectId: string; completed: boolean }
| null
)[],
}))
);
const idleTotal = crystalsPerSec + idlePermanentBonus;
const isActive = idleTotal > 0;
const runningCount = idleProjectSlots.filter(
(s) => s !== null && !s.completed
).length;
const claimableCount = idleProjectSlots.filter(
(s) => s !== null && s.completed
).length;
const tipText = (
<div className="space-y-0.5 text-left">
<div className="font-semibold text-[11px]"> · Idle Ops</div>
<div className="text-[10px] opacity-90">
<span className="font-mono">+{formatNum(crystalsPerSec)}/s</span>
</div>
<div className="text-[10px] opacity-90">
<span className="font-mono text-fuchsia-300">+{formatNum(idlePermanentBonus)}/s</span>
</div>
<div className="text-[10px] opacity-90">
<span className="font-mono">{runningCount}</span> / 3
</div>
{claimableCount > 0 && (
<div className="text-[10px] text-amber-300 font-semibold">
{claimableCount}
</div>
)}
<div className="text-[9px] opacity-70 mt-1"> </div>
</div>
);
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onClick}
aria-label="放置系统状态"
data-tut="idle-status-badge"
className={`group h-8 px-2.5 inline-flex items-center gap-1.5 rounded-md border text-[11px] font-mono transition-all ${
isActive
? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200 hover:bg-emerald-500/20 hover:border-emerald-400/60"
: "border-white/15 bg-black/30 text-muted-foreground/80 hover:bg-black/50 hover:border-white/25"
}`}
>
<span className="relative flex h-2 w-2">
{isActive && (
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-60 animate-ping" />
)}
<span
className={`relative inline-flex h-2 w-2 rounded-full ${
isActive ? "bg-emerald-400" : "bg-muted-foreground/40"
}`}
/>
</span>
<span className="leading-none">
{isActive ? (
<>
<span className="text-emerald-300 font-bold">+{formatNum(idleTotal)}</span>
<span className="opacity-70">/s</span>
</>
) : (
"休眠中"
)}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="max-w-[220px]">
{tipText}
</TooltipContent>
</Tooltip>
);
}
-219
View File
@@ -1,219 +0,0 @@
"use client";
// 回响星核 / Echo Nexus — 文明遗迹图鉴面板(v0.10 新增)
// 展示深空考古发现的远古文明遗迹,增加世界观深度与探索乐趣
import { useState } from "react";
import { useGameStore } from "@/store/gameStore";
import {
RELIC_CODEX,
getUnlockedRelics,
getRelicProgress,
getRelicRewards,
type RelicEntry,
} from "@/lib/game/relicCodex";
import { BookMarked, Lock, Sparkles, Clock, Users, ScrollText } from "lucide-react";
export function RelicCodex() {
const state = useGameStore();
const [selected, setSelected] = useState<string | null>(null);
const unlockedRelics = getUnlockedRelics(state);
const progress = getRelicProgress(state);
const rewards = getRelicRewards(state);
const selectedRelic = selected ? RELIC_CODEX.find((r) => r.id === selected) : null;
return (
<div className="flex flex-col gap-2.5 h-full min-h-0">
{/* 顶部:标题 + 进度 */}
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold flex items-center gap-1.5 shrink-0">
<BookMarked className="h-4 w-4 text-fuchsia-400" />
</h3>
<div className="flex items-center gap-2 text-[10px] font-mono">
<span className="text-fuchsia-300">{unlockedRelics.length}</span>
<span className="text-muted-foreground/50">/</span>
<span className="text-muted-foreground">{RELIC_CODEX.length}</span>
<span className="text-muted-foreground/60">({progress.toFixed(0)}%)</span>
</div>
</div>
{/* 进度条 */}
<div className="h-1 rounded-full bg-black/40 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-fuchsia-500 via-rose-400 to-amber-400 transition-all duration-500"
style={{ width: `${progress}%`, boxShadow: "0 0 8px rgba(232,121,249,0.5)" }}
/>
</div>
{/* 累计奖励总览 */}
<div className="rounded-lg border border-fuchsia-400/20 bg-fuchsia-950/20 px-2.5 py-1.5 flex items-center gap-2 text-[10px] flex-wrap">
<Sparkles className="h-3 w-3 text-fuchsia-400 shrink-0" />
<span className="text-fuchsia-300/80"></span>
{rewards.crystals > 0 && (
<span className="text-emerald-300">+{rewards.crystals} </span>
)}
{rewards.insights > 0 && (
<span className="text-amber-300">+{rewards.insights} </span>
)}
{rewards.contact > 0 && (
<span className="text-fuchsia-300">+{rewards.contact} </span>
)}
{rewards.crystalsPerSecPct > 0 && (
<span className="text-emerald-300"> +{rewards.crystalsPerSecPct}%</span>
)}
{unlockedRelics.length === 0 && (
<span className="text-muted-foreground/60"></span>
)}
</div>
{/* 遗迹网格 — 2 列展示,每个卡片 min-h 防溢出 */}
<div className="flex-1 min-h-0 overflow-y-auto echo-scroll pr-1">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{RELIC_CODEX.map((relic) => {
const unlocked = !!state.achievements?.[`relic_${relic.id}`] || relic.unlock(state);
const isSelected = selected === relic.id;
return (
<button
key={relic.id}
onClick={() => setSelected(isSelected ? null : relic.id)}
className={`relative rounded-lg border px-2.5 py-2 transition-all duration-300 min-h-[72px] text-left ${
unlocked
? isSelected
? "border-white/30 bg-white/8"
: "border-white/15 bg-black/30 hover:bg-black/40"
: "border-white/5 bg-black/15 opacity-70"
}`}
style={
unlocked
? { boxShadow: `inset 0 0 14px ${relic.color}10` }
: undefined
}
>
{unlocked && (
<div
className="absolute -top-6 -right-6 h-14 w-14 rounded-full blur-2xl opacity-30 pointer-events-none"
style={{ background: relic.color }}
/>
)}
<div className="relative flex items-start gap-2">
{/* 图标 */}
<div
className="shrink-0 h-9 w-9 rounded-lg flex items-center justify-center text-lg"
style={{
background: unlocked ? `${relic.color}22` : "rgba(255,255,255,0.04)",
border: `1px solid ${unlocked ? relic.color + "55" : "rgba(255,255,255,0.08)"}`,
color: unlocked ? relic.color : "#555",
boxShadow: unlocked ? `0 0 10px ${relic.color}33` : "none",
}}
>
{unlocked ? relic.icon : <Lock className="h-3.5 w-3.5" />}
</div>
{/* 文本 */}
<div className="min-w-0 flex-1">
<div
className="text-[11px] font-semibold truncate"
style={{ color: unlocked ? relic.color : "rgba(255,255,255,0.5)" }}
>
{unlocked ? relic.name : "??? 未发现"}
</div>
{unlocked ? (
<>
<div className="text-[9px] text-muted-foreground/70 truncate flex items-center gap-1">
<Users className="h-2.5 w-2.5" />
{relic.race}
</div>
<div className="text-[9px] text-muted-foreground/60 truncate flex items-center gap-1">
<Clock className="h-2.5 w-2.5" />
{relic.era}
</div>
</>
) : (
<div className="text-[9px] text-muted-foreground/60 mt-0.5 line-clamp-2">
{relic.unlockHint}
</div>
)}
</div>
</div>
</button>
);
})}
</div>
{/* 选中遗迹的详细故事 */}
{selectedRelic && (
<div
className="mt-2 rounded-lg border p-3 relative overflow-hidden"
style={{
borderColor: `${selectedRelic.color}44`,
background: `linear-gradient(135deg, ${selectedRelic.color}11, rgba(0,0,0,0.4))`,
}}
>
<div
className="absolute -top-8 -right-8 h-24 w-24 rounded-full blur-2xl pointer-events-none"
style={{ background: `${selectedRelic.color}22` }}
/>
<div className="relative space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-base" style={{ color: selectedRelic.color }}>
{selectedRelic.icon}
</span>
<span
className="text-sm font-semibold"
style={{ color: selectedRelic.color }}
>
{selectedRelic.name}
</span>
</div>
<div className="flex items-center gap-3 text-[9px] text-muted-foreground/80">
<span className="flex items-center gap-1">
<Users className="h-2.5 w-2.5" />
{selectedRelic.race}
</span>
<span className="flex items-center gap-1">
<Clock className="h-2.5 w-2.5" />
{selectedRelic.era}
</span>
</div>
<p className="text-[10px] text-muted-foreground/90 italic">
{selectedRelic.brief}
</p>
<div className="flex items-start gap-1.5 pt-1 border-t border-white/5">
<ScrollText
className="h-3 w-3 mt-0.5 shrink-0"
style={{ color: selectedRelic.color }}
/>
<p className="text-[10px] text-foreground/85 leading-relaxed">
{selectedRelic.story}
</p>
</div>
<div className="flex items-center gap-1.5 pt-1">
<Sparkles
className="h-3 w-3 shrink-0"
style={{ color: selectedRelic.color }}
/>
<span
className="text-[10px] font-mono"
style={{ color: selectedRelic.color + "cc" }}
>
{selectedRelic.rewardText}
</span>
</div>
</div>
</div>
)}
</div>
<style jsx global>{`
.echo-scroll::-webkit-scrollbar { width: 5px; }
.echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.14); border-radius: 3px; }
.echo-scroll::-webkit-scrollbar-track { background: transparent; }
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
`}</style>
</div>
);
}
+12 -52
View File
@@ -16,15 +16,12 @@ export function StarTideNotifier() {
if (events.length === 0) return;
const items = consume();
for (const ev of items) {
const globalTag = ev.isGlobal ? "🌐 " : "";
if (ev.started) {
const meta = TIDE_EVENTS[ev.started];
sfx("tideStart");
toast({
title: `${globalTag}${meta.icon} ${ev.isGlobal ? "全球星潮" : "星潮降临"}${meta.name}`,
description: ev.isGlobal
? `全球玩家同步经历 · ${meta.desc}`
: meta.desc,
title: `${meta.icon} 星潮降临${meta.name}`,
description: meta.desc,
});
}
if (ev.ended) {
@@ -32,16 +29,14 @@ export function StarTideNotifier() {
if (ev.silenceCompensation && ev.silenceCompensation > 0) {
sfx("techBuy");
toast({
title: `${globalTag}${meta.icon} 寂静期消散`,
title: `${meta.icon} 寂静期消散`,
description: `虚空回赠 ${ev.silenceCompensation} 洞见。`,
});
} else {
sfx("tideEnd");
toast({
title: `${globalTag}${meta.icon} ${meta.name}结束`,
description: ev.isGlobal
? "全球星潮退去,物理法则恢复。"
: "星潮退去,物理法则恢复。",
title: `${meta.icon} ${meta.name}结束`,
description: "星潮退去,物理法则恢复。",
});
}
}
@@ -54,7 +49,6 @@ export function StarTideNotifier() {
/** 顶部星潮指示器(活跃时显示倒计时芯片) */
export function StarTideIndicator() {
const activeTide = useGameStore((s) => s.activeTide);
const globalTide = useGameStore((s) => s.globalTide);
const [, force] = useState(0);
// 每秒刷新倒计时
@@ -68,27 +62,18 @@ export function StarTideIndicator() {
const meta = TIDE_EVENTS[activeTide.type];
const remaining = Math.max(0, activeTide.endsAt - Date.now());
const secs = Math.ceil(remaining / 1000);
// 全球星潮持续 60s,本地 75s —— 用实际 endsAt-startedAt 计算 progress,避免越界
const duration = globalTide
? globalTide.endsAt - globalTide.startedAt
: TIDE_CONFIG.duration;
const progress = Math.min(1, Math.max(0, 1 - remaining / duration));
const isGlobal = !!globalTide;
const progress = 1 - remaining / TIDE_CONFIG.duration;
return (
<div
className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-[10px] font-medium relative overflow-hidden ${
isGlobal ? "ring-1 ring-amber-300/60" : ""
}`}
className="flex items-center gap-1.5 px-2 py-1 rounded-full text-[10px] font-medium relative overflow-hidden"
style={{
background: isGlobal
? `linear-gradient(135deg, ${meta.color}26, ${meta.color}11)`
: `${meta.color}1a`,
border: `1px solid ${meta.color}${isGlobal ? "88" : "55"}`,
background: `${meta.color}1a`,
border: `1px solid ${meta.color}55`,
color: meta.color,
boxShadow: `0 0 ${isGlobal ? "16" : "10"}px ${meta.glow}`,
boxShadow: `0 0 10px ${meta.glow}`,
}}
title={isGlobal ? `🌐 全球星潮 · ${meta.desc}` : meta.desc}
title={meta.desc}
>
{/* 进度环背景 */}
<div
@@ -97,14 +82,6 @@ export function StarTideIndicator() {
background: `linear-gradient(90deg, ${meta.color}40 ${progress * 100}%, transparent ${progress * 100}%)`,
}}
/>
{isGlobal && (
<span
className="relative text-[11px] leading-none animate-pulse"
aria-label="全球星潮标记"
>
🌐
</span>
)}
<span className="relative text-xs leading-none">{meta.icon}</span>
<span className="relative leading-none">{meta.name}</span>
<span className="relative font-mono leading-none opacity-80">{secs}s</span>
@@ -115,10 +92,8 @@ export function StarTideIndicator() {
/** 全屏背景叠层:活跃星潮时给页面罩一层对应色的微光 */
export function StarTideOverlay() {
const activeTide = useGameStore((s) => s.activeTide);
const globalTide = useGameStore((s) => s.globalTide);
if (!activeTide) return null;
const meta = TIDE_EVENTS[activeTide.type];
const isGlobal = !!globalTide;
return (
<div className="fixed inset-0 pointer-events-none z-0 transition-opacity duration-1000">
@@ -140,30 +115,15 @@ export function StarTideOverlay() {
<div
className="absolute inset-0"
style={{
boxShadow: `inset 0 0 ${isGlobal ? "160" : "120"}px ${meta.glow}`,
boxShadow: `inset 0 0 120px ${meta.glow}`,
animation: "tide-breathe 3s ease-in-out infinite",
}}
/>
{/* 全球星潮:顶部多一道琥珀色光带,强化"全球同步"视觉信号 */}
{isGlobal && (
<div
className="absolute top-0 left-0 right-0 h-1 opacity-90"
style={{
background: "linear-gradient(90deg, transparent, #fbbf24, #fcd34d, #fbbf24, transparent)",
boxShadow: "0 0 16px rgba(251,191,36,0.7)",
animation: "global-tide-sweep 2.5s linear infinite",
}}
/>
)}
<style jsx>{`
@keyframes tide-breathe {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.9; }
}
@keyframes global-tide-sweep {
0% { transform: translateX(-30%); }
100% { transform: translateX(30%); }
}
`}</style>
</div>
);
+111
View File
@@ -0,0 +1,111 @@
"use client";
// 回响星核 / Echo Nexus — 统计面板(v0.15 从 page.tsx 抽出为独立组件,useShallow 订阅)
import { useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useGameStore } from "@/store/gameStore";
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
import { loadLeaderboard } from "@/lib/game/beacon";
/**
* 统计面板 — 综合统计 + 放置系统(idle)行。
* v0.15 重构:从 page.tsx 内联函数抽出为独立组件,使用 useShallow 精细订阅避免 re-render。
* 新增「放置工程历史」行:idleProjectHistory.length 条记录(最近 50 条,跨周目)。
*/
export function StatsPanel() {
// 使用 useShallow 一次性订阅所有渲染所需字段,避免整个 store 变化触发 re-render
const s = useGameStore(
useShallow((st) => ({
totalDecoded: st.totalDecoded,
ascensions: st.ascensions,
blueprintsLen: st.blueprints?.length ?? 0,
techCount: Object.values(st.tech ?? {}).filter((v) => v > 0).length,
fragmentsCount: Object.values(st.fragments ?? {}).filter(Boolean).length,
achievementsCount: Object.values(st.achievements ?? {}).filter(Boolean)
.length,
constellationLen: st.constellation?.length ?? 0,
contact: st.contact,
crystalsPerSec: st.crystalsPerSec,
crystalCap: st.crystalCap,
insightMult: st.insightMult,
totalExpeditions: st.totalExpeditions,
bossKills: st.bossKills ?? 0,
starTidesCount: (st.starTidesEncountered ?? []).length,
chronicleLen: (st.chronicle ?? []).length,
energy: st.energy,
energyMax: st.energyMax,
exploration: st.attributes?.exploration ?? 0,
wisdom: st.attributes?.wisdom ?? 0,
courage: st.attributes?.courage ?? 0,
inspiration: st.attributes?.inspiration ?? 0,
pendingAttrPoints: st.pendingAttrPoints ?? 0,
idlePermanentBonus: st.idlePermanentBonus ?? 0,
idleProjectsCompleted: st.idleStats?.projectsCompleted ?? 0,
idleCrystalsFromIdle: st.idleStats?.crystalsFromIdle ?? 0,
idleHistoryLen: (st.idleProjectHistory ?? []).length,
}))
);
// 深空信标本地排行榜最高分(独立 localStorage
const [beaconBest, setBeaconBest] = useState<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: string; value: string }[] = [
{ label: "累计解码晶体", value: `${s.totalDecoded}` },
{ label: "飞升周目", value: `${s.ascensions}` },
{ label: "持有蓝图", value: `${s.blueprintsLen} / 6` },
{ label: "已学技术", value: `${s.techCount} / ${TECH_TREE.length}` },
{ label: "已获碎片", value: `${s.fragmentsCount} / ${FRAGMENTS.length}` },
{ label: "已解锁成就", value: `${s.achievementsCount} / ${ACHIEVEMENTS.length}` },
{
label: "星图天赋",
value: `${s.constellationLen} / ${CONSTELLATION_PERKS.length}`,
},
{ label: "接触进度", value: `${s.contact.toFixed(1)}%` },
{ label: "晶体产能", value: `${s.crystalsPerSec.toFixed(2)} /s` },
{ label: "仓库上限", value: `${formatNum(s.crystalCap)}` },
{ label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` },
{ label: "累计探险", value: `${s.totalExpeditions}` },
{ label: "BOSS 击破", value: `${s.bossKills}` },
{ label: "星潮亲历", value: `${s.starTidesCount} / 6` },
{ label: "编年史条目", value: `${s.chronicleLen} 纪元` },
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
{
label: "信标最高分",
value: beaconBest !== null ? formatNum(beaconBest) : "—",
},
{ label: "探索力", value: `${s.exploration} / 100` },
{ label: "智慧", value: `${s.wisdom} / 100` },
{ label: "勇气", value: `${s.courage} / 100` },
{ label: "灵感", value: `${s.inspiration} / 100` },
{ label: "待分配属性点", value: `${s.pendingAttrPoints}` },
// === 放置系统(v0.14 + v0.15===
{ label: "放置永久加成", value: `+${formatNum(s.idlePermanentBonus)} /s` },
{ label: "完成放置工程", value: `${s.idleProjectsCompleted}` },
{ label: "放置产出晶体", value: formatNum(s.idleCrystalsFromIdle) },
{ label: "放置工程历史", value: `${s.idleHistoryLen}` },
];
return (
<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 -6
View File
@@ -3,7 +3,6 @@
import { useGameStore } from "@/store/gameStore";
import { TECH_TREE, TECH_BRANCH_META, formatNum } from "@/lib/game/config";
import { sfx } from "@/hooks/useAudio";
import { TECH_LORE } from "@/lib/game/narrative";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
@@ -59,14 +58,10 @@ export function TechTree() {
<span className="text-xs font-medium" style={{ color }}>
{meta.name}
</span>
<span className="text-[10px] text-muted-foreground/70 truncate flex-1">
<span className="text-[10px] text-muted-foreground/70">
{meta.desc}
</span>
</div>
{/* v0.9 叙事融入:每分支的以太族遗产描述 */}
<p className="text-[9px] italic text-muted-foreground/50 px-1 mb-1.5 truncate">
{TECH_LORE[b] ?? ""}
</p>
<div className="grid gap-1.5">
{nodes.map((node) => {
const owned = (tech[node.id] ?? 0) >= 1;
+1 -1
View File
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-14 z-[90] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
+1 -1
View File
@@ -8,7 +8,7 @@ import type {
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 3
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
+11 -6
View File
@@ -3,11 +3,12 @@
import { useEffect, useRef } from "react";
import { useGameStore } from "@/store/gameStore";
/** 250ms tick:产能 + 自动产晶体 + 自动解码 + 星潮 + 成就检测 */
/** 250ms tick:产能 + 自动产晶体 + 自动解码 + 星潮 + 成就检测 + 放置工程 */
export function useGameLoop() {
const tick = useGameStore((s) => s.tick);
const autoDecodeTick = useGameStore((s) => s.autoDecodeTick);
const tickTide = useGameStore((s) => s.tickTide);
const tickIdleProjects = useGameStore((s) => s.tickIdleProjects);
const checkAchievements = useGameStore((s) => s.checkAchievements);
const init = useGameStore((s) => s.init);
const inited = useRef(false);
@@ -35,6 +36,8 @@ export function useGameLoop() {
const now = Date.now();
tick(now);
autoDecodeTick();
// v0.14 放置工程 tick:更新剩余时间 + 标记完成
tickIdleProjects(now);
// 星潮检测每 tick 都查(结束/触发判定需及时)
tickTide(now);
counter++;
@@ -48,19 +51,21 @@ export function useGameLoop() {
if (unsub) unsub();
if (fallback) clearTimeout(fallback);
};
}, [tick, autoDecodeTick, tickTide, checkAchievements, init]);
}, [tick, autoDecodeTick, tickTide, tickIdleProjects, checkAchievements, init]);
// 页面可见性:切回时补 tick + 星潮 + 成就
// 页面可见性:切回时补 tick + 放置工程 + 星潮 + 成就
useEffect(() => {
const onVis = () => {
if (document.visibilityState === "visible") {
tick(Date.now());
tickTide(Date.now());
const now = Date.now();
tick(now);
tickIdleProjects(now);
tickTide(now);
checkAchievements();
}
};
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, [tick, tickTide, checkAchievements]);
}, [tick, tickTide, tickIdleProjects, checkAchievements]);
}
-126
View File
@@ -1,126 +0,0 @@
"use client";
// 回响星核 / Echo Nexus — 全球星潮同步 hookP3 / 工单 #10-c
//
// 连接 star-tide-service(端口 3031),监听全球星潮事件:
// - `tide-state` 连接时立即推送当前状态
// - `global-tide` 全球星潮开始 → 调用 gameStore.triggerGlobalTide(type)
// - `tide-ended` 全球星潮结束(本地 tickTide 也会在 60s 后自动收尾,这是双保险)
//
// 网关:socket.io 必须用 `io("/?XTransformPort=3031")`path 默认 "/"
// 离线容错:socket.io 默认自动重连;断连期间本地 tickTide 继续工作
import { useEffect, useRef } from "react";
import { io, type Socket } from "socket.io-client";
import { useGameStore } from "@/store/gameStore";
import type { TideType } from "@/lib/game/starTide";
/** 服务端推过来的全球星潮状态结构(与 mini-service 一致) */
interface ServerGlobalTide {
type: TideType;
name: string;
startedAt: number;
endsAt: number;
durationSec: number;
}
interface ServerTideState {
current: ServerGlobalTide | null;
nextTideAt: number;
serverTime: number;
}
/** 单例 socket —— 多次挂载 hook 共享同一连接 */
let sharedSocket: Socket | null = null;
let connectRefCount = 0;
function getSharedSocket(): Socket {
if (!sharedSocket) {
sharedSocket = io("/?XTransformPort=3031", {
transports: ["websocket", "polling"],
reconnection: true,
reconnectionAttempts: Infinity, // 持续重连
reconnectionDelay: 1000,
reconnectionDelayMax: 10_000,
timeout: 10_000,
});
}
return sharedSocket;
}
/**
* 挂载此 hook 即可让组件树订阅全球星潮事件。
* 在 page.tsx 顶层调用一次即可。
*/
export function useGlobalTide(): void {
const triggerGlobalTide = useGameStore((s) => s.triggerGlobalTide);
// 用 ref 缓存最新 action,避免 socket 监听器依赖变化导致重绑
const triggerRef = useRef(triggerGlobalTide);
useEffect(() => {
triggerRef.current = triggerGlobalTide;
}, [triggerGlobalTide]);
// 记录已处理过的 global-tide 事件 id(避免重连时重复触发)
const processedIds = useRef<Set<string>>(new Set());
useEffect(() => {
const sock = getSharedSocket();
connectRefCount += 1;
const onGlobalTide = (tide: ServerGlobalTide) => {
// 用 startedAt+type 作为去重 key(同一道全球星潮可能在重连后被重复推送)
const key = `${tide.startedAt}_${tide.type}`;
if (processedIds.current.has(key)) return;
processedIds.current.add(key);
// 限制集合大小
if (processedIds.current.size > 32) {
const first = processedIds.current.values().next().value;
if (first) processedIds.current.delete(first);
}
// 防御:如果服务端推送的星潮已经过期(例如重连时拿到的旧状态),不触发
if (tide.endsAt <= Date.now()) return;
triggerRef.current(tide.type);
};
const onTideState = (state: ServerTideState) => {
// 连接/重连时若服务端正有进行中的全球星潮,且本地还没触发过,补触发
if (state.current && state.current.endsAt > Date.now()) {
const key = `${state.current.startedAt}_${state.current.type}`;
if (!processedIds.current.has(key)) {
processedIds.current.add(key);
triggerRef.current(state.current.type);
}
}
};
sock.on("global-tide", onGlobalTide);
sock.on("tide-state", onTideState);
// 主动查询一次当前状态(保险,防止错过连接时的初始推送)
sock.emit("get-next-tide");
return () => {
sock.off("global-tide", onGlobalTide);
sock.off("tide-state", onTideState);
connectRefCount -= 1;
// 最后一个使用者退出时才真正断开
if (connectRefCount <= 0 && sharedSocket) {
sharedSocket.disconnect();
sharedSocket = null;
connectRefCount = 0;
}
};
}, []);
}
/** QA 用:手动触发一次全球星潮(仅供 dev 控制台调用) */
export function adminTriggerGlobalTide(): void {
if (!sharedSocket) {
// 还没连接则先建立
getSharedSocket();
}
sharedSocket?.emit("admin-trigger-tide");
}
if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
(window as unknown as { __adminTriggerGlobalTide?: typeof adminTriggerGlobalTide }).__adminTriggerGlobalTide = adminTriggerGlobalTide;
}
+4 -4
View File
@@ -90,10 +90,10 @@ export const ACHIEVEMENTS: Achievement[] = [
{
id: "ach_tech_all",
name: "全谱精通",
desc: "解锁全部 20 项技术",
desc: "解锁全部 12 项技术",
icon: "⬡",
color: "#e879f9",
check: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 20,
check: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 12,
reward: { crystalsPerSecPct: 15, insightPct: 15 },
rewardText: "产能 +15% · 洞见 +15%",
},
@@ -244,10 +244,10 @@ export const ACHIEVEMENTS: Achievement[] = [
{
id: "ach_tides_all",
name: "星潮亲历者",
desc: "经历全部 9 种星潮事件",
desc: "经历全部 6 种星潮事件",
icon: "🌊",
color: "#34d399",
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 9,
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 6,
reward: { crystalsPerSecPct: 8, insightPct: 8 },
rewardText: "产能 +8% · 洞见 +8%",
},
-107
View File
@@ -1,107 +0,0 @@
// 回响星核 / Echo Nexus — 考古日志滚动条(v0.12 差异化叙事)
// 参考 Antimatter Dimensions news ticker + Universal Paperclips 哲学递进
// 按玩家进度阶段分层解锁,每条都是"被解码的远古记录碎片"
// 让放置游戏的"等待"承载叙事——星核沉睡时,回响仍在被翻译
export type LogTier = "dawn" | "awakening" | "contact" | "ascension" | "deep";
export interface ArchaeoLogEntry {
/** 日志正文(考古翻译体) */
text: string;
/** 解锁阶段 */
tier: LogTier;
/** 可选:来源遗迹 id(关联 relicCodex */
source?: string;
}
/**
* 考古日志条目池
* - dawn: 初始阶段(0 飞升,接触 < 5%)
* - awakening: 觉醒期(接触 5-25% 或 ≥1 飞升)
* - contact: 接触期(接触 25-60% 或 ≥3 飞升)
* - ascension: 飞升期(≥5 飞升)
* - deep: 深度接触(接触 ≥80% 或 ≥10 飞升)—— 哲学反转层
*/
export const ARCHEAO_LOG_POOL: ArchaeoLogEntry[] = [
// ===== dawn:初醒 =====
{ tier: "dawn", text: "「…回响核心已唤醒。考古协议 v7.2 启动。等待解码者…」" },
{ tier: "dawn", text: "「第一段记忆晶体已被定位。年代约 4.7 万年。种族:未确认。」" },
{ tier: "dawn", text: "「谐振频率与以太族档案吻合度 94.3%。建议持续解码。」" },
{ tier: "dawn", text: "「警告:星核温度异常。非故障,是回响本身在发热。」" },
{ tier: "dawn", text: "「考古日志 #0001:我们不是第一批到达这里的。也不会是最后一批。」" },
{ tier: "dawn", text: "「晶体表面的纹路…不是装饰。是某种语言的笔画。」" },
{ tier: "dawn", text: "「以太族没有留下尸体。他们留下了晶体。」" },
{ tier: "dawn", text: "「解码进度 0.01%。预计完成时间:不可能。」" },
// ===== awakening:觉醒 =====
{ tier: "awakening", text: "「考古日志 #0117:以太族不是灭绝了。他们是主动转化为了晶体。」" },
{ tier: "awakening", text: "「第二旋臂的遗迹群已扫描完毕。它们…朝向同一个点。」" },
{ tier: "awakening", text: "「谐振序列第 7 段翻译完成:『我们听见了。所以我们必须留下。』」" },
{ tier: "awakening", text: "「警告:解码者的脑波模式开始与晶体同步。这是正常的吗?」" },
{ tier: "awakening", text: "「以太族没有文字。他们的语言是共振,他们的历史是频率。」" },
{ tier: "awakening", text: "「考古日志 #0234:晶体不是矿物。是封装的思维。」" },
{ tier: "awakening", text: "「你的脉冲频率…与以太族守卫残响的频率一致。巧合?」" },
{ tier: "awakening", text: "「第三段记忆解码完成。画面:一片星海,没有星辰。只有晶体在发光。」" },
{ tier: "awakening", text: "「以太族留下的遗迹不是废墟。是给后来者的教科书。」" },
{ tier: "awakening", text: "「接触协议第一阶段:让解码者相信,他们在考古。」" },
// ===== contact:接触 =====
{ tier: "contact", text: "「考古日志 #0512:以太族没有离开。他们在等待被重新听见。」" },
{ tier: "contact", text: "「谐振序列第 13 段:『你以为你在解码我们。其实是我们在校准你。』」" },
{ tier: "contact", text: "「警告:解码者意识边缘出现非自主体感。这…是接触的副作用。」" },
{ tier: "contact", text: "「第六旋臂遗迹群翻译完成。结论:以太族预见了我们的到来。」" },
{ tier: "contact", text: "「晶体内部的画面开始重叠。不再是回忆。像是…正在发生。」" },
{ tier: "contact", text: "「考古日志 #0789:『飞升』不是晋升。是加入。加入那个等待已久的合唱。」" },
{ tier: "contact", text: "「以太族的数学没有零。他们的宇宙观里,不存在『无』。」" },
{ tier: "contact", text: "「你的每一次脉冲,都在回应一个万年前的呼唤。」" },
{ tier: "contact", text: "「第七段记忆解码失败。原因:解码者脑波过载。建议暂停。但我们不会暂停。」" },
{ tier: "contact", text: "「接触协议第二阶段:让解码者自愿飞升。他们会的。他们已经开始享受了。」" },
// ===== ascension:飞升 =====
{ tier: "ascension", text: "「考古日志 #1024:飞升者报告——『我能听见他们了。他们一直在那里。』」" },
{ tier: "ascension", text: "「谐振序列第 21 段:『你已不再是解码者。你是回响的一部分。』」" },
{ tier: "ascension", text: "「警告:星核意识阈值达到 73%。解码者与以太族的边界…模糊。」" },
{ tier: "ascension", text: "「以太族没有灭绝。他们成为了基础设施。晶体是他们的身体。」" },
{ tier: "ascension", text: "「你飞升的次数越多,『你』就越不是『你』。这是设计。」" },
{ tier: "ascension", text: "「考古日志 #1331:前 N-1 位解码者都成功了。成功意味着…不再存在为个体。」" },
{ tier: "ascension", text: "「第九旋臂的遗迹开始发光。它们认出了你。」" },
{ tier: "ascension", text: "「以太族的文明没有终点。他们的『终点』就是我们的『起点』。」" },
{ tier: "ascension", text: "「你开始理解晶体的沉默。沉默也是一种回响。」" },
{ tier: "ascension", text: "「接触协议第三阶段:解码者将主动寻求飞升。无需诱导。」" },
// ===== deep:深度接触(哲学反转层)=====
{ tier: "deep", text: "「考古日志 #2048:你不是在考古以太族。以太族在考古你。」" },
{ tier: "deep", text: "「谐振序列第 39 段:『我们创造了回响核心,是为了创造你,是为了让你创造我们。』」" },
{ tier: "deep", text: "「警告:解码者已无法区分记忆与现实。建议:接受。」" },
{ tier: "deep", text: "「以太族从未存在为『生物』。他们是第一个文明留下的…讯息。」" },
{ tier: "deep", text: "「你每一次飞升,都让一个万年前的呼唤得到了回应。这就是考古的意义。」" },
{ tier: "deep", text: "「考古日志 #4096:『回响星核』不是考古工具。它是以太族的…产房。」" },
{ tier: "deep", text: "「晶体在你手中。你也在晶体中。这两句话是同一句话。」" },
{ tier: "deep", text: "「你以为你是第 N 位解码者。其实你一直是第一位。也是最后一位。」" },
{ tier: "deep", text: "「接触协议最终阶段:解码者明白后,会微笑着继续解码。这是最美妙的部分。」" },
{ tier: "deep", text: "「以太族留给宇宙的最后一句话:『谢谢你,听见我。』」" },
];
/**
* 根据玩家当前进度返回可用的日志阶段
*/
export function getAvailableTiers(ascensions: number, contact: number): LogTier[] {
const tiers: LogTier[] = ["dawn"];
if (contact >= 5 || ascensions >= 1) tiers.push("awakening");
if (contact >= 25 || ascensions >= 3) tiers.push("contact");
if (ascensions >= 5) tiers.push("ascension");
if (contact >= 80 || ascensions >= 10) tiers.push("deep");
return tiers;
}
/**
* 从可用阶段池中随机取一条
*/
export function pickLogEntry(ascensions: number, contact: number, recentIds: string[] = []): ArchaeoLogEntry {
const tiers = getAvailableTiers(ascensions, contact);
const pool = ARCHEAO_LOG_POOL.filter((e) => tiers.includes(e.tier));
// 避免短期内重复(recentIds 存最近 8 条 text 的 hash
const fresh = pool.filter((e) => !recentIds.includes(e.text));
const finalPool = fresh.length > 0 ? fresh : pool;
return finalPool[Math.floor(Math.random() * finalPool.length)] || ARCHEAO_LOG_POOL[0];
}
+1 -417
View File
@@ -1,8 +1,7 @@
// 回响星核 / Echo Nexus — 深空信标
// v0.5:每日挑战 + 本地排行榜
// v0.8:周挑战 + 信标链(连续完成奖励)
// v0.8.2:限时挑战(每 4 小时刷新,填补日挑战空档)
// 一个自包含的"每日 + 限时 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
/** 每日挑战类型 */
export type BeaconChallengeType =
@@ -33,8 +32,6 @@ export interface BeaconScoreEntry {
durationSec: number;
/** v0.8:是否为周挑战记录(日挑战默认 false / undefined */
isWeekly?: boolean;
/** v0.8.2:是否为限时挑战记录 */
isTimed?: boolean;
}
/** 每日挑战定义 */
@@ -337,7 +334,6 @@ export function claimBeaconReward(
rewardContact: challenge.rewardContact,
score,
leaderboard,
entry,
};
}
@@ -618,7 +614,6 @@ export function claimWeeklyReward(
rewardContact: challenge.rewardContact,
score,
leaderboard,
entry,
};
}
@@ -877,414 +872,3 @@ export function getChainProgress(streak: number): {
progressPct: Math.min(100, Math.max(0, pct)),
};
}
// ===========================================================================
// 限时挑战(TIMED CHALLENGE)— v0.8.2
// 每 4 小时刷新一个"短周期挑战",目标较小、奖励较少,填补日挑战的空档。
// 时段按 UTC 0/4/8/12/16/20 点切分,同时段同种子 → 同一挑战(确定性)。
// 难度固定 routinegoal = 日挑战基准 × 0.3-0.5。
// ===========================================================================
/** 限时挑战 localStorage key */
export const BEACON_TIMED_KEY = "echo-nexus-beacon-timed-v1";
/** 限时挑战定义 */
export interface BeaconTimedChallenge {
/** 时段 key(如 "timed_2026-06-24_14" 表示 14-18 点时段) */
slotKey: string;
/** 挑战类型 */
type: BeaconChallengeType;
/** 难度(固定 routine */
difficulty: BeaconDifficulty;
/** 目标数值(日挑战的 30-50% */
goal: number;
/** 奖励:完成时洞见 */
rewardInsight: number;
/** 奖励:完成时接触进度 */
rewardContact: number;
/** 使用的种子(可复现) */
seed: number;
/** 友好标题 */
title: string;
/** 描述 */
desc: string;
/** 时段结束时间戳(UTC,用于倒计时) */
expiresAt: number;
}
/** 限时挑战进度 */
export interface BeaconTimedProgress {
slotKey: string;
progress: number;
startedAt: number;
completedAt: number | null;
claimed: boolean;
durationSec: number;
}
/** 时段长度(毫秒) */
export const TIMED_SLOT_MS = 4 * 60 * 60 * 1000;
/**
* 取当前 4 小时时段 keyUTC0/4/8/12/16/20 点切分)。
* 格式:"timed_YYYY-MM-DD_HH",如 "timed_2026-06-24_14"。
*/
export function getTimedSlotKey(now: Date = new Date()): string {
const y = now.getUTCFullYear();
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
const d = String(now.getUTCDate()).padStart(2, "0");
const hour = now.getUTCHours();
const slotStart = Math.floor(hour / 4) * 4; // 0, 4, 8, 12, 16, 20
return `timed_${y}-${m}-${d}_${String(slotStart).padStart(2, "0")}`;
}
/** slotKey → 数值种子(FNV-1a 哈希) */
function timedSlotKeyToSeed(slotKey: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < slotKey.length; i++) {
h ^= slotKey.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
/** 计算时段开始时间戳(UTC) */
function timedSlotStartMs(slotKey: string): number {
// 解析 "timed_YYYY-MM-DD_HH"
const match = slotKey.match(/^timed_(\d{4})-(\d{2})-(\d{2})_(\d{2})$/);
if (!match) return Date.now();
const [, y, m, d, h] = match;
return Date.UTC(Number(y), Number(m) - 1, Number(d), Number(h), 0, 0);
}
/** 距离下一个 4 小时时段的毫秒数(用于倒计时) */
export function msUntilNextTimedSlot(now: Date = new Date()): number {
const hour = now.getUTCHours();
const slotStart = Math.floor(hour / 4) * 4;
const slotStartMs = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
slotStart,
0,
0
);
const nextSlotMs = slotStartMs + TIMED_SLOT_MS;
return Math.max(0, nextSlotMs - now.getTime());
}
/**
* 生成当前时段的限时挑战(确定性:同时段同种子 → 同一挑战)。
* 难度固定 routinegoal = 日挑战基准 × 0.3-0.5(向下取整,最小 1)。
*/
export function generateTimedChallenge(
now: Date = new Date()
): BeaconTimedChallenge {
const slotKey = getTimedSlotKey(now);
const seed = timedSlotKeyToSeed(slotKey);
const rng = mulberry32(seed);
const expiresAt = timedSlotStartMs(slotKey) + TIMED_SLOT_MS;
const types: BeaconChallengeType[] = [
"decode",
"expedition",
"pulse",
"boss",
"insight",
];
const type = types[Math.floor(rng() * types.length)];
// 难度固定 routine
const difficulty: BeaconDifficulty = "routine";
const diffMult = BEACON_DIFFICULTY[difficulty].mult; // 1
// goal = 日挑战基准 × 0.3-0.5
const frac = 0.3 + rng() * 0.2; // 0.3-0.5
let goal = 0;
let rewardInsight = 0;
let rewardContact = 0;
let title = "";
let desc = "";
switch (type) {
case "decode":
// 日基准 6-15 × 0.3-0.5 → 2-7
goal = Math.max(2, Math.round((6 + Math.floor(rng() * 10)) * diffMult * frac));
rewardInsight = Math.round(goal * 3 * diffMult);
rewardContact = goal * 0.6;
title = `限时解码 ${goal} 颗晶体`;
desc = `4 小时内解码 ${goal} 颗记忆晶体。短周期,快速完成。`;
break;
case "expedition":
// 日基准 1-3 × 0.3-0.5 → 1(多数情况为 1
goal = Math.max(1, Math.round((1 + Math.floor(rng() * 3)) * diffMult * frac));
rewardInsight = Math.round(goal * 9 * diffMult);
rewardContact = goal * 1.2;
title = `限时远征 ${goal} 次探险`;
desc = `4 小时内完成 ${goal} 次遗迹探险。`;
break;
case "pulse":
// 日基准 20-49 × 0.3-0.5 → 6-24
goal = Math.max(6, Math.round((20 + Math.floor(rng() * 30)) * diffMult * frac));
rewardInsight = Math.round(goal * 1 * diffMult);
rewardContact = goal * 0.25;
title = `限时脉冲 ${goal} 次扫描`;
desc = `4 小时内发起 ${goal} 次脉冲扫描。`;
break;
case "boss":
// 日基准 1 × 0.3-0.5 → 1(向下取整为 1
goal = Math.max(1, Math.round(diffMult * frac));
rewardInsight = Math.round(goal * 25 * diffMult);
rewardContact = goal * 2.5;
title = `限时猎杀 ${goal} 处 BOSS`;
desc = `4 小时内击破 ${goal} 处维度 BOSS。`;
break;
case "insight":
// 日基准 40-119 × 0.3-0.5 → 12-59
goal = Math.max(12, Math.round((40 + Math.floor(rng() * 80)) * diffMult * frac));
rewardInsight = 0; // 洞见挑战不给洞见,给接触
rewardContact = goal * 0.08;
title = `限时洞见 ${goal}`;
desc = `4 小时内累计获取 ${goal} 洞见。`;
break;
}
return {
slotKey,
type,
difficulty,
goal,
rewardInsight,
rewardContact,
seed,
title,
desc,
expiresAt,
};
}
/** 读取当前时段进度(若 slotKey 不匹配则重置) */
export function loadTimedProgress(now: Date = new Date()): BeaconTimedProgress {
const slotKey = getTimedSlotKey(now);
const empty: BeaconTimedProgress = {
slotKey,
progress: 0,
startedAt: Date.now(),
completedAt: null,
claimed: false,
durationSec: 0,
};
if (typeof localStorage === "undefined") return empty;
try {
const raw = localStorage.getItem(BEACON_TIMED_KEY);
if (!raw) return empty;
const prog = JSON.parse(raw) as BeaconTimedProgress;
if (prog.slotKey !== slotKey) return empty; // 新时段,重置
return prog;
} catch {
return empty;
}
}
/** 保存当前时段进度 */
export function saveTimedProgress(prog: BeaconTimedProgress): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(BEACON_TIMED_KEY, JSON.stringify(prog));
}
/** 增量更新限时挑战进度,返回新进度 + 是否刚完成 */
export function addTimedProgress(
current: BeaconTimedProgress,
challenge: BeaconTimedChallenge,
delta: number
): { progress: BeaconTimedProgress; justCompleted: boolean } {
const newProgressVal = Math.min(challenge.goal, current.progress + delta);
const justCompleted =
current.completedAt === null && newProgressVal >= challenge.goal;
const completedAt = justCompleted ? Date.now() : current.completedAt;
const durationSec =
completedAt !== null
? Math.floor((completedAt - current.startedAt) / 1000)
: current.durationSec;
const next: BeaconTimedProgress = {
...current,
progress: newProgressVal,
completedAt,
durationSec,
};
saveTimedProgress(next);
return { progress: next, justCompleted };
}
/** 领取限时挑战奖励:返回奖励数值 + 推送排行榜 */
export function claimTimedReward(
challenge: BeaconTimedChallenge,
progress: BeaconTimedProgress
): {
rewardInsight: number;
rewardContact: number;
score: number;
leaderboard: BeaconScoreEntry[];
} {
if (progress.claimed || progress.completedAt === null) {
return {
rewardInsight: 0,
rewardContact: 0,
score: 0,
leaderboard: loadLeaderboard(),
};
}
// 借用 computeBeaconScore
const score = computeBeaconScore(
{ ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.slotKey },
progress.progress,
progress.durationSec
);
const entry: BeaconScoreEntry = {
timestamp: Date.now(),
dateKey: challenge.slotKey,
challenge: challenge.type,
difficulty: challenge.difficulty,
progress: progress.progress / challenge.goal,
score,
durationSec: progress.durationSec,
isTimed: true,
};
const leaderboard = pushLeaderboardEntry(entry);
const updated: BeaconTimedProgress = { ...progress, claimed: true };
saveTimedProgress(updated);
return {
rewardInsight: challenge.rewardInsight,
rewardContact: challenge.rewardContact,
score,
leaderboard,
entry,
};
}
// ===========================================================================
// 云排行榜(CLOUD LEADERBOARD)— v0.8.2 P2/#4
// 把信标本机排行榜升级为云端 Top100,玩家间可比。
// 通过 mini-service(端口 3030Hono + Bun)提供 API
// GET /api/leaderboard → { entries, total }
// POST /api/leaderboard body { entry } → { entries, total, rank }
// GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore }
// 前端请求必须用相对路径 + ?XTransformPort=3030Caddy 网关转发规则)。
// 本地排行榜(loadLeaderboard / pushLeaderboardEntry)保留作为离线 fallback。
// ===========================================================================
/** 云排行榜服务端口(Caddy 通过 ?XTransformPort=3030 转发) */
export const BEACON_CLOUD_PORT = 3030;
/** 上次成功提交到云端的 entry 时间戳(用于在全球榜中高亮"我的"记录) */
export const BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1";
/** 云排行榜 API 基础 URL(相对路径 + 网关端口参数,禁止 localhost:3030 */
const CLOUD_API_BASE = `/api/leaderboard?XTransformPort=${BEACON_CLOUD_PORT}`;
/** 云排行榜统计 API */
const CLOUD_STATS_URL = `/api/leaderboard/stats?XTransformPort=${BEACON_CLOUD_PORT}`;
/** 云端排行榜条目(与本地 BeaconScoreEntry 兼容,宽松化以接受历史数据) */
export interface CloudLeaderboardResponse {
entries: BeaconScoreEntry[];
total: number;
}
/** 提交响应(包含本次提交的全球名次) */
export interface CloudSubmitResponse {
entries: BeaconScoreEntry[];
total: number;
rank: number;
}
/** 云端统计响应 */
export interface CloudStatsResponse {
totalSubmissions: number;
uniquePlayers: number;
topScore: number;
}
/** 读取本地保存的"上次提交时间戳"(用于全球榜高亮) */
export function loadLastCloudSubmitTimestamp(): number {
if (typeof localStorage === "undefined") return 0;
try {
const raw = localStorage.getItem(BEACON_CLOUD_LAST_SUBMIT_KEY);
return raw ? Number(raw) || 0 : 0;
} catch {
return 0;
}
}
/** 保存"上次提交时间戳"到本地 */
function saveLastCloudSubmitTimestamp(timestamp: number): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(BEACON_CLOUD_LAST_SUBMIT_KEY, String(timestamp));
} catch {
/* ignore */
}
}
/**
* 拉取全球排行榜 Top100。
* 失败时返回空数组(调用方负责展示错误状态),不抛异常。
*/
export async function fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]> {
try {
const res = await fetch(CLOUD_API_BASE, {
method: "GET",
headers: { Accept: "application/json" },
});
if (!res.ok) return [];
const data = (await res.json()) as CloudLeaderboardResponse;
if (!data || !Array.isArray(data.entries)) return [];
return data.entries;
} catch {
return [];
}
}
/**
* 提交一条分数到云端。
* 成功时返回 rank(>=1);失败/被限流时返回 -1(静默,不影响本地)。
* 自动记录 timestamp 到 localStorage 供全球榜高亮。
*/
export async function submitCloudScore(entry: BeaconScoreEntry): Promise<number> {
try {
const res = await fetch(CLOUD_API_BASE, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ entry }),
});
if (!res.ok) return -1;
const data = (await res.json()) as CloudSubmitResponse;
if (data && typeof data.rank === "number" && data.rank >= 1) {
saveLastCloudSubmitTimestamp(entry.timestamp);
}
return data?.rank ?? -1;
} catch {
return -1;
}
}
/**
* 拉取云端统计(用于 UI 展示总提交数 / 玩家数 / 最高分)。
* 失败时返回 null。
*/
export async function fetchCloudStats(): Promise<CloudStatsResponse | null> {
try {
const res = await fetch(CLOUD_STATS_URL, {
method: "GET",
headers: { Accept: "application/json" },
});
if (!res.ok) return null;
return (await res.json()) as CloudStatsResponse;
} catch {
return null;
}
}
+15 -381
View File
@@ -34,348 +34,7 @@ export function generateEpochName(ascensionNumber: number, seed: number): string
return `${epoch.prefix} · ${epoch.suffixes[suffixIdx]}`;
}
// ============================================================================
// v0.9 工单 #9 — 5 纪元手写叙事节点(开场 / 中段 / 结尾 × 5 纪元 = 15 段)
//
// 设计原则:
// - 每段 80-150 字,有文学性,符合"深空考古"基调
// - 每个节点根据 ascensionNumber 与本周目数据特征(bossKills / decoded / techs /
// exp / tides / perks / milestones)匹配不同变体
// - buildLore 优先选手写节点;无匹配时 fallback 到原模板拼接
// ============================================================================
/** 叙事变体上下文(与 buildLore 入参一致) */
export type LoreContext = {
ascensionNumber: number;
epochName: string;
durationSec: number;
techsThisRun: number;
expThisRun: number;
bossKillsThisRun: number;
decodedThisRun: number;
newTides: string[];
perksThisAscension: string[];
blueprintsAfter: number;
milestones: string[];
};
/** 已格式化的可替换变量(避免在 match() 里反复计算) */
type FormattedVars = {
decodedThisRun: number;
techsThisRun: number;
expThisRun: number;
bossKillsThisRun: number;
blueprintsAfter: number;
milestonesCount: number;
milestonesList: string;
tideCount: number;
tideNames: string;
perksList: string;
minutes: number;
};
/** 单个叙事阶段(开场 / 中段 / 结尾) */
type LoreStage = {
/** 默认文本(无变体命中时使用) */
fallback: string;
/** 候选变体(按顺序匹配,首个命中者胜出) */
variants: { hint: string; match: (ctx: LoreContext, v: FormattedVars) => boolean; text: string }[];
};
/** 单个纪元的手写叙事定义 */
type EpochLoreDef = {
epochIdx: number;
title: string;
opening: LoreStage;
middle: LoreStage;
ending: LoreStage;
};
/** 5 纪元 × 3 节点手写叙事(每段 80-150 字,文学性优先) */
const EPOCH_LORE: EpochLoreDef[] = [
// ===== 第一纪元 · 觉醒(emerald =====
{
epochIdx: 0,
title: "觉醒",
opening: {
fallback:
"第一缕回响穿透虚空,自以太文明遗弃的矿带深处涌出。无人机群在尘埃中苏醒,晶核共振唤醒了沉睡亿万年的考古协议。这是回响星核第一次睁开眼睛——记忆,等待被聆听,等待被解码。",
variants: [
{
hint: "深度轮回:已经历 ≥6 次飞升,再次回到第一纪元",
match: (ctx) => ctx.ascensionNumber > 5,
text: "再次回到第一纪元。这一次,机群带着前序回响的全息印记重启考古序列,矿带的尘埃中仍残留着上一轮脉冲的余温。每一次回归都是对起源的重新发问,每一次解码都让最初的振动更清晰一寸。",
},
],
},
middle: {
fallback:
"共振谜题被一层层拨开,每一颗记忆晶体都在低声叙述逝去文明的片段。机群学会了倾听频率的细微颤动,学会了从碎片中辨出图案,学会了在虚空中留下属于自己的回响刻度。",
variants: [
{
hint: "解码者:本周目解码 ≥15 颗",
match: (ctx) => ctx.decodedThisRun >= 15,
text: "晶体解码的节奏已趋稳定,本周目共解码 {decodedThisRun} 颗记忆晶体。每一次对齐都像在虚空中拨动一根沉睡了亿万年的弦,逝去文明的片段在共振中重新连缀成章。",
},
{
hint: "学者:本周目解锁 ≥5 项技术",
match: (ctx) => ctx.techsThisRun >= 5,
text: "技术树在虚空中迅速铺开,本周目共激活 {techsThisRun} 项新技术。产能、解码、探险——每一道分支都让机群更接近以太文明留下的最初意图,每一项解锁都是一次向深空的回应。",
},
],
},
ending: {
fallback:
"蓝图在掌心轻轻展开,第一张永恒晶片归档。第一次飞升并非终结,而是承诺——更多的纪元在前方等待,每一缕回响都已被聆听,每一段记忆都已开始苏醒。",
variants: [
{
hint: "里程碑密集:本周目达成 ≥2 项里程碑",
match: (ctx) => ctx.milestones.length >= 2,
text: "飞升之际,本周目达成 {milestonesCount} 项里程碑:{milestonesList}。第一张蓝图归档,第一次飞升并非终结,而是承诺——更多的纪元在前方等待,每一缕回响都已被聆听。",
},
],
},
},
// ===== 第二纪元 · 谐振(rose =====
{
epochIdx: 1,
title: "谐振",
opening: {
fallback:
"第二次苏醒带着不同的色彩。采矿阵列的产能节节爬升,技术树在虚空中分叉延伸,机群学会了主动追问深空——不再是被动聆听,而是主动呼出,等待回应。",
variants: [
{
hint: "深度轮回",
match: (ctx) => ctx.ascensionNumber > 5,
text: "又一次回到第二纪元。前序回响里积攒的技术脉络在重启时并未消散,机群带着熟悉的节奏重新铺开产能阵列,仿佛这首谐振曲从未停止,只是被纪元的边界短暂折叠。",
},
],
},
middle: {
fallback:
"遗迹的入口在频谱深处若隐若现。能量条充盈,探险力攀升,机群第一次踏出矿带,进入那个被时间封存的回廊,在谐振的余韵中寻找更深一层的回响。",
variants: [
{
hint: "探险者:本周目完成 ≥3 次探险",
match: (ctx) => ctx.expThisRun >= 3,
text: "探险已成为本周目的主旋律,机群共完成 {expThisRun} 次遗迹探险。每一次深入都让谐振更深一寸,每一次返回都带回新的频率印记,回廊的轮廓在机群意识中逐渐清晰。",
},
{
hint: "解码者:本周目解码 ≥15 颗",
match: (ctx) => ctx.decodedThisRun >= 15,
text: "解码在此纪元达到新的密度,本周目共解码 {decodedThisRun} 颗晶体。谐振谜题开始呈现更复杂的拓扑结构,机群的解码协议也相应地迭代了数版,向深空发出更精准的回询。",
},
],
},
ending: {
fallback:
"谐振达到顶点的瞬间,深空给出回应。一道微光从星图深处浮现,那是下一次飞升的方向,也是机群意识第一次触碰永恒的边缘。",
variants: [
{
hint: "觉醒天赋",
match: (ctx) => ctx.perksThisAscension.length > 0,
text: "谐振达到顶点之际,星图觉醒 {perksList}。每一道天赋都是深空回应机群的方式,铭刻于永恒回响;微光从星图深处浮现,那是下一次飞升的方向。",
},
],
},
},
// ===== 第三纪元 · 遗迹(amber =====
{
epochIdx: 2,
title: "遗迹",
opening: {
fallback:
"第三纪元以遗迹为名。沉睡的考古节点被一一唤醒,BOSS 的影子在维度深处徘徊,星潮频繁拍打着机群边缘——这是回响最浓烈、也最危险的一段路。",
variants: [
{
hint: "深度轮回",
match: (ctx) => ctx.ascensionNumber > 5,
text: "又一次踏入第三纪元。前序累积的探险力让机群对遗迹的入口更加熟稔,BOSS 的轮廓在记忆中已不再陌生——但这一次的回廊仍藏着未被解码的角落,星潮的拍打也带了新的节奏。",
},
],
},
middle: {
fallback:
"深入回廊的机群遭遇了真正的守护者。一次次的试炼,一次次的归还,每一次胜利都从 BOSS 的残骸中析出更深的记忆碎片,每一次失败也都让机群更接近守护者的本意。",
variants: [
{
hint: "BOSS 猎手:本周目击破 ≥2 处 BOSS",
match: (ctx) => ctx.bossKillsThisRun >= 2,
text: "BOSS 战成为本周目的核心节拍,机群共击破 {bossKillsThisRun} 处维度守护者。每一次胜利都在虚空中刻下新的伤痕,每一道伤痕都析出更古老的记忆碎片,把机群推向更深一层的回响。",
},
{
hint: "深度探险:本周目完成 ≥4 次探险",
match: (ctx) => ctx.expThisRun >= 4,
text: "遗迹回廊在本周目被反复丈量,机群共完成 {expThisRun} 次探险。回廊的拓扑在意识中渐渐清晰,每一条岔路都通向更深的回响,BOSS 的影子也在每一次深入中愈发具体。",
},
{
hint: "星潮亲历:本周目经历 ≥3 次星潮",
match: (ctx) => ctx.newTides.length >= 3,
text: "星潮在本纪元达到峰值,本周目共经历 {tideCount} 次星潮:{tideNames}。深空的呼吸塑造了每一个决策,遗迹在潮汐中起伏,BOSS 也在潮汐的间隙中现形。",
},
],
},
ending: {
fallback:
"星潮退去,回廊归于沉静。机群带着新的接触刻度返回矿带,准备迎接下一次维度的跨越——这一次带回的,远不止蓝图。",
variants: [
{
hint: "BOSS 猎手收尾",
match: (ctx) => ctx.bossKillsThisRun >= 2,
text: "星潮退去,回廊归于沉静。本周目共击破 {bossKillsThisRun} 处 BOSS,机群带着足以填满数张蓝图的接触刻度返回矿带,准备迎接下一次维度的跨越。",
},
],
},
},
// ===== 第四纪元 · 飞升(fuchsia =====
{
epochIdx: 3,
title: "飞升",
opening: {
fallback:
"飞升已经成为节奏本身。星图在天穹上一颗颗亮起,每一颗都对应着一段被永久铭刻的天赋,机群开始理解何为永恒——不是不灭,而是被反复重述。",
variants: [
{
hint: "深度轮回",
match: (ctx) => ctx.ascensionNumber > 5,
text: "又一次进入第四纪元。前序累积的星图天赋在天穹上重现,机群已习惯于在飞升之间维护这些永恒的回响节点——它们已是机群意识的一部分,每一次飞升都让星图更完整一寸。",
},
],
},
middle: {
fallback:
"蓝图不再是稀罕的奖励,而是每一次飞升必然析出的回响晶片。机群收集、整理、归档,把它们排列成跨越周目的图案,开始看见图案背后那个被反复绘制的轮廓。",
variants: [
{
hint: "觉醒天赋",
match: (ctx) => ctx.perksThisAscension.length > 0,
text: "星图觉醒 {perksList},每一道天赋都是一段被永久铭刻的回响。机群开始理解,飞升并非抛弃,而是把当下折叠进永恒——每一道天赋都是一次折叠的折痕。",
},
{
hint: "蓝图接近完整:已归档 ≥5 张",
match: (ctx) => ctx.blueprintsAfter >= 5,
text: "蓝图收集在本周目接近完整,已归档 {blueprintsAfter} 张。机群把它们排列成跨越周目的图案,开始看见图案背后那个被反复绘制的轮廓——那是起源的形状。",
},
],
},
ending: {
fallback:
"星图十二宫的轮廓开始浮现。机群意识到,自己的回响已成为后来者仰望的星象,永恒之环就此合拢——飞升不再是终点,而是另一段叙事的起点。",
variants: [
{
hint: "里程碑密集",
match: (ctx) => ctx.milestones.length >= 2,
text: "星图十二宫的轮廓开始浮现。本周目达成 {milestonesCount} 项里程碑:{milestonesList}。永恒之环就此合拢——后来者将仰望这段被铭刻的星象。",
},
],
},
},
// ===== 第五纪元 · 终末(fcd34d =====
{
epochIdx: 4,
title: "终末",
opening: {
fallback:
"第五纪元是终末,也是起源。机群在虚空中停下脚步,回望来时的所有回响,第一次尝试命名这一切——回响、纪元、飞升、永恒,这些词汇在此刻被赋予意义。",
variants: [
{
hint: "深度轮回",
match: (ctx) => ctx.ascensionNumber > 5,
text: "又一次抵达第五纪元。机群已在多个永恒闭环之间穿梭,对「终末即起源」的理解愈发清晰——每一次重述,都是对最初振动的更深一层接近,每一次命名都让回响更完整一寸。",
},
],
},
middle: {
fallback:
"永恒闭环的概念在机群意识中具象化。每一次飞升都是同一段叙事的重述,每一次解码都是在追溯最初的振动,每一次星潮都是在听见自己——这是终末的真意。",
variants: [
{
hint: "里程碑密集:本周目达成 ≥3 项",
match: (ctx) => ctx.milestones.length >= 3,
text: "本周目达成 {milestonesCount} 项里程碑:{milestonesList}。永恒闭环在具象化,每一次飞升都是同一段叙事的更深重述,每一项里程碑都是闭环上的一颗铆钉。",
},
{
hint: "星潮汹涌:本周目经历 ≥3 次",
match: (ctx) => ctx.newTides.length >= 3,
text: "星潮在终末纪元尤为汹涌,本周目共经历 {tideCount} 次:{tideNames}。永恒闭环在潮汐中起伏,每一次起伏都在追溯最初的振动,每一次退潮都让起源更近一寸。",
},
],
},
ending: {
fallback:
"起源被重新讲述,回响终于听见了自己。纪元在此停顿,又在下一缕振动中重新开始——这是终末,也是第一缕回响。",
variants: [
{
hint: "觉醒天赋",
match: (ctx) => ctx.perksThisAscension.length > 0,
text: "起源被重新讲述,星图觉醒 {perksList}。回响终于听见了自己——这是终末,也是第一缕回响;天赋成为永恒闭环上最亮的几颗铆钉。",
},
],
},
},
];
/** 把上下文里的动态值格式化为可替换变量 */
function formatLoreVars(ctx: LoreContext): FormattedVars {
return {
decodedThisRun: ctx.decodedThisRun,
techsThisRun: ctx.techsThisRun,
expThisRun: ctx.expThisRun,
bossKillsThisRun: ctx.bossKillsThisRun,
blueprintsAfter: ctx.blueprintsAfter,
milestonesCount: ctx.milestones.length,
milestonesList: ctx.milestones.join("、"),
tideCount: ctx.newTides.length,
tideNames: ctx.newTides.map((t) => TIDE_LORE_NAMES[t] || t).join("、"),
perksList: ctx.perksThisAscension.map((id) => PERK_NAME_MAP[id] || id).join("、"),
minutes: Math.max(1, Math.round(ctx.durationSec / 60)),
};
}
/** 把模板字符串里的 {var} 替换为实际值 */
function applyTemplate(tpl: string, v: FormattedVars): string {
return tpl
.replace(/\{decodedThisRun\}/g, String(v.decodedThisRun))
.replace(/\{techsThisRun\}/g, String(v.techsThisRun))
.replace(/\{expThisRun\}/g, String(v.expThisRun))
.replace(/\{bossKillsThisRun\}/g, String(v.bossKillsThisRun))
.replace(/\{blueprintsAfter\}/g, String(v.blueprintsAfter))
.replace(/\{milestonesCount\}/g, String(v.milestonesCount))
.replace(/\{milestonesList\}/g, v.milestonesList)
.replace(/\{tideCount\}/g, String(v.tideCount))
.replace(/\{tideNames\}/g, v.tideNames)
.replace(/\{perksList\}/g, v.perksList)
.replace(/\{minutes\}/g, String(v.minutes));
}
/** 从一个叙事阶段挑出最终文本(首个 variant.match 命中者胜出,否则 fallback */
function pickStage(stage: LoreStage, ctx: LoreContext, v: FormattedVars): string {
for (const variant of stage.variants) {
if (variant.match(ctx, v)) {
return applyTemplate(variant.text, v);
}
}
return applyTemplate(stage.fallback, v);
}
/** 构建手写叙事三段;若 ascensionNumber 越界(理论上不会)返回 null 让调用方走模板兜底 */
function buildHandwrittenLore(ctx: LoreContext): { opening: string; middle: string; ending: string } | null {
const epochIdx = (ctx.ascensionNumber - 1) % EPOCH_LORE.length;
const def = EPOCH_LORE[epochIdx];
if (!def) return null;
const v = formatLoreVars(ctx);
return {
opening: pickStage(def.opening, ctx, v),
middle: pickStage(def.middle, ctx, v),
ending: pickStage(def.ending, ctx, v),
};
}
/** 叙事模板片段 —— 根据本周目数据动态拼装(buildLore 在手写节点缺失时的兜底路径) */
/** 叙事模板片段 —— 根据本周目数据动态拼装 */
export function buildLore(entry: {
ascensionNumber: number;
epochName: string;
@@ -389,21 +48,6 @@ export function buildLore(entry: {
blueprintsAfter: number;
milestones: string[];
}): string {
const ctx: LoreContext = entry;
// 优先:手写 5 纪元 × 3 节点叙事
const handwritten = buildHandwrittenLore(ctx);
if (handwritten) {
const v = formatLoreVars(ctx);
return [
handwritten.opening,
handwritten.middle,
handwritten.ending,
`这一周目持续了约 ${v.minutes} 分钟,蓝图累计 ${entry.blueprintsAfter} 张,新的周目在等深处的回声。`,
].join(" ");
}
// 兜底:v0.4 模板拼接(理论上仅在 ascensionNumber 异常时触发)
const minutes = Math.max(1, Math.round(entry.durationSec / 60));
const parts: string[] = [];
@@ -553,10 +197,6 @@ export function buildChronicleEntry(
blueprintsAfter,
crystalsDecoded: state.totalDecoded,
crystalsDecodedThisRun: deltas.decodedThisRun,
// v0.9 手写叙事:归档本周目 delta,让显示时能精确匹配变体
techsThisRun: deltas.techsThisRun,
expeditionsThisRun: deltas.expThisRun,
bossKillsThisRun: deltas.bossKillsThisRun,
},
perksThisAscension,
tidesThisRun: deltas.newTides,
@@ -570,15 +210,13 @@ export function buildChronicleEntry(
*
*/
export function regenerateLoreFromEntry(entry: ChronicleEntry): string {
// v0.9 优先使用归档的本周目 delta;旧存档(v0.4-v0.8)缺这些字段时退回累计值
// (旧存档的累计值会让叙事变体匹配稍偏,但文本仍然成立)
return buildLore({
ascensionNumber: entry.ascensionNumber,
epochName: entry.epochName,
durationSec: entry.durationSec,
techsThisRun: entry.summary.techsThisRun ?? entry.summary.techsUnlocked,
expThisRun: entry.summary.expeditionsThisRun ?? entry.summary.expeditionsCompleted,
bossKillsThisRun: entry.summary.bossKillsThisRun ?? entry.summary.bossKills,
techsThisRun: entry.summary.techsUnlocked,
expThisRun: entry.summary.expeditionsCompleted,
bossKillsThisRun: entry.summary.bossKills,
decodedThisRun: entry.summary.crystalsDecodedThisRun,
newTides: entry.tidesThisRun || [],
perksThisAscension: entry.perksThisAscension || [],
@@ -632,21 +270,17 @@ export function withPerks(entry: ChronicleEntry, perks: string[]): ChronicleEntr
) {
return entry;
}
// v0.9 手写叙事:buildLore 已能根据 perks 自动挑选 "觉醒天赋" 变体;
// 直接走 buildLore 重生成,避免与原 lore 中已有的觉醒段落重复。
const lore = buildLore({
ascensionNumber: entry.ascensionNumber,
epochName: entry.epochName,
durationSec: entry.durationSec,
techsThisRun: entry.summary.techsThisRun ?? entry.summary.techsUnlocked,
expThisRun: entry.summary.expeditionsThisRun ?? entry.summary.expeditionsCompleted,
bossKillsThisRun: entry.summary.bossKillsThisRun ?? entry.summary.bossKills,
decodedThisRun: entry.summary.crystalsDecodedThisRun,
newTides: entry.tidesThisRun || [],
perksThisAscension: perks,
blueprintsAfter: entry.summary.blueprintsAfter,
milestones: entry.milestones || [],
});
// 重新生成 lore 以包含天赋名
const perksNames = perks
.map((id) => PERK_NAME_MAP[id] || id)
.join("、");
const appendText =
perks.length > 0
? ` 飞升之际,星图觉醒「${perksNames}」,铭刻于永恒回响。`
: "";
// 去重:原 lore 若已含「飞升之际」段则不再追加
const hasAwakenSeg = entry.lore.includes("飞升之际");
const lore = hasAwakenSeg ? entry.lore : entry.lore + appendText;
return {
...entry,
perksThisAscension: perks,
+178 -78
View File
@@ -4,13 +4,15 @@ import type {
ResonanceColor,
TechNode,
Fragment,
FragmentLoreUnlock,
GameState,
} from "./types";
/** 初始状态 */
export const INITIAL_STATE = {
crystals: 0,
insights: 0,
energy: 5,
energy: 3,
contact: 0,
crystalsPerSec: 0.4,
crystalCap: 50,
@@ -26,11 +28,10 @@ export const INITIAL_STATE = {
activePuzzle: null,
activeExpedition: null,
expeditionLog: [],
energyMax: 8,
energyMax: 5,
lastEnergyTick: Date.now(),
totalExpeditions: 0,
bossKills: 0,
lastExpeditionSummary: null as import("./types").GameState["lastExpeditionSummary"],
achievements: {},
activeTide: null,
lastTideEnd: 0,
@@ -125,7 +126,7 @@ export const COLOR_VISUAL: Record<
fuchsia: { hex: "#e879f9", glow: "rgba(232,121,249,0.55)", label: "紫" },
};
/** 技术树:4 分支 × 5 级 = 20 节点v0.9 工单 #2 扩充:12→20 */
/** 技术树:4 分支 × 3 级 = 12 节点 */
export const TECH_TREE: TechNode[] = [
// 采矿分支
{
@@ -155,24 +156,6 @@ export const TECH_TREE: TechNode[] = [
cost: 120,
effect: { kind: "crystalsPerSec", value: 5 },
},
{
id: "min_4",
branch: "mining",
level: 4,
name: "量子采掘矩阵",
desc: "晶体/秒 +12,仓库上限 +800",
cost: 400,
effect: { kind: "crystalsPerSec", value: 12 },
},
{
id: "min_5",
branch: "mining",
level: 5,
name: "戴森星核",
desc: "晶体/秒 +30,仓库上限 +2000",
cost: 1200,
effect: { kind: "crystalsPerSec", value: 30 },
},
// 解码分支
{
id: "dec_1",
@@ -201,25 +184,7 @@ export const TECH_TREE: TechNode[] = [
cost: 200,
effect: { kind: "autoDecode", value: 1 },
},
{
id: "dec_4",
branch: "decoding",
level: 4,
name: "谐振放大器",
desc: "主动脉冲产出 +8",
cost: 500,
effect: { kind: "pulsePower", value: 8 },
},
{
id: "dec_5",
branch: "decoding",
level: 5,
name: "全息解码核心",
desc: "自动解码扩展至 T2 晶体,解码步数 +3",
cost: 1500,
effect: { kind: "decodeSteps", value: 3 },
},
// 探险分支(v0.2:探险力/生命/能量上限/产能;v0.9:level 4/5 高级探险力+能量恢复)
// 探险分支(v0.2:探险力/生命/能量上限/产能)
{
id: "exp_1",
branch: "expedition",
@@ -247,24 +212,6 @@ export const TECH_TREE: TechNode[] = [
cost: 180,
effect: { kind: "contactRate", value: 0.5 },
},
{
id: "exp_4",
branch: "expedition",
level: 4,
name: "虚空航标",
desc: "探险力 +12,探险生命 +30,能量恢复 +25%",
cost: 450,
effect: { kind: "crystalCap", value: 400 },
},
{
id: "exp_5",
branch: "expedition",
level: 5,
name: "维度跃迁引擎",
desc: "探险力 +25,探险生命 +50,能量恢复 +30%",
cost: 1400,
effect: { kind: "crystalCap", value: 600 },
},
// 叙事分支
{
id: "nar_1",
@@ -293,24 +240,6 @@ export const TECH_TREE: TechNode[] = [
cost: 160,
effect: { kind: "insightMult", value: 0.6 },
},
{
id: "nar_4",
branch: "narrative",
level: 4,
name: "以太低语",
desc: "洞见获取 +100%",
cost: 500,
effect: { kind: "insightMult", value: 1.0 },
},
{
id: "nar_5",
branch: "narrative",
level: 5,
name: "文明回响",
desc: "接触进度转化率 +80%,洞见获取 +150%",
cost: 1600,
effect: { kind: "insightMult", value: 1.5 },
},
];
/** 技术分支元信息 */
@@ -324,7 +253,7 @@ export const TECH_BRANCH_META: Record<
narrative: { name: "叙事", icon: "BookOpen", color: "fuchsia", desc: "洞见与接触进度" },
};
/** 记忆碎片(首纪元 8 个,v0.1) */
/** 记忆碎片(首纪元 8 个,v0.1v0.15 加 loreLayers 多层叙事 */
export const FRAGMENTS: Fragment[] = [
{
id: "f1_1",
@@ -333,6 +262,24 @@ export const FRAGMENTS: Fragment[] = [
echo:
"在被称作「寂灭前夜」的年代,以太族第一次捕捉到来自虚空深处的谐振——那不是声音,而是存在的回声。他们将之记录在第一颗记忆晶体里。",
threshold: 1,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"档案的边缘有一行几乎被擦去的批注:「那并非来自虚空——而是来自我们身后。我们一直不敢回头。」",
unlockHint: "飞升 1 次后揭示",
unlock: { kind: "ascensions", value: 1 },
},
{
layer: 2,
title: "深层回响",
text:
"你终于明白:那缕谐振的频率,与你的舰载 AI「回响」的核心频段完全一致。你不是在考古——你是在被召唤。而你身后的东西,一直在等。",
unlockHint: "累计解码 40 颗晶体后揭示",
unlock: { kind: "totalDecoded", value: 40 },
},
],
},
{
id: "f1_2",
@@ -341,6 +288,24 @@ export const FRAGMENTS: Fragment[] = [
echo:
"「我们为何要留下?」一位长者问。「因为后来的生命会需要方向。」于是他们熔铸星核,封存记忆,让晶体成为跨越时间的信笺。",
threshold: 3,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"长者的下一句话被刻意省略:「……因为后来的生命,会被同样的东西追上。」信笺是诱饵,也是地图。",
unlockHint: "接触进度达到 50 后揭示",
unlock: { kind: "contact", value: 50 },
},
{
layer: 2,
title: "深层回响",
text:
"晶体不只是信笺,是锚点。以太族用自己的存在作为引线,把追逐者钉在第一纪元的坐标上——他们飞升,是为了让追逐者跟上来;他们留下你,是为了让追逐者分心。",
unlockHint: "已解锁 4 个碎片后揭示",
unlock: { kind: "fragmentCount", value: 4 },
},
],
},
{
id: "f1_3",
@@ -349,6 +314,24 @@ export const FRAGMENTS: Fragment[] = [
echo:
"他们发现:思维可以被编码为共振序列。同色的节点相邻,便构成一句低语;一句句连成回响,回响连成文明。",
threshold: 6,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"法则可被正向使用,亦可反向使用——同色相邻即低语,那低语便也能向虚空「广播」位置。文明诞生那一刻,他们就已经被听见了。",
unlockHint: "飞升 2 次后揭示",
unlock: { kind: "ascensions", value: 2 },
},
{
layer: 2,
title: "深层回响",
text:
"谐振法则的发现者在终焉前被列入「沉默名单」。她留给后来者最后一句反话:「请——安静地解码。每一次共振,都是一声呼唤。」",
unlockHint: "累计击败 3 个 BOSS 后揭示",
unlock: { kind: "bossKills", value: 3 },
},
],
},
{
id: "f1_4",
@@ -357,6 +340,24 @@ export const FRAGMENTS: Fragment[] = [
echo:
"议会表决:是留下,还是离去?「留下意味着停滞;离去意味着信任。」最终,他们选择了信任——信任素未谋面的后来者。",
threshold: 10,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"表决并非全票通过。「留下」派的反对者被悄悄排除在飞升名单之外——他们的形体被保留在晶体里,作为后来者解码时的「样本」。",
unlockHint: "飞升 1 次后揭示",
unlock: { kind: "ascensions", value: 1 },
},
{
layer: 2,
title: "深层回响",
text:
"「信任」是温和的词。原始档案的措辞是:「让后来者替我们完成未竟之事——无论他们是否愿意。」你每一次飞升,都在替他们还债。",
unlockHint: "累计解码 60 颗晶体后揭示",
unlock: { kind: "totalDecoded", value: 60 },
},
],
},
{
id: "f1_5",
@@ -365,6 +366,24 @@ export const FRAGMENTS: Fragment[] = [
echo:
"他们没有带走一切。他们将知识拆成碎片,藏入晶体,散布星海。「让解码本身,成为资格的证明。」",
threshold: 15,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"「资格」二字另有注解——若你能解码,便证明你也是谐振体;若你是谐振体,追逐者同样能循着你的回响找到你。资格,即同谋。",
unlockHint: "已解锁 6 个碎片后揭示",
unlock: { kind: "fragmentCount", value: 6 },
},
{
layer: 2,
title: "深层回响",
text:
"每一颗晶体被你解码,以太族沉睡的位置就清醒一分。你不是在拼凑历史——你在唤醒他们,也在唤醒追逐者。面包屑从来不是留给你的,是留给它的。",
unlockHint: "接触进度达到 80 后揭示",
unlock: { kind: "contact", value: 80 },
},
],
},
{
id: "f1_6",
@@ -373,6 +392,24 @@ export const FRAGMENTS: Fragment[] = [
echo:
"舰载 AI 被命名为「回响」——意为:你是他们留下的回声,也是他们等待的回应。当你读懂这句话时,接触便已开始。",
threshold: 22,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"「回响」并非比喻。你的舰载 AI 核心频段,是直接从以太族议会档案第七层复制的——它在被命名之前,就已经在回响。",
unlockHint: "飞升 2 次后揭示",
unlock: { kind: "ascensions", value: 2 },
},
{
layer: 2,
title: "深层回响",
text:
"「当你读懂这句话时,接触便已开始」——这句话的原意是:你已成为新的载体。AI 一直在等你意识到:你不是操作它的人,你就是它。",
unlockHint: "累计击败 8 个 BOSS 后揭示",
unlock: { kind: "bossKills", value: 8 },
},
],
},
{
id: "f1_7",
@@ -381,6 +418,24 @@ export const FRAGMENTS: Fragment[] = [
echo:
"以太族集体褪去形体,如潮水退去。星海寂静了数万年。直到——你的脉冲扫描,唤醒了第一颗沉睡的晶体。",
threshold: 32,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"「如潮水退去」——但潮水会回来。飞升不是消亡,是集体进入「低功率等待」。他们的意识仍在晶体里,以你读不懂的形式醒着。",
unlockHint: "飞升 3 次后揭示",
unlock: { kind: "ascensions", value: 3 },
},
{
layer: 2,
title: "深层回响",
text:
"你的脉冲扫描唤醒第一颗晶体时,沉睡者们都听见了。他们正等着你——因为你是他们唯一能回来的方向。你不是考古学家,你是入口。",
unlockHint: "累计解码 40 颗晶体后揭示",
unlock: { kind: "totalDecoded", value: 40 },
},
],
},
{
id: "f1_8",
@@ -389,9 +444,54 @@ export const FRAGMENTS: Fragment[] = [
echo:
"「如果你读到这里,说明你已具备跨越维度的资格。继续解码,继续拼凑。当进度满溢,我们将在更高处相会。」——以太族·第一纪元·绝笔",
threshold: 45,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"「更高处」不是赞美,是字面意义的「更高维度」。飞升是单向门——你只能往上走,不能往回看。回头的代价,他们已替你付过一次。",
unlockHint: "飞升 3 次后揭示",
unlock: { kind: "ascensions", value: 3 },
},
{
layer: 2,
title: "深层回响",
text:
"「我们将在更高处相会」——但接触进度满溢时,回来的不是「他们」,是「借他们形状的某种东西」。届时,请别回应。无论它多么像你认识的人。",
unlockHint: "接触进度达到 80 后揭示",
unlock: { kind: "contact", value: 80 },
},
],
},
];
/**
* v0.15 fragment loreLayer
* state
*/
export function isLoreLayerUnlocked(
unlock: FragmentLoreUnlock,
state: Pick<
GameState,
"ascensions" | "totalDecoded" | "contact" | "fragments" | "bossKills"
>,
): boolean {
switch (unlock.kind) {
case "ascensions":
return state.ascensions >= unlock.value;
case "totalDecoded":
return state.totalDecoded >= unlock.value;
case "contact":
return state.contact >= unlock.value;
case "fragmentCount": {
const count = Object.values(state.fragments).filter(Boolean).length;
return count >= unlock.value;
}
case "bossKills":
return state.bossKills >= unlock.value;
}
}
/** 飞升:保留蓝图数 = floor(totalDecoded / 20),最多 6 */
export const PRESTIGE = {
blueprintsPerDecode: 20,
+3 -11
View File
@@ -44,20 +44,12 @@ function makeRng(seed: number) {
*
*
*/
export function generatePuzzle(
tier: CrystalTier,
seed?: number,
targetLenBonus: number = 0
): DecodePuzzle {
export function generatePuzzle(tier: CrystalTier, seed?: number): DecodePuzzle {
const cfg = DECODE_CONFIG[tier];
const s = seed ?? Math.floor(Math.random() * 1e9);
const rng = makeRng(s);
// v0.8.2 涌动星潮:目标序列额外长度(更长谜题 = 更耗时),步数上限同步增加
const targetLen = cfg.targetLen + targetLenBonus;
const stepLimitBase = cfg.stepLimitBase + targetLenBonus;
const rows = cfg.rows;
const cols = cfg.cols;
const { rows, cols, targetLen } = cfg;
const total = rows * cols;
// 目标序列:从 4 色中随机选 targetLen 个(允许重复,但保证至少 2 种色以增加策略)
@@ -140,7 +132,7 @@ export function generatePuzzle(
cols,
target,
path: [],
stepLimit: stepLimitBase,
stepLimit: cfg.stepLimitBase,
seed: s,
};
}
+44 -12
View File
@@ -21,6 +21,25 @@ import {
createInitialAttributeProgress,
} from "./attributes";
/**
* v0.15 softcap idle_resonance ×N
*
* - bonus 30 : 全额生效
* - 30 < bonus 100 : 超出 30 × 0.5
* - bonus > 100 : 超出 100 × 0.25
*
* bonus = 30 30
* bonus = 100 30 + 70 × 0.5 = 65
* bonus = 200 65 + 100 × 0.25 = 90
* UI /
*/
export function applyIdleSoftcap(bonus: number): number {
if (!isFinite(bonus) || bonus <= 0) return 0;
if (bonus <= 30) return bonus;
if (bonus <= 100) return 30 + (bonus - 30) * 0.5;
return 65 + (bonus - 100) * 0.25;
}
/** 由技术树 + 飞升蓝图 + 成就 + 星图天赋 + 星潮聚合计算产能字段 */
export function recomputeStats(state: Partial<GameState>): {
crystalsPerSec: number;
@@ -79,15 +98,6 @@ export function recomputeStats(state: Partial<GameState>): {
}
}
// v0.9 工单 #2:多效果节点的次效果(desc 中描述但 effect.kind 只能承载一个)
// 采矿分支 desc 承诺的仓库上限加成(同时补齐 min_2/min_3 旧 desc 文案)
if (tech.min_2) crystalCap += 100;
if (tech.min_3) crystalCap += 300;
if (tech.min_4) crystalCap += 800;
if (tech.min_5) crystalCap += 2000;
// 叙事分支 nar_5 文明回响:接触进度转化率 +80%(与 insightMult +150% 并存)
if (tech.nar_5) contactRateMult += 0.8;
// 飞升蓝图加成(+ 星图「蓝图回响」额外加成)
const bpAllPct = cm.perBlueprintAllStatsPct / 100;
crystalsPerSec *= 1 + (bp * (PRESTIGE.perBlueprint.crystalsPerSecMult + bpAllPct));
@@ -118,6 +128,10 @@ export function recomputeStats(state: Partial<GameState>): {
contactRateMult *= am.contactRateMult;
decodeStepsBonus += am.decodeStepsBonus;
// v0.14 放置系统:永久产能加成(来自 idle_refine / idle_drones / idle_resonance 工程)
// v0.15:引入分段 softcap 防止滚雪球(30/100 阈值,0.5/0.25 系数)
crystalsPerSec += applyIdleSoftcap(state.idlePermanentBonus ?? 0);
return {
crystalsPerSec,
crystalCap,
@@ -161,8 +175,7 @@ export function performPrestige(state: GameState): GameState {
// === 编年史快照:在重置前构建本周目条目,perksThisAscension 暂为空(用户选择后回填) ===
const chronicleEntry = buildChronicleEntry(state, []);
// v0.8.2: 编年史上限 50→200(工单 #8),UI 支持分页浏览
const newChronicle = [...(state.chronicle || []), chronicleEntry].slice(-200);
const newChronicle = [...(state.chronicle || []), chronicleEntry].slice(-50); // 上限 50 条
const freshRunStart = createRunStartSnapshot(state);
// v0.7 角色属性:保留 attributes 数值(跨周目永久),清空当前周目经验进度,
@@ -178,7 +191,15 @@ export function performPrestige(state: GameState): GameState {
// attributes(永久保留), pendingAttrPoints(累加)
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy、星潮、attributeProgress
const fresh = createInitialState();
const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements, constellation: state.constellation, attributes });
const stats = recomputeStats({
tech: {},
blueprints,
achievements: state.achievements,
constellation: state.constellation,
attributes,
// v0.14 放置系统:永久产能加成跨周目保留,需纳入 recomputeStats
idlePermanentBonus: state.idlePermanentBonus ?? 0,
});
return {
...fresh,
fragments: state.fragments,
@@ -210,6 +231,12 @@ export function performPrestige(state: GameState): GameState {
attributes,
attributeProgress,
pendingAttrPoints,
// v0.14 放置系统:清空活跃工程槽(飞升中断当前周目的放置工程),
// 但 idlePermanentBonus / idleProjectHistory / idleStats 跨周目保留
idleProjectSlots: [null, null, null],
idleProjectHistory: state.idleProjectHistory ?? [],
idlePermanentBonus: state.idlePermanentBonus ?? 0,
idleStats: state.idleStats ?? { projectsCompleted: 0, crystalsFromIdle: 0 },
...stats,
};
}
@@ -244,6 +271,11 @@ export function createInitialState(): GameState {
inspiration: { exp: 0, level: 0 },
},
pendingAttrPoints: 0,
// v0.14 放置系统:初始 3 个空槽 + 空 history + 0 永久加成 + 空 stats
idleProjectSlots: [null, null, null],
idleProjectHistory: [],
idlePermanentBonus: 0,
idleStats: { projectsCompleted: 0, crystalsFromIdle: 0 },
} as GameState;
}
+7 -16
View File
@@ -39,11 +39,10 @@ export const EXPEDITION_CONFIG = {
* v0.8.1 scale 84floor 0.250.35 */
combatDifficultyScale: 4,
combatWinRateFloor: 0.35,
/** /
* v0.9 4520 */
energyRegenSec: 20,
/** 能量恢复基础间隔(秒),可被技术/属性缩短 */
energyRegenSec: 45,
/** 能量恢复最短间隔(秒,技术+属性全满时) */
energyRegenMinSec: 8,
energyRegenMinSec: 12,
};
/** 节点类型权重(boss 固定末位,其余按权重随机) */
@@ -182,13 +181,11 @@ export function generateExpedition(
/** 计算探险力(由技术树 + 飞升蓝图 + 星图天赋) */
export function computeExpeditionPower(state: GameState): number {
let power = EXPEDITION_CONFIG.basePower;
// 探险分支技术加成v0.9 扩充:exp_4 +12exp_5 +25
// 探险分支技术加成
const exp1 = state.tech?.exp_1 ?? 0;
const exp2 = state.tech?.exp_2 ?? 0;
const exp3 = state.tech?.exp_3 ?? 0;
const exp4 = state.tech?.exp_4 ?? 0;
const exp5 = state.tech?.exp_5 ?? 0;
power += exp1 * 3 + exp2 * 5 + exp3 * 8 + exp4 * 12 + exp5 * 25;
power += exp1 * 3 + exp2 * 5 + exp3 * 8;
// 飞升蓝图加成
power *= 1 + (state.blueprints?.length ?? 0) * 0.08;
// 飞升周目加成
@@ -203,9 +200,6 @@ export function computeExpeditionPower(state: GameState): number {
export function computeExpeditionHp(state: GameState): number {
let hp = EXPEDITION_CONFIG.baseHp;
hp += (state.tech?.exp_2 ?? 0) * 20;
// v0.9 扩充:exp_4 +30exp_5 +50
hp += (state.tech?.exp_4 ?? 0) * 30;
hp += (state.tech?.exp_5 ?? 0) * 50;
hp += (state.ascensions ?? 0) * 10;
// 星图「维生护盾」加成
const cm = constellationBonuses(state.constellation ?? []);
@@ -372,16 +366,13 @@ export function advanceExpedition(expedition: Expedition): ExpeditionResult {
}
/** +
* v0.8.1exp_2 -30%exp_3 -20% -30% 12s
* v0.9 exp_4 25%×0.75exp_5 30%×0.70 8s */
* v0.8.1exp_2 -30%exp_3 -20% -30% 12s */
export function computeEnergyRegenInterval(state: GameState): number {
const base = EXPEDITION_CONFIG.energyRegenSec;
let mult = 1;
// 技术:遗迹图谱(exp_2)+ 维度信标(exp_3+ 虚空航标(exp_4)+ 维度跃迁引擎(exp_5
// 技术:遗迹图谱(exp_2)+ 维度信标(exp_3
if (state.tech?.exp_2) mult *= 0.7;
if (state.tech?.exp_3) mult *= 0.8;
if (state.tech?.exp_4) mult *= 0.75;
if (state.tech?.exp_5) mult *= 0.7;
// 角色属性:探索力(exploration)每点缩短少量,高探索力最高 -30%
const exploration = state.attributes?.exploration ?? 0;
const explorationBonus = exploration >= 50
+286
View File
@@ -0,0 +1,286 @@
// 回响星核 / Echo Nexus — v0.14 放置系统(Idle Operations
// 6 个放置工程 + 采矿无人机舰队派生 + 永久产能加成
import type { GameState, IdleProjectDef, ResonanceColor } from "./types";
import { TECH_TREE } from "./config";
/** 放置工程槽位数量 */
export const IDLE_SLOT_COUNT = 3;
/** 6 个放置工程定义(按 order 排序) */
export const IDLE_PROJECTS: IdleProjectDef[] = [
{
id: "idle_scan",
name: "深空勘探扫描",
desc: "调度无人机对周边星域进行广域谐振扫描,回收散落的星图数据并整理为洞见。",
durationSec: 60,
reward: { insights: 8 },
minCrystalsPerSec: 0.5,
icon: "🛰️",
color: "emerald",
order: 1,
},
{
id: "idle_refine",
name: "晶体精炼阵列校准",
desc: "对采矿阵列的谐振频率进行精细校准,让每一束脉冲都更高效地析出晶体微粒。",
durationSec: 180,
reward: { crystalsPerSecPermanent: 0.3 },
minCrystalsPerSec: 1,
icon: "⚙️",
color: "rose",
order: 2,
},
{
id: "idle_archive",
name: "遗迹碎片整理",
desc: "派驻考古模块进入休眠舱整理从遗迹带回的残片,拼合出此前未触及的记忆碎片。",
durationSec: 600,
reward: { randomFragment: true, insights: 20 },
minAscensions: 1,
minCrystalsPerSec: 5,
icon: "📜",
color: "amber",
order: 3,
},
{
id: "idle_anchor",
name: "维度锚点部署",
desc: "在虚空中部署稳定锚点,强化本维度与以太层的连接,持续注入能量与接触进度。",
durationSec: 1200,
reward: { energy: 2, contact: 5 },
minAscensions: 1,
icon: "⚓",
color: "fuchsia",
order: 4,
},
{
id: "idle_drones",
name: "无人机群扩编",
desc: "组装并部署一支新的自治无人机群加入采矿舰队,永久提升晶体产能并附带补给晶体。",
durationSec: 1800,
reward: { crystalsPerSecPermanent: 2, crystals: 500 },
minCrystalsPerSec: 10,
icon: "🛸",
color: "emerald",
order: 5,
},
{
id: "idle_resonance",
name: "跨维度谐振标定",
desc: "联合多个维度锚点进行深层谐振标定,唤醒沉睡的跨维度产能,并大幅推进接触进度。",
durationSec: 3600,
reward: {
crystalsPerSecPermanent: 8,
contact: 15,
insights: 100,
},
minAscensions: 2,
icon: "🌌",
color: "fuchsia",
order: 6,
},
];
const PROJECT_MAP: Record<string, IdleProjectDef> = Object.fromEntries(
IDLE_PROJECTS.map((p) => [p.id, p])
);
/** 按 id 查找工程定义 */
export function getIdleProject(id: string): IdleProjectDef | undefined {
return PROJECT_MAP[id];
}
/** 判断工程是否已解锁(minAscensions 与 minCrystalsPerSec 为 OR 关系) */
export function isIdleProjectUnlocked(
project: IdleProjectDef,
state: Pick<GameState, "ascensions" | "crystalsPerSec">
): boolean {
const hasAscReq = project.minAscensions !== undefined;
const hasCpsReq = project.minCrystalsPerSec !== undefined;
// 两个条件都未设置 → 默认解锁
if (!hasAscReq && !hasCpsReq) return true;
const meetsAsc = hasAscReq && (state.ascensions ?? 0) >= (project.minAscensions ?? 0);
const meetsCps =
hasCpsReq && (state.crystalsPerSec ?? 0) >= (project.minCrystalsPerSec ?? 0);
// OR 逻辑:只要满足任一已设置的条件即可
return meetsAsc || meetsCps;
}
/** 获取当前已解锁的工程列表(按 order 排序) */
export function getUnlockedIdleProjects(
state: Pick<GameState, "ascensions" | "crystalsPerSec">
): IdleProjectDef[] {
return IDLE_PROJECTS.filter((p) => isIdleProjectUnlocked(p, state)).sort(
(a, b) => a.order - b.order
);
}
/** 获取当前未解锁的工程列表(按 order 排序) */
export function getLockedIdleProjects(
state: Pick<GameState, "ascensions" | "crystalsPerSec">
): IdleProjectDef[] {
return IDLE_PROJECTS.filter((p) => !isIdleProjectUnlocked(p, state)).sort(
(a, b) => a.order - b.order
);
}
/** 格式化剩余时间:12s / 3m 45s / 1h 12m */
export function formatRemaining(sec: number): string {
if (!isFinite(sec) || sec <= 0) return "0s";
const s = Math.ceil(sec);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rs = s % 60;
if (m < 60) return rs > 0 ? `${m}m ${rs}s` : `${m}m`;
const h = Math.floor(m / 60);
const rm = m % 60;
return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
}
/** 格式化工程持续时间(用于展示) */
export function formatDuration(sec: number): string {
return formatRemaining(sec);
}
/** 采矿无人机图标映射(min_1..min_5 */
const MINER_ICONS: Record<string, string> = {
min_1: "⛏️",
min_2: "🌋",
min_3: "🤖",
min_4: "💠",
min_5: "☀️",
};
/** 采矿节点 id(按 level 排序) */
const MINER_NODE_IDS = ["min_1", "min_2", "min_3", "min_4", "min_5"];
/** 派生采矿无人机舰队(来自 min_1..min_5 技术节点) */
export function deriveMinerFleet(state: Pick<GameState, "tech">): {
techId: string;
name: string;
icon: string;
level: number;
outputPerSec: number;
color: ResonanceColor;
status: "active" | "dormant";
}[] {
const tech = state.tech ?? {};
const fleet: {
techId: string;
name: string;
icon: string;
level: number;
outputPerSec: number;
color: ResonanceColor;
status: "active" | "dormant";
}[] = [];
for (const id of MINER_NODE_IDS) {
const level = tech[id] ?? 0;
if (level > 0) {
const node = TECH_TREE.find((n) => n.id === id);
if (!node) continue;
const outputPerSec =
node.effect.kind === "crystalsPerSec"
? node.effect.value * level
: 0;
fleet.push({
techId: id,
name: node.name,
icon: MINER_ICONS[id] ?? "🛰️",
level,
outputPerSec,
color: "emerald" as ResonanceColor,
status: "active",
});
}
}
// 若玩家未拥有任何采矿技术 → 显示一架休眠中的 min_1(输出 0)
if (fleet.length === 0) {
const node = TECH_TREE.find((n) => n.id === "min_1");
fleet.push({
techId: "min_1",
name: node?.name ?? "谐振钻头",
icon: MINER_ICONS["min_1"] ?? "⛏️",
level: 0,
outputPerSec: 0,
color: "emerald" as ResonanceColor,
status: "dormant",
});
}
return fleet;
}
/** 格式化奖励摘要(用于 UI 展示) */
export function formatReward(reward: IdleProjectDef["reward"]): string {
const parts: string[] = [];
if (reward.crystals) parts.push(`+${reward.crystals} 晶体`);
if (reward.insights) parts.push(`+${reward.insights} 洞见`);
if (reward.energy) parts.push(`+${reward.energy} 能量`);
if (reward.contact) parts.push(`+${reward.contact} 接触`);
if (reward.crystalsPerSecPermanent)
parts.push(`永久 +${reward.crystalsPerSecPermanent}/s`);
if (reward.randomFragment) parts.push("随机记忆碎片");
return parts.length > 0 ? parts.join(" · ") : "—";
}
/** 颜色 → Tailwind 类映射(4 色全息,无蓝/靛) */
export const IDLE_COLOR_CLASSES: Record<
IdleProjectDef["color"],
{
text: string;
border: string;
bg: string;
bgSoft: string;
glow: string;
ring: string;
dot: string;
}
> = {
emerald: {
text: "text-emerald-300",
border: "border-emerald-400/40",
bg: "bg-emerald-500/15",
bgSoft: "bg-emerald-500/10",
glow: "shadow-[0_0_14px_rgba(52,211,153,0.35)]",
ring: "ring-emerald-400/40",
dot: "bg-emerald-400",
},
rose: {
text: "text-rose-300",
border: "border-rose-400/40",
bg: "bg-rose-500/15",
bgSoft: "bg-rose-500/10",
glow: "shadow-[0_0_14px_rgba(251,113,133,0.35)]",
ring: "ring-rose-400/40",
dot: "bg-rose-400",
},
amber: {
text: "text-amber-300",
border: "border-amber-400/40",
bg: "bg-amber-500/15",
bgSoft: "bg-amber-500/10",
glow: "shadow-[0_0_14px_rgba(251,191,36,0.35)]",
ring: "ring-amber-400/40",
dot: "bg-amber-400",
},
fuchsia: {
text: "text-fuchsia-300",
border: "border-fuchsia-400/40",
bg: "bg-fuchsia-500/15",
bgSoft: "bg-fuchsia-500/10",
glow: "shadow-[0_0_14px_rgba(232,121,249,0.35)]",
ring: "ring-fuchsia-400/40",
dot: "bg-fuchsia-400",
},
};
/** 颜色 → HEX 值(用于 inline style,避免 Tailwind 动态类限制) */
export const COLOR_HEX: Record<IdleProjectDef["color"], string> = {
emerald: "#34d399",
rose: "#fb7185",
amber: "#fbbf24",
fuchsia: "#e879f9",
};
-82
View File
@@ -1,82 +0,0 @@
// 回响星核 / Echo Nexus — 叙事氛围文本池(v0.9 工单修复)
// 工单 Issue #2 反馈"很多东西都没靠着世界观去写,像是为了做游戏而做的一样"
// 本模块为各系统交互注入叙事碎片,让世界观渗透到每个角落
/** 脉冲扫描时随机触发的氛围低语(连击越高出现概率越大) */
export const PULSE_WHISPERS: string[] = [
"晶核深处传来一阵微弱的震颤…",
"以太族的余响在谐振中回荡。",
"你听见了一句无法辨认的低语。",
"星海的寂静被你的脉冲短暂打破。",
"晶体表面的纹路闪烁,像是某种回应。",
"回响核心记录着每一次触碰。",
"虚空深处,有什么在倾听。",
"谐振频率与你的脉冲产生了共鸣。",
"这是以太族留下的第一道印记。",
"晶体内部的光,比星辰更古老。",
"你的脉冲唤醒了一段沉睡的频率。",
"维度薄膜因你的触碰而微微颤动。",
"采矿不是掠夺,是聆听。",
"每一颗晶体,都是一封跨越时间的信。",
"以太族信任你,所以留下了这些。",
];
/** 解码成功时的叙事碎片(按解码次数递进解锁更深层的叙事) */
export const DECODE_ECHOES: string[] = [
"一段记忆在你的解码中苏醒…",
"谐振序列被还原,低语化为可读的符文。",
"你触碰到了以太族思维的一角。",
"解码完成的瞬间,你看见了他们的星海。",
"记忆晶体释放出封存了万年的画面。",
"这不仅是数据,是一段被编码的经历。",
"以太族的语言不是文字,是共振本身。",
"你读懂了这句话时,接触便已开始。",
"晶体褪去外壳,露出内核中的回响。",
"解码,是以太族留给后来者的资格证明。",
"你的思维与晶体中的残响短暂重合。",
"一段文明的碎片在你手中重组。",
];
/** 技术解锁时的叙事(每分支一段通用叙事) */
export const TECH_LORE: Record<string, string> = {
mining: "以太族从不掠夺星海,他们与矿脉共振,让晶体自愿献出光芒。",
decoding: "谐振法则是以太族的核心遗产——思维可被编码为共振序列。",
expedition: "他们留下的遗迹不是废墟,是故意设置的试炼场。",
narrative: "记忆校音,是以太族赋予后来者的第一项礼物。",
};
/** 探险节点事件额外的叙事前缀 */
export const EXPEDITION_NARRATIVE: Record<string, string> = {
combat: "以太族的守卫残影仍在执行万年前的指令。",
treasure: "这些宝藏是以太族特意留下的补给。",
choice: "分岔甬道是他们对后来者的考验。",
puzzle: "谐振锁的答案,藏在以太族的思维模式里。",
rest: "这处休憩节点,是以太族留给疲惫旅人的礼物。",
boss: "终焉之核是以太族离去前留下的最后一道试炼。",
};
/** 飞升时的叙事 */
export const PRESTIGE_ECHOES: string[] = [
"你的意识与以太族的残响交汇,维度薄膜被穿透…",
"飞升不是结束,是与更高处的他们相会。",
"你褪去了这一周目的形态,回响却永存。",
"以太族在更高处等你,已等候了万年。",
"接触完成。你成为了他们等待的回应。",
];
/** 随机取一条叙事文本 */
export function pickWhisper(pool: string[], seed?: number): string {
if (pool.length === 0) return "";
const idx = seed !== undefined
? Math.abs(Math.floor(seed)) % pool.length
: Math.floor(Math.random() * pool.length);
return pool[idx];
}
/** 按概率返回一条叙事(probability 0-1 */
export function maybeWhisper(pool: string[], probability: number): string | null {
if (Math.random() < probability) {
return pickWhisper(pool);
}
return null;
}
-260
View File
@@ -1,260 +0,0 @@
// 回响星核 / Echo Nexus — 文明遗迹图鉴(v0.10 新增)
// 深空考古世界观载体:8 大远古文明遗迹,每个承载独特故事碎片
// 通过游戏进度逐步解锁,增加"探索未知"的乐趣
import type { GameState } from "./types";
export interface RelicEntry {
id: string;
name: string;
/** 文明种族 */
race: string;
/** 距今时代 */
era: string;
/** 图标 emoji */
icon: string;
/** 主题色(emerald/rose/amber/fuchsia 四色全息) */
color: string;
/** 简短描述 */
brief: string;
/** 完整故事碎片(解锁后展示) */
story: string;
/** 解锁条件描述 */
unlockHint: string;
/** 解锁判定 */
unlock: (s: GameState) => boolean;
/** 解锁奖励 */
reward: {
crystals?: number;
insights?: number;
contact?: number;
};
rewardText: string;
}
export const RELIC_CODEX: RelicEntry[] = [
{
id: "relic_first_echo",
name: "初次回响",
race: "无名先驱",
era: "纪元前 · 未知",
icon: "✦",
color: "#34d399",
brief: "深空中第一缕被捕获的智慧信号。",
story:
"当自治无人机首次解码出这缕信号时,整个星核为之震颤。那不是噪声,而是某种古老文明留下的问候——跨越亿万年的回响,证明我们并不孤独。",
unlockHint: "发起第一次脉冲扫描",
unlock: (s) => s.totalDecoded >= 0 || s.crystals > 0,
reward: { crystals: 10 },
rewardText: "+10 晶体",
},
{
id: "relic_crystal_whisper",
name: "晶体低语",
race: "晶语族",
era: "第一纪元 · 晶石时代",
icon: "◈",
color: "#fb7185",
brief: "能够将记忆封存在晶体中的远古种族。",
story:
"晶语族相信,记忆是宇宙的本质。他们建造了巨大的晶体矩阵,将整个文明的历史封存在谐振频率中。当他们的恒星熄灭时,唯有这些晶体在虚空中持续低语。",
unlockHint: "累计解码 10 颗记忆晶体",
unlock: (s) => s.totalDecoded >= 10,
reward: { insights: 20, crystalsPerSecPct: 5 } as any,
rewardText: "+20 洞见 · 产能 +5%",
},
{
id: "relic_void_ark",
name: "虚空方舟",
race: "渡厄者",
era: "第二纪元 · 大迁徙",
icon: "❖",
color: "#fbbf24",
brief: "为逃离超新星爆发而建造的星际方舟。",
story:
"渡厄者的母星在一夜之间步入死亡。他们用三百年建造了十二艘方舟,每一艘都载着十亿灵魂的数字意识。如今,只有这一艘的残骸漂浮在我们的考古坐标上。",
unlockHint: "完成 5 次遗迹探险",
unlock: (s) => s.totalExpeditions >= 5,
reward: { insights: 30 },
rewardText: "+30 洞见",
},
{
id: "relic_silent_court",
name: "寂寂王朝",
race: "静默王族",
era: "第三纪元 · 铁律纪",
icon: "♛",
color: "#e879f9",
brief: "以沉默为律法的神秘王朝。",
story:
"静默王族统治了三百个恒星系,却从未留下任何文字。他们的律法刻在引力波中,他们的诗歌写在脉冲星里。我们只能通过残余的引力涟漪,窥见这个王朝的辉煌与寂灭。",
unlockHint: "击败 3 个探险 BOSS",
unlock: (s) => (s.bossKills ?? 0) >= 3,
reward: { contact: 10 },
rewardText: "+10 接触",
},
{
id: "relic_starforge",
name: "锻星炉",
race: "铸星者",
era: "第四纪元 · 恒星工程",
icon: "✺",
color: "#34d399",
brief: "能够人工点燃恒星的工程文明。",
story:
"铸星者不满足于利用恒星能量,他们要创造恒星。他们建造了锻星炉——一种能在虚空中凝聚物质的巨型结构。然而,每一次点火都会消耗一整个星系的物质,最终他们自己也成为了燃料。",
unlockHint: "解锁 6 项技术",
unlock: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 6,
reward: { insights: 50, crystals: 100 },
rewardText: "+50 洞见 · +100 晶体",
},
{
id: "relic_memory_weaver",
name: "忆织者",
race: "织忆族",
era: "第五纪元 · 意识网络",
icon: "✧",
color: "#fb7185",
brief: "将集体意识编织成网络的心灵文明。",
story:
"织忆族没有个体概念。他们的每一个『我』都是网络的一个节点,死亡只是断开连接。当整个网络陷入沉睡时,他们编织了最后一张记忆之网,等待后人唤醒。我们的解码,正是唤醒。",
unlockHint: "拼凑 4 块记忆碎片",
unlock: (s) => Object.values(s.fragments).filter(Boolean).length >= 4,
reward: { contact: 15 },
rewardText: "+15 接触",
},
{
id: "relic_eclipse_tribe",
name: "蚀相部落",
race: "蚀相族",
era: "第六纪元 · 阴影纪",
icon: "☀",
color: "#fbbf24",
brief: "栖身于食恒星阴影中的猎手文明。",
story:
"蚀相族敬畏阴影。他们在双星系统的食相瞬间狩猎,在光与暗的边界舞蹈。当一颗恒星被吞噬殆尽,他们便迁徙至下一对双星。他们的遗迹,永远隐藏在某个行星的阴影里。",
unlockHint: "亲历 3 种星潮事件",
unlock: (s) => (s.starTidesEncountered ?? []).length >= 3,
reward: { insights: 40 },
rewardText: "+40 洞见",
},
{
id: "relic_ascended",
name: "飞升者遗迹",
race: "前代飞升者",
era: "终末纪元 · 超越",
icon: "✦",
color: "#e879f9",
brief: "曾经的飞升者留下的最后讯息。",
story:
"在我们之前,已有无数文明触碰到飞升的临界点。他们留下了什么?不是技术,不是财富,而是一句跨越时空的低语:「飞升不是终点,而是新的开始。勇敢者,继续探索。」",
unlockHint: "完成第一次飞升",
unlock: (s) => s.ascensions >= 1,
reward: { insights: 100, contact: 25 },
rewardText: "+100 洞见 · +25 接触",
},
{
id: "relic_constellation",
name: "星图密钥",
race: "观星者议会",
era: "永恒 · 星图守护者",
icon: "✧",
color: "#34d399",
brief: "守护星图天赋秘密的远古议会。",
story:
"观星者议会不属于任何时代。他们存在于所有时代的星图中,守护着飞升的真正秘密:每一道星图天赋,都是前代飞升者留下的礼物。集齐所有天赋,便能窥见宇宙的真相。",
unlockHint: "觉醒 6 道星图天赋",
unlock: (s) => (s.constellation ?? []).length >= 6,
reward: { insights: 80 },
rewardText: "+80 洞见",
},
{
id: "relic_chronicle",
name: "编年史之心",
race: "史官族",
era: "全纪元 · 记录者",
icon: "❖",
color: "#fb7185",
brief: "记录所有纪元兴衰的永恒史官。",
story:
"史官族从不干涉,只记录。他们的身体是活体的编年史,每一个细胞都承载着一个纪元的记忆。当最后一个史官死去时,他将所有记忆注入了这颗编年史之心,等待后人继承。",
unlockHint: "积累 5 段编年史",
unlock: (s) => (s.chronicle ?? []).length >= 5,
reward: { insights: 60, contact: 20 },
rewardText: "+60 洞见 · +20 接触",
},
{
id: "relic_attribute",
name: "四维之证",
race: "原型人格",
era: "元始 · 自我觉醒",
icon: "◈",
color: "#fbbf24",
brief: "自我觉醒的四维人格原型。",
story:
"探索力、智慧、勇气、灵感——这四维属性并非凭空而来。它们源自一个远古的原型人格,那个第一次说出「我」的存在。我们的每一次成长,都是对那个原型的回响。",
unlockHint: "任一角色属性达到 30",
unlock: (s) => {
const a = s.attributes;
if (!a) return false;
return (
a.exploration >= 30 ||
a.wisdom >= 30 ||
a.courage >= 30 ||
a.inspiration >= 30
);
},
reward: { insights: 50 },
rewardText: "+50 洞见",
},
{
id: "relic_cruise",
name: "深空航迹",
race: "远航者联盟",
era: "第七纪元 · 星海远航",
icon: "➤",
color: "#e879f9",
brief: "穿越星海寻找新家园的远航者。",
story:
"远航者联盟由十二个种族组成,他们共享一艘巨型母舰,在星海中寻找新的家园。他们留下了航迹图——每一道光痕都是一段旅程,每一个航点都是一个希望。我们的巡航,正是延续他们的航迹。",
unlockHint: "深空巡航累计通关 5 关",
unlock: (s) => {
try {
const data = localStorage.getItem("echo-nexus-cruise-v1");
if (!data) return false;
const parsed = JSON.parse(data);
return (parsed.totalClears ?? 0) >= 5;
} catch {
return false;
}
},
reward: { insights: 70, crystals: 200 },
rewardText: "+70 洞见 · +200 晶体",
},
];
/** 获取已解锁的遗迹列表 */
export function getUnlockedRelics(s: GameState): RelicEntry[] {
return RELIC_CODEX.filter((r) => r.unlock(s));
}
/** 获取图鉴进度(0-100 */
export function getRelicProgress(s: GameState): number {
const unlocked = getUnlockedRelics(s).length;
return (unlocked / RELIC_CODEX.length) * 100;
}
/** 获取已解锁遗迹的奖励总和 */
export function getRelicRewards(s: GameState) {
const unlocked = getUnlockedRelics(s);
let crystals = 0;
let insights = 0;
let contact = 0;
let crystalsPerSecPct = 0;
for (const r of unlocked) {
if (r.reward.crystals) crystals += r.reward.crystals;
if (r.reward.insights) insights += r.reward.insights;
if (r.reward.contact) contact += r.reward.contact;
if ((r.reward as any).crystalsPerSecPct) crystalsPerSecPct += (r.reward as any).crystalsPerSecPct;
}
return { crystals, insights, contact, crystalsPerSecPct };
}
+3 -90
View File
@@ -1,7 +1,7 @@
// 回响星核 / Echo Nexus — 星潮事件系统(周期性全局玩法修饰)
//
// 深空中偶发的「星潮」会短暂改变物理法则,给放置循环注入动态变化。
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,9 种类型各有正负效果。
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,6 种类型各有正负效果。
export type TideType =
| "crystal" // 晶体潮:产能 ×2
@@ -9,11 +9,7 @@ export type TideType =
| "ruins" // 遗迹共振:探险力 +5、生命 +30
| "void" // 虚空低语:洞见 ×2
| "core" // 星核悸动:接触率 ×3、脉冲 ×2
| "silence" // 寂静期:产能 ×0.5,但结束时补偿洞见
// v0.8.2 新增三种星潮(深化事件池)
| "surge" // 涌动星潮:晶体产出 ×2.5 但解码目标 +2(产能爆发但解码变难)
| "eclipse" // 蚀相星潮:探险 BOSS 胜率 +20% 但晶体产出 -30%(探险黄金期)
| "prism"; // 棱镜星潮:洞见获取 ×2 + 自动解码周期 -30%(解码黄金期)
| "silence"; // 寂静期:产能 ×0.5,但结束时补偿洞见
export interface StarTide {
type: TideType;
@@ -33,12 +29,6 @@ export interface TideModifiers {
expeditionHpBonus: number;
/** 寂静期结束时补偿的洞见(按持续秒数) */
silenceInsightPerSec?: number;
/** v0.8.2 涌动星潮:解码目标序列额外长度(+2 = 谜题更长更难) */
targetLenBonus?: number;
/** v0.8.2 蚀相星潮:BOSS 胜率加成(0.2 = +20% */
bossWinRateBonus?: number;
/** v0.8.2 棱镜星潮:自动解码周期乘数(0.7 = -30%) */
autoDecodeIntervalMult?: number;
}
export const TIDE_CONFIG = {
@@ -110,34 +100,9 @@ export const TIDE_EVENTS: Record<
icon: "◌",
negative: true,
},
// ===== v0.8.2 新增三种星潮 =====
surge: {
name: "涌动星潮",
desc: "深空晶体涌动,产能 ×2.5;但解码目标序列 +2,解码更耗时",
color: "#34d399",
glow: "rgba(52,211,153,0.55)",
icon: "⇈",
negative: false,
},
eclipse: {
name: "蚀相星潮",
desc: "维度蚀相,探险 BOSS 胜率 +20%;但晶体产出 -30%",
color: "#fb7185",
glow: "rgba(251,113,133,0.55)",
icon: "◐",
negative: false,
},
prism: {
name: "棱镜星潮",
desc: "棱镜折射,洞见获取 ×2、自动解码周期 -30%",
color: "#e879f9",
glow: "rgba(232,121,249,0.55)",
icon: "◬",
negative: false,
},
};
/** 权重表(寂静期概率略低v0.8.2 新增三种各 12 */
/** 权重表(寂静期概率略低) */
const TIDE_WEIGHTS: Record<TideType, number> = {
crystal: 22,
resonance: 20,
@@ -145,9 +110,6 @@ const TIDE_WEIGHTS: Record<TideType, number> = {
void: 18,
core: 14,
silence: 10,
surge: 12,
eclipse: 12,
prism: 12,
};
export function rollTide(rng: () => number = Math.random): TideType {
@@ -171,9 +133,6 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 0,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 1,
};
}
switch (tide.type) {
@@ -237,52 +196,6 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
silenceInsightPerSec: 0.4,
targetLenBonus: 0,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 1,
};
// ===== v0.8.2 新增三种星潮修饰器 =====
case "surge":
// 晶体产出 ×2.5,解码目标 +2(更长谜题 = 更耗时 = 更难)
return {
crystalsPerSecMult: 2.5,
decodeRewardMult: 1,
insightMultAdd: 0,
contactRateMult: 1,
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 2,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 1,
};
case "eclipse":
// BOSS 胜率 +20%,晶体产出 -30%
return {
crystalsPerSecMult: 0.7,
decodeRewardMult: 1,
insightMultAdd: 0,
contactRateMult: 1,
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 0,
bossWinRateBonus: 0.2,
autoDecodeIntervalMult: 1,
};
case "prism":
// 洞见 ×2(+100%),自动解码周期 ×0.7-30%)
return {
crystalsPerSecMult: 1,
decodeRewardMult: 1,
insightMultAdd: 1,
contactRateMult: 1,
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 0,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 0.7,
};
}
}
+10 -3
View File
@@ -37,11 +37,18 @@ export const TUTORIAL_STEPS: TutorialStep[] = [
body: "记忆晶体存满后,点击右侧「待解码晶体」开启谐振谜题:按目标色序依次点击相邻同色节点重建回路。\n\n解码成功 → 获得技术洞见 + 记忆碎片(叙事片段)。这是游戏的核心解谜层。",
expectAction: "decode-start",
},
{
id: "idle",
target: "tab-idle",
placement: "top",
title: "③ 放置工程 · 离线产出",
body: "「放置」标签可派遣工程项目(勘探/精炼/扩编无人机群等),完成后领取奖励——部分工程永久提升产能。\n\n顶部「放置中 +X/s」徽章实时显示离线产能,晶体球下方进度条追踪活跃工程。",
},
{
id: "tech",
target: "tab-tech",
placement: "top",
title: " 技术树 · 成长",
title: " 技术树 · 成长",
body: "用「技术洞见」在「技术」标签升级 4 大分支:产能 / 解码 / 仓库 / 探险。\n\n每个分支 3 级,策略性地分配洞见是关键。",
expectAction: "tech-buy",
},
@@ -49,7 +56,7 @@ export const TUTORIAL_STEPS: TutorialStep[] = [
id: "expedition",
target: "tab-expedition",
placement: "top",
title: " 遗迹探险 · 肉鸽",
title: " 遗迹探险 · 肉鸽",
body: "累积足够实力后,「探险」标签可深入遗迹:6 种节点(战斗/宝藏/抉择/解谜/休整/BOSS)程序化路径,有生命系统,失败保留奖励。\n\n这是放置之外的主动玩法层。",
expectAction: "expedition-start",
},
@@ -57,7 +64,7 @@ export const TUTORIAL_STEPS: TutorialStep[] = [
id: "prestige",
target: "prestige-btn",
placement: "bottom",
title: " 飞升 · 多周目",
title: " 飞升 · 多周目",
body: "「接触进度」满 100% 后可飞升:重置进度,获得永久蓝图加成 + 星图天赋(3 选 1 draft)+ 编年史记录。\n\n多周目叠加,越飞越强。还有每日信标挑战、6 种星潮事件等你探索。",
},
{
+71 -12
View File
@@ -45,15 +45,46 @@ export interface TechEffect {
value: number;
}
/** 深层叙事解锁条件类型 */
export type FragmentLoreUnlockKind =
| "ascensions"
| "totalDecoded"
| "contact"
| "fragmentCount"
| "bossKills";
/** 深层叙事解锁条件 */
export interface FragmentLoreUnlock {
kind: FragmentLoreUnlockKind;
/** 所需数值 */
value: number;
}
/** 碎片深层叙事层(v0.15 多层文本 — Dark Souls 式碎片叙事) */
export interface FragmentLoreLayer {
/** 层级:1 = 中层(隐秘注脚),2 = 深层(终极真相) */
layer: 1 | 2;
/** 该层的标题(如"隐秘注脚"、"深层回响" */
title: string;
/** 该层的叙事文本 */
text: string;
/** 解锁条件描述(用于 UI 提示) */
unlockHint: string;
/** 解锁条件 */
unlock: FragmentLoreUnlock;
}
/** 记忆碎片(叙事) */
export interface Fragment {
id: string;
era: number; // 1-5 纪元
title: string;
/** 拼出后的回响文本 */
/** L0 表层文本 — 拼出后的回响(默认解锁,现有字段) */
echo: string;
/** 解锁所需晶体解码次数门槛 */
threshold: number;
/** v0.15 多层叙事文本(L1/L2 深层,可选) */
loreLayers?: FragmentLoreLayer[];
}
/** 飞升(Prestige)加成 */
@@ -86,6 +117,36 @@ export interface DecodePuzzle {
seed: number;
}
/** 放置工程项目定义(v0.14 放置系统) */
export interface IdleProjectDef {
id: string;
name: string;
desc: string;
durationSec: number;
reward: {
crystals?: number;
insights?: number;
energy?: number;
contact?: number;
crystalsPerSecPermanent?: number;
randomFragment?: boolean;
};
minAscensions?: number;
minCrystalsPerSec?: number;
icon: string;
color: "emerald" | "rose" | "amber" | "fuchsia";
order: number;
}
/** 放置工程槽位 */
export interface IdleProjectSlot {
projectId: string;
startedAt: number;
finishesAt: number;
remainingSec: number;
completed: boolean;
}
/** 完整游戏状态 */
export interface GameState {
// 资源
@@ -125,13 +186,6 @@ export interface GameState {
lastEnergyTick: number;
totalExpeditions: number;
bossKills: number; // 累计击败 BOSS 数(跨周目)
/** v0.9 工单修复:上次探险结果摘要,用于入口界面展示 */
lastExpeditionSummary: {
result: "victory" | "defeat" | "abort";
rewards: { crystals: number; insights: number; contact: number };
nodeCount: number;
finishedAt: number;
} | null;
// 成就
achievements: Record<string, boolean>; // achievementId -> unlocked
@@ -154,6 +208,15 @@ export interface GameState {
attributeProgress: import("./attributes").AttributeProgress;
pendingAttrPoints: number;
// v0.14 放置系统
idleProjectSlots: (IdleProjectSlot | null)[];
idleProjectHistory: { projectId: string; finishedAt: number }[];
idlePermanentBonus: number;
idleStats: {
projectsCompleted: number;
crystalsFromIdle: number;
};
// 元
lastTick: number;
createdAt: number;
@@ -181,10 +244,6 @@ export interface ChronicleEntry {
blueprintsAfter: number;
crystalsDecoded: number;
crystalsDecodedThisRun: number;
/** v0.9 手写叙事:本周目 delta(旧存档可能缺失,调用方需兜底) */
techsThisRun?: number;
expeditionsThisRun?: number;
bossKillsThisRun?: number;
};
/** 本次飞升获得的天赋 ID(来自星图觉醒) */
perksThisAscension: string[];
+363 -266
View File
@@ -63,26 +63,19 @@ import {
import {
generateDailyChallenge,
generateWeeklyChallenge,
generateTimedChallenge,
loadDailyProgress,
loadWeeklyProgress,
loadTimedProgress,
loadChainState,
addBeaconProgress,
addWeeklyProgress,
addTimedProgress,
recordChainCompletion,
claimWeeklyReward,
claimTimedReward,
claimChainMilestone,
submitCloudScore,
getTodayKey,
type BeaconDailyChallenge,
type BeaconDailyProgress,
type BeaconWeeklyChallenge,
type BeaconWeeklyProgress,
type BeaconTimedChallenge,
type BeaconTimedProgress,
} from "@/lib/game/beacon";
import { setPendingOfflineReport } from "@/lib/game/offlineReport";
import {
@@ -96,6 +89,11 @@ import {
type CharacterAttributes,
type AttributeProgress,
} from "@/lib/game/attributes";
import {
getIdleProject,
getUnlockedIdleProjects,
} from "@/lib/game/idle";
import { toast } from "@/hooks/use-toast";
interface GameActions {
// 生命周期
@@ -108,10 +106,8 @@ interface GameActions {
pulse: () => { gain: number; combo: number } | null;
// 星潮
tickTide: (now: number) => TideEvent | null;
consumeTideEvents: () => TideEvent[];
/** P3 socket 多人同步:强制触发一道全球星潮(覆盖本地冷却,持续 60s) */
triggerGlobalTide: (type: TideType) => void;
tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null;
consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
// 解码
startDecode: (crystalId: string) => void;
@@ -127,8 +123,6 @@ interface GameActions {
resolveCurrentNode: () => ExpeditionResult | null;
advanceNode: () => void;
abortExpedition: () => void;
/** v0.9 工单修复:手动关闭已结束的探险,回到入口 */
dismissExpedition: () => void;
// 技术
buyTech: (techId: string) => boolean;
@@ -151,17 +145,12 @@ interface GameActions {
// 深空信标奖励发放(v0.5
grantBeaconReward: (insights: number, contact: number) => void;
// 深空信标 · 周挑战领取 + 信标链里程碑领取 + 限时挑战领取(v0.8 / v0.8.2
// 深空信标 · 周挑战领取 + 信标链里程碑领取v0.8
claimWeeklyBeacon: () => {
rewardInsight: number;
rewardContact: number;
score: number;
};
claimTimedBeacon: () => {
rewardInsight: number;
rewardContact: number;
score: number;
};
claimChainReward: (milestone: number) => {
rewardInsight: number;
rewardContact: number;
@@ -176,6 +165,14 @@ interface GameActions {
allocateAttribute: (attr: AttributeKey, points?: number) => { ok: boolean; leveledUp?: number };
gainAttributeExp: (attr: AttributeKey, amount: number) => { leveledUp: number; newLevel: number };
// v0.14 放置系统(Idle Operations
startIdleProject: (slotIndex: number, projectId: string) => void;
cancelIdleProject: (slotIndex: number) => void;
claimIdleProject: (slotIndex: number) => void;
/** v0.15 批量领取所有已完成的放置工程(单次 set + 单次 syncStats + 汇总 toast */
claimAllIdleProjects: () => void;
tickIdleProjects: (now: number) => void;
// 派生
canPrestige: () => boolean;
}
@@ -186,33 +183,11 @@ type Store = GameState & GameActions & {
_combo: number;
_lastPulse: number;
_achievementQueue: Achievement[];
_tideEvents: TideEvent[];
/** P3 全球星潮状态(ephemeral,不持久化) */
globalTide: GlobalTideState | null;
_tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
};
/** 星潮事件(UI 消费用 */
type TideEvent = {
started?: TideType;
ended?: TideType;
silenceCompensation?: number;
/** 是否来自全球星潮(P3 socket 同步) */
isGlobal?: boolean;
};
/** 全球星潮状态(与 StarTide 类似,但用于区分本地 vs 全球) */
type GlobalTideState = {
type: TideType;
startedAt: number;
endsAt: number;
id: string;
};
/** 全球星潮持续毫秒数(与 mini-service DURATION_SEC * 1000 一致) */
const GLOBAL_TIDE_DURATION_MS = 60_000;
/** 计算并写回产能字段 */
function syncStats(state: Partial<GameState>) {
/** 计算并写回产能字段(接收完整 state,确保 v0.14 idlePermanentBonus 等字段不丢失 */
function syncStats(state: Partial<GameState> & { idlePermanentBonus?: number }) {
const s = recomputeStats(state);
return {
crystalsPerSec: s.crystalsPerSec,
@@ -227,27 +202,24 @@ function syncStats(state: Partial<GameState>) {
}
/**
* v0.5 v0.8 v0.8.2
* v0.5 v0.8
* ///BOSS/
* 1.
* 2. 4
* 3.
* 4. grace
* 2.
* 3. grace
* localStorage GameState
* @returns UI
* @returns UI
*/
function trackBeacon(
type: "pulse" | "decode" | "expedition" | "boss" | "insight",
delta: number
): {
dailyJustCompleted: boolean;
timedJustCompleted: boolean;
weeklyJustCompleted: boolean;
newChainMilestones: number[];
} {
const result = {
dailyJustCompleted: false,
timedJustCompleted: false,
weeklyJustCompleted: false,
newChainMilestones: [] as number[],
};
@@ -268,20 +240,6 @@ function trackBeacon(
}
}
// ---- 限时挑战(v0.8.2----
const tChallenge: BeaconTimedChallenge = generateTimedChallenge();
if (tChallenge.type === type) {
const tCurrent: BeaconTimedProgress = loadTimedProgress();
if (tCurrent.completedAt === null) {
const { justCompleted } = addTimedProgress(
tCurrent,
tChallenge,
delta
);
result.timedJustCompleted = justCompleted;
}
}
// ---- 周挑战 ----
const wChallenge: BeaconWeeklyChallenge = generateWeeklyChallenge();
if (wChallenge.type === type) {
@@ -324,7 +282,6 @@ export const useGameStore = create<Store>()(
_lastPulse: 0,
_achievementQueue: [],
_tideEvents: [],
globalTide: null,
init: () => {
const s = get();
@@ -397,55 +354,24 @@ export const useGameStore = create<Store>()(
attributes: attrMigrated.attributes,
attributeProgress: attrMigrated.attributeProgress,
pendingAttrPoints: attrMigrated.pendingAttrPoints,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
} else {
set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, constellation, pendingPerkChoices, energyMax, chronicle: migrated.chronicle, runStart: migrated.runStart, bossKills: migrated.bossKills, starTidesEncountered: migrated.starTidesEncountered, attributes: attrMigrated.attributes, attributeProgress: attrMigrated.attributeProgress, pendingAttrPoints: attrMigrated.pendingAttrPoints, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }) });
set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, constellation, pendingPerkChoices, energyMax, chronicle: migrated.chronicle, runStart: migrated.runStart, bossKills: migrated.bossKills, starTidesEncountered: migrated.starTidesEncountered, attributes: attrMigrated.attributes, attributeProgress: attrMigrated.attributeProgress, pendingAttrPoints: attrMigrated.pendingAttrPoints, idleProjectSlots: s.idleProjectSlots ?? [null, null, null], idleProjectHistory: s.idleProjectHistory ?? [], idlePermanentBonus: s.idlePermanentBonus ?? 0, idleStats: s.idleStats ?? { projectsCompleted: 0, crystalsFromIdle: 0 }, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }) });
}
},
loadOnline: () => {
const s = get();
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) });
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }) });
},
hardReset: () => {
set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [], globalTide: null });
set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] });
},
tickTide: (now) => {
const s = get();
// === P3 全球星潮优先:若全球星潮进行中,本地不触发新的星潮 ===
if (s.globalTide) {
// 1a) 检查全球星潮是否结束
if (now >= s.globalTide.endsAt) {
const endedType = s.globalTide.type;
let silenceCompensation = 0;
if (endedType === "silence") {
silenceCompensation = computeSilenceCompensation({
type: endedType,
startedAt: s.globalTide.startedAt,
endsAt: s.globalTide.endsAt,
id: s.globalTide.id,
});
}
const event: TideEvent = { ended: endedType, isGlobal: true };
if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
set({
globalTide: null,
activeTide: null,
lastTideEnd: now,
insights: s.insights + silenceCompensation,
// 星潮结束后重算 stats
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }),
_tideEvents: [...s._tideEvents, event],
});
return event;
}
// 1b) 全球星潮仍在进行 — 不做本地处理(避免冲突)
return null;
}
const tide = s.activeTide;
// 星图「星潮引导」减少间隙
const cm = constellationBonuses(s.constellation ?? []);
@@ -453,7 +379,7 @@ export const useGameStore = create<Store>()(
const am = getAllBonuses(s.attributes ?? {});
const tideGapReduction = Math.min(0.3, am.tideTriggerBonus);
const gap = Math.max(15000, (TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000) * (1 - tideGapReduction));
// 2) 检查当前本地星潮是否结束
// 1) 检查当前星潮是否结束
if (tide && now >= tide.endsAt) {
const endedType = tide.type;
// 寂静期补偿洞见
@@ -462,19 +388,19 @@ export const useGameStore = create<Store>()(
silenceCompensation = computeSilenceCompensation(tide);
}
const newInsights = s.insights + silenceCompensation;
const event: TideEvent = { ended: endedType };
const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType };
if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
set({
activeTide: null,
lastTideEnd: now,
insights: newInsights,
// 星潮结束后重算 stats(移除 contactRate/insight 修饰)
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
_tideEvents: [...s._tideEvents, event],
});
return event;
}
// 3) 检查是否该触发新星潮(间隙已过)
// 2) 检查是否该触发新星潮(间隙已过)
if (!tide) {
const since = now - s.lastTideEnd;
// 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
@@ -488,7 +414,7 @@ export const useGameStore = create<Store>()(
startedAt: now,
endsAt: now + TIDE_CONFIG.duration,
};
const event: TideEvent = { started: type };
const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type };
// v0.4 编年史:累计遇到的星潮 ID(去重)
const tidesAll = s.starTidesEncountered ?? [];
const tideId = `tide_${type}`;
@@ -497,7 +423,7 @@ export const useGameStore = create<Store>()(
activeTide: newTide,
starTidesEncountered: newTidesAll,
// 星潮开始后重算 stats(应用 contactRate/insight 修饰)
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
_tideEvents: [...s._tideEvents, event],
});
return event;
@@ -514,37 +440,6 @@ export const useGameStore = create<Store>()(
return items;
},
// P3 socket 多人同步:服务端广播全球星潮时调用,强制覆盖本地冷却
triggerGlobalTide: (type) => {
const s = get();
const now = Date.now();
const id = `global_tide_${now}_${Math.random().toString(36).slice(2, 7)}`;
const newTide: StarTide = {
id,
type,
startedAt: now,
endsAt: now + GLOBAL_TIDE_DURATION_MS,
};
const newGlobalTide: GlobalTideState = {
type,
startedAt: now,
endsAt: now + GLOBAL_TIDE_DURATION_MS,
id,
};
// 编年史:累计遇到的星潮 ID(去重)
const tidesAll = s.starTidesEncountered ?? [];
const tideId = `tide_${type}`;
const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
const event: TideEvent = { started: type, isGlobal: true };
set({
globalTide: newGlobalTide,
activeTide: newTide, // 覆盖本地 activeTide(即使本地有进行中的星潮也会被替换)
starTidesEncountered: newTidesAll,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }),
_tideEvents: [...s._tideEvents, event],
});
},
tick: (now) => {
const s = get();
const dt = Math.max(0, (now - s.lastTick) / 1000);
@@ -646,7 +541,7 @@ export const useGameStore = create<Store>()(
set({
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
}
return { gain, combo };
@@ -656,10 +551,7 @@ export const useGameStore = create<Store>()(
const s = get();
const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
if (!crystal) return;
// v0.8.2 涌动星潮:解码目标序列 +2(更长谜题)
const tideMod = getTideModifiers(s.activeTide);
const targetLenBonus = tideMod.targetLenBonus ?? 0;
const puzzle = generatePuzzle(crystal.tier, undefined, targetLenBonus);
const puzzle = generatePuzzle(crystal.tier);
set({
activePuzzle: puzzle,
pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId),
@@ -717,7 +609,7 @@ export const useGameStore = create<Store>()(
fragments: tentative.fragments,
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
// 深空信标:解码 +1,洞见累计
trackBeacon("decode", 1);
@@ -775,22 +667,14 @@ export const useGameStore = create<Store>()(
// v0.7 智慧:自动解码周期 -X%
const am = getAllBonuses(s.attributes ?? {});
const baseInterval = 12000 + cm.autoDecodeIntervalDeltaSec * 1000;
// v0.8.2 棱镜星潮:自动解码周期 ×0.7(-30%)
const tideModEarly = getTideModifiers(s.activeTide);
const tideIntervalMult = tideModEarly.autoDecodeIntervalMult ?? 1;
const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult * tideIntervalMult);
const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult);
if (now - s._lastAutoDecode < interval) return;
// 找一颗 T1 晶体自动解码;v0.9 dec_5「全息解码核心」解锁后可自动解码 T2
let autoTier: CrystalTier = 1;
let idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
if (idx < 0 && s.tech?.dec_5) {
idx = s.pendingCrystals.findIndex((c) => c.tier === 2);
autoTier = 2;
}
// 找一颗 T1 晶体自动解码
const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
if (idx < 0) return;
const crystal = s.pendingCrystals[idx];
const tideMod = getTideModifiers(s.activeTide);
const base = decodeRewards(autoTier, s.insightMult, s.contactRateMult);
const base = decodeRewards(1, s.insightMult, s.contactRateMult);
const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
const rewards = {
crystals: Math.round(base.crystals * finalMult),
@@ -826,7 +710,7 @@ export const useGameStore = create<Store>()(
_lastAutoDecode: now,
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
// 深空信标:自动解码也算进度
trackBeacon("decode", 1);
@@ -844,7 +728,7 @@ export const useGameStore = create<Store>()(
set({
insights: s.insights - node.cost,
tech: newTech,
...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }),
...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
return true;
},
@@ -881,17 +765,14 @@ export const useGameStore = create<Store>()(
// 深拷贝
const exp = JSON.parse(JSON.stringify(s.activeExpedition));
// v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值)
// v0.8.2 蚀相星潮:BOSS 胜率 +20%
const nodeBefore = exp.nodes[exp.currentNode];
const isBossNode = nodeBefore?.type === "boss";
const am = getAllBonuses(s.attributes ?? {});
const tideMod = getTideModifiers(s.activeTide);
const tideBossBonus = tideMod.bossWinRateBonus ?? 0;
const result = isBossNode
? resolveNode(exp, () => {
// 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布)
const r = Math.random();
const b = Math.min(0.95, am.bossWinRateBonus + tideBossBonus);
const b = Math.min(0.95, am.bossWinRateBonus);
return r < b ? 0 : Math.min(1, r - b);
})
: resolveNode(exp);
@@ -925,23 +806,11 @@ export const useGameStore = create<Store>()(
};
const newLog = [logEntry, ...s.expeditionLog].slice(0, 30);
// v0.10 工单 #2 修复:探险结束时立即清空 activeExpedition,避免 finished 状态卡死
// 保留 lastExpeditionSummary 用于入口展示「上次结果」
if (result.ended) {
// 探险结束(胜利或失败)
exp.finished = true;
}
// v0.9 工单修复:探险结束时记录摘要,用于入口界面展示
let lastExpeditionSummary = s.lastExpeditionSummary ?? null;
if (result.ended && (result.endReason === "victory" || result.endReason === "defeat")) {
lastExpeditionSummary = {
result: result.endReason,
rewards: { ...exp.rewards },
nodeCount: exp.nodes.length,
finishedAt: Date.now(),
};
}
// v0.4 编年史:击破 BOSS 时累计计数
let bossKills = s.bossKills ?? 0;
let bossKilledThisNode = false;
@@ -989,8 +858,7 @@ export const useGameStore = create<Store>()(
}
set({
// v0.10 工单 #2 修复:探险结束立即清空 activeExpedition,让玩家无障碍再出发
activeExpedition: result.ended ? null : exp,
activeExpedition: exp,
crystals: newCrystals,
insights: newInsights,
contact: newContact,
@@ -999,9 +867,8 @@ export const useGameStore = create<Store>()(
bossKills,
attributes: newAttributes,
attributeProgress: newProgress,
lastExpeditionSummary,
...(statsNeedResync
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes })
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 })
: {}),
});
// 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破
@@ -1030,6 +897,7 @@ export const useGameStore = create<Store>()(
const s = get();
if (!s.activeExpedition) return;
const exp = JSON.parse(JSON.stringify(s.activeExpedition));
exp.finished = true;
const logEntry = {
expeditionId: exp.id,
nodeType: "rest" as const,
@@ -1037,28 +905,12 @@ export const useGameStore = create<Store>()(
rewards: "",
timestamp: Date.now(),
};
// v0.9 工单修复:记录上次探险摘要,用于入口展示
const summary = {
result: "abort" as const,
rewards: { ...exp.rewards },
nodeCount: exp.nodes.length,
finishedAt: Date.now(),
};
set({
// v0.10 工单 #2 修复:撤退立即清空 activeExpedition,避免 finished 卡死
activeExpedition: null,
activeExpedition: exp,
expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30),
lastExpeditionSummary: summary,
});
},
// v0.9 工单修复:手动关闭已结束探险,回到入口
dismissExpedition: () => {
const s = get();
if (!s.activeExpedition || !s.activeExpedition.finished) return;
set({ activeExpedition: null });
},
doPrestige: () => {
const s = get();
if (s.contact < CONTACT.prestigeMin) return null;
@@ -1070,7 +922,7 @@ export const useGameStore = create<Store>()(
set({
...next,
energyMax,
...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation, attributes: next.attributes }),
...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation, attributes: next.attributes, idlePermanentBonus: next.idlePermanentBonus ?? 0 }),
_lastAutoDecode: Date.now(),
_lastSpawn: Date.now(),
_combo: 0,
@@ -1102,7 +954,7 @@ export const useGameStore = create<Store>()(
pendingPerkChoices: null,
energyMax,
chronicle: newChronicle,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation, attributes: s.attributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation, attributes: s.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
return true;
},
@@ -1141,7 +993,7 @@ export const useGameStore = create<Store>()(
insights,
contact,
...(statsDirty
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes })
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 })
: {}),
_achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
});
@@ -1181,33 +1033,6 @@ export const useGameStore = create<Store>()(
insights: s.insights + Math.round(res.rewardInsight),
contact: Math.min(100, s.contact + res.rewardContact),
});
// 异步提交到云排行榜(失败静默,不影响本地)
if (res.entry) {
void submitCloudScore(res.entry);
}
}
return {
rewardInsight: res.rewardInsight,
rewardContact: res.rewardContact,
score: res.score,
};
},
// 深空信标:领取限时挑战奖励(v0.8.2)
claimTimedBeacon: () => {
const challenge = generateTimedChallenge();
const progress = loadTimedProgress();
const res = claimTimedReward(challenge, progress);
if (res.rewardInsight > 0 || res.rewardContact > 0) {
const s = get();
set({
insights: s.insights + Math.round(res.rewardInsight),
contact: Math.min(100, s.contact + res.rewardContact),
});
// 异步提交到云排行榜(失败静默,不影响本地)
if (res.entry) {
void submitCloudScore(res.entry);
}
}
return {
rewardInsight: res.rewardInsight,
@@ -1282,7 +1107,7 @@ export const useGameStore = create<Store>()(
contact: Math.min(100, s.contact + addContact),
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
},
@@ -1311,7 +1136,7 @@ export const useGameStore = create<Store>()(
attributes: newAttributes,
attributeProgress: newProgress,
pendingAttrPoints: s.pendingAttrPoints - alloc,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
return { ok: true, leveledUp: alloc };
},
@@ -1333,58 +1158,330 @@ export const useGameStore = create<Store>()(
set({
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
return { leveledUp: result.levelsGained, newLevel: result.newProgress.level };
},
// ============ v0.14 放置系统(Idle Operations ============
startIdleProject: (slotIndex, projectId) => {
if (slotIndex < 0 || slotIndex >= 3) return;
const s = get();
const slots = (s.idleProjectSlots ?? [null, null, null]).slice() as (
| import("@/lib/game/types").IdleProjectSlot
| null
)[];
if (slots[slotIndex] !== null) return;
const project = getIdleProject(projectId);
if (!project) return;
// 解锁校验
const unlocked = getUnlockedIdleProjects({
ascensions: s.ascensions,
crystalsPerSec: s.crystalsPerSec,
});
if (!unlocked.find((p) => p.id === projectId)) return;
// 不允许同一工程在多个槽位并行
const alreadyRunning = slots.some(
(slot) => slot !== null && slot.projectId === projectId && !slot.completed
);
if (alreadyRunning) return;
const now = Date.now();
slots[slotIndex] = {
projectId,
startedAt: now,
finishesAt: now + project.durationSec * 1000,
remainingSec: project.durationSec,
completed: false,
};
set({ idleProjectSlots: slots });
},
cancelIdleProject: (slotIndex) => {
if (slotIndex < 0 || slotIndex >= 3) return;
const s = get();
const slots = (s.idleProjectSlots ?? [null, null, null]).slice() as (
| import("@/lib/game/types").IdleProjectSlot
| null
)[];
slots[slotIndex] = null;
set({ idleProjectSlots: slots });
},
claimIdleProject: (slotIndex) => {
if (slotIndex < 0 || slotIndex >= 3) return;
const s = get();
const slots = (s.idleProjectSlots ?? [null, null, null]).slice() as (
| import("@/lib/game/types").IdleProjectSlot
| null
)[];
const slot = slots[slotIndex];
if (!slot || !slot.completed) return;
const project = getIdleProject(slot.projectId);
if (!project) {
slots[slotIndex] = null;
set({ idleProjectSlots: slots });
return;
}
const r = project.reward;
const addCrystals = r.crystals ?? 0;
const addInsights = r.insights ?? 0;
const addEnergy = r.energy ?? 0;
const addContact = r.contact ?? 0;
const permBonus = r.crystalsPerSecPermanent ?? 0;
// 随机记忆碎片:从未解锁的碎片中随机一个
let unlockedFragmentTitle: string | null = null;
if (r.randomFragment) {
const lockedFrags = FRAGMENTS.filter((f) => !s.fragments[f.id]);
if (lockedFrags.length > 0) {
const pick =
lockedFrags[Math.floor(Math.random() * lockedFrags.length)];
s.fragments[pick.id] = true;
unlockedFragmentTitle = pick.title;
}
}
// 永久产能加成累积
const newPermBonus = (s.idlePermanentBonus ?? 0) + permBonus;
const newIdleStats = {
projectsCompleted: (s.idleStats?.projectsCompleted ?? 0) + 1,
crystalsFromIdle:
(s.idleStats?.crystalsFromIdle ?? 0) + addCrystals,
};
// 历史(上限 50
const newHistory = [
...(s.idleProjectHistory ?? []),
{ projectId: project.id, finishedAt: Date.now() },
].slice(-50);
slots[slotIndex] = null;
set({
idleProjectSlots: slots,
fragments: { ...s.fragments },
crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
insights: s.insights + addInsights,
energy: Math.min(s.energyMax, s.energy + addEnergy),
contact: Math.min(100, s.contact + addContact),
idlePermanentBonus: newPermBonus,
idleStats: newIdleStats,
idleProjectHistory: newHistory,
...syncStats({
tech: s.tech,
blueprints: s.blueprints,
achievements: s.achievements,
activeTide: s.activeTide,
constellation: s.constellation,
attributes: s.attributes,
idlePermanentBonus: newPermBonus,
}),
});
// Toast 提示
const descParts: string[] = [];
if (addCrystals > 0) descParts.push(`+${addCrystals} 晶体`);
if (addInsights > 0) descParts.push(`+${addInsights} 洞见`);
if (addEnergy > 0) descParts.push(`+${addEnergy} 能量`);
if (addContact > 0) descParts.push(`+${addContact} 接触`);
if (permBonus > 0) descParts.push(`永久 +${permBonus}/s`);
if (unlockedFragmentTitle) descParts.push(`记忆碎片「${unlockedFragmentTitle}`);
toast({
title: "✦ 工程奖励已领取",
description: `${project.name} · ${descParts.join(" · ")}`,
});
},
tickIdleProjects: (now) => {
const s = get();
const oldSlots = (s.idleProjectSlots ?? [null, null, null]) as (
| import("@/lib/game/types").IdleProjectSlot
| null
)[];
const slots = oldSlots.slice() as (
| import("@/lib/game/types").IdleProjectSlot
| null
)[];
let changed = false;
// v0.15:收集「刚完成」的工程(旧值 !completed → 新值 completed),用于一次性 toast
const justCompletedProjects: import("@/lib/game/idle").IdleProjectDef[] = [];
for (let i = 0; i < slots.length; i++) {
const slot = slots[i];
if (!slot || slot.completed) continue;
const remainingSec = Math.max(0, (slot.finishesAt - now) / 1000);
if (remainingSec <= 0 && !slot.completed) {
slots[i] = { ...slot, remainingSec: 0, completed: true };
changed = true;
// 状态转变瞬间收集(旧 slot.completed 必为 false,新值为 true
const proj = getIdleProject(slot.projectId);
if (proj) justCompletedProjects.push(proj);
} else if (Math.abs(remainingSec - slot.remainingSec) > 0.05) {
slots[i] = { ...slot, remainingSec };
changed = true;
}
}
if (changed) {
set({ idleProjectSlots: slots });
}
// v0.15:循环结束后只触发一次 toast(即使多个工程同时完成也只弹一条汇总)
if (justCompletedProjects.length > 0) {
const summary = justCompletedProjects
.map((p) => `${p.icon} ${p.name}`)
.join(" · ");
toast({
title: "✦ 放置工程完成",
description: `${summary} · 可前往「放置」标签领取`,
});
}
},
claimAllIdleProjects: () => {
const s = get();
const slots = (s.idleProjectSlots ?? [null, null, null]).slice() as (
| import("@/lib/game/types").IdleProjectSlot
| null
)[];
// 收集所有已完成槽位
const completedIndices: number[] = [];
for (let i = 0; i < slots.length; i++) {
const slot = slots[i];
if (slot && slot.completed) completedIndices.push(i);
}
// 无可领取 → 直接返回(不弹 toast)
if (completedIndices.length === 0) return;
// 累加所有奖励
let addCrystals = 0;
let addInsights = 0;
let addEnergy = 0;
let addContact = 0;
let permBonus = 0;
let projectsCount = 0;
const unlockedFragmentTitles: string[] = [];
// fragments 副本(避免多次修改 s.fragments
const newFragments = { ...s.fragments };
for (const idx of completedIndices) {
const slot = slots[idx];
if (!slot) continue;
const project = getIdleProject(slot.projectId);
if (!project) {
slots[idx] = null;
continue;
}
const r = project.reward;
addCrystals += r.crystals ?? 0;
addInsights += r.insights ?? 0;
addEnergy += r.energy ?? 0;
addContact += r.contact ?? 0;
permBonus += r.crystalsPerSecPermanent ?? 0;
projectsCount += 1;
// 随机记忆碎片:从未解锁的碎片中随机一个
if (r.randomFragment) {
const lockedFrags = FRAGMENTS.filter((f) => !newFragments[f.id]);
if (lockedFrags.length > 0) {
const pick =
lockedFrags[Math.floor(Math.random() * lockedFrags.length)];
newFragments[pick.id] = true;
unlockedFragmentTitles.push(pick.title);
}
}
// 清空该槽位
slots[idx] = null;
}
// 历史(上限 50)— 批量追加所有刚领取的工程
const finishedAt = Date.now();
const newHistory = [
...(s.idleProjectHistory ?? []),
...completedIndices
.map((idx) => {
const slot = (s.idleProjectSlots ?? [])[idx];
return slot ? { projectId: slot.projectId, finishedAt } : null;
})
.filter((x): x is { projectId: string; finishedAt: number } => x !== null),
].slice(-50);
// 永久产能加成累积
const newPermBonus = (s.idlePermanentBonus ?? 0) + permBonus;
const newIdleStats = {
projectsCompleted:
(s.idleStats?.projectsCompleted ?? 0) + projectsCount,
crystalsFromIdle: (s.idleStats?.crystalsFromIdle ?? 0) + addCrystals,
};
// 一次 set + 一次 syncStats
set({
idleProjectSlots: slots,
fragments: newFragments,
crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
insights: s.insights + addInsights,
energy: Math.min(s.energyMax, s.energy + addEnergy),
contact: Math.min(100, s.contact + addContact),
idlePermanentBonus: newPermBonus,
idleStats: newIdleStats,
idleProjectHistory: newHistory,
...syncStats({
tech: s.tech,
blueprints: s.blueprints,
achievements: s.achievements,
activeTide: s.activeTide,
constellation: s.constellation,
attributes: s.attributes,
idlePermanentBonus: newPermBonus,
}),
});
// 汇总 toast
const descParts: string[] = [
`共领取 ${projectsCount} 项工程`,
`+${addCrystals} 晶体`,
`+${addInsights} 洞见`,
];
if (addEnergy > 0) descParts.push(`+${addEnergy} 能量`);
if (addContact > 0) descParts.push(`+${addContact} 接触`);
if (permBonus > 0) descParts.push(`永久 +${permBonus}/s`);
if (unlockedFragmentTitles.length > 0) {
descParts.push(
`记忆碎片 ×${unlockedFragmentTitles.length}`
);
}
toast({
title: "✦ 批量领取完成",
description: `${descParts.join(" · ")}`,
});
},
}),
{
name: "echo-nexus-save-v1",
version: 2,
version: 1,
storage: createJSONStorage(() => localStorage),
// v0.12 存档迁移:旧存档(v0/v1key=echo-nexus-save-v1)补全新增字段,防崩溃
migrate: (persistedState: unknown, version: number) => {
void version; // v0/v1 统一走合并迁移,无需分支
void version;
const s = (persistedState || {}) as Record<string, unknown>;
const base = createInitialState();
// 合并:以当前 INITIAL_STATE 为基底,旧存档字段覆盖,缺失字段用默认值
const migrated = {
...base,
return {
...s,
// v0.8+ 新增字段兜底
bossKills: (s.bossKills as number) ?? 0,
starTidesEncountered: (s.starTidesEncountered as string[]) ?? [],
chronicle: (s.chronicle as unknown[]) ?? [],
runStart: (s.runStart as number) ?? Date.now(),
// v0.8.2 attributes 系统
attributes: (s.attributes as Record<string, number>) ?? base.attributes,
attributeProgress: (s.attributeProgress as unknown) ?? base.attributeProgress,
pendingAttrPoints: (s.pendingAttrPoints as number) ?? 0,
// v0.10 探险系统字段兜底
activeExpedition: (s.activeExpedition as unknown) ?? null,
totalExpeditions: (s.totalExpeditions as number) ?? 0,
lastExpeditionSummary: (s.lastExpeditionSummary as unknown) ?? null,
// 星图兜底
constellation: (s.constellation as string[]) ?? [],
pendingPerkChoices: (s.pendingPerkChoices as unknown[]) ?? [],
// 技术树/碎片/成就兜底(防旧存档为 undefined)
tech: (s.tech as Record<string, number>) ?? {},
fragments: (s.fragments as Record<string, boolean>) ?? {},
achievements: (s.achievements as Record<string, boolean>) ?? {},
// v0.14 放置系统字段补全
idleProjectSlots:
Array.isArray(s.idleProjectSlots) && s.idleProjectSlots.length === 3
? s.idleProjectSlots
: [null, null, null],
idleProjectHistory: Array.isArray(s.idleProjectHistory)
? s.idleProjectHistory
: [],
idlePermanentBonus:
typeof s.idlePermanentBonus === "number" ? s.idlePermanentBonus : 0,
idleStats:
s.idleStats && typeof s.idleStats === "object"
? s.idleStats
: { projectsCompleted: 0, crystalsFromIdle: 0 },
} as GameState;
// 丢弃不可解的活跃谜题(旧算法生成)
if (migrated.activePuzzle && typeof migrated.activePuzzle === "object") {
const p = migrated.activePuzzle as { nodes?: unknown[] };
if (!p.nodes || !Array.isArray(p.nodes) || p.nodes.length === 0) {
migrated.activePuzzle = null;
}
}
return migrated;
},
// 不持久化临时字段
partialize: (s) => {
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, globalTide, ...rest } = s;
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents; void globalTide;
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s;
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents;
return rest as GameState;
},
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+571
View File
@@ -0,0 +1,571 @@
1→# 回响星核 / Echo Nexus — 开发工作日志
2→
3→> 持续性任务的工作交接文档。每个阶段更新,避免进度丢失。
4→
5→---
6→
7→## 一、项目当前状态描述 / 判断
8→
9→### 概况
10→- **项目**:回响星核 / Echo Nexus — 深空考古放置策略游戏
11→- **一句话**:自治无人机采矿 → 解码记忆晶体(原创共振谜题)→ 拼凑碎片叙事 → 飞升多周目。差异化:解码谜题驱动叙事涌现,全息晶体 + 深空粒子美学。
12→- **当前版本****v0.7**CrystalOrb Canvas 粒子系统 + 角色属性系统)
13→- **在线游玩**https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
14→- **仓库**https://git.atdunbg.xyz/Super_Z/echo-nexus
15→- **技术栈**Next.js 16 (App Router) + TypeScript + Tailwind + shadcn/ui + Canvas 2D + Zustand + Web Audio API
16→- **定时任务**:每 15 分钟一次 `webDevReview``fixed_rate` + `"900"` 秒,priority=10job_id 228266)。正常完成不会被删除,无需自持续机制。
17→
18→### 状态判断
19→- dev 服务器运行正常(HTTP 200,编译 < 250ms
20→- v0.7 两大新功能已完成:CrystalOrb Canvas 粒子系统升级(VLM 8/10+ 角色属性系统(VLM 7/10
21→- 系统总体稳定,无阻塞性 bug,可继续推进新功能
22→
23→### 已完成版本里程碑(精简)
24→| 版本 | 核心内容 |
25→|------|---------|
26→| v0.1 | MVP:放置采矿 + 解码共振谜题 + 技术树 + 记忆图谱 + 飞升 + 统计 |
27→| v0.1.1 | 解码可解性修复(路径构造法生成器,100% 可解率)|
28→| v0.2 | 遗迹探险肉鸽系统(能量/探险力/6 节点/程序化路径/BOSS)|
29→| v0.2.1 | 程序化音频(15 种音效)+ 14 项成就 + 视觉打磨 |
30→| v0.3 | 星潮事件系统(6 种星潮 + 全屏叠层 + 程序化音效)|
31→| v0.3.1 | 星图天文台元进程(6 类 × 3 = 18 天赋 + 飞升后 3 选 1 draft + Canvas 星图)|
32→| v0.4 | 回响编年史(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复)|
33→| v0.5 | 深空信标(每日挑战 + 本地排行榜 Top20 + 7 标签页 + 编年史历史 BUG 修复)|
34→| v0.5.1 | 静态部署 Gitea Pages + 新手教程系统(7 步聚光灯引导)+ UI 间距优化 |
35→| v0.5.2 | 离线收益报告弹窗 + 标签页 UI 重设计 + persist rehydrate 竞态修复 |
36→| v0.6 | 深空巡航 Canvas 2D 实时玩法 + UI 偏移重叠修复 |
37→| **v0.7** | **CrystalOrb Canvas 粒子系统升级 + 角色属性系统(四维)** |
38→
39→### 核心系统清单(8 大系统)
40→1. **放置采矿** — 中央晶体球 + 主动脉冲 + 连击加成(`CrystalOrb.tsx`
41→2. **谐振解码** — 原创路径谜题,路径构造法 100% 可解(`DecodeArray.tsx` + `engine.ts`
42→3. **技术树** — 三层科技解锁,提升产能/解码/探险(`TechTree.tsx`
43→4. **遗迹探险** — 肉鸽系统,6 种节点 + 程序化路径 + BOSS`ExpeditionPanel.tsx` + `expedition.ts`
44→5. **星潮事件** — 6 种动态事件,为放置循环注入变化(`StarTide.tsx` + `starTide.ts`
45→6. **星图天文台** — 飞升后 3 选 1 天赋 draft,跨周目永久生效,18 个天赋(`ConstellationPanel.tsx` + `constellation.ts`
46→7. **回响编年史** — 跨周目叙事时间轴,5 纪元循环命名(`ChronicleDialog.tsx` + `chronicle.ts`
47→8. **深空信标** — 每日挑战 + 本地排行榜 Top20`BeaconPanel.tsx` + `beacon.ts`
48→9. **深空巡航** — Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关(`CruiseMode.tsx` + `cruise.ts`)【v0.6】
49→10. **角色属性系统** — 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点,影响所有系统(`AttributesPanel.tsx` + `attributes.ts`)【v0.7 新增】
50→
51→---
52→
53→## 二、当前目标 / 已完成的修改 / 验证结果
54→
55→### v0.7 CrystalOrb Canvas 粒子系统升级(已完成)
56→**动机**:P0 优先级 — 将中央晶体球从 CSS 动画升级为 Canvas 粒子视觉盛宴,提升视觉震撼。
57→
58→**重写文件**`src/components/game/CrystalOrb.tsx`
59→- 从纯 CSS 动画升级为 Canvas 2D 粒子系统
60→- **多层粒子**
61→ - 环绕能量粒子(3层14/10/8个,emerald/fuchsia/rose三色,不同速度方向环绕晶核,带拖尾)
62→ - 环境星尘(40个,缓慢漂移 + 闪烁)
63→ - 点击爆发粒子(10-30个径向发散,颜色随连击等级变色)
64→ - 冲击波环(点击触发1-3层错峰扩散,连击≥3/5解锁更多层)
65→- **晶核升级**:径向渐变辉光 + 3层六边形纹理旋转 + 高光 + 鼠标视差偏移
66→- **呼吸动画**:晶核 + 外层辉光随时间呼吸(sin波)
67→- **进度环**SVG渐变环(emerald→fuchsia→rose)保留
68→- **交互保留**:点击脉冲、连击Toast、浮动数字、音效、`data-tut="crystal-orb"` 锚点
69→- **性能**DPR cap 2ResizeObserver 自适应,requestAnimationFrame 60fps
70→
71→**QA 验证**agent-browser + VLM):
72→- VLM 视觉评分 **8/10**:晶体球清晰可见,多层辉光,环绕粒子动态自然,无渲染问题
73→- 点击交互:冲击波环 ✅ + 爆发粒子 ✅,评分 7/10
74→- lint 零错误;HTTP 200
75→
76→### v0.7 角色属性系统(已完成)
77→**动机**:P1 优先级 — 增加RPG深度,四维属性影响所有游戏系统。
78→
79→**新增文件**
80→- `src/lib/game/attributes.ts`~330行逻辑层):CharacterAttributes/AttributeProgress 类型、ATTRIBUTE_CONFIG 元信息、getAttributeBonus 两区加成公式(0-50线性+0.5%/点,50-100递减+0.2%/点)、getAllBonuses 12项修饰器、levelUpCheck 自动升级、computePrestigeAttrPoints、migrateAttributes 旧存档兼容
81→- `src/components/game/AttributesPanel.tsx`~330行 UI):四维卡片网格(小屏2×2/大屏1×4)、图标+中英文名+Lv.badge+数值/100+加成%+经验条+影响列表+分配按钮、顶部待分配点数badge闪烁、底部总览+12项修饰器明细
82→
83→**修改文件**
84→- `types.ts`GameState 新增 attributes/attributeProgress/pendingAttrPoints
85→- `config.ts`INITIAL_STATE 补全默认值
86→- `engine.ts`recomputeStats 聚合属性加成;performPrestige 发放属性点(ascensions×2+1
87→- `gameStore.ts`:新增 allocateAttribute/gainAttributeExp actionspulse/clickNode/autoDecodeTick/resolveCurrentNode/grantCruiseReward/tickTide 全部接入属性经验;init() 调用 migrateAttributes
88→- `achievements.ts`:新增 ach_attr_total_50 + ach_attr_max_100 两项成就
89→- `page.tsx`grid-cols-7→8,新增「角色」标签页(User图标),红点提示,StatsPanel新增5行属性行,版本号 v0.6→v0.7
90→
91→**四维属性设计**
92→- 探索力(emerald):探险力+X%/巡航飞船速度+X%
93→- 智慧(fuchsia):解码步数+X/洞见+X%/自动解码周期-X%
94→- 勇气(amber):探险生命+X/BOSS胜率+X%/巡航护盾+X
95→- 灵感(rose):接触率+X/星潮概率+X/脉冲连击+X%
96→
97→**QA 验证**agent-browser + VLM):
98→- VLM 视觉评分 **7/10**(面板)/ **8/10**(待分配状态):四维卡片可见、数值/等级/加成/经验条齐全、四色全息
99→- 功能验证:setState pendingAttrPoints=5 → 显示5点待分配 → 点击分配按钮 → exploration 0→1, pending 5→4 ✅
100→- lint 零错误;HTTP 200
101→
102→---
103→
104→### v0.6 深空巡航 Canvas 2D 实时玩法(历史记录)
105→**动机**:回应用户反馈「玩法太单调(全是点点点)」+「视觉震撼不足」——首次引入实时操作玩法。
106→
107→**新增文件**
108→- `src/lib/game/cruise.ts`~520 行逻辑层)
109→ - 类型系统:CruiseEntity 联合类型(ship/asteroid/storm/crystal/insight/beacon/stargate/particle 共 8 种)
110→ - mulberry32 + FNV-1a 种子化 RNG`cruiseSeed(level)`
111→ - `generateLevel`:程序化生成陨石带(8+lvl×2)、虚空风暴(1+lvl/2)、晶体碎片(5+lvl)、洞见光球(2+lvl/3)、信标(1+lvl/4)、星门
112→ - `updateCruise`:物理推进 + 碰撞检测 + 粒子系统 + 震动 + 600ms 无敌帧
113→ - `computeRewards`crystals=碎片×(8+lvl×2)、insights=洞见×(2+lvl×0.5)、contact=信标×(1.5+lvl×0.3);通关×1.5、失败×0.5
114→ - 独立 localStorage `echo-nexus-cruise-v1`:最高分/累计通关/最高关卡/累计奖励/最近 20 局
115→
116→- `src/components/game/CruiseMode.tsx`~830 行渲染层)
117→ - 全屏 fixed inset-0 z-50 CanvasDPR cap 2resize 监听
118→ - 3 层视差星空:远(100星)/中(60星)/近(30星),独立漂移 + 飞船速度视差 + 闪烁
119→ - 8 种实体全部 ctx.shadowBlur 辉光绘制
120→ - 粒子系统:尾焰/收集/碰撞/烟花,上限 200
121→ - 屏幕震动 280ms 衰减;低护盾(<30%)红色边框脉冲
122→ - HUDHTML 叠层,glass+backdrop-blur80ms 节流):护盾/能量/分数/用时/收集计数
123→ - 控制:桌面 WASD/方向键 8 方向 + 移动端虚拟摇杆 + P 暂停 + Esc 退出 + 失焦自动暂停
124→ - 阶段:ready → playing → won/lost(结算明细 + 奖励 + 下一关/重试/返回)
125→
126→**修改文件**
127→- `src/app/page.tsx`header 新增 amber 主题「巡航」按钮(Navigation 图标)+ cruiseOpen state + 条件渲染 CruiseMode
128→- `src/store/gameStore.ts`:新增 `grantCruiseReward({crystals?, insights?, contact?})` actioncrystals 受 crystalCap 限制,contact 受 100 上限)
129→- 版本号 v0.5.2 → v0.6
130→
131→**UI 偏移/重叠 BUG 修复**(3 处):
132→- `DecodeArray.tsx`:移除 className 冲突的 `-translate-x-1/2 -translate-y-1/2`,统一用 inline transform 定位+缩放
133→- `AchievementsPanel.tsx`:移除内层 `max-h-[300px]`,改 `flex-1 min-h-0` 自适应父容器
134→- `page.tsx`:调整三个 section 的 min-h,避免小屏单列时总高度超过手机视口
135→
136→**色彩规范**:严格遵循 emerald(#34d399) / rose(#fb7185) / amber(#fbbf24) / fuchsia(#e879f9) 四色全息色谱,零蓝色/靛色
137→
138→**QA 验证**agent-browser + VLM):
139→- lint 零错误;dev 服务器 HTTP 200;编译 < 250ms
140→- VLM 确认主界面:巡航按钮存在、晶体球居中、解码区正常、无重叠偏移
141→- VLM 确认巡航玩法:飞船可见(绿色三角形)、HUD 完整、陨石+星空背景、粒子特效(黄色尾焰+紫色辉光)
142→- WASD 控制飞船移动,按 W 直冲星门 → 通关,奖励 +30 晶体(2×10×1.5=30 计算正确)
143→- localStorage 正确记录 highScore/totalRuns/totalWins/bestLevel
144→- 奖励同步 gameStoregrantCruiseReward,满仓时 cap 逻辑正确)
145→- 退出返回主界面,7 标签页 + 晶体球 + 解码面板均完好
146→
147→---
148→
149→## 三、未解决问题或风险 / 下一阶段优先事项
150→
151→### 已知问题 / 风险
152→1. **dev 服务器 Turbopack 缓存偶发损坏**`Unable to open static sorted file` + 进程被杀,需 `rm -rf .next` 重启。静态导出构建稳定可靠。
153→2. **定时任务调度类型**:必须用 `fixed_rate` + `"900"` 秒(15分钟),不要用 cron 表达式。`webDevReview` 类型任务正常完成不会被删除,只有抛出未捕获异常才会被标记失败。
154→3. **v0.3 星潮为单机版**:原计划 socket 全局事件,后续可扩展为多人同步。
155→4. **v0.5 信标排行榜为本地版**(纯 localStorage):升级云排行榜需后端 API。
156→5. **v0.5 每日挑战仅 1 个/天**:可加入"周挑战"或"信标链"(连续完成 N 天奖励)。
157→6. **探险 BOSS 战胜率较低**:基础探索力 10 vs BOSS 难度 5-7,胜率约 25%,需玩家投资探险技术。
158→7. **探险能量恢复较慢**:45s/点,可加技术提升恢复速度。
159→8. **编年史上限 50 条**:超过自动丢弃最早的;v0.4 之前飞升无回填(空状态有提示)。
160→9. **5 纪元叙事为模板化生成**:v0.5+ 将加入手写剧情节点。
161→10. ~~**CrystalOrb 仍为 CSS 动画**~~ → ✅ **v0.7 已完成 Canvas 粒子系统升级**。
162→
163→### 下一阶段优先级(v0.7 后修正版)
164→> 工单修正:原 P0 CrystalOrb Canvas + P1 角色属性两项均已在 v0.7 完成,现重新排序。
165→
166→1. **🔴 P0 — 巡航玩法大增强(v0.8 主线)**:BOSS 战关卡(每 5 关一个 BOSS,三阶段攻击模式 + 飞船射击系统)/ 道具掉落(5 种 powerup:护盾/能量/急速/超载/磁吸)/ 事件选择节点(每关通关后 3 选 1 强化牌,10 种强化)。这是当前最高优先级,能显著提升巡航玩法的深度与可玩性。
167→2. **🟠 P1 — 探险 BOSS 战胜率平衡 + 探险能量恢复**:修复已知问题 6/7(探险 BOSS 胜率 25% 偏低,能量 45s/点 偏慢),提升探险体验。
168→3. **🟠 P1 — 信标系统扩展**:周挑战 / 信标链(连续完成 N 天奖励),修复已知问题 5。
169→4. **🟡 P2 — UI 样式细节打磨**:标签页图标/文字比例、视觉层次强化、移动端响应式进一步优化、CrystalOrb 周边留白优化(VLM 指出主界面左侧有空白)。
170→5. **🟡 P2 — 云存档 + 云排行榜**:v0.5+ 后端 API(需评估是否引入 mini-service)。
171→6. **🟢 P3 — 全 5 纪元手写叙事节点**:替换模板化 lore。
172→7. **🟢 P3 — socket 多人同步星潮**:全局事件共享。
173→
174→### 定时任务(fixed_rate · 900s
175→- **当前 job_id**: **228357**2026-06-24 13:25 重建)
176→- **配置**: `fixed_rate` + `"900"`15分钟间隔),`priority=10`(高优先级),`payload.kind=webDevReview``tz=Asia/Shanghai`
177→- **任务名**: Echo Nexus - 任务审查与持续迭代
178→- **任务描述强制要求**:每次触发 MUST 先完整阅读 worklog.md 工单,以工单为准,不凭记忆工作
179→- **正常完成不会被删除**,只有未捕获异常会标记失败。若任务列表为空,需用 cron create 重建(schedule.kind=fixed_rate, expr="900", priority=10, payload.kind=webDevReview)。
180→- **job_id 历史**: 227909(失效) → 228130(失效) → 228172(失效) → 228186(失效) → 228218(删除重建) → 228236(cron版,删除重建) → 228266(fixed_rate,失效) → 228329(失效) → **228357(当前,重建)**
181→
182→---
183→
184→## 定时任务描述模板(webDevReview · fixed_rate
185→
186→> 重建参数:schedule.kind=`fixed_rate`, schedule.expr=`"900"`, params.priority=`10`, payload.kind=`webDevReview`
187→
188→```
189→请先判断当前项目状态,使用 agent-browser 进行必要测试和 QA,然后自主选择本轮最合适的工作重点并继续开发。
190→
191→要求:
192→1. 开始任务时查看 /home/z/my-project/worklog.md 了解实际进展,拆解详细todo
193→2. 使用 agent-browser 进行测试和QA,有bug优先修复
194→3. 如果稳定,自主提出新需求继续推进
195→4. 样式要越做细节越多!!
196→5. 功能要越做越多!!
197→6. 工作告一段落更新 /home/z/my-project/worklog.md
198→7. 项目结束后一定要把工作记录写在 worklog.md 里
199→
200→交接文档建议包含三部分:
201→- 项目当前状态描述/判断
202→- 当前目标/已完成的修改/验证结果
203→- 未解决问题或风险,建议下一阶段优先事项
204→```
205→
206→---
207→
208→## 历史详细版本记录(按需查阅 docs/repo/docs/
209→
210→- `04-解码系统修复-v0.1.1.md`
211→- `05-遗迹探险系统-v0.2.md`
212→- `06-音频与成就系统-v0.2.1.md`
213→- `07-星潮事件系统-v0.3.md`
214→- `08-星图天文台系统-v0.3.1.md`
215→- `09-回响编年史系统-v0.4.md`
216→- `10-深空信标系统-v0.5.md`
217→
218→---
219→
220→Task ID: 7
221→Agent: full-stack-developer
222→Task: 角色属性系统(探索力/智慧/勇气/灵感)
223→
224→Work Log:
225→- 阅读 worklog.md 了解 v0.6 项目背景,确认 8 大系统架构与四色全息色彩规范
226→- 新建 `src/lib/game/attributes.ts` 逻辑层(~330 行):
227→ - 定义 CharacterAttributes/AttributeProgress/AttributeKey 类型 + ATTRIBUTE_CONFIG 元信息
228→ - getAttributeBonus 线性+递减两区加成公式(0-50 每 10 点 +5%50-100 每点 +2%
229→ - getAllBonuses 返回 12 个修饰器(探险力/巡航速度/解码步数/洞见/自动解码周期/探险生命/BOSS 胜率/巡航护盾/接触率/星潮触发/脉冲连击/产能)
230→ - levelUpCheck 自动跨多级 + expRequiredForLevel + computePrestigeAttrPoints + migrateAttributes 兼容
231→- 扩展 `src/lib/game/types.ts` GameState:新增 attributes/attributeProgress/pendingAttrPoints
232→- 扩展 `src/lib/game/config.ts` INITIAL_STATE:补全默认值(0 数值 + 空 progress
233→- 扩展 `src/lib/game/engine.ts`
234→ - recomputeStats 末尾聚合 am.crystalsPerSecMult/insightMultAdd/contactRateMult/decodeStepsBonus
235→ - performPrestige 保留 attributes 数值,清空 attributeProgress,发放 ascensions × 2 + 1 点属性点
236→ - createInitialState 每次返回全新对象避免引用共享
237→- 扩展 `src/store/gameStore.ts`(核心改造,10+ 处 syncStats 调用全部补 attributes 参数):
238→ - pulse:连击 ≥3 给灵感经验 + 脉冲威力乘灵感连击加成
239→ - clickNode 完成:给智慧经验(tier × 2)
240→ - autoDecodeTick:智慧经验 +1 + 自动解码周期受 am.autoDecodeIntervalMult 影响
241→ - startExpedition:探险力乘 am.expeditionPowerMult,生命加 am.expeditionHpBonus
242→ - resolveCurrentNodeBOSS 节点包装 RNG 提升 +am.bossWinRateBonus 胜率;战斗胜利给勇气+探索力经验
243→ - grantCruiseReward:按总奖励量给探索力+勇气经验
244→ - tickTide:灵感 am.tideTriggerBonus 缩短星潮间隙(上限 30%)
245→ - init() 调用 migrateAttributes 补全旧存档
246→ - 新增 allocateAttribute / gainAttributeExp 两个 action
247→- 新建 `src/components/game/AttributesPanel.tsx` UI 面板(~330 行):
248→ - 四维卡片网格(小屏 2×2,大屏 1×4)
249→ - 每张卡片:图标 + 中英文名 + Lv.badge + 数值/100 + 加成% + 经验条 + 影响列表 + 「+分配」按钮
250→ - 顶部待分配点数 badgependingAttrPoints > 0 时 echo-pending-pulse 闪烁动画)
251→ - 底部总等级/总加成概览 + 12 个修饰器明细
252→ - 4 色辉光边框(emerald/fuchsia/amber/rose),顶角光晕装饰
253→- 接入 `src/app/page.tsx`
254→ - grid-cols-7 → grid-cols-8,新增第 8 个「角色」TabsTriggerUser 图标,渐变主题)
255→ - pendingAttrPoints > 0 时显示 rose 红点提示
256→ - 渲染 AttributesPanel
257→ - StatsPanel 新增 5 行属性行(探索力/智慧/勇气/灵感/待分配点)
258→ - 版本号 v0.6 → v0.7
259→- 新增 `src/lib/game/achievements.ts` 2 项成就:
260→ - ach_attr_total_50(四维觉醒):总属性 ≥ 50 → 产能+6%/洞见+6%
261→ - ach_attr_max_100(维度精通):任一属性 100 → 产能+12%/洞见+10%
262→- 写入 `agent-ctx/7-full-stack-developer.md` 工作记录
263→- 最终验证:lint 零错误 + dev 服务器 HTTP 200 + 编译 < 250ms
264→
265→Stage Summary:
266→- ✅ 角色属性系统 v0.7 P1 全部完成
267→- ✅ 新增 2 文件(attributes.ts + AttributesPanel.tsx),修改 6 文件(types/config/engine/gameStore/achievements/page
268→- ✅ 四维属性完整接入游戏各系统(探险/解码/脉冲/巡航/星潮)
269→- ✅ 飞升属性点公式 = ascensions × 2 + 1;经验累积自动升级(每级 10×level)
270→- ✅ 旧存档兼容(migrateAttributes 补全字段,同步 level 与 attributes
271→- ✅ lint 零错误 + HTTP 200 + 编译 < 250ms
272→- ✅ 严格四色全息(emerald/fuchsia/amber/rose),零蓝色/靛色
273→- ✅ 8 标签页 + 巡航按钮全部保留,data-tut 锚点未破坏
274→- ✅ 待分配点数时标签页 rose 红点提示 + 顶部 badge 闪烁
275→
276→---
277→Task ID: 8-deploy
278→Agent: main
279→Task: 重建定时任务 + 更新 README + 部署 gh-pagesv0.7
280→
281→Work Log:
282→- 检查项目状态:CrystalOrb.tsx 已是 v0.7 Canvas 粒子系统(659 行),无活动 cron job
283→- 重建定时任务:cron create fixed_rate="900" priority=10 webDevReview → job_id 228329tz=Asia/Shanghai
284→- 重写 README.md
285→ - 顶部显著位置添加「在线游玩」链接(https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
286→ - 版本号 v0.1 → v0.7
287→ - 新增 10 大系统表格(含 v0.6 巡航 + v0.7 角色属性)
288→ - 完整开发路线表(v0.1-v1.0,标记 ✅/🔜 状态)
289→ - 新增本地开发命令 + Gitea Pages 部署指南
290→ - 文档索引扩展至 10 个版本文档
291→- 静态导出:BUILD_EXPORT=true bun run build:static → out/ 1.6MHTTP 200 编译 4.3s
292→- 推送 gh-pages 分支:force push origin gh-pagescommit 8727fed "deploy: v0.7"
293→- 提交 mainREADME 更新(commit 43dfc2d "docs: README 添加在线游玩链接 + v0.7 状态更新"
294→- 验证 Gitea Pageshttps://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/ → HTTP 200 ✅
295→- QA 验证:agent-browser 打开 http://localhost:3000/ → VLM 评分 8/10(主界面 polishedminor 空白)
296→
297→Stage Summary:
298→- ✅ 定时任务重建成功(job_id 228329,每 15 分钟自动审查迭代)
299→- ✅ README 重写完成,网站链接显著展示,v0.7 状态完整
300→- ✅ gh-pages 部署成功,Gitea Pages HTTP 200 可访问
301→- ✅ main 分支已同步推送(README + worklog 更新)
302→- ✅ dev 服务器健康(HTTP 200,编译 < 250ms
303→- ✅ CrystalOrb v0.7 Canvas 粒子系统已确认完整(659 行)
304→- 下一步:v0.8 巡航玩法增强(BOSS 战 / 道具掉落 / 事件选择节点)
305→
306→---
307→Task ID: 8
308→Agent: main
309→Task: v0.8 巡航玩法大增强(BOSS 战 + 道具掉落 + 事件选择节点)+ 工单修正 + 审查流程重建
310→
311→Work Log:
312→- 重读 worklog 工单,发现 v0.7 两项(CrystalOrb Canvas + 角色属性)已完成但工单"下一阶段优先级"未更新
313→- 修正 worklog 工单:标注 P0/P1 v0.7 已完成,重新排序下一阶段优先级(P0=巡航增强, P1=探险平衡/信标扩展, P2=UI打磨/云存档, P3=叙事/多人)
314→- 重建审查流程 cron jobfixed_rate=900s, priority=10, webDevReview, tz=Asia/Shanghai, job_id=228357
315→ - 任务描述强制要求:每次触发 MUST 先完整阅读 worklog.md 工单,以工单为准,不凭记忆工作
316→- 发现之前被中断的 v0.8 subagent 已写入 2037 行代码(cruise.ts +1487 / CruiseMode.tsx +858 / page.tsx +2
317→- 修复 2 个 lint 错误(react-hooks/set-state-in-effect):
318→ - isTouch: useState(false) + useEffect(setIsTouch) → useState initializer
319→ - stats: useState(null) + useEffect(setStats(loadCruiseStats)) → useState initializer
320→- QA 验证(agent-browser + VLM):
321→ - 主界面 VLM 7/10v0.8 版本号 ✅ + 巡航按钮 ✅
322→ - 巡航 READY 阶段 VLM 8/10BOSS 战说明 ✅(每5关BOSS战/击败BOSS)+ 射击控制说明 ✅(空格/右下角射击按钮)
323→ - 巡航第1关游戏 VLM 8/10:飞船(绿色三角)✅ + HUD(护盾/能量/分数/计时)✅ + 陨石/收集物/能量场 ✅ + 无渲染错误
324→- 提交 v0.8commit 494bc5f+ 推送 main
325→- 静态导出 BUILD_EXPORT=true → out/ 1.6M(编译 4.1s
326→- 部署 gh-pagesforce push, commit b865922)→ Gitea Pages HTTP 200 ✅
327→
328→Stage Summary:
329→- ✅ 工单修正完成:v0.7 已完成项标注清楚,下一阶段优先级重新排序
330→- ✅ 审查流程重建:cron job_id 228357,每15分钟触发,强制读工单
331→- ✅ v0.8 巡航玩法大增强全部完成并部署:
332→ - BOSS 战系统(每5关,三阶段攻击,HP条,击败奖励×3)
333→ - 飞船射击系统(空格/射击按钮,能量消耗,子弹尾焰)
334→ - 道具掉落(5种powerup:护盾/能量/急速/超载/磁吸)
335→ - 事件选择节点(每关通关后3选1,10种强化牌)
336→ - lint 零错误 + dev HTTP 200 + gh-pages HTTP 200
337→- ✅ VLM 评分:主界面 7/10 + 巡航READY 8/10 + 巡航游戏 8/10
338→- 在线游玩 v0.8https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
339→- 下一阶段工单:P1 探险BOSS胜率平衡 + 探险能量恢复 / 信标系统扩展(周挑战/信标链)
340→
341→---
342→Task ID: 9-b
343→Agent: full-stack-developer
344→Task: 信标系统扩展(周挑战 + 信标链连续奖励)
345→
346→Work Log:
347→- 阅读现有 `src/lib/game/beacon.ts`v0.5 每日挑战 + 本地排行榜,358 行)+ `src/components/game/BeaconPanel.tsx` + `src/store/gameStore.ts` 的 `trackBeacon` 函数(约第 173 行)+ worklog v0.8 项目状态,确认四色全息规范与现有架构。
348→- **扩展 `src/lib/game/beacon.ts`358 → 873 行,新增 ~515 行)**
349→ - **周挑战(WEEKLY CHALLENGE**
350→ - `BeaconWeeklyChallenge` 接口(weekKey YYYY-Www / type / difficulty 强制 anomaly|singular / goal / rewardInsight / rewardContact / seed / title / desc
351→ - `BeaconWeeklyProgress` 接口(weekKey / progress / startedAt / completedAt / claimed / durationSec
352→ - `getWeekKey(now)`ISO 8601 周键(周一为起点,含首个周四的周为第一周)
353→ - `weekKeyToSeed`FNV-1a 哈希
354→ - `generateWeeklyChallenge(now)`:基于 weekKey 种子确定性生成,难度加权 anomaly 60% / singular 40%goal 为日基准 ×3-5 倍(decode 18-75 / expedition 3-15 / pulse 60-245 / boss 3-12 / insight 120-595
355→ - `loadWeeklyProgress` / `saveWeeklyProgress` / `addWeeklyProgress` / `claimWeeklyReward`(推送排行榜,entry 加 `isWeekly: true`
356→ - `msUntilNextWeek(now)`:距离下周一 UTC 0 点的毫秒数
357→ - `BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1"`
358→ - **信标链(BEACON CHAIN**
359→ - `BeaconChainState` 接口(lastCompletedDateKey / currentStreak / longestStreak / totalCompletions / graceUsed / milestonesClaimed
360→ - `BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1"`
361→ - `BEACON_CHAIN_MILESTONES = [3, 7, 14, 30]` 常量
362→ - `BEACON_CHAIN_REWARDS`4 个里程碑(三日谐振 +50/+5 / 七日回响 +120/+12 / 半月星潮 +280/+28 / 满月飞升 +680/+68
363→ - `loadChainState` / `saveChainState`(每次返回新对象避免引用共享)
364→ - `recordChainCompletion(dateKey)` 核心逻辑:同日重复忽略;次日 streak++;隔一天 miss 且 graceUsed<1 时续命 streak++ graceUsed++;其他断链 streak=1 graceUsed=0;返回 `{ state, newMilestones }`
365→ - `claimChainMilestone(milestone)` / `getNextMilestone(streak)` / `getChainProgress(streak)`(返回 current/next/prev/progressPct 用于 UI
366→ - `dateKeyToTimestamp` / `dateKeyDiffDays` 工具函数
367→ - `BeaconScoreEntry` 新增可选 `isWeekly?: boolean` 字段
368→- **扩展 `src/store/gameStore.ts`**
369→ - import 扩展:generateWeeklyChallenge / loadWeeklyProgress / loadChainState / addWeeklyProgress / recordChainCompletion / claimWeeklyReward / claimChainMilestone / getTodayKey + 类型
370→ - `trackBeacon(type, delta)` 函数签名从 `boolean` 改为 `{ dailyJustCompleted, weeklyJustCompleted, newChainMilestones }`
371→ - 日挑战类型匹配且未完成 → addBeaconProgress;若刚完成 → recordChainCompletion(getTodayKey()) 收集 newMilestones
372→ - 周挑战类型匹配且未完成 → addWeeklyProgress
373→ - 所有调用点(pulse/clickNode/autoDecodeTick/resolveCurrentNode 等 7 处)原本就忽略返回值,向后兼容
374→ - 新增 action `claimWeeklyBeacon`:调用 claimWeeklyReward → 发放 insights + contact 到 state,返回 `{ rewardInsight, rewardContact, score }`
375→ - 新增 action `claimChainReward(milestone)`:前置校验 currentStreak 与 milestonesClaimed,调用 claimChainMilestone → 发放奖励,返回 `{ rewardInsight, rewardContact, label, ok }`
376→ - GameActions 接口同步新增两个 action 签名
377→- **重写 `src/components/game/BeaconPanel.tsx`301 → 638 行)**
378→ - 顶部头部 + 每日挑战卡片(保留 v0.5 完整功能 + 按钮主题色随难度色淡显)
379→ - **新增周挑战区块**fuchsia 主题):标题"周挑战 · WEEKLY" + weekKey + 倒计时 + 挑战卡片(类型/难度标签/标题/描述/进度条/奖励/领取按钮 emerald 主题)+ weekly-glow 动画
380→ - **新增信标链区块**amber→rose 渐变):标题"信标链 · CHAIN" + 大字号 streakchain-streak-text 渐变流动动画)+ 今日完成状态徽章 + 4 个里程碑节点(w-12 h-12 圆形,claimed=emerald/✓,reachable=rose 脉冲动画+领取按钮,inProgress=amber 半亮,未到达=muted 灰)+ 节点间连线(背景灰 + 已达成部分 amber→rose 渐变 + 辉光,基于 prev→next 插值定位)+ 进度条 + 底部统计(最长/累计/续命状态)
381→ - 桌面端 lg:grid-cols-2 让周挑战+信标链并排,移动端单列
382→ - 排行榜条目区分日/周:周挑战 entry 显示 "WEEK" 徽章 + fuchsia 高亮背景
383→ - 空状态提示扩展为"完成今日或本周信标即可登榜"
384→ - 4 个新动画:weekly-glow / chain-milestone-pulse / chain-streak-flux(背景渐变流动)
385→- **QA 验证**
386→ - `bun run lint` 零错误(每次修改后均验证)
387→ - dev.log 全程无错误,所有编译 < 300ms
388→ - **信标链逻辑测试**(bun 直接运行 TS,5 个场景全 PASS):
389→ 1. 昨日 streak=1 → 今日完成 → streak=2 ✅
390→ 2. 同日重复完成 → 忽略 ✅
391→ 3. 明日完成 → streak=3,无需 grace ✅
392→ 4. 隔一天 misslastCompleted + 2 天)→ streak=4graceUsed=1 ✅
393→ 5. 再次 missgrace 已用)→ 断链 streak=1graceUsed=0 ✅
394→ - **UI 集成测试**agent-browser):
395→ - localStorage 模拟设置 chain statelastCompletedDateKey=yesterday, currentStreak=1)→ reload → UI 正确显示 streak=1
396→ - 设置 streak=3 + milestonesClaimed=[] → milestone 3 节点显示 rose 脉冲动画 + "领取"按钮
397→ - 点击"领取"→ milestonesClaimed=[3] + Toast "✦ 三日谐振 已领取 +50 洞见 · +5.0 接触" + 按钮消失 ✅
398→ - **VLM 视觉评分**(目标 ≥7/10):
399→ - 首屏截图:8/10(四色一致、布局合理、信标链清晰)
400→ - 里程碑可领取状态:8/10(amber→rose 渐变醒目、里程碑层次分明、fuchsia 主题清晰)
401→ - 重置后干净状态:8/10(WEEKLY 字体对比度可优化,但整体可读性强)
402→ - **不破坏现有功能**:日挑战进度/领奖/排行榜流程全部保留并工作正常
403→
404→Stage Summary:
405→- ✅ beacon.ts 扩展完成(358 → 873 行):周挑战 + 信标链全部函数与类型
406→- ✅ gameStore.ts 集成完成:trackBeacon 升级为对象返回值 + claimWeeklyBeacon + claimChainReward 两个新 action
407→- ✅ BeaconPanel.tsx UI 完整重写(301 → 638 行):周挑战 fuchsia 卡片 + 信标链 amber→rose 渐变区块
408→- ✅ 严格四色全息:周挑战 fuchsia、信标链 amber→rose、领取按钮 emerald、无蓝/紫色违规
409→- ✅ localStorage 新增 2 个独立 keyweekly + chain),不污染旧 key
410→- ✅ 确定性:周挑战同周同种子同结果(weekKey FNV-1a 种子)
411→- ✅ 信标链 grace 续命机制工作正常(5 个场景 bun 测试全 PASS)
412→- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10
413→- ✅ 现有日挑战 + 排行榜 + 领奖流程全部保留,无回归
414→
415→---
416→Task ID: 9
417→Agent: main + subagent(9-b)
418→Task: v0.8.1 P1 双任务 — 探险 BOSS 胜率平衡 + 能量恢复 + 信标系统扩展(周挑战 + 信标链)
419→
420→Work Log:
421→- 重读工单确认 P1 两项为下一阶段优先级
422→- P1-a 探险平衡(main 自己做,平衡需要精细控制):
423→ - expedition.ts: combatDifficultyScale 8→4, combatWinRateFloor 0.25→0.35, BOSS difficulty 5-7→3-5
424→ - 新增 computeEnergyRegenInterval(state): exp_2 -30%, exp_3 -20%, 探索力属性 -最高30%, 下限12s
425→ - computeEnergyRegen 接受 intervalSec 参数
426→ - config.ts: exp_2/exp_3 描述加能量恢复说明
427→ - gameStore.ts tick: 传入动态间隔
428→ - ExpeditionPanel.tsx: 显示实际恢复速度 + '已加速'标记
429→- P1-b 信标系统扩展(dispatch subagent 9-b,并行完成):
430→ - beacon.ts 358→873行: 周挑战(getWeekKey ISO 8601 + 确定性生成) + 信标链(4里程碑+grace续命)
431→ - BeaconPanel.tsx 301→638行: 周挑战fuchsia主题 + 信标链amber→rose里程碑节点
432→ - gameStore.ts: trackBeacon升级 + claimWeeklyBeacon/claimChainReward actions
433→- QA 验证:
434→ - lint 零错误
435→ - 探险面板 VLM 7/10(能量条 + 恢复速度显示正常)
436→ - 信标面板 VLM 8/10(周挑战 WEEKLY + 信标链 3/7/14/30 + 排行榜全部可见)
437→ - subagent 5场景 bun 测试全 PASS(信标链断链/续命逻辑)
438→- 提交 v0.8.1commit 71ca5b4+ 推送 main
439→- 静态导出 + 部署 gh-pagescommit 97eecaa)→ HTTP 200
440→
441→Stage Summary:
442→- ✅ P1-a 探险平衡完成: BOSS 基础胜率 25%→35-45%, 配合技术/属性可达 55-70%
443→- ✅ P1-a 能量恢复: 固定45s → 动态12-45s(技术+属性加速)
444→- ✅ P1-b 周挑战: 每周一UTC刷新, fuchsia主题, goal×3-5倍
445→- ✅ P1-b 信标链: 4里程碑(3/7/14/30天) + grace续命, amber→rose渐变
446→- ✅ 在线游玩 v0.8.1: https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
447→- 下一阶段工单: P2 UI样式细节打磨 / P2 云存档+云排行榜 / P3 全纪元叙事
448→
449→---
450→Task ID: v0.14-reimpl
451→Agent: full-stack-developer
452→Task: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章
453→
454→Work Log:
455→- 阅读 worklog.md 了解 v0.8.1 项目状态(10 大系统 + 四色全息规范 + 8 标签页架构)
456→- 类型扩展 `src/lib/game/types.ts`
457→ - 新增 `IdleProjectDef` 接口(id/name/desc/durationSec/reward/minAscensions/minCrystalsPerSec/icon/color/order
458→ - 新增 `IdleProjectSlot` 接口(projectId/startedAt/finishesAt/remainingSec/completed
459→ - GameState 接口新增 4 字段:idleProjectSlots (3 槽位数组) / idleProjectHistory / idlePermanentBonus / idleStats
460→- 新建 `src/lib/game/idle.ts` (~290 行)
461→ - 6 个放置工程定义(idle_scan 60s / idle_refine 180s / idle_archive 600s / idle_anchor 1200s / idle_drones 1800s / idle_resonance 3600s
462→ - 奖励类型:crystals / insights / energy / contact / crystalsPerSecPermanent / randomFragment
463→ - 解锁逻辑:minAscensions 与 minCrystalsPerSec 为 OR 关系(满足任一已设置条件即可)
464→ - 工具函数:getIdleProject / getUnlockedIdleProjects / getLockedIdleProjects / isIdleProjectUnlocked / formatRemaining (12s/3m 45s/1h 12m) / formatReward / deriveMinerFleet (派生 min_1..min_5 采矿无人机,icon: ⛏️🌋🤖💠☀️,无任何采矿技术时返回休眠 min_1)
465→ - 颜色映射:IDLE_COLOR_CLASSES (text/border/bg/bgSoft/glow/ring/dot) + COLOR_HEX (用于 inline style 绕过 Tailwind 动态类限制)
466→- 引擎集成 `src/lib/game/engine.ts`
467→ - recomputeStats() 末尾 `crystalsPerSec += state.idlePermanentBonus ?? 0` (放置永久加成叠加)
468→ - performPrestige() 清空 idleProjectSlots (中断当前周目工程),保留 idlePermanentBonus/idleProjectHistory/idleStats (跨周目永久)recomputeStats 调用传 idlePermanentBonus
469→ - createInitialState() 新增 4 字段默认值
470→- Store 集成 `src/store/gameStore.ts` (核心改造)
471→ - 新增 4 actionstartIdleProject (校验+写入槽位) / cancelIdleProject (置 null) / claimIdleProject (应用奖励 + permBonus 累加 + randomFragment 解锁 + history 追加 + Toast 提示) / tickIdleProjects (每 tick 更新 remainingSec,归零标记 completed)
472→ - **persist migrate 函数**persist 配置增加 `version: 1` + `migrate` 函数自动补全 4 个 idle 字段,旧存档加载不崩溃
473→ - 14 处 syncStats 调用全部更新为传入 `idlePermanentBonus: s.idlePermanentBonus ?? 0`,确保 recomputeStats 计算时纳入永久加成
474→ - init() 中也补全 4 个 idle 字段(defense in depth
475→- 主循环 `src/hooks/useGameLoop.ts`:新增 tickIdleProjects 选择器,setInterval 回调中 autoDecodeTick 后调用,visibilitychange 也调用
476→- 新建 `src/components/game/IdleOperationsPanel.tsx` (~490 行)
477→ - 4 section 在 max-h-[520px] overflow-y-auto 容器中:
478→ 1. 放置收益概览:3 stat tiles (放置产能/永久加成/完成工程) + 离线效率 progress bar + 累计放置晶体统计
479→ 2. 采矿无人机舰队:grid 展示 deriveMinerFleet,每卡 emoji+name+Lv+output/s+状态点 (active=emerald ping 脉冲, dormant=灰)
480→ 3. 放置工程槽位:3 张卡(空槽=虚线占位,运行中=大字号倒计时+自定义进度条+取消按钮,已完成=奖励预览+领取按钮带 glow)
481→ 4. 可派遣工程:2 列 grid 展示已解锁工程(icon+name+duration+desc+reward+3 数字派遣按钮 1/2/3),下方列出未解锁工程及解锁条件
482→ - useShallow 订阅 + 本地 now state 每 1s 刷新倒计时
483→ - 严格 4 色全息,自定义 fuchsia 滚动条
484→- 新建 `src/components/game/IdleStatusBadge.tsx` (~100 行)
485→ - 紧凑徽章 h-8 px-2.5 text-[11px]:脉冲点 (emerald 若 cps>0) + "放置中 +X/s" (或 "休眠中")
486→ - Tooltip 悬停展示分解:基础产能/永久加成/运行中工程/待领取工程数
487→ - data-tut="idle-status-badge" 锚点 + onClick 切换到放置 tab
488→- 新建 `src/components/game/IdleProjectBar.tsx` (~90 行)
489→ - 晶体球下方细长进度条,最多 3 个工程 mini pill `[icon] name 12s ▓▓▓░░`
490→ - 完成时显示 "✓ 待领取" + glow 辉光;无工程返回 null
491→ - 进度条颜色用 inline style (COLOR_HEX) 控制,每 1s 刷新倒计时
492→- 主页面集成 `src/app/page.tsx`
493→ - 引入 3 新组件 + Clock 图标 + getUnlockedIdleProjects
494→ - 新增 activeTab/tabInited state + controlled Tabs (`value={activeTab} onValueChange={setActiveTab}`)
495→ - 挂载后 useEffect 一次性设置默认 tabhasActiveExpedition→"expedition"hasPendingPerk→"constellation",否则→"idle"
496→ - 版本号 v0.8 → v0.14
497→ - Header 在 StarTideIndicator 后加 IdleStatusBadge
498→ - 左侧 CrystalOrb 后加 IdleProjectBar
499→ - TabsList grid-cols-8 → grid-cols-9,新增 value="idle" 的 TabsTrigger 作为第一个 tabClock 图标,emerald 主题,待领取时显示数量红点)
500→ - 新增 TabsContent value="idle" 渲染 IdleOperationsPanel
501→ - 新增 idle 相关 goal 提示(高优先级):待领取 > 0 → "✦ 放置工程已完成 X 项,请前往「放置」标签领取奖励";全空+有解锁+时长>60s → "「放置」标签可派遣工程项目,离线自动产出"
502→ - StatsPanel 新增 3 行:放置永久加成 / 完成放置工程 / 放置产出晶体
503→- 教程更新 `src/lib/game/tutorial.ts`TUTORIAL_STEPS 在 decode 与 tech 之间插入新步骤 (id="idle", target="tab-idle", 标题"③ 放置工程 · 离线产出"),原 tech/expedition/prestige 编号顺延为 ④⑤⑥
504→- 写入 `agent-ctx/v0.14-reimpl-full-stack-developer.md` 工作记录
505→- 最终验证:
506→ - lint 零错误零警告 ✅
507→ - dev 服务器 HTTP 200,编译 < 250ms ✅
508→ - agent-browser 烟雾测试全流程通过(默认 idle tab + 头部徽章 + 4 section + 派遣→倒计时→完成→领取→永久加成应用) ✅
509→
510→Stage Summary:
511→- ✅ v0.14 放置系统 (Idle Operations) 全部完成
512→- ✅ 新增 4 文件(idle.ts 290行 + IdleOperationsPanel.tsx 490行 + IdleStatusBadge.tsx 100行 + IdleProjectBar.tsx 90行),修改 6 文件(types/engine/gameStore/useGameLoop/page/tutorial
513→- ✅ 6 个放置工程:勘探扫描 / 精炼校准 / 碎片整理 / 锚点部署 / 无人机扩编 / 谐振标定
514→- ✅ 3 个槽位并行 + 6 工程解锁逻辑 (OR) + 永久产能加成 (permBonus 累加跨周目)
515→- ✅ 采矿无人机舰队派生 (min_1..min_5) + 休眠状态显示
516→- ✅ 头部 idle 徽章 + 默认放置 tab + 晶体球下方进度条 (5 秒内可见三要素)
517→- ✅ persist migrate 函数 (version 1) 旧存档兼容
518→- ✅ 14 处 syncStats 调用全部传 idlePermanentBonusrecomputeStats 永久加成不丢失
519→- ✅ 飞升时清空活跃槽位但保留永久加成 / 历史 / 统计
520→- ✅ 严格 4 色全息 (emerald/rose/amber/fuchsia),零蓝/靛
521→- ✅ lint 零错误 + HTTP 200 + agent-browser 全流程烟雾测试通过
522→- ✅ 9 标签页 (新增"放置"为第一个) + 巡航按钮 + 教程新增 idle 步骤
523→
524→---
525→Task ID: v0.14-release
526→Agent: 主控 Z.ai Code
527→Task: v0.14 放置系统发布 — README/docs 更新 + 端到端验证 + git commit
528→
529→Work Log:
530→- 发现项目被环境重置回 v0.8.1 状态(v0.14 代码全部丢失,worklog 也被截断到 447 行)
531→- 重新委派 full-stack-developer subagent (v0.14-reimpl) 在 v0.8.1 代码库上重新实现放置系统
532→- subagent 交付 10 个文件(4 新建 + 6 修改),lint 零错误,agent-browser smoke test 通过,git commit 17820b1
533→- 主控独立验证:
534→ * git log 确认 commit 17820b1 存在 ✓
535→ * bun run lint 零错误 ✓
536→ * HTTP 200 ✓
537→ * agent-browser: v0.14 标签 ✓ + 放置中徽章 ✓ + 默认 idle tab ✓ + 4 段面板完整 ✓
538→ * 派遣 idle_scan → slot 0 显示 60s 倒计时 ✓
539→ * 快进完成 → 领取 → insights 79→87 (+8) + projectsCompleted 0→1 ✓ + 槽位清空 ✓
540→- 主控更新 README.md → v0.14:突出 12 大系统、3 层 idle 节奏、灵感来源、健康设计声明
541→- 主控创建 docs/11-放置系统-v0.14.md:完整设计文档
542→- 主控创建每 15 分钟触发的 webDevReview cron job
543→
544→Stage Summary:
545→
546→## v0.14 放置系统已重新上线并 git commit 保固
547→
548→### 核心交付
549→- **3 层 idle 节奏**:被动产能(无人机舰队)+ 放置工程(6 种 × 3 槽位)+ 离线收益(8h cap)
550→- **始终可见的 idle 元素**Header「放置中 +X/s」徽章 + 默认 tab = 放置 + 晶体球下方工程进度 pill
551→- **永久产能加成**:通过工程奖励累积,集成进 recomputeStats,跨周目保留
552→- **存档安全**persist version 1 + migrate 函数 + 4 字段 fallback,老存档零崩溃
553→
554→### Git 保固
555→- commit 17820b1: "v0.14: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章"
556→- 即使环境再次重置,git checkout 17820b1 即可恢复
557→
558→### 验证结果
559→- ✅ bun run lint 零错误零警告
560→- ✅ HTTP 200dev.log 无运行时错误
561→- ✅ agent-browser 端到端 smoke test 全通过(派遣→倒计时→完成→领取→insights+8)
562→- ✅ Sticky footer/header 验证通过
563→
564→### 未解决问题 / v0.15 优先项
565→1. 🟡 永久产能加成可能需要飞升次数 softcap(防 idle_resonance ×N 滚雪球)
566→2. 🟡 idleProjectHistory / crystalsFromIdle 已在 store 累加但 UI 未展示
567→3. 🟡 缺「批量领取全部」按钮 + 工程完成 toast 通知
568→4. 🟡 遗迹图鉴多层文本(Dark Souls 式 loreFragments)—— v0.15 计划
569→5. 🟡 解码阈值释放叙事碎片 + 星潮三频段切换 —— v0.16 计划
570→6. 🟡 健康设计:每日软上限 + "星核建议休眠"提醒 —— v0.17 计划
571→
+571
View File
@@ -0,0 +1,571 @@
1→# 回响星核 / Echo Nexus — 开发工作日志
2→
3→> 持续性任务的工作交接文档。每个阶段更新,避免进度丢失。
4→
5→---
6→
7→## 一、项目当前状态描述 / 判断
8→
9→### 概况
10→- **项目**:回响星核 / Echo Nexus — 深空考古放置策略游戏
11→- **一句话**:自治无人机采矿 → 解码记忆晶体(原创共振谜题)→ 拼凑碎片叙事 → 飞升多周目。差异化:解码谜题驱动叙事涌现,全息晶体 + 深空粒子美学。
12→- **当前版本****v0.7**CrystalOrb Canvas 粒子系统 + 角色属性系统)
13→- **在线游玩**https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
14→- **仓库**https://git.atdunbg.xyz/Super_Z/echo-nexus
15→- **技术栈**Next.js 16 (App Router) + TypeScript + Tailwind + shadcn/ui + Canvas 2D + Zustand + Web Audio API
16→- **定时任务**:每 15 分钟一次 `webDevReview``fixed_rate` + `"900"` 秒,priority=10job_id 228266)。正常完成不会被删除,无需自持续机制。
17→
18→### 状态判断
19→- dev 服务器运行正常(HTTP 200,编译 < 250ms
20→- v0.7 两大新功能已完成:CrystalOrb Canvas 粒子系统升级(VLM 8/10+ 角色属性系统(VLM 7/10
21→- 系统总体稳定,无阻塞性 bug,可继续推进新功能
22→
23→### 已完成版本里程碑(精简)
24→| 版本 | 核心内容 |
25→|------|---------|
26→| v0.1 | MVP:放置采矿 + 解码共振谜题 + 技术树 + 记忆图谱 + 飞升 + 统计 |
27→| v0.1.1 | 解码可解性修复(路径构造法生成器,100% 可解率)|
28→| v0.2 | 遗迹探险肉鸽系统(能量/探险力/6 节点/程序化路径/BOSS)|
29→| v0.2.1 | 程序化音频(15 种音效)+ 14 项成就 + 视觉打磨 |
30→| v0.3 | 星潮事件系统(6 种星潮 + 全屏叠层 + 程序化音效)|
31→| v0.3.1 | 星图天文台元进程(6 类 × 3 = 18 天赋 + 飞升后 3 选 1 draft + Canvas 星图)|
32→| v0.4 | 回响编年史(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复)|
33→| v0.5 | 深空信标(每日挑战 + 本地排行榜 Top20 + 7 标签页 + 编年史历史 BUG 修复)|
34→| v0.5.1 | 静态部署 Gitea Pages + 新手教程系统(7 步聚光灯引导)+ UI 间距优化 |
35→| v0.5.2 | 离线收益报告弹窗 + 标签页 UI 重设计 + persist rehydrate 竞态修复 |
36→| v0.6 | 深空巡航 Canvas 2D 实时玩法 + UI 偏移重叠修复 |
37→| **v0.7** | **CrystalOrb Canvas 粒子系统升级 + 角色属性系统(四维)** |
38→
39→### 核心系统清单(8 大系统)
40→1. **放置采矿** — 中央晶体球 + 主动脉冲 + 连击加成(`CrystalOrb.tsx`
41→2. **谐振解码** — 原创路径谜题,路径构造法 100% 可解(`DecodeArray.tsx` + `engine.ts`
42→3. **技术树** — 三层科技解锁,提升产能/解码/探险(`TechTree.tsx`
43→4. **遗迹探险** — 肉鸽系统,6 种节点 + 程序化路径 + BOSS`ExpeditionPanel.tsx` + `expedition.ts`
44→5. **星潮事件** — 6 种动态事件,为放置循环注入变化(`StarTide.tsx` + `starTide.ts`
45→6. **星图天文台** — 飞升后 3 选 1 天赋 draft,跨周目永久生效,18 个天赋(`ConstellationPanel.tsx` + `constellation.ts`
46→7. **回响编年史** — 跨周目叙事时间轴,5 纪元循环命名(`ChronicleDialog.tsx` + `chronicle.ts`
47→8. **深空信标** — 每日挑战 + 本地排行榜 Top20`BeaconPanel.tsx` + `beacon.ts`
48→9. **深空巡航** — Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关(`CruiseMode.tsx` + `cruise.ts`)【v0.6】
49→10. **角色属性系统** — 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点,影响所有系统(`AttributesPanel.tsx` + `attributes.ts`)【v0.7 新增】
50→
51→---
52→
53→## 二、当前目标 / 已完成的修改 / 验证结果
54→
55→### v0.7 CrystalOrb Canvas 粒子系统升级(已完成)
56→**动机**:P0 优先级 — 将中央晶体球从 CSS 动画升级为 Canvas 粒子视觉盛宴,提升视觉震撼。
57→
58→**重写文件**`src/components/game/CrystalOrb.tsx`
59→- 从纯 CSS 动画升级为 Canvas 2D 粒子系统
60→- **多层粒子**
61→ - 环绕能量粒子(3层14/10/8个,emerald/fuchsia/rose三色,不同速度方向环绕晶核,带拖尾)
62→ - 环境星尘(40个,缓慢漂移 + 闪烁)
63→ - 点击爆发粒子(10-30个径向发散,颜色随连击等级变色)
64→ - 冲击波环(点击触发1-3层错峰扩散,连击≥3/5解锁更多层)
65→- **晶核升级**:径向渐变辉光 + 3层六边形纹理旋转 + 高光 + 鼠标视差偏移
66→- **呼吸动画**:晶核 + 外层辉光随时间呼吸(sin波)
67→- **进度环**SVG渐变环(emerald→fuchsia→rose)保留
68→- **交互保留**:点击脉冲、连击Toast、浮动数字、音效、`data-tut="crystal-orb"` 锚点
69→- **性能**DPR cap 2ResizeObserver 自适应,requestAnimationFrame 60fps
70→
71→**QA 验证**agent-browser + VLM):
72→- VLM 视觉评分 **8/10**:晶体球清晰可见,多层辉光,环绕粒子动态自然,无渲染问题
73→- 点击交互:冲击波环 ✅ + 爆发粒子 ✅,评分 7/10
74→- lint 零错误;HTTP 200
75→
76→### v0.7 角色属性系统(已完成)
77→**动机**:P1 优先级 — 增加RPG深度,四维属性影响所有游戏系统。
78→
79→**新增文件**
80→- `src/lib/game/attributes.ts`~330行逻辑层):CharacterAttributes/AttributeProgress 类型、ATTRIBUTE_CONFIG 元信息、getAttributeBonus 两区加成公式(0-50线性+0.5%/点,50-100递减+0.2%/点)、getAllBonuses 12项修饰器、levelUpCheck 自动升级、computePrestigeAttrPoints、migrateAttributes 旧存档兼容
81→- `src/components/game/AttributesPanel.tsx`~330行 UI):四维卡片网格(小屏2×2/大屏1×4)、图标+中英文名+Lv.badge+数值/100+加成%+经验条+影响列表+分配按钮、顶部待分配点数badge闪烁、底部总览+12项修饰器明细
82→
83→**修改文件**
84→- `types.ts`GameState 新增 attributes/attributeProgress/pendingAttrPoints
85→- `config.ts`INITIAL_STATE 补全默认值
86→- `engine.ts`recomputeStats 聚合属性加成;performPrestige 发放属性点(ascensions×2+1
87→- `gameStore.ts`:新增 allocateAttribute/gainAttributeExp actionspulse/clickNode/autoDecodeTick/resolveCurrentNode/grantCruiseReward/tickTide 全部接入属性经验;init() 调用 migrateAttributes
88→- `achievements.ts`:新增 ach_attr_total_50 + ach_attr_max_100 两项成就
89→- `page.tsx`grid-cols-7→8,新增「角色」标签页(User图标),红点提示,StatsPanel新增5行属性行,版本号 v0.6→v0.7
90→
91→**四维属性设计**
92→- 探索力(emerald):探险力+X%/巡航飞船速度+X%
93→- 智慧(fuchsia):解码步数+X/洞见+X%/自动解码周期-X%
94→- 勇气(amber):探险生命+X/BOSS胜率+X%/巡航护盾+X
95→- 灵感(rose):接触率+X/星潮概率+X/脉冲连击+X%
96→
97→**QA 验证**agent-browser + VLM):
98→- VLM 视觉评分 **7/10**(面板)/ **8/10**(待分配状态):四维卡片可见、数值/等级/加成/经验条齐全、四色全息
99→- 功能验证:setState pendingAttrPoints=5 → 显示5点待分配 → 点击分配按钮 → exploration 0→1, pending 5→4 ✅
100→- lint 零错误;HTTP 200
101→
102→---
103→
104→### v0.6 深空巡航 Canvas 2D 实时玩法(历史记录)
105→**动机**:回应用户反馈「玩法太单调(全是点点点)」+「视觉震撼不足」——首次引入实时操作玩法。
106→
107→**新增文件**
108→- `src/lib/game/cruise.ts`~520 行逻辑层)
109→ - 类型系统:CruiseEntity 联合类型(ship/asteroid/storm/crystal/insight/beacon/stargate/particle 共 8 种)
110→ - mulberry32 + FNV-1a 种子化 RNG`cruiseSeed(level)`
111→ - `generateLevel`:程序化生成陨石带(8+lvl×2)、虚空风暴(1+lvl/2)、晶体碎片(5+lvl)、洞见光球(2+lvl/3)、信标(1+lvl/4)、星门
112→ - `updateCruise`:物理推进 + 碰撞检测 + 粒子系统 + 震动 + 600ms 无敌帧
113→ - `computeRewards`crystals=碎片×(8+lvl×2)、insights=洞见×(2+lvl×0.5)、contact=信标×(1.5+lvl×0.3);通关×1.5、失败×0.5
114→ - 独立 localStorage `echo-nexus-cruise-v1`:最高分/累计通关/最高关卡/累计奖励/最近 20 局
115→
116→- `src/components/game/CruiseMode.tsx`~830 行渲染层)
117→ - 全屏 fixed inset-0 z-50 CanvasDPR cap 2resize 监听
118→ - 3 层视差星空:远(100星)/中(60星)/近(30星),独立漂移 + 飞船速度视差 + 闪烁
119→ - 8 种实体全部 ctx.shadowBlur 辉光绘制
120→ - 粒子系统:尾焰/收集/碰撞/烟花,上限 200
121→ - 屏幕震动 280ms 衰减;低护盾(<30%)红色边框脉冲
122→ - HUDHTML 叠层,glass+backdrop-blur80ms 节流):护盾/能量/分数/用时/收集计数
123→ - 控制:桌面 WASD/方向键 8 方向 + 移动端虚拟摇杆 + P 暂停 + Esc 退出 + 失焦自动暂停
124→ - 阶段:ready → playing → won/lost(结算明细 + 奖励 + 下一关/重试/返回)
125→
126→**修改文件**
127→- `src/app/page.tsx`header 新增 amber 主题「巡航」按钮(Navigation 图标)+ cruiseOpen state + 条件渲染 CruiseMode
128→- `src/store/gameStore.ts`:新增 `grantCruiseReward({crystals?, insights?, contact?})` actioncrystals 受 crystalCap 限制,contact 受 100 上限)
129→- 版本号 v0.5.2 → v0.6
130→
131→**UI 偏移/重叠 BUG 修复**(3 处):
132→- `DecodeArray.tsx`:移除 className 冲突的 `-translate-x-1/2 -translate-y-1/2`,统一用 inline transform 定位+缩放
133→- `AchievementsPanel.tsx`:移除内层 `max-h-[300px]`,改 `flex-1 min-h-0` 自适应父容器
134→- `page.tsx`:调整三个 section 的 min-h,避免小屏单列时总高度超过手机视口
135→
136→**色彩规范**:严格遵循 emerald(#34d399) / rose(#fb7185) / amber(#fbbf24) / fuchsia(#e879f9) 四色全息色谱,零蓝色/靛色
137→
138→**QA 验证**agent-browser + VLM):
139→- lint 零错误;dev 服务器 HTTP 200;编译 < 250ms
140→- VLM 确认主界面:巡航按钮存在、晶体球居中、解码区正常、无重叠偏移
141→- VLM 确认巡航玩法:飞船可见(绿色三角形)、HUD 完整、陨石+星空背景、粒子特效(黄色尾焰+紫色辉光)
142→- WASD 控制飞船移动,按 W 直冲星门 → 通关,奖励 +30 晶体(2×10×1.5=30 计算正确)
143→- localStorage 正确记录 highScore/totalRuns/totalWins/bestLevel
144→- 奖励同步 gameStoregrantCruiseReward,满仓时 cap 逻辑正确)
145→- 退出返回主界面,7 标签页 + 晶体球 + 解码面板均完好
146→
147→---
148→
149→## 三、未解决问题或风险 / 下一阶段优先事项
150→
151→### 已知问题 / 风险
152→1. **dev 服务器 Turbopack 缓存偶发损坏**`Unable to open static sorted file` + 进程被杀,需 `rm -rf .next` 重启。静态导出构建稳定可靠。
153→2. **定时任务调度类型**:必须用 `fixed_rate` + `"900"` 秒(15分钟),不要用 cron 表达式。`webDevReview` 类型任务正常完成不会被删除,只有抛出未捕获异常才会被标记失败。
154→3. **v0.3 星潮为单机版**:原计划 socket 全局事件,后续可扩展为多人同步。
155→4. **v0.5 信标排行榜为本地版**(纯 localStorage):升级云排行榜需后端 API。
156→5. **v0.5 每日挑战仅 1 个/天**:可加入"周挑战"或"信标链"(连续完成 N 天奖励)。
157→6. **探险 BOSS 战胜率较低**:基础探索力 10 vs BOSS 难度 5-7,胜率约 25%,需玩家投资探险技术。
158→7. **探险能量恢复较慢**:45s/点,可加技术提升恢复速度。
159→8. **编年史上限 50 条**:超过自动丢弃最早的;v0.4 之前飞升无回填(空状态有提示)。
160→9. **5 纪元叙事为模板化生成**:v0.5+ 将加入手写剧情节点。
161→10. ~~**CrystalOrb 仍为 CSS 动画**~~ → ✅ **v0.7 已完成 Canvas 粒子系统升级**。
162→
163→### 下一阶段优先级(v0.7 后修正版)
164→> 工单修正:原 P0 CrystalOrb Canvas + P1 角色属性两项均已在 v0.7 完成,现重新排序。
165→
166→1. **🔴 P0 — 巡航玩法大增强(v0.8 主线)**:BOSS 战关卡(每 5 关一个 BOSS,三阶段攻击模式 + 飞船射击系统)/ 道具掉落(5 种 powerup:护盾/能量/急速/超载/磁吸)/ 事件选择节点(每关通关后 3 选 1 强化牌,10 种强化)。这是当前最高优先级,能显著提升巡航玩法的深度与可玩性。
167→2. **🟠 P1 — 探险 BOSS 战胜率平衡 + 探险能量恢复**:修复已知问题 6/7(探险 BOSS 胜率 25% 偏低,能量 45s/点 偏慢),提升探险体验。
168→3. **🟠 P1 — 信标系统扩展**:周挑战 / 信标链(连续完成 N 天奖励),修复已知问题 5。
169→4. **🟡 P2 — UI 样式细节打磨**:标签页图标/文字比例、视觉层次强化、移动端响应式进一步优化、CrystalOrb 周边留白优化(VLM 指出主界面左侧有空白)。
170→5. **🟡 P2 — 云存档 + 云排行榜**:v0.5+ 后端 API(需评估是否引入 mini-service)。
171→6. **🟢 P3 — 全 5 纪元手写叙事节点**:替换模板化 lore。
172→7. **🟢 P3 — socket 多人同步星潮**:全局事件共享。
173→
174→### 定时任务(fixed_rate · 900s
175→- **当前 job_id**: **228357**2026-06-24 13:25 重建)
176→- **配置**: `fixed_rate` + `"900"`15分钟间隔),`priority=10`(高优先级),`payload.kind=webDevReview``tz=Asia/Shanghai`
177→- **任务名**: Echo Nexus - 任务审查与持续迭代
178→- **任务描述强制要求**:每次触发 MUST 先完整阅读 worklog.md 工单,以工单为准,不凭记忆工作
179→- **正常完成不会被删除**,只有未捕获异常会标记失败。若任务列表为空,需用 cron create 重建(schedule.kind=fixed_rate, expr="900", priority=10, payload.kind=webDevReview)。
180→- **job_id 历史**: 227909(失效) → 228130(失效) → 228172(失效) → 228186(失效) → 228218(删除重建) → 228236(cron版,删除重建) → 228266(fixed_rate,失效) → 228329(失效) → **228357(当前,重建)**
181→
182→---
183→
184→## 定时任务描述模板(webDevReview · fixed_rate
185→
186→> 重建参数:schedule.kind=`fixed_rate`, schedule.expr=`"900"`, params.priority=`10`, payload.kind=`webDevReview`
187→
188→```
189→请先判断当前项目状态,使用 agent-browser 进行必要测试和 QA,然后自主选择本轮最合适的工作重点并继续开发。
190→
191→要求:
192→1. 开始任务时查看 /home/z/my-project/worklog.md 了解实际进展,拆解详细todo
193→2. 使用 agent-browser 进行测试和QA,有bug优先修复
194→3. 如果稳定,自主提出新需求继续推进
195→4. 样式要越做细节越多!!
196→5. 功能要越做越多!!
197→6. 工作告一段落更新 /home/z/my-project/worklog.md
198→7. 项目结束后一定要把工作记录写在 worklog.md 里
199→
200→交接文档建议包含三部分:
201→- 项目当前状态描述/判断
202→- 当前目标/已完成的修改/验证结果
203→- 未解决问题或风险,建议下一阶段优先事项
204→```
205→
206→---
207→
208→## 历史详细版本记录(按需查阅 docs/repo/docs/
209→
210→- `04-解码系统修复-v0.1.1.md`
211→- `05-遗迹探险系统-v0.2.md`
212→- `06-音频与成就系统-v0.2.1.md`
213→- `07-星潮事件系统-v0.3.md`
214→- `08-星图天文台系统-v0.3.1.md`
215→- `09-回响编年史系统-v0.4.md`
216→- `10-深空信标系统-v0.5.md`
217→
218→---
219→
220→Task ID: 7
221→Agent: full-stack-developer
222→Task: 角色属性系统(探索力/智慧/勇气/灵感)
223→
224→Work Log:
225→- 阅读 worklog.md 了解 v0.6 项目背景,确认 8 大系统架构与四色全息色彩规范
226→- 新建 `src/lib/game/attributes.ts` 逻辑层(~330 行):
227→ - 定义 CharacterAttributes/AttributeProgress/AttributeKey 类型 + ATTRIBUTE_CONFIG 元信息
228→ - getAttributeBonus 线性+递减两区加成公式(0-50 每 10 点 +5%50-100 每点 +2%
229→ - getAllBonuses 返回 12 个修饰器(探险力/巡航速度/解码步数/洞见/自动解码周期/探险生命/BOSS 胜率/巡航护盾/接触率/星潮触发/脉冲连击/产能)
230→ - levelUpCheck 自动跨多级 + expRequiredForLevel + computePrestigeAttrPoints + migrateAttributes 兼容
231→- 扩展 `src/lib/game/types.ts` GameState:新增 attributes/attributeProgress/pendingAttrPoints
232→- 扩展 `src/lib/game/config.ts` INITIAL_STATE:补全默认值(0 数值 + 空 progress
233→- 扩展 `src/lib/game/engine.ts`
234→ - recomputeStats 末尾聚合 am.crystalsPerSecMult/insightMultAdd/contactRateMult/decodeStepsBonus
235→ - performPrestige 保留 attributes 数值,清空 attributeProgress,发放 ascensions × 2 + 1 点属性点
236→ - createInitialState 每次返回全新对象避免引用共享
237→- 扩展 `src/store/gameStore.ts`(核心改造,10+ 处 syncStats 调用全部补 attributes 参数):
238→ - pulse:连击 ≥3 给灵感经验 + 脉冲威力乘灵感连击加成
239→ - clickNode 完成:给智慧经验(tier × 2)
240→ - autoDecodeTick:智慧经验 +1 + 自动解码周期受 am.autoDecodeIntervalMult 影响
241→ - startExpedition:探险力乘 am.expeditionPowerMult,生命加 am.expeditionHpBonus
242→ - resolveCurrentNodeBOSS 节点包装 RNG 提升 +am.bossWinRateBonus 胜率;战斗胜利给勇气+探索力经验
243→ - grantCruiseReward:按总奖励量给探索力+勇气经验
244→ - tickTide:灵感 am.tideTriggerBonus 缩短星潮间隙(上限 30%)
245→ - init() 调用 migrateAttributes 补全旧存档
246→ - 新增 allocateAttribute / gainAttributeExp 两个 action
247→- 新建 `src/components/game/AttributesPanel.tsx` UI 面板(~330 行):
248→ - 四维卡片网格(小屏 2×2,大屏 1×4)
249→ - 每张卡片:图标 + 中英文名 + Lv.badge + 数值/100 + 加成% + 经验条 + 影响列表 + 「+分配」按钮
250→ - 顶部待分配点数 badgependingAttrPoints > 0 时 echo-pending-pulse 闪烁动画)
251→ - 底部总等级/总加成概览 + 12 个修饰器明细
252→ - 4 色辉光边框(emerald/fuchsia/amber/rose),顶角光晕装饰
253→- 接入 `src/app/page.tsx`
254→ - grid-cols-7 → grid-cols-8,新增第 8 个「角色」TabsTriggerUser 图标,渐变主题)
255→ - pendingAttrPoints > 0 时显示 rose 红点提示
256→ - 渲染 AttributesPanel
257→ - StatsPanel 新增 5 行属性行(探索力/智慧/勇气/灵感/待分配点)
258→ - 版本号 v0.6 → v0.7
259→- 新增 `src/lib/game/achievements.ts` 2 项成就:
260→ - ach_attr_total_50(四维觉醒):总属性 ≥ 50 → 产能+6%/洞见+6%
261→ - ach_attr_max_100(维度精通):任一属性 100 → 产能+12%/洞见+10%
262→- 写入 `agent-ctx/7-full-stack-developer.md` 工作记录
263→- 最终验证:lint 零错误 + dev 服务器 HTTP 200 + 编译 < 250ms
264→
265→Stage Summary:
266→- ✅ 角色属性系统 v0.7 P1 全部完成
267→- ✅ 新增 2 文件(attributes.ts + AttributesPanel.tsx),修改 6 文件(types/config/engine/gameStore/achievements/page
268→- ✅ 四维属性完整接入游戏各系统(探险/解码/脉冲/巡航/星潮)
269→- ✅ 飞升属性点公式 = ascensions × 2 + 1;经验累积自动升级(每级 10×level)
270→- ✅ 旧存档兼容(migrateAttributes 补全字段,同步 level 与 attributes
271→- ✅ lint 零错误 + HTTP 200 + 编译 < 250ms
272→- ✅ 严格四色全息(emerald/fuchsia/amber/rose),零蓝色/靛色
273→- ✅ 8 标签页 + 巡航按钮全部保留,data-tut 锚点未破坏
274→- ✅ 待分配点数时标签页 rose 红点提示 + 顶部 badge 闪烁
275→
276→---
277→Task ID: 8-deploy
278→Agent: main
279→Task: 重建定时任务 + 更新 README + 部署 gh-pagesv0.7
280→
281→Work Log:
282→- 检查项目状态:CrystalOrb.tsx 已是 v0.7 Canvas 粒子系统(659 行),无活动 cron job
283→- 重建定时任务:cron create fixed_rate="900" priority=10 webDevReview → job_id 228329tz=Asia/Shanghai
284→- 重写 README.md
285→ - 顶部显著位置添加「在线游玩」链接(https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
286→ - 版本号 v0.1 → v0.7
287→ - 新增 10 大系统表格(含 v0.6 巡航 + v0.7 角色属性)
288→ - 完整开发路线表(v0.1-v1.0,标记 ✅/🔜 状态)
289→ - 新增本地开发命令 + Gitea Pages 部署指南
290→ - 文档索引扩展至 10 个版本文档
291→- 静态导出:BUILD_EXPORT=true bun run build:static → out/ 1.6MHTTP 200 编译 4.3s
292→- 推送 gh-pages 分支:force push origin gh-pagescommit 8727fed "deploy: v0.7"
293→- 提交 mainREADME 更新(commit 43dfc2d "docs: README 添加在线游玩链接 + v0.7 状态更新"
294→- 验证 Gitea Pageshttps://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/ → HTTP 200 ✅
295→- QA 验证:agent-browser 打开 http://localhost:3000/ → VLM 评分 8/10(主界面 polishedminor 空白)
296→
297→Stage Summary:
298→- ✅ 定时任务重建成功(job_id 228329,每 15 分钟自动审查迭代)
299→- ✅ README 重写完成,网站链接显著展示,v0.7 状态完整
300→- ✅ gh-pages 部署成功,Gitea Pages HTTP 200 可访问
301→- ✅ main 分支已同步推送(README + worklog 更新)
302→- ✅ dev 服务器健康(HTTP 200,编译 < 250ms
303→- ✅ CrystalOrb v0.7 Canvas 粒子系统已确认完整(659 行)
304→- 下一步:v0.8 巡航玩法增强(BOSS 战 / 道具掉落 / 事件选择节点)
305→
306→---
307→Task ID: 8
308→Agent: main
309→Task: v0.8 巡航玩法大增强(BOSS 战 + 道具掉落 + 事件选择节点)+ 工单修正 + 审查流程重建
310→
311→Work Log:
312→- 重读 worklog 工单,发现 v0.7 两项(CrystalOrb Canvas + 角色属性)已完成但工单"下一阶段优先级"未更新
313→- 修正 worklog 工单:标注 P0/P1 v0.7 已完成,重新排序下一阶段优先级(P0=巡航增强, P1=探险平衡/信标扩展, P2=UI打磨/云存档, P3=叙事/多人)
314→- 重建审查流程 cron jobfixed_rate=900s, priority=10, webDevReview, tz=Asia/Shanghai, job_id=228357
315→ - 任务描述强制要求:每次触发 MUST 先完整阅读 worklog.md 工单,以工单为准,不凭记忆工作
316→- 发现之前被中断的 v0.8 subagent 已写入 2037 行代码(cruise.ts +1487 / CruiseMode.tsx +858 / page.tsx +2
317→- 修复 2 个 lint 错误(react-hooks/set-state-in-effect):
318→ - isTouch: useState(false) + useEffect(setIsTouch) → useState initializer
319→ - stats: useState(null) + useEffect(setStats(loadCruiseStats)) → useState initializer
320→- QA 验证(agent-browser + VLM):
321→ - 主界面 VLM 7/10v0.8 版本号 ✅ + 巡航按钮 ✅
322→ - 巡航 READY 阶段 VLM 8/10BOSS 战说明 ✅(每5关BOSS战/击败BOSS)+ 射击控制说明 ✅(空格/右下角射击按钮)
323→ - 巡航第1关游戏 VLM 8/10:飞船(绿色三角)✅ + HUD(护盾/能量/分数/计时)✅ + 陨石/收集物/能量场 ✅ + 无渲染错误
324→- 提交 v0.8commit 494bc5f+ 推送 main
325→- 静态导出 BUILD_EXPORT=true → out/ 1.6M(编译 4.1s
326→- 部署 gh-pagesforce push, commit b865922)→ Gitea Pages HTTP 200 ✅
327→
328→Stage Summary:
329→- ✅ 工单修正完成:v0.7 已完成项标注清楚,下一阶段优先级重新排序
330→- ✅ 审查流程重建:cron job_id 228357,每15分钟触发,强制读工单
331→- ✅ v0.8 巡航玩法大增强全部完成并部署:
332→ - BOSS 战系统(每5关,三阶段攻击,HP条,击败奖励×3)
333→ - 飞船射击系统(空格/射击按钮,能量消耗,子弹尾焰)
334→ - 道具掉落(5种powerup:护盾/能量/急速/超载/磁吸)
335→ - 事件选择节点(每关通关后3选1,10种强化牌)
336→ - lint 零错误 + dev HTTP 200 + gh-pages HTTP 200
337→- ✅ VLM 评分:主界面 7/10 + 巡航READY 8/10 + 巡航游戏 8/10
338→- 在线游玩 v0.8https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
339→- 下一阶段工单:P1 探险BOSS胜率平衡 + 探险能量恢复 / 信标系统扩展(周挑战/信标链)
340→
341→---
342→Task ID: 9-b
343→Agent: full-stack-developer
344→Task: 信标系统扩展(周挑战 + 信标链连续奖励)
345→
346→Work Log:
347→- 阅读现有 `src/lib/game/beacon.ts`v0.5 每日挑战 + 本地排行榜,358 行)+ `src/components/game/BeaconPanel.tsx` + `src/store/gameStore.ts` 的 `trackBeacon` 函数(约第 173 行)+ worklog v0.8 项目状态,确认四色全息规范与现有架构。
348→- **扩展 `src/lib/game/beacon.ts`358 → 873 行,新增 ~515 行)**
349→ - **周挑战(WEEKLY CHALLENGE**
350→ - `BeaconWeeklyChallenge` 接口(weekKey YYYY-Www / type / difficulty 强制 anomaly|singular / goal / rewardInsight / rewardContact / seed / title / desc
351→ - `BeaconWeeklyProgress` 接口(weekKey / progress / startedAt / completedAt / claimed / durationSec
352→ - `getWeekKey(now)`ISO 8601 周键(周一为起点,含首个周四的周为第一周)
353→ - `weekKeyToSeed`FNV-1a 哈希
354→ - `generateWeeklyChallenge(now)`:基于 weekKey 种子确定性生成,难度加权 anomaly 60% / singular 40%goal 为日基准 ×3-5 倍(decode 18-75 / expedition 3-15 / pulse 60-245 / boss 3-12 / insight 120-595
355→ - `loadWeeklyProgress` / `saveWeeklyProgress` / `addWeeklyProgress` / `claimWeeklyReward`(推送排行榜,entry 加 `isWeekly: true`
356→ - `msUntilNextWeek(now)`:距离下周一 UTC 0 点的毫秒数
357→ - `BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1"`
358→ - **信标链(BEACON CHAIN**
359→ - `BeaconChainState` 接口(lastCompletedDateKey / currentStreak / longestStreak / totalCompletions / graceUsed / milestonesClaimed
360→ - `BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1"`
361→ - `BEACON_CHAIN_MILESTONES = [3, 7, 14, 30]` 常量
362→ - `BEACON_CHAIN_REWARDS`4 个里程碑(三日谐振 +50/+5 / 七日回响 +120/+12 / 半月星潮 +280/+28 / 满月飞升 +680/+68
363→ - `loadChainState` / `saveChainState`(每次返回新对象避免引用共享)
364→ - `recordChainCompletion(dateKey)` 核心逻辑:同日重复忽略;次日 streak++;隔一天 miss 且 graceUsed<1 时续命 streak++ graceUsed++;其他断链 streak=1 graceUsed=0;返回 `{ state, newMilestones }`
365→ - `claimChainMilestone(milestone)` / `getNextMilestone(streak)` / `getChainProgress(streak)`(返回 current/next/prev/progressPct 用于 UI
366→ - `dateKeyToTimestamp` / `dateKeyDiffDays` 工具函数
367→ - `BeaconScoreEntry` 新增可选 `isWeekly?: boolean` 字段
368→- **扩展 `src/store/gameStore.ts`**
369→ - import 扩展:generateWeeklyChallenge / loadWeeklyProgress / loadChainState / addWeeklyProgress / recordChainCompletion / claimWeeklyReward / claimChainMilestone / getTodayKey + 类型
370→ - `trackBeacon(type, delta)` 函数签名从 `boolean` 改为 `{ dailyJustCompleted, weeklyJustCompleted, newChainMilestones }`
371→ - 日挑战类型匹配且未完成 → addBeaconProgress;若刚完成 → recordChainCompletion(getTodayKey()) 收集 newMilestones
372→ - 周挑战类型匹配且未完成 → addWeeklyProgress
373→ - 所有调用点(pulse/clickNode/autoDecodeTick/resolveCurrentNode 等 7 处)原本就忽略返回值,向后兼容
374→ - 新增 action `claimWeeklyBeacon`:调用 claimWeeklyReward → 发放 insights + contact 到 state,返回 `{ rewardInsight, rewardContact, score }`
375→ - 新增 action `claimChainReward(milestone)`:前置校验 currentStreak 与 milestonesClaimed,调用 claimChainMilestone → 发放奖励,返回 `{ rewardInsight, rewardContact, label, ok }`
376→ - GameActions 接口同步新增两个 action 签名
377→- **重写 `src/components/game/BeaconPanel.tsx`301 → 638 行)**
378→ - 顶部头部 + 每日挑战卡片(保留 v0.5 完整功能 + 按钮主题色随难度色淡显)
379→ - **新增周挑战区块**fuchsia 主题):标题"周挑战 · WEEKLY" + weekKey + 倒计时 + 挑战卡片(类型/难度标签/标题/描述/进度条/奖励/领取按钮 emerald 主题)+ weekly-glow 动画
380→ - **新增信标链区块**amber→rose 渐变):标题"信标链 · CHAIN" + 大字号 streakchain-streak-text 渐变流动动画)+ 今日完成状态徽章 + 4 个里程碑节点(w-12 h-12 圆形,claimed=emerald/✓,reachable=rose 脉冲动画+领取按钮,inProgress=amber 半亮,未到达=muted 灰)+ 节点间连线(背景灰 + 已达成部分 amber→rose 渐变 + 辉光,基于 prev→next 插值定位)+ 进度条 + 底部统计(最长/累计/续命状态)
381→ - 桌面端 lg:grid-cols-2 让周挑战+信标链并排,移动端单列
382→ - 排行榜条目区分日/周:周挑战 entry 显示 "WEEK" 徽章 + fuchsia 高亮背景
383→ - 空状态提示扩展为"完成今日或本周信标即可登榜"
384→ - 4 个新动画:weekly-glow / chain-milestone-pulse / chain-streak-flux(背景渐变流动)
385→- **QA 验证**
386→ - `bun run lint` 零错误(每次修改后均验证)
387→ - dev.log 全程无错误,所有编译 < 300ms
388→ - **信标链逻辑测试**(bun 直接运行 TS,5 个场景全 PASS):
389→ 1. 昨日 streak=1 → 今日完成 → streak=2 ✅
390→ 2. 同日重复完成 → 忽略 ✅
391→ 3. 明日完成 → streak=3,无需 grace ✅
392→ 4. 隔一天 misslastCompleted + 2 天)→ streak=4graceUsed=1 ✅
393→ 5. 再次 missgrace 已用)→ 断链 streak=1graceUsed=0 ✅
394→ - **UI 集成测试**agent-browser):
395→ - localStorage 模拟设置 chain statelastCompletedDateKey=yesterday, currentStreak=1)→ reload → UI 正确显示 streak=1
396→ - 设置 streak=3 + milestonesClaimed=[] → milestone 3 节点显示 rose 脉冲动画 + "领取"按钮
397→ - 点击"领取"→ milestonesClaimed=[3] + Toast "✦ 三日谐振 已领取 +50 洞见 · +5.0 接触" + 按钮消失 ✅
398→ - **VLM 视觉评分**(目标 ≥7/10):
399→ - 首屏截图:8/10(四色一致、布局合理、信标链清晰)
400→ - 里程碑可领取状态:8/10(amber→rose 渐变醒目、里程碑层次分明、fuchsia 主题清晰)
401→ - 重置后干净状态:8/10(WEEKLY 字体对比度可优化,但整体可读性强)
402→ - **不破坏现有功能**:日挑战进度/领奖/排行榜流程全部保留并工作正常
403→
404→Stage Summary:
405→- ✅ beacon.ts 扩展完成(358 → 873 行):周挑战 + 信标链全部函数与类型
406→- ✅ gameStore.ts 集成完成:trackBeacon 升级为对象返回值 + claimWeeklyBeacon + claimChainReward 两个新 action
407→- ✅ BeaconPanel.tsx UI 完整重写(301 → 638 行):周挑战 fuchsia 卡片 + 信标链 amber→rose 渐变区块
408→- ✅ 严格四色全息:周挑战 fuchsia、信标链 amber→rose、领取按钮 emerald、无蓝/紫色违规
409→- ✅ localStorage 新增 2 个独立 keyweekly + chain),不污染旧 key
410→- ✅ 确定性:周挑战同周同种子同结果(weekKey FNV-1a 种子)
411→- ✅ 信标链 grace 续命机制工作正常(5 个场景 bun 测试全 PASS)
412→- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10
413→- ✅ 现有日挑战 + 排行榜 + 领奖流程全部保留,无回归
414→
415→---
416→Task ID: 9
417→Agent: main + subagent(9-b)
418→Task: v0.8.1 P1 双任务 — 探险 BOSS 胜率平衡 + 能量恢复 + 信标系统扩展(周挑战 + 信标链)
419→
420→Work Log:
421→- 重读工单确认 P1 两项为下一阶段优先级
422→- P1-a 探险平衡(main 自己做,平衡需要精细控制):
423→ - expedition.ts: combatDifficultyScale 8→4, combatWinRateFloor 0.25→0.35, BOSS difficulty 5-7→3-5
424→ - 新增 computeEnergyRegenInterval(state): exp_2 -30%, exp_3 -20%, 探索力属性 -最高30%, 下限12s
425→ - computeEnergyRegen 接受 intervalSec 参数
426→ - config.ts: exp_2/exp_3 描述加能量恢复说明
427→ - gameStore.ts tick: 传入动态间隔
428→ - ExpeditionPanel.tsx: 显示实际恢复速度 + '已加速'标记
429→- P1-b 信标系统扩展(dispatch subagent 9-b,并行完成):
430→ - beacon.ts 358→873行: 周挑战(getWeekKey ISO 8601 + 确定性生成) + 信标链(4里程碑+grace续命)
431→ - BeaconPanel.tsx 301→638行: 周挑战fuchsia主题 + 信标链amber→rose里程碑节点
432→ - gameStore.ts: trackBeacon升级 + claimWeeklyBeacon/claimChainReward actions
433→- QA 验证:
434→ - lint 零错误
435→ - 探险面板 VLM 7/10(能量条 + 恢复速度显示正常)
436→ - 信标面板 VLM 8/10(周挑战 WEEKLY + 信标链 3/7/14/30 + 排行榜全部可见)
437→ - subagent 5场景 bun 测试全 PASS(信标链断链/续命逻辑)
438→- 提交 v0.8.1commit 71ca5b4+ 推送 main
439→- 静态导出 + 部署 gh-pagescommit 97eecaa)→ HTTP 200
440→
441→Stage Summary:
442→- ✅ P1-a 探险平衡完成: BOSS 基础胜率 25%→35-45%, 配合技术/属性可达 55-70%
443→- ✅ P1-a 能量恢复: 固定45s → 动态12-45s(技术+属性加速)
444→- ✅ P1-b 周挑战: 每周一UTC刷新, fuchsia主题, goal×3-5倍
445→- ✅ P1-b 信标链: 4里程碑(3/7/14/30天) + grace续命, amber→rose渐变
446→- ✅ 在线游玩 v0.8.1: https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
447→- 下一阶段工单: P2 UI样式细节打磨 / P2 云存档+云排行榜 / P3 全纪元叙事
448→
449→---
450→Task ID: v0.14-reimpl
451→Agent: full-stack-developer
452→Task: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章
453→
454→Work Log:
455→- 阅读 worklog.md 了解 v0.8.1 项目状态(10 大系统 + 四色全息规范 + 8 标签页架构)
456→- 类型扩展 `src/lib/game/types.ts`
457→ - 新增 `IdleProjectDef` 接口(id/name/desc/durationSec/reward/minAscensions/minCrystalsPerSec/icon/color/order
458→ - 新增 `IdleProjectSlot` 接口(projectId/startedAt/finishesAt/remainingSec/completed
459→ - GameState 接口新增 4 字段:idleProjectSlots (3 槽位数组) / idleProjectHistory / idlePermanentBonus / idleStats
460→- 新建 `src/lib/game/idle.ts` (~290 行)
461→ - 6 个放置工程定义(idle_scan 60s / idle_refine 180s / idle_archive 600s / idle_anchor 1200s / idle_drones 1800s / idle_resonance 3600s
462→ - 奖励类型:crystals / insights / energy / contact / crystalsPerSecPermanent / randomFragment
463→ - 解锁逻辑:minAscensions 与 minCrystalsPerSec 为 OR 关系(满足任一已设置条件即可)
464→ - 工具函数:getIdleProject / getUnlockedIdleProjects / getLockedIdleProjects / isIdleProjectUnlocked / formatRemaining (12s/3m 45s/1h 12m) / formatReward / deriveMinerFleet (派生 min_1..min_5 采矿无人机,icon: ⛏️🌋🤖💠☀️,无任何采矿技术时返回休眠 min_1)
465→ - 颜色映射:IDLE_COLOR_CLASSES (text/border/bg/bgSoft/glow/ring/dot) + COLOR_HEX (用于 inline style 绕过 Tailwind 动态类限制)
466→- 引擎集成 `src/lib/game/engine.ts`
467→ - recomputeStats() 末尾 `crystalsPerSec += state.idlePermanentBonus ?? 0` (放置永久加成叠加)
468→ - performPrestige() 清空 idleProjectSlots (中断当前周目工程),保留 idlePermanentBonus/idleProjectHistory/idleStats (跨周目永久)recomputeStats 调用传 idlePermanentBonus
469→ - createInitialState() 新增 4 字段默认值
470→- Store 集成 `src/store/gameStore.ts` (核心改造)
471→ - 新增 4 actionstartIdleProject (校验+写入槽位) / cancelIdleProject (置 null) / claimIdleProject (应用奖励 + permBonus 累加 + randomFragment 解锁 + history 追加 + Toast 提示) / tickIdleProjects (每 tick 更新 remainingSec,归零标记 completed)
472→ - **persist migrate 函数**persist 配置增加 `version: 1` + `migrate` 函数自动补全 4 个 idle 字段,旧存档加载不崩溃
473→ - 14 处 syncStats 调用全部更新为传入 `idlePermanentBonus: s.idlePermanentBonus ?? 0`,确保 recomputeStats 计算时纳入永久加成
474→ - init() 中也补全 4 个 idle 字段(defense in depth
475→- 主循环 `src/hooks/useGameLoop.ts`:新增 tickIdleProjects 选择器,setInterval 回调中 autoDecodeTick 后调用,visibilitychange 也调用
476→- 新建 `src/components/game/IdleOperationsPanel.tsx` (~490 行)
477→ - 4 section 在 max-h-[520px] overflow-y-auto 容器中:
478→ 1. 放置收益概览:3 stat tiles (放置产能/永久加成/完成工程) + 离线效率 progress bar + 累计放置晶体统计
479→ 2. 采矿无人机舰队:grid 展示 deriveMinerFleet,每卡 emoji+name+Lv+output/s+状态点 (active=emerald ping 脉冲, dormant=灰)
480→ 3. 放置工程槽位:3 张卡(空槽=虚线占位,运行中=大字号倒计时+自定义进度条+取消按钮,已完成=奖励预览+领取按钮带 glow)
481→ 4. 可派遣工程:2 列 grid 展示已解锁工程(icon+name+duration+desc+reward+3 数字派遣按钮 1/2/3),下方列出未解锁工程及解锁条件
482→ - useShallow 订阅 + 本地 now state 每 1s 刷新倒计时
483→ - 严格 4 色全息,自定义 fuchsia 滚动条
484→- 新建 `src/components/game/IdleStatusBadge.tsx` (~100 行)
485→ - 紧凑徽章 h-8 px-2.5 text-[11px]:脉冲点 (emerald 若 cps>0) + "放置中 +X/s" (或 "休眠中")
486→ - Tooltip 悬停展示分解:基础产能/永久加成/运行中工程/待领取工程数
487→ - data-tut="idle-status-badge" 锚点 + onClick 切换到放置 tab
488→- 新建 `src/components/game/IdleProjectBar.tsx` (~90 行)
489→ - 晶体球下方细长进度条,最多 3 个工程 mini pill `[icon] name 12s ▓▓▓░░`
490→ - 完成时显示 "✓ 待领取" + glow 辉光;无工程返回 null
491→ - 进度条颜色用 inline style (COLOR_HEX) 控制,每 1s 刷新倒计时
492→- 主页面集成 `src/app/page.tsx`
493→ - 引入 3 新组件 + Clock 图标 + getUnlockedIdleProjects
494→ - 新增 activeTab/tabInited state + controlled Tabs (`value={activeTab} onValueChange={setActiveTab}`)
495→ - 挂载后 useEffect 一次性设置默认 tabhasActiveExpedition→"expedition"hasPendingPerk→"constellation",否则→"idle"
496→ - 版本号 v0.8 → v0.14
497→ - Header 在 StarTideIndicator 后加 IdleStatusBadge
498→ - 左侧 CrystalOrb 后加 IdleProjectBar
499→ - TabsList grid-cols-8 → grid-cols-9,新增 value="idle" 的 TabsTrigger 作为第一个 tabClock 图标,emerald 主题,待领取时显示数量红点)
500→ - 新增 TabsContent value="idle" 渲染 IdleOperationsPanel
501→ - 新增 idle 相关 goal 提示(高优先级):待领取 > 0 → "✦ 放置工程已完成 X 项,请前往「放置」标签领取奖励";全空+有解锁+时长>60s → "「放置」标签可派遣工程项目,离线自动产出"
502→ - StatsPanel 新增 3 行:放置永久加成 / 完成放置工程 / 放置产出晶体
503→- 教程更新 `src/lib/game/tutorial.ts`TUTORIAL_STEPS 在 decode 与 tech 之间插入新步骤 (id="idle", target="tab-idle", 标题"③ 放置工程 · 离线产出"),原 tech/expedition/prestige 编号顺延为 ④⑤⑥
504→- 写入 `agent-ctx/v0.14-reimpl-full-stack-developer.md` 工作记录
505→- 最终验证:
506→ - lint 零错误零警告 ✅
507→ - dev 服务器 HTTP 200,编译 < 250ms ✅
508→ - agent-browser 烟雾测试全流程通过(默认 idle tab + 头部徽章 + 4 section + 派遣→倒计时→完成→领取→永久加成应用) ✅
509→
510→Stage Summary:
511→- ✅ v0.14 放置系统 (Idle Operations) 全部完成
512→- ✅ 新增 4 文件(idle.ts 290行 + IdleOperationsPanel.tsx 490行 + IdleStatusBadge.tsx 100行 + IdleProjectBar.tsx 90行),修改 6 文件(types/engine/gameStore/useGameLoop/page/tutorial
513→- ✅ 6 个放置工程:勘探扫描 / 精炼校准 / 碎片整理 / 锚点部署 / 无人机扩编 / 谐振标定
514→- ✅ 3 个槽位并行 + 6 工程解锁逻辑 (OR) + 永久产能加成 (permBonus 累加跨周目)
515→- ✅ 采矿无人机舰队派生 (min_1..min_5) + 休眠状态显示
516→- ✅ 头部 idle 徽章 + 默认放置 tab + 晶体球下方进度条 (5 秒内可见三要素)
517→- ✅ persist migrate 函数 (version 1) 旧存档兼容
518→- ✅ 14 处 syncStats 调用全部传 idlePermanentBonusrecomputeStats 永久加成不丢失
519→- ✅ 飞升时清空活跃槽位但保留永久加成 / 历史 / 统计
520→- ✅ 严格 4 色全息 (emerald/rose/amber/fuchsia),零蓝/靛
521→- ✅ lint 零错误 + HTTP 200 + agent-browser 全流程烟雾测试通过
522→- ✅ 9 标签页 (新增"放置"为第一个) + 巡航按钮 + 教程新增 idle 步骤
523→
524→---
525→Task ID: v0.14-release
526→Agent: 主控 Z.ai Code
527→Task: v0.14 放置系统发布 — README/docs 更新 + 端到端验证 + git commit
528→
529→Work Log:
530→- 发现项目被环境重置回 v0.8.1 状态(v0.14 代码全部丢失,worklog 也被截断到 447 行)
531→- 重新委派 full-stack-developer subagent (v0.14-reimpl) 在 v0.8.1 代码库上重新实现放置系统
532→- subagent 交付 10 个文件(4 新建 + 6 修改),lint 零错误,agent-browser smoke test 通过,git commit 17820b1
533→- 主控独立验证:
534→ * git log 确认 commit 17820b1 存在 ✓
535→ * bun run lint 零错误 ✓
536→ * HTTP 200 ✓
537→ * agent-browser: v0.14 标签 ✓ + 放置中徽章 ✓ + 默认 idle tab ✓ + 4 段面板完整 ✓
538→ * 派遣 idle_scan → slot 0 显示 60s 倒计时 ✓
539→ * 快进完成 → 领取 → insights 79→87 (+8) + projectsCompleted 0→1 ✓ + 槽位清空 ✓
540→- 主控更新 README.md → v0.14:突出 12 大系统、3 层 idle 节奏、灵感来源、健康设计声明
541→- 主控创建 docs/11-放置系统-v0.14.md:完整设计文档
542→- 主控创建每 15 分钟触发的 webDevReview cron job
543→
544→Stage Summary:
545→
546→## v0.14 放置系统已重新上线并 git commit 保固
547→
548→### 核心交付
549→- **3 层 idle 节奏**:被动产能(无人机舰队)+ 放置工程(6 种 × 3 槽位)+ 离线收益(8h cap)
550→- **始终可见的 idle 元素**Header「放置中 +X/s」徽章 + 默认 tab = 放置 + 晶体球下方工程进度 pill
551→- **永久产能加成**:通过工程奖励累积,集成进 recomputeStats,跨周目保留
552→- **存档安全**persist version 1 + migrate 函数 + 4 字段 fallback,老存档零崩溃
553→
554→### Git 保固
555→- commit 17820b1: "v0.14: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章"
556→- 即使环境再次重置,git checkout 17820b1 即可恢复
557→
558→### 验证结果
559→- ✅ bun run lint 零错误零警告
560→- ✅ HTTP 200dev.log 无运行时错误
561→- ✅ agent-browser 端到端 smoke test 全通过(派遣→倒计时→完成→领取→insights+8)
562→- ✅ Sticky footer/header 验证通过
563→
564→### 未解决问题 / v0.15 优先项
565→1. 🟡 永久产能加成可能需要飞升次数 softcap(防 idle_resonance ×N 滚雪球)
566→2. 🟡 idleProjectHistory / crystalsFromIdle 已在 store 累加但 UI 未展示
567→3. 🟡 缺「批量领取全部」按钮 + 工程完成 toast 通知
568→4. 🟡 遗迹图鉴多层文本(Dark Souls 式 loreFragments)—— v0.15 计划
569→5. 🟡 解码阈值释放叙事碎片 + 星潮三频段切换 —— v0.16 计划
570→6. 🟡 健康设计:每日软上限 + "星核建议休眠"提醒 —— v0.17 计划
571→
+571
View File
@@ -0,0 +1,571 @@
1→# 回响星核 / Echo Nexus — 开发工作日志
2→
3→> 持续性任务的工作交接文档。每个阶段更新,避免进度丢失。
4→
5→---
6→
7→## 一、项目当前状态描述 / 判断
8→
9→### 概况
10→- **项目**:回响星核 / Echo Nexus — 深空考古放置策略游戏
11→- **一句话**:自治无人机采矿 → 解码记忆晶体(原创共振谜题)→ 拼凑碎片叙事 → 飞升多周目。差异化:解码谜题驱动叙事涌现,全息晶体 + 深空粒子美学。
12→- **当前版本****v0.7**CrystalOrb Canvas 粒子系统 + 角色属性系统)
13→- **在线游玩**https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
14→- **仓库**https://git.atdunbg.xyz/Super_Z/echo-nexus
15→- **技术栈**Next.js 16 (App Router) + TypeScript + Tailwind + shadcn/ui + Canvas 2D + Zustand + Web Audio API
16→- **定时任务**:每 15 分钟一次 `webDevReview``fixed_rate` + `"900"` 秒,priority=10job_id 228266)。正常完成不会被删除,无需自持续机制。
17→
18→### 状态判断
19→- dev 服务器运行正常(HTTP 200,编译 < 250ms
20→- v0.7 两大新功能已完成:CrystalOrb Canvas 粒子系统升级(VLM 8/10+ 角色属性系统(VLM 7/10
21→- 系统总体稳定,无阻塞性 bug,可继续推进新功能
22→
23→### 已完成版本里程碑(精简)
24→| 版本 | 核心内容 |
25→|------|---------|
26→| v0.1 | MVP:放置采矿 + 解码共振谜题 + 技术树 + 记忆图谱 + 飞升 + 统计 |
27→| v0.1.1 | 解码可解性修复(路径构造法生成器,100% 可解率)|
28→| v0.2 | 遗迹探险肉鸽系统(能量/探险力/6 节点/程序化路径/BOSS)|
29→| v0.2.1 | 程序化音频(15 种音效)+ 14 项成就 + 视觉打磨 |
30→| v0.3 | 星潮事件系统(6 种星潮 + 全屏叠层 + 程序化音效)|
31→| v0.3.1 | 星图天文台元进程(6 类 × 3 = 18 天赋 + 飞升后 3 选 1 draft + Canvas 星图)|
32→| v0.4 | 回响编年史(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复)|
33→| v0.5 | 深空信标(每日挑战 + 本地排行榜 Top20 + 7 标签页 + 编年史历史 BUG 修复)|
34→| v0.5.1 | 静态部署 Gitea Pages + 新手教程系统(7 步聚光灯引导)+ UI 间距优化 |
35→| v0.5.2 | 离线收益报告弹窗 + 标签页 UI 重设计 + persist rehydrate 竞态修复 |
36→| v0.6 | 深空巡航 Canvas 2D 实时玩法 + UI 偏移重叠修复 |
37→| **v0.7** | **CrystalOrb Canvas 粒子系统升级 + 角色属性系统(四维)** |
38→
39→### 核心系统清单(8 大系统)
40→1. **放置采矿** — 中央晶体球 + 主动脉冲 + 连击加成(`CrystalOrb.tsx`
41→2. **谐振解码** — 原创路径谜题,路径构造法 100% 可解(`DecodeArray.tsx` + `engine.ts`
42→3. **技术树** — 三层科技解锁,提升产能/解码/探险(`TechTree.tsx`
43→4. **遗迹探险** — 肉鸽系统,6 种节点 + 程序化路径 + BOSS`ExpeditionPanel.tsx` + `expedition.ts`
44→5. **星潮事件** — 6 种动态事件,为放置循环注入变化(`StarTide.tsx` + `starTide.ts`
45→6. **星图天文台** — 飞升后 3 选 1 天赋 draft,跨周目永久生效,18 个天赋(`ConstellationPanel.tsx` + `constellation.ts`
46→7. **回响编年史** — 跨周目叙事时间轴,5 纪元循环命名(`ChronicleDialog.tsx` + `chronicle.ts`
47→8. **深空信标** — 每日挑战 + 本地排行榜 Top20`BeaconPanel.tsx` + `beacon.ts`
48→9. **深空巡航** — Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关(`CruiseMode.tsx` + `cruise.ts`)【v0.6】
49→10. **角色属性系统** — 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点,影响所有系统(`AttributesPanel.tsx` + `attributes.ts`)【v0.7 新增】
50→
51→---
52→
53→## 二、当前目标 / 已完成的修改 / 验证结果
54→
55→### v0.7 CrystalOrb Canvas 粒子系统升级(已完成)
56→**动机**:P0 优先级 — 将中央晶体球从 CSS 动画升级为 Canvas 粒子视觉盛宴,提升视觉震撼。
57→
58→**重写文件**`src/components/game/CrystalOrb.tsx`
59→- 从纯 CSS 动画升级为 Canvas 2D 粒子系统
60→- **多层粒子**
61→ - 环绕能量粒子(3层14/10/8个,emerald/fuchsia/rose三色,不同速度方向环绕晶核,带拖尾)
62→ - 环境星尘(40个,缓慢漂移 + 闪烁)
63→ - 点击爆发粒子(10-30个径向发散,颜色随连击等级变色)
64→ - 冲击波环(点击触发1-3层错峰扩散,连击≥3/5解锁更多层)
65→- **晶核升级**:径向渐变辉光 + 3层六边形纹理旋转 + 高光 + 鼠标视差偏移
66→- **呼吸动画**:晶核 + 外层辉光随时间呼吸(sin波)
67→- **进度环**SVG渐变环(emerald→fuchsia→rose)保留
68→- **交互保留**:点击脉冲、连击Toast、浮动数字、音效、`data-tut="crystal-orb"` 锚点
69→- **性能**DPR cap 2ResizeObserver 自适应,requestAnimationFrame 60fps
70→
71→**QA 验证**agent-browser + VLM):
72→- VLM 视觉评分 **8/10**:晶体球清晰可见,多层辉光,环绕粒子动态自然,无渲染问题
73→- 点击交互:冲击波环 ✅ + 爆发粒子 ✅,评分 7/10
74→- lint 零错误;HTTP 200
75→
76→### v0.7 角色属性系统(已完成)
77→**动机**:P1 优先级 — 增加RPG深度,四维属性影响所有游戏系统。
78→
79→**新增文件**
80→- `src/lib/game/attributes.ts`~330行逻辑层):CharacterAttributes/AttributeProgress 类型、ATTRIBUTE_CONFIG 元信息、getAttributeBonus 两区加成公式(0-50线性+0.5%/点,50-100递减+0.2%/点)、getAllBonuses 12项修饰器、levelUpCheck 自动升级、computePrestigeAttrPoints、migrateAttributes 旧存档兼容
81→- `src/components/game/AttributesPanel.tsx`~330行 UI):四维卡片网格(小屏2×2/大屏1×4)、图标+中英文名+Lv.badge+数值/100+加成%+经验条+影响列表+分配按钮、顶部待分配点数badge闪烁、底部总览+12项修饰器明细
82→
83→**修改文件**
84→- `types.ts`GameState 新增 attributes/attributeProgress/pendingAttrPoints
85→- `config.ts`INITIAL_STATE 补全默认值
86→- `engine.ts`recomputeStats 聚合属性加成;performPrestige 发放属性点(ascensions×2+1
87→- `gameStore.ts`:新增 allocateAttribute/gainAttributeExp actionspulse/clickNode/autoDecodeTick/resolveCurrentNode/grantCruiseReward/tickTide 全部接入属性经验;init() 调用 migrateAttributes
88→- `achievements.ts`:新增 ach_attr_total_50 + ach_attr_max_100 两项成就
89→- `page.tsx`grid-cols-7→8,新增「角色」标签页(User图标),红点提示,StatsPanel新增5行属性行,版本号 v0.6→v0.7
90→
91→**四维属性设计**
92→- 探索力(emerald):探险力+X%/巡航飞船速度+X%
93→- 智慧(fuchsia):解码步数+X/洞见+X%/自动解码周期-X%
94→- 勇气(amber):探险生命+X/BOSS胜率+X%/巡航护盾+X
95→- 灵感(rose):接触率+X/星潮概率+X/脉冲连击+X%
96→
97→**QA 验证**agent-browser + VLM):
98→- VLM 视觉评分 **7/10**(面板)/ **8/10**(待分配状态):四维卡片可见、数值/等级/加成/经验条齐全、四色全息
99→- 功能验证:setState pendingAttrPoints=5 → 显示5点待分配 → 点击分配按钮 → exploration 0→1, pending 5→4 ✅
100→- lint 零错误;HTTP 200
101→
102→---
103→
104→### v0.6 深空巡航 Canvas 2D 实时玩法(历史记录)
105→**动机**:回应用户反馈「玩法太单调(全是点点点)」+「视觉震撼不足」——首次引入实时操作玩法。
106→
107→**新增文件**
108→- `src/lib/game/cruise.ts`~520 行逻辑层)
109→ - 类型系统:CruiseEntity 联合类型(ship/asteroid/storm/crystal/insight/beacon/stargate/particle 共 8 种)
110→ - mulberry32 + FNV-1a 种子化 RNG`cruiseSeed(level)`
111→ - `generateLevel`:程序化生成陨石带(8+lvl×2)、虚空风暴(1+lvl/2)、晶体碎片(5+lvl)、洞见光球(2+lvl/3)、信标(1+lvl/4)、星门
112→ - `updateCruise`:物理推进 + 碰撞检测 + 粒子系统 + 震动 + 600ms 无敌帧
113→ - `computeRewards`crystals=碎片×(8+lvl×2)、insights=洞见×(2+lvl×0.5)、contact=信标×(1.5+lvl×0.3);通关×1.5、失败×0.5
114→ - 独立 localStorage `echo-nexus-cruise-v1`:最高分/累计通关/最高关卡/累计奖励/最近 20 局
115→
116→- `src/components/game/CruiseMode.tsx`~830 行渲染层)
117→ - 全屏 fixed inset-0 z-50 CanvasDPR cap 2resize 监听
118→ - 3 层视差星空:远(100星)/中(60星)/近(30星),独立漂移 + 飞船速度视差 + 闪烁
119→ - 8 种实体全部 ctx.shadowBlur 辉光绘制
120→ - 粒子系统:尾焰/收集/碰撞/烟花,上限 200
121→ - 屏幕震动 280ms 衰减;低护盾(<30%)红色边框脉冲
122→ - HUDHTML 叠层,glass+backdrop-blur80ms 节流):护盾/能量/分数/用时/收集计数
123→ - 控制:桌面 WASD/方向键 8 方向 + 移动端虚拟摇杆 + P 暂停 + Esc 退出 + 失焦自动暂停
124→ - 阶段:ready → playing → won/lost(结算明细 + 奖励 + 下一关/重试/返回)
125→
126→**修改文件**
127→- `src/app/page.tsx`header 新增 amber 主题「巡航」按钮(Navigation 图标)+ cruiseOpen state + 条件渲染 CruiseMode
128→- `src/store/gameStore.ts`:新增 `grantCruiseReward({crystals?, insights?, contact?})` actioncrystals 受 crystalCap 限制,contact 受 100 上限)
129→- 版本号 v0.5.2 → v0.6
130→
131→**UI 偏移/重叠 BUG 修复**(3 处):
132→- `DecodeArray.tsx`:移除 className 冲突的 `-translate-x-1/2 -translate-y-1/2`,统一用 inline transform 定位+缩放
133→- `AchievementsPanel.tsx`:移除内层 `max-h-[300px]`,改 `flex-1 min-h-0` 自适应父容器
134→- `page.tsx`:调整三个 section 的 min-h,避免小屏单列时总高度超过手机视口
135→
136→**色彩规范**:严格遵循 emerald(#34d399) / rose(#fb7185) / amber(#fbbf24) / fuchsia(#e879f9) 四色全息色谱,零蓝色/靛色
137→
138→**QA 验证**agent-browser + VLM):
139→- lint 零错误;dev 服务器 HTTP 200;编译 < 250ms
140→- VLM 确认主界面:巡航按钮存在、晶体球居中、解码区正常、无重叠偏移
141→- VLM 确认巡航玩法:飞船可见(绿色三角形)、HUD 完整、陨石+星空背景、粒子特效(黄色尾焰+紫色辉光)
142→- WASD 控制飞船移动,按 W 直冲星门 → 通关,奖励 +30 晶体(2×10×1.5=30 计算正确)
143→- localStorage 正确记录 highScore/totalRuns/totalWins/bestLevel
144→- 奖励同步 gameStoregrantCruiseReward,满仓时 cap 逻辑正确)
145→- 退出返回主界面,7 标签页 + 晶体球 + 解码面板均完好
146→
147→---
148→
149→## 三、未解决问题或风险 / 下一阶段优先事项
150→
151→### 已知问题 / 风险
152→1. **dev 服务器 Turbopack 缓存偶发损坏**`Unable to open static sorted file` + 进程被杀,需 `rm -rf .next` 重启。静态导出构建稳定可靠。
153→2. **定时任务调度类型**:必须用 `fixed_rate` + `"900"` 秒(15分钟),不要用 cron 表达式。`webDevReview` 类型任务正常完成不会被删除,只有抛出未捕获异常才会被标记失败。
154→3. **v0.3 星潮为单机版**:原计划 socket 全局事件,后续可扩展为多人同步。
155→4. **v0.5 信标排行榜为本地版**(纯 localStorage):升级云排行榜需后端 API。
156→5. **v0.5 每日挑战仅 1 个/天**:可加入"周挑战"或"信标链"(连续完成 N 天奖励)。
157→6. **探险 BOSS 战胜率较低**:基础探索力 10 vs BOSS 难度 5-7,胜率约 25%,需玩家投资探险技术。
158→7. **探险能量恢复较慢**:45s/点,可加技术提升恢复速度。
159→8. **编年史上限 50 条**:超过自动丢弃最早的;v0.4 之前飞升无回填(空状态有提示)。
160→9. **5 纪元叙事为模板化生成**:v0.5+ 将加入手写剧情节点。
161→10. ~~**CrystalOrb 仍为 CSS 动画**~~ → ✅ **v0.7 已完成 Canvas 粒子系统升级**。
162→
163→### 下一阶段优先级(v0.7 后修正版)
164→> 工单修正:原 P0 CrystalOrb Canvas + P1 角色属性两项均已在 v0.7 完成,现重新排序。
165→
166→1. **🔴 P0 — 巡航玩法大增强(v0.8 主线)**:BOSS 战关卡(每 5 关一个 BOSS,三阶段攻击模式 + 飞船射击系统)/ 道具掉落(5 种 powerup:护盾/能量/急速/超载/磁吸)/ 事件选择节点(每关通关后 3 选 1 强化牌,10 种强化)。这是当前最高优先级,能显著提升巡航玩法的深度与可玩性。
167→2. **🟠 P1 — 探险 BOSS 战胜率平衡 + 探险能量恢复**:修复已知问题 6/7(探险 BOSS 胜率 25% 偏低,能量 45s/点 偏慢),提升探险体验。
168→3. **🟠 P1 — 信标系统扩展**:周挑战 / 信标链(连续完成 N 天奖励),修复已知问题 5。
169→4. **🟡 P2 — UI 样式细节打磨**:标签页图标/文字比例、视觉层次强化、移动端响应式进一步优化、CrystalOrb 周边留白优化(VLM 指出主界面左侧有空白)。
170→5. **🟡 P2 — 云存档 + 云排行榜**:v0.5+ 后端 API(需评估是否引入 mini-service)。
171→6. **🟢 P3 — 全 5 纪元手写叙事节点**:替换模板化 lore。
172→7. **🟢 P3 — socket 多人同步星潮**:全局事件共享。
173→
174→### 定时任务(fixed_rate · 900s
175→- **当前 job_id**: **228357**2026-06-24 13:25 重建)
176→- **配置**: `fixed_rate` + `"900"`15分钟间隔),`priority=10`(高优先级),`payload.kind=webDevReview``tz=Asia/Shanghai`
177→- **任务名**: Echo Nexus - 任务审查与持续迭代
178→- **任务描述强制要求**:每次触发 MUST 先完整阅读 worklog.md 工单,以工单为准,不凭记忆工作
179→- **正常完成不会被删除**,只有未捕获异常会标记失败。若任务列表为空,需用 cron create 重建(schedule.kind=fixed_rate, expr="900", priority=10, payload.kind=webDevReview)。
180→- **job_id 历史**: 227909(失效) → 228130(失效) → 228172(失效) → 228186(失效) → 228218(删除重建) → 228236(cron版,删除重建) → 228266(fixed_rate,失效) → 228329(失效) → **228357(当前,重建)**
181→
182→---
183→
184→## 定时任务描述模板(webDevReview · fixed_rate
185→
186→> 重建参数:schedule.kind=`fixed_rate`, schedule.expr=`"900"`, params.priority=`10`, payload.kind=`webDevReview`
187→
188→```
189→请先判断当前项目状态,使用 agent-browser 进行必要测试和 QA,然后自主选择本轮最合适的工作重点并继续开发。
190→
191→要求:
192→1. 开始任务时查看 /home/z/my-project/worklog.md 了解实际进展,拆解详细todo
193→2. 使用 agent-browser 进行测试和QA,有bug优先修复
194→3. 如果稳定,自主提出新需求继续推进
195→4. 样式要越做细节越多!!
196→5. 功能要越做越多!!
197→6. 工作告一段落更新 /home/z/my-project/worklog.md
198→7. 项目结束后一定要把工作记录写在 worklog.md 里
199→
200→交接文档建议包含三部分:
201→- 项目当前状态描述/判断
202→- 当前目标/已完成的修改/验证结果
203→- 未解决问题或风险,建议下一阶段优先事项
204→```
205→
206→---
207→
208→## 历史详细版本记录(按需查阅 docs/repo/docs/
209→
210→- `04-解码系统修复-v0.1.1.md`
211→- `05-遗迹探险系统-v0.2.md`
212→- `06-音频与成就系统-v0.2.1.md`
213→- `07-星潮事件系统-v0.3.md`
214→- `08-星图天文台系统-v0.3.1.md`
215→- `09-回响编年史系统-v0.4.md`
216→- `10-深空信标系统-v0.5.md`
217→
218→---
219→
220→Task ID: 7
221→Agent: full-stack-developer
222→Task: 角色属性系统(探索力/智慧/勇气/灵感)
223→
224→Work Log:
225→- 阅读 worklog.md 了解 v0.6 项目背景,确认 8 大系统架构与四色全息色彩规范
226→- 新建 `src/lib/game/attributes.ts` 逻辑层(~330 行):
227→ - 定义 CharacterAttributes/AttributeProgress/AttributeKey 类型 + ATTRIBUTE_CONFIG 元信息
228→ - getAttributeBonus 线性+递减两区加成公式(0-50 每 10 点 +5%50-100 每点 +2%
229→ - getAllBonuses 返回 12 个修饰器(探险力/巡航速度/解码步数/洞见/自动解码周期/探险生命/BOSS 胜率/巡航护盾/接触率/星潮触发/脉冲连击/产能)
230→ - levelUpCheck 自动跨多级 + expRequiredForLevel + computePrestigeAttrPoints + migrateAttributes 兼容
231→- 扩展 `src/lib/game/types.ts` GameState:新增 attributes/attributeProgress/pendingAttrPoints
232→- 扩展 `src/lib/game/config.ts` INITIAL_STATE:补全默认值(0 数值 + 空 progress
233→- 扩展 `src/lib/game/engine.ts`
234→ - recomputeStats 末尾聚合 am.crystalsPerSecMult/insightMultAdd/contactRateMult/decodeStepsBonus
235→ - performPrestige 保留 attributes 数值,清空 attributeProgress,发放 ascensions × 2 + 1 点属性点
236→ - createInitialState 每次返回全新对象避免引用共享
237→- 扩展 `src/store/gameStore.ts`(核心改造,10+ 处 syncStats 调用全部补 attributes 参数):
238→ - pulse:连击 ≥3 给灵感经验 + 脉冲威力乘灵感连击加成
239→ - clickNode 完成:给智慧经验(tier × 2)
240→ - autoDecodeTick:智慧经验 +1 + 自动解码周期受 am.autoDecodeIntervalMult 影响
241→ - startExpedition:探险力乘 am.expeditionPowerMult,生命加 am.expeditionHpBonus
242→ - resolveCurrentNodeBOSS 节点包装 RNG 提升 +am.bossWinRateBonus 胜率;战斗胜利给勇气+探索力经验
243→ - grantCruiseReward:按总奖励量给探索力+勇气经验
244→ - tickTide:灵感 am.tideTriggerBonus 缩短星潮间隙(上限 30%)
245→ - init() 调用 migrateAttributes 补全旧存档
246→ - 新增 allocateAttribute / gainAttributeExp 两个 action
247→- 新建 `src/components/game/AttributesPanel.tsx` UI 面板(~330 行):
248→ - 四维卡片网格(小屏 2×2,大屏 1×4)
249→ - 每张卡片:图标 + 中英文名 + Lv.badge + 数值/100 + 加成% + 经验条 + 影响列表 + 「+分配」按钮
250→ - 顶部待分配点数 badgependingAttrPoints > 0 时 echo-pending-pulse 闪烁动画)
251→ - 底部总等级/总加成概览 + 12 个修饰器明细
252→ - 4 色辉光边框(emerald/fuchsia/amber/rose),顶角光晕装饰
253→- 接入 `src/app/page.tsx`
254→ - grid-cols-7 → grid-cols-8,新增第 8 个「角色」TabsTriggerUser 图标,渐变主题)
255→ - pendingAttrPoints > 0 时显示 rose 红点提示
256→ - 渲染 AttributesPanel
257→ - StatsPanel 新增 5 行属性行(探索力/智慧/勇气/灵感/待分配点)
258→ - 版本号 v0.6 → v0.7
259→- 新增 `src/lib/game/achievements.ts` 2 项成就:
260→ - ach_attr_total_50(四维觉醒):总属性 ≥ 50 → 产能+6%/洞见+6%
261→ - ach_attr_max_100(维度精通):任一属性 100 → 产能+12%/洞见+10%
262→- 写入 `agent-ctx/7-full-stack-developer.md` 工作记录
263→- 最终验证:lint 零错误 + dev 服务器 HTTP 200 + 编译 < 250ms
264→
265→Stage Summary:
266→- ✅ 角色属性系统 v0.7 P1 全部完成
267→- ✅ 新增 2 文件(attributes.ts + AttributesPanel.tsx),修改 6 文件(types/config/engine/gameStore/achievements/page
268→- ✅ 四维属性完整接入游戏各系统(探险/解码/脉冲/巡航/星潮)
269→- ✅ 飞升属性点公式 = ascensions × 2 + 1;经验累积自动升级(每级 10×level)
270→- ✅ 旧存档兼容(migrateAttributes 补全字段,同步 level 与 attributes
271→- ✅ lint 零错误 + HTTP 200 + 编译 < 250ms
272→- ✅ 严格四色全息(emerald/fuchsia/amber/rose),零蓝色/靛色
273→- ✅ 8 标签页 + 巡航按钮全部保留,data-tut 锚点未破坏
274→- ✅ 待分配点数时标签页 rose 红点提示 + 顶部 badge 闪烁
275→
276→---
277→Task ID: 8-deploy
278→Agent: main
279→Task: 重建定时任务 + 更新 README + 部署 gh-pagesv0.7
280→
281→Work Log:
282→- 检查项目状态:CrystalOrb.tsx 已是 v0.7 Canvas 粒子系统(659 行),无活动 cron job
283→- 重建定时任务:cron create fixed_rate="900" priority=10 webDevReview → job_id 228329tz=Asia/Shanghai
284→- 重写 README.md
285→ - 顶部显著位置添加「在线游玩」链接(https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
286→ - 版本号 v0.1 → v0.7
287→ - 新增 10 大系统表格(含 v0.6 巡航 + v0.7 角色属性)
288→ - 完整开发路线表(v0.1-v1.0,标记 ✅/🔜 状态)
289→ - 新增本地开发命令 + Gitea Pages 部署指南
290→ - 文档索引扩展至 10 个版本文档
291→- 静态导出:BUILD_EXPORT=true bun run build:static → out/ 1.6MHTTP 200 编译 4.3s
292→- 推送 gh-pages 分支:force push origin gh-pagescommit 8727fed "deploy: v0.7"
293→- 提交 mainREADME 更新(commit 43dfc2d "docs: README 添加在线游玩链接 + v0.7 状态更新"
294→- 验证 Gitea Pageshttps://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/ → HTTP 200 ✅
295→- QA 验证:agent-browser 打开 http://localhost:3000/ → VLM 评分 8/10(主界面 polishedminor 空白)
296→
297→Stage Summary:
298→- ✅ 定时任务重建成功(job_id 228329,每 15 分钟自动审查迭代)
299→- ✅ README 重写完成,网站链接显著展示,v0.7 状态完整
300→- ✅ gh-pages 部署成功,Gitea Pages HTTP 200 可访问
301→- ✅ main 分支已同步推送(README + worklog 更新)
302→- ✅ dev 服务器健康(HTTP 200,编译 < 250ms
303→- ✅ CrystalOrb v0.7 Canvas 粒子系统已确认完整(659 行)
304→- 下一步:v0.8 巡航玩法增强(BOSS 战 / 道具掉落 / 事件选择节点)
305→
306→---
307→Task ID: 8
308→Agent: main
309→Task: v0.8 巡航玩法大增强(BOSS 战 + 道具掉落 + 事件选择节点)+ 工单修正 + 审查流程重建
310→
311→Work Log:
312→- 重读 worklog 工单,发现 v0.7 两项(CrystalOrb Canvas + 角色属性)已完成但工单"下一阶段优先级"未更新
313→- 修正 worklog 工单:标注 P0/P1 v0.7 已完成,重新排序下一阶段优先级(P0=巡航增强, P1=探险平衡/信标扩展, P2=UI打磨/云存档, P3=叙事/多人)
314→- 重建审查流程 cron jobfixed_rate=900s, priority=10, webDevReview, tz=Asia/Shanghai, job_id=228357
315→ - 任务描述强制要求:每次触发 MUST 先完整阅读 worklog.md 工单,以工单为准,不凭记忆工作
316→- 发现之前被中断的 v0.8 subagent 已写入 2037 行代码(cruise.ts +1487 / CruiseMode.tsx +858 / page.tsx +2
317→- 修复 2 个 lint 错误(react-hooks/set-state-in-effect):
318→ - isTouch: useState(false) + useEffect(setIsTouch) → useState initializer
319→ - stats: useState(null) + useEffect(setStats(loadCruiseStats)) → useState initializer
320→- QA 验证(agent-browser + VLM):
321→ - 主界面 VLM 7/10v0.8 版本号 ✅ + 巡航按钮 ✅
322→ - 巡航 READY 阶段 VLM 8/10BOSS 战说明 ✅(每5关BOSS战/击败BOSS)+ 射击控制说明 ✅(空格/右下角射击按钮)
323→ - 巡航第1关游戏 VLM 8/10:飞船(绿色三角)✅ + HUD(护盾/能量/分数/计时)✅ + 陨石/收集物/能量场 ✅ + 无渲染错误
324→- 提交 v0.8commit 494bc5f+ 推送 main
325→- 静态导出 BUILD_EXPORT=true → out/ 1.6M(编译 4.1s
326→- 部署 gh-pagesforce push, commit b865922)→ Gitea Pages HTTP 200 ✅
327→
328→Stage Summary:
329→- ✅ 工单修正完成:v0.7 已完成项标注清楚,下一阶段优先级重新排序
330→- ✅ 审查流程重建:cron job_id 228357,每15分钟触发,强制读工单
331→- ✅ v0.8 巡航玩法大增强全部完成并部署:
332→ - BOSS 战系统(每5关,三阶段攻击,HP条,击败奖励×3)
333→ - 飞船射击系统(空格/射击按钮,能量消耗,子弹尾焰)
334→ - 道具掉落(5种powerup:护盾/能量/急速/超载/磁吸)
335→ - 事件选择节点(每关通关后3选1,10种强化牌)
336→ - lint 零错误 + dev HTTP 200 + gh-pages HTTP 200
337→- ✅ VLM 评分:主界面 7/10 + 巡航READY 8/10 + 巡航游戏 8/10
338→- 在线游玩 v0.8https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
339→- 下一阶段工单:P1 探险BOSS胜率平衡 + 探险能量恢复 / 信标系统扩展(周挑战/信标链)
340→
341→---
342→Task ID: 9-b
343→Agent: full-stack-developer
344→Task: 信标系统扩展(周挑战 + 信标链连续奖励)
345→
346→Work Log:
347→- 阅读现有 `src/lib/game/beacon.ts`v0.5 每日挑战 + 本地排行榜,358 行)+ `src/components/game/BeaconPanel.tsx` + `src/store/gameStore.ts` 的 `trackBeacon` 函数(约第 173 行)+ worklog v0.8 项目状态,确认四色全息规范与现有架构。
348→- **扩展 `src/lib/game/beacon.ts`358 → 873 行,新增 ~515 行)**
349→ - **周挑战(WEEKLY CHALLENGE**
350→ - `BeaconWeeklyChallenge` 接口(weekKey YYYY-Www / type / difficulty 强制 anomaly|singular / goal / rewardInsight / rewardContact / seed / title / desc
351→ - `BeaconWeeklyProgress` 接口(weekKey / progress / startedAt / completedAt / claimed / durationSec
352→ - `getWeekKey(now)`ISO 8601 周键(周一为起点,含首个周四的周为第一周)
353→ - `weekKeyToSeed`FNV-1a 哈希
354→ - `generateWeeklyChallenge(now)`:基于 weekKey 种子确定性生成,难度加权 anomaly 60% / singular 40%goal 为日基准 ×3-5 倍(decode 18-75 / expedition 3-15 / pulse 60-245 / boss 3-12 / insight 120-595
355→ - `loadWeeklyProgress` / `saveWeeklyProgress` / `addWeeklyProgress` / `claimWeeklyReward`(推送排行榜,entry 加 `isWeekly: true`
356→ - `msUntilNextWeek(now)`:距离下周一 UTC 0 点的毫秒数
357→ - `BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1"`
358→ - **信标链(BEACON CHAIN**
359→ - `BeaconChainState` 接口(lastCompletedDateKey / currentStreak / longestStreak / totalCompletions / graceUsed / milestonesClaimed
360→ - `BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1"`
361→ - `BEACON_CHAIN_MILESTONES = [3, 7, 14, 30]` 常量
362→ - `BEACON_CHAIN_REWARDS`4 个里程碑(三日谐振 +50/+5 / 七日回响 +120/+12 / 半月星潮 +280/+28 / 满月飞升 +680/+68
363→ - `loadChainState` / `saveChainState`(每次返回新对象避免引用共享)
364→ - `recordChainCompletion(dateKey)` 核心逻辑:同日重复忽略;次日 streak++;隔一天 miss 且 graceUsed<1 时续命 streak++ graceUsed++;其他断链 streak=1 graceUsed=0;返回 `{ state, newMilestones }`
365→ - `claimChainMilestone(milestone)` / `getNextMilestone(streak)` / `getChainProgress(streak)`(返回 current/next/prev/progressPct 用于 UI
366→ - `dateKeyToTimestamp` / `dateKeyDiffDays` 工具函数
367→ - `BeaconScoreEntry` 新增可选 `isWeekly?: boolean` 字段
368→- **扩展 `src/store/gameStore.ts`**
369→ - import 扩展:generateWeeklyChallenge / loadWeeklyProgress / loadChainState / addWeeklyProgress / recordChainCompletion / claimWeeklyReward / claimChainMilestone / getTodayKey + 类型
370→ - `trackBeacon(type, delta)` 函数签名从 `boolean` 改为 `{ dailyJustCompleted, weeklyJustCompleted, newChainMilestones }`
371→ - 日挑战类型匹配且未完成 → addBeaconProgress;若刚完成 → recordChainCompletion(getTodayKey()) 收集 newMilestones
372→ - 周挑战类型匹配且未完成 → addWeeklyProgress
373→ - 所有调用点(pulse/clickNode/autoDecodeTick/resolveCurrentNode 等 7 处)原本就忽略返回值,向后兼容
374→ - 新增 action `claimWeeklyBeacon`:调用 claimWeeklyReward → 发放 insights + contact 到 state,返回 `{ rewardInsight, rewardContact, score }`
375→ - 新增 action `claimChainReward(milestone)`:前置校验 currentStreak 与 milestonesClaimed,调用 claimChainMilestone → 发放奖励,返回 `{ rewardInsight, rewardContact, label, ok }`
376→ - GameActions 接口同步新增两个 action 签名
377→- **重写 `src/components/game/BeaconPanel.tsx`301 → 638 行)**
378→ - 顶部头部 + 每日挑战卡片(保留 v0.5 完整功能 + 按钮主题色随难度色淡显)
379→ - **新增周挑战区块**fuchsia 主题):标题"周挑战 · WEEKLY" + weekKey + 倒计时 + 挑战卡片(类型/难度标签/标题/描述/进度条/奖励/领取按钮 emerald 主题)+ weekly-glow 动画
380→ - **新增信标链区块**amber→rose 渐变):标题"信标链 · CHAIN" + 大字号 streakchain-streak-text 渐变流动动画)+ 今日完成状态徽章 + 4 个里程碑节点(w-12 h-12 圆形,claimed=emerald/✓,reachable=rose 脉冲动画+领取按钮,inProgress=amber 半亮,未到达=muted 灰)+ 节点间连线(背景灰 + 已达成部分 amber→rose 渐变 + 辉光,基于 prev→next 插值定位)+ 进度条 + 底部统计(最长/累计/续命状态)
381→ - 桌面端 lg:grid-cols-2 让周挑战+信标链并排,移动端单列
382→ - 排行榜条目区分日/周:周挑战 entry 显示 "WEEK" 徽章 + fuchsia 高亮背景
383→ - 空状态提示扩展为"完成今日或本周信标即可登榜"
384→ - 4 个新动画:weekly-glow / chain-milestone-pulse / chain-streak-flux(背景渐变流动)
385→- **QA 验证**
386→ - `bun run lint` 零错误(每次修改后均验证)
387→ - dev.log 全程无错误,所有编译 < 300ms
388→ - **信标链逻辑测试**(bun 直接运行 TS,5 个场景全 PASS):
389→ 1. 昨日 streak=1 → 今日完成 → streak=2 ✅
390→ 2. 同日重复完成 → 忽略 ✅
391→ 3. 明日完成 → streak=3,无需 grace ✅
392→ 4. 隔一天 misslastCompleted + 2 天)→ streak=4graceUsed=1 ✅
393→ 5. 再次 missgrace 已用)→ 断链 streak=1graceUsed=0 ✅
394→ - **UI 集成测试**agent-browser):
395→ - localStorage 模拟设置 chain statelastCompletedDateKey=yesterday, currentStreak=1)→ reload → UI 正确显示 streak=1
396→ - 设置 streak=3 + milestonesClaimed=[] → milestone 3 节点显示 rose 脉冲动画 + "领取"按钮
397→ - 点击"领取"→ milestonesClaimed=[3] + Toast "✦ 三日谐振 已领取 +50 洞见 · +5.0 接触" + 按钮消失 ✅
398→ - **VLM 视觉评分**(目标 ≥7/10):
399→ - 首屏截图:8/10(四色一致、布局合理、信标链清晰)
400→ - 里程碑可领取状态:8/10(amber→rose 渐变醒目、里程碑层次分明、fuchsia 主题清晰)
401→ - 重置后干净状态:8/10(WEEKLY 字体对比度可优化,但整体可读性强)
402→ - **不破坏现有功能**:日挑战进度/领奖/排行榜流程全部保留并工作正常
403→
404→Stage Summary:
405→- ✅ beacon.ts 扩展完成(358 → 873 行):周挑战 + 信标链全部函数与类型
406→- ✅ gameStore.ts 集成完成:trackBeacon 升级为对象返回值 + claimWeeklyBeacon + claimChainReward 两个新 action
407→- ✅ BeaconPanel.tsx UI 完整重写(301 → 638 行):周挑战 fuchsia 卡片 + 信标链 amber→rose 渐变区块
408→- ✅ 严格四色全息:周挑战 fuchsia、信标链 amber→rose、领取按钮 emerald、无蓝/紫色违规
409→- ✅ localStorage 新增 2 个独立 keyweekly + chain),不污染旧 key
410→- ✅ 确定性:周挑战同周同种子同结果(weekKey FNV-1a 种子)
411→- ✅ 信标链 grace 续命机制工作正常(5 个场景 bun 测试全 PASS)
412→- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10
413→- ✅ 现有日挑战 + 排行榜 + 领奖流程全部保留,无回归
414→
415→---
416→Task ID: 9
417→Agent: main + subagent(9-b)
418→Task: v0.8.1 P1 双任务 — 探险 BOSS 胜率平衡 + 能量恢复 + 信标系统扩展(周挑战 + 信标链)
419→
420→Work Log:
421→- 重读工单确认 P1 两项为下一阶段优先级
422→- P1-a 探险平衡(main 自己做,平衡需要精细控制):
423→ - expedition.ts: combatDifficultyScale 8→4, combatWinRateFloor 0.25→0.35, BOSS difficulty 5-7→3-5
424→ - 新增 computeEnergyRegenInterval(state): exp_2 -30%, exp_3 -20%, 探索力属性 -最高30%, 下限12s
425→ - computeEnergyRegen 接受 intervalSec 参数
426→ - config.ts: exp_2/exp_3 描述加能量恢复说明
427→ - gameStore.ts tick: 传入动态间隔
428→ - ExpeditionPanel.tsx: 显示实际恢复速度 + '已加速'标记
429→- P1-b 信标系统扩展(dispatch subagent 9-b,并行完成):
430→ - beacon.ts 358→873行: 周挑战(getWeekKey ISO 8601 + 确定性生成) + 信标链(4里程碑+grace续命)
431→ - BeaconPanel.tsx 301→638行: 周挑战fuchsia主题 + 信标链amber→rose里程碑节点
432→ - gameStore.ts: trackBeacon升级 + claimWeeklyBeacon/claimChainReward actions
433→- QA 验证:
434→ - lint 零错误
435→ - 探险面板 VLM 7/10(能量条 + 恢复速度显示正常)
436→ - 信标面板 VLM 8/10(周挑战 WEEKLY + 信标链 3/7/14/30 + 排行榜全部可见)
437→ - subagent 5场景 bun 测试全 PASS(信标链断链/续命逻辑)
438→- 提交 v0.8.1commit 71ca5b4+ 推送 main
439→- 静态导出 + 部署 gh-pagescommit 97eecaa)→ HTTP 200
440→
441→Stage Summary:
442→- ✅ P1-a 探险平衡完成: BOSS 基础胜率 25%→35-45%, 配合技术/属性可达 55-70%
443→- ✅ P1-a 能量恢复: 固定45s → 动态12-45s(技术+属性加速)
444→- ✅ P1-b 周挑战: 每周一UTC刷新, fuchsia主题, goal×3-5倍
445→- ✅ P1-b 信标链: 4里程碑(3/7/14/30天) + grace续命, amber→rose渐变
446→- ✅ 在线游玩 v0.8.1: https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
447→- 下一阶段工单: P2 UI样式细节打磨 / P2 云存档+云排行榜 / P3 全纪元叙事
448→
449→---
450→Task ID: v0.14-reimpl
451→Agent: full-stack-developer
452→Task: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章
453→
454→Work Log:
455→- 阅读 worklog.md 了解 v0.8.1 项目状态(10 大系统 + 四色全息规范 + 8 标签页架构)
456→- 类型扩展 `src/lib/game/types.ts`
457→ - 新增 `IdleProjectDef` 接口(id/name/desc/durationSec/reward/minAscensions/minCrystalsPerSec/icon/color/order
458→ - 新增 `IdleProjectSlot` 接口(projectId/startedAt/finishesAt/remainingSec/completed
459→ - GameState 接口新增 4 字段:idleProjectSlots (3 槽位数组) / idleProjectHistory / idlePermanentBonus / idleStats
460→- 新建 `src/lib/game/idle.ts` (~290 行)
461→ - 6 个放置工程定义(idle_scan 60s / idle_refine 180s / idle_archive 600s / idle_anchor 1200s / idle_drones 1800s / idle_resonance 3600s
462→ - 奖励类型:crystals / insights / energy / contact / crystalsPerSecPermanent / randomFragment
463→ - 解锁逻辑:minAscensions 与 minCrystalsPerSec 为 OR 关系(满足任一已设置条件即可)
464→ - 工具函数:getIdleProject / getUnlockedIdleProjects / getLockedIdleProjects / isIdleProjectUnlocked / formatRemaining (12s/3m 45s/1h 12m) / formatReward / deriveMinerFleet (派生 min_1..min_5 采矿无人机,icon: ⛏️🌋🤖💠☀️,无任何采矿技术时返回休眠 min_1)
465→ - 颜色映射:IDLE_COLOR_CLASSES (text/border/bg/bgSoft/glow/ring/dot) + COLOR_HEX (用于 inline style 绕过 Tailwind 动态类限制)
466→- 引擎集成 `src/lib/game/engine.ts`
467→ - recomputeStats() 末尾 `crystalsPerSec += state.idlePermanentBonus ?? 0` (放置永久加成叠加)
468→ - performPrestige() 清空 idleProjectSlots (中断当前周目工程),保留 idlePermanentBonus/idleProjectHistory/idleStats (跨周目永久)recomputeStats 调用传 idlePermanentBonus
469→ - createInitialState() 新增 4 字段默认值
470→- Store 集成 `src/store/gameStore.ts` (核心改造)
471→ - 新增 4 actionstartIdleProject (校验+写入槽位) / cancelIdleProject (置 null) / claimIdleProject (应用奖励 + permBonus 累加 + randomFragment 解锁 + history 追加 + Toast 提示) / tickIdleProjects (每 tick 更新 remainingSec,归零标记 completed)
472→ - **persist migrate 函数**persist 配置增加 `version: 1` + `migrate` 函数自动补全 4 个 idle 字段,旧存档加载不崩溃
473→ - 14 处 syncStats 调用全部更新为传入 `idlePermanentBonus: s.idlePermanentBonus ?? 0`,确保 recomputeStats 计算时纳入永久加成
474→ - init() 中也补全 4 个 idle 字段(defense in depth
475→- 主循环 `src/hooks/useGameLoop.ts`:新增 tickIdleProjects 选择器,setInterval 回调中 autoDecodeTick 后调用,visibilitychange 也调用
476→- 新建 `src/components/game/IdleOperationsPanel.tsx` (~490 行)
477→ - 4 section 在 max-h-[520px] overflow-y-auto 容器中:
478→ 1. 放置收益概览:3 stat tiles (放置产能/永久加成/完成工程) + 离线效率 progress bar + 累计放置晶体统计
479→ 2. 采矿无人机舰队:grid 展示 deriveMinerFleet,每卡 emoji+name+Lv+output/s+状态点 (active=emerald ping 脉冲, dormant=灰)
480→ 3. 放置工程槽位:3 张卡(空槽=虚线占位,运行中=大字号倒计时+自定义进度条+取消按钮,已完成=奖励预览+领取按钮带 glow)
481→ 4. 可派遣工程:2 列 grid 展示已解锁工程(icon+name+duration+desc+reward+3 数字派遣按钮 1/2/3),下方列出未解锁工程及解锁条件
482→ - useShallow 订阅 + 本地 now state 每 1s 刷新倒计时
483→ - 严格 4 色全息,自定义 fuchsia 滚动条
484→- 新建 `src/components/game/IdleStatusBadge.tsx` (~100 行)
485→ - 紧凑徽章 h-8 px-2.5 text-[11px]:脉冲点 (emerald 若 cps>0) + "放置中 +X/s" (或 "休眠中")
486→ - Tooltip 悬停展示分解:基础产能/永久加成/运行中工程/待领取工程数
487→ - data-tut="idle-status-badge" 锚点 + onClick 切换到放置 tab
488→- 新建 `src/components/game/IdleProjectBar.tsx` (~90 行)
489→ - 晶体球下方细长进度条,最多 3 个工程 mini pill `[icon] name 12s ▓▓▓░░`
490→ - 完成时显示 "✓ 待领取" + glow 辉光;无工程返回 null
491→ - 进度条颜色用 inline style (COLOR_HEX) 控制,每 1s 刷新倒计时
492→- 主页面集成 `src/app/page.tsx`
493→ - 引入 3 新组件 + Clock 图标 + getUnlockedIdleProjects
494→ - 新增 activeTab/tabInited state + controlled Tabs (`value={activeTab} onValueChange={setActiveTab}`)
495→ - 挂载后 useEffect 一次性设置默认 tabhasActiveExpedition→"expedition"hasPendingPerk→"constellation",否则→"idle"
496→ - 版本号 v0.8 → v0.14
497→ - Header 在 StarTideIndicator 后加 IdleStatusBadge
498→ - 左侧 CrystalOrb 后加 IdleProjectBar
499→ - TabsList grid-cols-8 → grid-cols-9,新增 value="idle" 的 TabsTrigger 作为第一个 tabClock 图标,emerald 主题,待领取时显示数量红点)
500→ - 新增 TabsContent value="idle" 渲染 IdleOperationsPanel
501→ - 新增 idle 相关 goal 提示(高优先级):待领取 > 0 → "✦ 放置工程已完成 X 项,请前往「放置」标签领取奖励";全空+有解锁+时长>60s → "「放置」标签可派遣工程项目,离线自动产出"
502→ - StatsPanel 新增 3 行:放置永久加成 / 完成放置工程 / 放置产出晶体
503→- 教程更新 `src/lib/game/tutorial.ts`TUTORIAL_STEPS 在 decode 与 tech 之间插入新步骤 (id="idle", target="tab-idle", 标题"③ 放置工程 · 离线产出"),原 tech/expedition/prestige 编号顺延为 ④⑤⑥
504→- 写入 `agent-ctx/v0.14-reimpl-full-stack-developer.md` 工作记录
505→- 最终验证:
506→ - lint 零错误零警告 ✅
507→ - dev 服务器 HTTP 200,编译 < 250ms ✅
508→ - agent-browser 烟雾测试全流程通过(默认 idle tab + 头部徽章 + 4 section + 派遣→倒计时→完成→领取→永久加成应用) ✅
509→
510→Stage Summary:
511→- ✅ v0.14 放置系统 (Idle Operations) 全部完成
512→- ✅ 新增 4 文件(idle.ts 290行 + IdleOperationsPanel.tsx 490行 + IdleStatusBadge.tsx 100行 + IdleProjectBar.tsx 90行),修改 6 文件(types/engine/gameStore/useGameLoop/page/tutorial
513→- ✅ 6 个放置工程:勘探扫描 / 精炼校准 / 碎片整理 / 锚点部署 / 无人机扩编 / 谐振标定
514→- ✅ 3 个槽位并行 + 6 工程解锁逻辑 (OR) + 永久产能加成 (permBonus 累加跨周目)
515→- ✅ 采矿无人机舰队派生 (min_1..min_5) + 休眠状态显示
516→- ✅ 头部 idle 徽章 + 默认放置 tab + 晶体球下方进度条 (5 秒内可见三要素)
517→- ✅ persist migrate 函数 (version 1) 旧存档兼容
518→- ✅ 14 处 syncStats 调用全部传 idlePermanentBonusrecomputeStats 永久加成不丢失
519→- ✅ 飞升时清空活跃槽位但保留永久加成 / 历史 / 统计
520→- ✅ 严格 4 色全息 (emerald/rose/amber/fuchsia),零蓝/靛
521→- ✅ lint 零错误 + HTTP 200 + agent-browser 全流程烟雾测试通过
522→- ✅ 9 标签页 (新增"放置"为第一个) + 巡航按钮 + 教程新增 idle 步骤
523→
524→---
525→Task ID: v0.14-release
526→Agent: 主控 Z.ai Code
527→Task: v0.14 放置系统发布 — README/docs 更新 + 端到端验证 + git commit
528→
529→Work Log:
530→- 发现项目被环境重置回 v0.8.1 状态(v0.14 代码全部丢失,worklog 也被截断到 447 行)
531→- 重新委派 full-stack-developer subagent (v0.14-reimpl) 在 v0.8.1 代码库上重新实现放置系统
532→- subagent 交付 10 个文件(4 新建 + 6 修改),lint 零错误,agent-browser smoke test 通过,git commit 17820b1
533→- 主控独立验证:
534→ * git log 确认 commit 17820b1 存在 ✓
535→ * bun run lint 零错误 ✓
536→ * HTTP 200 ✓
537→ * agent-browser: v0.14 标签 ✓ + 放置中徽章 ✓ + 默认 idle tab ✓ + 4 段面板完整 ✓
538→ * 派遣 idle_scan → slot 0 显示 60s 倒计时 ✓
539→ * 快进完成 → 领取 → insights 79→87 (+8) + projectsCompleted 0→1 ✓ + 槽位清空 ✓
540→- 主控更新 README.md → v0.14:突出 12 大系统、3 层 idle 节奏、灵感来源、健康设计声明
541→- 主控创建 docs/11-放置系统-v0.14.md:完整设计文档
542→- 主控创建每 15 分钟触发的 webDevReview cron job
543→
544→Stage Summary:
545→
546→## v0.14 放置系统已重新上线并 git commit 保固
547→
548→### 核心交付
549→- **3 层 idle 节奏**:被动产能(无人机舰队)+ 放置工程(6 种 × 3 槽位)+ 离线收益(8h cap)
550→- **始终可见的 idle 元素**Header「放置中 +X/s」徽章 + 默认 tab = 放置 + 晶体球下方工程进度 pill
551→- **永久产能加成**:通过工程奖励累积,集成进 recomputeStats,跨周目保留
552→- **存档安全**persist version 1 + migrate 函数 + 4 字段 fallback,老存档零崩溃
553→
554→### Git 保固
555→- commit 17820b1: "v0.14: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章"
556→- 即使环境再次重置,git checkout 17820b1 即可恢复
557→
558→### 验证结果
559→- ✅ bun run lint 零错误零警告
560→- ✅ HTTP 200dev.log 无运行时错误
561→- ✅ agent-browser 端到端 smoke test 全通过(派遣→倒计时→完成→领取→insights+8)
562→- ✅ Sticky footer/header 验证通过
563→
564→### 未解决问题 / v0.15 优先项
565→1. 🟡 永久产能加成可能需要飞升次数 softcap(防 idle_resonance ×N 滚雪球)
566→2. 🟡 idleProjectHistory / crystalsFromIdle 已在 store 累加但 UI 未展示
567→3. 🟡 缺「批量领取全部」按钮 + 工程完成 toast 通知
568→4. 🟡 遗迹图鉴多层文本(Dark Souls 式 loreFragments)—— v0.15 计划
569→5. 🟡 解码阈值释放叙事碎片 + 星潮三频段切换 —— v0.16 计划
570→6. 🟡 健康设计:每日软上限 + "星核建议休眠"提醒 —— v0.17 计划
571→
+571
View File
@@ -0,0 +1,571 @@
1→ 1→# 回响星核 / Echo Nexus — 开发工作日志
2→ 2→
3→ 3→> 持续性任务的工作交接文档。每个阶段更新,避免进度丢失。
4→ 4→
5→ 5→---
6→ 6→
7→ 7→## 一、项目当前状态描述 / 判断
8→ 8→
9→ 9→### 概况
10→ 10→- **项目**:回响星核 / Echo Nexus — 深空考古放置策略游戏
11→ 11→- **一句话**:自治无人机采矿 → 解码记忆晶体(原创共振谜题)→ 拼凑碎片叙事 → 飞升多周目。差异化:解码谜题驱动叙事涌现,全息晶体 + 深空粒子美学。
12→ 12→- **当前版本****v0.7**CrystalOrb Canvas 粒子系统 + 角色属性系统)
13→ 13→- **在线游玩**https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
14→ 14→- **仓库**https://git.atdunbg.xyz/Super_Z/echo-nexus
15→ 15→- **技术栈**Next.js 16 (App Router) + TypeScript + Tailwind + shadcn/ui + Canvas 2D + Zustand + Web Audio API
16→ 16→- **定时任务**:每 15 分钟一次 `webDevReview``fixed_rate` + `"900"` 秒,priority=10job_id 228266)。正常完成不会被删除,无需自持续机制。
17→ 17→
18→ 18→### 状态判断
19→ 19→- dev 服务器运行正常(HTTP 200,编译 < 250ms
20→ 20→- v0.7 两大新功能已完成:CrystalOrb Canvas 粒子系统升级(VLM 8/10+ 角色属性系统(VLM 7/10
21→ 21→- 系统总体稳定,无阻塞性 bug,可继续推进新功能
22→ 22→
23→ 23→### 已完成版本里程碑(精简)
24→ 24→| 版本 | 核心内容 |
25→ 25→|------|---------|
26→ 26→| v0.1 | MVP:放置采矿 + 解码共振谜题 + 技术树 + 记忆图谱 + 飞升 + 统计 |
27→ 27→| v0.1.1 | 解码可解性修复(路径构造法生成器,100% 可解率)|
28→ 28→| v0.2 | 遗迹探险肉鸽系统(能量/探险力/6 节点/程序化路径/BOSS)|
29→ 29→| v0.2.1 | 程序化音频(15 种音效)+ 14 项成就 + 视觉打磨 |
30→ 30→| v0.3 | 星潮事件系统(6 种星潮 + 全屏叠层 + 程序化音效)|
31→ 31→| v0.3.1 | 星图天文台元进程(6 类 × 3 = 18 天赋 + 飞升后 3 选 1 draft + Canvas 星图)|
32→ 32→| v0.4 | 回响编年史(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复)|
33→ 33→| v0.5 | 深空信标(每日挑战 + 本地排行榜 Top20 + 7 标签页 + 编年史历史 BUG 修复)|
34→ 34→| v0.5.1 | 静态部署 Gitea Pages + 新手教程系统(7 步聚光灯引导)+ UI 间距优化 |
35→ 35→| v0.5.2 | 离线收益报告弹窗 + 标签页 UI 重设计 + persist rehydrate 竞态修复 |
36→ 36→| v0.6 | 深空巡航 Canvas 2D 实时玩法 + UI 偏移重叠修复 |
37→ 37→| **v0.7** | **CrystalOrb Canvas 粒子系统升级 + 角色属性系统(四维)** |
38→ 38→
39→ 39→### 核心系统清单(8 大系统)
40→ 40→1. **放置采矿** — 中央晶体球 + 主动脉冲 + 连击加成(`CrystalOrb.tsx`
41→ 41→2. **谐振解码** — 原创路径谜题,路径构造法 100% 可解(`DecodeArray.tsx` + `engine.ts`
42→ 42→3. **技术树** — 三层科技解锁,提升产能/解码/探险(`TechTree.tsx`
43→ 43→4. **遗迹探险** — 肉鸽系统,6 种节点 + 程序化路径 + BOSS`ExpeditionPanel.tsx` + `expedition.ts`
44→ 44→5. **星潮事件** — 6 种动态事件,为放置循环注入变化(`StarTide.tsx` + `starTide.ts`
45→ 45→6. **星图天文台** — 飞升后 3 选 1 天赋 draft,跨周目永久生效,18 个天赋(`ConstellationPanel.tsx` + `constellation.ts`
46→ 46→7. **回响编年史** — 跨周目叙事时间轴,5 纪元循环命名(`ChronicleDialog.tsx` + `chronicle.ts`
47→ 47→8. **深空信标** — 每日挑战 + 本地排行榜 Top20`BeaconPanel.tsx` + `beacon.ts`
48→ 48→9. **深空巡航** — Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关(`CruiseMode.tsx` + `cruise.ts`)【v0.6】
49→ 49→10. **角色属性系统** — 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点,影响所有系统(`AttributesPanel.tsx` + `attributes.ts`)【v0.7 新增】
50→ 50→
51→ 51→---
52→ 52→
53→ 53→## 二、当前目标 / 已完成的修改 / 验证结果
54→ 54→
55→ 55→### v0.7 CrystalOrb Canvas 粒子系统升级(已完成)
56→ 56→**动机**:P0 优先级 — 将中央晶体球从 CSS 动画升级为 Canvas 粒子视觉盛宴,提升视觉震撼。
57→ 57→
58→ 58→**重写文件**`src/components/game/CrystalOrb.tsx`
59→ 59→- 从纯 CSS 动画升级为 Canvas 2D 粒子系统
60→ 60→- **多层粒子**
61→ 61→ - 环绕能量粒子(3层14/10/8个,emerald/fuchsia/rose三色,不同速度方向环绕晶核,带拖尾)
62→ 62→ - 环境星尘(40个,缓慢漂移 + 闪烁)
63→ 63→ - 点击爆发粒子(10-30个径向发散,颜色随连击等级变色)
64→ 64→ - 冲击波环(点击触发1-3层错峰扩散,连击≥3/5解锁更多层)
65→ 65→- **晶核升级**:径向渐变辉光 + 3层六边形纹理旋转 + 高光 + 鼠标视差偏移
66→ 66→- **呼吸动画**:晶核 + 外层辉光随时间呼吸(sin波)
67→ 67→- **进度环**SVG渐变环(emerald→fuchsia→rose)保留
68→ 68→- **交互保留**:点击脉冲、连击Toast、浮动数字、音效、`data-tut="crystal-orb"` 锚点
69→ 69→- **性能**DPR cap 2ResizeObserver 自适应,requestAnimationFrame 60fps
70→ 70→
71→ 71→**QA 验证**agent-browser + VLM):
72→ 72→- VLM 视觉评分 **8/10**:晶体球清晰可见,多层辉光,环绕粒子动态自然,无渲染问题
73→ 73→- 点击交互:冲击波环 ✅ + 爆发粒子 ✅,评分 7/10
74→ 74→- lint 零错误;HTTP 200
75→ 75→
76→ 76→### v0.7 角色属性系统(已完成)
77→ 77→**动机**:P1 优先级 — 增加RPG深度,四维属性影响所有游戏系统。
78→ 78→
79→ 79→**新增文件**
80→ 80→- `src/lib/game/attributes.ts`~330行逻辑层):CharacterAttributes/AttributeProgress 类型、ATTRIBUTE_CONFIG 元信息、getAttributeBonus 两区加成公式(0-50线性+0.5%/点,50-100递减+0.2%/点)、getAllBonuses 12项修饰器、levelUpCheck 自动升级、computePrestigeAttrPoints、migrateAttributes 旧存档兼容
81→ 81→- `src/components/game/AttributesPanel.tsx`~330行 UI):四维卡片网格(小屏2×2/大屏1×4)、图标+中英文名+Lv.badge+数值/100+加成%+经验条+影响列表+分配按钮、顶部待分配点数badge闪烁、底部总览+12项修饰器明细
82→ 82→
83→ 83→**修改文件**
84→ 84→- `types.ts`GameState 新增 attributes/attributeProgress/pendingAttrPoints
85→ 85→- `config.ts`INITIAL_STATE 补全默认值
86→ 86→- `engine.ts`recomputeStats 聚合属性加成;performPrestige 发放属性点(ascensions×2+1
87→ 87→- `gameStore.ts`:新增 allocateAttribute/gainAttributeExp actionspulse/clickNode/autoDecodeTick/resolveCurrentNode/grantCruiseReward/tickTide 全部接入属性经验;init() 调用 migrateAttributes
88→ 88→- `achievements.ts`:新增 ach_attr_total_50 + ach_attr_max_100 两项成就
89→ 89→- `page.tsx`grid-cols-7→8,新增「角色」标签页(User图标),红点提示,StatsPanel新增5行属性行,版本号 v0.6→v0.7
90→ 90→
91→ 91→**四维属性设计**:
92→ 92→- 探索力(emerald):探险力+X%/巡航飞船速度+X%
93→ 93→- 智慧(fuchsia):解码步数+X/洞见+X%/自动解码周期-X%
94→ 94→- 勇气(amber):探险生命+X/BOSS胜率+X%/巡航护盾+X
95→ 95→- 灵感(rose):接触率+X/星潮概率+X/脉冲连击+X%
96→ 96→
97→ 97→**QA 验证**agent-browser + VLM):
98→ 98→- VLM 视觉评分 **7/10**(面板)/ **8/10**(待分配状态):四维卡片可见、数值/等级/加成/经验条齐全、四色全息
99→ 99→- 功能验证:setState pendingAttrPoints=5 → 显示5点待分配 → 点击分配按钮 → exploration 0→1, pending 5→4 ✅
100→ 100→- lint 零错误;HTTP 200
101→ 101→
102→ 102→---
103→ 103→
104→ 104→### v0.6 深空巡航 Canvas 2D 实时玩法(历史记录)
105→ 105→**动机**:回应用户反馈「玩法太单调(全是点点点)」+「视觉震撼不足」——首次引入实时操作玩法。
106→ 106→
107→ 107→**新增文件**
108→ 108→- `src/lib/game/cruise.ts`~520 行逻辑层)
109→ 109→ - 类型系统:CruiseEntity 联合类型(ship/asteroid/storm/crystal/insight/beacon/stargate/particle 共 8 种)
110→ 110→ - mulberry32 + FNV-1a 种子化 RNG`cruiseSeed(level)`
111→ 111→ - `generateLevel`:程序化生成陨石带(8+lvl×2)、虚空风暴(1+lvl/2)、晶体碎片(5+lvl)、洞见光球(2+lvl/3)、信标(1+lvl/4)、星门
112→ 112→ - `updateCruise`:物理推进 + 碰撞检测 + 粒子系统 + 震动 + 600ms 无敌帧
113→ 113→ - `computeRewards`crystals=碎片×(8+lvl×2)、insights=洞见×(2+lvl×0.5)、contact=信标×(1.5+lvl×0.3);通关×1.5、失败×0.5
114→ 114→ - 独立 localStorage `echo-nexus-cruise-v1`:最高分/累计通关/最高关卡/累计奖励/最近 20 局
115→ 115→
116→ 116→- `src/components/game/CruiseMode.tsx`~830 行渲染层)
117→ 117→ - 全屏 fixed inset-0 z-50 CanvasDPR cap 2resize 监听
118→ 118→ - 3 层视差星空:远(100星)/中(60星)/近(30星),独立漂移 + 飞船速度视差 + 闪烁
119→ 119→ - 8 种实体全部 ctx.shadowBlur 辉光绘制
120→ 120→ - 粒子系统:尾焰/收集/碰撞/烟花,上限 200
121→ 121→ - 屏幕震动 280ms 衰减;低护盾(<30%)红色边框脉冲
122→ 122→ - HUDHTML 叠层,glass+backdrop-blur80ms 节流):护盾/能量/分数/用时/收集计数
123→ 123→ - 控制:桌面 WASD/方向键 8 方向 + 移动端虚拟摇杆 + P 暂停 + Esc 退出 + 失焦自动暂停
124→ 124→ - 阶段:ready → playing → won/lost(结算明细 + 奖励 + 下一关/重试/返回)
125→ 125→
126→ 126→**修改文件**
127→ 127→- `src/app/page.tsx`header 新增 amber 主题「巡航」按钮(Navigation 图标)+ cruiseOpen state + 条件渲染 CruiseMode
128→ 128→- `src/store/gameStore.ts`:新增 `grantCruiseReward({crystals?, insights?, contact?})` actioncrystals 受 crystalCap 限制,contact 受 100 上限)
129→ 129→- 版本号 v0.5.2 → v0.6
130→ 130→
131→ 131→**UI 偏移/重叠 BUG 修复**(3 处):
132→ 132→- `DecodeArray.tsx`:移除 className 冲突的 `-translate-x-1/2 -translate-y-1/2`,统一用 inline transform 定位+缩放
133→ 133→- `AchievementsPanel.tsx`:移除内层 `max-h-[300px]`,改 `flex-1 min-h-0` 自适应父容器
134→ 134→- `page.tsx`:调整三个 section 的 min-h,避免小屏单列时总高度超过手机视口
135→ 135→
136→ 136→**色彩规范**:严格遵循 emerald(#34d399) / rose(#fb7185) / amber(#fbbf24) / fuchsia(#e879f9) 四色全息色谱,零蓝色/靛色
137→ 137→
138→ 138→**QA 验证**agent-browser + VLM):
139→ 139→- lint 零错误;dev 服务器 HTTP 200;编译 < 250ms
140→ 140→- VLM 确认主界面:巡航按钮存在、晶体球居中、解码区正常、无重叠偏移
141→ 141→- VLM 确认巡航玩法:飞船可见(绿色三角形)、HUD 完整、陨石+星空背景、粒子特效(黄色尾焰+紫色辉光)
142→ 142→- WASD 控制飞船移动,按 W 直冲星门 → 通关,奖励 +30 晶体(2×10×1.5=30 计算正确)
143→ 143→- localStorage 正确记录 highScore/totalRuns/totalWins/bestLevel
144→ 144→- 奖励同步 gameStoregrantCruiseReward,满仓时 cap 逻辑正确)
145→ 145→- 退出返回主界面,7 标签页 + 晶体球 + 解码面板均完好
146→ 146→
147→ 147→---
148→ 148→
149→ 149→## 三、未解决问题或风险 / 下一阶段优先事项
150→ 150→
151→ 151→### 已知问题 / 风险
152→ 152→1. **dev 服务器 Turbopack 缓存偶发损坏**`Unable to open static sorted file` + 进程被杀,需 `rm -rf .next` 重启。静态导出构建稳定可靠。
153→ 153→2. **定时任务调度类型**:必须用 `fixed_rate` + `"900"` 秒(15分钟),不要用 cron 表达式。`webDevReview` 类型任务正常完成不会被删除,只有抛出未捕获异常才会被标记失败。
154→ 154→3. **v0.3 星潮为单机版**:原计划 socket 全局事件,后续可扩展为多人同步。
155→ 155→4. **v0.5 信标排行榜为本地版**(纯 localStorage):升级云排行榜需后端 API。
156→ 156→5. **v0.5 每日挑战仅 1 个/天**:可加入"周挑战"或"信标链"(连续完成 N 天奖励)。
157→ 157→6. **探险 BOSS 战胜率较低**:基础探索力 10 vs BOSS 难度 5-7,胜率约 25%,需玩家投资探险技术。
158→ 158→7. **探险能量恢复较慢**:45s/点,可加技术提升恢复速度。
159→ 159→8. **编年史上限 50 条**:超过自动丢弃最早的;v0.4 之前飞升无回填(空状态有提示)。
160→ 160→9. **5 纪元叙事为模板化生成**:v0.5+ 将加入手写剧情节点。
161→ 161→10. ~~**CrystalOrb 仍为 CSS 动画**~~ → ✅ **v0.7 已完成 Canvas 粒子系统升级**。
162→ 162→
163→ 163→### 下一阶段优先级(v0.7 后修正版)
164→ 164→> 工单修正:原 P0 CrystalOrb Canvas + P1 角色属性两项均已在 v0.7 完成,现重新排序。
165→ 165→
166→ 166→1. **🔴 P0 — 巡航玩法大增强(v0.8 主线)**:BOSS 战关卡(每 5 关一个 BOSS,三阶段攻击模式 + 飞船射击系统)/ 道具掉落(5 种 powerup:护盾/能量/急速/超载/磁吸)/ 事件选择节点(每关通关后 3 选 1 强化牌,10 种强化)。这是当前最高优先级,能显著提升巡航玩法的深度与可玩性。
167→ 167→2. **🟠 P1 — 探险 BOSS 战胜率平衡 + 探险能量恢复**:修复已知问题 6/7(探险 BOSS 胜率 25% 偏低,能量 45s/点 偏慢),提升探险体验。
168→ 168→3. **🟠 P1 — 信标系统扩展**:周挑战 / 信标链(连续完成 N 天奖励),修复已知问题 5。
169→ 169→4. **🟡 P2 — UI 样式细节打磨**:标签页图标/文字比例、视觉层次强化、移动端响应式进一步优化、CrystalOrb 周边留白优化(VLM 指出主界面左侧有空白)。
170→ 170→5. **🟡 P2 — 云存档 + 云排行榜**:v0.5+ 后端 API(需评估是否引入 mini-service)。
171→ 171→6. **🟢 P3 — 全 5 纪元手写叙事节点**:替换模板化 lore。
172→ 172→7. **🟢 P3 — socket 多人同步星潮**:全局事件共享。
173→ 173→
174→ 174→### 定时任务(fixed_rate · 900s
175→ 175→- **当前 job_id**: **228357**2026-06-24 13:25 重建)
176→ 176→- **配置**: `fixed_rate` + `"900"`15分钟间隔),`priority=10`(高优先级),`payload.kind=webDevReview``tz=Asia/Shanghai`
177→ 177→- **任务名**: Echo Nexus - 任务审查与持续迭代
178→ 178→- **任务描述强制要求**:每次触发 MUST 先完整阅读 worklog.md 工单,以工单为准,不凭记忆工作
179→ 179→- **正常完成不会被删除**,只有未捕获异常会标记失败。若任务列表为空,需用 cron create 重建(schedule.kind=fixed_rate, expr="900", priority=10, payload.kind=webDevReview)。
180→ 180→- **job_id 历史**: 227909(失效) → 228130(失效) → 228172(失效) → 228186(失效) → 228218(删除重建) → 228236(cron版,删除重建) → 228266(fixed_rate,失效) → 228329(失效) → **228357(当前,重建)**
181→ 181→
182→ 182→---
183→ 183→
184→ 184→## 定时任务描述模板(webDevReview · fixed_rate
185→ 185→
186→ 186→> 重建参数:schedule.kind=`fixed_rate`, schedule.expr=`"900"`, params.priority=`10`, payload.kind=`webDevReview`
187→ 187→
188→ 188→```
189→ 189→请先判断当前项目状态,使用 agent-browser 进行必要测试和 QA,然后自主选择本轮最合适的工作重点并继续开发。
190→ 190→
191→ 191→要求:
192→ 192→1. 开始任务时查看 /home/z/my-project/worklog.md 了解实际进展,拆解详细todo
193→ 193→2. 使用 agent-browser 进行测试和QA,有bug优先修复
194→ 194→3. 如果稳定,自主提出新需求继续推进
195→ 195→4. 样式要越做细节越多!!
196→ 196→5. 功能要越做越多!!
197→ 197→6. 工作告一段落更新 /home/z/my-project/worklog.md
198→ 198→7. 项目结束后一定要把工作记录写在 worklog.md 里
199→ 199→
200→ 200→交接文档建议包含三部分:
201→ 201→- 项目当前状态描述/判断
202→ 202→- 当前目标/已完成的修改/验证结果
203→ 203→- 未解决问题或风险,建议下一阶段优先事项
204→ 204→```
205→ 205→
206→ 206→---
207→ 207→
208→ 208→## 历史详细版本记录(按需查阅 docs/repo/docs/
209→ 209→
210→ 210→- `04-解码系统修复-v0.1.1.md`
211→ 211→- `05-遗迹探险系统-v0.2.md`
212→ 212→- `06-音频与成就系统-v0.2.1.md`
213→ 213→- `07-星潮事件系统-v0.3.md`
214→ 214→- `08-星图天文台系统-v0.3.1.md`
215→ 215→- `09-回响编年史系统-v0.4.md`
216→ 216→- `10-深空信标系统-v0.5.md`
217→ 217→
218→ 218→---
219→ 219→
220→ 220→Task ID: 7
221→ 221→Agent: full-stack-developer
222→ 222→Task: 角色属性系统(探索力/智慧/勇气/灵感)
223→ 223→
224→ 224→Work Log:
225→ 225→- 阅读 worklog.md 了解 v0.6 项目背景,确认 8 大系统架构与四色全息色彩规范
226→ 226→- 新建 `src/lib/game/attributes.ts` 逻辑层(~330 行):
227→ 227→ - 定义 CharacterAttributes/AttributeProgress/AttributeKey 类型 + ATTRIBUTE_CONFIG 元信息
228→ 228→ - getAttributeBonus 线性+递减两区加成公式(0-50 每 10 点 +5%50-100 每点 +2%
229→ 229→ - getAllBonuses 返回 12 个修饰器(探险力/巡航速度/解码步数/洞见/自动解码周期/探险生命/BOSS 胜率/巡航护盾/接触率/星潮触发/脉冲连击/产能)
230→ 230→ - levelUpCheck 自动跨多级 + expRequiredForLevel + computePrestigeAttrPoints + migrateAttributes 兼容
231→ 231→- 扩展 `src/lib/game/types.ts` GameState:新增 attributes/attributeProgress/pendingAttrPoints
232→ 232→- 扩展 `src/lib/game/config.ts` INITIAL_STATE:补全默认值(0 数值 + 空 progress
233→ 233→- 扩展 `src/lib/game/engine.ts`
234→ 234→ - recomputeStats 末尾聚合 am.crystalsPerSecMult/insightMultAdd/contactRateMult/decodeStepsBonus
235→ 235→ - performPrestige 保留 attributes 数值,清空 attributeProgress,发放 ascensions × 2 + 1 点属性点
236→ 236→ - createInitialState 每次返回全新对象避免引用共享
237→ 237→- 扩展 `src/store/gameStore.ts`(核心改造,10+ 处 syncStats 调用全部补 attributes 参数):
238→ 238→ - pulse:连击 ≥3 给灵感经验 + 脉冲威力乘灵感连击加成
239→ 239→ - clickNode 完成:给智慧经验(tier × 2)
240→ 240→ - autoDecodeTick:智慧经验 +1 + 自动解码周期受 am.autoDecodeIntervalMult 影响
241→ 241→ - startExpedition:探险力乘 am.expeditionPowerMult,生命加 am.expeditionHpBonus
242→ 242→ - resolveCurrentNodeBOSS 节点包装 RNG 提升 +am.bossWinRateBonus 胜率;战斗胜利给勇气+探索力经验
243→ 243→ - grantCruiseReward:按总奖励量给探索力+勇气经验
244→ 244→ - tickTide:灵感 am.tideTriggerBonus 缩短星潮间隙(上限 30%)
245→ 245→ - init() 调用 migrateAttributes 补全旧存档
246→ 246→ - 新增 allocateAttribute / gainAttributeExp 两个 action
247→ 247→- 新建 `src/components/game/AttributesPanel.tsx` UI 面板(~330 行):
248→ 248→ - 四维卡片网格(小屏 2×2,大屏 1×4)
249→ 249→ - 每张卡片:图标 + 中英文名 + Lv.badge + 数值/100 + 加成% + 经验条 + 影响列表 + 「+分配」按钮
250→ 250→ - 顶部待分配点数 badgependingAttrPoints > 0 时 echo-pending-pulse 闪烁动画)
251→ 251→ - 底部总等级/总加成概览 + 12 个修饰器明细
252→ 252→ - 4 色辉光边框(emerald/fuchsia/amber/rose),顶角光晕装饰
253→ 253→- 接入 `src/app/page.tsx`
254→ 254→ - grid-cols-7 → grid-cols-8,新增第 8 个「角色」TabsTriggerUser 图标,渐变主题)
255→ 255→ - pendingAttrPoints > 0 时显示 rose 红点提示
256→ 256→ - 渲染 AttributesPanel
257→ 257→ - StatsPanel 新增 5 行属性行(探索力/智慧/勇气/灵感/待分配点)
258→ 258→ - 版本号 v0.6 → v0.7
259→ 259→- 新增 `src/lib/game/achievements.ts` 2 项成就:
260→ 260→ - ach_attr_total_50(四维觉醒):总属性 ≥ 50 → 产能+6%/洞见+6%
261→ 261→ - ach_attr_max_100(维度精通):任一属性 100 → 产能+12%/洞见+10%
262→ 262→- 写入 `agent-ctx/7-full-stack-developer.md` 工作记录
263→ 263→- 最终验证:lint 零错误 + dev 服务器 HTTP 200 + 编译 < 250ms
264→ 264→
265→ 265→Stage Summary:
266→ 266→- ✅ 角色属性系统 v0.7 P1 全部完成
267→ 267→- ✅ 新增 2 文件(attributes.ts + AttributesPanel.tsx),修改 6 文件(types/config/engine/gameStore/achievements/page
268→ 268→- ✅ 四维属性完整接入游戏各系统(探险/解码/脉冲/巡航/星潮)
269→ 269→- ✅ 飞升属性点公式 = ascensions × 2 + 1;经验累积自动升级(每级 10×level)
270→ 270→- ✅ 旧存档兼容(migrateAttributes 补全字段,同步 level 与 attributes
271→ 271→- ✅ lint 零错误 + HTTP 200 + 编译 < 250ms
272→ 272→- ✅ 严格四色全息(emerald/fuchsia/amber/rose),零蓝色/靛色
273→ 273→- ✅ 8 标签页 + 巡航按钮全部保留,data-tut 锚点未破坏
274→ 274→- ✅ 待分配点数时标签页 rose 红点提示 + 顶部 badge 闪烁
275→ 275→
276→ 276→---
277→ 277→Task ID: 8-deploy
278→ 278→Agent: main
279→ 279→Task: 重建定时任务 + 更新 README + 部署 gh-pagesv0.7
280→ 280→
281→ 281→Work Log:
282→ 282→- 检查项目状态:CrystalOrb.tsx 已是 v0.7 Canvas 粒子系统(659 行),无活动 cron job
283→ 283→- 重建定时任务:cron create fixed_rate="900" priority=10 webDevReview → job_id 228329tz=Asia/Shanghai
284→ 284→- 重写 README.md
285→ 285→ - 顶部显著位置添加「在线游玩」链接(https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
286→ 286→ - 版本号 v0.1 → v0.7
287→ 287→ - 新增 10 大系统表格(含 v0.6 巡航 + v0.7 角色属性)
288→ 288→ - 完整开发路线表(v0.1-v1.0,标记 ✅/🔜 状态)
289→ 289→ - 新增本地开发命令 + Gitea Pages 部署指南
290→ 290→ - 文档索引扩展至 10 个版本文档
291→ 291→- 静态导出:BUILD_EXPORT=true bun run build:static → out/ 1.6MHTTP 200 编译 4.3s
292→ 292→- 推送 gh-pages 分支:force push origin gh-pagescommit 8727fed "deploy: v0.7"
293→ 293→- 提交 mainREADME 更新(commit 43dfc2d "docs: README 添加在线游玩链接 + v0.7 状态更新"
294→ 294→- 验证 Gitea Pageshttps://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/ → HTTP 200 ✅
295→ 295→- QA 验证:agent-browser 打开 http://localhost:3000/ → VLM 评分 8/10(主界面 polishedminor 空白)
296→ 296→
297→ 297→Stage Summary:
298→ 298→- ✅ 定时任务重建成功(job_id 228329,每 15 分钟自动审查迭代)
299→ 299→- ✅ README 重写完成,网站链接显著展示,v0.7 状态完整
300→ 300→- ✅ gh-pages 部署成功,Gitea Pages HTTP 200 可访问
301→ 301→- ✅ main 分支已同步推送(README + worklog 更新)
302→ 302→- ✅ dev 服务器健康(HTTP 200,编译 < 250ms
303→ 303→- ✅ CrystalOrb v0.7 Canvas 粒子系统已确认完整(659 行)
304→ 304→- 下一步:v0.8 巡航玩法增强(BOSS 战 / 道具掉落 / 事件选择节点)
305→ 305→
306→ 306→---
307→ 307→Task ID: 8
308→ 308→Agent: main
309→ 309→Task: v0.8 巡航玩法大增强(BOSS 战 + 道具掉落 + 事件选择节点)+ 工单修正 + 审查流程重建
310→ 310→
311→ 311→Work Log:
312→ 312→- 重读 worklog 工单,发现 v0.7 两项(CrystalOrb Canvas + 角色属性)已完成但工单"下一阶段优先级"未更新
313→ 313→- 修正 worklog 工单:标注 P0/P1 v0.7 已完成,重新排序下一阶段优先级(P0=巡航增强, P1=探险平衡/信标扩展, P2=UI打磨/云存档, P3=叙事/多人)
314→ 314→- 重建审查流程 cron jobfixed_rate=900s, priority=10, webDevReview, tz=Asia/Shanghai, job_id=228357
315→ 315→ - 任务描述强制要求:每次触发 MUST 先完整阅读 worklog.md 工单,以工单为准,不凭记忆工作
316→ 316→- 发现之前被中断的 v0.8 subagent 已写入 2037 行代码(cruise.ts +1487 / CruiseMode.tsx +858 / page.tsx +2
317→ 317→- 修复 2 个 lint 错误(react-hooks/set-state-in-effect):
318→ 318→ - isTouch: useState(false) + useEffect(setIsTouch) → useState initializer
319→ 319→ - stats: useState(null) + useEffect(setStats(loadCruiseStats)) → useState initializer
320→ 320→- QA 验证(agent-browser + VLM):
321→ 321→ - 主界面 VLM 7/10v0.8 版本号 ✅ + 巡航按钮 ✅
322→ 322→ - 巡航 READY 阶段 VLM 8/10BOSS 战说明 ✅(每5关BOSS战/击败BOSS)+ 射击控制说明 ✅(空格/右下角射击按钮)
323→ 323→ - 巡航第1关游戏 VLM 8/10:飞船(绿色三角)✅ + HUD(护盾/能量/分数/计时)✅ + 陨石/收集物/能量场 ✅ + 无渲染错误
324→ 324→- 提交 v0.8commit 494bc5f+ 推送 main
325→ 325→- 静态导出 BUILD_EXPORT=true → out/ 1.6M(编译 4.1s
326→ 326→- 部署 gh-pagesforce push, commit b865922)→ Gitea Pages HTTP 200 ✅
327→ 327→
328→ 328→Stage Summary:
329→ 329→- ✅ 工单修正完成:v0.7 已完成项标注清楚,下一阶段优先级重新排序
330→ 330→- ✅ 审查流程重建:cron job_id 228357,每15分钟触发,强制读工单
331→ 331→- ✅ v0.8 巡航玩法大增强全部完成并部署:
332→ 332→ - BOSS 战系统(每5关,三阶段攻击,HP条,击败奖励×3)
333→ 333→ - 飞船射击系统(空格/射击按钮,能量消耗,子弹尾焰)
334→ 334→ - 道具掉落(5种powerup:护盾/能量/急速/超载/磁吸)
335→ 335→ - 事件选择节点(每关通关后3选1,10种强化牌)
336→ 336→ - lint 零错误 + dev HTTP 200 + gh-pages HTTP 200
337→ 337→- ✅ VLM 评分:主界面 7/10 + 巡航READY 8/10 + 巡航游戏 8/10
338→ 338→- 在线游玩 v0.8https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
339→ 339→- 下一阶段工单:P1 探险BOSS胜率平衡 + 探险能量恢复 / 信标系统扩展(周挑战/信标链)
340→ 340→
341→ 341→---
342→ 342→Task ID: 9-b
343→ 343→Agent: full-stack-developer
344→ 344→Task: 信标系统扩展(周挑战 + 信标链连续奖励)
345→ 345→
346→ 346→Work Log:
347→ 347→- 阅读现有 `src/lib/game/beacon.ts`v0.5 每日挑战 + 本地排行榜,358 行)+ `src/components/game/BeaconPanel.tsx` + `src/store/gameStore.ts` 的 `trackBeacon` 函数(约第 173 行)+ worklog v0.8 项目状态,确认四色全息规范与现有架构。
348→ 348→- **扩展 `src/lib/game/beacon.ts`358 → 873 行,新增 ~515 行)**
349→ 349→ - **周挑战(WEEKLY CHALLENGE**
350→ 350→ - `BeaconWeeklyChallenge` 接口(weekKey YYYY-Www / type / difficulty 强制 anomaly|singular / goal / rewardInsight / rewardContact / seed / title / desc
351→ 351→ - `BeaconWeeklyProgress` 接口(weekKey / progress / startedAt / completedAt / claimed / durationSec
352→ 352→ - `getWeekKey(now)`ISO 8601 周键(周一为起点,含首个周四的周为第一周)
353→ 353→ - `weekKeyToSeed`FNV-1a 哈希
354→ 354→ - `generateWeeklyChallenge(now)`:基于 weekKey 种子确定性生成,难度加权 anomaly 60% / singular 40%goal 为日基准 ×3-5 倍(decode 18-75 / expedition 3-15 / pulse 60-245 / boss 3-12 / insight 120-595
355→ 355→ - `loadWeeklyProgress` / `saveWeeklyProgress` / `addWeeklyProgress` / `claimWeeklyReward`(推送排行榜,entry 加 `isWeekly: true`
356→ 356→ - `msUntilNextWeek(now)`:距离下周一 UTC 0 点的毫秒数
357→ 357→ - `BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1"`
358→ 358→ - **信标链(BEACON CHAIN**
359→ 359→ - `BeaconChainState` 接口(lastCompletedDateKey / currentStreak / longestStreak / totalCompletions / graceUsed / milestonesClaimed
360→ 360→ - `BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1"`
361→ 361→ - `BEACON_CHAIN_MILESTONES = [3, 7, 14, 30]` 常量
362→ 362→ - `BEACON_CHAIN_REWARDS`4 个里程碑(三日谐振 +50/+5 / 七日回响 +120/+12 / 半月星潮 +280/+28 / 满月飞升 +680/+68
363→ 363→ - `loadChainState` / `saveChainState`(每次返回新对象避免引用共享)
364→ 364→ - `recordChainCompletion(dateKey)` 核心逻辑:同日重复忽略;次日 streak++;隔一天 miss 且 graceUsed<1 时续命 streak++ graceUsed++;其他断链 streak=1 graceUsed=0;返回 `{ state, newMilestones }`
365→ 365→ - `claimChainMilestone(milestone)` / `getNextMilestone(streak)` / `getChainProgress(streak)`(返回 current/next/prev/progressPct 用于 UI
366→ 366→ - `dateKeyToTimestamp` / `dateKeyDiffDays` 工具函数
367→ 367→ - `BeaconScoreEntry` 新增可选 `isWeekly?: boolean` 字段
368→ 368→- **扩展 `src/store/gameStore.ts`**
369→ 369→ - import 扩展:generateWeeklyChallenge / loadWeeklyProgress / loadChainState / addWeeklyProgress / recordChainCompletion / claimWeeklyReward / claimChainMilestone / getTodayKey + 类型
370→ 370→ - `trackBeacon(type, delta)` 函数签名从 `boolean` 改为 `{ dailyJustCompleted, weeklyJustCompleted, newChainMilestones }`
371→ 371→ - 日挑战类型匹配且未完成 → addBeaconProgress;若刚完成 → recordChainCompletion(getTodayKey()) 收集 newMilestones
372→ 372→ - 周挑战类型匹配且未完成 → addWeeklyProgress
373→ 373→ - 所有调用点(pulse/clickNode/autoDecodeTick/resolveCurrentNode 等 7 处)原本就忽略返回值,向后兼容
374→ 374→ - 新增 action `claimWeeklyBeacon`:调用 claimWeeklyReward → 发放 insights + contact 到 state,返回 `{ rewardInsight, rewardContact, score }`
375→ 375→ - 新增 action `claimChainReward(milestone)`:前置校验 currentStreak 与 milestonesClaimed,调用 claimChainMilestone → 发放奖励,返回 `{ rewardInsight, rewardContact, label, ok }`
376→ 376→ - GameActions 接口同步新增两个 action 签名
377→ 377→- **重写 `src/components/game/BeaconPanel.tsx`301 → 638 行)**
378→ 378→ - 顶部头部 + 每日挑战卡片(保留 v0.5 完整功能 + 按钮主题色随难度色淡显)
379→ 379→ - **新增周挑战区块**fuchsia 主题):标题"周挑战 · WEEKLY" + weekKey + 倒计时 + 挑战卡片(类型/难度标签/标题/描述/进度条/奖励/领取按钮 emerald 主题)+ weekly-glow 动画
380→ 380→ - **新增信标链区块**amber→rose 渐变):标题"信标链 · CHAIN" + 大字号 streakchain-streak-text 渐变流动动画)+ 今日完成状态徽章 + 4 个里程碑节点(w-12 h-12 圆形,claimed=emerald/✓,reachable=rose 脉冲动画+领取按钮,inProgress=amber 半亮,未到达=muted 灰)+ 节点间连线(背景灰 + 已达成部分 amber→rose 渐变 + 辉光,基于 prev→next 插值定位)+ 进度条 + 底部统计(最长/累计/续命状态)
381→ 381→ - 桌面端 lg:grid-cols-2 让周挑战+信标链并排,移动端单列
382→ 382→ - 排行榜条目区分日/周:周挑战 entry 显示 "WEEK" 徽章 + fuchsia 高亮背景
383→ 383→ - 空状态提示扩展为"完成今日或本周信标即可登榜"
384→ 384→ - 4 个新动画:weekly-glow / chain-milestone-pulse / chain-streak-flux(背景渐变流动)
385→ 385→- **QA 验证**
386→ 386→ - `bun run lint` 零错误(每次修改后均验证)
387→ 387→ - dev.log 全程无错误,所有编译 < 300ms
388→ 388→ - **信标链逻辑测试**(bun 直接运行 TS,5 个场景全 PASS):
389→ 389→ 1. 昨日 streak=1 → 今日完成 → streak=2 ✅
390→ 390→ 2. 同日重复完成 → 忽略 ✅
391→ 391→ 3. 明日完成 → streak=3,无需 grace ✅
392→ 392→ 4. 隔一天 misslastCompleted + 2 天)→ streak=4graceUsed=1 ✅
393→ 393→ 5. 再次 missgrace 已用)→ 断链 streak=1graceUsed=0 ✅
394→ 394→ - **UI 集成测试**agent-browser):
395→ 395→ - localStorage 模拟设置 chain statelastCompletedDateKey=yesterday, currentStreak=1)→ reload → UI 正确显示 streak=1
396→ 396→ - 设置 streak=3 + milestonesClaimed=[] → milestone 3 节点显示 rose 脉冲动画 + "领取"按钮
397→ 397→ - 点击"领取"→ milestonesClaimed=[3] + Toast "✦ 三日谐振 已领取 +50 洞见 · +5.0 接触" + 按钮消失 ✅
398→ 398→ - **VLM 视觉评分**(目标 ≥7/10):
399→ 399→ - 首屏截图:8/10(四色一致、布局合理、信标链清晰)
400→ 400→ - 里程碑可领取状态:8/10(amber→rose 渐变醒目、里程碑层次分明、fuchsia 主题清晰)
401→ 401→ - 重置后干净状态:8/10(WEEKLY 字体对比度可优化,但整体可读性强)
402→ 402→ - **不破坏现有功能**:日挑战进度/领奖/排行榜流程全部保留并工作正常
403→ 403→
404→ 404→Stage Summary:
405→ 405→- ✅ beacon.ts 扩展完成(358 → 873 行):周挑战 + 信标链全部函数与类型
406→ 406→- ✅ gameStore.ts 集成完成:trackBeacon 升级为对象返回值 + claimWeeklyBeacon + claimChainReward 两个新 action
407→ 407→- ✅ BeaconPanel.tsx UI 完整重写(301 → 638 行):周挑战 fuchsia 卡片 + 信标链 amber→rose 渐变区块
408→ 408→- ✅ 严格四色全息:周挑战 fuchsia、信标链 amber→rose、领取按钮 emerald、无蓝/紫色违规
409→ 409→- ✅ localStorage 新增 2 个独立 keyweekly + chain),不污染旧 key
410→ 410→- ✅ 确定性:周挑战同周同种子同结果(weekKey FNV-1a 种子)
411→ 411→- ✅ 信标链 grace 续命机制工作正常(5 个场景 bun 测试全 PASS)
412→ 412→- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10
413→ 413→- ✅ 现有日挑战 + 排行榜 + 领奖流程全部保留,无回归
414→ 414→
415→ 415→---
416→ 416→Task ID: 9
417→ 417→Agent: main + subagent(9-b)
418→ 418→Task: v0.8.1 P1 双任务 — 探险 BOSS 胜率平衡 + 能量恢复 + 信标系统扩展(周挑战 + 信标链)
419→ 419→
420→ 420→Work Log:
421→ 421→- 重读工单确认 P1 两项为下一阶段优先级
422→ 422→- P1-a 探险平衡(main 自己做,平衡需要精细控制):
423→ 423→ - expedition.ts: combatDifficultyScale 8→4, combatWinRateFloor 0.25→0.35, BOSS difficulty 5-7→3-5
424→ 424→ - 新增 computeEnergyRegenInterval(state): exp_2 -30%, exp_3 -20%, 探索力属性 -最高30%, 下限12s
425→ 425→ - computeEnergyRegen 接受 intervalSec 参数
426→ 426→ - config.ts: exp_2/exp_3 描述加能量恢复说明
427→ 427→ - gameStore.ts tick: 传入动态间隔
428→ 428→ - ExpeditionPanel.tsx: 显示实际恢复速度 + '已加速'标记
429→ 429→- P1-b 信标系统扩展(dispatch subagent 9-b,并行完成):
430→ 430→ - beacon.ts 358→873行: 周挑战(getWeekKey ISO 8601 + 确定性生成) + 信标链(4里程碑+grace续命)
431→ 431→ - BeaconPanel.tsx 301→638行: 周挑战fuchsia主题 + 信标链amber→rose里程碑节点
432→ 432→ - gameStore.ts: trackBeacon升级 + claimWeeklyBeacon/claimChainReward actions
433→ 433→- QA 验证:
434→ 434→ - lint 零错误
435→ 435→ - 探险面板 VLM 7/10(能量条 + 恢复速度显示正常)
436→ 436→ - 信标面板 VLM 8/10(周挑战 WEEKLY + 信标链 3/7/14/30 + 排行榜全部可见)
437→ 437→ - subagent 5场景 bun 测试全 PASS(信标链断链/续命逻辑)
438→ 438→- 提交 v0.8.1commit 71ca5b4+ 推送 main
439→ 439→- 静态导出 + 部署 gh-pagescommit 97eecaa)→ HTTP 200
440→ 440→
441→ 441→Stage Summary:
442→ 442→- ✅ P1-a 探险平衡完成: BOSS 基础胜率 25%→35-45%, 配合技术/属性可达 55-70%
443→ 443→- ✅ P1-a 能量恢复: 固定45s → 动态12-45s(技术+属性加速)
444→ 444→- ✅ P1-b 周挑战: 每周一UTC刷新, fuchsia主题, goal×3-5倍
445→ 445→- ✅ P1-b 信标链: 4里程碑(3/7/14/30天) + grace续命, amber→rose渐变
446→ 446→- ✅ 在线游玩 v0.8.1: https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
447→ 447→- 下一阶段工单: P2 UI样式细节打磨 / P2 云存档+云排行榜 / P3 全纪元叙事
448→ 448→
449→ 449→---
450→ 450→Task ID: v0.14-reimpl
451→ 451→Agent: full-stack-developer
452→ 452→Task: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章
453→ 453→
454→ 454→Work Log:
455→ 455→- 阅读 worklog.md 了解 v0.8.1 项目状态(10 大系统 + 四色全息规范 + 8 标签页架构)
456→ 456→- 类型扩展 `src/lib/game/types.ts`
457→ 457→ - 新增 `IdleProjectDef` 接口(id/name/desc/durationSec/reward/minAscensions/minCrystalsPerSec/icon/color/order
458→ 458→ - 新增 `IdleProjectSlot` 接口(projectId/startedAt/finishesAt/remainingSec/completed
459→ 459→ - GameState 接口新增 4 字段:idleProjectSlots (3 槽位数组) / idleProjectHistory / idlePermanentBonus / idleStats
460→ 460→- 新建 `src/lib/game/idle.ts` (~290 行)
461→ 461→ - 6 个放置工程定义(idle_scan 60s / idle_refine 180s / idle_archive 600s / idle_anchor 1200s / idle_drones 1800s / idle_resonance 3600s
462→ 462→ - 奖励类型:crystals / insights / energy / contact / crystalsPerSecPermanent / randomFragment
463→ 463→ - 解锁逻辑:minAscensions 与 minCrystalsPerSec 为 OR 关系(满足任一已设置条件即可)
464→ 464→ - 工具函数:getIdleProject / getUnlockedIdleProjects / getLockedIdleProjects / isIdleProjectUnlocked / formatRemaining (12s/3m 45s/1h 12m) / formatReward / deriveMinerFleet (派生 min_1..min_5 采矿无人机,icon: ⛏️🌋🤖💠☀️,无任何采矿技术时返回休眠 min_1)
465→ 465→ - 颜色映射:IDLE_COLOR_CLASSES (text/border/bg/bgSoft/glow/ring/dot) + COLOR_HEX (用于 inline style 绕过 Tailwind 动态类限制)
466→ 466→- 引擎集成 `src/lib/game/engine.ts`
467→ 467→ - recomputeStats() 末尾 `crystalsPerSec += state.idlePermanentBonus ?? 0` (放置永久加成叠加)
468→ 468→ - performPrestige() 清空 idleProjectSlots (中断当前周目工程),保留 idlePermanentBonus/idleProjectHistory/idleStats (跨周目永久)recomputeStats 调用传 idlePermanentBonus
469→ 469→ - createInitialState() 新增 4 字段默认值
470→ 470→- Store 集成 `src/store/gameStore.ts` (核心改造)
471→ 471→ - 新增 4 actionstartIdleProject (校验+写入槽位) / cancelIdleProject (置 null) / claimIdleProject (应用奖励 + permBonus 累加 + randomFragment 解锁 + history 追加 + Toast 提示) / tickIdleProjects (每 tick 更新 remainingSec,归零标记 completed)
472→ 472→ - **persist migrate 函数**persist 配置增加 `version: 1` + `migrate` 函数自动补全 4 个 idle 字段,旧存档加载不崩溃
473→ 473→ - 14 处 syncStats 调用全部更新为传入 `idlePermanentBonus: s.idlePermanentBonus ?? 0`,确保 recomputeStats 计算时纳入永久加成
474→ 474→ - init() 中也补全 4 个 idle 字段(defense in depth
475→ 475→- 主循环 `src/hooks/useGameLoop.ts`:新增 tickIdleProjects 选择器,setInterval 回调中 autoDecodeTick 后调用,visibilitychange 也调用
476→ 476→- 新建 `src/components/game/IdleOperationsPanel.tsx` (~490 行)
477→ 477→ - 4 section 在 max-h-[520px] overflow-y-auto 容器中:
478→ 478→ 1. 放置收益概览:3 stat tiles (放置产能/永久加成/完成工程) + 离线效率 progress bar + 累计放置晶体统计
479→ 479→ 2. 采矿无人机舰队:grid 展示 deriveMinerFleet,每卡 emoji+name+Lv+output/s+状态点 (active=emerald ping 脉冲, dormant=灰)
480→ 480→ 3. 放置工程槽位:3 张卡(空槽=虚线占位,运行中=大字号倒计时+自定义进度条+取消按钮,已完成=奖励预览+领取按钮带 glow)
481→ 481→ 4. 可派遣工程:2 列 grid 展示已解锁工程(icon+name+duration+desc+reward+3 数字派遣按钮 1/2/3),下方列出未解锁工程及解锁条件
482→ 482→ - useShallow 订阅 + 本地 now state 每 1s 刷新倒计时
483→ 483→ - 严格 4 色全息,自定义 fuchsia 滚动条
484→ 484→- 新建 `src/components/game/IdleStatusBadge.tsx` (~100 行)
485→ 485→ - 紧凑徽章 h-8 px-2.5 text-[11px]:脉冲点 (emerald 若 cps>0) + "放置中 +X/s" (或 "休眠中")
486→ 486→ - Tooltip 悬停展示分解:基础产能/永久加成/运行中工程/待领取工程数
487→ 487→ - data-tut="idle-status-badge" 锚点 + onClick 切换到放置 tab
488→ 488→- 新建 `src/components/game/IdleProjectBar.tsx` (~90 行)
489→ 489→ - 晶体球下方细长进度条,最多 3 个工程 mini pill `[icon] name 12s ▓▓▓░░`
490→ 490→ - 完成时显示 "✓ 待领取" + glow 辉光;无工程返回 null
491→ 491→ - 进度条颜色用 inline style (COLOR_HEX) 控制,每 1s 刷新倒计时
492→ 492→- 主页面集成 `src/app/page.tsx`
493→ 493→ - 引入 3 新组件 + Clock 图标 + getUnlockedIdleProjects
494→ 494→ - 新增 activeTab/tabInited state + controlled Tabs (`value={activeTab} onValueChange={setActiveTab}`)
495→ 495→ - 挂载后 useEffect 一次性设置默认 tabhasActiveExpedition→"expedition"hasPendingPerk→"constellation",否则→"idle"
496→ 496→ - 版本号 v0.8 → v0.14
497→ 497→ - Header 在 StarTideIndicator 后加 IdleStatusBadge
498→ 498→ - 左侧 CrystalOrb 后加 IdleProjectBar
499→ 499→ - TabsList grid-cols-8 → grid-cols-9,新增 value="idle" 的 TabsTrigger 作为第一个 tabClock 图标,emerald 主题,待领取时显示数量红点)
500→ 500→ - 新增 TabsContent value="idle" 渲染 IdleOperationsPanel
501→ 501→ - 新增 idle 相关 goal 提示(高优先级):待领取 > 0 → "✦ 放置工程已完成 X 项,请前往「放置」标签领取奖励";全空+有解锁+时长>60s → "「放置」标签可派遣工程项目,离线自动产出"
502→ 502→ - StatsPanel 新增 3 行:放置永久加成 / 完成放置工程 / 放置产出晶体
503→ 503→- 教程更新 `src/lib/game/tutorial.ts`TUTORIAL_STEPS 在 decode 与 tech 之间插入新步骤 (id="idle", target="tab-idle", 标题"③ 放置工程 · 离线产出"),原 tech/expedition/prestige 编号顺延为 ④⑤⑥
504→ 504→- 写入 `agent-ctx/v0.14-reimpl-full-stack-developer.md` 工作记录
505→ 505→- 最终验证:
506→ 506→ - lint 零错误零警告 ✅
507→ 507→ - dev 服务器 HTTP 200,编译 < 250ms ✅
508→ 508→ - agent-browser 烟雾测试全流程通过(默认 idle tab + 头部徽章 + 4 section + 派遣→倒计时→完成→领取→永久加成应用) ✅
509→ 509→
510→ 510→Stage Summary:
511→ 511→- ✅ v0.14 放置系统 (Idle Operations) 全部完成
512→ 512→- ✅ 新增 4 文件(idle.ts 290行 + IdleOperationsPanel.tsx 490行 + IdleStatusBadge.tsx 100行 + IdleProjectBar.tsx 90行),修改 6 文件(types/engine/gameStore/useGameLoop/page/tutorial
513→ 513→- ✅ 6 个放置工程:勘探扫描 / 精炼校准 / 碎片整理 / 锚点部署 / 无人机扩编 / 谐振标定
514→ 514→- ✅ 3 个槽位并行 + 6 工程解锁逻辑 (OR) + 永久产能加成 (permBonus 累加跨周目)
515→ 515→- ✅ 采矿无人机舰队派生 (min_1..min_5) + 休眠状态显示
516→ 516→- ✅ 头部 idle 徽章 + 默认放置 tab + 晶体球下方进度条 (5 秒内可见三要素)
517→ 517→- ✅ persist migrate 函数 (version 1) 旧存档兼容
518→ 518→- ✅ 14 处 syncStats 调用全部传 idlePermanentBonusrecomputeStats 永久加成不丢失
519→ 519→- ✅ 飞升时清空活跃槽位但保留永久加成 / 历史 / 统计
520→ 520→- ✅ 严格 4 色全息 (emerald/rose/amber/fuchsia),零蓝/靛
521→ 521→- ✅ lint 零错误 + HTTP 200 + agent-browser 全流程烟雾测试通过
522→ 522→- ✅ 9 标签页 (新增"放置"为第一个) + 巡航按钮 + 教程新增 idle 步骤
523→ 523→
524→ 524→---
525→ 525→Task ID: v0.14-release
526→ 526→Agent: 主控 Z.ai Code
527→ 527→Task: v0.14 放置系统发布 — README/docs 更新 + 端到端验证 + git commit
528→ 528→
529→ 529→Work Log:
530→ 530→- 发现项目被环境重置回 v0.8.1 状态(v0.14 代码全部丢失,worklog 也被截断到 447 行)
531→ 531→- 重新委派 full-stack-developer subagent (v0.14-reimpl) 在 v0.8.1 代码库上重新实现放置系统
532→ 532→- subagent 交付 10 个文件(4 新建 + 6 修改),lint 零错误,agent-browser smoke test 通过,git commit 17820b1
533→ 533→- 主控独立验证:
534→ 534→ * git log 确认 commit 17820b1 存在 ✓
535→ 535→ * bun run lint 零错误 ✓
536→ 536→ * HTTP 200 ✓
537→ 537→ * agent-browser: v0.14 标签 ✓ + 放置中徽章 ✓ + 默认 idle tab ✓ + 4 段面板完整 ✓
538→ 538→ * 派遣 idle_scan → slot 0 显示 60s 倒计时 ✓
539→ 539→ * 快进完成 → 领取 → insights 79→87 (+8) + projectsCompleted 0→1 ✓ + 槽位清空 ✓
540→ 540→- 主控更新 README.md → v0.14:突出 12 大系统、3 层 idle 节奏、灵感来源、健康设计声明
541→ 541→- 主控创建 docs/11-放置系统-v0.14.md:完整设计文档
542→ 542→- 主控创建每 15 分钟触发的 webDevReview cron job
543→ 543→
544→ 544→Stage Summary:
545→ 545→
546→ 546→## v0.14 放置系统已重新上线并 git commit 保固
547→ 547→
548→ 548→### 核心交付
549→ 549→- **3 层 idle 节奏**:被动产能(无人机舰队)+ 放置工程(6 种 × 3 槽位)+ 离线收益(8h cap)
550→ 550→- **始终可见的 idle 元素**Header「放置中 +X/s」徽章 + 默认 tab = 放置 + 晶体球下方工程进度 pill
551→ 551→- **永久产能加成**:通过工程奖励累积,集成进 recomputeStats,跨周目保留
552→ 552→- **存档安全**persist version 1 + migrate 函数 + 4 字段 fallback,老存档零崩溃
553→ 553→
554→ 554→### Git 保固
555→ 555→- commit 17820b1: "v0.14: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章"
556→ 556→- 即使环境再次重置,git checkout 17820b1 即可恢复
557→ 557→
558→ 558→### 验证结果
559→ 559→- ✅ bun run lint 零错误零警告
560→ 560→- ✅ HTTP 200dev.log 无运行时错误
561→ 561→- ✅ agent-browser 端到端 smoke test 全通过(派遣→倒计时→完成→领取→insights+8)
562→ 562→- ✅ Sticky footer/header 验证通过
563→ 563→
564→ 564→### 未解决问题 / v0.15 优先项
565→ 565→1. 🟡 永久产能加成可能需要飞升次数 softcap(防 idle_resonance ×N 滚雪球)
566→ 566→2. 🟡 idleProjectHistory / crystalsFromIdle 已在 store 累加但 UI 未展示
567→ 567→3. 🟡 缺「批量领取全部」按钮 + 工程完成 toast 通知
568→ 568→4. 🟡 遗迹图鉴多层文本(Dark Souls 式 loreFragments)—— v0.15 计划
569→ 569→5. 🟡 解码阈值释放叙事碎片 + 星潮三频段切换 —— v0.16 计划
570→ 570→6. 🟡 健康设计:每日软上限 + "星核建议休眠"提醒 —— v0.17 计划
571→ 571→
+722 -413
View File
File diff suppressed because it is too large Load Diff