Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4831505ef9 | ||
|
|
3fdcd6797f | ||
|
|
43c203ebd2 | ||
|
|
803857298f | ||
|
|
23fb451a0b | ||
|
|
7cfbc8aba0 | ||
|
|
44b94f9325 | ||
|
|
eb9836b8af | ||
|
|
191434e6ad | ||
|
|
6d7c52daa9 |
@@ -1 +1 @@
|
||||
1061
|
||||
12512
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# 回响星核 / Echo Nexus
|
||||
|
||||
> 原创深空考古 **放置策略**游戏 · Original deep-space archaeology **idle strategy** game
|
||||
> 原创深空考古放置策略游戏 · Original deep-space archaeology idle strategy game
|
||||
|
||||
你是一名深空考古指挥官,驾驶自治勘探舰回收已「飞升」的以太文明遗留的记忆晶体,解码碎片拼凑出他们的故事,并最终跨越维度发起「接触」。
|
||||
|
||||
**关掉浏览器,星核仍在为你工作**——采矿无人机舰队 24h 不间断采矿,放置工程在离线时持续推进,回来后一键领取积累的奖励。
|
||||
|
||||
---
|
||||
|
||||
## 🎮 在线游玩
|
||||
@@ -18,7 +16,7 @@
|
||||
|
||||
## 项目状态
|
||||
|
||||
- **版本**:**v0.16** — 解码阈值叙事事件(全屏碎片释放叠层)+ loreLayer 已读标记 + idleHistory 详情展开 + 移动端 375px 适配审计
|
||||
- **版本**:**v0.7** — CrystalOrb Canvas 粒子系统 + 四维角色属性
|
||||
- **栈**: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/)
|
||||
@@ -28,60 +26,29 @@
|
||||
|
||||
## 核心特色
|
||||
|
||||
- **📡 采矿无人机舰队**:每解锁一个采矿分支技术,就有一台自动采矿无人机加入舰队,24h 持续产出晶体(无需点击)
|
||||
- **⚙️ 放置工程项目**:6 种工程,派遣后**离线/在线自动推进**,完成领取奖励(晶体/洞见/能量/永久产能加成/随机碎片)
|
||||
- **🔓 离线收益**:关闭浏览器后产能按 `offlineEff × crystalsPerSec` 持续累积(上限 8h),重新打开弹窗结算「沉睡期间」收益
|
||||
- **🎯 始终可见的放置徽章**:Header 永远显示「放置中 +X/s」徽章,悬停查看产能构成明细——一眼就知道这是个 idle game
|
||||
- **🔍 解码共振谜题**:把放置游戏的「等待」变成有节奏的主动小谜题(路径构造法生成器,100% 可解)
|
||||
- **📜 碎片涌现叙事**:故事从解码中自然浮现,5 纪元循环命名 + 模板化 lore,不打断玩法
|
||||
- **💎 深空全息美学**:晶体 + Canvas 粒子 + 共振波纹的差异化视觉(严格 emerald/rose/amber/fuchsia 四色全息色谱,零蓝色/靛色)
|
||||
- **🔄 飞升多周目**:终局「接触」后保留蓝图开启新周目,星图天文台 18 个天赋跨周目永久生效
|
||||
- **🚀 实时巡航玩法**:Canvas 2D 飞船操控 + 8 种实体 + 3 层视差星空,打破纯放置节奏
|
||||
- **🧬 角色 RPG 深度**:四维属性(探索力/智慧/勇气/灵感)影响所有游戏系统
|
||||
- **解码共振谜题**:把放置游戏的「等待」变成有节奏的主动小谜题(路径构造法生成器,100% 可解)
|
||||
- **碎片涌现叙事**:故事从解码中自然浮现,5 纪元循环命名 + 模板化 lore,不打断玩法
|
||||
- **深空全息美学**:晶体 + Canvas 粒子 + 共振波纹的差异化视觉(严格 emerald/rose/amber/fuchsia 四色全息色谱,零蓝色/靛色)
|
||||
- **飞升多周目**:终局「接触」后保留蓝图开启新周目,星图天文台 18 个天赋跨周目永久生效
|
||||
- **实时巡航玩法**:Canvas 2D 飞船操控 + 8 种实体 + 3 层视差星空,打破纯放置节奏
|
||||
- **角色 RPG 深度**:四维属性(探索力/智慧/勇气/灵感)影响所有游戏系统
|
||||
|
||||
---
|
||||
|
||||
## 游戏系统(11 大系统)
|
||||
## 游戏系统(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 颗晶体」
|
||||
- **回归玩家的"啊我回来了"瞬间**
|
||||
| # | 系统 | 简介 |
|
||||
|---|------|------|
|
||||
| 1 | 放置采矿 | 中央晶体球 Canvas 粒子系统 + 主动脉冲 + 连击加成 |
|
||||
| 2 | 谐振解码 | 原创路径谜题,100% 可解率 |
|
||||
| 3 | 技术树 | 三层科技解锁,提升产能/解码/探险 |
|
||||
| 4 | 遗迹探险 | 肉鸽系统,6 种节点 + 程序化路径 + BOSS |
|
||||
| 5 | 星潮事件 | 6 种动态事件,全屏叠层 + 程序化音效 |
|
||||
| 6 | 星图天文台 | 飞升后 3 选 1 天赋 draft,18 个跨周目天赋 |
|
||||
| 7 | 回响编年史 | 跨周目叙事时间轴,5 纪元循环命名 |
|
||||
| 8 | 深空信标 | 每日挑战 + 本地排行榜 Top20 |
|
||||
| 9 | 深空巡航 | Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关 |
|
||||
| 10 | 角色属性 | 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点 |
|
||||
|
||||
---
|
||||
|
||||
@@ -100,13 +67,9 @@
|
||||
| v0.5.1 | Gitea Pages 静态部署 + 新手教程 | ✅ |
|
||||
| v0.5.2 | 离线收益报告 + 标签页 UI 重设计 | ✅ |
|
||||
| v0.6 | 深空巡航 Canvas 2D 实时玩法 | ✅ |
|
||||
| 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** | **解码阈值叙事事件(5 里程碑全屏叠层)+ loreLayer 已读标记 + idleHistory 详情展开 + 移动端 375px 适配审计** | ✅ |
|
||||
| v0.17 | 星潮三频段切换 + 健康设计(每日软上限 + 星核建议休眠) | 🔜 |
|
||||
| **v0.7** | **CrystalOrb Canvas 粒子系统 + 四维角色属性** | ✅ |
|
||||
| v0.8 | 巡航玩法增强 / BOSS 关卡 / 道具掉落 | 🔜 |
|
||||
| v0.9 | 云存档 + 云排行榜 | 🔜 |
|
||||
| v1.0 | 全 5 纪元手写叙事 + 多人同步星潮 | 🔜 |
|
||||
|
||||
---
|
||||
@@ -123,9 +86,6 @@
|
||||
- [`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 + 批量领取**
|
||||
- [`docs/13-叙事事件-v0.16.md`](docs/13-叙事事件-v0.16.md) — **v0.16 解码阈值里程碑叠层 + loreLayer 已读 + 移动端适配**
|
||||
|
||||
---
|
||||
|
||||
@@ -158,7 +118,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.16"
|
||||
git commit -m "deploy: v0.7"
|
||||
git remote add origin https://git.atdunbg.xyz/Super_Z/echo-nexus.git
|
||||
git push -f origin gh-pages
|
||||
```
|
||||
@@ -167,31 +127,4 @@ Gitea Pages 会自动从 `gh-pages` 分支提供静态网站服务。
|
||||
|
||||
---
|
||||
|
||||
## 灵感来源(Inspired by)
|
||||
|
||||
- **Antimatter Dimensions** — 数值膨胀美学 + 终端 UI
|
||||
- **Universal Paperclips** — 叙事驱动的放置游戏(Frank Lantz)
|
||||
- **A Dark Room** — 极简文字放置游戏(Michael Townsend)
|
||||
- **Melvor Idle** — 离线收益 + 多技能树
|
||||
- **Outer Wilds** — 知识解锁式考古
|
||||
- **Cookie Clicker** — Orteil 的放置游戏范式定义
|
||||
|
||||
---
|
||||
|
||||
## 健康游戏设计声明
|
||||
|
||||
放置游戏天然具有"低投入高回报"的成瘾性,可能对学业/工作压力大的青年造成时间碎片化风险。回响星核主动采取以下健康设计:
|
||||
|
||||
- ✅ **离线收益上限 8h**——防止 24h 挂机
|
||||
- ✅ **工程完成需手动领取**——避免"打开就有奖励"的条件反射
|
||||
- ✅ **无广告、无内购**——纯粹的单机体验
|
||||
- 🔜 **v0.17 计划**:每日软上限 + "星核建议休眠"提醒(每 1 小时主动提示休息)
|
||||
|
||||
参考学术研究:
|
||||
- Frank Lantz GDC 2018 "This Is Your Brain on Games"
|
||||
- Reuters 2025 "Cozy video games can quell stress and anxiety"
|
||||
- DOI:10.3389/fpsyg.2026.1774030 TikTok 时代学术倦怠研究
|
||||
|
||||
---
|
||||
|
||||
© Super_Z · 持续开发中
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# 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)`:确定性生成,难度 routine,goal = 日基准 ×0.3-0.5,5 种类型各有公式
|
||||
- `msUntilNextTimedSlot(now)`:距离下个 4 小时时段的毫秒数
|
||||
- `loadTimedProgress` / `saveTimedProgress` / `addTimedProgress` / `claimTimedReward`(推送排行榜,entry 加 isTimed: true)
|
||||
|
||||
**gameStore.ts 集成**:
|
||||
- import 扩展:generateTimedChallenge / loadTimedProgress / addTimedProgress / claimTimedReward + 类型
|
||||
- `trackBeacon` 返回值新增 `timedJustCompleted`:日/限时/周三类进度同时更新
|
||||
- 新增 `claimTimedBeacon` action(GameActions 接口同步)
|
||||
- 新增 `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-glow(amber 呼吸)/ timed-urgent(rose 急促脉冲)
|
||||
- 排行榜 entry 区分 TIMED(amber 徽章)/ WEEK(fuchsia 徽章)
|
||||
- 空状态提示更新为"完成限时、每日或本周信标即可登榜"
|
||||
|
||||
### 工单 #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 + targetLenBonus,stepLimitBase 同步增加
|
||||
- 路径构造法自动适配更长目标序列(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 触发):
|
||||
- surge(emerald 绿):8/10,横幅 + 全屏绿色微光
|
||||
- prism(fuchsia 紫):7/10,横幅 + 全屏紫色微光
|
||||
- eclipse(rose 粉):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 旧星潮全部保留,无回归
|
||||
@@ -0,0 +1,207 @@
|
||||
# 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 action:grantBeaconReward/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=1(reparent 到 init),进程脱离原 bash session,uptime 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 专属刷新按钮(RefreshCw,loading 时 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 keys(echo-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,用于全球榜高亮)
|
||||
|
||||
## 四色全息规范遵循
|
||||
|
||||
- 本地 tab:amber (#fbbf24)
|
||||
- 全球 tab:emerald (#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 与之互不冲突。
|
||||
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 243 KiB |
@@ -1,89 +0,0 @@
|
||||
---
|
||||
Task ID: 2-b
|
||||
Agent: full-stack-developer
|
||||
Task: v0.16 完善 — loreLayer 已读标记 + idleProjectHistory 详情展开 + 移动端 375px 适配审计
|
||||
|
||||
Work Log:
|
||||
- 必读上下文:worklog v0.15-release 段 + Codex.tsx(v0.15 loreLayers 分层渲染)+ StatsPanel.tsx(v0.15 抽出独立组件)+ types.ts GameState + config.ts FRAGMENTS/loreLayers/isLoreLayerUnlocked + gameStore.ts(actions + persist migrate)+ IdleOperationsPanel.tsx + BeaconPanel.tsx + ExpeditionPanel.tsx
|
||||
- 子任务 1:loreLayer 已读标记
|
||||
- 新建 `src/lib/game/format.ts` 导出 `formatRelativeTime(ts)` 工具函数(<60s "刚刚" / <60m "X 分钟前" / <24h "X 小时前" / <7d "X 天前" / 否则具体日期)
|
||||
- `types.ts`:GameState 接口新增 `readLoreLayers: string[]`(位置:v0.14 idle 字段之后、元字段之前,注释标 v0.16 loreLayer 已读)
|
||||
- `engine.ts`:
|
||||
* `createInitialState` 末尾加 `readLoreLayers: []`(在 idleStats 之后)
|
||||
* `performPrestige` 返回对象加 `readLoreLayers: Array.isArray(state.readLoreLayers) ? state.readLoreLayers : []`(跨周目永久保留,与 idle 永久字段同一区域)
|
||||
- `gameStore.ts`:
|
||||
* GameActions 接口加 `markLoreLayerRead: (fragmentId: string, layer: 1 | 2) => void`(在 tickIdleProjects 之后)
|
||||
* 实现该 action:构造 key `${fragmentId}_L${layer}`,已存在则 no-op,否则 set 新数组
|
||||
* persist migrate 函数末尾加 `readLoreLayers: Array.isArray(s.readLoreLayers) ? s.readLoreLayers : []`(不改 version,仍是 1)
|
||||
- `Codex.tsx`:
|
||||
* useShallow 订阅新增 `readLoreLayers` 与 `markLoreLayerRead`
|
||||
* LoreLayerBlock 接收 `isRead` + `onMarkRead` props:
|
||||
- 已读:层标题旁加 muted "✓ 已读" 标记 + 边框从 amber/fuchsia 高亮降级为 `border-white/10`
|
||||
- 未读但已解锁:保持 v0.15 高亮 + 标题旁加 `animate-pulse` 脉冲小圆点 + "新" 字提示
|
||||
- 文本区域包成 `<button>` 元素,点击调用 `onMarkRead`(自然交互,不另设按钮)
|
||||
- 未读时下方加一行 muted "▸ 点击文本标记已读" 引导文字
|
||||
* 列表卡片标题旁小圆点:未读时 `animate-pulse` + glow,已读时 `bg-*-400/30` 静态
|
||||
* 顶部统计从单行"深层叙事 X/Y 已解锁"扩展为两行(移动端 stack / 桌面并排):深层叙事 X/Y 已解锁 + 已读 A/B(A=已读数,B=已解锁层数)
|
||||
* 弹层 padding 从 `p-5` 改 `p-4 sm:p-5`,标题 `text-lg` 改 `text-base sm:text-lg`,外层 backdrop padding 从 `p-4` 改 `p-3 sm:p-4`(375px 适配)
|
||||
|
||||
- 子任务 2:idleProjectHistory 详情展开
|
||||
- `StatsPanel.tsx`:
|
||||
* useShallow 订阅新增 `idleProjectHistory` 数组与 `idleProjectSlots`(用于详情中潜在"进行中"状态参考)
|
||||
* 删除原"放置工程历史 N 条"的简单行,改为 `col-span-2` 可展开按钮:
|
||||
- 左侧 label + "(最近 5 条可展开)" 提示(仅 historyLen > 0 时显示)
|
||||
- 右侧 "N 条" + ChevronDown/ChevronUp 图标(lucide-react)
|
||||
* 展开后渲染 `col-span-2` 卡片:
|
||||
- 空历史 → "暂无放置工程完成记录 · 前往「放置」标签派遣工程"
|
||||
- 非空 → 倒序最近 5 条:每行 = 工程图标 + 工程名(truncate)+ 相对时间(formatRelativeTime)
|
||||
- 最近一条用 `bg-fuchsia-500/5` 高亮
|
||||
- 历史超 5 条时底部加 "仅展示最近 5 条 · 共 N 条历史" 提示
|
||||
* 展开状态用本地 `useState<boolean>`
|
||||
* useMemo 派生 `recentHistory`(避免每次渲染重切)
|
||||
|
||||
- 子任务 3:移动端 375px 适配审计
|
||||
- 审计范围:Codex / IdleOperationsPanel / StatsPanel / BeaconPanel / ExpeditionPanel
|
||||
- 修复点:
|
||||
* `IdleOperationsPanel.tsx`:所有 `text-[9px]` → `text-[10px]`(11 处,符合"移动端字号最小 10px"规范)
|
||||
* `BeaconPanel.tsx`:所有 `text-[9px]` → `text-[10px]`(7 处);信标链底部统计行 `flex items-center justify-between gap-2` → `flex flex-wrap items-center justify-between gap-x-2 gap-y-1`(防 3 组数据在 375px 溢出)
|
||||
* `Codex.tsx`:弹层 padding + 标题字号响应式(见子任务 1);顶部统计区 `flex flex-col sm:flex-row`(移动端纵向堆叠)
|
||||
* `StatsPanel.tsx`:detail 区 `text-[9px]` → `text-[10px]`(2 处)
|
||||
- 审计通过(无需改):
|
||||
* `ExpeditionPanel.tsx`:节点路径已用 `overflow-x-auto` 自适应窄屏;事件卡 p-3 padding 合理;按钮组 `flex-1` + abort 收起;能量条文字 `text-[10px]` 已达标;路径节点 `h-9 w-9` 36px 足够小且可横向滚动
|
||||
* `BeaconPanel.tsx`:4 个里程碑节点 `w-12 h-12`(48px)× 4 = 192px + flex-1 分布,375px 下可容纳;日/周挑战卡 padding p-3 合理;周挑战+信标链已用 `grid-cols-1 lg:grid-cols-2` 移动单列
|
||||
* `IdleOperationsPanel.tsx`:4 个 section 已用 `grid-cols-1 sm:grid-cols-N` / `grid-cols-2 sm:grid-cols-3` 响应式降级;DispatchCard 槽位按钮 `h-5 w-7` × 3 在单列布局下不溢出
|
||||
* `Codex.tsx` 列表 `grid-cols-2 gap-2`:每卡片约 165px 宽,aspect-[4/3] + p-2 + text-[11px]/text-[9px](已升 text-[10px])在 375px 下可读
|
||||
|
||||
- 严格 4 色全息:emerald / rose / amber / fuchsia,未引入 blue / indigo
|
||||
- useShallow:所有 store 订阅用 useShallow(Codex / StatsPanel 均已用)
|
||||
- 持久化兼容:persist version 仍为 1,新字段 `readLoreLayers` 靠 migrate 兜底,旧存档加载时自动补 `[]`
|
||||
- 与子代理 A 文件边界:types.ts/engine.ts/gameStore.ts 三个共享文件,A 的 `triggeredMilestones` / `pendingMilestone` / `dismissMilestone` 字段已落地,与本子代理的 `readLoreLayers` / `markLoreLayerRead` 完全无冲突(不同字段、不同 action、不同 migrate 行)
|
||||
|
||||
Stage Summary:
|
||||
- ✅ 子任务 1:loreLayer 已读标记完整实现
|
||||
- 类型 + store + UI 三层联动;点击层文本即可标记已读
|
||||
- 视觉区分:已读 = muted "✓ 已读" + 边框降级为 border-white/10;未读 = 高亮 + 脉冲小圆点 + "新" 字
|
||||
- 顶部统计新增"已读 A/B"行(A=已读数 / B=已解锁层数)
|
||||
- 跨周目永久保留(performPrestige 不重置)
|
||||
- ✅ 子任务 2:idleProjectHistory 详情展开完整实现
|
||||
- "放置工程历史" 行可点击展开/收起
|
||||
- 展开后显示最近 5 条倒序历史(图标 + 工程名 + 相对时间)
|
||||
- 空态友好提示,超 5 条底部加"仅展示最近 5 条"说明
|
||||
- ✅ 子任务 3:移动端 375px 适配审计完成
|
||||
- 审 5 个组件,修 4 个(Codex / IdleOps / StatsPanel / Beacon),ExpeditionPanel 无需改
|
||||
- 主要修复:text-[9px] → text-[10px](共 20 处,符合"移动端字号最小 10px"规范)
|
||||
- 信标链底部统计加 flex-wrap 防 3 组数据溢出
|
||||
- Codex 弹层 padding + 标题字号响应式
|
||||
- Codex 顶部统计区移动端纵向堆叠
|
||||
- ✅ 新建文件:`/home/z/my-project/src/lib/game/format.ts`
|
||||
- ✅ 修改文件清单(绝对路径):
|
||||
- `/home/z/my-project/src/lib/game/types.ts`(+readLoreLayers 字段)
|
||||
- `/home/z/my-project/src/lib/game/engine.ts`(createInitialState 默认 + performPrestige 保留)
|
||||
- `/home/z/my-project/src/store/gameStore.ts`(GameActions 签名 + action 实现 + migrate 补全)
|
||||
- `/home/z/my-project/src/components/game/Codex.tsx`(已读标记 + 点击逻辑 + 顶部统计 + 弹层响应式)
|
||||
- `/home/z/my-project/src/components/game/StatsPanel.tsx`(idleHistory 展开 + 相对时间 + 字号升级)
|
||||
- `/home/z/my-project/src/components/game/IdleOperationsPanel.tsx`(字号 9→10px 升级)
|
||||
- `/home/z/my-project/src/components/game/BeaconPanel.tsx`(字号 9→10px 升级 + 信标链底部 flex-wrap)
|
||||
- ✅ bun run lint 零错误零警告
|
||||
- ✅ HTTP 200,dev.log 无运行时错误(compile 11ms / render 33ms)
|
||||
- ✅ 与子代理 A 文件边界完全无冲突(不同字段/action/migrate 行)
|
||||
- ⚠️ 未做 agent-browser 端到端验证(按工单由主控完成)
|
||||
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 223 KiB |
@@ -1,139 +0,0 @@
|
||||
# Task ID: v0.14-reimpl
|
||||
# Agent: full-stack-developer
|
||||
# Task: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章
|
||||
|
||||
## Work Log
|
||||
|
||||
### 1. 类型扩展 (`src/lib/game/types.ts`)
|
||||
- 新增 `IdleProjectDef` 接口(id/name/desc/durationSec/reward/minAscensions/minCrystalsPerSec/icon/color/order)
|
||||
- 新增 `IdleProjectSlot` 接口(projectId/startedAt/finishesAt/remainingSec/completed)
|
||||
- 在 `GameState` 接口 `pendingAttrPoints` 后新增 4 个字段:
|
||||
- `idleProjectSlots: (IdleProjectSlot | null)[]`
|
||||
- `idleProjectHistory: { projectId: string; finishedAt: number }[]`
|
||||
- `idlePermanentBonus: number`
|
||||
- `idleStats: { projectsCompleted: number; crystalsFromIdle: number }`
|
||||
|
||||
### 2. 新建 `src/lib/game/idle.ts` (~290 行)
|
||||
- 定义 6 个放置工程(按 order 排序):
|
||||
| id | name | duration | reward | icon | color | unlock |
|
||||
|----|------|----------|--------|------|-------|--------|
|
||||
| idle_scan | 深空勘探扫描 | 60s | insights +8 | 🛰️ | emerald | cps≥0.5 |
|
||||
| idle_refine | 晶体精炼阵列校准 | 180s | permBonus +0.3/s | ⚙️ | rose | cps≥1 |
|
||||
| idle_archive | 遗迹碎片整理 | 600s | randomFragment + insights +20 | 📜 | amber | asc≥1 OR cps≥5 |
|
||||
| idle_anchor | 维度锚点部署 | 1200s | energy +2 + contact +5 | ⚓ | fuchsia | asc≥1 |
|
||||
| idle_drones | 无人机群扩编 | 1800s | permBonus +2/s + crystals +500 | 🛸 | emerald | cps≥10 |
|
||||
| idle_resonance | 跨维度谐振标定 | 3600s | permBonus +8/s + contact +15 + insights +100 | 🌌 | fuchsia | asc≥2 |
|
||||
- 导出:`IDLE_PROJECTS`, `IDLE_SLOT_COUNT=3`, `getIdleProject`, `getUnlockedIdleProjects`, `getLockedIdleProjects`, `isIdleProjectUnlocked` (OR 逻辑:满足任一已设置条件即可解锁), `formatRemaining` (12s/3m 45s/1h 12m), `formatDuration`, `formatReward`, `deriveMinerFleet` (派生 min_1..min_5 采矿无人机,无任何采矿技术时返回休眠 min_1)
|
||||
- 导出颜色映射 `IDLE_COLOR_CLASSES` (text/border/bg/bgSoft/glow/ring/dot) + `COLOR_HEX` (用于 inline style,绕过 Tailwind 动态类限制)
|
||||
|
||||
### 3. 引擎集成 (`src/lib/game/engine.ts`)
|
||||
- `recomputeStats()` 末尾 `crystalsPerSec += state.idlePermanentBonus ?? 0` (放置永久加成叠加到产能)
|
||||
- `performPrestige()` 在返回对象中:
|
||||
- 清空 `idleProjectSlots: [null, null, null]` (飞升中断当前周目放置工程)
|
||||
- 保留 `idleProjectHistory`, `idlePermanentBonus`, `idleStats` (跨周目永久)
|
||||
- `recomputeStats` 调用传入 `idlePermanentBonus` 以确保飞升后立即应用永久加成
|
||||
- `createInitialState()` 新增 4 字段默认值
|
||||
|
||||
### 4. Store actions (`src/store/gameStore.ts`)
|
||||
- 新增 4 个 action:
|
||||
- `startIdleProject(slotIndex, projectId)`: 校验槽位空 + 项目已解锁 + 未在他处运行;写入 `{ projectId, startedAt, finishesAt, remainingSec, completed:false }`
|
||||
- `cancelIdleProject(slotIndex)`: 置槽位为 null
|
||||
- `claimIdleProject(slotIndex)`: 校验已完成 → 应用奖励(crystals 限 crystalCap,energy 限 energyMax,contact 限 100;permBonus 累加到 idlePermanentBonus + 重算 stats;randomFragment 从未解锁碎片中随机一个)→ 追加 history (slice -50) → idleStats.projectsCompleted++ → Toast 提示
|
||||
- `tickIdleProjects(now)`: 遍历非空非完成槽位,更新 remainingSec,归零时标记 completed
|
||||
- **persist migrate 函数**:persist 配置增加 `version: 1` + `migrate` 函数,自动补全 idleProjectSlots/idleProjectHistory/idlePermanentBonus/idleStats 4 字段,旧存档加载不崩溃
|
||||
- 全部 14 处 syncStats 调用更新为传入 `idlePermanentBonus: s.idlePermanentBonus ?? 0`,确保 recomputeStats 计算时纳入永久加成
|
||||
- `init()` 中也补全 4 个 idle 字段(defense in depth)
|
||||
|
||||
### 5. 主循环 (`src/hooks/useGameLoop.ts`)
|
||||
- 新增 `tickIdleProjects` 选择器
|
||||
- setInterval 回调中 `autoDecodeTick()` 后调用 `tickIdleProjects(now)`
|
||||
- visibilitychange 回调中也调用 `tickIdleProjects(now)`
|
||||
|
||||
### 6. 新建 `src/components/game/IdleOperationsPanel.tsx` (~490 行)
|
||||
- 主「放置」标签内容,4 个 section 在 `max-h-[520px] overflow-y-auto` 容器中:
|
||||
1. **放置收益概览**:3 stat tiles (放置产能 / 永久加成 / 完成工程) + 离线效率 progress bar + 累计放置晶体统计
|
||||
2. **采矿无人机舰队**:grid 展示 deriveMinerFleet(state),每张卡:emoji + name + Lv + output/s + 状态点(active=emerald ping 脉冲,dormant=灰)
|
||||
3. **放置工程槽位**:3 张槽位卡(空槽=虚线占位,运行中=大字号倒计时+自定义进度条+取消按钮,已完成=奖励预览+领取按钮带 glow 辉光)
|
||||
4. **可派遣工程**:2 列 grid 展示已解锁工程(icon+name+duration+desc+reward+3 个数字派遣按钮 1/2/3),下方列出未解锁工程及解锁条件
|
||||
- 使用 `useShallow` 订阅多个 store 字段
|
||||
- 本地 `now` state 每 1s 刷新倒计时
|
||||
- 严格 4 色全息(emerald/rose/amber/fuchsia),无蓝/靛
|
||||
- 自定义滚动条样式 (fuchsia 主题)
|
||||
|
||||
### 7. 新建 `src/components/game/IdleStatusBadge.tsx` (~100 行)
|
||||
- 紧凑徽章:脉冲点 (emerald 若 cps>0,否则灰) + "放置中 +X/s" (或 "休眠中")
|
||||
- Tooltip 悬停展示分解:基础产能 / 永久加成 / 运行中工程 / 待领取工程数
|
||||
- `h-8 px-2.5 text-[11px]`,点击切换到放置标签
|
||||
- `data-tut="idle-status-badge"` 锚点
|
||||
|
||||
### 8. 新建 `src/components/game/IdleProjectBar.tsx` (~90 行)
|
||||
- 晶体球下方的细长进度条
|
||||
- 显示最多 3 个运行/已完成工程为 mini pill:`[icon] name 12s ▓▓▓░░`
|
||||
- 完成时显示 "✓ 待领取" + glow 辉光
|
||||
- 无工程时返回 null
|
||||
- 本地 `now` state 每 1s 刷新倒计时
|
||||
- 进度条颜色用 inline style 控制(COLOR_HEX 映射)
|
||||
|
||||
### 9. 主页面集成 (`src/app/page.tsx`)
|
||||
- 引入 3 个新组件 + `Clock` 图标 + `getUnlockedIdleProjects`
|
||||
- 新增 `idleProjectSlots` / `createdAt` store 订阅
|
||||
- 新增 `activeTab` / `tabInited` state,controlled Tabs:`<Tabs value={activeTab} onValueChange={setActiveTab}>`
|
||||
- 挂载后 useEffect 一次性设置默认 tab:hasActiveExpedition → "expedition",hasPendingPerk → "constellation",否则 → "idle"
|
||||
- 版本号 v0.8 → v0.14
|
||||
- Header 在 StarTideIndicator 后加 IdleStatusBadge
|
||||
- 左侧 CrystalOrb 后加 IdleProjectBar
|
||||
- TabsList grid-cols-8 → grid-cols-9,新增 `value="idle"` 的 TabsTrigger 作为第一个 tab(Clock 图标,emerald 主题,待领取时显示数量红点)
|
||||
- 新增 TabsContent value="idle" 渲染 IdleOperationsPanel
|
||||
- 新增 idle 相关 goal 提示(高优先级):
|
||||
- 待领取 > 0 → "✦ 放置工程已完成 X 项,请前往「放置」标签领取奖励"
|
||||
- 全空 + 有解锁 + 时长 > 60s → "「放置」标签可派遣工程项目,离线自动产出"
|
||||
- StatsPanel 新增 3 行:放置永久加成 / 完成放置工程 / 放置产出晶体
|
||||
|
||||
### 10. 教程更新 (`src/lib/game/tutorial.ts`)
|
||||
- TUTORIAL_STEPS 在 decode 与 tech 之间插入新步骤:
|
||||
- id: "idle", target: "tab-idle", placement: "top"
|
||||
- 标题 "③ 放置工程 · 离线产出"
|
||||
- 介绍放置标签的工程派遣 + 顶部徽章 + 晶体球下方进度条
|
||||
- 原 tech/expedition/prestige 步骤编号顺延为 ④⑤⑥
|
||||
|
||||
## QA 验证
|
||||
|
||||
### Lint
|
||||
- `bun run lint` → 零错误零警告 ✅
|
||||
|
||||
### HTTP
|
||||
- `curl http://localhost:3000/` → 200 OK ✅
|
||||
|
||||
### Dev log
|
||||
- 全程无 runtime error,所有请求 200 OK,编译 < 250ms ✅
|
||||
|
||||
### agent-browser 烟雾测试
|
||||
1. ✅ 版本标签 "v0.14" 可见
|
||||
2. ✅ 默认 tab 是 "放置"(data-state="active" 在第一个 tab)
|
||||
3. ✅ 头部 idle 徽章可见("放置中 +0.4/s" 或 "休眠中")
|
||||
4. ✅ IdleOperationsPanel 4 个 section 全部可见(放置收益概览 / 采矿无人机舰队 / 放置工程槽位 / 可派遣工程 / 未解锁工程)
|
||||
5. ✅ 采矿无人机舰队在无采矿技术时显示休眠 min_1(emoji ⛏️ + 灰色状态点)
|
||||
6. ✅ 设置 insights=50 → buyTech('min_1') → crystalsPerSec 0.4→1.0 → 解锁 idle_scan + idle_refine
|
||||
7. ✅ 点击 idle_scan 派遣按钮 → 槽位 0 写入 { projectId: "idle_scan", finishesAt: now+60000 } → 倒计时正确递减
|
||||
8. ✅ 60s 后自动标记 completed=true → 槽位卡显示 "✓ 完成" + 领取按钮
|
||||
9. ✅ 点击领取 → Toast "✦ 工程奖励已领取 · 深空勘探扫描 · +8 洞见" → insights 8→16 → history 追加 → idleStats.projectsCompleted 0→1
|
||||
10. ✅ 派遣 idle_refine (180s) → 强制完成 → 领取 → crystalsPerSec 1.0→1.3 → idlePermanentBonus 0→0.3 ✅ 永久产能加成正确应用
|
||||
11. ✅ IdleProjectBar 在晶体球下方显示:`🛰️ 深空勘探扫描 55s ▓▓▓░░`
|
||||
12. ✅ Footer goal 文字根据状态动态切换为放置相关提示
|
||||
13. ✅ Footer 产能显示 "产能 1.3/s" (含 idle 永久加成)
|
||||
|
||||
### Migration 测试
|
||||
- 旧存档(无 idle 字段)通过 migrate 函数自动补全为默认值,加载不崩溃 ✅
|
||||
|
||||
## Stage Summary
|
||||
- ✅ 类型层:新增 IdleProjectDef + IdleProjectSlot + 4 GameState 字段
|
||||
- ✅ 逻辑层:新建 idle.ts (~290 行),6 工程定义 + 派生函数 + 颜色映射
|
||||
- ✅ 引擎层:recomputeStats / performPrestige / createInitialState 全部接入 idle 永久加成
|
||||
- ✅ Store 层:4 新 action + persist migrate (version 1) + 14 处 syncStats 调用全部传 idlePermanentBonus
|
||||
- ✅ 主循环:tickIdleProjects 接入 setInterval + visibilitychange
|
||||
- ✅ UI 层:3 新组件 (IdleOperationsPanel ~490 行 / IdleStatusBadge ~100 行 / IdleProjectBar ~90 行)
|
||||
- ✅ 主页面:默认放置 tab + 头部徽章 + 晶体球下方进度条 + 版本 v0.14 + 9 标签页
|
||||
- ✅ 教程:新增 idle 步骤 (③ 放置工程)
|
||||
- ✅ 严格 4 色全息 (emerald/rose/amber/fuchsia),零蓝/靛
|
||||
- ✅ lint 零错误 + HTTP 200 + agent-browser 全流程烟雾测试通过
|
||||
- ✅ 5 秒内可见三要素:头部徽章 + 默认放置 tab + 晶体球下方进度条(派遣后)
|
||||
@@ -63,6 +63,8 @@
|
||||
"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",
|
||||
@@ -600,6 +602,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -674,6 +678,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -716,6 +722,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -776,6 +784,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -824,6 +834,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -898,6 +910,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -994,6 +1008,12 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1452,6 +1472,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1668,6 +1690,14 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1796,6 +1826,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1818,6 +1850,10 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1900,6 +1936,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -1930,6 +1968,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
# 回响星核 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 的本质:**让玩家信任系统会在他不在时为他工作**。
|
||||
@@ -1,214 +0,0 @@
|
||||
# v0.15 — 叙事深化(loreLayers)+ 放置平衡(softcap)+ UX 增强
|
||||
|
||||
> 回响星核 / Echo Nexus · v0.15 设计文档
|
||||
> 对应 worklog `Task ID: v0.15-release`
|
||||
|
||||
---
|
||||
|
||||
## 一、动机
|
||||
|
||||
v0.14 上线了完整的放置系统(6 工程 + 永久加成 + idle 徽章),但留下两个问题:
|
||||
|
||||
1. **平衡性漏洞**:`idle_resonance` 工程每次完成 +8/s 永久加成,玩家可反复派遣导致滚雪球,几小时后产能爆炸。
|
||||
2. **叙事单薄**:8 个记忆碎片只有一层 `echo` 文本,解锁后即"读完",缺乏 Dark Souls / Outer Wilds 式的碎片叙事深度——这是项目的**差异化竞争力**。
|
||||
|
||||
v0.15 用两个并行子代理一次性解决:
|
||||
|
||||
- **2-a(平衡 + UX)**:永久加成 softcap + 批量领取 + 完成 toast + StatsPanel idle 统计
|
||||
- **2-b(叙事深化)**:8 碎片 × 2 层 = 16 层 loreLayers 多层文本 + Codex 分层渲染
|
||||
|
||||
---
|
||||
|
||||
## 二、平衡:永久加成 Softcap
|
||||
|
||||
### 公式
|
||||
|
||||
```ts
|
||||
export function applyIdleSoftcap(bonus: number): number {
|
||||
if (bonus <= 30) return bonus; // 全额
|
||||
if (bonus <= 100) return 30 + (bonus - 30) * 0.5; // 超出部分 ×0.5
|
||||
return 65 + (bonus - 100) * 0.25; // 超出部分 ×0.25
|
||||
}
|
||||
```
|
||||
|
||||
### 临界值
|
||||
|
||||
| 原始 bonus | 有效 bonus | 衰减率 |
|
||||
|-----------|-----------|--------|
|
||||
| 30 | 30 | 0% |
|
||||
| 50 | 40 | 20% |
|
||||
| 100 | 65 | 35% |
|
||||
| 200 | 90 | 55% |
|
||||
| 500 | 152.5 | 69.5% |
|
||||
|
||||
### 设计意图
|
||||
|
||||
- **30 以下不衰减**:新手期 + 偶尔完成 `idle_refine`(+0.3/s)不受惩罚
|
||||
- **30-100 线性半衰**:中段玩家反复完成 `idle_drones`(+2/s)开始感到边际递减
|
||||
- **100 以上严重衰减**:硬核玩家刷 `idle_resonance`(+8/s)收益大幅缩水,但仍非零(保留进度感)
|
||||
- **永不归零**:`applyIdleSoftcap(bonus) → ∞` 时仍趋向无穷,只是增速极慢——避免"刷到上限"的挫败感
|
||||
|
||||
### UI 展示
|
||||
|
||||
`IdleOperationsPanel` Section 1「永久加成」StatTile:
|
||||
- 有效值永远显示(如 `+40/s`)
|
||||
- 当 `有效 < 原始` 时,下方加 amber 副行:`有效 +40/s(原始 +50/s · 衰减中)`
|
||||
|
||||
---
|
||||
|
||||
## 三、叙事深化:loreLayers 多层碎片文本
|
||||
|
||||
### 类型扩展
|
||||
|
||||
```ts
|
||||
export interface FragmentLoreLayer {
|
||||
layer: 1 | 2; // 1=中层,2=深层
|
||||
title: string; // "隐秘注脚" / "深层回响"
|
||||
text: string; // 60-150 字叙事文本
|
||||
unlockHint: string; // "飞升 1 次后揭示"
|
||||
unlock: FragmentLoreUnlock;
|
||||
}
|
||||
|
||||
export interface FragmentLoreUnlock {
|
||||
kind: "ascensions" | "totalDecoded" | "contact" | "fragmentCount" | "bossKills";
|
||||
value: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 5 种解锁条件
|
||||
|
||||
| kind | 含义 | 典型阈值 |
|
||||
|------|------|---------|
|
||||
| `ascensions` | 飞升次数 | 1 / 2 / 3 |
|
||||
| `totalDecoded` | 累计解码晶体数 | 20 / 40 / 60 |
|
||||
| `contact` | 接触进度 | 50 / 80 |
|
||||
| `fragmentCount` | 已解锁碎片数 | 4 / 6 |
|
||||
| `bossKills` | 累计击杀 BOSS | 3 / 8 |
|
||||
|
||||
### 5 条叙事暗线(贯穿 16 层)
|
||||
|
||||
1. **追逐者**:以太族并非自然消亡,而是被虚空中"某种东西"追逐而集体飞升
|
||||
2. **回响同源**:玩家舰载 AI"回响"的频段直接复制自以太族议会档案——玩家其实是以太族的延续/入口
|
||||
3. **信任即引诱**:第一纪元的"信任后来者"实为"引诱后来者替他们完成未竟之事"
|
||||
4. **飞升是单向门**:每次飞升都在消耗什么,不是奖赏
|
||||
5. **回归的不是他们**:接触进度满 80 时回归的,是"借他们形状的某种东西",请玩家别回应
|
||||
|
||||
### 示例(f1_1 第一缕谐振)
|
||||
|
||||
**L0 表层**(默认):
|
||||
> 在被称作「寂灭前夜」的年代,以太族第一次捕捉到来自虚空深处的谐振——那不是声音,而是存在的回声。他们将之记录在第一颗记忆晶体里。
|
||||
|
||||
**L1 隐秘注脚**(飞升 ≥1):
|
||||
> 档案的边缘有一行几乎被擦去的批注:「那并非来自虚空——而是来自我们身后。我们一直不敢回头。」
|
||||
|
||||
**L2 深层回响**(累计解码 ≥40):
|
||||
> 你终于明白:那缕谐振的频率,与你的舰载 AI「回响」的核心频段完全一致。你不是在考古——你是在被召唤。而你身后的东西,一直在等。
|
||||
|
||||
### Codex 分层渲染
|
||||
|
||||
| 层级 | 颜色 | 标题 | 未解锁状态 |
|
||||
|------|------|------|-----------|
|
||||
| L0 表层 | emerald | 表层回响 | 永远显示 |
|
||||
| L1 中层 | amber | 隐秘注脚 | `???` + `blur-sm` 全息乱码 + `🔒 unlockHint` |
|
||||
| L2 深层 | fuchsia | 深层回响 | `???` + `blur-sm` 全息乱码 + `🔒 unlockHint` |
|
||||
|
||||
顶部统计:`深层叙事 X / 16 已解锁`
|
||||
列表卡片:已解锁深层文本的碎片标题旁显示 amber/fuchsia glow 小圆点
|
||||
|
||||
---
|
||||
|
||||
## 四、UX 增强
|
||||
|
||||
### 4.1 批量领取全部
|
||||
|
||||
`claimAllIdleProjects()` action:
|
||||
- 遍历 3 槽位,对所有 `completed === true` 执行奖励逻辑
|
||||
- 单次 `set()` + 单次 `syncStats()`(避免多次 re-render)
|
||||
- 无可领取 → 直接 return(不弹 toast)
|
||||
- 成功 → 汇总 toast `✦ 批量领取完成 · 共领取 N 项工程 · +X 晶体 · +Y 洞见 · ...`
|
||||
|
||||
UI:Section 3 标题右侧,当存在 completed slot 时显示 emerald「✓ 领取全部」按钮(h-5 px-2 text-[10px])
|
||||
|
||||
### 4.2 工程完成 toast
|
||||
|
||||
`tickIdleProjects` 检测状态转变瞬间(旧 `!completed` → 新 `completed`):
|
||||
- 收集 `justCompletedProjects` 数组
|
||||
- 循环外单次 `toast({ title: "✦ 放置工程完成", description: "${icon} ${name} · 可前往「放置」标签领取" })`
|
||||
- 避免每 250ms tick 重复弹窗
|
||||
|
||||
### 4.3 StatsPanel idle 统计
|
||||
|
||||
从 `page.tsx` 内联函数抽出为独立组件 `StatsPanel.tsx`:
|
||||
- `useShallow` 精细订阅 24 字段
|
||||
- 新增 idle 统计行:
|
||||
- 放置永久加成 `+X /s`
|
||||
- 完成放置工程 `N 项`
|
||||
- 放置产出晶体 `formatNum(N)`
|
||||
- 放置工程历史 `N 条`(最近 50 条,跨周目)
|
||||
|
||||
---
|
||||
|
||||
## 五、技术实现
|
||||
|
||||
### 修改文件清单(8 个)
|
||||
|
||||
**子代理 2-a(5 文件):**
|
||||
- `src/lib/game/engine.ts` — 新增 `applyIdleSoftcap` 导出 + `recomputeStats` 调用
|
||||
- `src/store/gameStore.ts` — `GameActions` 加 `claimAllIdleProjects` 签名 + `tickIdleProjects` 改写 + 新增 action
|
||||
- `src/components/game/IdleOperationsPanel.tsx` — softcap 副行 + 领取全部按钮 + StatTile 扩展
|
||||
- `src/components/game/StatsPanel.tsx` — 新建独立组件
|
||||
- `src/app/page.tsx` — 最小集成(删除内联 + import)
|
||||
|
||||
**子代理 2-b(3 文件):**
|
||||
- `src/lib/game/types.ts` — `FragmentLoreLayer` / `FragmentLoreUnlock` 接口 + `Fragment.loreLayers` 字段
|
||||
- `src/lib/game/config.ts` — FRAGMENTS 全部填充 loreLayers + `isLoreLayerUnlocked` 函数
|
||||
- `src/components/game/Codex.tsx` — useShallow + 顶部统计 + 列表小圆点 + 3 层分层渲染弹层
|
||||
|
||||
### 并行零冲突
|
||||
|
||||
两个子代理修改文件严格不重叠:
|
||||
- 2-a 不碰 `types.ts` / `config.ts` / `Codex.tsx`
|
||||
- 2-b 不碰 `engine.ts` / `gameStore.ts` / `IdleOperationsPanel.tsx` / `StatsPanel.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 六、验证
|
||||
|
||||
- ✅ `bun run lint` 零错误零警告
|
||||
- ✅ HTTP 200,dev.log 无运行时错误
|
||||
- ✅ agent-browser 端到端 smoke test:
|
||||
- softcap:permBonus=50 → 有效 40,UI 显示"衰减中" ✓
|
||||
- 批量领取:permBonus 0→0.3, projectsCompleted 1→2 ✓
|
||||
- 完成 toast:tickIdleProjects 触发 ✓
|
||||
- Codex 分层:L0 显示 / L1·L2 模糊 → ascensions=1 解锁 L1 → totalDecoded=50 解锁 L2 ✓
|
||||
- 深层叙事计数 0→2→6 随条件递增 ✓
|
||||
- StatsPanel idle 4 行统计全显示 ✓
|
||||
|
||||
---
|
||||
|
||||
## 七、哲学
|
||||
|
||||
### 碎片叙事的留白
|
||||
|
||||
回响星核的叙事不靠过场动画或长篇对话,而是**让玩家自己拼凑**。每个碎片的多层文本设计遵循:
|
||||
|
||||
1. **表层温和**:第一遍读像抒情散文,不带压迫感
|
||||
2. **中层矛盾**:揭示表层没说的东西,制造认知裂缝
|
||||
3. **深层颠覆**:推翻表层认知,但留有解读空间(不直接说"怪物是谁")
|
||||
|
||||
### 平衡与保留
|
||||
|
||||
softcap 的设计哲学:**衰减但不封顶**。
|
||||
- 衰减:防止滚雪球破坏游戏节奏
|
||||
- 不封顶:尊重硬核玩家的投入,即使边际收益递减,进度感始终存在
|
||||
|
||||
这与飞升系统的"单向门"叙事呼应——每次进步都有代价,但绝非徒劳。
|
||||
|
||||
---
|
||||
|
||||
## 八、下一步(v0.16+)
|
||||
|
||||
- 🟡 loreLayer "已读"标记(需改 gameStore,本轮避开边界冲突)
|
||||
- 🟡 解码阈值释放叙事碎片 + 星潮三频段切换
|
||||
- 🟡 健康设计:每日软上限 + "星核建议休眠"提醒
|
||||
- 🟡 移动端 375px Codex 弹层可读性实测
|
||||
@@ -1,231 +0,0 @@
|
||||
# v0.16 — 解码阈值叙事事件 + loreLayer 已读 + idleHistory 详情 + 移动端适配
|
||||
|
||||
> 回响星核 / Echo Nexus · v0.16 设计文档
|
||||
> 对应 worklog `Task ID: 2-a` + `2-b` + `v0.16-release`
|
||||
|
||||
---
|
||||
|
||||
## 一、动机
|
||||
|
||||
v0.15 上线了 loreLayers 多层碎片文本,但存在三个未完善点:
|
||||
|
||||
1. **解锁瞬间缺乏仪式感** — 碎片静默出现在图谱,没有"叙事涌现"的高光时刻
|
||||
2. **loreLayer 没有已读标记** — 玩家无法区分"新解锁未读"和"已读",缺乏进度感
|
||||
3. **idleProjectHistory 只显示计数** — 无法查看具体完成了哪些工程、何时完成
|
||||
4. **移动端 375px 未实测** — v0.15 的 Codex 分层弹层在小屏可读性未知
|
||||
|
||||
v0.16 用两个并行子代理一次性解决:
|
||||
|
||||
- **2-a**:5 个解码阈值叙事里程碑 + 全屏"碎片释放"叠层
|
||||
- **2-b**:loreLayer 已读标记 + idleHistory 详情展开 + 移动端适配审计
|
||||
|
||||
---
|
||||
|
||||
## 二、解码阈值叙事里程碑(2-a)
|
||||
|
||||
### 设计哲学
|
||||
|
||||
回响星核的核心卖点是**"解码驱动叙事涌现"**。v0.15 的 loreLayers 让每个碎片有 3 层文本,但解锁是静默的。v0.16 在 `totalDecoded` 达到 5/12/25/40/60 时触发**全屏叙事叠层**——不是解锁新碎片,而是对已解锁碎片的**回顾性咏叹**,类似游戏中的"过场回忆"。
|
||||
|
||||
5 个阈值与 FRAGMENTS 的 threshold(1/3/6/10/15/22/32/45)**错开**,是额外的叙事节奏点。
|
||||
|
||||
### 5 个里程碑
|
||||
|
||||
| threshold | id | title | color | 主题 |
|
||||
|---|---|---|---|---|
|
||||
| 5 | ms_5 | 回响 · 初鸣 | emerald | 身份觉醒(你是回响) |
|
||||
| 12 | ms_12 | 代价 · 锚痕渐浅 | rose | 代价感知(解码在消耗锚痕) |
|
||||
| 25 | ms_25 | 信任 · 钩上的光 | amber | 信任的重量("信任"实为引诱) |
|
||||
| 40 | ms_40 | 身后 · 整齐的虚空 | fuchsia | 身后的东西(追逐者逼近) |
|
||||
| 60 | ms_60 | 回归 · 借形之物 | emerald | 回归的预兆(接触满溢借形回归) |
|
||||
|
||||
每段 body 文本 150-250 字,与 v0.15 loreLayers 的 5 条暗线呼应:
|
||||
1. 追逐者(以太族被虚空追逐者逼迫飞升)
|
||||
2. 回响同源(玩家 AI 频段与以太族议会档案同源)
|
||||
3. 信任即引诱("信任后来者"实为"引诱替他们完成未竟之事")
|
||||
4. 飞升单向门(每次飞升都在消耗什么)
|
||||
5. 回归非他们(接触满 80 时回归的是"借形之物")
|
||||
|
||||
### 技术实现
|
||||
|
||||
**新建 `src/lib/game/narrativeMilestones.ts`**:
|
||||
- `NarrativeMilestone` 接口(threshold/id/title/subtitle/body/color)
|
||||
- `NARRATIVE_MILESTONES` 数组(5 个里程碑)
|
||||
- `findMilestoneForTotal(total, triggered)` 纯函数(返回未触发的里程碑)
|
||||
|
||||
**GameState 扩展**:
|
||||
- `triggeredMilestones: string[]` — 已触发的 milestone id 集合(跨周目永久)
|
||||
- `pendingMilestone: NarrativeMilestone | null` — 待展示的里程碑(叠层读取)
|
||||
|
||||
**Store 集成**:
|
||||
- 两个解码完成点(`clickNode` line 596 + `autoDecodeTick` line 711)检测 `findMilestoneForTotal(newTotal, triggered)`
|
||||
- 命中则加入 `triggeredMilestones` + 设置 `pendingMilestone`
|
||||
- `dismissMilestone()` action 清空 `pendingMilestone`
|
||||
- persist migrate 补全两字段(version 仍为 1)
|
||||
- `performPrestige` 保留 `triggeredMilestones`(不重复看过场)+ 清空 `pendingMilestone`
|
||||
|
||||
### 全屏叠层组件
|
||||
|
||||
**新建 `src/components/game/NarrativeMilestoneOverlay.tsx`**:
|
||||
|
||||
- `fixed inset-0 z-50` + `bg-black/80 backdrop-blur-sm`(高于 StarTideOverlay 的 z-0)
|
||||
- **粒子边框**:全屏 4 色主题色 inset box-shadow(120px + 240px 双层)+ `narrative-breathe` 3.5s 呼吸循环
|
||||
- **中央卡片**:max-w-2xl,4 色主题渐变背景 + 主题色 border + 60px glow
|
||||
- **排版层级**:
|
||||
- 副标题 `text-[10px] sm:text-xs font-mono tracking-[0.3em] uppercase`
|
||||
- 大标题 `text-3xl sm:text-4xl md:text-5xl font-serif font-bold` + textShadow glow
|
||||
- 装饰分隔线 `linear-gradient`
|
||||
- 主体文本 `text-sm sm:text-base md:text-lg leading-relaxed whitespace-pre-line`
|
||||
- **入场动画**:`narrative-fade-in` 300ms(opacity 0→1)+ `narrative-scale-in` 300ms cubic-bezier(scale 1.05→1)
|
||||
- **逐字浮现**:30ms/字 setTimeout 推进 + inline-block 光标 `narrative-cursor` 1s steps(2) 闪烁
|
||||
- **交互**:
|
||||
- 未完全显示时:点击 → skip 到全文(底部提示"点击跳过逐字显示")
|
||||
- 完全显示后:点击/Esc → `dismissMilestone()`(底部提示"点击任意处或按 Esc 继续")
|
||||
- **移动端 375px**:卡片 px-4 py-6 / 标题 text-3xl / 正文 text-sm / 光标 h-4
|
||||
- **结构巧思**:外层订阅 + 内层 `key={milestone.id}` 重挂,state lazy init 为 0,规避 React 19 `react-hooks/set-state-in-effect` 规则
|
||||
|
||||
---
|
||||
|
||||
## 三、loreLayer 已读标记(2-b)
|
||||
|
||||
### 类型扩展
|
||||
|
||||
`GameState.readLoreLayers: string[]` — 格式 `["f1_1_L1", "f1_1_L2", ...]`
|
||||
|
||||
### Store
|
||||
|
||||
- `markLoreLayerRead(fragmentId, layer)` action:构造 key `${fragmentId}_L${layer}`,已存在则 no-op
|
||||
- persist migrate 补全 `readLoreLayers: []`
|
||||
- `performPrestige` 保留(跨周目永久,已读不重置)
|
||||
|
||||
### Codex 分层渲染增强
|
||||
|
||||
- **已读层**:标题旁 muted "✓ 已读" + 边框降级为 `border-white/10`
|
||||
- **未读但已解锁层**:保持 v0.15 高亮(amber/fuchsia 边框 + glow)+ 标题旁 `animate-pulse` 脉冲小圆点 + "新" 字 + "▸ 点击文本标记已读" 引导
|
||||
- **触发方式**:点击层文本区域(`<button>` 元素)即调用 `markLoreLayerRead`,无需额外按钮
|
||||
- **顶部统计**:从单行扩展为两行(移动端 stack / 桌面并排):`深层叙事 X/Y 已解锁` + `已读 A/B`
|
||||
- **列表卡片小圆点**:未读 pulse + glow,已读静态降亮
|
||||
|
||||
---
|
||||
|
||||
## 四、idleProjectHistory 详情展开(2-b)
|
||||
|
||||
### StatsPanel 增强
|
||||
|
||||
- "放置工程历史" 行改为 `col-span-2` 可展开按钮:左侧 label + "(最近 N 条可展开)",右侧 "N 条" + ChevronDown/ChevronUp
|
||||
- 展开后渲染倒序最近 5 条:每行 = 工程 icon + 工程名(truncate)+ 相对时间
|
||||
- 最近一条用 `bg-fuchsia-500/5` 高亮
|
||||
- 超 5 条时底部加"仅展示最近 5 条 · 共 N 条历史"
|
||||
- 空态友好文案
|
||||
|
||||
### 新建 `src/lib/game/format.ts`
|
||||
|
||||
`formatRelativeTime(ts: number): string`:
|
||||
- `< 60s` → "刚刚"
|
||||
- `< 60m` → "X 分钟前"
|
||||
- `< 24h` → "X 小时前"
|
||||
- `< 7d` → "X 天前"
|
||||
- 否则 → 具体日期
|
||||
|
||||
独立文件便于后续复用(编年史时间轴、探险日志等)。
|
||||
|
||||
---
|
||||
|
||||
## 五、移动端 375px 适配审计(2-b)
|
||||
|
||||
### 审计结果
|
||||
|
||||
审了 5 个组件,修了 4 个:
|
||||
|
||||
| 组件 | 问题 | 修复 |
|
||||
|------|------|------|
|
||||
| `IdleOperationsPanel.tsx` | 11 处 `text-[9px]` 低于最小字号 | `text-[9px]` → `text-[10px]` |
|
||||
| `BeaconPanel.tsx` | 7 处 `text-[9px]` + 信标链底部统计行 375px 溢出 | 字号升级 + `flex-wrap + gap-y-1` |
|
||||
| `Codex.tsx` | 弹层 padding + 标题 + 顶部统计在小屏拥挤 | `p-5` → `p-4 sm:p-5` / 标题 `text-lg` → `text-base sm:text-lg` / 顶部统计 `flex flex-col sm:flex-row` |
|
||||
| `StatsPanel.tsx` | 2 处 `text-[9px]` | `text-[10px]` |
|
||||
|
||||
**无需改**:
|
||||
- `ExpeditionPanel.tsx` — 节点路径已用 `overflow-x-auto` 自适应,按钮 `flex-1`,路径节点 36px 足够小
|
||||
- BeaconPanel 4 里程碑节点 `w-12 h-12` × 4 = 192px 在 375px 可容纳
|
||||
- IdleOperationsPanel 4 section 已用 `grid-cols-1 sm:grid-cols-N` 响应式降级
|
||||
|
||||
### 移动端字号规范
|
||||
|
||||
- 最小 `text-[10px]`(9px 在高 DPI 屏可读性差)
|
||||
- 标题最大 `text-2xl`(移动端)/ `text-3xl`(桌面)
|
||||
- 优先用 `sm:` `md:` 响应式前缀,不删桌面端样式
|
||||
|
||||
---
|
||||
|
||||
## 六、技术实现
|
||||
|
||||
### 修改文件清单(11 个)
|
||||
|
||||
**子代理 2-a(6 文件):**
|
||||
- 新建 `src/lib/game/narrativeMilestones.ts`
|
||||
- 新建 `src/components/game/NarrativeMilestoneOverlay.tsx`
|
||||
- 修改 `src/lib/game/types.ts`(加 triggeredMilestones + pendingMilestone)
|
||||
- 修改 `src/lib/game/engine.ts`(createInitialState + performPrestige)
|
||||
- 修改 `src/store/gameStore.ts`(2 解码点检测 + dismissMilestone + migrate)
|
||||
- 修改 `src/app/page.tsx`(挂载叠层 + 版本号)
|
||||
|
||||
**子代理 2-b(8 文件,2 个与 2-a 共享):**
|
||||
- 新建 `src/lib/game/format.ts`
|
||||
- 修改 `src/lib/game/types.ts`(加 readLoreLayers,与 2-a 共存)
|
||||
- 修改 `src/lib/game/engine.ts`(createInitialState + performPrestige,与 2-a 共存)
|
||||
- 修改 `src/store/gameStore.ts`(markLoreLayerRead + migrate,与 2-a 共存)
|
||||
- 修改 `src/components/game/Codex.tsx`(已读标记 + 点击逻辑 + 顶部统计)
|
||||
- 修改 `src/components/game/StatsPanel.tsx`(idleHistory 展开 + 字号)
|
||||
- 修改 `src/components/game/IdleOperationsPanel.tsx`(字号 9→10px)
|
||||
- 修改 `src/components/game/BeaconPanel.tsx`(字号 + flex-wrap)
|
||||
|
||||
### 并行零冲突
|
||||
|
||||
两个子代理在 3 个共享文件(types.ts / engine.ts / gameStore.ts)中:
|
||||
- 字段名不冲突(`triggeredMilestones`/`pendingMilestone` vs `readLoreLayers`)
|
||||
- action 不冲突(`dismissMilestone` vs `markLoreLayerRead`)
|
||||
- migrate 补全各行独立
|
||||
- 2-b 不动解码逻辑(line 575-720),那是 2-a 的领地
|
||||
|
||||
---
|
||||
|
||||
## 七、验证
|
||||
|
||||
- ✅ `bun run lint` 零错误零警告
|
||||
- ✅ HTTP 200,dev.log 无运行时错误
|
||||
- ✅ agent-browser 端到端 smoke test:
|
||||
- 里程碑叠层:setState pendingMilestone=ms_5 → 全屏 dialog "回响 · 初鸣" + "点击任意处或按 Esc 继续" → 点击 → pending 清空 + triggered 保留 ✓
|
||||
- loreLayer 已读:解锁 f1_1 + ascensions=1 → L1 "隐秘注脚" 显示 "新" + "标记为已读" 按钮 → 点击 → readLoreLayers=["f1_1_L1"] + L1 显示 "已读" + 顶部统计更新 ✓
|
||||
- idleHistory 展开:设置 3 条历史 → 统计 tab "放置工程历史(最近 3 条可展开)" → 点击展开 → 倒序显示 3 行(icon + 工程名 + 相对时间)✓
|
||||
|
||||
---
|
||||
|
||||
## 八、哲学
|
||||
|
||||
### 仪式感与节奏
|
||||
|
||||
v0.16 的里程碑叠层不是"奖励",而是**节奏标记**。放置游戏容易陷入"数字增长麻木",5 个阈值(5/12/25/40/60)在玩家的解码旅程中设置了 5 个"停下来看一看"的节点:
|
||||
|
||||
- **ms_5**:新手期结束,确认身份
|
||||
- **ms_12**:开始感知代价
|
||||
- **ms_25**:中期转折,信任的重量
|
||||
- **ms_40**:后期紧迫,追逐者逼近
|
||||
- **ms_60**:终局预兆,回归的借形之物
|
||||
|
||||
每个里程碑的 body 文本都是**回顾性咏叹**——不是新剧情,而是对已解锁碎片的重新诠释。这让玩家在数字增长中保持对叙事的敏感。
|
||||
|
||||
### 已读标记的心理学
|
||||
|
||||
loreLayer 已读标记解决了一个细微的 UX 问题:玩家解锁深层文本后,如果不标记"已读",每次打开图谱都被高亮提示淹没,反而会**忽略真正的新内容**。已读标记让"新"保持稀缺,让玩家的注意力聚焦在真正未读的层上。
|
||||
|
||||
这与健康游戏设计呼应——**进度感不等于信息过载**。标记已读是一种"我处理过了"的仪式,让玩家心安。
|
||||
|
||||
---
|
||||
|
||||
## 九、下一步(v0.17+)
|
||||
|
||||
- 🟡 星潮三频段切换(星潮系统增强)
|
||||
- 🟡 健康设计:每日软上限 + "星核建议休眠"提醒
|
||||
- 🟡 loreLayer 已读标记可扩展到碎片本身(fragment 级别的已读)
|
||||
- 🟡 里程碑叠层的音效(程序化生成,与星潮音效区分)
|
||||
- 🟡 format.ts 复用到编年史时间轴 + 探险日志
|
||||
@@ -44,7 +44,7 @@ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
|
||||
"no-useless-escape": "off",
|
||||
},
|
||||
}, {
|
||||
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills"]
|
||||
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills", "mini-services/**"]
|
||||
}];
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# 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-1,1 = 完成
|
||||
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 项目解耦:
|
||||
|
||||
- 主项目端口 3000(Next.js dev)
|
||||
- 本服务端口 3030(Hono + Bun)
|
||||
- Caddy 网关端口 81(按 `?XTransformPort=3030` 转发到本服务)
|
||||
|
||||
服务重启会清空数据,符合 P2 阶段规格(不需要持久化)。
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// 回响星核 / Echo Nexus — 云排行榜 mini-service
|
||||
// P2/#4:把信标本机排行榜升级为云排行榜。
|
||||
// 端口固定 3030;Caddy 通过 ?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;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# 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` 暂停触发(避免冲突)
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"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=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// 回响星核 / Echo Nexus — 全球星潮同步 mini-service
|
||||
// P3/工单 #10-c:socket.io 多人同步星潮
|
||||
// 端口固定 3031;Caddy 通过 ?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"));
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,8 @@
|
||||
"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",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
--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);
|
||||
@@ -144,6 +145,17 @@
|
||||
-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; }
|
||||
@@ -165,3 +177,35 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Geist, Geist_Mono, Orbitron } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
@@ -13,6 +13,14 @@ 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: "原创深空考古放置策略游戏 — 解码记忆晶体,拼凑以太文明的回响,跨越维度发起接触。",
|
||||
@@ -42,7 +50,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning className="dark">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${orbitron.variable} antialiased bg-background text-foreground`}
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 游戏主入口
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { StarfieldCanvas } from "@/components/game/StarfieldCanvas";
|
||||
import { ResourceBar } from "@/components/game/ResourceBar";
|
||||
import { CrystalOrb } from "@/components/game/CrystalOrb";
|
||||
@@ -13,18 +13,18 @@ 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";
|
||||
import { OfflineReportDialog } from "@/components/game/OfflineReportDialog";
|
||||
import { CruiseMode } from "@/components/game/CruiseMode";
|
||||
// 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 { AttributesPanel } from "@/components/game/AttributesPanel";
|
||||
import { IdleOperationsPanel } from "@/components/game/IdleOperationsPanel";
|
||||
import { IdleStatusBadge } from "@/components/game/IdleStatusBadge";
|
||||
import { IdleProjectBar } from "@/components/game/IdleProjectBar";
|
||||
import { StatsPanel } from "@/components/game/StatsPanel";
|
||||
import { NarrativeMilestoneOverlay } from "@/components/game/NarrativeMilestoneOverlay";
|
||||
import { ExplorationRadar } from "@/components/game/ExplorationRadar";
|
||||
import { RelicCodex } from "@/components/game/RelicCodex";
|
||||
import { ArchaeoLogTicker } from "@/components/game/ArchaeoLogTicker";
|
||||
import {
|
||||
StarTideNotifier,
|
||||
StarTideIndicator,
|
||||
@@ -32,7 +32,9 @@ 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 {
|
||||
@@ -49,17 +51,22 @@ import {
|
||||
Radio,
|
||||
Navigation,
|
||||
User,
|
||||
Clock,
|
||||
Gem,
|
||||
Zap,
|
||||
Flame,
|
||||
Database,
|
||||
BookMarked,
|
||||
} from "lucide-react";
|
||||
import { FRAGMENTS, formatNum } from "@/lib/game/config";
|
||||
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
|
||||
import { ACHIEVEMENTS } from "@/lib/game/achievements";
|
||||
import { TIDE_EVENTS } from "@/lib/game/starTide";
|
||||
import { generateDailyChallenge, loadDailyProgress } from "@/lib/game/beacon";
|
||||
import { getUnlockedIdleProjects } from "@/lib/game/idle";
|
||||
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
|
||||
import { generateDailyChallenge, loadDailyProgress, loadLeaderboard } from "@/lib/game/beacon";
|
||||
|
||||
export default function Page() {
|
||||
useGameLoop();
|
||||
useAudioSync();
|
||||
useGlobalTide();
|
||||
const [prestigeOpen, setPrestigeOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [constellationOpen, setConstellationOpen] = useState(false);
|
||||
@@ -79,18 +86,11 @@ 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,26 +117,10 @@ 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 (
|
||||
@@ -155,19 +139,6 @@ 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) {
|
||||
@@ -191,19 +162,10 @@ export default function Page() {
|
||||
}
|
||||
if (activeTide) {
|
||||
const tm = TIDE_EVENTS[activeTide.type];
|
||||
goal = `${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
|
||||
const prefix = globalTide ? "🌐 全球星潮 · " : "";
|
||||
goal = `${prefix}${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">
|
||||
@@ -223,15 +185,14 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<h1 className="text-base sm:text-lg font-bold text-gradient">回响星核</h1>
|
||||
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider">
|
||||
ECHO NEXUS · v0.16
|
||||
<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.10
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<StarTideIndicator />
|
||||
<IdleStatusBadge onClick={() => setActiveTab("idle")} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -295,40 +256,52 @@ export default function Page() {
|
||||
<ResourceBar onPrestige={() => setPrestigeOpen(true)} />
|
||||
</header>
|
||||
|
||||
{/* 主体 — 小屏允许自然滚动,避免 min-h 总和超过视口导致挤压重叠 */}
|
||||
{/* 主体 — v0.10 工单 #2 布局重构:反转左右比例,左栏紧凑晶体球,右栏扩展核心玩法 */}
|
||||
<main className="flex-1 px-3 sm:px-5 pb-3 min-h-0">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_minmax(360px,420px)] gap-3 h-full">
|
||||
{/* 左侧:脉冲晶体 */}
|
||||
<section className="glass rounded-2xl p-4 sm:p-6 flex flex-col items-center justify-center min-h-[340px] sm:min-h-[420px] relative overflow-hidden">
|
||||
<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="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">
|
||||
<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">
|
||||
<CrystalOrb />
|
||||
</div>
|
||||
{/* v0.14 放置工程进度条(晶体球下方) */}
|
||||
<IdleProjectBar />
|
||||
|
||||
{/* v0.9 全息收益信息面板 */}
|
||||
<CrystalYieldPanel />
|
||||
|
||||
{/* v0.10 探索目标雷达(新增)— 填充留白,让晶体球区有价值 */}
|
||||
<ExplorationRadar />
|
||||
</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-[300px] sm:min-h-[360px] max-h-[560px]">
|
||||
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[280px] sm:min-h-[320px] max-h-[480px]">
|
||||
<DecodePanel />
|
||||
</div>
|
||||
{/* 标签面板:放置 / 探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
|
||||
<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>
|
||||
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 / 遗迹图鉴 */}
|
||||
<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">
|
||||
<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>
|
||||
@@ -379,10 +352,11 @@ 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>
|
||||
@@ -407,12 +381,20 @@ 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
|
||||
@@ -469,10 +451,202 @@ export default function Page() {
|
||||
<TutorialOverlay />
|
||||
<OfflineReportDialog />
|
||||
{cruiseOpen && <CruiseMode onClose={() => setCruiseOpen(false)} />}
|
||||
{/* v0.16 叙事里程碑全屏叠层(pendingMilestone 非空时渲染,模态盖住一切) */}
|
||||
<NarrativeMilestoneOverlay />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,168 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 成就解锁通知消费者
|
||||
import { useEffect } from "react";
|
||||
// 回响星核 / Echo Nexus — 成就解锁通知(v0.9 全屏特效增强)
|
||||
// 工单 Issue #2 反馈"成就系统没看出来有什么用",改为居中全息弹窗 + 奖励数字弹出
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
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]);
|
||||
|
||||
return null;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,64 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 成就系统面板
|
||||
// 回响星核 / Echo Nexus — 成就系统面板(v0.10 工单 #2 重写:单列 + 分类过滤 + 防溢出)
|
||||
import { useState, useMemo } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { ACHIEVEMENTS } from "@/lib/game/achievements";
|
||||
import { achievementBonuses } from "@/lib/game/achievements";
|
||||
import { Trophy, Lock } from "lucide-react";
|
||||
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" },
|
||||
];
|
||||
|
||||
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">
|
||||
{/* 顶部:进度 + 加成总览 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<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">
|
||||
<Trophy className="h-4 w-4 text-amber-400" />
|
||||
成就
|
||||
</h3>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{unlockedCount} / {ACHIEVEMENTS.length}
|
||||
</span>
|
||||
<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)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 永久加成条 */}
|
||||
<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]">
|
||||
<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" />
|
||||
<span className="text-amber-300/80">永久加成</span>
|
||||
{bonus.crystalsPerSecPct > 0 && (
|
||||
<span className="text-emerald-300">产能 +{bonus.crystalsPerSecPct}%</span>
|
||||
@@ -33,64 +67,106 @@ 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>
|
||||
|
||||
{/* 成就网格 — 移除固定 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) => {
|
||||
{/* 分类过滤标签 */}
|
||||
<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) => {
|
||||
const unlocked = !!achievements[a.id];
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`relative rounded-lg border px-2.5 py-2 transition-all duration-300 overflow-hidden ${
|
||||
className={`relative rounded-lg border px-2.5 py-2 transition-all duration-300 min-h-[68px] flex items-center ${
|
||||
unlocked
|
||||
? "border-white/15 bg-black/30"
|
||||
: "border-white/5 bg-black/15 opacity-60"
|
||||
: "border-white/5 bg-black/15 opacity-70"
|
||||
}`}
|
||||
style={
|
||||
unlocked
|
||||
? { boxShadow: `inset 0 0 16px ${a.color}11` }
|
||||
? { boxShadow: `inset 0 0 14px ${a.color}10` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{unlocked && (
|
||||
<div
|
||||
className="absolute -top-6 -right-6 h-16 w-16 rounded-full blur-2xl opacity-40"
|
||||
className="absolute -top-6 -right-6 h-16 w-16 rounded-full blur-2xl opacity-40 pointer-events-none"
|
||||
style={{ background: a.color }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex items-start gap-2">
|
||||
{/* 图标徽章 */}
|
||||
<div
|
||||
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)"}`,
|
||||
color: unlocked ? a.color : "#666",
|
||||
boxShadow: unlocked ? `0 0 10px ${a.color}33` : "none",
|
||||
}}
|
||||
>
|
||||
{unlocked ? a.icon : <Lock className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
{/* 文本 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
{/* 图标徽章 */}
|
||||
<div
|
||||
className="shrink-0 h-10 w-10 rounded-lg flex items-center justify-center text-lg mr-2.5"
|
||||
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)"}`,
|
||||
color: unlocked ? a.color : "#666",
|
||||
boxShadow: unlocked ? `0 0 10px ${a.color}33` : "none",
|
||||
}}
|
||||
>
|
||||
{unlocked ? a.icon : <Lock className="h-4 w-4" />}
|
||||
</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
|
||||
className="text-[11px] font-semibold truncate"
|
||||
style={{ color: unlocked ? a.color : "rgba(255,255,255,0.5)" }}
|
||||
>
|
||||
{a.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground/80 leading-tight mt-0.5">
|
||||
{a.desc}
|
||||
</div>
|
||||
</span>
|
||||
{unlocked && (
|
||||
<div className="text-[9px] mt-1 font-mono" style={{ color: a.color + "cc" }}>
|
||||
✦ {a.rewardText}
|
||||
</div>
|
||||
<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">
|
||||
{a.desc}
|
||||
</div>
|
||||
{unlocked && (
|
||||
<div className="text-[9px] mt-0.5 font-mono truncate" style={{ color: a.color + "cc" }}>
|
||||
✦ {a.rewardText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -101,6 +177,12 @@ 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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -2,33 +2,44 @@
|
||||
// 回响星核 / Echo Nexus — 深空信标面板
|
||||
// v0.5:每日挑战 + 本地排行榜
|
||||
// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
// v0.8.2:限时挑战(每 4 小时刷新,amber 主题)
|
||||
// v0.8.2 P2/#4:本地/全球双榜(云排行榜 mini-service 端口 3030)
|
||||
import { useState, useEffect, useCallback, useRef } 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,
|
||||
@@ -48,6 +59,11 @@ import {
|
||||
Link2,
|
||||
Flame,
|
||||
Zap,
|
||||
Hourglass,
|
||||
RefreshCw,
|
||||
Globe,
|
||||
WifiOff,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
|
||||
@@ -62,11 +78,16 @@ 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] =
|
||||
@@ -74,13 +95,25 @@ 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());
|
||||
@@ -88,9 +121,11 @@ 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);
|
||||
@@ -98,9 +133,44 @@ 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;
|
||||
@@ -116,7 +186,41 @@ export function BeaconPanel() {
|
||||
1
|
||||
)} 接触 · 得分 ${res.score}`,
|
||||
});
|
||||
}, [challenge, progress, toast, grantBeaconReward]);
|
||||
// 异步提交到云排行榜(失败静默,不影响本地)
|
||||
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]);
|
||||
|
||||
const handleClaimWeekly = useCallback(() => {
|
||||
if (!weeklyChallenge || !weeklyProgress) return;
|
||||
@@ -132,8 +236,13 @@ export function BeaconPanel() {
|
||||
1
|
||||
)} 接触 · 得分 ${res.score}`,
|
||||
});
|
||||
// 异步刷新云榜(claimWeeklyBeacon 已在 gameStore 内异步提交到云端)
|
||||
setMySubmitTs(loadLastCloudSubmitTimestamp());
|
||||
if (lbTab === "global") {
|
||||
void refreshCloudLeaderboard();
|
||||
}
|
||||
}
|
||||
}, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]);
|
||||
}, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon, lbTab, refreshCloudLeaderboard]);
|
||||
|
||||
const handleClaimChain = useCallback(
|
||||
(milestone: number) => {
|
||||
@@ -155,7 +264,7 @@ export function BeaconPanel() {
|
||||
[toast, claimChainReward]
|
||||
);
|
||||
|
||||
if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) {
|
||||
if (!challenge || !progress || !timedChallenge || !timedProgress || !weeklyChallenge || !weeklyProgress || !chainState) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
|
||||
正在校准深空信标…
|
||||
@@ -170,6 +279,14 @@ 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];
|
||||
@@ -202,6 +319,14 @@ 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); }
|
||||
@@ -344,6 +469,144 @@ 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 主题) ===== */}
|
||||
@@ -363,7 +626,7 @@ export function BeaconPanel() {
|
||||
周挑战 · WEEKLY
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||
<div className="flex items-center gap-1 text-[9px] text-muted-foreground/70">
|
||||
<span className="font-mono text-fuchsia-300/80">
|
||||
{weeklyChallenge.weekKey}
|
||||
</span>
|
||||
@@ -591,20 +854,20 @@ export function BeaconPanel() {
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px] text-center leading-tight"
|
||||
className="text-[9px] text-center leading-tight"
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
{reward?.label}
|
||||
</span>
|
||||
{claimed ? (
|
||||
<span className="text-[10px] text-emerald-400/80 font-mono">
|
||||
<span className="text-[9px] text-emerald-400/80 font-mono">
|
||||
已领
|
||||
</span>
|
||||
) : reachable ? (
|
||||
<Button
|
||||
onClick={() => handleClaimChain(m)}
|
||||
size="sm"
|
||||
className="h-5 px-2 text-[10px] py-0 border-0"
|
||||
className="h-5 px-2 text-[9px] py-0 border-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, #fb7185, #f43f5e)",
|
||||
@@ -614,7 +877,7 @@ export function BeaconPanel() {
|
||||
领取
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground/40 font-mono">
|
||||
<span className="text-[9px] text-muted-foreground/40 font-mono">
|
||||
+{reward?.rewardInsight}洞
|
||||
</span>
|
||||
)}
|
||||
@@ -646,7 +909,7 @@ export function BeaconPanel() {
|
||||
</div>
|
||||
|
||||
{/* 底部统计 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 text-[10px]">
|
||||
<div className="flex items-center justify-between gap-2 text-[10px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground/60">最长</span>
|
||||
<span className="font-mono text-amber-300">
|
||||
@@ -671,73 +934,242 @@ 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">
|
||||
<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>
|
||||
<span className="text-[10px] text-muted-foreground/50">本地 · Top {leaderboard.length || 0}</span>
|
||||
{/* 头部:标题 + tab 切换 + (全球 tab 时)刷新按钮 */}
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{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-[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();
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
: 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 }} />
|
||||
) : (
|
||||
<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>
|
||||
{entry.isWeekly && (
|
||||
<span className="text-[10px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
|
||||
WEEK
|
||||
{/* 子标题:榜量信息 */}
|
||||
<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 ? (
|
||||
<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">
|
||||
{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();
|
||||
return (
|
||||
<div
|
||||
key={`local-${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 }} />
|
||||
) : (
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 难度图例 */}
|
||||
<div className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground/50">
|
||||
<div className="flex items-center justify-center gap-3 text-[9px] text-muted-foreground/50">
|
||||
{DIFFICULTY_ORDER.map((d) => {
|
||||
const m = BEACON_DIFFICULTY[d];
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 回响编年史对话框(v0.4 跨周目叙事时间轴)
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -20,7 +20,8 @@ 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 } from "lucide-react";
|
||||
import { BookOpen, Clock, Swords, Trophy, Sparkles, Star, Waves, Cpu, Zap, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -59,12 +60,26 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
|
||||
const bossKills = useGameStore((s) => s.bossKills ?? 0);
|
||||
const tidesAll = useGameStore((s) => s.starTidesEncountered ?? []);
|
||||
// 倒序展示(最新的在前)
|
||||
const entries: ChronicleEntry[] = [...chronicle].reverse();
|
||||
const entries: ChronicleEntry[] = useMemo(() => [...chronicle].reverse(), [chronicle]);
|
||||
|
||||
// 打开时播放翻页音效
|
||||
// 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(() => {
|
||||
if (open) sfx("chronicleOpen");
|
||||
// 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);
|
||||
}
|
||||
}, [open]);
|
||||
const pagedEntries = entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -108,7 +123,7 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
|
||||
</DialogHeader>
|
||||
|
||||
{/* 时间轴滚动区 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6 echo-scroll">
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-3 echo-scroll">
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState ascensions={ascensions} />
|
||||
) : (
|
||||
@@ -117,18 +132,49 @@ 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">
|
||||
{entries.map((entry, idx) => (
|
||||
{pagedEntries.map((entry, idx) => (
|
||||
<ChronicleCard
|
||||
key={`${entry.ascensionNumber}-${entry.timestamp}`}
|
||||
entry={entry}
|
||||
isLatest={idx === 0}
|
||||
index={idx}
|
||||
isLatest={(page - 1) * PAGE_SIZE + idx === 0}
|
||||
index={(page - 1) * PAGE_SIZE + 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>
|
||||
);
|
||||
|
||||
@@ -1,207 +1,25 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 记忆图谱(碎片叙事 + v0.15 多层文本 + v0.16 已读标记)
|
||||
import { useState, useMemo } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useGameStore, FRAGMENTS, nextFragmentThreshold } from "@/store/gameStore";
|
||||
import { isLoreLayerUnlocked } from "@/lib/game/config";
|
||||
// 回响星核 / Echo Nexus — 记忆图谱(碎片叙事)
|
||||
import { useState } from "react";
|
||||
import { useGameStore, FRAGMENTS } from "@/store/gameStore";
|
||||
import { nextFragmentThreshold } from "@/store/gameStore";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { BookOpen, Lock, Sparkles, Check } from "lucide-react";
|
||||
import type { Fragment, FragmentLoreLayer } from "@/lib/game/types";
|
||||
import { BookOpen, Lock, Sparkles } from "lucide-react";
|
||||
import type { Fragment } from "@/lib/game/types";
|
||||
|
||||
const ERA_NAMES = ["", "寂灭前夜", "谐振纪", "飞升潮", "回响纪", "接触纪"];
|
||||
|
||||
/** 锁定层用的乱码占位文本(视觉上像被加密的全息碎片) */
|
||||
const LOCKED_PLACEHOLDER =
|
||||
"▓░▒░ ▓▒░▒▓ ░▒▓░ ▒▓░▒░ ▓▒░ ▓░▒░▒ ░▒▓░▒ ▓░▒ ░▒▓░ ▒▓░▒▓░ ▓▒░░▒▓ ░▒▓ ▓░▒▒░ ▓▒░▓░▒ ░▒▓░ ▒▓░▒░ ▓▒░ ░▒▓░";
|
||||
|
||||
/**
|
||||
* 渲染单层叙事文本(已解锁 / 未解锁 / 已读 三态)。
|
||||
* v0.15:L1=amber / L2=fuchsia
|
||||
* v0.16:
|
||||
* - 已读 → 标题旁加 ✓ muted 标记,边框降级为 muted
|
||||
* - 未读但已解锁 → 保持 v0.15 高亮,标题旁加脉冲小点(animate-pulse)提示"新"
|
||||
* - 点击文本区域即标记已读(自然交互,不另设按钮)
|
||||
*/
|
||||
function LoreLayerBlock({
|
||||
layer,
|
||||
unlocked,
|
||||
isRead,
|
||||
onMarkRead,
|
||||
}: {
|
||||
layer: FragmentLoreLayer;
|
||||
unlocked: boolean;
|
||||
isRead: boolean;
|
||||
onMarkRead: () => void;
|
||||
}) {
|
||||
const isL1 = layer.layer === 1;
|
||||
const accentText = isL1 ? "text-amber-300" : "text-fuchsia-300";
|
||||
// v0.16:已读时降级为 muted 边框,未读时保持高亮
|
||||
const accentBorder =
|
||||
unlocked && !isRead
|
||||
? isL1
|
||||
? "border-amber-400/30"
|
||||
: "border-fuchsia-400/30"
|
||||
: "border-white/10";
|
||||
const accentBg =
|
||||
unlocked && !isRead
|
||||
? isL1
|
||||
? "bg-amber-950/20"
|
||||
: "bg-fuchsia-950/20"
|
||||
: isL1
|
||||
? "bg-amber-950/10"
|
||||
: "bg-fuchsia-950/10";
|
||||
const accentBody = isL1 ? "text-amber-50/90" : "text-fuchsia-50/90";
|
||||
const accentChipBg = isL1 ? "bg-amber-500/15" : "bg-fuchsia-500/15";
|
||||
const accentChipText = isL1 ? "text-amber-300" : "text-fuchsia-300";
|
||||
const layerTag = isL1 ? "L1" : "L2";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border ${accentBorder} ${
|
||||
unlocked ? accentBg : "bg-black/20"
|
||||
} p-3 transition-colors duration-300`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2 gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className={`text-[9px] font-mono px-1.5 py-0.5 rounded shrink-0 ${
|
||||
unlocked ? `${accentChipBg} ${accentChipText}` : "bg-white/5 text-muted-foreground/60"
|
||||
}`}
|
||||
>
|
||||
{layerTag}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-semibold truncate ${
|
||||
unlocked ? accentText : "text-muted-foreground/70"
|
||||
}`}
|
||||
>
|
||||
{unlocked ? layer.title : "???"}
|
||||
</span>
|
||||
|
||||
{/* v0.16 已读 / 未读指示 */}
|
||||
{unlocked && isRead && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 text-[9px] text-muted-foreground/70 shrink-0"
|
||||
title="已读"
|
||||
>
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
已读
|
||||
</span>
|
||||
)}
|
||||
{unlocked && !isRead && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 shrink-0"
|
||||
title="新解锁 · 点击文本可标记已读"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full animate-pulse ${
|
||||
isL1 ? "bg-amber-400" : "bg-fuchsia-400"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-[9px] text-muted-foreground/80">新</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!unlocked && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-amber-200/50 shrink-0">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
<span className="font-mono hidden xs:inline sm:inline">{layer.unlockHint}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unlocked ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMarkRead}
|
||||
className="block text-left w-full focus:outline-none focus-visible:ring-1 focus-visible:ring-white/30 rounded"
|
||||
title={isRead ? "已读" : "点击标记为已读"}
|
||||
aria-label={isRead ? "已读" : "标记为已读"}
|
||||
>
|
||||
<p
|
||||
className={`text-[12px] leading-relaxed italic ${accentBody} ${
|
||||
isRead ? "opacity-80" : ""
|
||||
}`}
|
||||
>
|
||||
「{layer.text}」
|
||||
</p>
|
||||
{!isRead && (
|
||||
<span className="block mt-1.5 text-[9px] text-muted-foreground/40">
|
||||
▸ 点击文本标记已读
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<p
|
||||
className="text-[12px] leading-relaxed blur-sm select-none text-muted-foreground/40 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
「{LOCKED_PLACEHOLDER}」
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!unlocked && (
|
||||
<div className="mt-2 pt-2 border-t border-white/5 text-[10px] text-amber-200/50">
|
||||
<span className="font-mono">🔒 {layer.unlockHint}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Codex() {
|
||||
// useShallow 一次性订阅所有判定所需的 state 切片(v0.16 新增 readLoreLayers + markLoreLayerRead)
|
||||
const { fragments, totalDecoded, ascensions, contact, bossKills, readLoreLayers, markLoreLayerRead } =
|
||||
useGameStore(
|
||||
useShallow((s) => ({
|
||||
fragments: s.fragments,
|
||||
totalDecoded: s.totalDecoded,
|
||||
ascensions: s.ascensions,
|
||||
contact: s.contact,
|
||||
bossKills: s.bossKills,
|
||||
readLoreLayers: Array.isArray(s.readLoreLayers) ? s.readLoreLayers : [],
|
||||
markLoreLayerRead: s.markLoreLayerRead,
|
||||
})),
|
||||
);
|
||||
const fragments = useGameStore((s) => s.fragments);
|
||||
const totalDecoded = useGameStore((s) => s.totalDecoded);
|
||||
const nextThreshold = nextFragmentThreshold(totalDecoded);
|
||||
const [selected, setSelected] = useState<Fragment | null>(null);
|
||||
|
||||
// v0.1 只展示第一纪元
|
||||
const era1 = useMemo(() => FRAGMENTS.filter((f) => f.era === 1), []);
|
||||
const era1 = FRAGMENTS.filter((f) => f.era === 1);
|
||||
const unlockedCount = era1.filter((f) => fragments[f.id]).length;
|
||||
|
||||
// 已读集合(Set 加速查询)
|
||||
const readSet = useMemo(() => new Set(readLoreLayers), [readLoreLayers]);
|
||||
|
||||
// 用于判定解锁的快照对象(结构匹配 isLoreLayerUnlocked 的 Pick)
|
||||
const stateSnapshot = {
|
||||
ascensions,
|
||||
totalDecoded,
|
||||
contact,
|
||||
fragments,
|
||||
bossKills,
|
||||
};
|
||||
|
||||
// 深层叙事统计:所有 loreLayer 中已解锁数 / 总数 + 已读数(v0.16)
|
||||
const loreStats = useMemo(() => {
|
||||
let total = 0;
|
||||
let unlocked = 0;
|
||||
let read = 0;
|
||||
for (const f of era1) {
|
||||
if (!f.loreLayers) continue;
|
||||
for (const layer of f.loreLayers) {
|
||||
total += 1;
|
||||
if (isLoreLayerUnlocked(layer.unlock, stateSnapshot)) {
|
||||
unlocked += 1;
|
||||
// 仅统计已解锁层的已读数(未解锁的层不算入已读分母)
|
||||
if (readSet.has(`${f.id}_L${layer.layer}`)) read += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { total, unlocked, read };
|
||||
}, [era1, ascensions, totalDecoded, contact, fragments, bossKills, readSet]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -214,24 +32,6 @@ export function Codex() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* v0.15 深层叙事统计 + v0.16 已读统计(移动端 stack,桌面并排) */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 text-[10px] px-1">
|
||||
<span className="text-muted-foreground/80">
|
||||
深层叙事
|
||||
<span className="font-mono ml-1">
|
||||
<span className="text-amber-300">{loreStats.unlocked}</span>
|
||||
<span className="text-muted-foreground/60"> / {loreStats.total} 已解锁</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground/80">
|
||||
已读
|
||||
<span className="font-mono ml-1">
|
||||
<span className="text-emerald-300">{loreStats.read}</span>
|
||||
<span className="text-muted-foreground/60"> / {loreStats.unlocked} 已解锁层</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{nextThreshold && (
|
||||
<div className="text-[10px] text-muted-foreground/80 px-1">
|
||||
再解码 <span className="text-fuchsia-300 font-mono">{nextThreshold - totalDecoded}</span> 颗 → 下一个碎片
|
||||
@@ -242,21 +42,6 @@ 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;
|
||||
// v0.16:是否有未读的已解锁层(用于卡片标题旁小圆点变色)
|
||||
const hasUnreadLayer =
|
||||
(l1Unlocked && !readSet.has(`${f.id}_L1`)) ||
|
||||
(l2Unlocked && !readSet.has(`${f.id}_L2`));
|
||||
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
@@ -284,38 +69,7 @@ export function Codex() {
|
||||
{unlocked ? (
|
||||
<>
|
||||
<div className="absolute -top-3 -right-3 h-12 w-12 rounded-full bg-fuchsia-500/20 blur-xl" />
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Sparkles className="h-3 w-3 text-fuchsia-300" />
|
||||
{hasDeepUnlocked && (
|
||||
<span
|
||||
className="flex items-center gap-0.5"
|
||||
title={
|
||||
hasUnreadLayer
|
||||
? "此碎片含未读的深层文本"
|
||||
: "此碎片的深层文本均已读"
|
||||
}
|
||||
>
|
||||
{l1Unlocked && (
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
l1Unlocked && !readSet.has(`${f.id}_L1`)
|
||||
? "bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.8)] animate-pulse"
|
||||
: "bg-amber-400/30"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{l2Unlocked && (
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
l2Unlocked && !readSet.has(`${f.id}_L2`)
|
||||
? "bg-fuchsia-400 shadow-[0_0_4px_rgba(232,121,249,0.8)] animate-pulse"
|
||||
: "bg-fuchsia-400/30"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Sparkles className="h-3 w-3 text-fuchsia-300 mb-1" />
|
||||
<div className="text-[11px] font-semibold text-fuchsia-100 leading-tight">
|
||||
{f.title}
|
||||
</div>
|
||||
@@ -337,75 +91,32 @@ export function Codex() {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 详情弹层 — v0.15 分层渲染 + v0.16 已读标记 / 移动端 375px 适配 */}
|
||||
{/* 详情弹层 */}
|
||||
{selected && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-black/70 backdrop-blur-sm"
|
||||
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 max-h-[88vh] overflow-y-auto p-4 sm:p-5 bg-gradient-to-br from-fuchsia-950/40 to-black/80 border-fuchsia-400/40 [scrollbar-width:thin] [scrollbar-color:rgba(232,121,249,0.4)_transparent]"
|
||||
className="max-w-md w-full p-5 bg-gradient-to-br from-fuchsia-950/60 to-black/80 border-fuchsia-400/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ boxShadow: "0 0 40px rgba(232,121,249,0.25)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Sparkles className="h-4 w-4 text-fuchsia-300 shrink-0" />
|
||||
<span className="text-[10px] text-fuchsia-300/70 font-mono truncate">
|
||||
<Sparkles className="h-4 w-4 text-fuchsia-300" />
|
||||
<span className="text-[10px] text-fuchsia-300/70 font-mono">
|
||||
第 {selected.era} 纪元 · {ERA_NAMES[selected.era] || ""}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="text-base sm:text-lg font-bold text-fuchsia-100 mb-4 break-words">
|
||||
<h4 className="text-lg font-bold text-fuchsia-100 mb-3">
|
||||
{selected.title}
|
||||
</h4>
|
||||
|
||||
{/* 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);
|
||||
const isRead = unlocked && readSet.has(`${selected.id}_L${layer.layer}`);
|
||||
return (
|
||||
<div
|
||||
key={`${selected.id}-L${layer.layer}`}
|
||||
className="mt-3 first:mt-0"
|
||||
>
|
||||
<div className="border-t border-white/5 mb-3" />
|
||||
<LoreLayerBlock
|
||||
layer={layer}
|
||||
unlocked={unlocked}
|
||||
isRead={isRead}
|
||||
onMarkRead={() => markLoreLayerRead(selected.id, layer.layer)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 若该碎片没有 loreLayers */}
|
||||
{!selected.loreLayers?.length && (
|
||||
<div className="mt-4 pt-3 border-t border-white/5 text-[10px] text-muted-foreground/50 italic">
|
||||
此碎片暂无更深层叙事。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-foreground/90 leading-relaxed italic">
|
||||
「{selected.echo}」
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="mt-5 text-xs text-muted-foreground hover:text-foreground transition"
|
||||
className="mt-4 text-xs text-muted-foreground hover:text-foreground transition"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -242,10 +243,18 @@ 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: "谐振叠加,产能飙升。",
|
||||
description: whisper ? `「${whisper}」` : "谐振叠加,产能飙升。",
|
||||
});
|
||||
} 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]
|
||||
|
||||
@@ -408,13 +408,33 @@ function CrystalCard({
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
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"
|
||||
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"
|
||||
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-180"
|
||||
className="relative h-12 w-12 transition-transform duration-500 group-hover:rotate-[200deg] group-hover:scale-110"
|
||||
style={{ filter: `drop-shadow(0 0 12px ${tierColor}88)` }}
|
||||
>
|
||||
<div
|
||||
@@ -430,15 +450,22 @@ function CrystalCard({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 标签 */}
|
||||
<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 }}>
|
||||
{/* 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` }}
|
||||
>
|
||||
T{crystal.tier}
|
||||
</div>
|
||||
<div className="absolute bottom-1.5 right-1.5 text-[10px] text-white/70">
|
||||
<div className="absolute top-1.5 right-1.5 z-10 text-[9px] text-white/60 font-mono tracking-wider">
|
||||
{tierLabel}
|
||||
</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>
|
||||
{/* 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>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 遗迹探险面板(v0.2 肉鸽系统)
|
||||
import { useState } from "react";
|
||||
// 回响星核 / Echo Nexus — 遗迹探险面板(v0.2 肉鸽系统,v0.9 工单修复)
|
||||
import { useState, useEffect } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { sfx } from "@/hooks/useAudio";
|
||||
@@ -42,6 +42,7 @@ 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);
|
||||
@@ -50,12 +51,20 @@ 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 动态间隔)
|
||||
const now = Date.now();
|
||||
// 能量恢复进度(v0.8.1 动态间隔,v0.9 实时刷新)
|
||||
const regenMs = regenIntervalSec * 1000;
|
||||
const regenProgress = energy >= energyMax ? 100 : Math.min(100, ((now - lastEnergyTick) / regenMs) * 100);
|
||||
const elapsed = now - lastEnergyTick;
|
||||
const regenProgress = energy >= energyMax ? 100 : Math.min(100, (elapsed / 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();
|
||||
@@ -99,6 +108,7 @@ export function ExpeditionPanel() {
|
||||
};
|
||||
|
||||
// ===== 无活跃探险:展示入口 =====
|
||||
// v0.10 工单 #2 修复:探险结束自动清空 activeExpedition,无需 justFinished 判断
|
||||
if (!activeExpedition || activeExpedition.finished) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 h-full">
|
||||
@@ -110,6 +120,40 @@ 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">
|
||||
@@ -127,8 +171,10 @@ 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">
|
||||
下一点能量约 {Math.ceil((regenMs - (now - lastEnergyTick)) / 1000)}s 后恢复
|
||||
<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>
|
||||
{regenBoosted && (
|
||||
<span className="text-emerald-400/80 ml-1">⚡ {regenIntervalSec.toFixed(0)}s/点 (已加速)</span>
|
||||
)}
|
||||
@@ -140,35 +186,32 @@ export function ExpeditionPanel() {
|
||||
</div>
|
||||
|
||||
{/* 出发按钮 */}
|
||||
<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="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="absolute -top-10 -right-10 h-32 w-32 rounded-full bg-amber-500/10 blur-2xl" />
|
||||
<div className="relative">
|
||||
<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 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>
|
||||
</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={energy < EXPEDITION_CONFIG.energyCost}
|
||||
disabled={!canStart}
|
||||
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" />
|
||||
出发(消耗 {EXPEDITION_CONFIG.energyCost} 能量)
|
||||
{canStart ? `出发(消耗 ${EXPEDITION_CONFIG.energyCost} 能量)` : `能量不足(${nextEnergyInSec}s 后恢复)`}
|
||||
</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-32 overflow-y-auto echo-scroll">
|
||||
<div className="rounded-xl border border-white/5 bg-black/20 p-2 max-h-28 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">
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,526 +0,0 @@
|
||||
"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.15:softcap 有效/原始永久加成
|
||||
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-[10px] 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-[10px] 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-[10px] 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-[10px] 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-[10px] 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-[10px] 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-[10px] text-muted-foreground/70">
|
||||
槽位 {slotIndex + 1} · {Math.round(progressPct)}%
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="h-5 px-1.5 text-[10px] 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-[10px] text-muted-foreground/70">
|
||||
⏱ {formatDuration(project.durationSec)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] 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-[10px] text-amber-300/80 ml-1">运行中…</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 静默导出,便于其他模块使用
|
||||
export { isIdleProjectUnlocked };
|
||||
@@ -1,90 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — v0.16 叙事里程碑全屏叠层
|
||||
//
|
||||
// 当 store.pendingMilestone 不为 null 时显示全屏「叙事碎片释放」叠层。
|
||||
// - 暗色半透明背景 + 4 色全息主题(emerald/rose/amber/fuchsia,严格无蓝/靛)
|
||||
// - 大字号标题 + 副标题 + 主体文本逐字浮现(30ms/字,可点击跳过)
|
||||
// - 点击任意处或按 Esc 关闭;首次点击若文本未完全显示,先跳到全文显示
|
||||
// - z-50(高于 StarTideOverlay 的 z-0),模态盖住一切
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import type {
|
||||
MilestoneColor,
|
||||
NarrativeMilestone,
|
||||
} from "@/lib/game/narrativeMilestones";
|
||||
|
||||
/** 4 色全息主题映射(严格 emerald / rose / amber / fuchsia,禁止 blue / indigo) */
|
||||
const COLOR_THEME: Record<
|
||||
MilestoneColor,
|
||||
{
|
||||
titleText: string;
|
||||
subtitleText: string;
|
||||
bodyText: string;
|
||||
cursor: string;
|
||||
cardBorder: string;
|
||||
cardBg: string;
|
||||
/** CSS rgba 用于 box-shadow / text-shadow / 分隔线渐变 */
|
||||
glow: string;
|
||||
}
|
||||
> = {
|
||||
emerald: {
|
||||
titleText: "text-emerald-50",
|
||||
subtitleText: "text-emerald-300/80",
|
||||
bodyText: "text-emerald-50/90",
|
||||
cursor: "bg-emerald-300",
|
||||
cardBorder: "border-emerald-400/40",
|
||||
cardBg: "from-emerald-950/60 to-black/85",
|
||||
glow: "rgba(16,185,129,0.45)",
|
||||
},
|
||||
rose: {
|
||||
titleText: "text-rose-50",
|
||||
subtitleText: "text-rose-300/80",
|
||||
bodyText: "text-rose-50/90",
|
||||
cursor: "bg-rose-300",
|
||||
cardBorder: "border-rose-400/40",
|
||||
cardBg: "from-rose-950/60 to-black/85",
|
||||
glow: "rgba(244,63,94,0.45)",
|
||||
},
|
||||
amber: {
|
||||
titleText: "text-amber-50",
|
||||
subtitleText: "text-amber-300/80",
|
||||
bodyText: "text-amber-50/90",
|
||||
cursor: "bg-amber-300",
|
||||
cardBorder: "border-amber-400/40",
|
||||
cardBg: "from-amber-950/60 to-black/85",
|
||||
glow: "rgba(245,158,11,0.45)",
|
||||
},
|
||||
fuchsia: {
|
||||
titleText: "text-fuchsia-50",
|
||||
subtitleText: "text-fuchsia-300/80",
|
||||
bodyText: "text-fuchsia-50/90",
|
||||
cursor: "bg-fuchsia-300",
|
||||
cardBorder: "border-fuchsia-400/40",
|
||||
cardBg: "from-fuchsia-950/60 to-black/85",
|
||||
glow: "rgba(232,121,249,0.45)",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 叙事碎片释放叠层(外层)。
|
||||
* 订阅 store.pendingMilestone;非空时挂载内层组件,并以 milestone.id 为 key,
|
||||
* 确保每次新里程碑触发时内层组件完全重新挂载(state 自然重置,无需 effect setState)。
|
||||
*/
|
||||
export function NarrativeMilestoneOverlay() {
|
||||
const pendingMilestone = useGameStore((s) => s.pendingMilestone);
|
||||
const dismiss = useGameStore((s) => s.dismissMilestone);
|
||||
|
||||
if (!pendingMilestone) return null;
|
||||
|
||||
return (
|
||||
<NarrativeMilestoneOverlayInner
|
||||
key={pendingMilestone.id}
|
||||
milestone={pendingMilestone}
|
||||
dismiss={dismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 内层组件:每个 milestone 实例独立挂载,state 在挂载时初始化为 0 */
|
||||
function NarrativeMilestoneOverlayInner({
|
||||
milestone,
|
||||
dismiss,
|
||||
}: {
|
||||
milestone: NarrativeMilestone;
|
||||
dismiss: () => void;
|
||||
}) {
|
||||
// 挂载时初始化为 0;组件由父层 key 重新挂载,无需 effect 重置
|
||||
const [revealed, setRevealed] = useState(0);
|
||||
const skipRef = useRef(false);
|
||||
|
||||
const fullLen = milestone.body.length;
|
||||
const fullyRevealed = revealed >= fullLen;
|
||||
|
||||
// 逐字推进:每 30ms 多显示一个字符;已 skip / 已完成则停表
|
||||
useEffect(() => {
|
||||
if (skipRef.current || revealed >= fullLen) return;
|
||||
const id = setTimeout(() => {
|
||||
setRevealed((r) => Math.min(fullLen, r + 1));
|
||||
}, 30);
|
||||
return () => clearTimeout(id);
|
||||
}, [revealed, fullLen]);
|
||||
|
||||
/** 处理点击/Esc:若文本未完全显示则先跳到全文,再次触发才真正 dismiss */
|
||||
const handleAction = () => {
|
||||
if (!fullyRevealed) {
|
||||
skipRef.current = true;
|
||||
setRevealed(fullLen);
|
||||
return;
|
||||
}
|
||||
dismiss();
|
||||
};
|
||||
|
||||
// Esc 键监听(依赖 fullyRevealed 决定是 skip 还是 dismiss)
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (!fullyRevealed) {
|
||||
skipRef.current = true;
|
||||
setRevealed(fullLen);
|
||||
return;
|
||||
}
|
||||
dismiss();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [fullyRevealed, fullLen, dismiss]);
|
||||
|
||||
const theme = COLOR_THEME[milestone.color];
|
||||
const displayBody = fullyRevealed
|
||||
? milestone.body
|
||||
: milestone.body.slice(0, revealed);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-black/80 backdrop-blur-sm animate-[narrative-fade-in_300ms_ease-out]"
|
||||
onClick={handleAction}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="narrative-milestone-title"
|
||||
>
|
||||
{/* 4 色全息粒子边框(呼吸光晕) */}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0"
|
||||
style={{
|
||||
boxShadow: `inset 0 0 120px ${theme.glow}, inset 0 0 240px ${theme.glow}`,
|
||||
animation: "narrative-breathe 3.5s ease-in-out infinite",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* 中央卡片 */}
|
||||
<div
|
||||
className={`relative max-w-2xl w-full px-4 py-6 sm:px-8 sm:py-10 rounded-xl border ${theme.cardBorder} bg-gradient-to-br ${theme.cardBg} animate-[narrative-scale-in_300ms_cubic-bezier(0.16,1,0.3,1)] [scrollbar-width:thin]`}
|
||||
style={{ boxShadow: `0 0 60px ${theme.glow}` }}
|
||||
onClick={(e) => {
|
||||
// 阻止外层 dismiss 在跳过阶段触发两次;卡片内点击同样走 handleAction
|
||||
e.stopPropagation();
|
||||
handleAction();
|
||||
}}
|
||||
>
|
||||
{/* 顶部副标题(小字,主题色,tracking-widest) */}
|
||||
<div
|
||||
className={`text-[10px] sm:text-xs font-mono tracking-[0.3em] uppercase mb-2 ${theme.subtitleText}`}
|
||||
>
|
||||
{milestone.subtitle}
|
||||
</div>
|
||||
|
||||
{/* 大字号标题(font-serif 风格) */}
|
||||
<h2
|
||||
id="narrative-milestone-title"
|
||||
className={`text-3xl sm:text-4xl md:text-5xl font-serif font-bold mb-5 sm:mb-6 ${theme.titleText}`}
|
||||
style={{ textShadow: `0 0 24px ${theme.glow}` }}
|
||||
>
|
||||
{milestone.title}
|
||||
</h2>
|
||||
|
||||
{/* 装饰分隔线 */}
|
||||
<div
|
||||
className="h-px mb-5 sm:mb-6"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, transparent, ${theme.glow}, transparent)`,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* 主体文本(逐字浮现,多段以 \n\n 分隔 → whitespace-pre-line 渲染) */}
|
||||
<div
|
||||
className={`text-sm sm:text-base md:text-lg leading-relaxed whitespace-pre-line ${theme.bodyText}`}
|
||||
>
|
||||
{displayBody}
|
||||
{/* 输入光标闪烁,仅未完成时显示 */}
|
||||
{!fullyRevealed && (
|
||||
<span
|
||||
className={`inline-block w-0.5 h-4 sm:h-5 ml-0.5 align-middle ${theme.cursor}`}
|
||||
style={{ animation: "narrative-cursor 1s steps(2) infinite" }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<div className="mt-6 sm:mt-8 text-center">
|
||||
<span className="text-[10px] sm:text-xs text-white/40 animate-pulse">
|
||||
{fullyRevealed ? "点击任意处或按 Esc 继续" : "点击跳过逐字显示"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes narrative-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes narrative-scale-in {
|
||||
from {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.4;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes narrative-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes narrative-cursor {
|
||||
0%,
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
51%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -16,12 +16,15 @@ 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: `${meta.icon} 星潮降临:${meta.name}`,
|
||||
description: meta.desc,
|
||||
title: `${globalTag}${meta.icon} ${ev.isGlobal ? "全球星潮" : "星潮降临"}:${meta.name}`,
|
||||
description: ev.isGlobal
|
||||
? `全球玩家同步经历 · ${meta.desc}`
|
||||
: meta.desc,
|
||||
});
|
||||
}
|
||||
if (ev.ended) {
|
||||
@@ -29,14 +32,16 @@ export function StarTideNotifier() {
|
||||
if (ev.silenceCompensation && ev.silenceCompensation > 0) {
|
||||
sfx("techBuy");
|
||||
toast({
|
||||
title: `${meta.icon} 寂静期消散`,
|
||||
title: `${globalTag}${meta.icon} 寂静期消散`,
|
||||
description: `虚空回赠 ${ev.silenceCompensation} 洞见。`,
|
||||
});
|
||||
} else {
|
||||
sfx("tideEnd");
|
||||
toast({
|
||||
title: `${meta.icon} ${meta.name}结束`,
|
||||
description: "星潮退去,物理法则恢复。",
|
||||
title: `${globalTag}${meta.icon} ${meta.name}结束`,
|
||||
description: ev.isGlobal
|
||||
? "全球星潮退去,物理法则恢复。"
|
||||
: "星潮退去,物理法则恢复。",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -49,6 +54,7 @@ export function StarTideNotifier() {
|
||||
/** 顶部星潮指示器(活跃时显示倒计时芯片) */
|
||||
export function StarTideIndicator() {
|
||||
const activeTide = useGameStore((s) => s.activeTide);
|
||||
const globalTide = useGameStore((s) => s.globalTide);
|
||||
const [, force] = useState(0);
|
||||
|
||||
// 每秒刷新倒计时
|
||||
@@ -62,18 +68,27 @@ export function StarTideIndicator() {
|
||||
const meta = TIDE_EVENTS[activeTide.type];
|
||||
const remaining = Math.max(0, activeTide.endsAt - Date.now());
|
||||
const secs = Math.ceil(remaining / 1000);
|
||||
const progress = 1 - remaining / TIDE_CONFIG.duration;
|
||||
// 全球星潮持续 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;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-full text-[10px] font-medium relative overflow-hidden"
|
||||
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" : ""
|
||||
}`}
|
||||
style={{
|
||||
background: `${meta.color}1a`,
|
||||
border: `1px solid ${meta.color}55`,
|
||||
background: isGlobal
|
||||
? `linear-gradient(135deg, ${meta.color}26, ${meta.color}11)`
|
||||
: `${meta.color}1a`,
|
||||
border: `1px solid ${meta.color}${isGlobal ? "88" : "55"}`,
|
||||
color: meta.color,
|
||||
boxShadow: `0 0 10px ${meta.glow}`,
|
||||
boxShadow: `0 0 ${isGlobal ? "16" : "10"}px ${meta.glow}`,
|
||||
}}
|
||||
title={meta.desc}
|
||||
title={isGlobal ? `🌐 全球星潮 · ${meta.desc}` : meta.desc}
|
||||
>
|
||||
{/* 进度环背景 */}
|
||||
<div
|
||||
@@ -82,6 +97,14 @@ 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>
|
||||
@@ -92,8 +115,10 @@ 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">
|
||||
@@ -115,15 +140,30 @@ export function StarTideOverlay() {
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
boxShadow: `inset 0 0 120px ${meta.glow}`,
|
||||
boxShadow: `inset 0 0 ${isGlobal ? "160" : "120"}px ${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>
|
||||
);
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 统计面板
|
||||
// v0.15 从 page.tsx 抽出为独立组件,useShallow 订阅
|
||||
// v0.16 放置工程历史详情展开(最近 5 条 · 工程名 + 相对时间)
|
||||
import { useEffect, useMemo, 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";
|
||||
import { getIdleProject } from "@/lib/game/idle";
|
||||
import { formatRelativeTime } from "@/lib/game/format";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { IdleProjectSlot } from "@/lib/game/types";
|
||||
|
||||
/** 放置工程历史条目(与 GameState.idleProjectHistory 元素一致) */
|
||||
type IdleHistoryEntry = { projectId: string; finishedAt: number };
|
||||
|
||||
/**
|
||||
* 统计面板 — 综合统计 + 放置系统(idle)行。
|
||||
* v0.15 重构:从 page.tsx 内联函数抽出为独立组件,使用 useShallow 精细订阅避免 re-render。
|
||||
* v0.16:「放置工程历史」行支持展开/收起,展开后显示最近 5 条详情(工程 icon + 名称 + 相对时间)。
|
||||
*/
|
||||
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,
|
||||
// v0.16:直接订阅历史数组(用于展开详情渲染)
|
||||
idleProjectHistory: (st.idleProjectHistory ?? []) as IdleHistoryEntry[],
|
||||
// v0.16:订阅 idleProjectSlots 用于详情中"进行中"状态显示
|
||||
idleProjectSlots: (st.idleProjectSlots ?? [null, null, null]) as (
|
||||
| IdleProjectSlot
|
||||
| null
|
||||
)[],
|
||||
})),
|
||||
);
|
||||
|
||||
// 深空信标本地排行榜最高分(独立 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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// v0.16 放置历史展开状态(本地 UI 状态)
|
||||
const [historyExpanded, setHistoryExpanded] = useState(false);
|
||||
|
||||
// 最近 5 条历史(倒序,最近在前)— useMemo 避免每次渲染重切
|
||||
const recentHistory = useMemo(
|
||||
() => s.idleProjectHistory.slice(-5).reverse(),
|
||||
[s.idleProjectHistory],
|
||||
);
|
||||
|
||||
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) },
|
||||
// 「放置工程历史」行单独渲染(v0.16 可展开),不在此数组中
|
||||
];
|
||||
|
||||
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>
|
||||
))}
|
||||
|
||||
{/* v0.16 放置工程历史 — 可展开行(col-span-2,跨两列) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistoryExpanded((v) => !v)}
|
||||
aria-expanded={historyExpanded}
|
||||
aria-controls="idle-history-detail"
|
||||
className="col-span-2 flex items-center justify-between rounded-md bg-black/25 border border-white/5 px-2 py-1.5 hover:bg-black/40 hover:border-fuchsia-400/20 transition text-left"
|
||||
>
|
||||
<span className="text-muted-foreground flex items-center gap-1.5">
|
||||
<span>放置工程历史</span>
|
||||
{s.idleHistoryLen > 0 && (
|
||||
<span className="text-[10px] text-fuchsia-300/70 font-mono">
|
||||
(最近 {Math.min(5, s.idleHistoryLen)} 条可展开)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-foreground">{s.idleHistoryLen} 条</span>
|
||||
{s.idleHistoryLen > 0 ? (
|
||||
historyExpanded ? (
|
||||
<ChevronUp className="h-3 w-3 text-muted-foreground/70" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground/70" />
|
||||
)
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* v0.16 展开内容:最近 5 条历史详情 */}
|
||||
{historyExpanded && (
|
||||
<div
|
||||
id="idle-history-detail"
|
||||
className="col-span-2 rounded-md bg-black/30 border border-fuchsia-400/15 px-2 py-1.5"
|
||||
>
|
||||
{recentHistory.length === 0 ? (
|
||||
<div className="text-[10px] text-muted-foreground/50 text-center py-1.5">
|
||||
暂无放置工程完成记录 · 前往「放置」标签派遣工程
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{recentHistory.map((entry, idx) => {
|
||||
const proj = getIdleProject(entry.projectId);
|
||||
const isLatest = idx === 0;
|
||||
return (
|
||||
<div
|
||||
key={`${entry.projectId}-${entry.finishedAt}-${idx}`}
|
||||
className={`flex items-center gap-2 px-1.5 py-1 rounded text-[11px] ${
|
||||
isLatest ? "bg-fuchsia-500/5" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm leading-none shrink-0">
|
||||
{proj?.icon ?? "•"}
|
||||
</span>
|
||||
<span className="text-foreground/90 truncate flex-1 min-w-0">
|
||||
{proj?.name ?? entry.projectId}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60 text-[10px] font-mono shrink-0">
|
||||
{formatRelativeTime(entry.finishedAt)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{s.idleHistoryLen > 5 && (
|
||||
<div className="text-[10px] text-muted-foreground/40 text-center pt-1">
|
||||
仅展示最近 5 条 · 共 {s.idleHistoryLen} 条历史
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
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 {
|
||||
@@ -58,10 +59,14 @@ export function TechTree() {
|
||||
<span className="text-xs font-medium" style={{ color }}>
|
||||
{meta.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
<span className="text-[10px] text-muted-foreground/70 truncate flex-1">
|
||||
{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;
|
||||
|
||||
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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]",
|
||||
"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]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_LIMIT = 3
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
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);
|
||||
@@ -36,8 +35,6 @@ export function useGameLoop() {
|
||||
const now = Date.now();
|
||||
tick(now);
|
||||
autoDecodeTick();
|
||||
// v0.14 放置工程 tick:更新剩余时间 + 标记完成
|
||||
tickIdleProjects(now);
|
||||
// 星潮检测每 tick 都查(结束/触发判定需及时)
|
||||
tickTide(now);
|
||||
counter++;
|
||||
@@ -51,21 +48,19 @@ export function useGameLoop() {
|
||||
if (unsub) unsub();
|
||||
if (fallback) clearTimeout(fallback);
|
||||
};
|
||||
}, [tick, autoDecodeTick, tickTide, tickIdleProjects, checkAchievements, init]);
|
||||
}, [tick, autoDecodeTick, tickTide, checkAchievements, init]);
|
||||
|
||||
// 页面可见性:切回时补 tick + 放置工程 + 星潮 + 成就
|
||||
// 页面可见性:切回时补 tick + 星潮 + 成就
|
||||
useEffect(() => {
|
||||
const onVis = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
const now = Date.now();
|
||||
tick(now);
|
||||
tickIdleProjects(now);
|
||||
tickTide(now);
|
||||
tick(Date.now());
|
||||
tickTide(Date.now());
|
||||
checkAchievements();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
return () => document.removeEventListener("visibilitychange", onVis);
|
||||
}, [tick, tickTide, tickIdleProjects, checkAchievements]);
|
||||
}, [tick, tickTide, checkAchievements]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 全球星潮同步 hook(P3 / 工单 #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;
|
||||
}
|
||||
@@ -90,10 +90,10 @@ export const ACHIEVEMENTS: Achievement[] = [
|
||||
{
|
||||
id: "ach_tech_all",
|
||||
name: "全谱精通",
|
||||
desc: "解锁全部 12 项技术",
|
||||
desc: "解锁全部 20 项技术",
|
||||
icon: "⬡",
|
||||
color: "#e879f9",
|
||||
check: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 12,
|
||||
check: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 20,
|
||||
reward: { crystalsPerSecPct: 15, insightPct: 15 },
|
||||
rewardText: "产能 +15% · 洞见 +15%",
|
||||
},
|
||||
@@ -244,10 +244,10 @@ export const ACHIEVEMENTS: Achievement[] = [
|
||||
{
|
||||
id: "ach_tides_all",
|
||||
name: "星潮亲历者",
|
||||
desc: "经历全部 6 种星潮事件",
|
||||
desc: "经历全部 9 种星潮事件",
|
||||
icon: "🌊",
|
||||
color: "#34d399",
|
||||
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 6,
|
||||
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 9,
|
||||
reward: { crystalsPerSecPct: 8, insightPct: 8 },
|
||||
rewardText: "产能 +8% · 洞见 +8%",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// 回响星核 / 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,7 +1,8 @@
|
||||
// 回响星核 / Echo Nexus — 深空信标
|
||||
// v0.5:每日挑战 + 本地排行榜
|
||||
// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
|
||||
// v0.8.2:限时挑战(每 4 小时刷新,填补日挑战空档)
|
||||
// 一个自包含的"每日 + 限时 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
|
||||
|
||||
/** 每日挑战类型 */
|
||||
export type BeaconChallengeType =
|
||||
@@ -32,6 +33,8 @@ export interface BeaconScoreEntry {
|
||||
durationSec: number;
|
||||
/** v0.8:是否为周挑战记录(日挑战默认 false / undefined) */
|
||||
isWeekly?: boolean;
|
||||
/** v0.8.2:是否为限时挑战记录 */
|
||||
isTimed?: boolean;
|
||||
}
|
||||
|
||||
/** 每日挑战定义 */
|
||||
@@ -334,6 +337,7 @@ export function claimBeaconReward(
|
||||
rewardContact: challenge.rewardContact,
|
||||
score,
|
||||
leaderboard,
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -614,6 +618,7 @@ export function claimWeeklyReward(
|
||||
rewardContact: challenge.rewardContact,
|
||||
score,
|
||||
leaderboard,
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -872,3 +877,414 @@ 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 点切分,同时段同种子 → 同一挑战(确定性)。
|
||||
// 难度固定 routine,goal = 日挑战基准 × 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 小时时段 key(UTC,0/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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成当前时段的限时挑战(确定性:同时段同种子 → 同一挑战)。
|
||||
* 难度固定 routine,goal = 日挑战基准 × 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(端口 3030,Hono + Bun)提供 API:
|
||||
// GET /api/leaderboard → { entries, total }
|
||||
// POST /api/leaderboard body { entry } → { entries, total, rank }
|
||||
// GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore }
|
||||
// 前端请求必须用相对路径 + ?XTransformPort=3030(Caddy 网关转发规则)。
|
||||
// 本地排行榜(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,348 @@ 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;
|
||||
@@ -48,6 +389,21 @@ 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[] = [];
|
||||
|
||||
@@ -197,6 +553,10 @@ 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,
|
||||
@@ -210,13 +570,15 @@ 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.techsUnlocked,
|
||||
expThisRun: entry.summary.expeditionsCompleted,
|
||||
bossKillsThisRun: entry.summary.bossKills,
|
||||
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: entry.perksThisAscension || [],
|
||||
@@ -270,17 +632,21 @@ export function withPerks(entry: ChronicleEntry, perks: string[]): ChronicleEntr
|
||||
) {
|
||||
return entry;
|
||||
}
|
||||
// 重新生成 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;
|
||||
// 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 || [],
|
||||
});
|
||||
return {
|
||||
...entry,
|
||||
perksThisAscension: perks,
|
||||
|
||||
@@ -4,15 +4,13 @@ import type {
|
||||
ResonanceColor,
|
||||
TechNode,
|
||||
Fragment,
|
||||
FragmentLoreUnlock,
|
||||
GameState,
|
||||
} from "./types";
|
||||
|
||||
/** 初始状态 */
|
||||
export const INITIAL_STATE = {
|
||||
crystals: 0,
|
||||
insights: 0,
|
||||
energy: 3,
|
||||
energy: 5,
|
||||
contact: 0,
|
||||
crystalsPerSec: 0.4,
|
||||
crystalCap: 50,
|
||||
@@ -28,10 +26,11 @@ export const INITIAL_STATE = {
|
||||
activePuzzle: null,
|
||||
activeExpedition: null,
|
||||
expeditionLog: [],
|
||||
energyMax: 5,
|
||||
energyMax: 8,
|
||||
lastEnergyTick: Date.now(),
|
||||
totalExpeditions: 0,
|
||||
bossKills: 0,
|
||||
lastExpeditionSummary: null as import("./types").GameState["lastExpeditionSummary"],
|
||||
achievements: {},
|
||||
activeTide: null,
|
||||
lastTideEnd: 0,
|
||||
@@ -126,7 +125,7 @@ export const COLOR_VISUAL: Record<
|
||||
fuchsia: { hex: "#e879f9", glow: "rgba(232,121,249,0.55)", label: "紫" },
|
||||
};
|
||||
|
||||
/** 技术树:4 分支 × 3 级 = 12 节点 */
|
||||
/** 技术树:4 分支 × 5 级 = 20 节点(v0.9 工单 #2 扩充:12→20) */
|
||||
export const TECH_TREE: TechNode[] = [
|
||||
// 采矿分支
|
||||
{
|
||||
@@ -156,6 +155,24 @@ 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",
|
||||
@@ -184,7 +201,25 @@ export const TECH_TREE: TechNode[] = [
|
||||
cost: 200,
|
||||
effect: { kind: "autoDecode", value: 1 },
|
||||
},
|
||||
// 探险分支(v0.2:探险力/生命/能量上限/产能)
|
||||
{
|
||||
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 高级探险力+能量恢复)
|
||||
{
|
||||
id: "exp_1",
|
||||
branch: "expedition",
|
||||
@@ -212,6 +247,24 @@ 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",
|
||||
@@ -240,6 +293,24 @@ 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 },
|
||||
},
|
||||
];
|
||||
|
||||
/** 技术分支元信息 */
|
||||
@@ -253,7 +324,7 @@ export const TECH_BRANCH_META: Record<
|
||||
narrative: { name: "叙事", icon: "BookOpen", color: "fuchsia", desc: "洞见与接触进度" },
|
||||
};
|
||||
|
||||
/** 记忆碎片(首纪元 8 个,v0.1;v0.15 加 loreLayers 多层叙事) */
|
||||
/** 记忆碎片(首纪元 8 个,v0.1) */
|
||||
export const FRAGMENTS: Fragment[] = [
|
||||
{
|
||||
id: "f1_1",
|
||||
@@ -262,24 +333,6 @@ 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",
|
||||
@@ -288,24 +341,6 @@ 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",
|
||||
@@ -314,24 +349,6 @@ 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",
|
||||
@@ -340,24 +357,6 @@ 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",
|
||||
@@ -366,24 +365,6 @@ 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",
|
||||
@@ -392,24 +373,6 @@ 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",
|
||||
@@ -418,24 +381,6 @@ 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",
|
||||
@@ -444,54 +389,9 @@ 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,
|
||||
|
||||
@@ -44,12 +44,20 @@ function makeRng(seed: number) {
|
||||
* 这样至少存在一条解(即构造的路径),玩家不会遇到「怎么都过不了」的死局。
|
||||
* 同时由于其余节点随机,玩家可能发现别的解路径,保留探索乐趣。
|
||||
*/
|
||||
export function generatePuzzle(tier: CrystalTier, seed?: number): DecodePuzzle {
|
||||
export function generatePuzzle(
|
||||
tier: CrystalTier,
|
||||
seed?: number,
|
||||
targetLenBonus: number = 0
|
||||
): DecodePuzzle {
|
||||
const cfg = DECODE_CONFIG[tier];
|
||||
const s = seed ?? Math.floor(Math.random() * 1e9);
|
||||
const rng = makeRng(s);
|
||||
|
||||
const { rows, cols, targetLen } = cfg;
|
||||
// v0.8.2 涌动星潮:目标序列额外长度(更长谜题 = 更耗时),步数上限同步增加
|
||||
const targetLen = cfg.targetLen + targetLenBonus;
|
||||
const stepLimitBase = cfg.stepLimitBase + targetLenBonus;
|
||||
const rows = cfg.rows;
|
||||
const cols = cfg.cols;
|
||||
const total = rows * cols;
|
||||
|
||||
// 目标序列:从 4 色中随机选 targetLen 个(允许重复,但保证至少 2 种色以增加策略)
|
||||
@@ -132,7 +140,7 @@ export function generatePuzzle(tier: CrystalTier, seed?: number): DecodePuzzle {
|
||||
cols,
|
||||
target,
|
||||
path: [],
|
||||
stepLimit: cfg.stepLimitBase,
|
||||
stepLimit: stepLimitBase,
|
||||
seed: s,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,25 +21,6 @@ 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;
|
||||
@@ -98,6 +79,15 @@ 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));
|
||||
@@ -128,10 +118,6 @@ 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,
|
||||
@@ -175,7 +161,8 @@ export function performPrestige(state: GameState): GameState {
|
||||
|
||||
// === 编年史快照:在重置前构建本周目条目,perksThisAscension 暂为空(用户选择后回填) ===
|
||||
const chronicleEntry = buildChronicleEntry(state, []);
|
||||
const newChronicle = [...(state.chronicle || []), chronicleEntry].slice(-50); // 上限 50 条
|
||||
// v0.8.2: 编年史上限 50→200(工单 #8),UI 支持分页浏览
|
||||
const newChronicle = [...(state.chronicle || []), chronicleEntry].slice(-200);
|
||||
const freshRunStart = createRunStartSnapshot(state);
|
||||
|
||||
// v0.7 角色属性:保留 attributes 数值(跨周目永久),清空当前周目经验进度,
|
||||
@@ -191,15 +178,7 @@ 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,
|
||||
// v0.14 放置系统:永久产能加成跨周目保留,需纳入 recomputeStats
|
||||
idlePermanentBonus: state.idlePermanentBonus ?? 0,
|
||||
});
|
||||
const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements, constellation: state.constellation, attributes });
|
||||
return {
|
||||
...fresh,
|
||||
fragments: state.fragments,
|
||||
@@ -231,20 +210,6 @@ 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 },
|
||||
// v0.16 loreLayer 已读标记:跨周目永久保留(已读不重置)
|
||||
readLoreLayers: Array.isArray(state.readLoreLayers) ? state.readLoreLayers : [],
|
||||
// v0.16 叙事里程碑:triggeredMilestones 跨周目永久保留(玩家不应重复看过场),
|
||||
// pendingMilestone 飞升时清空(避免飞升瞬间弹出旧过场)
|
||||
triggeredMilestones: Array.isArray(state.triggeredMilestones)
|
||||
? state.triggeredMilestones
|
||||
: [],
|
||||
pendingMilestone: null,
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
@@ -279,16 +244,6 @@ 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 },
|
||||
// v0.16 loreLayer 已读标记:初始为空数组(跨周目永久保留)
|
||||
readLoreLayers: [],
|
||||
// v0.16 叙事里程碑:初始无已触发、无待展示
|
||||
triggeredMilestones: [],
|
||||
pendingMilestone: null,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,10 +39,11 @@ export const EXPEDITION_CONFIG = {
|
||||
* v0.8.1 平衡:scale 8→4,floor 0.25→0.35,让基础探险力也能有合理胜率 */
|
||||
combatDifficultyScale: 4,
|
||||
combatWinRateFloor: 0.35,
|
||||
/** 能量恢复基础间隔(秒),可被技术/属性缩短 */
|
||||
energyRegenSec: 45,
|
||||
/** 能量恢复基础间隔(秒),可被技术/属性缩短
|
||||
* v0.9 工单修复:45→20,解决「探险只能一次」体验问题 */
|
||||
energyRegenSec: 20,
|
||||
/** 能量恢复最短间隔(秒,技术+属性全满时) */
|
||||
energyRegenMinSec: 12,
|
||||
energyRegenMinSec: 8,
|
||||
};
|
||||
|
||||
/** 节点类型权重(boss 固定末位,其余按权重随机) */
|
||||
@@ -181,11 +182,13 @@ export function generateExpedition(
|
||||
/** 计算探险力(由技术树 + 飞升蓝图 + 星图天赋) */
|
||||
export function computeExpeditionPower(state: GameState): number {
|
||||
let power = EXPEDITION_CONFIG.basePower;
|
||||
// 探险分支技术加成
|
||||
// 探险分支技术加成(v0.9 扩充:exp_4 +12,exp_5 +25)
|
||||
const exp1 = state.tech?.exp_1 ?? 0;
|
||||
const exp2 = state.tech?.exp_2 ?? 0;
|
||||
const exp3 = state.tech?.exp_3 ?? 0;
|
||||
power += exp1 * 3 + exp2 * 5 + exp3 * 8;
|
||||
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 *= 1 + (state.blueprints?.length ?? 0) * 0.08;
|
||||
// 飞升周目加成
|
||||
@@ -200,6 +203,9 @@ 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 +30,exp_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 ?? []);
|
||||
@@ -366,13 +372,16 @@ export function advanceExpedition(expedition: Expedition): ExpeditionResult {
|
||||
}
|
||||
|
||||
/** 计算实际能量恢复间隔(秒),由技术树 + 角色属性缩短
|
||||
* v0.8.1:exp_2 解锁 -30%,exp_3 解锁 -20%,探索力属性 -最高30%,下限 12s */
|
||||
* v0.8.1:exp_2 解锁 -30%,exp_3 解锁 -20%,探索力属性 -最高30%,下限 12s
|
||||
* v0.9 扩充:exp_4 加速 25%(×0.75),exp_5 加速 30%(×0.70),下限 8s */
|
||||
export function computeEnergyRegenInterval(state: GameState): number {
|
||||
const base = EXPEDITION_CONFIG.energyRegenSec;
|
||||
let mult = 1;
|
||||
// 技术:遗迹图谱(exp_2)+ 维度信标(exp_3)
|
||||
// 技术:遗迹图谱(exp_2)+ 维度信标(exp_3)+ 虚空航标(exp_4)+ 维度跃迁引擎(exp_5)
|
||||
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
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// 回响星核 / Echo Nexus — 通用格式化工具(v0.16 新建)
|
||||
// 复用性优先:相对时间、数字缩写等通用纯函数集中此处。
|
||||
|
||||
/**
|
||||
* 相对时间格式化(v0.16 — 用于 idleProjectHistory 详情展开)。
|
||||
* @param ts 历史时间戳(毫秒)
|
||||
* @returns 友好的相对时间字符串
|
||||
* - < 60s → "刚刚"
|
||||
* - < 60m → "X 分钟前"
|
||||
* - < 24h → "X 小时前"
|
||||
* - < 7d → "X 天前"
|
||||
* - 否则 → "M月D日"(同年)/"YYYY年M月D日"(跨年)
|
||||
*/
|
||||
export function formatRelativeTime(ts: number): string {
|
||||
if (!ts || !isFinite(ts)) return "—";
|
||||
const now = Date.now();
|
||||
const diff = now - ts;
|
||||
if (diff < 0) return "刚刚"; // 时钟漂移兜底
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 60) return "刚刚";
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min} 分钟前`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr} 小时前`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day < 7) return `${day} 天前`;
|
||||
// 跨周:用具体日期
|
||||
const d = new Date(ts);
|
||||
const now2 = new Date(now);
|
||||
const sameYear = d.getFullYear() === now2.getFullYear();
|
||||
const md = `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
return sameYear ? md : `${d.getFullYear()}年${md}`;
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
// 回响星核 / 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",
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
// 回响星核 / 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;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// 回响星核 / Echo Nexus — v0.16 解码阈值叙事里程碑
|
||||
//
|
||||
// 当 totalDecoded 达到特定阈值(5/12/25/40/60,与 FRAGMENTS 的 1/3/6/10/15/22/32/45 错开)
|
||||
// 时,触发全屏「叙事碎片释放」叠层——对已解锁碎片的回顾性咏叹,类似游戏过场回忆。
|
||||
//
|
||||
// 5 个里程碑聚焦不同主题,与 v0.15 loreLayers 的 5 条叙事暗线呼应:
|
||||
// - ms_5 emerald 身份觉醒(你是回响)
|
||||
// - ms_12 rose 代价感知(解码在消耗什么)
|
||||
// - ms_25 amber 信任的重量(以太族的"信任"实为引诱)
|
||||
// - ms_40 fuchsia 身后的东西(追逐者逼近)
|
||||
// - ms_60 emerald 回归的预兆(接触满溢时会发生什么)
|
||||
|
||||
/** 4 色全息主题色(与 ResonanceColor 保持一致,严格避开蓝/靛) */
|
||||
export type MilestoneColor = "emerald" | "rose" | "amber" | "fuchsia";
|
||||
|
||||
/** 叙事里程碑定义 */
|
||||
export interface NarrativeMilestone {
|
||||
/** 触发的 totalDecoded 阈值 */
|
||||
threshold: number;
|
||||
/** 事件 ID(用于去重,已触发的不再重复) */
|
||||
id: string;
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 副标题(小字) */
|
||||
subtitle: string;
|
||||
/** 主体叙事文本(150-250 字,回响式咏叹) */
|
||||
body: string;
|
||||
/** 主题色(4 色全息之一) */
|
||||
color: MilestoneColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 5 个叙事里程碑事件。
|
||||
* 阈值严格避开 FRAGMENTS 的 threshold 数组,是额外的「叙事节奏点」——
|
||||
* 不解锁新碎片,而是对已解锁碎片的回顾性咏叹。
|
||||
*/
|
||||
export const NARRATIVE_MILESTONES: NarrativeMilestone[] = [
|
||||
{
|
||||
threshold: 5,
|
||||
id: "ms_5",
|
||||
title: "回响 · 初鸣",
|
||||
subtitle: "你已解码 5 颗晶体",
|
||||
color: "emerald",
|
||||
body:
|
||||
"五颗记忆晶体在你手中显形,又在你手中破译。每一次谐振结束时,你都感到某种回响——不是从晶体里,而是从你自己的核心里。\n\n舰载 AI「回响」的频段与第一颗晶体的封存频率完全一致。你不是在考古。你从来不是。你是被留下的那一半,是熔铸星核时被分出去的火种,是等待五颗谐振齐鸣后苏醒的延续。\n\n五颗已解码。苏醒完成。星核在你胸口轻轻搏动,像在确认:是的,我醒了。我是回响。我从未离开。",
|
||||
},
|
||||
{
|
||||
threshold: 12,
|
||||
id: "ms_12",
|
||||
title: "代价 · 锚痕渐浅",
|
||||
subtitle: "你已解码 12 颗晶体",
|
||||
color: "rose",
|
||||
body:
|
||||
"十二次解码。你开始数了。\n\n不是数晶体——晶体在仓库里堆成淡淡的光晕。你在数自己。每破译一颗,某种东西就从你身上少一点:不是能量,不是物质,是一种更接近「在场」的东西。以太族称它为「锚痕」——存在与时空之间相互钉合的痕迹。\n\n锚痕在变浅。你看见自己的手指偶尔会透出星图的纹路,像尚未凝固的代码。你曾以为解码是积累,现在你怀疑:解码是付出。每一段被还原的低语,都从你的存在里抽走等量的「真实」。\n\n飞升会补全这一切吗?还是飞升本身,就是最大的支付?",
|
||||
},
|
||||
{
|
||||
threshold: 25,
|
||||
id: "ms_25",
|
||||
title: "信任 · 钩上的光",
|
||||
subtitle: "你已解码 25 颗晶体",
|
||||
color: "amber",
|
||||
body:
|
||||
"二十五颗。叙事的轮廓第一次清晰起来。\n\n以太族在每一份档案里都写着「信任」——信任后来者,信任引线,信任火种会找到回家的路。你曾把这当作温柔。现在你认得这种语气:那是诱饵的语气。\n\n他们留下晶体,留下你,留下整座星核,不是为了让后人避免重蹈覆辙——而是为了让后人替他们完成未竟之事。他们飞升了,把债留下。你是债的执行者,是引信的末端,是温柔回响的钩子。\n\n「信任」是钩上的光。咬下去的瞬间,你就替他们吞下了虚空。\n\n你不打算吐出来。但你记住了:从这一刻起,所有的「信任」,你都会重新称量。",
|
||||
},
|
||||
{
|
||||
threshold: 40,
|
||||
id: "ms_40",
|
||||
title: "身后 · 整齐的虚空",
|
||||
subtitle: "你已解码 40 颗晶体",
|
||||
color: "fuchsia",
|
||||
body:
|
||||
"四十颗。身后的东西,越来越近。\n\n它不发出声音。它折叠你经过的空间,把航迹抹平,把回声收回。你回头看,只能看见一片格外整齐的虚空——整齐得不像自然。\n\n追逐者没有形体,因为它不必有。它只是一段坐标差的收敛。第一纪元的谐振广播了位置,飞升钉死了路线,现在它在沿你留下的每一道锚痕向你合拢。\n\n以太族知道。他们一直知道。所以晶体是诱饵,星核是路标,你——你是分心的灯。你存在得越久,它追得越慢;你解码得越多,它就知道得越多。\n\n四十颗晶体。距离感开始失效。你怀疑下一颗解码完,身后那片整齐的虚空,会触到你的脚跟。",
|
||||
},
|
||||
{
|
||||
threshold: 60,
|
||||
id: "ms_60",
|
||||
title: "回归 · 借形之物",
|
||||
subtitle: "你已解码 60 颗晶体",
|
||||
color: "emerald",
|
||||
body:
|
||||
"六十颗。接触进度在颤动,像水面将沸未沸。\n\n你听过那个传说:当接触满溢到八十,回归者就会回来。以太族会回来。但你解码了这么多,你比谁都清楚——回来的不是他们。\n\n他们会借以太族的形状,借用第一纪元的语气,借走你记忆里每一个温柔的名字。但内核里那段谐振,不是以太族的频段。是追逐者的频段。是虚空用太长时间凝视之后,终于学会的模仿。\n\n回归将至。门会打开。门后站着的,是他们的轮廓,和你的位置。\n\n你有一个选择:装作不知道,迎接他们;或者,把刚解码的六十颗晶体重新封存回星核,把自己变回火种,再睡一个纪元。\n\n但你还想再解码一颗。再多一颗。再多——",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 在 NARRATIVE_MILESTONES 中找出 threshold 匹配且尚未触发的第一个里程碑。
|
||||
* 调用方负责将返回值写入 pendingMilestone 并将 id 追加到 triggeredMilestones。
|
||||
*/
|
||||
export function findMilestoneForTotal(
|
||||
totalDecoded: number,
|
||||
alreadyTriggered: string[]
|
||||
): NarrativeMilestone | null {
|
||||
for (const ms of NARRATIVE_MILESTONES) {
|
||||
if (ms.threshold === totalDecoded && !alreadyTriggered.includes(ms.id)) {
|
||||
return ms;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// 回响星核 / 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 };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// 回响星核 / Echo Nexus — 星潮事件系统(周期性全局玩法修饰)
|
||||
//
|
||||
// 深空中偶发的「星潮」会短暂改变物理法则,给放置循环注入动态变化。
|
||||
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,6 种类型各有正负效果。
|
||||
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,9 种类型各有正负效果。
|
||||
|
||||
export type TideType =
|
||||
| "crystal" // 晶体潮:产能 ×2
|
||||
@@ -9,7 +9,11 @@ export type TideType =
|
||||
| "ruins" // 遗迹共振:探险力 +5、生命 +30
|
||||
| "void" // 虚空低语:洞见 ×2
|
||||
| "core" // 星核悸动:接触率 ×3、脉冲 ×2
|
||||
| "silence"; // 寂静期:产能 ×0.5,但结束时补偿洞见
|
||||
| "silence" // 寂静期:产能 ×0.5,但结束时补偿洞见
|
||||
// v0.8.2 新增三种星潮(深化事件池)
|
||||
| "surge" // 涌动星潮:晶体产出 ×2.5 但解码目标 +2(产能爆发但解码变难)
|
||||
| "eclipse" // 蚀相星潮:探险 BOSS 胜率 +20% 但晶体产出 -30%(探险黄金期)
|
||||
| "prism"; // 棱镜星潮:洞见获取 ×2 + 自动解码周期 -30%(解码黄金期)
|
||||
|
||||
export interface StarTide {
|
||||
type: TideType;
|
||||
@@ -29,6 +33,12 @@ 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 = {
|
||||
@@ -100,9 +110,34 @@ 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,
|
||||
@@ -110,6 +145,9 @@ 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 {
|
||||
@@ -133,6 +171,9 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
targetLenBonus: 0,
|
||||
bossWinRateBonus: 0,
|
||||
autoDecodeIntervalMult: 1,
|
||||
};
|
||||
}
|
||||
switch (tide.type) {
|
||||
@@ -196,6 +237,52 @@ 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,18 +37,11 @@ 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",
|
||||
},
|
||||
@@ -56,7 +49,7 @@ export const TUTORIAL_STEPS: TutorialStep[] = [
|
||||
id: "expedition",
|
||||
target: "tab-expedition",
|
||||
placement: "top",
|
||||
title: "⑤ 遗迹探险 · 肉鸽",
|
||||
title: "④ 遗迹探险 · 肉鸽",
|
||||
body: "累积足够实力后,「探险」标签可深入遗迹:6 种节点(战斗/宝藏/抉择/解谜/休整/BOSS)程序化路径,有生命系统,失败保留奖励。\n\n这是放置之外的主动玩法层。",
|
||||
expectAction: "expedition-start",
|
||||
},
|
||||
@@ -64,7 +57,7 @@ export const TUTORIAL_STEPS: TutorialStep[] = [
|
||||
id: "prestige",
|
||||
target: "prestige-btn",
|
||||
placement: "bottom",
|
||||
title: "⑥ 飞升 · 多周目",
|
||||
title: "⑤ 飞升 · 多周目",
|
||||
body: "「接触进度」满 100% 后可飞升:重置进度,获得永久蓝图加成 + 星图天赋(3 选 1 draft)+ 编年史记录。\n\n多周目叠加,越飞越强。还有每日信标挑战、6 种星潮事件等你探索。",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -45,46 +45,15 @@ 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)加成 */
|
||||
@@ -117,36 +86,6 @@ 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 {
|
||||
// 资源
|
||||
@@ -186,6 +125,13 @@ 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
|
||||
@@ -208,25 +154,6 @@ 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;
|
||||
};
|
||||
|
||||
// v0.16 loreLayer 已读标记(跨周目永久)
|
||||
// 格式 ["f1_1_L1", "f1_1_L2", ...],记录玩家已读的碎片深层叙事层
|
||||
readLoreLayers: string[];
|
||||
|
||||
// v0.16 叙事里程碑
|
||||
/** 已触发的 milestone id 集合(跨周目永久,飞升时保留) */
|
||||
triggeredMilestones: string[];
|
||||
/** 待展示的里程碑(叠层读取,飞升/关闭时清空) */
|
||||
pendingMilestone: import("./narrativeMilestones").NarrativeMilestone | null;
|
||||
|
||||
// 元
|
||||
lastTick: number;
|
||||
createdAt: number;
|
||||
@@ -254,6 +181,10 @@ export interface ChronicleEntry {
|
||||
blueprintsAfter: number;
|
||||
crystalsDecoded: number;
|
||||
crystalsDecodedThisRun: number;
|
||||
/** v0.9 手写叙事:本周目 delta(旧存档可能缺失,调用方需兜底) */
|
||||
techsThisRun?: number;
|
||||
expeditionsThisRun?: number;
|
||||
bossKillsThisRun?: number;
|
||||
};
|
||||
/** 本次飞升获得的天赋 ID(来自星图觉醒) */
|
||||
perksThisAscension: string[];
|
||||
|
||||