Compare commits

..
7 Commits
Author SHA1 Message Date
Super_Z 5bed2599f5 v0.16: 解码阈值叙事事件 + loreLayer 已读标记 + idleHistory 详情 + 移动端适配
- 5 个解码阈值叙事里程碑(ms_5/12/25/40/60)+ 全屏碎片释放叠层
- loreLayer 已读标记(readLoreLayers + 点击文本标记 + 已读/未读视觉区分)
- idleProjectHistory 详情展开(倒序最近 5 条 + 相对时间)
- 新建 format.ts formatRelativeTime 工具函数
- 移动端 375px 适配审计(4 组件字号 9→10px + flex-wrap + 响应式 padding)
- 严格 4 色全息,lint 零错误,agent-browser 端到端验证通过
2026-06-24 23:45:34 +00:00
Super_Z 174d1227c0 68f373b9-921a-4acf-844c-ab7e89ffb727 2026-06-24 22:50:18 +00:00
Z.ai Code 774ddaf9d1 docs: v0.15 部署记录 + main 分叉说明 2026-06-24 22:47:11 +00:00
Super_Z 22588425cf 48c8e466-e804-444f-9f6d-4a2d34b3379a 2026-06-24 12:34:05 +00:00
Super_Z 96748d726f v0.15: 叙事深化 + 放置平衡 + UX 增强
- loreLayers 多层碎片文本(8 碎片 × 2 层 = 16 层深层叙事,5 条暗线交织)
- 永久加成 softcap(≤30 全额 / 30-100 ×0.5 / >100 ×0.25,防滚雪球)
- 批量领取全部工程 + 工程完成 toast 通知
- StatsPanel 独立组件 + idle 统计行(永久加成/完成工程/产出晶体/历史)
- Codex 分层渲染(L0 emerald 表层 / L1 amber 隐秘注脚 / L2 fuchsia 深层回响)
- 修复 v0.14 双重计算 bug(idleTotalPerSec)
- 严格 4 色全息,lint 零错误,agent-browser 端到端验证通过
2026-06-24 12:32:56 +00:00
Super_Z 20dc1f252f docs: v0.14 README + 放置系统设计文档 + worklog 更新 2026-06-24 10:23:08 +00:00
Super_Z 17820b1f28 v0.14: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章 2026-06-24 10:19:40 +00:00
85 changed files with 4995 additions and 5319 deletions
+1 -1
View File
@@ -1 +1 @@
12512 1061
+92 -25
View File
@@ -1,9 +1,11 @@
# 回响星核 / Echo Nexus # 回响星核 / Echo Nexus
> 原创深空考古放置策略游戏 · Original deep-space archaeology idle strategy game > 原创深空考古 **放置策略**游戏 · Original deep-space archaeology **idle strategy** game
你是一名深空考古指挥官,驾驶自治勘探舰回收已「飞升」的以太文明遗留的记忆晶体,解码碎片拼凑出他们的故事,并最终跨越维度发起「接触」。 你是一名深空考古指挥官,驾驶自治勘探舰回收已「飞升」的以太文明遗留的记忆晶体,解码碎片拼凑出他们的故事,并最终跨越维度发起「接触」。
**关掉浏览器,星核仍在为你工作**——采矿无人机舰队 24h 不间断采矿,放置工程在离线时持续推进,回来后一键领取积累的奖励。
--- ---
## 🎮 在线游玩 ## 🎮 在线游玩
@@ -16,7 +18,7 @@
## 项目状态 ## 项目状态
- **版本****v0.7** — CrystalOrb Canvas 粒子系统 + 四维角色属性 - **版本****v0.16** — 解码阈值叙事事件(全屏碎片释放叠层)+ loreLayer 已读标记 + idleHistory 详情展开 + 移动端 375px 适配审计
- **栈**Next.js 16 (App Router) · TypeScript · Tailwind CSS 4 · shadcn/ui · Canvas 2D · Zustand · Web Audio API - **栈**Next.js 16 (App Router) · TypeScript · Tailwind CSS 4 · shadcn/ui · Canvas 2D · Zustand · Web Audio API
- **仓库**[https://git.atdunbg.xyz/Super_Z/echo-nexus](https://git.atdunbg.xyz/Super_Z/echo-nexus) - **仓库**[https://git.atdunbg.xyz/Super_Z/echo-nexus](https://git.atdunbg.xyz/Super_Z/echo-nexus)
- **在线版**[https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/](https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/) - **在线版**[https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/](https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/)
@@ -26,29 +28,60 @@
## 核心特色 ## 核心特色
- **解码共振谜题**:把放置游戏的「等待」变成有节奏的主动小谜题(路径构造法生成器,100% 可解 - **📡 采矿无人机舰队**:每解锁一个采矿分支技术,就有一台自动采矿无人机加入舰队,24h 持续产出晶体(无需点击
- **碎片涌现叙事**:故事从解码中自然浮现,5 纪元循环命名 + 模板化 lore,不打断玩法 - **⚙️ 放置工程项目**:6 种工程,派遣后**离线/在线自动推进**,完成领取奖励(晶体/洞见/能量/永久产能加成/随机碎片)
- **深空全息美学**:晶体 + Canvas 粒子 + 共振波纹的差异化视觉(严格 emerald/rose/amber/fuchsia 四色全息色谱,零蓝色/靛色) - **🔓 离线收益**:关闭浏览器后产能按 `offlineEff × crystalsPerSec` 持续累积(上限 8h),重新打开弹窗结算「沉睡期间」收益
- **飞升多周目**:终局「接触」后保留蓝图开启新周目,星图天文台 18 个天赋跨周目永久生效 - **🎯 始终可见的放置徽章**:Header 永远显示「放置中 +X/s」徽章,悬停查看产能构成明细——一眼就知道这是个 idle game
- **实时巡航玩法**Canvas 2D 飞船操控 + 8 种实体 + 3 层视差星空,打破纯放置节奏 - **🔍 解码共振谜题**:把放置游戏的「等待」变成有节奏的主动小谜题(路径构造法生成器,100% 可解)
- **角色 RPG 深度**:四维属性(探索力/智慧/勇气/灵感)影响所有游戏系统 - **📜 碎片涌现叙事**:故事从解码中自然浮现,5 纪元循环命名 + 模板化 lore,不打断玩法
- **💎 深空全息美学**:晶体 + Canvas 粒子 + 共振波纹的差异化视觉(严格 emerald/rose/amber/fuchsia 四色全息色谱,零蓝色/靛色)
- **🔄 飞升多周目**:终局「接触」后保留蓝图开启新周目,星图天文台 18 个天赋跨周目永久生效
- **🚀 实时巡航玩法**Canvas 2D 飞船操控 + 8 种实体 + 3 层视差星空,打破纯放置节奏
- **🧬 角色 RPG 深度**:四维属性(探索力/智慧/勇气/灵感)影响所有游戏系统
--- ---
## 游戏系统(10 大系统) ## 游戏系统(11 大系统)
| # | 系统 | 简介 | | # | 系统 | 简介 | 类型 |
|---|------|------| |---|------|------|------|
| 1 | 放置采矿 | 中央晶体球 Canvas 粒子系统 + 主动脉冲 + 连击加成 | | 1 | **📡 放置采矿** | 中央晶体球 Canvas 粒子系统 + 主动脉冲 + 连击加成 + **自动采矿无人机舰队** | 放置 |
| 2 | 谐振解码 | 原创路径谜题,100% 可解率 | | 2 | **⚙️ 放置工程** | 6 种工程 × 3 槽位,**离线/在线自动推进**,完成领取奖励 | **放置** |
| 3 | 技术树 | 三层科技解锁,提升产能/解码/探险 | | 3 | **🔓 离线收益** | 关闭浏览器后产能按 `eff × cps` 累积(8h 上限),重新打开结算 | **放置** |
| 4 | 遗迹探险 | 肉鸽系统,6 种节点 + 程序化路径 + BOSS | | 4 | 谐振解码 | 原创路径谜题,100% 可解率 | 主动 |
| 5 | 星潮事件 | 6 种动态事件,全屏叠层 + 程序化音效 | | 5 | 技术树 | 三层科技解锁,提升产能/解码/探险 | 主动 |
| 6 | 星图天文台 | 飞升后 3 选 1 天赋 draft,18 个跨周目天赋 | | 6 | 遗迹探险 | 肉鸽系统,6 种节点 + 程序化路径 + BOSS | 主动 |
| 7 | 回响编年史 | 跨周目叙事时间轴,5 纪元循环命名 | | 7 | 星潮事件 | 6 种动态事件,全屏叠层 + 程序化音效 | 被动 |
| 8 | 深空信标 | 每日挑战 + 本地排行榜 Top20 | | 8 | 星图天文台 | 飞升后 3 选 1 天赋 draft,18 个跨周目天赋 | 元进程 |
| 9 | 深空巡航 | Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关 | | 9 | 回响编年史 | 跨周目叙事时间轴,5 纪元循环命名 | 元进程 |
| 10 | 角色属性 | 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点 | | 10 | 深空信标 | 每日挑战 + 本地排行榜 Top20 | 每日 |
| 11 | 深空巡航 | Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关 | 实时 |
| 12 | 角色属性 | 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点 | 元进程 |
---
## 放置系统设计哲学(v0.14 + v0.15 平衡)
回响星核的「放置」不是简单地把数字挂机增长,而是分层呈现 **3 种 idle 节奏**
### 节奏 1:被动产能(最慢,最持续)
- 采矿无人机舰队 = 你已解锁的采矿技术节点
- 每台无人机 24h 持续产出晶体,无需任何操作
- 晶体进入仓库(受 `crystalCap` 限制),仓库满后停止产出(防溢出)
- **永远在线,离线也累积**
### 节奏 2:放置工程(中等周期,需要决策)
- 6 种工程,时长 1min ~ 1h 不等
- 玩家选择派遣到 3 个槽位之一 → 启动后**离线/在线自动推进**
- 完成后必须**手动领取**(防玩家错过奖励时机)
- 奖励类型多样:晶体 / 洞见 / 能量 / 永久产能加成 / 随机碎片
- **核心 idle loop:派遣 → 离开 → 回来 → 领取 → 再派遣**
### 节奏 3:离线收益(最长周期,回归奖励)
- 关闭浏览器后,被动产能按 `offlineEff`(默认 50%,可通过技术提升至 100%)继续累积
- 上限 8 小时(防极端挂机)
- 重新打开页面弹窗结算:「你离开了 X 小时,星核为你产出 Y 颗晶体」
- **回归玩家的"啊我回来了"瞬间**
--- ---
@@ -67,9 +100,13 @@
| v0.5.1 | Gitea Pages 静态部署 + 新手教程 | ✅ | | v0.5.1 | Gitea Pages 静态部署 + 新手教程 | ✅ |
| v0.5.2 | 离线收益报告 + 标签页 UI 重设计 | ✅ | | v0.5.2 | 离线收益报告 + 标签页 UI 重设计 | ✅ |
| v0.6 | 深空巡航 Canvas 2D 实时玩法 | ✅ | | v0.6 | 深空巡航 Canvas 2D 实时玩法 | ✅ |
| **v0.7** | **CrystalOrb Canvas 粒子系统 + 四维角色属性** | ✅ | | v0.7 | CrystalOrb Canvas 粒子系统 + 四维角色属性 | ✅ |
| v0.8 | 巡航玩法增强 / BOSS 关卡 / 道具掉落 | 🔜 | | v0.8 | 巡航玩法增强 / BOSS 关卡 / 道具掉落 | |
| v0.9 | 云存档 + 云排行榜 | 🔜 | | v0.8.1 | 探险平衡 + 信标系统扩展(周挑战 + 信标链) | |
| **v0.14** | **放置系统:采矿无人机舰队 + 6 种放置工程 + 永久产能加成 + 始终可见 idle 徽章** | ✅ |
| **v0.15** | **叙事深化(8 碎片 × 2 层 = 16 层 loreLayers+ 永久加成 softcap + 批量领取 + 完成 toast + StatsPanel idle 统计** | ✅ |
| **v0.16** | **解码阈值叙事事件(5 里程碑全屏叠层)+ loreLayer 已读标记 + idleHistory 详情展开 + 移动端 375px 适配审计** | ✅ |
| v0.17 | 星潮三频段切换 + 健康设计(每日软上限 + 星核建议休眠) | 🔜 |
| v1.0 | 全 5 纪元手写叙事 + 多人同步星潮 | 🔜 | | v1.0 | 全 5 纪元手写叙事 + 多人同步星潮 | 🔜 |
--- ---
@@ -86,6 +123,9 @@
- [`docs/08-星图天文台系统-v0.3.1.md`](docs/08-星图天文台系统-v0.3.1.md) — v0.3.1 天文台 - [`docs/08-星图天文台系统-v0.3.1.md`](docs/08-星图天文台系统-v0.3.1.md) — v0.3.1 天文台
- [`docs/09-回响编年史系统-v0.4.md`](docs/09-回响编年史系统-v0.4.md) — v0.4 编年史 - [`docs/09-回响编年史系统-v0.4.md`](docs/09-回响编年史系统-v0.4.md) — v0.4 编年史
- [`docs/10-深空信标系统-v0.5.md`](docs/10-深空信标系统-v0.5.md) — v0.5 信标 - [`docs/10-深空信标系统-v0.5.md`](docs/10-深空信标系统-v0.5.md) — v0.5 信标
- [`docs/11-放置系统-v0.14.md`](docs/11-放置系统-v0.14.md) — **v0.14 放置系统(idle operations**
- [`docs/12-叙事深化-v0.15.md`](docs/12-叙事深化-v0.15.md) — **v0.15 loreLayers 多层碎片文本 + softcap + 批量领取**
- [`docs/13-叙事事件-v0.16.md`](docs/13-叙事事件-v0.16.md) — **v0.16 解码阈值里程碑叠层 + loreLayer 已读 + 移动端适配**
--- ---
@@ -118,7 +158,7 @@ BUILD_EXPORT=true bun run build:static
cd out cd out
git init && git checkout -b gh-pages git init && git checkout -b gh-pages
git add -A git add -A
git commit -m "deploy: v0.7" git commit -m "deploy: v0.16"
git remote add origin https://git.atdunbg.xyz/Super_Z/echo-nexus.git git remote add origin https://git.atdunbg.xyz/Super_Z/echo-nexus.git
git push -f origin gh-pages git push -f origin gh-pages
``` ```
@@ -127,4 +167,31 @@ Gitea Pages 会自动从 `gh-pages` 分支提供静态网站服务。
--- ---
## 灵感来源(Inspired by
- **Antimatter Dimensions** — 数值膨胀美学 + 终端 UI
- **Universal Paperclips** — 叙事驱动的放置游戏(Frank Lantz
- **A Dark Room** — 极简文字放置游戏(Michael Townsend
- **Melvor Idle** — 离线收益 + 多技能树
- **Outer Wilds** — 知识解锁式考古
- **Cookie Clicker** — Orteil 的放置游戏范式定义
---
## 健康游戏设计声明
放置游戏天然具有"低投入高回报"的成瘾性,可能对学业/工作压力大的青年造成时间碎片化风险。回响星核主动采取以下健康设计:
-**离线收益上限 8h**——防止 24h 挂机
-**工程完成需手动领取**——避免"打开就有奖励"的条件反射
-**无广告、无内购**——纯粹的单机体验
- 🔜 **v0.17 计划**:每日软上限 + "星核建议休眠"提醒(每 1 小时主动提示休息)
参考学术研究:
- Frank Lantz GDC 2018 "This Is Your Brain on Games"
- Reuters 2025 "Cozy video games can quell stress and anxiety"
- DOI:10.3389/fpsyg.2026.1774030 TikTok 时代学术倦怠研究
---
© Super_Z · 持续开发中 © Super_Z · 持续开发中
-91
View File
@@ -1,91 +0,0 @@
# Task ID: 10-a — 限时挑战 + 星潮类型深化
**Agent**: full-stack-developer
**Task**: 工单 #5 限时挑战 + 工单 #3 星潮类型深化
## Work Log
### 前置准备
- 阅读 `worklog.md` 了解 v0.8.1 项目状态(10 大系统,四色全息 emerald/rose/amber/fuchsia
- 精读 `beacon.ts`(873 行,日挑战+周挑战+信标链)、`BeaconPanel.tsx`753 行)、`starTide.ts`209 行,6 种星潮)、`gameStore.ts` trackBeacon~170 行)+ tickTide~440 行)
- 确认现有 UI 均动态读取 `TIDE_EVENTS[type]`,新增类型自动生效
### 工单 #5:限时挑战(Timed Challenge
**beacon.ts 扩展**873 → 1162 行,+289 行):
- `BeaconScoreEntry` 新增 `isTimed?: boolean` 字段
- `BeaconTimedChallenge` 接口:slotKey / type / difficulty(固定 routine) / goal / rewardInsight / rewardContact / seed / title / desc / expiresAt
- `BeaconTimedProgress` 接口:slotKey / progress / startedAt / completedAt / claimed / durationSec
- `TIMED_SLOT_MS = 4h``BEACON_TIMED_KEY = "echo-nexus-beacon-timed-v1"`
- `getTimedSlotKey(now)`UTC 0/4/8/12/16/20 点切分,格式 "timed_YYYY-MM-DD_HH"
- `timedSlotKeyToSeed`FNV-1a 哈希
- `generateTimedChallenge(now)`:确定性生成,难度 routinegoal = 日基准 ×0.3-0.55 种类型各有公式
- `msUntilNextTimedSlot(now)`:距离下个 4 小时时段的毫秒数
- `loadTimedProgress` / `saveTimedProgress` / `addTimedProgress` / `claimTimedReward`(推送排行榜,entry 加 isTimed: true
**gameStore.ts 集成**
- import 扩展:generateTimedChallenge / loadTimedProgress / addTimedProgress / claimTimedReward + 类型
- `trackBeacon` 返回值新增 `timedJustCompleted`:日/限时/周三类进度同时更新
- 新增 `claimTimedBeacon` actionGameActions 接口同步)
- 新增 `timedJustCompleted` 到 trackBeacon 返回类型
**BeaconPanel.tsx UI**753 → 944 行):
- import 扩展:generateTimedChallenge / loadTimedProgress / getTimedSlotKey / msUntilNextTimedSlot / Hourglass 图标
- 新增 timedChallenge/timedProgress/timedCountdown state
- 每秒刷新 timedProgress + timedCountdown
- 新增 handleClaimTimed 回调
- **限时挑战区块**(amber 主题,插入日挑战与周挑战之间):
- Hourglass 图标 + "限时挑战 · TIMED" 标题 + slotKey + 倒计时
- 难度/类型标签 + 标题 + 描述 + 进度条 + 奖励 + 领取按钮(emerald 主题)
- **紧急状态**:距时段结束 <30min 时切换 rose 脉冲动画 + "即将结束"徽章
- 2 个新动画:timed-glowamber 呼吸)/ timed-urgentrose 急促脉冲)
- 排行榜 entry 区分 TIMEDamber 徽章)/ WEEKfuchsia 徽章)
- 空状态提示更新为"完成限时、每日或本周信标即可登榜"
### 工单 #3:星潮类型深化
**starTide.ts 扩展**209 → 289 行):
- `TideType` 新增 3 种:surge / eclipse / prism
- `TideModifiers` 新增 3 个可选字段:targetLenBonus / bossWinRateBonus / autoDecodeIntervalMult
- `TIDE_EVENTS` 新增 3 种元信息(name/desc/color/glow/icon/negative):
- surge(涌动星潮,emerald #34d399,⇈):产能 ×2.5 但解码目标 +2
- eclipse(蚀相星潮,rose #fb7185,◐):BOSS 胜率 +20% 但产能 -30%
- prism(棱镜星潮,fuchsia #e879f9,◬):洞见 ×2 + 自动解码周期 -30%
- `TIDE_WEIGHTS` 新增 surge/eclipse/prism 各 12(总权重 136
- `getTideModifiers` 新增 3 个 case + null 分支补全新字段
- 注释更新:"6 种类型" → "9 种类型"
**decode.ts 扩展**
- `generatePuzzle` 新增第 3 参数 `targetLenBonus`(默认 0
- targetLen = cfg.targetLen + targetLenBonusstepLimitBase 同步增加
- 路径构造法自动适配更长目标序列(300 次尝试 + 线性兜底)
**gameStore.ts 集成**3 处修饰器应用):
- `startDecode`surge 星潮时传入 `tideMod.targetLenBonus` → 谜题更长
- `autoDecodeTick`prism 星潮时 `interval *= tideMod.autoDecodeIntervalMult`(×0.7
- `resolveCurrentNode`eclipse 星潮时 `b = am.bossWinRateBonus + tideMod.bossWinRateBonus`+20%
**achievements.ts**`ach_tides_all` 阈值 6 → 9,描述"经历全部 9 种星潮事件"
**page.tsx**StatsPanel "星潮亲历" 显示 `/ 9`
### QA 验证
1. **lint**`bun run lint` 零错误(exit 0
2. **dev 服务器**HTTP 200,编译 < 30ms
3. **BeaconPanel VLM 评分**
- 首屏 8.5/10:限时挑战 TIMED 区块可见、四色一致、倒计时/进度/标题/奖励/按钮清晰
- 最终截图 9/10:4 个挑战区块齐全、限时区块带倒计时、无蓝色
4. **新星潮 VLM 评分**localStorage 注入 + reload 触发):
- surgeemerald 绿):8/10,横幅 + 全屏绿色微光
- prismfuchsia 紫):7/10,横幅 + 全屏紫色微光
- eclipserose 粉):8/10(单图),横幅 + 全屏粉色微光
5. **不破坏现有功能**:日挑战/周挑战/信标链/6 旧星潮全部保留,排行榜正常
## Stage Summary
- ✅ 工单 #5 限时挑战完成:beacon.ts +289 行 + gameStore claimTimedBeacon + BeaconPanel amber 区块
- ✅ 工单 #3 星潮深化完成:starTide.ts +80 行(3 新类型)+ decode.ts targetLenBonus + gameStore 3 处修饰器
- ✅ 确定性生成:限时挑战同时段同种子同结果(FNV-1a slotKey 哈希)
- ✅ 严格四色规范:限时 amber、surge emerald、eclipse rose、prism fuchsia,零蓝色/靛色
- ✅ 紧急状态设计:限时挑战 <30min 时 rose 脉冲 + "即将结束"徽章
- ✅ lint 零错误 + dev HTTP 200 + VLM ≥7/10(信标 9/10、surge 8/10、prism 7/10、eclipse 8/10
- ✅ 现有日/周/信标链/6 旧星潮全部保留,无回归
-207
View File
@@ -1,207 +0,0 @@
# Task 10-b · full-stack-developer · P2/#4 云排行榜 mini-service
> 本文件为本 agent 在 Task 10-b 的工作记录,供后续 agent 查阅。
## 任务概述
为「回响星核 / Echo Nexus」v0.8.2 实现 P2/#4 工单:把信标本机排行榜(纯 localStorage)升级为云端 Top100,让玩家间可以比较分数。
## 阅读的前置工作
- `/home/z/my-project/worklog.md`(v0.8.1 项目状态,10 大系统,四色全息规范)
- `/home/z/my-project/src/lib/game/beacon.ts`(实际 1161 行 — worklog 信息滞后,v0.8.2 已加限时挑战 `claimTimedReward`
- `/home/z/my-project/src/components/game/BeaconPanel.tsx`(实际 953 行 — 已含限时挑战 amber 主题)
- `/home/z/my-project/src/store/gameStore.ts`1241 行 — trackBeacon + 3 个 claim actiongrantBeaconReward/claimWeeklyBeacon/claimTimedBeacon/claimChainReward
- `/home/z/my-project/Caddyfile`:81 网关,按 ?XTransformPort=* 转发到对应端口)
- `/home/z/my-project/examples/websocket/server.ts` + `frontend.tsx`mini-service + 前端连接参考)
- `/home/z/my-project/.zscripts/dev.sh`mini-service 自动扫描启动逻辑,发现 mini-services 目录下子服务自动 `bun run dev`
- `/home/z/my-project/agent-ctx/9-b-full-stack-developer.md`(前一个 agent 的信标系统扩展记录)
- `/home/z/my-project/mini-services/star-tide-service/`(已存在的 P3 #10-c 服务,端口 3031
## 实现细节
### 1. mini-service`mini-services/leaderboard-service/`
**目录结构**
```
mini-services/leaderboard-service/
├── package.json # 独立 bun 项目,type=module
├── index.ts # Hono 入口(182 行)
├── bun.lock # 自动生成
├── node_modules/ # hono 独立安装
└── README.md # API 文档
```
**package.json**
- `"dev": "bun --hot index.ts"`(规范要求的热重载脚本)
- `"start": "bun index.ts"`(无热重载,用于稳定运行)
- 依赖:hono ^4.6.14
**index.ts 设计要点**
- Hono + `cors()` 中间件,origin: * 全开放(前端跨端口必须)
- 内存数组 `entries: BeaconScoreEntry[]`,最多 1000 条,按 score 降序
- 同分排序:先看用时短,再看提交早
- 防刷:`lastSubmitAt: Map<dateKey|challenge, ts>`,10 秒内只接受 1 次,返回 429 + retryAfterMs
- 4 个路由:
- `GET /` → 健康检查 `{service, version, ok, uptime}`
- `GET /api/leaderboard` → Top100 `{entries, total}`
- `POST /api/leaderboard` body `{entry}` → schema 校验 → 推入 → 排序 → `{entries, total, rank}`
- `GET /api/leaderboard/stats``{totalSubmissions, uniquePlayers, topScore}`
- OPTIONS 预检由 hono/cors 自动处理,返回 204 + 完整 CORS 头
- 端口固定 3030`export default { port, fetch }` 标准 bun 模式
**启动调试经验**
- `bun --hot` 在本沙盒环境下不稳定,多次启动后约 5-10s 内被 kill(即使 setsid + nohup
- 解决方案:用 `bun index.ts`(无 --hot+ 双 fork 模式:`( bun index.ts >/tmp/log 2>&1 & )`
- 双 fork 后 PPID=1reparent 到 init),进程脱离原 bash sessionuptime 4+ 分钟稳定
### 2. beacon.ts 扩展(1161 → 1290 行,+129 行)
**三个 claim 函数返回值扩展(非破坏性)**
- `claimBeaconReward` 返回值新增 `entry: BeaconScoreEntry`
- `claimWeeklyReward` 返回值新增 `entry: BeaconScoreEntry`
- `claimTimedReward` 返回值新增 `entry: BeaconScoreEntry`
- 用途:让前端 / gameStore 能拿到完整 entry 提交到云端
**新增云排行榜模块**
- `BEACON_CLOUD_PORT = 3030` 常量
- `BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1"` localStorage key
- 3 个响应接口:`CloudLeaderboardResponse` / `CloudSubmitResponse` / `CloudStatsResponse`
- `loadLastCloudSubmitTimestamp()` / `saveLastCloudSubmitTimestamp(ts)`
- `fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]>` — GET `/api/leaderboard?XTransformPort=3030`
- `submitCloudScore(entry): Promise<number>` — POST,成功返回 rank ≥ 1,失败返回 -1,自动记录 ts
- `fetchCloudStats(): Promise<CloudStatsResponse | null>`
- **所有 fetch 用相对路径 + ?XTransformPort=3030,禁止 localhost:3030**
- try-catch 包裹,失败时静默返回空/null,不抛异常
### 3. gameStore.ts 集成
- import 新增 `submitCloudScore`
- `claimWeeklyBeacon` action:领奖成功后 `void submitCloudScore(res.entry)`fire-and-forget,不 await,不阻塞)
- `claimTimedBeacon` action:同上
- `claimBeaconReward` (daily) 由 BeaconPanel 直接调用,不通过 gameStore
### 4. BeaconPanel.tsx 重写(953 → 1170 行,+217 行)
**新增 import**
- `fetchCloudLeaderboard` / `submitCloudScore` / `loadLastCloudSubmitTimestamp` from beacon
- 4 个 lucide 图标:`RefreshCw` / `Globe` / `WifiOff` / `Loader2`
- `useRef` from react
**新增状态**
- `lbTab: 'local' | 'global'`(默认 local
- `cloudEntries: BeaconScoreEntry[]`
- `cloudLoading: boolean`
- `cloudError: string | null`
- `cloudFetched: boolean`
- `mySubmitTs: number`
- `cloudFetchingRef` (防并发)
**新增逻辑**
- `refreshCloudLeaderboard` callback:拉取云端榜 + 防并发 + 空 entries 时显示"成为首位登顶者"
- useEffect:切到全球 tab 自动拉取(仅首次)+ 初始化读取 mySubmitTs
- `handleClaimDaily`:领奖后 `void submitCloudScore(res.entry).then(rank => ...)` — 若用户在全球 tab 自动刷新
- `handleClaimTimed` / `handleClaimWeekly`:领奖后更新 mySubmitTs + 若在全球 tab 自动刷新
**排行榜 UI 重写为双 tab**
- 顶部 tab 切换:「本地」(amber 主题) / 「全球」(emerald 主题) — 用 `<button aria-pressed>` 实现可访问性
- 全球 tab 专属刷新按钮(RefreshCwloading 时 animate-spin
- 子标题:本地"本机 Top N · 离线可用" / 全球"全球 Top N · 已同步 / 同步中…"
- 本地榜:保留原 v0.8.2 渲染(max-h-200px overflow-y-auto
- 全球榜:
- 加载中:Loader2 spinner + "正在拉取全球榜…"
- 空榜 + 错误:WifiOff 图标 + 错误文案 + 重试按钮
- 空榜 + 无错:Globe 图标 + "尚无全球记录。完成挑战并领取即可登顶全球榜!"
- 有数据:渲染前 100 条,每条带排名(1-3 名用 Crown/Medal/Award 图标)/ 难度色 / WEEK·TIMED 徽章 / 用时 / 分数
- **玩家自己的记录高亮**:通过 `entry.timestamp === mySubmitTs` 匹配
- emerald 边框 (`border-emerald-500/40`)
- emerald 阴影 (`shadow-[0_0_12px_rgba(52,211,153,0.25)]`)
- YOU 徽章 (`bg-emerald-500/25 text-emerald-200 border-emerald-500/50`)
- emerald 分数色 (`color: #34d399`)
- 加粗排名
### 5. eslint 配置
- `eslint.config.mjs` 新增 ignores `"mini-services/**"`
- 原因:mini-service 是独立 bun 项目,有自己的 TypeScript 配置和 lint 规范,不应参与主项目 lint
## QA 验证结果
### 1. lint
- `bun run lint` 零错误零警告
### 2. dev 服务器
- dev.log 全程无错误,所有编译 < 300ms
- 注:dev.log 中显示 `/api/leaderboard?XTransformPort=3030 404` 是预期行为 — 通过 :3000 直接访问时 Next.js 没有 /api/leaderboard 路由;通过 :81 网关访问时 Caddy 转发到 :3030 正常返回 200
### 3. mini-service curl 全通过
- `GET /` → 200 `{"service":"echo-nexus-leaderboard","version":"v0.8.2","ok":true,"uptime":N}`
- `GET /api/leaderboard` (空) → 200 `{"entries":[],"total":0}`
- `POST /api/leaderboard` → 200 `{"entries":[...],"total":1,"rank":1}`
- `GET /api/leaderboard` (有数据) → 200 `{"entries":[...],"total":N}`
- `GET /api/leaderboard/stats` → 200 `{"totalSubmissions":N,"uniquePlayers":M,"topScore":X}`
- `OPTIONS /api/leaderboard` 预检 → 204 + 完整 CORS 头(Allow-Origin: *
- 重复 POST(同 dateKey+challenge 10s 内)→ 429 `{"error":"Rate limited","retryAfterMs":N}`
### 4. 网关路由验证
- Caddy :81 → localhost:3030 通过 `?XTransformPort=3030` 正确转发,返回 JSON 200
### 5. agent-browser 集成测试
- 通过 :81 端口访问(绕过 Next.js :3000 的 404
- 信标 tab 选中(用 KeyboardEvent Enter 激活 Radix Tabs — 因 pointerdown 被覆盖层拦截)
- 切到「全球」tab → 自动拉取 → 显示"全球 Top 6 · 已同步" → 渲染 6 条记录
- 刷新按钮可点击,旋转动画正常
- POST 测试:浏览器 fetch 提交 score=9999 → 返回 rank=1 → 刷新后榜首显示 10.00K
- YOU 高亮:手动设置 localStorage[BEACON_CLOUD_LAST_SUBMIT_KEY] 后刷新 → 榜首显示 emerald 边框 + YOU 徽章
### 6. VLM 视觉评分(目标 ≥7/10
- 全球榜含 YOU 高亮:**8/10**
- VLM 评语:"双tab切换清晰,全球榜记录完整,难度色与标签规范,视觉层次佳,但「YOU」徽章未突出显示"
- 本地榜:**8/10**
- VLM 评语:"双tab切换逻辑清晰,本地tab内容完整呈现;四色规范应用,视觉区分度高;信息层级合理"
## 修改的文件
1. `mini-services/leaderboard-service/package.json`(新建)
2. `mini-services/leaderboard-service/index.ts`(新建,182 行)
3. `mini-services/leaderboard-service/README.md`(新建)
4. `src/lib/game/beacon.ts` — 1161 → 1290 行(+129 行:3 个 claim 返回 entry + 云排行榜模块)
5. `src/store/gameStore.ts` — 1235 → 1252 行(+17 行:import + 2 个 claim action 加 submitCloudScore
6. `src/components/game/BeaconPanel.tsx` — 953 → 1170 行(+217 行:双 tab UI + 云端拉取 + 提交 + YOU 高亮)
7. `eslint.config.mjs` — ignores 新增 "mini-services/**"
## 不破坏的现有功能
- 本地排行榜(`loadLeaderboard` / `pushLeaderboardEntry`)完整保留作为离线 fallback
- 日挑战 / 周挑战 / 限时挑战 / 信标链流程全部保留
- 现有 localStorage keysecho-nexus-beacon-lb-v1 / -prog-v1 / -weekly-v1 / -chain-v1 / -timed-v1)全部保留
- 主项目 package.json 不变(hono 装在 mini-service 子项目内)
## 新增的 localStorage keys
- `echo-nexus-beacon-cloud-last-submit-v1`(上次成功提交到云端的 entry timestamp,用于全球榜高亮)
## 四色全息规范遵循
- 本地 tabamber (#fbbf24)
- 全球 tabemerald (#34d399) — 与"在线/同步"语义一致
- 玩家高亮:emerald 边框 + emerald YOU 徽章 + emerald 分数色
- 难度色保留:routine emerald / anomaly amber / singular rose
- 周挑战徽章:fuchsia
- 限时挑战徽章:amber
- 错误状态:rose (#fb7185)
- **零蓝色/靛色违规**
## 截图资产
- `/home/z/my-project/agent-ctx/beacon-global-leaderboard.png` — 全球榜初始视图(6 条记录)
- `/home/z/my-project/agent-ctx/beacon-global-with-you.png` — 全球榜含 YOU 高亮(7 条记录,榜首是玩家)
- `/home/z/my-project/agent-ctx/beacon-local-leaderboard.png` — 本地榜视图
## 重要注意事项(供后续 agent)
1. **前端访问端口**:用户通过 Preview Panel 看到的是 Caddy 网关 :81,不是 Next.js :3000。所以 `fetch('/api/leaderboard?XTransformPort=3030')` 在用户视角下能正常路由。如果直接访问 :3000 会 404(这是预期行为)。
2. **mini-service 启动方式**:用 `bun index.ts`(无 --hot+ 双 fork `( ... & )` 模式启动。`bun --hot` 在沙盒下不稳定。
3. **mini-service 已被 .zscripts/dev.sh 自动扫描**:下次容器重启会自动启动。但当前会话需手动启动一次。
4. **localStorage key 命名**`echo-nexus-beacon-cloud-last-submit-v1` 而非 `echo-nexus-beacon-cloud-lb-v1`,前者存的是单个 timestamp(用于高亮),后者若需要存全球榜快照可用后者(目前未实现,按需拉取)。
5. **star-tide-service 端口 3031**:另一个 P3 工单已存在的服务,本服务的端口 3030 与之互不冲突。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

+89
View File
@@ -0,0 +1,89 @@
---
Task ID: 2-b
Agent: full-stack-developer
Task: v0.16 完善 — loreLayer 已读标记 + idleProjectHistory 详情展开 + 移动端 375px 适配审计
Work Log:
- 必读上下文:worklog v0.15-release 段 + Codex.tsxv0.15 loreLayers 分层渲染)+ StatsPanel.tsxv0.15 抽出独立组件)+ types.ts GameState + config.ts FRAGMENTS/loreLayers/isLoreLayerUnlocked + gameStore.tsactions + persist migrate+ IdleOperationsPanel.tsx + BeaconPanel.tsx + ExpeditionPanel.tsx
- 子任务 1loreLayer 已读标记
- 新建 `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 适配)
- 子任务 2idleProjectHistory 详情展开
- `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 订阅用 useShallowCodex / 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 不重置)
- ✅ 子任务 2idleProjectHistory 详情展开完整实现
- "放置工程历史" 行可点击展开/收起
- 展开后显示最近 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 200dev.log 无运行时错误(compile 11ms / render 33ms
- ✅ 与子代理 A 文件边界完全无冲突(不同字段/action/migrate 行)
- ⚠️ 未做 agent-browser 端到端验证(按工单由主控完成)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

+139
View File
@@ -0,0 +1,139 @@
# Task ID: v0.14-reimpl
# Agent: full-stack-developer
# Task: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章
## Work Log
### 1. 类型扩展 (`src/lib/game/types.ts`)
- 新增 `IdleProjectDef` 接口(id/name/desc/durationSec/reward/minAscensions/minCrystalsPerSec/icon/color/order
- 新增 `IdleProjectSlot` 接口(projectId/startedAt/finishesAt/remainingSec/completed
-`GameState` 接口 `pendingAttrPoints` 后新增 4 个字段:
- `idleProjectSlots: (IdleProjectSlot | null)[]`
- `idleProjectHistory: { projectId: string; finishedAt: number }[]`
- `idlePermanentBonus: number`
- `idleStats: { projectsCompleted: number; crystalsFromIdle: number }`
### 2. 新建 `src/lib/game/idle.ts` (~290 行)
- 定义 6 个放置工程(按 order 排序):
| id | name | duration | reward | icon | color | unlock |
|----|------|----------|--------|------|-------|--------|
| idle_scan | 深空勘探扫描 | 60s | insights +8 | 🛰️ | emerald | cps≥0.5 |
| idle_refine | 晶体精炼阵列校准 | 180s | permBonus +0.3/s | ⚙️ | rose | cps≥1 |
| idle_archive | 遗迹碎片整理 | 600s | randomFragment + insights +20 | 📜 | amber | asc≥1 OR cps≥5 |
| idle_anchor | 维度锚点部署 | 1200s | energy +2 + contact +5 | ⚓ | fuchsia | asc≥1 |
| idle_drones | 无人机群扩编 | 1800s | permBonus +2/s + crystals +500 | 🛸 | emerald | cps≥10 |
| idle_resonance | 跨维度谐振标定 | 3600s | permBonus +8/s + contact +15 + insights +100 | 🌌 | fuchsia | asc≥2 |
- 导出:`IDLE_PROJECTS`, `IDLE_SLOT_COUNT=3`, `getIdleProject`, `getUnlockedIdleProjects`, `getLockedIdleProjects`, `isIdleProjectUnlocked` (OR 逻辑:满足任一已设置条件即可解锁), `formatRemaining` (12s/3m 45s/1h 12m), `formatDuration`, `formatReward`, `deriveMinerFleet` (派生 min_1..min_5 采矿无人机,无任何采矿技术时返回休眠 min_1)
- 导出颜色映射 `IDLE_COLOR_CLASSES` (text/border/bg/bgSoft/glow/ring/dot) + `COLOR_HEX` (用于 inline style,绕过 Tailwind 动态类限制)
### 3. 引擎集成 (`src/lib/game/engine.ts`)
- `recomputeStats()` 末尾 `crystalsPerSec += state.idlePermanentBonus ?? 0` (放置永久加成叠加到产能)
- `performPrestige()` 在返回对象中:
- 清空 `idleProjectSlots: [null, null, null]` (飞升中断当前周目放置工程)
- 保留 `idleProjectHistory`, `idlePermanentBonus`, `idleStats` (跨周目永久)
- `recomputeStats` 调用传入 `idlePermanentBonus` 以确保飞升后立即应用永久加成
- `createInitialState()` 新增 4 字段默认值
### 4. Store actions (`src/store/gameStore.ts`)
- 新增 4 个 action
- `startIdleProject(slotIndex, projectId)`: 校验槽位空 + 项目已解锁 + 未在他处运行;写入 `{ projectId, startedAt, finishesAt, remainingSec, completed:false }`
- `cancelIdleProject(slotIndex)`: 置槽位为 null
- `claimIdleProject(slotIndex)`: 校验已完成 → 应用奖励(crystals 限 crystalCapenergy 限 energyMaxcontact 限 100permBonus 累加到 idlePermanentBonus + 重算 statsrandomFragment 从未解锁碎片中随机一个)→ 追加 history (slice -50) → idleStats.projectsCompleted++ → Toast 提示
- `tickIdleProjects(now)`: 遍历非空非完成槽位,更新 remainingSec,归零时标记 completed
- **persist migrate 函数**persist 配置增加 `version: 1` + `migrate` 函数,自动补全 idleProjectSlots/idleProjectHistory/idlePermanentBonus/idleStats 4 字段,旧存档加载不崩溃
- 全部 14 处 syncStats 调用更新为传入 `idlePermanentBonus: s.idlePermanentBonus ?? 0`,确保 recomputeStats 计算时纳入永久加成
- `init()` 中也补全 4 个 idle 字段(defense in depth
### 5. 主循环 (`src/hooks/useGameLoop.ts`)
- 新增 `tickIdleProjects` 选择器
- setInterval 回调中 `autoDecodeTick()` 后调用 `tickIdleProjects(now)`
- visibilitychange 回调中也调用 `tickIdleProjects(now)`
### 6. 新建 `src/components/game/IdleOperationsPanel.tsx` (~490 行)
- 主「放置」标签内容,4 个 section 在 `max-h-[520px] overflow-y-auto` 容器中:
1. **放置收益概览**3 stat tiles (放置产能 / 永久加成 / 完成工程) + 离线效率 progress bar + 累计放置晶体统计
2. **采矿无人机舰队**grid 展示 deriveMinerFleet(state),每张卡:emoji + name + Lv + output/s + 状态点(active=emerald ping 脉冲,dormant=灰)
3. **放置工程槽位**:3 张槽位卡(空槽=虚线占位,运行中=大字号倒计时+自定义进度条+取消按钮,已完成=奖励预览+领取按钮带 glow 辉光)
4. **可派遣工程**:2 列 grid 展示已解锁工程(icon+name+duration+desc+reward+3 个数字派遣按钮 1/2/3),下方列出未解锁工程及解锁条件
- 使用 `useShallow` 订阅多个 store 字段
- 本地 `now` state 每 1s 刷新倒计时
- 严格 4 色全息(emerald/rose/amber/fuchsia),无蓝/靛
- 自定义滚动条样式 (fuchsia 主题)
### 7. 新建 `src/components/game/IdleStatusBadge.tsx` (~100 行)
- 紧凑徽章:脉冲点 (emerald 若 cps>0,否则灰) + "放置中 +X/s" (或 "休眠中")
- Tooltip 悬停展示分解:基础产能 / 永久加成 / 运行中工程 / 待领取工程数
- `h-8 px-2.5 text-[11px]`,点击切换到放置标签
- `data-tut="idle-status-badge"` 锚点
### 8. 新建 `src/components/game/IdleProjectBar.tsx` (~90 行)
- 晶体球下方的细长进度条
- 显示最多 3 个运行/已完成工程为 mini pill:`[icon] name 12s ▓▓▓░░`
- 完成时显示 "✓ 待领取" + glow 辉光
- 无工程时返回 null
- 本地 `now` state 每 1s 刷新倒计时
- 进度条颜色用 inline style 控制(COLOR_HEX 映射)
### 9. 主页面集成 (`src/app/page.tsx`)
- 引入 3 个新组件 + `Clock` 图标 + `getUnlockedIdleProjects`
- 新增 `idleProjectSlots` / `createdAt` store 订阅
- 新增 `activeTab` / `tabInited` statecontrolled Tabs`<Tabs value={activeTab} onValueChange={setActiveTab}>`
- 挂载后 useEffect 一次性设置默认 tabhasActiveExpedition → "expedition"hasPendingPerk → "constellation",否则 → "idle"
- 版本号 v0.8 → v0.14
- Header 在 StarTideIndicator 后加 IdleStatusBadge
- 左侧 CrystalOrb 后加 IdleProjectBar
- TabsList grid-cols-8 → grid-cols-9,新增 `value="idle"` 的 TabsTrigger 作为第一个 tabClock 图标,emerald 主题,待领取时显示数量红点)
- 新增 TabsContent value="idle" 渲染 IdleOperationsPanel
- 新增 idle 相关 goal 提示(高优先级):
- 待领取 > 0 → "✦ 放置工程已完成 X 项,请前往「放置」标签领取奖励"
- 全空 + 有解锁 + 时长 > 60s → "「放置」标签可派遣工程项目,离线自动产出"
- StatsPanel 新增 3 行:放置永久加成 / 完成放置工程 / 放置产出晶体
### 10. 教程更新 (`src/lib/game/tutorial.ts`)
- TUTORIAL_STEPS 在 decode 与 tech 之间插入新步骤:
- id: "idle", target: "tab-idle", placement: "top"
- 标题 "③ 放置工程 · 离线产出"
- 介绍放置标签的工程派遣 + 顶部徽章 + 晶体球下方进度条
- 原 tech/expedition/prestige 步骤编号顺延为 ④⑤⑥
## QA 验证
### Lint
- `bun run lint` → 零错误零警告 ✅
### HTTP
- `curl http://localhost:3000/` → 200 OK ✅
### Dev log
- 全程无 runtime error,所有请求 200 OK,编译 < 250ms ✅
### agent-browser 烟雾测试
1. ✅ 版本标签 "v0.14" 可见
2. ✅ 默认 tab 是 "放置"data-state="active" 在第一个 tab
3. ✅ 头部 idle 徽章可见("放置中 +0.4/s" 或 "休眠中"
4. ✅ IdleOperationsPanel 4 个 section 全部可见(放置收益概览 / 采矿无人机舰队 / 放置工程槽位 / 可派遣工程 / 未解锁工程)
5. ✅ 采矿无人机舰队在无采矿技术时显示休眠 min_1(emoji ⛏️ + 灰色状态点)
6. ✅ 设置 insights=50 → buyTech('min_1') → crystalsPerSec 0.4→1.0 → 解锁 idle_scan + idle_refine
7. ✅ 点击 idle_scan 派遣按钮 → 槽位 0 写入 { projectId: "idle_scan", finishesAt: now+60000 } → 倒计时正确递减
8. ✅ 60s 后自动标记 completed=true → 槽位卡显示 "✓ 完成" + 领取按钮
9. ✅ 点击领取 → Toast "✦ 工程奖励已领取 · 深空勘探扫描 · +8 洞见" → insights 8→16 → history 追加 → idleStats.projectsCompleted 0→1
10. ✅ 派遣 idle_refine (180s) → 强制完成 → 领取 → crystalsPerSec 1.0→1.3 → idlePermanentBonus 0→0.3 ✅ 永久产能加成正确应用
11. ✅ IdleProjectBar 在晶体球下方显示:`🛰️ 深空勘探扫描 55s ▓▓▓░░`
12. ✅ Footer goal 文字根据状态动态切换为放置相关提示
13. ✅ Footer 产能显示 "产能 1.3/s" (含 idle 永久加成)
### Migration 测试
- 旧存档(无 idle 字段)通过 migrate 函数自动补全为默认值,加载不崩溃 ✅
## Stage Summary
- ✅ 类型层:新增 IdleProjectDef + IdleProjectSlot + 4 GameState 字段
- ✅ 逻辑层:新建 idle.ts (~290 行),6 工程定义 + 派生函数 + 颜色映射
- ✅ 引擎层:recomputeStats / performPrestige / createInitialState 全部接入 idle 永久加成
- ✅ Store 层:4 新 action + persist migrate (version 1) + 14 处 syncStats 调用全部传 idlePermanentBonus
- ✅ 主循环:tickIdleProjects 接入 setInterval + visibilitychange
- ✅ UI 层:3 新组件 (IdleOperationsPanel ~490 行 / IdleStatusBadge ~100 行 / IdleProjectBar ~90 行)
- ✅ 主页面:默认放置 tab + 头部徽章 + 晶体球下方进度条 + 版本 v0.14 + 9 标签页
- ✅ 教程:新增 idle 步骤 (③ 放置工程)
- ✅ 严格 4 色全息 (emerald/rose/amber/fuchsia),零蓝/靛
- ✅ lint 零错误 + HTTP 200 + agent-browser 全流程烟雾测试通过
- ✅ 5 秒内可见三要素:头部徽章 + 默认放置 tab + 晶体球下方进度条(派遣后)
Regular → Executable
-40
View File
@@ -63,8 +63,6 @@
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.6", "sonner": "^2.0.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -602,8 +600,6 @@
"@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "https://registry.npmjs.com/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="], "@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/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=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmjs.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -678,8 +674,6 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmjs.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@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-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=="], "@types/d3-color": ["@types/d3-color@3.1.3", "https://registry.npmjs.com/@types/d3-color/-/d3-color-3.1.3.tgz", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
@@ -722,8 +716,6 @@
"@types/unist": ["@types/unist@3.0.3", "https://registry.npmjs.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/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/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=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.0", "https://registry.npmjs.com/@typescript-eslint/parser/-/parser-8.53.0.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg=="],
@@ -784,8 +776,6 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "https://registry.npmjs.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], "@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": ["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=="], "acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmjs.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -834,8 +824,6 @@
"base64-js": ["base64-js@1.5.1", "https://registry.npmjs.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "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=="], "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=="], "brace-expansion": ["brace-expansion@1.1.12", "https://registry.npmjs.com/brace-expansion/-/brace-expansion-1.1.12.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
@@ -910,8 +898,6 @@
"cookie": ["cookie@0.7.2", "https://registry.npmjs.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "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=="], "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=="], "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmjs.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -1008,12 +994,6 @@
"empathic": ["empathic@2.0.0", "https://registry.npmjs.com/empathic/-/empathic-2.0.0.tgz", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], "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=="], "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=="], "es-abstract": ["es-abstract@1.24.1", "https://registry.npmjs.com/es-abstract/-/es-abstract-1.24.1.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
@@ -1472,8 +1452,6 @@
"mime-db": ["mime-db@1.54.0", "https://registry.npmjs.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-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=="], "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=="], "minimist": ["minimist@1.2.8", "https://registry.npmjs.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1690,14 +1668,6 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmjs.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], "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=="], "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=="], "source-map-js": ["source-map-js@1.2.1", "https://registry.npmjs.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -1826,8 +1796,6 @@
"uvu": ["uvu@0.5.6", "https://registry.npmjs.com/uvu/-/uvu-0.5.6.tgz", { "dependencies": { "dequal": "^2.0.0", "diff": "^5.0.0", "kleur": "^4.0.3", "sade": "^1.7.3" }, "bin": { "uvu": "bin.js" } }, "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA=="], "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=="], "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=="], "vfile": ["vfile@6.0.3", "https://registry.npmjs.com/vfile/-/vfile-6.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
@@ -1850,10 +1818,6 @@
"word-wrap": ["word-wrap@1.2.5", "https://registry.npmjs.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "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=="], "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=="], "yallist": ["yallist@4.0.0", "https://registry.npmjs.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
@@ -1936,8 +1900,6 @@
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "https://registry.npmjs.com/minimatch/-/minimatch-9.0.5.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@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=="], "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=="], "eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmjs.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -1968,8 +1930,6 @@
"micromatch/picomatch": ["picomatch@2.3.1", "https://registry.npmjs.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "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/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=="], "next-auth/uuid": ["uuid@8.3.2", "https://registry.npmjs.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
+309
View File
@@ -0,0 +1,309 @@
# 回响星核 v0.14 — 放置系统(Idle Operations)设计文档
> 版本:v0.14 · 日期:2026-01-25
> 核心目标:让玩家打开游戏 **5 秒内** 就能看出这是个 idle game
---
## 1. 设计动机
### 1.1 用户反馈
> "你的这个我没有看到任何可放置的功能" —— 玩家反馈
虽然 v0.8 已有 `crystalsPerSec` 在后台自动累积,但 **没有任何 UI 元素明示这一事实**
- 没有「放置中」徽章
- 没有可视化自动采矿机
- 没有定时工程项目
- 离线收益弹窗只在重新加载页面时触发,玩家日常游玩看不到
放置游戏的灵魂是「关掉也在进步」。如果玩家看不到这一点,就等于没有放置玩法。
### 1.2 设计目标
1. **可见性**:5 秒内让玩家意识到 idle 属性
2. **决策性**:放置不只是数字增长,要有玩家选择
3. **节奏感**:3 层 idle 节奏(被动产能 / 放置工程 / 离线收益)协同
4. **健康性**:避免 24h 挂机的极端沉迷
---
## 2. 三层 Idle 节奏
### 节奏 1:被动产能(Passive Generation
- **载体**:采矿无人机舰队(Miner Fleet
- **来源**:每解锁一个采矿分支技术节点 → 加入一架无人机
- **行为**:24h 持续产出晶体,无需任何操作
- **限制**:受 `crystalCap` 限制,仓库满后停止
- **离线**:按 `offlineEff`(默认 50%)持续累积,上限 8h
#### 采矿分支技术 → 无人机映射
| 技术节点 | 无人机名 | 输出/s | 解锁成本(洞见) |
|---------|---------|--------|-----------------|
| `min_1` | ⛏️ 谐振钻头 | +0.6 | 5 |
| `min_2` | 🌋 深层矿脉 | +1.6 | 24 |
| `min_3` | 🤖 自治机群 | +5 | 120 |
| `min_4` | 💠 量子采掘矩阵 | +12 | 400 |
| `min_5` | ☀️ 戴森星核 | +30 | 1200 |
无采矿技术时显示「谐振钻头(休眠中)」占位卡片,引导玩家解锁采矿分支。
---
### 节奏 2:放置工程(Idle Projects
#### 2.1 工程列表
| ID | 名称 | 时长 | 奖励 | 解锁条件 | 图标 | 色 |
|----|------|------|------|---------|------|-----|
| `idle_scan` | 深空勘探扫描 | 60s | ✦ +8 洞见 | 产能 ≥ 0.5/s | 🛰️ | emerald |
| `idle_refine` | 晶体精炼阵列校准 | 180s | ⇪ +0.3/s 永久 | 产能 ≥ 1/s | ⚙️ | rose |
| `idle_archive` | 遗迹碎片整理 | 600s | ✦ +20 洞见 + 📜 随机碎片 | 飞升 ≥ 1 OR 产能 ≥ 5/s | 📜 | amber |
| `idle_anchor` | 维度锚点部署 | 1200s | ⚡ +2 能量 + ☄ +5 接触进度 | 飞升 ≥ 1 | ⚓ | fuchsia |
| `idle_drones` | 无人机群扩编 | 1800s | 💎 +500 晶体 + ⇪ +2/s 永久 | 产能 ≥ 10/s | 🛸 | emerald |
| `idle_resonance` | 跨维度谐振标定 | 3600s | ✦ +100 洞见 + ☄ +15 接触 + ⇪ +8/s 永久 | 飞升 ≥ 2 | 🌌 | fuchsia |
#### 2.2 槽位机制
- **3 个并行槽位**:可同时运行 3 个工程
- **不重复**:同一工程不能在多个槽位同时运行
- **完成需手动领取**:避免"打开就有奖励"的条件反射
- **取消不退时间**:误取消的代价
- **离线推进**:关闭浏览器后工程继续倒计时
#### 2.3 奖励类型说明
| 奖励 | 含义 | 设计意图 |
|------|------|---------|
| 晶体 | 立即加到 `crystals` | 短期反馈 |
| 洞见 | 立即加到 `insights` | 用于解锁技术 |
| 能量 | 加到 `energy`(受 `energyMax` 上限) | 探险资源 |
| 接触进度 | 加到 `contact`0-100 | 推进飞升 |
| **永久产能加成** | 加到 `idlePermanentBonus`,影响 `recomputeStats` | 长期投资 |
| 随机碎片 | 从 `FRAGMENTS` 中随机解锁一个未拥有 | 叙事推进 |
---
### 节奏 3:离线收益(Offline Gains
#### 3.1 公式
```
gain = crystalsPerSec × elapsed × offlineEff
elapsed = min(realElapsed, 8h)
```
#### 3.2 解锁路径
- 初始 `offlineEff = 0.5`50% 离线效率)
- 通过技术树 `offlineEff` 类效果提升
- 通过星图天赋 `offlineEffBonus` 提升
- 上限 100%
#### 3.3 8h 上限理由
- 参考 GDC 2016 Anthony Pecorella "Quest for Progress"Egg Inc 2h 硬 cap 导致玩家流失
- 但完全无 cap 会导致 24h 挂机成为最优策略,破坏短会话体验
- 8h 折中:覆盖一个工作日/一夜,但不鼓励全天挂机
#### 3.4 离线报告弹窗
重新加载页面时,若 `elapsed > 60s`,弹出 `OfflineReportDialog`
- 「你离开了 X 小时 Y 分」
- 「星核为你产出 Z 颗晶体」
- 「产能 /s · 离线效率 N%」
- 「是否触达 8h 上限」标志
---
## 3. UI 设计
### 3.1 始终可见的 Idle 徽章(Header
位置:顶部 Header,紧邻「巡航」按钮左侧
状态:
- 产能 > 0`● 放置中 +X.X/s`(绿点脉动)
- 产能 = 0`○ 休眠中`(灰点静止)
悬停 Tooltip 显示明细:
```
基础产能 8.2/s
永久加成 +1.5/s
离线效率 75%
完成工程 12 次
```
点击徽章 → 切换到「放置」标签。
### 3.2 默认标签页 = 放置
首次打开游戏,默认 tab 是「放置」(v0.13 前是「探险」或「技术」)。
返回玩家若有进行中探险或待选天赋,自动切回原默认。
### 3.3 放置标签页四段式布局
```
┌─────────────────────────────────────────┐
│ Section 1: 放置收益概览 │
│ [放置产能] [永久加成] [完成工程] │
│ [━━━━━━━ 离线效率 50% · 上限 8h ━━━━━━] │
├─────────────────────────────────────────┤
│ Section 2: 采矿无人机舰队 │
│ [⛏️ 钻头] [🌋 矿脉] [🤖 机群] ... │
├─────────────────────────────────────────┤
│ Section 3: 放置工程槽位 │
│ [槽位1: 运行中 47s ▓▓▓░] [槽位2: 空] [槽位3: 空] │
├─────────────────────────────────────────┤
│ Section 4: 可派遣工程 │
│ [🛰️ 深空勘探扫描 1m +8] [⚙️ 精炼校准 3m +0.3/s] │
│ [📜 碎片整理 10m +20] │
│ │
│ 未解锁工程(3): │
│ ⚓ 维度锚点部署 · 需 飞升≥1 │
│ 🛸 无人机群扩编 · 需 产能≥10/s │
│ 🌌 跨维度谐振标定 · 需 飞升≥2 │
└─────────────────────────────────────────┘
```
### 3.4 IdleProjectBar(晶体球下方)
位置:左侧晶体球区,`CrystalYieldPanel` 下方
显示:当前运行的工程迷你 pill
```
🛰️ 深空勘探扫描 47s ▓▓▓░░░░░
⚙️ 晶体精炼校准 2m 13s ▓▓░░░░░░
```
无运行工程时返回 `null`(不占位)。
---
## 4. 技术实现
### 4.1 数据结构
```typescript
// types.ts
interface IdleProjectDef {
id: string;
name: string;
desc: string;
durationSec: number;
reward: { crystals?; insights?; energy?; contact?; crystalsPerSecPermanent?; randomFragment?; };
minAscensions?: number;
minCrystalsPerSec?: number;
icon: string;
color: "emerald" | "rose" | "amber" | "fuchsia";
order: number;
}
interface IdleProjectSlot {
projectId: string;
startedAt: number;
finishesAt: number;
remainingSec: number;
completed: boolean;
}
// GameState 新增 4 字段
idleProjectSlots: (IdleProjectSlot | null)[]; // 3 槽
idleProjectHistory: { projectId: string; finishedAt: number }[]; // 最近 50 条
idlePermanentBonus: number;
idleStats: { projectsCompleted: number; crystalsFromIdle: number };
```
### 4.2 Store actions
- `startIdleProject(slotIndex, projectId)` — 校验 + 创建槽位
- `cancelIdleProject(slotIndex)` — 置空槽位
- `claimIdleProject(slotIndex)` — 应用奖励 + toast + 置空槽位
- `tickIdleProjects(now)` — 推进剩余时间,标记完成
### 4.3 引擎集成
`recomputeStats()` 末尾添加:
```typescript
crystalsPerSec += state.idlePermanentBonus ?? 0;
```
这让永久产能加成真正生效。
### 4.4 飞升处理
`performPrestige()` 重置 `idleProjectSlots = [null, null, null]`(不跨周目继承进行中工程),但保留 `idlePermanentBonus` / `idleProjectHistory` / `idleStats`(跨周目永久)。
### 4.5 存档迁移
persist 添加 `version: 1` + `migrate` 函数,为旧存档补全 4 个新字段:
```typescript
idleProjectSlots: (s.idleProjectSlots as unknown[]) ?? [null, null, null],
idleProjectHistory: (s.idleProjectHistory as unknown[]) ?? [],
idlePermanentBonus: (s.idlePermanentBonus as number) ?? 0,
idleStats: (s.idleStats as unknown) ?? { projectsCompleted: 0, crystalsFromIdle: 0 },
```
旧存档零崩溃风险。
### 4.6 主循环集成
`useGameLoop` 每 250ms tick
1. `tick(now)` — 产能 + 自动产晶体
2. `autoDecodeTick()` — 自动解码
3. `tickTide(now)` — 星潮事件
4. **`tickIdleProjects(now)` — v0.14 放置工程进度**(新增)
5. `checkAchievements()` — 每秒一次
页面可见性切回时也调用 `tickIdleProjects(now)`
---
## 5. 平衡考虑
### 5.1 永久产能加成可能过强
- `idle_resonance` +8/s 永久,多次完成可能让玩家飞快积累
- **v0.15 待办**:加飞升次数 softcap,例如 `effectiveBonus = idlePermanentBonus × sqrt(ascensions + 1) / (ascensions + 1)`
### 5.2 工程时长曲线
- 60s / 180s / 600s / 1200s / 1800s / 3600s
- 短工程奖励低但灵活(适合短会话)
- 长工程奖励高但需等待(适合过夜挂机)
- 玩家自主选择节奏
### 5.3 解锁条件
- 0 飞升 + 0.5/s:新手 5 分钟内可解锁第一个
- 1 飞升:玩家完成首次飞升后解锁中段
- 2 飞升 + 10/s:长线目标
---
## 6. 验证记录
### 6.1 Lint
- ✅ 零错误零警告
### 6.2 Dev 服务器
- ✅ HTTP 200
- ✅ 无运行时错误
### 6.3 Agent Browser 端到端测试
1. ✅ 默认 tab 是「放置」
2. ✅ Header 显示「放置中 +0.4/s」徽章
3. ✅ 4 段面板完整渲染
4. ✅ 买 min_1+min_2 后产能 2.6/s,解锁 idle_scan + idle_refine
5. ✅ 点击「派遣至槽位 1」→ 槽位显示运行中 60s + 进度条 + 取消按钮
6. ✅ IdleProjectBar 同步显示迷你 pill
7. ✅ 快进完成后标记 completed=true
8. ✅ 领取后 insights +8、projectsCompleted 0→1、槽位清空
### 6.4 Sticky Footer 验证
- ✅ Header `position: sticky; top: 0`
- ✅ Footer `position: sticky; bottom: 0; margin-top: auto`
- ✅ 内容超出视口时 footer 被自然推下(无浮动空隙)
---
## 7. 未解决问题 / 后续
- 🟡 `crystalsFromIdle` / `idleProjectHistory` 已在 store 累加但 UI 未展示——v0.15 可在统计 tab 补
- 🟡 永久产能加成可能需要飞升次数 softcap
- 🟡 缺「批量领取全部」按钮(玩家有 3 个槽位都完成时方便)
- 🟡 缺工程完成 toast 通知(目前只能在面板内看到「待领取」标志)
- 🟢 v0.15 可加 Dark Souls 式 loreFragments 多层文本,让"遗迹碎片整理"工程的随机碎片奖励更有叙事感
---
## 8. 设计哲学总结
> "Incremental games are very raw. They're about relating to a process and becoming obsessed with its growth, like watching something you planted in the ground, through a time-lapse camera, grow into a tree." —— Clark (via Frank Lantz, New Yorker 2019)
回响星核 v0.14 的放置系统不只是把数字挂机增长,而是让玩家**看到**这个过程:
- 采矿无人机像植物在土里生长
- 放置工程像种子在发芽
- 离线收益像收获时节
玩家不再只是「数字变大」,而是「亲手布置了一个会自动运转的考古基地,然后离开,让它在自己不在的时候继续工作」。
这是 idle game 的本质:**让玩家信任系统会在他不在时为他工作**。
+214
View File
@@ -0,0 +1,214 @@
# v0.15 — 叙事深化(loreLayers+ 放置平衡(softcap+ UX 增强
> 回响星核 / Echo Nexus · v0.15 设计文档
> 对应 worklog `Task ID: v0.15-release`
---
## 一、动机
v0.14 上线了完整的放置系统(6 工程 + 永久加成 + idle 徽章),但留下两个问题:
1. **平衡性漏洞**`idle_resonance` 工程每次完成 +8/s 永久加成,玩家可反复派遣导致滚雪球,几小时后产能爆炸。
2. **叙事单薄**8 个记忆碎片只有一层 `echo` 文本,解锁后即"读完",缺乏 Dark Souls / Outer Wilds 式的碎片叙事深度——这是项目的**差异化竞争力**。
v0.15 用两个并行子代理一次性解决:
- **2-a(平衡 + UX**:永久加成 softcap + 批量领取 + 完成 toast + StatsPanel idle 统计
- **2-b(叙事深化)**8 碎片 × 2 层 = 16 层 loreLayers 多层文本 + Codex 分层渲染
---
## 二、平衡:永久加成 Softcap
### 公式
```ts
export function applyIdleSoftcap(bonus: number): number {
if (bonus <= 30) return bonus; // 全额
if (bonus <= 100) return 30 + (bonus - 30) * 0.5; // 超出部分 ×0.5
return 65 + (bonus - 100) * 0.25; // 超出部分 ×0.25
}
```
### 临界值
| 原始 bonus | 有效 bonus | 衰减率 |
|-----------|-----------|--------|
| 30 | 30 | 0% |
| 50 | 40 | 20% |
| 100 | 65 | 35% |
| 200 | 90 | 55% |
| 500 | 152.5 | 69.5% |
### 设计意图
- **30 以下不衰减**:新手期 + 偶尔完成 `idle_refine`+0.3/s)不受惩罚
- **30-100 线性半衰**:中段玩家反复完成 `idle_drones`+2/s)开始感到边际递减
- **100 以上严重衰减**:硬核玩家刷 `idle_resonance`(+8/s)收益大幅缩水,但仍非零(保留进度感)
- **永不归零**`applyIdleSoftcap(bonus) → ∞` 时仍趋向无穷,只是增速极慢——避免"刷到上限"的挫败感
### UI 展示
`IdleOperationsPanel` Section 1「永久加成」StatTile
- 有效值永远显示(如 `+40/s`
-`有效 < 原始` 时,下方加 amber 副行:`有效 +40/s(原始 +50/s · 衰减中)`
---
## 三、叙事深化:loreLayers 多层碎片文本
### 类型扩展
```ts
export interface FragmentLoreLayer {
layer: 1 | 2; // 1=中层,2=深层
title: string; // "隐秘注脚" / "深层回响"
text: string; // 60-150 字叙事文本
unlockHint: string; // "飞升 1 次后揭示"
unlock: FragmentLoreUnlock;
}
export interface FragmentLoreUnlock {
kind: "ascensions" | "totalDecoded" | "contact" | "fragmentCount" | "bossKills";
value: number;
}
```
### 5 种解锁条件
| kind | 含义 | 典型阈值 |
|------|------|---------|
| `ascensions` | 飞升次数 | 1 / 2 / 3 |
| `totalDecoded` | 累计解码晶体数 | 20 / 40 / 60 |
| `contact` | 接触进度 | 50 / 80 |
| `fragmentCount` | 已解锁碎片数 | 4 / 6 |
| `bossKills` | 累计击杀 BOSS | 3 / 8 |
### 5 条叙事暗线(贯穿 16 层)
1. **追逐者**:以太族并非自然消亡,而是被虚空中"某种东西"追逐而集体飞升
2. **回响同源**:玩家舰载 AI"回响"的频段直接复制自以太族议会档案——玩家其实是以太族的延续/入口
3. **信任即引诱**:第一纪元的"信任后来者"实为"引诱后来者替他们完成未竟之事"
4. **飞升是单向门**:每次飞升都在消耗什么,不是奖赏
5. **回归的不是他们**:接触进度满 80 时回归的,是"借他们形状的某种东西",请玩家别回应
### 示例(f1_1 第一缕谐振)
**L0 表层**(默认):
> 在被称作「寂灭前夜」的年代,以太族第一次捕捉到来自虚空深处的谐振——那不是声音,而是存在的回声。他们将之记录在第一颗记忆晶体里。
**L1 隐秘注脚**(飞升 ≥1):
> 档案的边缘有一行几乎被擦去的批注:「那并非来自虚空——而是来自我们身后。我们一直不敢回头。」
**L2 深层回响**(累计解码 ≥40):
> 你终于明白:那缕谐振的频率,与你的舰载 AI「回响」的核心频段完全一致。你不是在考古——你是在被召唤。而你身后的东西,一直在等。
### Codex 分层渲染
| 层级 | 颜色 | 标题 | 未解锁状态 |
|------|------|------|-----------|
| L0 表层 | emerald | 表层回响 | 永远显示 |
| L1 中层 | amber | 隐秘注脚 | `???` + `blur-sm` 全息乱码 + `🔒 unlockHint` |
| L2 深层 | fuchsia | 深层回响 | `???` + `blur-sm` 全息乱码 + `🔒 unlockHint` |
顶部统计:`深层叙事 X / 16 已解锁`
列表卡片:已解锁深层文本的碎片标题旁显示 amber/fuchsia glow 小圆点
---
## 四、UX 增强
### 4.1 批量领取全部
`claimAllIdleProjects()` action
- 遍历 3 槽位,对所有 `completed === true` 执行奖励逻辑
- 单次 `set()` + 单次 `syncStats()`(避免多次 re-render
- 无可领取 → 直接 return(不弹 toast
- 成功 → 汇总 toast `✦ 批量领取完成 · 共领取 N 项工程 · +X 晶体 · +Y 洞见 · ...`
UISection 3 标题右侧,当存在 completed slot 时显示 emerald「✓ 领取全部」按钮(h-5 px-2 text-[10px]
### 4.2 工程完成 toast
`tickIdleProjects` 检测状态转变瞬间(旧 `!completed` → 新 `completed`):
- 收集 `justCompletedProjects` 数组
- 循环外单次 `toast({ title: "✦ 放置工程完成", description: "${icon} ${name} · 可前往「放置」标签领取" })`
- 避免每 250ms tick 重复弹窗
### 4.3 StatsPanel idle 统计
`page.tsx` 内联函数抽出为独立组件 `StatsPanel.tsx`
- `useShallow` 精细订阅 24 字段
- 新增 idle 统计行:
- 放置永久加成 `+X /s`
- 完成放置工程 `N 项`
- 放置产出晶体 `formatNum(N)`
- 放置工程历史 `N 条`(最近 50 条,跨周目)
---
## 五、技术实现
### 修改文件清单(8 个)
**子代理 2-a5 文件):**
- `src/lib/game/engine.ts` — 新增 `applyIdleSoftcap` 导出 + `recomputeStats` 调用
- `src/store/gameStore.ts``GameActions``claimAllIdleProjects` 签名 + `tickIdleProjects` 改写 + 新增 action
- `src/components/game/IdleOperationsPanel.tsx` — softcap 副行 + 领取全部按钮 + StatTile 扩展
- `src/components/game/StatsPanel.tsx` — 新建独立组件
- `src/app/page.tsx` — 最小集成(删除内联 + import)
**子代理 2-b3 文件):**
- `src/lib/game/types.ts``FragmentLoreLayer` / `FragmentLoreUnlock` 接口 + `Fragment.loreLayers` 字段
- `src/lib/game/config.ts` — FRAGMENTS 全部填充 loreLayers + `isLoreLayerUnlocked` 函数
- `src/components/game/Codex.tsx` — useShallow + 顶部统计 + 列表小圆点 + 3 层分层渲染弹层
### 并行零冲突
两个子代理修改文件严格不重叠:
- 2-a 不碰 `types.ts` / `config.ts` / `Codex.tsx`
- 2-b 不碰 `engine.ts` / `gameStore.ts` / `IdleOperationsPanel.tsx` / `StatsPanel.tsx`
---
## 六、验证
-`bun run lint` 零错误零警告
- ✅ HTTP 200dev.log 无运行时错误
- ✅ agent-browser 端到端 smoke test
- softcappermBonus=50 → 有效 40UI 显示"衰减中" ✓
- 批量领取:permBonus 0→0.3, projectsCompleted 1→2 ✓
- 完成 toasttickIdleProjects 触发 ✓
- Codex 分层:L0 显示 / L1·L2 模糊 → ascensions=1 解锁 L1 → totalDecoded=50 解锁 L2 ✓
- 深层叙事计数 0→2→6 随条件递增 ✓
- StatsPanel idle 4 行统计全显示 ✓
---
## 七、哲学
### 碎片叙事的留白
回响星核的叙事不靠过场动画或长篇对话,而是**让玩家自己拼凑**。每个碎片的多层文本设计遵循:
1. **表层温和**:第一遍读像抒情散文,不带压迫感
2. **中层矛盾**:揭示表层没说的东西,制造认知裂缝
3. **深层颠覆**:推翻表层认知,但留有解读空间(不直接说"怪物是谁")
### 平衡与保留
softcap 的设计哲学:**衰减但不封顶**。
- 衰减:防止滚雪球破坏游戏节奏
- 不封顶:尊重硬核玩家的投入,即使边际收益递减,进度感始终存在
这与飞升系统的"单向门"叙事呼应——每次进步都有代价,但绝非徒劳。
---
## 八、下一步(v0.16+
- 🟡 loreLayer "已读"标记(需改 gameStore,本轮避开边界冲突)
- 🟡 解码阈值释放叙事碎片 + 星潮三频段切换
- 🟡 健康设计:每日软上限 + "星核建议休眠"提醒
- 🟡 移动端 375px Codex 弹层可读性实测
+231
View File
@@ -0,0 +1,231 @@
# v0.16 — 解码阈值叙事事件 + loreLayer 已读 + idleHistory 详情 + 移动端适配
> 回响星核 / Echo Nexus · v0.16 设计文档
> 对应 worklog `Task ID: 2-a` + `2-b` + `v0.16-release`
---
## 一、动机
v0.15 上线了 loreLayers 多层碎片文本,但存在三个未完善点:
1. **解锁瞬间缺乏仪式感** — 碎片静默出现在图谱,没有"叙事涌现"的高光时刻
2. **loreLayer 没有已读标记** — 玩家无法区分"新解锁未读"和"已读",缺乏进度感
3. **idleProjectHistory 只显示计数** — 无法查看具体完成了哪些工程、何时完成
4. **移动端 375px 未实测** — v0.15 的 Codex 分层弹层在小屏可读性未知
v0.16 用两个并行子代理一次性解决:
- **2-a**:5 个解码阈值叙事里程碑 + 全屏"碎片释放"叠层
- **2-b**loreLayer 已读标记 + idleHistory 详情展开 + 移动端适配审计
---
## 二、解码阈值叙事里程碑(2-a)
### 设计哲学
回响星核的核心卖点是**"解码驱动叙事涌现"**。v0.15 的 loreLayers 让每个碎片有 3 层文本,但解锁是静默的。v0.16 在 `totalDecoded` 达到 5/12/25/40/60 时触发**全屏叙事叠层**——不是解锁新碎片,而是对已解锁碎片的**回顾性咏叹**,类似游戏中的"过场回忆"。
5 个阈值与 FRAGMENTS 的 threshold1/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-shadow120px + 240px 双层)+ `narrative-breathe` 3.5s 呼吸循环
- **中央卡片**max-w-2xl4 色主题渐变背景 + 主题色 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` 300msopacity 0→1+ `narrative-scale-in` 300ms cubic-bezierscale 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-a6 文件):**
- 新建 `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 200dev.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 复用到编年史时间轴 + 探险日志
+1 -1
View File
@@ -44,7 +44,7 @@ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
"no-useless-escape": "off", "no-useless-escape": "off",
}, },
}, { }, {
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills", "mini-services/**"] ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills"]
}]; }];
export default eslintConfig; export default eslintConfig;
@@ -1,93 +0,0 @@
# Echo Nexus · 云排行榜 mini-service
回响星核 / Echo Nexus 的 P2 云排行榜服务(v0.8.2 / Task 10-b)。
把信标本机排行榜(`localStorage`)升级为云端 Top100,玩家间可比。
## 技术栈
- **运行时**Bun(原生 TypeScript,无需编译)
- **框架**Hono(轻量 web 框架,bun 原生支持)
- **存储**:内存数组,重启清空(够用,符合 P2 规格)
- **端口**:固定 `3030`Caddy 通过 `?XTransformPort=3030` 转发)
## 目录结构
```
mini-services/leaderboard-service/
├── package.json # 独立 bun 项目
├── index.ts # Hono 入口
└── README.md
```
## API
所有响应自动 CORS 开放,OPTIONS 预检由 Hono 自动处理。
| 方法 | 路径 | 入参 | 返回 |
|---|---|---|---|
| GET | `/` | — | `{ service, version, ok, uptime }` 健康检查 |
| GET | `/api/leaderboard` | — | `{ entries: BeaconScoreEntry[], total }` Top100 |
| POST | `/api/leaderboard` | body `{ entry: BeaconScoreEntry }` | `{ entries, total, rank }` rank=1..N 或 -1 |
| GET | `/api/leaderboard/stats` | — | `{ totalSubmissions, uniquePlayers, topScore }` |
### `BeaconScoreEntry` 结构
```ts
{
timestamp: number, // 提交时间戳
dateKey: string, // YYYY-MM-DD 或 YYYY-Www
challenge: string, // decode | expedition | pulse | boss | insight
difficulty: string, // routine | anomaly | singular
progress: number, // 0-11 = 完成
score: number, // 最终得分
durationSec: number, // 完成时长(秒)
isWeekly?: boolean, // 周挑战记录
isTimed?: boolean // 限时挑战记录(预留)
}
```
## 存储 & 防刷规则
- **容量**:最多 1000 条,按分数降序;超过自动丢弃尾部。
- **Top 榜**API 返回前 100 条。
- **同分排序**:先看用时短,再看提交早。
- **防刷**:同 `dateKey + challenge` 10 秒内只接受 1 次提交,返回 `429`
## 启动
```bash
cd mini-services/leaderboard-service
bun run dev
# 等价于 bun --hot index.ts,文件变更自动重启
```
## 联调(前端请求规范)
前端必须用相对路径 + `?XTransformPort=3030`
```ts
// 拉取全球 Top100
const res = await fetch("/api/leaderboard?XTransformPort=3030");
const { entries, total } = await res.json();
// 提交一条记录
const res = await fetch("/api/leaderboard?XTransformPort=3030", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entry }),
});
const { entries, total, rank } = await res.json();
```
**禁止** `fetch("http://localhost:3030/...")` —— 不通过 Caddy 网关,会被浏览器同源策略拦截。
## 部署形态
独立 bun 项目,与主 Next.js 项目解耦:
- 主项目端口 3000Next.js dev
- 本服务端口 3030Hono + Bun
- Caddy 网关端口 81(按 `?XTransformPort=3030` 转发到本服务)
服务重启会清空数据,符合 P2 阶段规格(不需要持久化)。
@@ -1,15 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "echo-nexus-leaderboard-service",
"dependencies": {
"hono": "^4.6.14",
},
},
},
"packages": {
"hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="],
}
}
-182
View File
@@ -1,182 +0,0 @@
// 回响星核 / Echo Nexus — 云排行榜 mini-service
// P2/#4:把信标本机排行榜升级为云排行榜。
// 端口固定 3030Caddy 通过 ?XTransformPort=3030 转发。
// 内存存储(重启清空),Hono + Bun 原生。
//
// API
// GET /api/leaderboard → { entries: BeaconScoreEntry[], total }
// POST /api/leaderboard body { entry } → { entries, total, rank }
// GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore }
// GET / → 健康检查
//
// 所有响应自动 CORS 开放(hono/cors),OPTIONS 预检 hono 自动处理。
import { Hono } from "hono";
import { cors } from "hono/cors";
// ---------------------------------------------------------------------------
// 类型(与前端 BeaconScoreEntry 一致,但这里宽松化以接受历史数据)
// ---------------------------------------------------------------------------
interface BeaconScoreEntry {
timestamp: number;
dateKey: string;
challenge: string;
difficulty: string;
progress: number;
score: number;
durationSec: number;
isWeekly?: boolean;
isTimed?: boolean;
}
// ---------------------------------------------------------------------------
// 内存存储
// ---------------------------------------------------------------------------
const entries: BeaconScoreEntry[] = [];
const MAX_ENTRIES = 1000;
const TOP_N = 100;
// 简单防刷:同 dateKey+challenge 10 秒内只接受 1 次提交
const lastSubmitAt = new Map<string, number>();
const RATE_LIMIT_MS = 10_000;
function sortEntries() {
entries.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
// 同分情况下,用时短者靠前
if (a.durationSec !== b.durationSec) return a.durationSec - b.durationSec;
// 仍相同,按时间戳升序(先提交靠前)
return a.timestamp - b.timestamp;
});
while (entries.length > MAX_ENTRIES) entries.pop();
}
function findRank(entry: BeaconScoreEntry): number {
const idx = entries.findIndex(
(e) =>
e.timestamp === entry.timestamp &&
e.dateKey === entry.dateKey &&
e.score === entry.score
);
return idx >= 0 ? idx + 1 : -1;
}
// ---------------------------------------------------------------------------
// Hono 实例
// ---------------------------------------------------------------------------
const app = new Hono();
// 全局 CORS 开放(前端跨端口访问)
app.use(
"*",
cors({
origin: "*",
allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ["Content-Type", "X-Requested-With"],
exposeHeaders: ["Content-Length"],
maxAge: 600,
})
);
// 健康检查
app.get("/", (c) =>
c.json({
service: "echo-nexus-leaderboard",
version: "v0.8.2",
ok: true,
uptime: Math.floor(process.uptime?.() ?? 0),
})
);
// GET /api/leaderboard — 返回 Top100
app.get("/api/leaderboard", (c) => {
sortEntries();
const top = entries.slice(0, TOP_N);
return c.json({ entries: top, total: entries.length });
});
// POST /api/leaderboard — 插入一条记录,返回新榜 + 排名
app.post("/api/leaderboard", async (c) => {
let body: { entry?: unknown };
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid JSON body" }, 400);
}
if (!body || !body.entry || typeof body.entry !== "object") {
return c.json({ error: "Missing entry in body" }, 400);
}
const e = body.entry as Partial<BeaconScoreEntry>;
if (
typeof e.timestamp !== "number" ||
typeof e.dateKey !== "string" ||
typeof e.challenge !== "string" ||
typeof e.difficulty !== "string" ||
typeof e.score !== "number"
) {
return c.json({ error: "Entry schema mismatch" }, 400);
}
// 防刷:同 dateKey+challenge 10 秒内只接受 1 次
const key = `${e.dateKey}|${e.challenge}`;
const now = Date.now();
const last = lastSubmitAt.get(key) ?? 0;
if (now - last < RATE_LIMIT_MS) {
return c.json(
{
error: "Rate limited",
retryAfterMs: RATE_LIMIT_MS - (now - last),
rank: -1,
},
429
);
}
lastSubmitAt.set(key, now);
// 规范化 entry(去多余字段,补默认值)
const entry: BeaconScoreEntry = {
timestamp: e.timestamp,
dateKey: e.dateKey,
challenge: e.challenge,
difficulty: e.difficulty,
progress: typeof e.progress === "number" ? e.progress : 0,
score: Math.max(0, Math.floor(e.score)),
durationSec: typeof e.durationSec === "number" ? e.durationSec : 0,
isWeekly: e.isWeekly === true,
isTimed: e.isTimed === true,
};
entries.push(entry);
sortEntries();
const rank = findRank(entry);
return c.json({
entries: entries.slice(0, TOP_N),
total: entries.length,
rank,
});
});
// GET /api/leaderboard/stats — 全局统计
app.get("/api/leaderboard/stats", (c) => {
sortEntries();
const uniqueDateKeys = new Set(entries.map((e) => e.dateKey));
return c.json({
totalSubmissions: entries.length,
uniquePlayers: uniqueDateKeys.size,
topScore: entries.length > 0 ? entries[0].score : 0,
});
});
// ---------------------------------------------------------------------------
// 启动
// ---------------------------------------------------------------------------
const PORT = 3030;
console.log(`[leaderboard-service] listening on :${PORT}`);
const server = {
port: PORT,
fetch: app.fetch,
};
export default server;
@@ -1,13 +0,0 @@
{
"name": "echo-nexus-leaderboard-service",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot index.ts",
"start": "bun index.ts"
},
"dependencies": {
"hono": "^4.6.14"
}
}
-75
View File
@@ -1,75 +0,0 @@
# star-tide-service · 全球星潮同步 mini-service
> 回响星核 / Echo Nexus · P3 工单 #10-c
>
> socket.io 多人同步星潮 —— 所有在线玩家同时经历同一道全球星潮 60 秒。
## 设计
- **端口**:固定 `3031`
- **网关**Caddy 通过 `?XTransformPort=3031` 转发;前端用 `io("/?XTransformPort=3031")`
- **运行时**bun + socket.io 4
- **存储**:纯内存(无状态,重启不影响前端游戏)
## 星潮节奏
- 首次星潮:服务端启动后 **60 秒**(可由 env `FIRST_TIDE_DELAY_MS` 覆盖,便于 QA
- 后续星潮:**10-15 分钟随机间隔**
- 每次持续:**60 秒**
- 类型:从 6 种星潮按权重随机抽取(与前端 `TIDE_EVENTS` 一致)
- 晶体潮 22 / 谐振风暴 20 / 遗迹共振 16 / 虚空低语 18 / 星核悸动 14 / 寂静期 10
## socket.io 事件
### 服务端 → 客户端
| 事件 | 载荷 | 触发时机 |
|------|------|---------|
| `tide-state` | `{ current: GlobalTideState \| null, nextTideAt, serverTime }` | 客户端连接时立即推送 |
| `global-tide` | `{ type, name, startedAt, endsAt, durationSec }` | 全球星潮开始 |
| `tide-ended` | `{ endedAt, nextTideAt }` | 全球星潮结束 |
| `next-tide-info` | `{ current, nextTideAt, serverTime, remainingMs }` | 响应客户端 `get-next-tide` |
### 客户端 → 服务端
| 事件 | 载荷 | 行为 |
|------|------|------|
| `get-next-tide` | — | 查询下次星潮剩余时间 |
| `admin-trigger-tide` | — | **dev/QA 用**:立即触发一次全球星潮 |
## HTTP 端点
socket.io `path: '/'` 拦截所有 HTTP 请求,因此本服务不提供独立 HTTP 健康检查端点。
- `GET /` —— socket.io 默认握手响应(`{"code":0,"message":"Transport unknown"}` 表示服务在线)
## 启动
```bash
cd mini-services/star-tide-service
bun run dev # bun --hot index.ts,热重载
# 或
bun run start # bun index.ts,无热重载
```
## QA 验证
```bash
# socket.io 在线验证(收到 JSON 响应即表示服务在线)
curl http://localhost:3031/
# 预期:{"code":0,"message":"Transport unknown"}
```
前端可在浏览器 console 触发全球星潮(QA 用):
```js
const sock = io("/?XTransformPort=3031");
sock.on("tide-state", (s) => console.log("当前状态:", s));
sock.emit("admin-trigger-tide"); // 立即触发一次全球星潮
```
## 离线容错
- 服务端宕机 → 前端 `useGlobalTide` 自动重连(socket.io 默认行为)
- 断连期间 → 前端本地 `tickTide` 继续按原节奏工作,玩家不会卡住
- 全球星潮进行中 → 前端本地 `tickTide` 暂停触发(避免冲突)
-57
View File
@@ -1,57 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "echo-nexus-star-tide-service",
"dependencies": {
"socket.io": "^4.8.3",
},
},
},
"packages": {
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/node": ["@types/node@26.0.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"engine.io": ["engine.io@6.6.9", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0" } }, "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg=="],
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="],
"socket.io-adapter": ["socket.io-adapter@2.5.8", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.21.0" } }, "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw=="],
"socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
}
}
-249
View File
@@ -1,249 +0,0 @@
// 回响星核 / Echo Nexus — 全球星潮同步 mini-service
// P3/工单 #10-csocket.io 多人同步星潮
// 端口固定 3031Caddy 通过 ?XTransformPort=3031 转发。
// 前端连接:io("/?XTransformPort=3031")path 默认 "/"。
//
// 逻辑:
// - 服务端每 10-15 分钟(随机间隔)随机选一个星潮类型,
// 广播 `global-tide` 事件 { type, name, startedAt, endsAt, durationSec: 60 }
// - 客户端连接后立即收到 `tide-state` 事件,包含当前进行中的星潮(如有)
// + 下次星潮时间戳 + 服务器时间(用于时钟同步)
// - 客户端可随时 emit `get-next-tide`,服务端响应 `next-tide-info`
// { current, nextTideAt, serverTime, remainingMs }
// - 全球星潮结束时广播 `tide-ended` { endedAt, nextTideAt }
// - `admin-trigger-tide`dev 用):立即触发一次全球星潮,便于 QA 验证
//
// 离线容错:服务端宕机时,前端 useGlobalTide hook 会自动重连;
// 在断连期间,本地 tickTide 继续按原节奏工作,玩家不会被卡住。
import { createServer } from "http";
import { Server } from "socket.io";
// ---------------------------------------------------------------------------
// 配置
// ---------------------------------------------------------------------------
/** 全球星潮持续秒数(与前端 triggerGlobalTide 保持一致) */
const DURATION_SEC = 60;
/** 首次星潮延迟(毫秒)—— 默认 60s 便于 QA 验证;可通过 env FIRST_TIDE_DELAY_MS 覆盖 */
const FIRST_TIDE_DELAY_MS = Number(process.env.FIRST_TIDE_DELAY_MS) || 60 * 1000;
/** 后续星潮最小间隔(10 分钟) */
const MIN_GAP_MS = 10 * 60 * 1000;
/** 后续星潮最大间隔(15 分钟) */
const MAX_GAP_MS = 15 * 60 * 1000;
/** 固定端口 3031 */
const PORT = 3031;
// ---------------------------------------------------------------------------
// 星潮类型表(与前端 TIDE_EVENTS 一致:crystal/resonance/ruins/void/core/silence
// ---------------------------------------------------------------------------
type TideType = "crystal" | "resonance" | "ruins" | "void" | "core" | "silence";
const TIDE_META: Record<TideType, { name: string; weight: number }> = {
crystal: { name: "晶体潮", weight: 22 },
resonance: { name: "谐振风暴", weight: 20 },
ruins: { name: "遗迹共振", weight: 16 },
void: { name: "虚空低语", weight: 18 },
core: { name: "星核悸动", weight: 14 },
silence: { name: "寂静期", weight: 10 },
};
const TIDE_TYPES = Object.keys(TIDE_META) as TideType[];
const TIDE_TOTAL_WEIGHT = TIDE_TYPES.reduce((s, t) => s + TIDE_META[t].weight, 0);
function rollTide(): TideType {
let r = Math.random() * TIDE_TOTAL_WEIGHT;
for (const t of TIDE_TYPES) {
r -= TIDE_META[t].weight;
if (r <= 0) return t;
}
return "crystal";
}
function randomGap(): number {
return MIN_GAP_MS + Math.floor(Math.random() * (MAX_GAP_MS - MIN_GAP_MS));
}
// ---------------------------------------------------------------------------
// 全局状态
// ---------------------------------------------------------------------------
interface GlobalTideState {
type: TideType;
name: string;
startedAt: number;
endsAt: number;
durationSec: number;
}
let currentTide: GlobalTideState | null = null;
let nextTideAt: number = Date.now() + FIRST_TIDE_DELAY_MS;
let tideStartTimer: ReturnType<typeof setTimeout> | null = null;
let tideEndTimer: ReturnType<typeof setTimeout> | null = null;
// ---------------------------------------------------------------------------
// 星潮调度
// ---------------------------------------------------------------------------
function startTide(): void {
// 若已有进行中的星潮,先结束再开(理论上不应发生,防御性)
if (currentTide) {
endTide();
}
// 清掉残留的 tideEndTimer,避免泄漏
if (tideEndTimer) {
clearTimeout(tideEndTimer);
tideEndTimer = null;
}
const type = rollTide();
const now = Date.now();
currentTide = {
type,
name: TIDE_META[type].name,
startedAt: now,
endsAt: now + DURATION_SEC * 1000,
durationSec: DURATION_SEC,
};
console.log(
`[global-tide] ✦ ${currentTide.name} (${type}) 已降临 · 持续 ${DURATION_SEC}s · 结束于 ${new Date(currentTide.endsAt).toLocaleTimeString()}`
);
io.emit("global-tide", currentTide);
// 调度结束 → endTide 会自己 clearTimeout(tideEndTimer)
// 结束后自动 scheduleNextTide
tideEndTimer = setTimeout(() => {
endTide();
scheduleNextTide();
}, DURATION_SEC * 1000);
}
function endTide(): void {
if (!currentTide) return;
console.log(`[global-tide] ◌ ${currentTide.name} 已退去`);
currentTide = null;
io.emit("tide-ended", {
endedAt: Date.now(),
nextTideAt,
});
if (tideEndTimer) {
clearTimeout(tideEndTimer);
tideEndTimer = null;
}
}
function scheduleNextTide(): void {
// 清掉残留的 tideStartTimer,避免泄漏
if (tideStartTimer) {
clearTimeout(tideStartTimer);
tideStartTimer = null;
}
const gap = randomGap();
nextTideAt = Date.now() + gap;
console.log(
`[global-tide] 下次星潮将在 ${Math.round(gap / 1000)}s 后 · ${new Date(nextTideAt).toLocaleTimeString()}`
);
tideStartTimer = setTimeout(() => {
startTide();
}, gap);
}
// ---------------------------------------------------------------------------
// socket.io
// ---------------------------------------------------------------------------
// 注意:socket.io `path: '/'` 会拦截所有 HTTP 请求(包括 /health),
// 因此本服务不提供独立 HTTP 健康检查端点;QA 用 `curl http://localhost:3031/`
// 收到 `{"code":0,"message":"Transport unknown"}` 即表示 socket.io 在线。
const httpServer = createServer();
const io = new Server(httpServer, {
// 路径必须为 "/"Caddy 据此转发;同时这也是 catch-all,
// 任何 URL 都会被 socket.io 接管(前端默认 /socket.io/ 也会被捕获)
path: "/",
cors: { origin: "*", methods: ["GET", "POST"] },
pingTimeout: 60000,
pingInterval: 25000,
});
io.on("connection", (socket) => {
console.log(`[socket] 客户端连接: ${socket.id}(当前在线 ${io.engine.clientsCount}`);
// 立即推送当前状态
socket.emit("tide-state", {
current: currentTide,
nextTideAt,
serverTime: Date.now(),
});
// 客户端查询下次星潮剩余时间
socket.on("get-next-tide", () => {
socket.emit("next-tide-info", {
current: currentTide,
nextTideAt,
serverTime: Date.now(),
remainingMs: Math.max(0, nextTideAt - Date.now()),
});
});
// dev/QA 用:手动触发一次全球星潮
socket.on("admin-trigger-tide", () => {
console.log(`[socket] admin-trigger-tide by ${socket.id}`);
// 取消尚未触发的下次星潮定时器(startTide 会负责调度本次星潮的结束 + 下次开始)
if (tideStartTimer) {
clearTimeout(tideStartTimer);
tideStartTimer = null;
}
startTide();
// startTide 内部已 setTimeout(DURATION_SEC) → endTide + scheduleNextTide
});
socket.on("disconnect", (reason) => {
console.log(`[socket] 客户端断开: ${socket.id}${reason}`);
});
socket.on("error", (err) => {
console.error(`[socket] 错误 (${socket.id}):`, err);
});
});
// ---------------------------------------------------------------------------
// 启动
// ---------------------------------------------------------------------------
httpServer.listen(PORT, () => {
console.log(`[star-tide-service] 监听端口 ${PORT}`);
console.log(
`[star-tide-service] 首次星潮将在 ${Math.round(FIRST_TIDE_DELAY_MS / 1000)}s 后 · ${new Date(nextTideAt).toLocaleTimeString()}`
);
console.log(`[star-tide-service] 后续星潮间隔:${MIN_GAP_MS / 1000 / 60}-${MAX_GAP_MS / 1000 / 60} 分钟随机`);
console.log(`[star-tide-service] QA 验证:curl http://localhost:${PORT}/ → {"code":0,"message":"Transport unknown"}`);
// 调度首次星潮(startTide 内部会在 60s 后自动 endTide + scheduleNextTide,无需在此重复调度)
tideStartTimer = setTimeout(() => {
startTide();
}, FIRST_TIDE_DELAY_MS);
});
// ---------------------------------------------------------------------------
// 优雅退出
// ---------------------------------------------------------------------------
function shutdown(signal: string) {
console.log(`[star-tide-service] 收到 ${signal},正在关闭…`);
if (tideStartTimer) clearTimeout(tideStartTimer);
if (tideEndTimer) clearTimeout(tideEndTimer);
io.close(() => {
httpServer.close(() => {
console.log("[star-tide-service] 已关闭");
process.exit(0);
});
});
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
@@ -1,13 +0,0 @@
{
"name": "echo-nexus-star-tide-service",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot index.ts",
"start": "bun index.ts"
},
"dependencies": {
"socket.io": "^4.8.3"
}
}
-2
View File
@@ -72,8 +72,6 @@
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.6", "sonner": "^2.0.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
-44
View File
@@ -8,7 +8,6 @@
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-display: var(--font-display);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -145,17 +144,6 @@
-webkit-text-fill-color: transparent; -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 { @keyframes echo-glow {
0%, 100% { opacity: 0.6; } 0%, 100% { opacity: 0.6; }
@@ -177,35 +165,3 @@
0% { opacity: 0; transform: scale(0.92) translateY(8px); } 0% { opacity: 0; transform: scale(0.92) translateY(8px); }
100% { opacity: 1; transform: scale(1) translateY(0); } 100% { opacity: 1; transform: scale(1) translateY(0); }
} }
/* v0.11 晶体卡片 hover 扫描线 */
@keyframes echo-scan {
0% { top: 0%; opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
100% { top: 100%; opacity: 0; }
}
/* v0.11 晶体卡片 hover 边框流光 */
@keyframes echo-border-flow {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
/* v0.12 考古日志滚动条 */
@keyframes echo-ticker-marquee {
0% { transform: translateX(100%); }
100% { transform: translateX(-100%); }
}
@keyframes echo-ticker-fade {
0% { opacity: 0; }
100% { opacity: 1; }
}
.echo-ticker-anim {
animation: echo-ticker-marquee 28s linear forwards, echo-ticker-fade 0.5s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.echo-ticker-anim {
animation: echo-ticker-fade 0.5s ease-out;
}
}
+2 -10
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono, Orbitron } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
@@ -13,14 +13,6 @@ const geistMono = Geist_Mono({
subsets: ["latin"], 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 = { export const metadata: Metadata = {
title: "回响星核 · Echo Nexus", title: "回响星核 · Echo Nexus",
description: "原创深空考古放置策略游戏 — 解码记忆晶体,拼凑以太文明的回响,跨越维度发起接触。", description: "原创深空考古放置策略游戏 — 解码记忆晶体,拼凑以太文明的回响,跨越维度发起接触。",
@@ -50,7 +42,7 @@ export default function RootLayout({
return ( return (
<html lang="zh-CN" suppressHydrationWarning className="dark"> <html lang="zh-CN" suppressHydrationWarning className="dark">
<body <body
className={`${geistSans.variable} ${geistMono.variable} ${orbitron.variable} antialiased bg-background text-foreground`} className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
> >
{children} {children}
<Toaster /> <Toaster />
+96 -270
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
// 回响星核 / Echo Nexus — 游戏主入口 // 回响星核 / Echo Nexus — 游戏主入口
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { StarfieldCanvas } from "@/components/game/StarfieldCanvas"; import { StarfieldCanvas } from "@/components/game/StarfieldCanvas";
import { ResourceBar } from "@/components/game/ResourceBar"; import { ResourceBar } from "@/components/game/ResourceBar";
import { CrystalOrb } from "@/components/game/CrystalOrb"; import { CrystalOrb } from "@/components/game/CrystalOrb";
@@ -13,18 +13,18 @@ import { ExpeditionPanel } from "@/components/game/ExpeditionPanel";
import { AchievementsPanel } from "@/components/game/AchievementsPanel"; import { AchievementsPanel } from "@/components/game/AchievementsPanel";
import { AchievementNotifier } from "@/components/game/AchievementNotifier"; import { AchievementNotifier } from "@/components/game/AchievementNotifier";
import { ConstellationPanel } from "@/components/game/ConstellationPanel"; 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 { BeaconPanel } from "@/components/game/BeaconPanel";
import { TutorialOverlay } from "@/components/game/TutorialOverlay"; import { TutorialOverlay } from "@/components/game/TutorialOverlay";
// v0.10.1 性能优化:重型对话框/全屏组件动态导入,减少首屏 bundle import { OfflineReportDialog } from "@/components/game/OfflineReportDialog";
import dynamic from "next/dynamic"; import { CruiseMode } from "@/components/game/CruiseMode";
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 { AttributesPanel } from "@/components/game/AttributesPanel";
import { ExplorationRadar } from "@/components/game/ExplorationRadar"; import { IdleOperationsPanel } from "@/components/game/IdleOperationsPanel";
import { RelicCodex } from "@/components/game/RelicCodex"; import { IdleStatusBadge } from "@/components/game/IdleStatusBadge";
import { ArchaeoLogTicker } from "@/components/game/ArchaeoLogTicker"; import { IdleProjectBar } from "@/components/game/IdleProjectBar";
import { StatsPanel } from "@/components/game/StatsPanel";
import { NarrativeMilestoneOverlay } from "@/components/game/NarrativeMilestoneOverlay";
import { import {
StarTideNotifier, StarTideNotifier,
StarTideIndicator, StarTideIndicator,
@@ -32,9 +32,7 @@ import {
} from "@/components/game/StarTide"; } from "@/components/game/StarTide";
import { useGameLoop } from "@/hooks/useGameLoop"; import { useGameLoop } from "@/hooks/useGameLoop";
import { useAudioSync } from "@/hooks/useAudio"; import { useAudioSync } from "@/hooks/useAudio";
import { useGlobalTide } from "@/hooks/useGlobalTide";
import { useGameStore } from "@/store/gameStore"; import { useGameStore } from "@/store/gameStore";
import { shallow } from "zustand/shallow";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -51,22 +49,17 @@ import {
Radio, Radio,
Navigation, Navigation,
User, User,
Gem, Clock,
Zap,
Flame,
Database,
BookMarked,
} from "lucide-react"; } from "lucide-react";
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config"; import { FRAGMENTS, formatNum } from "@/lib/game/config";
import { ACHIEVEMENTS } from "@/lib/game/achievements"; import { ACHIEVEMENTS } from "@/lib/game/achievements";
import { TIDE_EVENTS } from "@/lib/game/starTide"; import { TIDE_EVENTS } from "@/lib/game/starTide";
import { CONSTELLATION_PERKS } from "@/lib/game/constellation"; import { generateDailyChallenge, loadDailyProgress } from "@/lib/game/beacon";
import { generateDailyChallenge, loadDailyProgress, loadLeaderboard } from "@/lib/game/beacon"; import { getUnlockedIdleProjects } from "@/lib/game/idle";
export default function Page() { export default function Page() {
useGameLoop(); useGameLoop();
useAudioSync(); useAudioSync();
useGlobalTide();
const [prestigeOpen, setPrestigeOpen] = useState(false); const [prestigeOpen, setPrestigeOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [constellationOpen, setConstellationOpen] = useState(false); const [constellationOpen, setConstellationOpen] = useState(false);
@@ -86,11 +79,18 @@ export default function Page() {
const crystals = useGameStore((s) => s.crystals); const crystals = useGameStore((s) => s.crystals);
const crystalCap = useGameStore((s) => s.crystalCap); const crystalCap = useGameStore((s) => s.crystalCap);
const activeTide = useGameStore((s) => s.activeTide); const activeTide = useGameStore((s) => s.activeTide);
const globalTide = useGameStore((s) => s.globalTide);
const energy = useGameStore((s) => s.energy); const energy = useGameStore((s) => s.energy);
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished); const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0); const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
const pendingAttrPoints = useGameStore((s) => s.pendingAttrPoints ?? 0); const pendingAttrPoints = useGameStore((s) => s.pendingAttrPoints ?? 0);
// v0.14 放置系统:用于驱动 goal 提示 + 标签默认值
const idleProjectSlots = useGameStore(
(s) => (s.idleProjectSlots ?? [null, null, null]) as (
| { projectId: string; completed: boolean }
| null
)[]
);
const createdAt = useGameStore((s) => s.createdAt);
// 深空信标:检测是否有可领取的奖励(独立 localStorage // 深空信标:检测是否有可领取的奖励(独立 localStorage
const [beaconClaimable, setBeaconClaimable] = useState(false); const [beaconClaimable, setBeaconClaimable] = useState(false);
@@ -117,10 +117,26 @@ export default function Page() {
// 挂载检测:避免持久化 store 在 SSR/CSR 间产生 hydration 不一致 // 挂载检测:避免持久化 store 在 SSR/CSR 间产生 hydration 不一致
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true); setMounted(true);
}, []); }, []);
// v0.14 放置系统:默认放置标签(在挂载后按优先级切换一次)
// 注意:所有 hooks 必须在早期 return 之前调用,保持调用顺序一致
const [activeTab, setActiveTab] = useState("idle");
const tabInited = useRef(false);
const hasPendingPerkEarly = !!(pendingPerkChoices && pendingPerkChoices.length > 0);
useEffect(() => {
if (tabInited.current) return;
tabInited.current = true;
if (hasActiveExpedition) {
setActiveTab("expedition");
} else if (hasPendingPerkEarly) {
setActiveTab("constellation");
} else {
setActiveTab("idle");
}
}, [hasActiveExpedition, hasPendingPerkEarly]);
// 防止 SSR/CSR 不一致 // 防止 SSR/CSR 不一致
if (!mounted) { if (!mounted) {
return ( return (
@@ -139,6 +155,19 @@ export default function Page() {
// 仓库满仓警告 // 仓库满仓警告
const warehouseFull = crystals >= crystalCap * 0.98; const warehouseFull = crystals >= crystalCap * 0.98;
// v0.14 放置系统相关 goal 提示
const idleClaimableCount = idleProjectSlots.filter(
(s) => s !== null && s.completed
).length;
const idleRunningCount = idleProjectSlots.filter(
(s) => s !== null && !s.completed
).length;
const playtimeSec = (Date.now() - (createdAt ?? Date.now())) / 1000;
const unlockedIdleCount = getUnlockedIdleProjects({
ascensions,
crystalsPerSec,
}).length;
// 目标提示 // 目标提示
let goal = "点击中央晶体发起脉冲,累积记忆晶体"; let goal = "点击中央晶体发起脉冲,累积记忆晶体";
if (totalDecoded === 0 && crystals >= 5) { if (totalDecoded === 0 && crystals >= 5) {
@@ -162,10 +191,19 @@ export default function Page() {
} }
if (activeTide) { if (activeTide) {
const tm = TIDE_EVENTS[activeTide.type]; const tm = TIDE_EVENTS[activeTide.type];
const prefix = globalTide ? "🌐 全球星潮 · " : ""; goal = `${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
goal = `${prefix}${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
} }
if (canPrestige) goal = "✦ 接触进度已满,可发起飞升进入新周目"; if (canPrestige) goal = "✦ 接触进度已满,可发起飞升进入新周目";
// v0.14 放置工程 goal 提示(高优先级,覆盖默认)
if (idleClaimableCount > 0) {
goal = `✦ 放置工程已完成 ${idleClaimableCount} 项,请前往「放置」标签领取奖励`;
} else if (
idleRunningCount === 0 &&
unlockedIdleCount > 0 &&
playtimeSec > 60
) {
goal = "「放置」标签可派遣工程项目,离线自动产出";
}
return ( return (
<div className="relative min-h-screen flex flex-col bg-[#050410] text-foreground overflow-x-hidden"> <div className="relative min-h-screen flex flex-col bg-[#050410] text-foreground overflow-x-hidden">
@@ -185,14 +223,15 @@ export default function Page() {
</div> </div>
</div> </div>
<div className="leading-tight"> <div className="leading-tight">
<h1 className="text-base sm:text-lg font-bold text-gradient font-display"></h1> <h1 className="text-base sm:text-lg font-bold text-gradient"></h1>
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider font-tech"> <p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider">
ECHO NEXUS · v0.12 ECHO NEXUS · v0.16
</p> </p>
</div> </div>
</div> </div>
<div className="ml-auto flex items-center gap-1.5"> <div className="ml-auto flex items-center gap-1.5">
<StarTideIndicator /> <StarTideIndicator />
<IdleStatusBadge onClick={() => setActiveTab("idle")} />
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -256,52 +295,40 @@ export default function Page() {
<ResourceBar onPrestige={() => setPrestigeOpen(true)} /> <ResourceBar onPrestige={() => setPrestigeOpen(true)} />
</header> </header>
{/* 主体 — v0.10 工单 #2 布局重构:反转左右比例,左栏紧凑晶体球,右栏扩展核心玩法 */} {/* 主体 — 小屏允许自然滚动,避免 min-h 总和超过视口导致挤压重叠 */}
<main className="flex-1 px-3 sm:px-5 pb-3 min-h-0"> <main className="flex-1 px-3 sm:px-5 pb-3 min-h-0">
<div className="grid grid-cols-1 lg:grid-cols-[minmax(300px,360px)_1fr] gap-3 h-full"> <div className="grid grid-cols-1 lg:grid-cols-[1fr_minmax(360px,420px)] 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"> <section className="glass rounded-2xl p-4 sm:p-6 flex flex-col items-center justify-center min-h-[340px] sm:min-h-[420px] relative overflow-hidden">
{/* 装饰光圈 */} {/* 装饰光圈 */}
<div className="pointer-events-none absolute -top-16 -left-16 h-40 w-40 rounded-full bg-emerald-500/10 blur-3xl" /> <div className="pointer-events-none absolute -top-20 -left-20 h-60 w-60 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" /> <div className="pointer-events-none absolute -bottom-20 -right-20 h-60 w-60 rounded-full bg-fuchsia-500/10 blur-3xl" />
{/* v0.10 装饰全息环(缩小适应紧凑布局) */} <div data-tut="crystal-orb" className="contents">
<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 /> <CrystalOrb />
</div> </div>
{/* v0.14 放置工程进度条(晶体球下方) */}
{/* v0.9 全息收益信息面板 */} <IdleProjectBar />
<CrystalYieldPanel />
{/* v0.10 探索目标雷达(新增)— 填充留白,让晶体球区有价值 */}
<ExplorationRadar />
</section> </section>
{/* 右侧:解码 + 标签面板v0.10 扩展,从 420px 扩到 1fr */} {/* 右侧:解码 + 标签面板 */}
<section className="flex flex-col gap-3 min-h-0"> <section className="flex flex-col gap-3 min-h-0">
{/* 解码面板 */} {/* 解码面板 */}
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[280px] sm:min-h-[320px] max-h-[480px]"> <div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[300px] sm:min-h-[360px] max-h-[560px]">
<DecodePanel /> <DecodePanel />
</div> </div>
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 / 遗迹图鉴 */} {/* 标签面板:放置 / 探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
<div className="glass rounded-2xl p-3 min-h-[320px] sm:min-h-[380px] max-h-[560px] flex flex-col"> <div className="glass rounded-2xl p-3 min-h-[280px] sm:min-h-[320px] max-h-[440px] flex flex-col">
<Tabs defaultValue={hasActiveExpedition ? "expedition" : hasPendingPerk ? "constellation" : "tech"} className="h-full flex flex-col"> <Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
{/* v0.10 标签页响应式布局,小屏 3 列 3 行避免拥挤遮挡,大屏 9 列 */} <TabsList className="grid grid-cols-9 h-9 bg-black/30 gap-0.5 p-1">
<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="idle" data-tut="tab-idle" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-emerald-500/15 data-[state=active]:shadow-[0_0_12px_rgba(52,211,153,0.3)] transition-all">
<Clock className="h-3.5 w-3.5" />
<span className="leading-none"></span>
{idleClaimableCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[14px] h-[14px] px-1 rounded-full bg-emerald-500 text-[9px] font-mono font-bold text-white flex items-center justify-center border border-emerald-300/50 animate-pulse">
{idleClaimableCount}
</span>
)}
</TabsTrigger>
<TabsTrigger value="expedition" data-tut="tab-expedition" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-amber-500/15 data-[state=active]:shadow-[0_0_12px_rgba(251,191,36,0.3)] transition-all"> <TabsTrigger value="expedition" data-tut="tab-expedition" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-amber-500/15 data-[state=active]:shadow-[0_0_12px_rgba(251,191,36,0.3)] transition-all">
<Rocket className="h-3.5 w-3.5" /> <Rocket className="h-3.5 w-3.5" />
<span className="leading-none"></span> <span className="leading-none"></span>
@@ -352,11 +379,10 @@ export default function Page() {
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-rose-400 animate-pulse ring-1 ring-black/50" /> <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>
<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> </TabsList>
<TabsContent value="idle" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<IdleOperationsPanel />
</TabsContent>
<TabsContent value="expedition" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col"> <TabsContent value="expedition" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<ExpeditionPanel /> <ExpeditionPanel />
</TabsContent> </TabsContent>
@@ -381,20 +407,12 @@ export default function Page() {
<TabsContent value="attributes" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col"> <TabsContent value="attributes" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<AttributesPanel /> <AttributesPanel />
</TabsContent> </TabsContent>
<TabsContent value="relic" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<RelicCodex />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
</section> </section>
</div> </div>
</main> </main>
{/* v0.12 考古日志滚动条 — footer 上方的叙事层 */}
<div className="px-3 sm:px-5 pb-1 shrink-0">
<ArchaeoLogTicker />
</div>
{/* 底部 Footer */} {/* 底部 Footer */}
<footer className="sticky bottom-0 z-20 mt-auto px-3 sm:px-5 pb-2 pt-1"> <footer className="sticky bottom-0 z-20 mt-auto px-3 sm:px-5 pb-2 pt-1">
<div <div
@@ -451,202 +469,10 @@ export default function Page() {
<TutorialOverlay /> <TutorialOverlay />
<OfflineReportDialog /> <OfflineReportDialog />
{cruiseOpen && <CruiseMode onClose={() => setCruiseOpen(false)} />} {cruiseOpen && <CruiseMode onClose={() => setCruiseOpen(false)} />}
{/* v0.16 叙事里程碑全屏叠层(pendingMilestone 非空时渲染,模态盖住一切) */}
<NarrativeMilestoneOverlay />
</div> </div>
); );
} }
/**
* v0.9 工单 #2:全息收益信息面板
* 在中央晶体球下方展示 4 项核心收益指标(产能 / 脉冲 / 连击 / 仓库),
* 让玩家明确感知晶体球的价值,缓解"占地方又没用"的反馈。
* 严格四色全息配色:emerald / rose / amber / fuchsia(禁止蓝色/靛色)。
*/
function CrystalYieldPanel() {
const crystalsPerSec = useGameStore((s) => s.crystalsPerSec);
const pulsePower = useGameStore((s) => s.pulsePower);
const combo = useGameStore((s) => s._combo);
const crystals = useGameStore((s) => s.crystals);
const crystalCap = useGameStore((s) => s.crystalCap);
// 连击倍率:1 + (combo - 1) * 0.15(仅 combo≥1 时生效)
const comboMult = combo >= 1 ? 1 + (combo - 1) * 0.15 : 1;
const comboHot = combo >= 3; // 连击≥3 高亮闪烁
// 仓库容量百分比
const fillPct = crystalCap > 0 ? (crystals / crystalCap) * 100 : 0;
const warehouseWarn = fillPct >= 90; // ≥90% 警告色 + pulse
return (
<div className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 w-full max-w-md pointer-events-none">
{/* 晶体产能 — emerald */}
<div className="rounded-lg border border-emerald-400/30 bg-black/30 backdrop-blur p-2 flex items-center gap-1.5">
<Gem className="h-3.5 w-3.5 text-emerald-400 shrink-0" />
<div className="min-w-0 leading-tight">
<div className="text-[9px] uppercase tracking-wider text-emerald-300/70 truncate">
</div>
<div className="text-sm font-mono tabular-nums text-emerald-200 truncate">
{formatNum(crystalsPerSec)}
<span className="text-[9px] text-emerald-300/60">/s</span>
</div>
</div>
</div>
{/* 主动脉冲 — rose */}
<div className="rounded-lg border border-rose-400/30 bg-black/30 backdrop-blur p-2 flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-rose-400 shrink-0" />
<div className="min-w-0 leading-tight">
<div className="text-[9px] uppercase tracking-wider text-rose-300/70 truncate">
</div>
<div className="text-sm font-mono tabular-nums text-rose-200 truncate">
{formatNum(pulsePower)}
{combo >= 1 && (
<span className="text-[9px] text-rose-300/80"> ×{comboMult.toFixed(2)}</span>
)}
</div>
</div>
</div>
{/* 当前连击 — amber,≥3 时高亮闪烁 */}
<div
className={`rounded-lg border bg-black/30 backdrop-blur p-2 flex items-center gap-1.5 ${
comboHot ? "border-amber-400/70 animate-pulse" : "border-amber-400/30"
}`}
>
<Flame
className={`h-3.5 w-3.5 text-amber-400 shrink-0 ${
comboHot ? "drop-shadow-[0_0_4px_rgba(251,191,36,0.7)]" : ""
}`}
/>
<div className="min-w-0 leading-tight">
<div className="text-[9px] uppercase tracking-wider text-amber-300/70 truncate">
</div>
<div
className={`text-sm font-mono tabular-nums truncate ${
comboHot ? "text-amber-200" : "text-amber-300/80"
}`}
>
{combo}
<span className="text-[9px] text-amber-300/60">/10</span>
</div>
</div>
</div>
{/* 仓库容量 — fuchsia,≥90% 切 rose 警告色 + pulse */}
<div
className={`rounded-lg border bg-black/30 backdrop-blur p-2 flex items-center gap-1.5 ${
warehouseWarn ? "border-rose-400/70 animate-pulse" : "border-fuchsia-400/30"
}`}
>
<Database
className={`h-3.5 w-3.5 shrink-0 ${
warehouseWarn ? "text-rose-400" : "text-fuchsia-400"
}`}
/>
<div className="min-w-0 leading-tight">
<div
className={`text-[9px] uppercase tracking-wider truncate ${
warehouseWarn ? "text-rose-300/80" : "text-fuchsia-300/70"
}`}
>
</div>
<div
className={`text-sm font-mono tabular-nums truncate ${
warehouseWarn ? "text-rose-200" : "text-fuchsia-200"
}`}
>
{formatNum(crystals)}
<span
className={`text-[9px] ${
warehouseWarn ? "text-rose-300/70" : "text-fuchsia-300/60"
}`}
>
/{formatNum(crystalCap)}
</span>
</div>
</div>
</div>
</div>
);
}
function StatsPanel() {
// v0.11 性能优化:用 shallow 聚合 selector 替代全量订阅 useGameStore()
// 避免每 tick(250ms)因无关字段变化触发重渲染。仅当所列字段引用变化才重渲染。
const s = useGameStore(
(st) => ({
totalDecoded: st.totalDecoded,
ascensions: st.ascensions,
blueprints: st.blueprints,
tech: st.tech,
fragments: st.fragments,
achievements: st.achievements,
constellation: st.constellation,
contact: st.contact,
crystalsPerSec: st.crystalsPerSec,
crystalCap: st.crystalCap,
insightMult: st.insightMult,
totalExpeditions: st.totalExpeditions,
bossKills: st.bossKills,
starTidesEncountered: st.starTidesEncountered,
chronicle: st.chronicle,
energy: st.energy,
energyMax: st.energyMax,
attributes: st.attributes,
pendingAttrPoints: st.pendingAttrPoints,
}),
shallow
);
const achCount = Object.values(s.achievements).filter(Boolean).length;
// 深空信标本地排行榜最高分(独立 localStorage
const [beaconBest, setBeaconBest] = useState<number | null>(null);
useEffect(() => {
try {
const lb = loadLeaderboard();
// eslint-disable-next-line react-hooks/set-state-in-effect
setBeaconBest(lb.length > 0 ? lb[0].score : null);
} catch {
setBeaconBest(null);
}
}, []);
const rows = [
{ label: "累计解码晶体", value: `${s.totalDecoded}` },
{ label: "飞升周目", value: `${s.ascensions}` },
{ label: "持有蓝图", value: `${s.blueprints.length} / 6` },
{ label: "已学技术", value: `${Object.values(s.tech).filter((v) => v > 0).length} / ${TECH_TREE.length}` },
{ label: "已获碎片", value: `${Object.values(s.fragments).filter(Boolean).length} / ${FRAGMENTS.length}` },
{ label: "已解锁成就", value: `${achCount} / ${ACHIEVEMENTS.length}` },
{ label: "星图天赋", value: `${s.constellation?.length ?? 0} / ${CONSTELLATION_PERKS.length}` },
{ label: "接触进度", value: `${s.contact.toFixed(1)}%` },
{ label: "晶体产能", value: `${s.crystalsPerSec.toFixed(2)} /s` },
{ label: "仓库上限", value: `${formatNum(s.crystalCap)}` },
{ label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` },
{ label: "累计探险", value: `${s.totalExpeditions}` },
{ label: "BOSS 击破", value: `${s.bossKills ?? 0}` },
{ label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 9` },
{ label: "编年史条目", value: `${(s.chronicle ?? []).length} 纪元` },
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
{ label: "信标最高分", value: beaconBest !== null ? formatNum(beaconBest) : "—" },
{ label: "探索力", value: `${s.attributes?.exploration ?? 0} / 100` },
{ label: "智慧", value: `${s.attributes?.wisdom ?? 0} / 100` },
{ label: "勇气", value: `${s.attributes?.courage ?? 0} / 100` },
{ label: "灵感", value: `${s.attributes?.inspiration ?? 0} / 100` },
{ label: "待分配属性点", value: `${s.pendingAttrPoints ?? 0}` },
];
return (
<div className="grid grid-cols-2 gap-1.5 text-xs">
{rows.map((r) => (
<div
key={r.label}
className="flex items-center justify-between rounded-md bg-black/25 border border-white/5 px-2 py-1.5"
>
<span className="text-muted-foreground">{r.label}</span>
<span className="font-mono text-foreground">{r.value}</span>
</div>
))}
</div>
);
}
+4 -146
View File
@@ -1,168 +1,26 @@
"use client"; "use client";
// 回响星核 / Echo Nexus — 成就解锁通知v0.9 全屏特效增强) // 回响星核 / Echo Nexus — 成就解锁通知消费者
// 工单 Issue #2 反馈"成就系统没看出来有什么用",改为居中全息弹窗 + 奖励数字弹出 import { useEffect } from "react";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGameStore } from "@/store/gameStore"; import { useGameStore } from "@/store/gameStore";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio"; import { sfx } from "@/hooks/useAudio";
import type { Achievement } from "@/lib/game/achievements";
interface NotificationItem {
achievement: Achievement;
id: number;
}
export function AchievementNotifier() { export function AchievementNotifier() {
const queue = useGameStore((s) => s._achievementQueue); const queue = useGameStore((s) => s._achievementQueue);
const consume = useGameStore((s) => s.consumeAchievementQueue); const consume = useGameStore((s) => s.consumeAchievementQueue);
const { toast } = useToast(); const { toast } = useToast();
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
useEffect(() => { useEffect(() => {
if (queue.length === 0) return; if (queue.length === 0) return;
const items = consume(); const items = consume();
for (const a of items) { for (const a of items) {
sfx("achievement"); 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({ toast({
title: `成就解锁:${a.name}`, title: `🏆 成就解锁:${a.name}`,
description: `${a.desc} 奖励:${a.rewardText}`, description: `${a.desc} 奖励:${a.rewardText}`,
}); });
} }
}, [queue, consume, toast]); }, [queue, consume, toast]);
if (notifications.length === 0) return null; 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>
);
} }
+43 -125
View File
@@ -1,64 +1,30 @@
"use client"; "use client";
// 回响星核 / Echo Nexus — 成就系统面板(v0.10 工单 #2 重写:单列 + 分类过滤 + 防溢出) // 回响星核 / Echo Nexus — 成就系统面板
import { useState, useMemo } from "react";
import { useGameStore } from "@/store/gameStore"; import { useGameStore } from "@/store/gameStore";
import { ACHIEVEMENTS, achievementBonuses } from "@/lib/game/achievements"; import { ACHIEVEMENTS } from "@/lib/game/achievements";
import { Trophy, Lock, Sparkles, Filter } from "lucide-react"; import { achievementBonuses } from "@/lib/game/achievements";
import { Trophy, Lock } 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() { export function AchievementsPanel() {
const achievements = useGameStore((s) => s.achievements); const achievements = useGameStore((s) => s.achievements);
const [filter, setFilter] = useState<FilterKey>("all");
const unlockedCount = Object.values(achievements).filter(Boolean).length; const unlockedCount = Object.values(achievements).filter(Boolean).length;
const bonus = achievementBonuses(achievements); 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 ( return (
<div className="flex flex-col gap-2.5 h-full min-h-0"> <div className="flex flex-col gap-2.5 h-full">
{/* 顶部:标题 + 进度 */} {/* 顶部:进度 + 加成总览 */}
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold flex items-center gap-1.5 shrink-0"> <h3 className="text-sm font-semibold flex items-center gap-1.5">
<Trophy className="h-4 w-4 text-amber-400" /> <Trophy className="h-4 w-4 text-amber-400" />
</h3> </h3>
<div className="flex items-center gap-2 text-[10px] font-mono"> <span className="text-[10px] text-muted-foreground font-mono">
<span className="text-amber-300">{unlockedCount}</span> {unlockedCount} / {ACHIEVEMENTS.length}
<span className="text-muted-foreground/50">/</span> </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>
{/* 永久加成条 */} {/* 永久加成条 */}
<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"> <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]">
<Sparkles className="h-3 w-3 text-amber-400 shrink-0" />
<span className="text-amber-300/80"></span> <span className="text-amber-300/80"></span>
{bonus.crystalsPerSecPct > 0 && ( {bonus.crystalsPerSecPct > 0 && (
<span className="text-emerald-300"> +{bonus.crystalsPerSecPct}%</span> <span className="text-emerald-300"> +{bonus.crystalsPerSecPct}%</span>
@@ -67,106 +33,64 @@ export function AchievementsPanel() {
<span className="text-fuchsia-300"> +{bonus.insightPct}%</span> <span className="text-fuchsia-300"> +{bonus.insightPct}%</span>
)} )}
{bonus.crystalsPerSecPct === 0 && bonus.insightPct === 0 && ( {bonus.crystalsPerSecPct === 0 && bonus.insightPct === 0 && (
<span className="text-muted-foreground/60"></span> <span className="text-muted-foreground/60"></span>
)} )}
</div> </div>
{/* 分类过滤标签 */} {/* 成就网格 — 移除固定 max-h,用 flex-1 min-h-0 自适应父容器,避免小屏挤压重叠 */}
<div className="flex items-center gap-1 text-[10px]"> <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">
<Filter className="h-3 w-3 text-muted-foreground/60 shrink-0" /> {ACHIEVEMENTS.map((a) => {
{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]; const unlocked = !!achievements[a.id];
return ( return (
<div <div
key={a.id} key={a.id}
className={`relative rounded-lg border px-2.5 py-2 transition-all duration-300 min-h-[68px] flex items-center ${ className={`relative rounded-lg border px-2.5 py-2 transition-all duration-300 overflow-hidden ${
unlocked unlocked
? "border-white/15 bg-black/30" ? "border-white/15 bg-black/30"
: "border-white/5 bg-black/15 opacity-70" : "border-white/5 bg-black/15 opacity-60"
}`} }`}
style={ style={
unlocked unlocked
? { boxShadow: `inset 0 0 14px ${a.color}10` } ? { boxShadow: `inset 0 0 16px ${a.color}11` }
: undefined : undefined
} }
> >
{unlocked && ( {unlocked && (
<div <div
className="absolute -top-6 -right-6 h-16 w-16 rounded-full blur-2xl opacity-40 pointer-events-none" className="absolute -top-6 -right-6 h-16 w-16 rounded-full blur-2xl opacity-40"
style={{ background: a.color }} style={{ background: a.color }}
/> />
)} )}
{/* 图标徽章 */} <div className="relative flex items-start gap-2">
<div {/* 图标徽章 */}
className="shrink-0 h-10 w-10 rounded-lg flex items-center justify-center text-lg mr-2.5" <div
style={{ className="shrink-0 h-8 w-8 rounded-lg flex items-center justify-center text-base"
background: unlocked ? `${a.color}22` : "rgba(255,255,255,0.04)", style={{
border: `1px solid ${unlocked ? a.color + "55" : "rgba(255,255,255,0.08)"}`, background: unlocked ? `${a.color}22` : "rgba(255,255,255,0.04)",
color: unlocked ? a.color : "#666", border: `1px solid ${unlocked ? a.color + "55" : "rgba(255,255,255,0.08)"}`,
boxShadow: unlocked ? `0 0 10px ${a.color}33` : "none", color: unlocked ? a.color : "#666",
}} boxShadow: unlocked ? `0 0 10px ${a.color}33` : "none",
> }}
{unlocked ? a.icon : <Lock className="h-4 w-4" />} >
</div> {unlocked ? a.icon : <Lock className="h-3.5 w-3.5" />}
{/* 文本 — 使用 flex-1 + min-w-0 防止文本撑爆容器 */} </div>
<div className="min-w-0 flex-1 flex flex-col gap-0.5"> {/* 文本 */}
<div className="flex items-center gap-1.5"> <div className="min-w-0 flex-1">
<span <div
className="text-[11px] font-semibold truncate" className="text-[11px] font-semibold truncate"
style={{ color: unlocked ? a.color : "rgba(255,255,255,0.5)" }} style={{ color: unlocked ? a.color : "rgba(255,255,255,0.5)" }}
> >
{a.name} {a.name}
</span> </div>
<div className="text-[10px] text-muted-foreground/80 leading-tight mt-0.5">
{a.desc}
</div>
{unlocked && ( {unlocked && (
<span <div className="text-[9px] mt-1 font-mono" style={{ color: a.color + "cc" }}>
className="text-[8px] px-1 py-0 rounded shrink-0 font-mono" {a.rewardText}
style={{ background: `${a.color}22`, color: a.color }} </div>
>
</span>
)} )}
</div> </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>
</div> </div>
); );
@@ -177,12 +101,6 @@ export function AchievementsPanel() {
.echo-scroll::-webkit-scrollbar { width: 5px; } .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-thumb { background: rgba(255,255,255,0.14); border-radius: 3px; }
.echo-scroll::-webkit-scrollbar-track { background: transparent; } .echo-scroll::-webkit-scrollbar-track { background: transparent; }
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
`}</style> `}</style>
</div> </div>
); );
-89
View File
@@ -1,89 +0,0 @@
"use client";
// 回响星核 / Echo Nexus — 考古日志滚动条(v0.12 差异化叙事)
// 参考 Antimatter Dimensions news ticker
// 固定在 footer 上方,marquee 横向滚动,每 18-30s 推送新条目
// 让"等待"承载叙事——星核沉睡时,回响仍在被翻译
/* eslint-disable react-hooks/set-state-in-effect -- 定时刷新日志是放置游戏的合理副作用,effect 内 setState 不可避免 */
import { useEffect, useRef, useState, useCallback } from "react";
import { useGameStore } from "@/store/gameStore";
import { pickLogEntry, type ArchaeoLogEntry, type LogTier } from "@/lib/game/archaeoLog";
import { ScrollText } from "lucide-react";
const TIER_META: Record<LogTier, { label: string; color: string; glow: string }> = {
dawn: { label: "初醒", color: "text-emerald-300", glow: "rgba(52,211,153,0.5)" },
awakening: { label: "觉醒", color: "text-amber-300", glow: "rgba(251,191,36,0.5)" },
contact: { label: "接触", color: "text-fuchsia-300", glow: "rgba(232,121,249,0.5)" },
ascension: { label: "飞升", color: "text-rose-300", glow: "rgba(251,113,133,0.5)" },
deep: { label: "深度", color: "text-fuchsia-200", glow: "rgba(232,121,249,0.7)" },
};
export function ArchaeoLogTicker() {
const ascensions = useGameStore((s) => s.ascensions);
const contact = useGameStore((s) => s.contact);
const [entry, setEntry] = useState<ArchaeoLogEntry | null>(null);
const [recentIds, setRecentIds] = useState<string[]>([]);
const [fadeKey, setFadeKey] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const refresh = useCallback(() => {
const next = pickLogEntry(ascensions, contact, recentIds);
setEntry(next);
setRecentIds((prev) => [next.text, ...prev].slice(0, 8));
setFadeKey((k) => k + 1);
}, [ascensions, contact, recentIds]);
// 初始 + 进度变化时刷新一次(解锁新阶段)
useEffect(() => {
refresh();
}, [ascensions >= 1, ascensions >= 3, ascensions >= 5, ascensions >= 10, contact >= 5, contact >= 25, contact >= 80]);
// 定时刷新(18-30s 随机间隔,避免机械感)
useEffect(() => {
const schedule = () => {
const delay = 18000 + Math.random() * 12000;
timerRef.current = setTimeout(() => {
refresh();
schedule();
}, delay);
};
schedule();
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [refresh]);
if (!entry) return null;
const meta = TIER_META[entry.tier];
return (
<div
className="relative w-full overflow-hidden rounded-lg border border-white/10 bg-black/40 backdrop-blur-sm px-3 py-1.5 flex items-center gap-2.5 group"
style={{ boxShadow: `inset 0 0 12px ${meta.glow}` }}
role="status"
aria-live="polite"
aria-label="考古日志"
>
{/* 左侧标签 */}
<div className="flex items-center gap-1.5 shrink-0 z-10 bg-black/40 backdrop-blur-sm pr-2 -ml-1 pl-1 rounded">
<ScrollText className={`h-3 w-3 ${meta.color}`} />
<span className={`text-[9px] font-mono uppercase tracking-[0.15em] ${meta.color}`}>
{meta.label}
</span>
</div>
{/* 滚动文本区 */}
<div className="flex-1 overflow-hidden relative">
<div
key={fadeKey}
className="whitespace-nowrap text-[11px] text-muted-foreground/90 font-mono echo-ticker-anim"
>
<span className="text-white/60 mr-2"></span>
{entry.text}
</div>
</div>
{/* 右侧渐隐遮罩 */}
<div className="pointer-events-none absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-black/60 to-transparent" />
</div>
);
}
+69 -501
View File
@@ -2,44 +2,33 @@
// 回响星核 / Echo Nexus — 深空信标面板 // 回响星核 / Echo Nexus — 深空信标面板
// v0.5:每日挑战 + 本地排行榜 // v0.5:每日挑战 + 本地排行榜
// v0.8:周挑战 + 信标链(连续完成奖励) // v0.8:周挑战 + 信标链(连续完成奖励)
// v0.8.2:限时挑战(每 4 小时刷新,amber 主题) import { useState, useEffect, useCallback } from "react";
// v0.8.2 P2/#4:本地/全球双榜(云排行榜 mini-service 端口 3030
import { useState, useEffect, useCallback, useRef } from "react";
import { useGameStore } from "@/store/gameStore"; import { useGameStore } from "@/store/gameStore";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio"; import { sfx } from "@/hooks/useAudio";
import { import {
generateDailyChallenge, generateDailyChallenge,
generateWeeklyChallenge, generateWeeklyChallenge,
generateTimedChallenge,
loadDailyProgress, loadDailyProgress,
loadWeeklyProgress, loadWeeklyProgress,
loadTimedProgress,
loadLeaderboard, loadLeaderboard,
loadChainState, loadChainState,
claimBeaconReward, claimBeaconReward,
fetchCloudLeaderboard,
submitCloudScore,
loadLastCloudSubmitTimestamp,
BEACON_DIFFICULTY, BEACON_DIFFICULTY,
BEACON_TYPE_META, BEACON_TYPE_META,
BEACON_CHAIN_MILESTONES, BEACON_CHAIN_MILESTONES,
BEACON_CHAIN_REWARDS, BEACON_CHAIN_REWARDS,
getWeekKey, getWeekKey,
getTodayKey, getTodayKey,
getTimedSlotKey,
getNextMilestone, getNextMilestone,
getChainProgress, getChainProgress,
msUntilNextDay, msUntilNextDay,
msUntilNextWeek, msUntilNextWeek,
msUntilNextTimedSlot,
formatCountdown, formatCountdown,
type BeaconDailyChallenge, type BeaconDailyChallenge,
type BeaconDailyProgress, type BeaconDailyProgress,
type BeaconWeeklyChallenge, type BeaconWeeklyChallenge,
type BeaconWeeklyProgress, type BeaconWeeklyProgress,
type BeaconTimedChallenge,
type BeaconTimedProgress,
type BeaconScoreEntry, type BeaconScoreEntry,
type BeaconChallengeType, type BeaconChallengeType,
type BeaconDifficulty, type BeaconDifficulty,
@@ -59,11 +48,6 @@ import {
Link2, Link2,
Flame, Flame,
Zap, Zap,
Hourglass,
RefreshCw,
Globe,
WifiOff,
Loader2,
} from "lucide-react"; } from "lucide-react";
const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"]; const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
@@ -78,16 +62,11 @@ function rankBadge(rank: number) {
export function BeaconPanel() { export function BeaconPanel() {
const grantBeaconReward = useGameStore((s) => s.grantBeaconReward); const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon); const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon);
const claimTimedBeacon = useGameStore((s) => s.claimTimedBeacon);
const claimChainReward = useGameStore((s) => s.claimChainReward); const claimChainReward = useGameStore((s) => s.claimChainReward);
const { toast } = useToast(); const { toast } = useToast();
const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null); const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null);
const [progress, setProgress] = useState<BeaconDailyProgress | 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] = const [weeklyChallenge, setWeeklyChallenge] =
useState<BeaconWeeklyChallenge | null>(null); useState<BeaconWeeklyChallenge | null>(null);
const [weeklyProgress, setWeeklyProgress] = const [weeklyProgress, setWeeklyProgress] =
@@ -95,25 +74,13 @@ export function BeaconPanel() {
const [chainState, setChainState] = useState<BeaconChainState | null>(null); const [chainState, setChainState] = useState<BeaconChainState | null>(null);
const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]); const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
const [dayCountdown, setDayCountdown] = useState("00:00:00"); const [dayCountdown, setDayCountdown] = useState("00:00:00");
const [timedCountdown, setTimedCountdown] = useState("00:00:00");
const [weekCountdown, setWeekCountdown] = useState("00:00:00"); const [weekCountdown, setWeekCountdown] = useState("00:00:00");
const [now, setNow] = useState(Date.now()); 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(() => { useEffect(() => {
setChallenge(generateDailyChallenge()); setChallenge(generateDailyChallenge());
setProgress(loadDailyProgress()); setProgress(loadDailyProgress());
setTimedChallenge(generateTimedChallenge());
setTimedProgress(loadTimedProgress());
setWeeklyChallenge(generateWeeklyChallenge()); setWeeklyChallenge(generateWeeklyChallenge());
setWeeklyProgress(loadWeeklyProgress()); setWeeklyProgress(loadWeeklyProgress());
setChainState(loadChainState()); setChainState(loadChainState());
@@ -121,11 +88,9 @@ export function BeaconPanel() {
const id = setInterval(() => { const id = setInterval(() => {
setNow(Date.now()); setNow(Date.now());
setProgress(loadDailyProgress()); setProgress(loadDailyProgress());
setTimedProgress(loadTimedProgress());
setWeeklyProgress(loadWeeklyProgress()); setWeeklyProgress(loadWeeklyProgress());
setChainState(loadChainState()); setChainState(loadChainState());
setChallenge((c) => c ?? generateDailyChallenge()); setChallenge((c) => c ?? generateDailyChallenge());
setTimedChallenge((c) => c ?? generateTimedChallenge());
setWeeklyChallenge((c) => c ?? generateWeeklyChallenge()); setWeeklyChallenge((c) => c ?? generateWeeklyChallenge());
}, 1000); }, 1000);
return () => clearInterval(id); return () => clearInterval(id);
@@ -133,44 +98,9 @@ export function BeaconPanel() {
useEffect(() => { useEffect(() => {
setDayCountdown(formatCountdown(msUntilNextDay(new Date(now)))); setDayCountdown(formatCountdown(msUntilNextDay(new Date(now))));
setTimedCountdown(formatCountdown(msUntilNextTimedSlot(new Date(now))));
setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now)))); setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now))));
}, [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(() => { const handleClaimDaily = useCallback(() => {
if (!challenge || !progress) return; if (!challenge || !progress) return;
if (progress.completedAt === null || progress.claimed) return; if (progress.completedAt === null || progress.claimed) return;
@@ -186,41 +116,7 @@ export function BeaconPanel() {
1 1
)} 接触 · 得分 ${res.score}`, )} 接触 · 得分 ${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(() => { const handleClaimWeekly = useCallback(() => {
if (!weeklyChallenge || !weeklyProgress) return; if (!weeklyChallenge || !weeklyProgress) return;
@@ -236,13 +132,8 @@ export function BeaconPanel() {
1 1
)} 接触 · 得分 ${res.score}`, )} 接触 · 得分 ${res.score}`,
}); });
// 异步刷新云榜(claimWeeklyBeacon 已在 gameStore 内异步提交到云端)
setMySubmitTs(loadLastCloudSubmitTimestamp());
if (lbTab === "global") {
void refreshCloudLeaderboard();
}
} }
}, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon, lbTab, refreshCloudLeaderboard]); }, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]);
const handleClaimChain = useCallback( const handleClaimChain = useCallback(
(milestone: number) => { (milestone: number) => {
@@ -264,7 +155,7 @@ export function BeaconPanel() {
[toast, claimChainReward] [toast, claimChainReward]
); );
if (!challenge || !progress || !timedChallenge || !timedProgress || !weeklyChallenge || !weeklyProgress || !chainState) { if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) {
return ( return (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground/60"> <div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
@@ -279,14 +170,6 @@ export function BeaconPanel() {
const isClaimed = progress.claimed; const isClaimed = progress.claimed;
const canClaim = isCompleted && !isClaimed; 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 wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty];
const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type]; const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type];
@@ -319,14 +202,6 @@ export function BeaconPanel() {
0%, 100% { box-shadow: 0 0 18px rgba(232,121,249,0.35), inset 0 0 12px rgba(232,121,249,0.25); } 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); } 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 { @keyframes chain-milestone-pulse {
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(251,113,133,0.55); } 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); } 50% { transform: scale(1.06); box-shadow: 0 0 0 8px rgba(251,113,133,0); }
@@ -469,144 +344,6 @@ export function BeaconPanel() {
</div> </div>
</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"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
{/* ===== 周挑战卡片(fuchsia 主题) ===== */} {/* ===== 周挑战卡片(fuchsia 主题) ===== */}
@@ -626,7 +363,7 @@ export function BeaconPanel() {
· WEEKLY · WEEKLY
</span> </span>
</div> </div>
<div className="flex items-center gap-1 text-[9px] text-muted-foreground/70"> <div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
<span className="font-mono text-fuchsia-300/80"> <span className="font-mono text-fuchsia-300/80">
{weeklyChallenge.weekKey} {weeklyChallenge.weekKey}
</span> </span>
@@ -854,20 +591,20 @@ export function BeaconPanel() {
)} )}
</div> </div>
<span <span
className="text-[9px] text-center leading-tight" className="text-[10px] text-center leading-tight"
style={{ color: textColor }} style={{ color: textColor }}
> >
{reward?.label} {reward?.label}
</span> </span>
{claimed ? ( {claimed ? (
<span className="text-[9px] text-emerald-400/80 font-mono"> <span className="text-[10px] text-emerald-400/80 font-mono">
</span> </span>
) : reachable ? ( ) : reachable ? (
<Button <Button
onClick={() => handleClaimChain(m)} onClick={() => handleClaimChain(m)}
size="sm" size="sm"
className="h-5 px-2 text-[9px] py-0 border-0" className="h-5 px-2 text-[10px] py-0 border-0"
style={{ style={{
background: background:
"linear-gradient(90deg, #fb7185, #f43f5e)", "linear-gradient(90deg, #fb7185, #f43f5e)",
@@ -877,7 +614,7 @@ export function BeaconPanel() {
</Button> </Button>
) : ( ) : (
<span className="text-[9px] text-muted-foreground/40 font-mono"> <span className="text-[10px] text-muted-foreground/40 font-mono">
+{reward?.rewardInsight} +{reward?.rewardInsight}
</span> </span>
)} )}
@@ -909,7 +646,7 @@ export function BeaconPanel() {
</div> </div>
{/* 底部统计 */} {/* 底部统计 */}
<div className="flex items-center justify-between gap-2 text-[10px]"> <div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 text-[10px]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground/60"></span> <span className="text-muted-foreground/60"></span>
<span className="font-mono text-amber-300"> <span className="font-mono text-amber-300">
@@ -934,242 +671,73 @@ export function BeaconPanel() {
</div> </div>
</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="rounded-xl border border-white/10 bg-black/30 p-2.5">
{/* 头部:标题 + tab 切换 + (全球 tab 时)刷新按钮 */} <div className="flex items-center gap-1.5 mb-2">
<div className="flex items-center justify-between gap-2 mb-2"> <Trophy className="h-3.5 w-3.5 text-amber-400" />
<div className="flex items-center gap-1.5"> <span className="text-xs font-semibold"></span>
<Trophy className="h-3.5 w-3.5 text-amber-400" /> <span className="text-[10px] text-muted-foreground/50"> · Top {leaderboard.length || 0}</span>
<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> </div>
{/* 子标题:榜量信息 */} {leaderboard.length === 0 ? (
<div className="mb-1.5 text-[10px] text-muted-foreground/50 flex items-center gap-2"> <div className="text-center py-4 text-[11px] text-muted-foreground/40">
{lbTab === "local" ? ( <Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
<span> Top {leaderboard.length || 0} · 线</span>
) : ( </div>
<> ) : (
<span> Top {cloudEntries.length || 0}</span> <div className="space-y-0.5 max-h-[180px] overflow-y-auto echo-scroll">
{cloudFetched && !cloudLoading && cloudEntries.length > 0 && ( {leaderboard.map((entry, i) => {
<span className="text-emerald-400/60">· </span> const rb = rankBadge(i);
)} const eDiff = BEACON_DIFFICULTY[entry.difficulty];
{cloudLoading && ( const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
<span className="text-amber-400/70 flex items-center gap-1"> const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey();
<Loader2 className="h-2.5 w-2.5 animate-spin" /> return (
<div
</span> 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"
</div> : isMine
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
{/* ===== 本地榜 ===== */} : "hover:bg-white/5"
{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" /> <span className="w-6 flex items-center justify-center">
{rb ? (
</div> <rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
) : ( ) : (
<div className="space-y-0.5 max-h-[200px] overflow-y-auto echo-scroll"> <span className="text-muted-foreground/50 font-mono">{i + 1}</span>
{leaderboard.map((entry, i) => { )}
const rb = rankBadge(i); </span>
const eDiff = BEACON_DIFFICULTY[entry.difficulty]; {/* 类型 + 难度 + 周挑战标记 */}
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType]; <span className="flex items-center gap-1 flex-1 min-w-0">
const isMine = <span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
entry.dateKey === getTodayKey() || <span className="text-muted-foreground/80 truncate">{eType.label}</span>
entry.dateKey === getWeekKey() || {entry.isWeekly && (
entry.dateKey === getTimedSlotKey(); <span className="text-[10px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
return ( WEEK
<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> </span>
<span className="flex items-center gap-1 flex-1 min-w-0"> )}
<span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span> {entry.progress >= 1 && <span className="text-emerald-400/70"></span>}
<span className="text-muted-foreground/80 truncate">{eType.label}</span> </span>
{entry.isWeekly && ( {/* 用时 */}
<span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40"> <span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
WEEK {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
</span> </span>
)} {/* 分数 */}
{entry.isTimed && ( <span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
<span className="text-[9px] font-mono px-1 rounded bg-amber-500/20 text-amber-300 border border-amber-500/40"> {formatNum(entry.score)}
TIMED </span>
</span> </div>
)} );
{isMine && ( })}
<span className="text-[9px] font-mono px-1 rounded bg-emerald-500/25 text-emerald-200 border border-emerald-500/50"> </div>
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>
{/* 难度图例 */} {/* 难度图例 */}
<div className="flex items-center justify-center gap-3 text-[9px] text-muted-foreground/50"> <div className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground/50">
{DIFFICULTY_ORDER.map((d) => { {DIFFICULTY_ORDER.map((d) => {
const m = BEACON_DIFFICULTY[d]; const m = BEACON_DIFFICULTY[d];
return ( return (
+9 -55
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
// 回响星核 / Echo Nexus — 回响编年史对话框(v0.4 跨周目叙事时间轴) // 回响星核 / Echo Nexus — 回响编年史对话框(v0.4 跨周目叙事时间轴)
import { useEffect, useState, useMemo } from "react"; import { useEffect } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -20,8 +20,7 @@ import { getPerk } from "@/lib/game/constellation";
import { TIDE_EVENTS } from "@/lib/game/starTide"; import { TIDE_EVENTS } from "@/lib/game/starTide";
import { sfx } from "@/hooks/useAudio"; import { sfx } from "@/hooks/useAudio";
import type { ChronicleEntry } from "@/lib/game/types"; import type { ChronicleEntry } from "@/lib/game/types";
import { BookOpen, Clock, Swords, Trophy, Sparkles, Star, Waves, Cpu, Zap, ChevronLeft, ChevronRight } from "lucide-react"; import { BookOpen, Clock, Swords, Trophy, Sparkles, Star, Waves, Cpu, Zap } from "lucide-react";
import { Button } from "@/components/ui/button";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -60,26 +59,12 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
const bossKills = useGameStore((s) => s.bossKills ?? 0); const bossKills = useGameStore((s) => s.bossKills ?? 0);
const tidesAll = useGameStore((s) => s.starTidesEncountered ?? []); const tidesAll = useGameStore((s) => s.starTidesEncountered ?? []);
// 倒序展示(最新的在前) // 倒序展示(最新的在前)
const entries: ChronicleEntry[] = useMemo(() => [...chronicle].reverse(), [chronicle]); const entries: ChronicleEntry[] = [...chronicle].reverse();
// v0.8.2 分页(工单 #8):编年史上限 200 条,每页 10 条 // 打开时播放翻页音效
const PAGE_SIZE = 10;
const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
const [page, setPage] = useState(1);
// 当 entries 变化(新飞升)且当前页超出范围时,回到第 1 页(最新)
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect if (open) sfx("chronicleOpen");
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]); }, [open]);
const pagedEntries = entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -123,7 +108,7 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
</DialogHeader> </DialogHeader>
{/* 时间轴滚动区 */} {/* 时间轴滚动区 */}
<div className="flex-1 overflow-y-auto px-6 pb-3 echo-scroll"> <div className="flex-1 overflow-y-auto px-6 pb-6 echo-scroll">
{entries.length === 0 ? ( {entries.length === 0 ? (
<EmptyState ascensions={ascensions} /> <EmptyState ascensions={ascensions} />
) : ( ) : (
@@ -132,49 +117,18 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
<div className="absolute left-[27px] top-2 bottom-2 w-px bg-gradient-to-b from-fuchsia-400/40 via-violet-400/30 to-transparent" /> <div className="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"> <div className="space-y-4">
{pagedEntries.map((entry, idx) => ( {entries.map((entry, idx) => (
<ChronicleCard <ChronicleCard
key={`${entry.ascensionNumber}-${entry.timestamp}`} key={`${entry.ascensionNumber}-${entry.timestamp}`}
entry={entry} entry={entry}
isLatest={(page - 1) * PAGE_SIZE + idx === 0} isLatest={idx === 0}
index={(page - 1) * PAGE_SIZE + idx} index={idx}
/> />
))} ))}
</div> </div>
</div> </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> </DialogContent>
</Dialog> </Dialog>
); );
+309 -20
View File
@@ -1,25 +1,207 @@
"use client"; "use client";
// 回响星核 / Echo Nexus — 记忆图谱(碎片叙事) // 回响星核 / Echo Nexus — 记忆图谱(碎片叙事 + v0.15 多层文本 + v0.16 已读标记
import { useState } from "react"; import { useState, useMemo } from "react";
import { useGameStore, FRAGMENTS } from "@/store/gameStore"; import { useShallow } from "zustand/react/shallow";
import { nextFragmentThreshold } from "@/store/gameStore"; import { useGameStore, FRAGMENTS, nextFragmentThreshold } from "@/store/gameStore";
import { isLoreLayerUnlocked } from "@/lib/game/config";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { BookOpen, Lock, Sparkles } from "lucide-react"; import { BookOpen, Lock, Sparkles, Check } from "lucide-react";
import type { Fragment } from "@/lib/game/types"; import type { Fragment, FragmentLoreLayer } from "@/lib/game/types";
const ERA_NAMES = ["", "寂灭前夜", "谐振纪", "飞升潮", "回响纪", "接触纪"]; const ERA_NAMES = ["", "寂灭前夜", "谐振纪", "飞升潮", "回响纪", "接触纪"];
/** 锁定层用的乱码占位文本(视觉上像被加密的全息碎片) */
const LOCKED_PLACEHOLDER =
"▓░▒░ ▓▒░▒▓ ░▒▓░ ▒▓░▒░ ▓▒░ ▓░▒░▒ ░▒▓░▒ ▓░▒ ░▒▓░ ▒▓░▒▓░ ▓▒░░▒▓ ░▒▓ ▓░▒▒░ ▓▒░▓░▒ ░▒▓░ ▒▓░▒░ ▓▒░ ░▒▓░";
/**
* 渲染单层叙事文本(已解锁 / 未解锁 / 已读 三态)。
* v0.15L1=amber / L2=fuchsia
* v0.16
* - 已读 → 标题旁加 ✓ muted 标记,边框降级为 muted
* - 未读但已解锁 → 保持 v0.15 高亮,标题旁加脉冲小点(animate-pulse)提示"新"
* - 点击文本区域即标记已读(自然交互,不另设按钮)
*/
function LoreLayerBlock({
layer,
unlocked,
isRead,
onMarkRead,
}: {
layer: FragmentLoreLayer;
unlocked: boolean;
isRead: boolean;
onMarkRead: () => void;
}) {
const isL1 = layer.layer === 1;
const accentText = isL1 ? "text-amber-300" : "text-fuchsia-300";
// v0.16:已读时降级为 muted 边框,未读时保持高亮
const accentBorder =
unlocked && !isRead
? isL1
? "border-amber-400/30"
: "border-fuchsia-400/30"
: "border-white/10";
const accentBg =
unlocked && !isRead
? isL1
? "bg-amber-950/20"
: "bg-fuchsia-950/20"
: isL1
? "bg-amber-950/10"
: "bg-fuchsia-950/10";
const accentBody = isL1 ? "text-amber-50/90" : "text-fuchsia-50/90";
const accentChipBg = isL1 ? "bg-amber-500/15" : "bg-fuchsia-500/15";
const accentChipText = isL1 ? "text-amber-300" : "text-fuchsia-300";
const layerTag = isL1 ? "L1" : "L2";
return (
<div
className={`rounded-lg border ${accentBorder} ${
unlocked ? accentBg : "bg-black/20"
} p-3 transition-colors duration-300`}
>
<div className="flex items-center justify-between mb-2 gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={`text-[9px] font-mono px-1.5 py-0.5 rounded shrink-0 ${
unlocked ? `${accentChipBg} ${accentChipText}` : "bg-white/5 text-muted-foreground/60"
}`}
>
{layerTag}
</span>
<span
className={`text-[11px] font-semibold truncate ${
unlocked ? accentText : "text-muted-foreground/70"
}`}
>
{unlocked ? layer.title : "???"}
</span>
{/* v0.16 已读 / 未读指示 */}
{unlocked && isRead && (
<span
className="flex items-center gap-0.5 text-[9px] text-muted-foreground/70 shrink-0"
title="已读"
>
<Check className="h-2.5 w-2.5" />
</span>
)}
{unlocked && !isRead && (
<span
className="flex items-center gap-0.5 shrink-0"
title="新解锁 · 点击文本可标记已读"
>
<span
className={`inline-block h-1.5 w-1.5 rounded-full animate-pulse ${
isL1 ? "bg-amber-400" : "bg-fuchsia-400"
}`}
/>
<span className="text-[9px] text-muted-foreground/80"></span>
</span>
)}
</div>
{!unlocked && (
<span className="flex items-center gap-1 text-[10px] text-amber-200/50 shrink-0">
<Lock className="h-2.5 w-2.5" />
<span className="font-mono hidden xs:inline sm:inline">{layer.unlockHint}</span>
</span>
)}
</div>
{unlocked ? (
<button
type="button"
onClick={onMarkRead}
className="block text-left w-full focus:outline-none focus-visible:ring-1 focus-visible:ring-white/30 rounded"
title={isRead ? "已读" : "点击标记为已读"}
aria-label={isRead ? "已读" : "标记为已读"}
>
<p
className={`text-[12px] leading-relaxed italic ${accentBody} ${
isRead ? "opacity-80" : ""
}`}
>
{layer.text}
</p>
{!isRead && (
<span className="block mt-1.5 text-[9px] text-muted-foreground/40">
</span>
)}
</button>
) : (
<p
className="text-[12px] leading-relaxed blur-sm select-none text-muted-foreground/40 pointer-events-none"
aria-hidden="true"
>
{LOCKED_PLACEHOLDER}
</p>
)}
{!unlocked && (
<div className="mt-2 pt-2 border-t border-white/5 text-[10px] text-amber-200/50">
<span className="font-mono">🔒 {layer.unlockHint}</span>
</div>
)}
</div>
);
}
export function Codex() { export function Codex() {
const fragments = useGameStore((s) => s.fragments); // useShallow 一次性订阅所有判定所需的 state 切片(v0.16 新增 readLoreLayers + markLoreLayerRead
const totalDecoded = useGameStore((s) => s.totalDecoded); const { fragments, totalDecoded, ascensions, contact, bossKills, readLoreLayers, markLoreLayerRead } =
useGameStore(
useShallow((s) => ({
fragments: s.fragments,
totalDecoded: s.totalDecoded,
ascensions: s.ascensions,
contact: s.contact,
bossKills: s.bossKills,
readLoreLayers: Array.isArray(s.readLoreLayers) ? s.readLoreLayers : [],
markLoreLayerRead: s.markLoreLayerRead,
})),
);
const nextThreshold = nextFragmentThreshold(totalDecoded); const nextThreshold = nextFragmentThreshold(totalDecoded);
const [selected, setSelected] = useState<Fragment | null>(null); const [selected, setSelected] = useState<Fragment | null>(null);
// v0.1 只展示第一纪元 // v0.1 只展示第一纪元
const era1 = FRAGMENTS.filter((f) => f.era === 1); const era1 = useMemo(() => FRAGMENTS.filter((f) => f.era === 1), []);
const unlockedCount = era1.filter((f) => fragments[f.id]).length; const unlockedCount = era1.filter((f) => fragments[f.id]).length;
// 已读集合(Set 加速查询)
const readSet = useMemo(() => new Set(readLoreLayers), [readLoreLayers]);
// 用于判定解锁的快照对象(结构匹配 isLoreLayerUnlocked 的 Pick
const stateSnapshot = {
ascensions,
totalDecoded,
contact,
fragments,
bossKills,
};
// 深层叙事统计:所有 loreLayer 中已解锁数 / 总数 + 已读数(v0.16)
const loreStats = useMemo(() => {
let total = 0;
let unlocked = 0;
let read = 0;
for (const f of era1) {
if (!f.loreLayers) continue;
for (const layer of f.loreLayers) {
total += 1;
if (isLoreLayerUnlocked(layer.unlock, stateSnapshot)) {
unlocked += 1;
// 仅统计已解锁层的已读数(未解锁的层不算入已读分母)
if (readSet.has(`${f.id}_L${layer.layer}`)) read += 1;
}
}
}
return { total, unlocked, read };
}, [era1, ascensions, totalDecoded, contact, fragments, bossKills, readSet]);
return ( return (
<div className="flex flex-col gap-2 h-full"> <div className="flex flex-col gap-2 h-full">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -32,6 +214,24 @@ export function Codex() {
</span> </span>
</div> </div>
{/* v0.15 深层叙事统计 + v0.16 已读统计(移动端 stack,桌面并排) */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 text-[10px] px-1">
<span className="text-muted-foreground/80">
<span className="font-mono ml-1">
<span className="text-amber-300">{loreStats.unlocked}</span>
<span className="text-muted-foreground/60"> / {loreStats.total} </span>
</span>
</span>
<span className="text-muted-foreground/80">
<span className="font-mono ml-1">
<span className="text-emerald-300">{loreStats.read}</span>
<span className="text-muted-foreground/60"> / {loreStats.unlocked} </span>
</span>
</span>
</div>
{nextThreshold && ( {nextThreshold && (
<div className="text-[10px] text-muted-foreground/80 px-1"> <div className="text-[10px] text-muted-foreground/80 px-1">
<span className="text-fuchsia-300 font-mono">{nextThreshold - totalDecoded}</span> <span className="text-fuchsia-300 font-mono">{nextThreshold - totalDecoded}</span>
@@ -42,6 +242,21 @@ export function Codex() {
<div className="grid grid-cols-2 gap-2 pr-2"> <div className="grid grid-cols-2 gap-2 pr-2">
{era1.map((f, i) => { {era1.map((f, i) => {
const unlocked = !!fragments[f.id]; const unlocked = !!fragments[f.id];
// 计算此碎片 L1 / L2 解锁状态(用于标题旁标记)
const l1Layer = f.loreLayers?.find((l) => l.layer === 1);
const l2Layer = f.loreLayers?.find((l) => l.layer === 2);
const l1Unlocked = l1Layer
? isLoreLayerUnlocked(l1Layer.unlock, stateSnapshot)
: false;
const l2Unlocked = l2Layer
? isLoreLayerUnlocked(l2Layer.unlock, stateSnapshot)
: false;
const hasDeepUnlocked = l1Unlocked || l2Unlocked;
// v0.16:是否有未读的已解锁层(用于卡片标题旁小圆点变色)
const hasUnreadLayer =
(l1Unlocked && !readSet.has(`${f.id}_L1`)) ||
(l2Unlocked && !readSet.has(`${f.id}_L2`));
return ( return (
<button <button
key={f.id} key={f.id}
@@ -69,7 +284,38 @@ export function Codex() {
{unlocked ? ( {unlocked ? (
<> <>
<div className="absolute -top-3 -right-3 h-12 w-12 rounded-full bg-fuchsia-500/20 blur-xl" /> <div className="absolute -top-3 -right-3 h-12 w-12 rounded-full bg-fuchsia-500/20 blur-xl" />
<Sparkles className="h-3 w-3 text-fuchsia-300 mb-1" /> <div className="flex items-center gap-1 mb-1">
<Sparkles className="h-3 w-3 text-fuchsia-300" />
{hasDeepUnlocked && (
<span
className="flex items-center gap-0.5"
title={
hasUnreadLayer
? "此碎片含未读的深层文本"
: "此碎片的深层文本均已读"
}
>
{l1Unlocked && (
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
l1Unlocked && !readSet.has(`${f.id}_L1`)
? "bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.8)] animate-pulse"
: "bg-amber-400/30"
}`}
/>
)}
{l2Unlocked && (
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
l2Unlocked && !readSet.has(`${f.id}_L2`)
? "bg-fuchsia-400 shadow-[0_0_4px_rgba(232,121,249,0.8)] animate-pulse"
: "bg-fuchsia-400/30"
}`}
/>
)}
</span>
)}
</div>
<div className="text-[11px] font-semibold text-fuchsia-100 leading-tight"> <div className="text-[11px] font-semibold text-fuchsia-100 leading-tight">
{f.title} {f.title}
</div> </div>
@@ -91,32 +337,75 @@ export function Codex() {
</div> </div>
</ScrollArea> </ScrollArea>
{/* 详情弹层 */} {/* 详情弹层 — v0.15 分层渲染 + v0.16 已读标记 / 移动端 375px 适配 */}
{selected && ( {selected && (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-black/70 backdrop-blur-sm"
onClick={() => setSelected(null)} onClick={() => setSelected(null)}
> >
<Card <Card
className="max-w-md w-full p-5 bg-gradient-to-br from-fuchsia-950/60 to-black/80 border-fuchsia-400/40" className="max-w-md w-full max-h-[88vh] overflow-y-auto p-4 sm:p-5 bg-gradient-to-br from-fuchsia-950/40 to-black/80 border-fuchsia-400/40 [scrollbar-width:thin] [scrollbar-color:rgba(232,121,249,0.4)_transparent]"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ boxShadow: "0 0 40px rgba(232,121,249,0.25)" }} style={{ boxShadow: "0 0 40px rgba(232,121,249,0.25)" }}
> >
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Sparkles className="h-4 w-4 text-fuchsia-300" /> <Sparkles className="h-4 w-4 text-fuchsia-300 shrink-0" />
<span className="text-[10px] text-fuchsia-300/70 font-mono"> <span className="text-[10px] text-fuchsia-300/70 font-mono truncate">
{selected.era} · {ERA_NAMES[selected.era] || ""} {selected.era} · {ERA_NAMES[selected.era] || ""}
</span> </span>
</div> </div>
<h4 className="text-lg font-bold text-fuchsia-100 mb-3"> <h4 className="text-base sm:text-lg font-bold text-fuchsia-100 mb-4 break-words">
{selected.title} {selected.title}
</h4> </h4>
<p className="text-sm text-foreground/90 leading-relaxed italic">
{selected.echo} {/* L0 表层回响(永远显示,emerald 主题) */}
</p> <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>
)}
<button <button
onClick={() => setSelected(null)} onClick={() => setSelected(null)}
className="mt-4 text-xs text-muted-foreground hover:text-foreground transition" className="mt-5 text-xs text-muted-foreground hover:text-foreground transition"
> >
</button> </button>
+1 -10
View File
@@ -5,7 +5,6 @@ import { useGameStore } from "@/store/gameStore";
import { formatNum } from "@/lib/game/config"; import { formatNum } from "@/lib/game/config";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio"; import { sfx } from "@/hooks/useAudio";
import { PULSE_WHISPERS, maybeWhisper } from "@/lib/game/narrative";
interface FloatNum { interface FloatNum {
id: number; id: number;
@@ -243,18 +242,10 @@ export function CrystalOrb() {
setFloats((f) => f.filter((it) => it.id !== id)); setFloats((f) => f.filter((it) => it.id !== id));
}, 900); }, 900);
if (res.combo >= 5 && res.combo % 5 === 0) { if (res.combo >= 5 && res.combo % 5 === 0) {
// v0.9 叙事融入:高连击时注入世界观低语
const whisper = maybeWhisper(PULSE_WHISPERS, 0.6);
toast({ toast({
title: `×${res.combo} 连击!`, title: `×${res.combo} 连击!`,
description: whisper ? `${whisper}` : "谐振叠加,产能飙升。", description: "谐振叠加,产能飙升。",
}); });
} else if (res.combo >= 3 && res.combo % 3 === 0) {
// 3 连击时也有概率出现叙事
const whisper = maybeWhisper(PULSE_WHISPERS, 0.3);
if (whisper) {
toast({ title: "低语", description: `${whisper}` });
}
} }
}, },
[pulse, toast, triggerPulseEffect] [pulse, toast, triggerPulseEffect]
+7 -34
View File
@@ -408,33 +408,13 @@ function CrystalCard({
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className="group relative aspect-square rounded-2xl border border-white/10 bg-black/40 hover:border-white/40 transition-all duration-300 hover:scale-[1.04] focus:outline-none overflow-hidden" className="group relative aspect-square rounded-2xl border border-white/10 bg-black/40 hover:border-white/30 transition-all duration-300 hover:scale-[1.03] focus:outline-none overflow-hidden"
style={{ boxShadow: `inset 0 0 24px ${tierColor}22` }} 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="absolute inset-0 flex items-center justify-center">
<div <div
className="relative h-12 w-12 transition-transform duration-500 group-hover:rotate-[200deg] group-hover:scale-110" className="relative h-12 w-12 transition-transform duration-500 group-hover:rotate-180"
style={{ filter: `drop-shadow(0 0 12px ${tierColor}88)` }} style={{ filter: `drop-shadow(0 0 12px ${tierColor}88)` }}
> >
<div <div
@@ -450,22 +430,15 @@ function CrystalCard({
/> />
</div> </div>
</div> </div>
{/* v0.11 标签上移至顶部,z-10 避免被底部覆盖层遮挡 */} {/* 标签 */}
<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 }}>
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} T{crystal.tier}
</div> </div>
<div className="absolute top-1.5 right-1.5 z-10 text-[9px] text-white/60 font-mono tracking-wider"> <div className="absolute bottom-1.5 right-1.5 text-[10px] text-white/70">
{tierLabel} {tierLabel}
</div> </div>
{/* v0.11 hover 底部显示晶体数值 + 解码提示 */} <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">
<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"> <span className="text-[11px] font-medium text-white"> </span>
<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> </div>
</button> </button>
); );
+17 -60
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
// 回响星核 / Echo Nexus — 遗迹探险面板(v0.2 肉鸽系统v0.9 工单修复 // 回响星核 / Echo Nexus — 遗迹探险面板(v0.2 肉鸽系统)
import { useState, useEffect } from "react"; import { useState } from "react";
import { useGameStore } from "@/store/gameStore"; import { useGameStore } from "@/store/gameStore";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio"; import { sfx } from "@/hooks/useAudio";
@@ -42,7 +42,6 @@ export function ExpeditionPanel() {
const lastEnergyTick = useGameStore((s) => s.lastEnergyTick); const lastEnergyTick = useGameStore((s) => s.lastEnergyTick);
const expeditionLog = useGameStore((s) => s.expeditionLog); const expeditionLog = useGameStore((s) => s.expeditionLog);
const totalExpeditions = useGameStore((s) => s.totalExpeditions); const totalExpeditions = useGameStore((s) => s.totalExpeditions);
const lastExpeditionSummary = useGameStore((s) => s.lastExpeditionSummary);
// v0.8.1 动态能量恢复间隔(技术 + 探索力属性缩短) // v0.8.1 动态能量恢复间隔(技术 + 探索力属性缩短)
const regenIntervalSec = useGameStore((s) => computeEnergyRegenInterval(s)); const regenIntervalSec = useGameStore((s) => computeEnergyRegenInterval(s));
const startExpedition = useGameStore((s) => s.startExpedition); const startExpedition = useGameStore((s) => s.startExpedition);
@@ -51,20 +50,12 @@ export function ExpeditionPanel() {
const abortExpedition = useGameStore((s) => s.abortExpedition); const abortExpedition = useGameStore((s) => s.abortExpedition);
const { toast } = useToast(); const { toast } = useToast();
const [lastLog, setLastLog] = useState<string | null>(null); const [lastLog, setLastLog] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
// v0.9 工单修复:能量恢复实时倒计时(每秒刷新)
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// 能量恢复进度(v0.8.1 动态间隔v0.9 实时刷新 // 能量恢复进度(v0.8.1 动态间隔)
const now = Date.now();
const regenMs = regenIntervalSec * 1000; const regenMs = regenIntervalSec * 1000;
const elapsed = now - lastEnergyTick; const regenProgress = energy >= energyMax ? 100 : Math.min(100, ((now - lastEnergyTick) / regenMs) * 100);
const regenProgress = energy >= energyMax ? 100 : Math.min(100, (elapsed / regenMs) * 100);
const regenBoosted = regenIntervalSec < EXPEDITION_CONFIG.energyRegenSec; 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 handleStart = () => {
const res = startExpedition(); const res = startExpedition();
@@ -108,7 +99,6 @@ export function ExpeditionPanel() {
}; };
// ===== 无活跃探险:展示入口 ===== // ===== 无活跃探险:展示入口 =====
// v0.10 工单 #2 修复:探险结束自动清空 activeExpedition,无需 justFinished 判断
if (!activeExpedition || activeExpedition.finished) { if (!activeExpedition || activeExpedition.finished) {
return ( return (
<div className="flex flex-col gap-3 h-full"> <div className="flex flex-col gap-3 h-full">
@@ -120,40 +110,6 @@ export function ExpeditionPanel() {
<span className="text-[10px] text-muted-foreground"> {totalExpeditions} </span> <span className="text-[10px] text-muted-foreground"> {totalExpeditions} </span>
</div> </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="rounded-xl border border-amber-400/20 bg-amber-950/20 p-3">
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
@@ -171,10 +127,8 @@ export function ExpeditionPanel() {
value={regenProgress} value={regenProgress}
className="h-1.5 bg-black/40 [&>div]:bg-gradient-to-r [&>div]:from-amber-500 [&>div]:to-yellow-300" className="h-1.5 bg-black/40 [&>div]:bg-gradient-to-r [&>div]:from-amber-500 [&>div]:to-yellow-300"
/> />
<p className="text-[10px] text-muted-foreground/70 mt-1 flex items-center gap-1"> <p className="text-[10px] text-muted-foreground/70 mt-1">
<span></span> {Math.ceil((regenMs - (now - lastEnergyTick)) / 1000)}s
<span className="text-amber-300 font-mono font-semibold">{nextEnergyInSec}s</span>
<span></span>
{regenBoosted && ( {regenBoosted && (
<span className="text-emerald-400/80 ml-1"> {regenIntervalSec.toFixed(0)}s/ ()</span> <span className="text-emerald-400/80 ml-1"> {regenIntervalSec.toFixed(0)}s/ ()</span>
)} )}
@@ -186,32 +140,35 @@ export function ExpeditionPanel() {
</div> </div>
{/* 出发按钮 */} {/* 出发按钮 */}
<div className="flex-1 flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-amber-400/20 bg-black/30 p-4 relative overflow-hidden"> <div className="flex-1 flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-amber-400/20 bg-black/30 p-6 relative overflow-hidden">
<div className="absolute -top-10 -right-10 h-32 w-32 rounded-full bg-amber-500/10 blur-2xl" /> <div className="absolute -top-10 -right-10 h-32 w-32 rounded-full bg-amber-500/10 blur-2xl" />
<div className="relative"> <div className="relative">
<div className={`h-16 w-16 rounded-full bg-gradient-to-br from-amber-500/30 to-rose-500/20 flex items-center justify-center mx-auto ${canStart ? "animate-pulse" : ""}`} style={{ boxShadow: "0 0 30px rgba(251,191,36,0.3)" }}> <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-7 w-7 text-amber-300" /> <Rocket className="h-9 w-9 text-amber-300" />
</div> </div>
</div> </div>
<div className="text-center space-y-1"> <div className="text-center space-y-1">
<p className="text-sm font-medium text-amber-100"></p> <p className="text-sm font-medium text-amber-100"></p>
<p className="text-[11px] text-muted-foreground/80 max-w-[220px]"> <p className="text-[11px] text-muted-foreground/80 max-w-[220px]">
沿 BOSS 沿 BOSS
</p> </p>
</div> </div>
<Button <Button
onClick={handleStart} onClick={handleStart}
disabled={!canStart} disabled={energy < EXPEDITION_CONFIG.energyCost}
className="bg-gradient-to-r from-amber-600 to-rose-500 hover:from-amber-500 hover:to-rose-400 text-white border-0" 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" /> <Rocket className="h-4 w-4 mr-1.5" />
{canStart ? `出发(消耗 ${EXPEDITION_CONFIG.energyCost} 能量)` : `能量不足(${nextEnergyInSec}s 后恢复)`} {EXPEDITION_CONFIG.energyCost}
</Button> </Button>
{energy < EXPEDITION_CONFIG.energyCost && (
<p className="text-[10px] text-rose-300/80"></p>
)}
</div> </div>
{/* 探险日志 */} {/* 探险日志 */}
{expeditionLog.length > 0 && ( {expeditionLog.length > 0 && (
<div className="rounded-xl border border-white/5 bg-black/20 p-2 max-h-28 overflow-y-auto echo-scroll"> <div className="rounded-xl border border-white/5 bg-black/20 p-2 max-h-32 overflow-y-auto echo-scroll">
<div className="text-[10px] text-muted-foreground/60 mb-1 px-1"></div> <div className="text-[10px] text-muted-foreground/60 mb-1 px-1"></div>
{expeditionLog.slice(0, 4).map((entry, i) => ( {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"> <div key={i} className="text-[10px] text-muted-foreground/70 px-1 py-0.5 border-b border-white/5 last:border-0">
-215
View File
@@ -1,215 +0,0 @@
"use client";
// 回响星核 / Echo Nexus — 探索目标雷达(v0.10 新增)
// 动态展示当前可解锁/可探索的下一目标,让"等待"变"期待"
// 缓解工单 #2 "占地方没用" 反馈 — 晶体球下方提供持续探索动力
import { useGameStore } from "@/store/gameStore";
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
import { EXPEDITION_CONFIG } from "@/lib/game/expedition";
import {
Target,
Cpu,
Trophy,
BookOpen,
Rocket,
Star,
TrendingDown,
CheckCircle2,
Sparkles,
} from "lucide-react";
interface TargetItem {
id: string;
icon: React.ComponentType<{ className?: string }>;
color: string;
label: string;
detail: string;
progress: number; // 0-100
hint: string;
}
export function ExplorationRadar() {
const s = useGameStore();
// ===== 计算各系统的下一个目标 =====
const targets: TargetItem[] = [];
// 1. 下一个技术节点(按洞见排序)
const nextTech = TECH_TREE.filter((t) => !s.tech[t.id]).sort((a, b) => a.cost - b.cost)[0];
if (nextTech) {
const insightProgress = Math.min(100, (s.insights / nextTech.cost) * 100);
targets.push({
id: "tech_" + nextTech.id,
icon: Cpu,
color: "#34d399",
label: nextTech.name,
detail: `${nextTech.cost} 洞见 · ${nextTech.desc}`,
progress: insightProgress,
hint: s.insights >= nextTech.cost ? "可解锁" : `还差 ${nextTech.cost - s.insights} 洞见`,
});
}
// 2. 下一个成就(按达成难度近似)
const nextAch = ACHIEVEMENTS.find((a) => !s.achievements[a.id]);
if (nextAch) {
targets.push({
id: "ach_" + nextAch.id,
icon: Trophy,
color: "#fbbf24",
label: nextAch.name,
detail: nextAch.desc,
progress: 30, // 成就进度难以精确计算,给固定视觉提示
hint: nextAch.rewardText,
});
}
// 3. 下一个记忆碎片
const nextFrag = FRAGMENTS.find((f) => !s.fragments[f.id]);
if (nextFrag) {
const ownedFrag = FRAGMENTS.filter((f) => s.fragments[f.id]).length;
targets.push({
id: "frag_" + nextFrag.id,
icon: BookOpen,
color: "#e879f9",
label: nextFrag.title,
detail: "解码晶体有概率获取",
progress: (ownedFrag / FRAGMENTS.length) * 100,
hint: `图谱 ${ownedFrag}/${FRAGMENTS.length}`,
});
}
// 4. 探险能量
if (s.energy < EXPEDITION_CONFIG.energyCost) {
targets.push({
id: "expedition_energy",
icon: Rocket,
color: "#fb7185",
label: "探险能量恢复中",
detail: `满能量可深入遗迹`,
progress: (s.energy / EXPEDITION_CONFIG.energyCost) * 100,
hint: `${s.energy}/${EXPEDITION_CONFIG.energyCost}`,
});
}
// 5. 飞升进度(接触<100 时)
if (s.contact < 100) {
targets.push({
id: "ascension",
icon: Star,
color: "#a78bfa",
label: "接触进度",
detail: "满 100 可飞升新周目",
progress: s.contact,
hint: `${s.contact.toFixed(1)}/100`,
});
}
// 6. 星图天赋(飞升后未选择)
const ownedConstellation = s.constellation ?? [];
if (s.pendingPerkChoices && s.pendingPerkChoices.length > 0) {
targets.push({
id: "constellation_pending",
icon: Sparkles,
color: "#e879f9",
label: "星图觉醒待选择",
detail: "3 选 1 永久天赋",
progress: 100,
hint: "立即选择",
});
} else if (ownedConstellation.length < CONSTELLATION_PERKS.length && s.ascensions > 0) {
targets.push({
id: "constellation_next",
icon: Star,
color: "#e879f9",
label: "下一道星图天赋",
detail: `已觉醒 ${ownedConstellation.length}/${CONSTELLATION_PERKS.length}`,
progress: (ownedConstellation.length / CONSTELLATION_PERKS.length) * 100,
hint: "飞升后解锁",
});
}
// 取前 3 个最相关目标展示
const topTargets = targets.slice(0, 3);
if (topTargets.length === 0) {
return (
<div className="rounded-xl border border-emerald-400/20 bg-emerald-950/10 p-3 text-center">
<CheckCircle2 className="h-5 w-5 text-emerald-400 mx-auto mb-1" />
<p className="text-[11px] text-emerald-200/80"></p>
<p className="text-[9px] text-muted-foreground/60 mt-0.5"></p>
</div>
);
}
return (
<div className="rounded-xl border border-white/10 bg-black/30 p-2.5 space-y-2">
{/* 标题 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Target className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-[11px] font-semibold text-emerald-200"></span>
</div>
<span className="text-[9px] text-muted-foreground/50 font-mono">RADAR</span>
</div>
{/* 目标列表 */}
<div className="space-y-1.5">
{topTargets.map((t) => {
const Icon = t.icon;
const isReady = t.progress >= 100;
return (
<div
key={t.id}
className={`rounded-lg p-1.5 transition-all ${
isReady
? "bg-white/8 border border-white/15"
: "bg-black/20 border border-white/5"
}`}
style={
isReady
? { boxShadow: `0 0 10px ${t.color}33` }
: undefined
}
>
<div className="flex items-center gap-1.5 mb-1">
<Icon
className={`h-3 w-3 shrink-0 ${isReady ? "animate-pulse" : ""}`}
style={{ color: t.color }}
/>
<span
className="text-[10px] font-medium truncate flex-1"
style={{ color: isReady ? t.color : "rgba(255,255,255,0.75)" }}
>
{t.label}
</span>
<span className="text-[8px] font-mono text-muted-foreground/70 shrink-0">
{t.hint}
</span>
</div>
{/* 进度条 */}
<div className="h-0.5 rounded-full bg-black/50 overflow-hidden">
<div
className="h-full transition-all duration-500"
style={{
width: `${Math.min(100, t.progress)}%`,
background: t.color,
boxShadow: isReady ? `0 0 6px ${t.color}` : "none",
}}
/>
</div>
{/* 详情 */}
<p className="text-[8px] text-muted-foreground/60 mt-0.5 truncate">{t.detail}</p>
</div>
);
})}
</div>
{/* 底部提示 */}
<div className="flex items-center gap-1 text-[8px] text-muted-foreground/50 pt-0.5 border-t border-white/5">
<TrendingDown className="h-2.5 w-2.5" />
<span> · </span>
</div>
</div>
);
}
+526
View File
@@ -0,0 +1,526 @@
"use client";
// 回响星核 / Echo Nexus — v0.14 放置系统面板(主「放置」标签内容)
// v0.15:永久加成 softcap 展示 + 批量领取按钮
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useShallow } from "zustand/react/shallow";
import { useGameStore } from "@/store/gameStore";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import {
IDLE_PROJECTS,
IDLE_SLOT_COUNT,
IDLE_COLOR_CLASSES,
COLOR_HEX,
getIdleProject,
getUnlockedIdleProjects,
getLockedIdleProjects,
deriveMinerFleet,
formatRemaining,
formatDuration,
formatReward,
isIdleProjectUnlocked,
} from "@/lib/game/idle";
import { applyIdleSoftcap } from "@/lib/game/engine";
import { formatNum, OFFLINE_CAP_HOURS } from "@/lib/game/config";
import type { IdleProjectDef, IdleProjectSlot } from "@/lib/game/types";
/** 滚动条样式 */
const SCROLLBAR_CLS =
"[scrollbar-width:thin] [scrollbar-color:rgba(232,121,249,0.4)_transparent] " +
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full " +
"[&::-webkit-scrollbar-thumb]:bg-fuchsia-500/30 [&::-webkit-scrollbar-track]:bg-transparent";
export function IdleOperationsPanel() {
// 使用 useShallow 订阅多个字段,避免不必要的 re-render
const { crystalsPerSec, idlePermanentBonus, idleStats, idleProjectSlots, tech, ascensions, offlineEff } =
useGameStore(
useShallow((s) => ({
crystalsPerSec: s.crystalsPerSec,
idlePermanentBonus: s.idlePermanentBonus ?? 0,
idleStats: s.idleStats ?? { projectsCompleted: 0, crystalsFromIdle: 0 },
idleProjectSlots: (s.idleProjectSlots ?? [null, null, null]) as (
| IdleProjectSlot
| null
)[],
tech: s.tech,
ascensions: s.ascensions ?? 0,
offlineEff: s.offlineEff ?? 0.5,
}))
);
const startIdleProject = useGameStore((s) => s.startIdleProject);
const cancelIdleProject = useGameStore((s) => s.cancelIdleProject);
const claimIdleProject = useGameStore((s) => s.claimIdleProject);
// v0.15:批量领取全部已完成工程
const claimAllIdleProjects = useGameStore((s) => s.claimAllIdleProjects);
// 本地 now 状态,每秒刷新倒计时
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// 派生数据
const fleet = useMemo(() => deriveMinerFleet({ tech }), [tech]);
const unlockedProjects = useMemo(
() => getUnlockedIdleProjects({ ascensions, crystalsPerSec }),
[ascensions, crystalsPerSec]
);
const lockedProjects = useMemo(
() => getLockedIdleProjects({ ascensions, crystalsPerSec }),
[ascensions, crystalsPerSec]
);
const totalOutput = fleet.reduce((sum, f) => sum + f.outputPerSec, 0);
// v0.15softcap 有效/原始永久加成
const rawPermBonus = idlePermanentBonus;
const effectivePermBonus = applyIdleSoftcap(rawPermBonus);
const isSoftcapped = effectivePermBonus < rawPermBonus;
// crystalsPerSec 已包含 softcap 后的永久加成(recomputeStats 已应用)
const idleTotalPerSec = crystalsPerSec;
// 当前已运行/已完成的工程 id 集合(用于禁用"派遣"按钮)
const runningProjectIds = useMemo(() => {
const set = new Set<string>();
for (const slot of idleProjectSlots) {
if (slot && !slot.completed) set.add(slot.projectId);
}
return set;
}, [idleProjectSlots]);
// v0.15:可领取工程数量(用于"领取全部"按钮显示)
const claimableCount = useMemo(
() => idleProjectSlots.filter((s) => s !== null && s.completed).length,
[idleProjectSlots]
);
return (
<div className={`max-h-[520px] overflow-y-auto pr-1 ${SCROLLBAR_CLS} space-y-3`}>
{/* ====== Section 1: 放置收益概览 ====== */}
<Card className="gap-0 p-3 bg-black/25 border-white/5 rounded-xl">
<div className="flex items-center justify-between mb-2">
<h3 className="text-[11px] font-semibold text-emerald-300 tracking-wide flex items-center gap-1">
<span className="text-sm"></span>
</h3>
<Badge variant="outline" className="h-4 px-1.5 text-[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 };
+90
View File
@@ -0,0 +1,90 @@
"use client";
// 回响星核 / Echo Nexus — v0.14 放置工程进度条(晶体球下方的细长条)
import { useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useGameStore } from "@/store/gameStore";
import {
COLOR_HEX,
IDLE_COLOR_CLASSES,
getIdleProject,
formatRemaining,
} from "@/lib/game/idle";
import type { IdleProjectSlot } from "@/lib/game/types";
/**
* 3 /
* null
*/
export function IdleProjectBar() {
const slots = useGameStore(
useShallow((s) => (s.idleProjectSlots ?? [null, null, null]) as (
| IdleProjectSlot
| null
)[])
);
// 本地 now 状态:每秒刷新倒计时
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// 过滤出活跃的槽位(运行中或已完成待领取)
const activeSlots = slots
.map((slot, idx) => ({ slot, idx }))
.filter((x) => x.slot !== null);
if (activeSlots.length === 0) return null;
return (
<div className="w-full max-w-[280px] mx-auto flex flex-col gap-1 px-2">
{activeSlots.map(({ slot, idx }) => {
if (!slot) return null;
const project = getIdleProject(slot.projectId);
if (!project) return null;
const c = IDLE_COLOR_CLASSES[project.color];
const hex = COLOR_HEX[project.color];
const remainingSec = Math.max(0, (slot.finishesAt - now) / 1000);
const elapsedSec = Math.max(0, (now - slot.startedAt) / 1000);
const progressPct = slot.completed
? 100
: Math.min(100, Math.max(0, (elapsedSec / project.durationSec) * 100));
return (
<div
key={idx}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md border ${c.border} ${c.bgSoft} text-[10px] ${
slot.completed ? c.glow : ""
}`}
>
<span className="text-[11px] leading-none">{project.icon}</span>
<span className={`${c.text} font-semibold truncate flex-shrink-0 max-w-[80px]`}>
{project.name}
</span>
{slot.completed ? (
<span className={`${c.text} font-bold ml-auto`}> </span>
) : (
<>
<span className={`${c.text} font-mono tabular-nums ml-auto`}>
{formatRemaining(remainingSec)}
</span>
{/* 迷你进度条 ▓▓▓░░ */}
<div className="w-12 h-1.5 bg-black/40 rounded-full overflow-hidden flex-shrink-0">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progressPct}%`,
backgroundColor: hex,
boxShadow: `0 0 6px ${hex}80`,
}}
/>
</div>
</>
)}
</div>
);
})}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
// 回响星核 / Echo Nexus — v0.14 放置系统头部徽章(放置中 +X/s)
import { useShallow } from "zustand/react/shallow";
import { useGameStore } from "@/store/gameStore";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatNum } from "@/lib/game/config";
interface Props {
onClick?: () => void;
}
/** 紧凑徽章:脉冲点 + "放置中 +X/s"(或休眠中) */
export function IdleStatusBadge({ onClick }: Props) {
const { crystalsPerSec, idlePermanentBonus, idleProjectSlots } = useGameStore(
useShallow((s) => ({
crystalsPerSec: s.crystalsPerSec,
idlePermanentBonus: s.idlePermanentBonus ?? 0,
idleProjectSlots: (s.idleProjectSlots ?? [null, null, null]) as (
| { projectId: string; completed: boolean }
| null
)[],
}))
);
const idleTotal = crystalsPerSec + idlePermanentBonus;
const isActive = idleTotal > 0;
const runningCount = idleProjectSlots.filter(
(s) => s !== null && !s.completed
).length;
const claimableCount = idleProjectSlots.filter(
(s) => s !== null && s.completed
).length;
const tipText = (
<div className="space-y-0.5 text-left">
<div className="font-semibold text-[11px]"> · Idle Ops</div>
<div className="text-[10px] opacity-90">
<span className="font-mono">+{formatNum(crystalsPerSec)}/s</span>
</div>
<div className="text-[10px] opacity-90">
<span className="font-mono text-fuchsia-300">+{formatNum(idlePermanentBonus)}/s</span>
</div>
<div className="text-[10px] opacity-90">
<span className="font-mono">{runningCount}</span> / 3
</div>
{claimableCount > 0 && (
<div className="text-[10px] text-amber-300 font-semibold">
{claimableCount}
</div>
)}
<div className="text-[9px] opacity-70 mt-1"> </div>
</div>
);
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onClick}
aria-label="放置系统状态"
data-tut="idle-status-badge"
className={`group h-8 px-2.5 inline-flex items-center gap-1.5 rounded-md border text-[11px] font-mono transition-all ${
isActive
? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200 hover:bg-emerald-500/20 hover:border-emerald-400/60"
: "border-white/15 bg-black/30 text-muted-foreground/80 hover:bg-black/50 hover:border-white/25"
}`}
>
<span className="relative flex h-2 w-2">
{isActive && (
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-60 animate-ping" />
)}
<span
className={`relative inline-flex h-2 w-2 rounded-full ${
isActive ? "bg-emerald-400" : "bg-muted-foreground/40"
}`}
/>
</span>
<span className="leading-none">
{isActive ? (
<>
<span className="text-emerald-300 font-bold">+{formatNum(idleTotal)}</span>
<span className="opacity-70">/s</span>
</>
) : (
"休眠中"
)}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="max-w-[220px]">
{tipText}
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,261 @@
"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>
);
}
-219
View File
@@ -1,219 +0,0 @@
"use client";
// 回响星核 / Echo Nexus — 文明遗迹图鉴面板(v0.10 新增)
// 展示深空考古发现的远古文明遗迹,增加世界观深度与探索乐趣
import { useState } from "react";
import { useGameStore } from "@/store/gameStore";
import {
RELIC_CODEX,
getUnlockedRelics,
getRelicProgress,
getRelicRewards,
type RelicEntry,
} from "@/lib/game/relicCodex";
import { BookMarked, Lock, Sparkles, Clock, Users, ScrollText } from "lucide-react";
export function RelicCodex() {
const state = useGameStore();
const [selected, setSelected] = useState<string | null>(null);
const unlockedRelics = getUnlockedRelics(state);
const progress = getRelicProgress(state);
const rewards = getRelicRewards(state);
const selectedRelic = selected ? RELIC_CODEX.find((r) => r.id === selected) : null;
return (
<div className="flex flex-col gap-2.5 h-full min-h-0">
{/* 顶部:标题 + 进度 */}
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold flex items-center gap-1.5 shrink-0">
<BookMarked className="h-4 w-4 text-fuchsia-400" />
</h3>
<div className="flex items-center gap-2 text-[10px] font-mono">
<span className="text-fuchsia-300">{unlockedRelics.length}</span>
<span className="text-muted-foreground/50">/</span>
<span className="text-muted-foreground">{RELIC_CODEX.length}</span>
<span className="text-muted-foreground/60">({progress.toFixed(0)}%)</span>
</div>
</div>
{/* 进度条 */}
<div className="h-1 rounded-full bg-black/40 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-fuchsia-500 via-rose-400 to-amber-400 transition-all duration-500"
style={{ width: `${progress}%`, boxShadow: "0 0 8px rgba(232,121,249,0.5)" }}
/>
</div>
{/* 累计奖励总览 */}
<div className="rounded-lg border border-fuchsia-400/20 bg-fuchsia-950/20 px-2.5 py-1.5 flex items-center gap-2 text-[10px] flex-wrap">
<Sparkles className="h-3 w-3 text-fuchsia-400 shrink-0" />
<span className="text-fuchsia-300/80"></span>
{rewards.crystals > 0 && (
<span className="text-emerald-300">+{rewards.crystals} </span>
)}
{rewards.insights > 0 && (
<span className="text-amber-300">+{rewards.insights} </span>
)}
{rewards.contact > 0 && (
<span className="text-fuchsia-300">+{rewards.contact} </span>
)}
{rewards.crystalsPerSecPct > 0 && (
<span className="text-emerald-300"> +{rewards.crystalsPerSecPct}%</span>
)}
{unlockedRelics.length === 0 && (
<span className="text-muted-foreground/60"></span>
)}
</div>
{/* 遗迹网格 — 2 列展示,每个卡片 min-h 防溢出 */}
<div className="flex-1 min-h-0 overflow-y-auto echo-scroll pr-1">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{RELIC_CODEX.map((relic) => {
const unlocked = !!state.achievements?.[`relic_${relic.id}`] || relic.unlock(state);
const isSelected = selected === relic.id;
return (
<button
key={relic.id}
onClick={() => setSelected(isSelected ? null : relic.id)}
className={`relative rounded-lg border px-2.5 py-2 transition-all duration-300 min-h-[72px] text-left ${
unlocked
? isSelected
? "border-white/30 bg-white/8"
: "border-white/15 bg-black/30 hover:bg-black/40"
: "border-white/5 bg-black/15 opacity-70"
}`}
style={
unlocked
? { boxShadow: `inset 0 0 14px ${relic.color}10` }
: undefined
}
>
{unlocked && (
<div
className="absolute -top-6 -right-6 h-14 w-14 rounded-full blur-2xl opacity-30 pointer-events-none"
style={{ background: relic.color }}
/>
)}
<div className="relative flex items-start gap-2">
{/* 图标 */}
<div
className="shrink-0 h-9 w-9 rounded-lg flex items-center justify-center text-lg"
style={{
background: unlocked ? `${relic.color}22` : "rgba(255,255,255,0.04)",
border: `1px solid ${unlocked ? relic.color + "55" : "rgba(255,255,255,0.08)"}`,
color: unlocked ? relic.color : "#555",
boxShadow: unlocked ? `0 0 10px ${relic.color}33` : "none",
}}
>
{unlocked ? relic.icon : <Lock className="h-3.5 w-3.5" />}
</div>
{/* 文本 */}
<div className="min-w-0 flex-1">
<div
className="text-[11px] font-semibold truncate"
style={{ color: unlocked ? relic.color : "rgba(255,255,255,0.5)" }}
>
{unlocked ? relic.name : "??? 未发现"}
</div>
{unlocked ? (
<>
<div className="text-[9px] text-muted-foreground/70 truncate flex items-center gap-1">
<Users className="h-2.5 w-2.5" />
{relic.race}
</div>
<div className="text-[9px] text-muted-foreground/60 truncate flex items-center gap-1">
<Clock className="h-2.5 w-2.5" />
{relic.era}
</div>
</>
) : (
<div className="text-[9px] text-muted-foreground/60 mt-0.5 line-clamp-2">
{relic.unlockHint}
</div>
)}
</div>
</div>
</button>
);
})}
</div>
{/* 选中遗迹的详细故事 */}
{selectedRelic && (
<div
className="mt-2 rounded-lg border p-3 relative overflow-hidden"
style={{
borderColor: `${selectedRelic.color}44`,
background: `linear-gradient(135deg, ${selectedRelic.color}11, rgba(0,0,0,0.4))`,
}}
>
<div
className="absolute -top-8 -right-8 h-24 w-24 rounded-full blur-2xl pointer-events-none"
style={{ background: `${selectedRelic.color}22` }}
/>
<div className="relative space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-base" style={{ color: selectedRelic.color }}>
{selectedRelic.icon}
</span>
<span
className="text-sm font-semibold"
style={{ color: selectedRelic.color }}
>
{selectedRelic.name}
</span>
</div>
<div className="flex items-center gap-3 text-[9px] text-muted-foreground/80">
<span className="flex items-center gap-1">
<Users className="h-2.5 w-2.5" />
{selectedRelic.race}
</span>
<span className="flex items-center gap-1">
<Clock className="h-2.5 w-2.5" />
{selectedRelic.era}
</span>
</div>
<p className="text-[10px] text-muted-foreground/90 italic">
{selectedRelic.brief}
</p>
<div className="flex items-start gap-1.5 pt-1 border-t border-white/5">
<ScrollText
className="h-3 w-3 mt-0.5 shrink-0"
style={{ color: selectedRelic.color }}
/>
<p className="text-[10px] text-foreground/85 leading-relaxed">
{selectedRelic.story}
</p>
</div>
<div className="flex items-center gap-1.5 pt-1">
<Sparkles
className="h-3 w-3 shrink-0"
style={{ color: selectedRelic.color }}
/>
<span
className="text-[10px] font-mono"
style={{ color: selectedRelic.color + "cc" }}
>
{selectedRelic.rewardText}
</span>
</div>
</div>
</div>
)}
</div>
<style jsx global>{`
.echo-scroll::-webkit-scrollbar { width: 5px; }
.echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.14); border-radius: 3px; }
.echo-scroll::-webkit-scrollbar-track { background: transparent; }
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
`}</style>
</div>
);
}
+12 -52
View File
@@ -16,15 +16,12 @@ export function StarTideNotifier() {
if (events.length === 0) return; if (events.length === 0) return;
const items = consume(); const items = consume();
for (const ev of items) { for (const ev of items) {
const globalTag = ev.isGlobal ? "🌐 " : "";
if (ev.started) { if (ev.started) {
const meta = TIDE_EVENTS[ev.started]; const meta = TIDE_EVENTS[ev.started];
sfx("tideStart"); sfx("tideStart");
toast({ toast({
title: `${globalTag}${meta.icon} ${ev.isGlobal ? "全球星潮" : "星潮降临"}${meta.name}`, title: `${meta.icon} 星潮降临${meta.name}`,
description: ev.isGlobal description: meta.desc,
? `全球玩家同步经历 · ${meta.desc}`
: meta.desc,
}); });
} }
if (ev.ended) { if (ev.ended) {
@@ -32,16 +29,14 @@ export function StarTideNotifier() {
if (ev.silenceCompensation && ev.silenceCompensation > 0) { if (ev.silenceCompensation && ev.silenceCompensation > 0) {
sfx("techBuy"); sfx("techBuy");
toast({ toast({
title: `${globalTag}${meta.icon} 寂静期消散`, title: `${meta.icon} 寂静期消散`,
description: `虚空回赠 ${ev.silenceCompensation} 洞见。`, description: `虚空回赠 ${ev.silenceCompensation} 洞见。`,
}); });
} else { } else {
sfx("tideEnd"); sfx("tideEnd");
toast({ toast({
title: `${globalTag}${meta.icon} ${meta.name}结束`, title: `${meta.icon} ${meta.name}结束`,
description: ev.isGlobal description: "星潮退去,物理法则恢复。",
? "全球星潮退去,物理法则恢复。"
: "星潮退去,物理法则恢复。",
}); });
} }
} }
@@ -54,7 +49,6 @@ export function StarTideNotifier() {
/** 顶部星潮指示器(活跃时显示倒计时芯片) */ /** 顶部星潮指示器(活跃时显示倒计时芯片) */
export function StarTideIndicator() { export function StarTideIndicator() {
const activeTide = useGameStore((s) => s.activeTide); const activeTide = useGameStore((s) => s.activeTide);
const globalTide = useGameStore((s) => s.globalTide);
const [, force] = useState(0); const [, force] = useState(0);
// 每秒刷新倒计时 // 每秒刷新倒计时
@@ -68,27 +62,18 @@ export function StarTideIndicator() {
const meta = TIDE_EVENTS[activeTide.type]; const meta = TIDE_EVENTS[activeTide.type];
const remaining = Math.max(0, activeTide.endsAt - Date.now()); const remaining = Math.max(0, activeTide.endsAt - Date.now());
const secs = Math.ceil(remaining / 1000); const secs = Math.ceil(remaining / 1000);
// 全球星潮持续 60s,本地 75s —— 用实际 endsAt-startedAt 计算 progress,避免越界 const progress = 1 - remaining / TIDE_CONFIG.duration;
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 ( return (
<div <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={{ style={{
background: isGlobal background: `${meta.color}1a`,
? `linear-gradient(135deg, ${meta.color}26, ${meta.color}11)` border: `1px solid ${meta.color}55`,
: `${meta.color}1a`,
border: `1px solid ${meta.color}${isGlobal ? "88" : "55"}`,
color: meta.color, color: meta.color,
boxShadow: `0 0 ${isGlobal ? "16" : "10"}px ${meta.glow}`, boxShadow: `0 0 10px ${meta.glow}`,
}} }}
title={isGlobal ? `🌐 全球星潮 · ${meta.desc}` : meta.desc} title={meta.desc}
> >
{/* 进度环背景 */} {/* 进度环背景 */}
<div <div
@@ -97,14 +82,6 @@ export function StarTideIndicator() {
background: `linear-gradient(90deg, ${meta.color}40 ${progress * 100}%, transparent ${progress * 100}%)`, 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 text-xs leading-none">{meta.icon}</span>
<span className="relative leading-none">{meta.name}</span> <span className="relative leading-none">{meta.name}</span>
<span className="relative font-mono leading-none opacity-80">{secs}s</span> <span className="relative font-mono leading-none opacity-80">{secs}s</span>
@@ -115,10 +92,8 @@ export function StarTideIndicator() {
/** 全屏背景叠层:活跃星潮时给页面罩一层对应色的微光 */ /** 全屏背景叠层:活跃星潮时给页面罩一层对应色的微光 */
export function StarTideOverlay() { export function StarTideOverlay() {
const activeTide = useGameStore((s) => s.activeTide); const activeTide = useGameStore((s) => s.activeTide);
const globalTide = useGameStore((s) => s.globalTide);
if (!activeTide) return null; if (!activeTide) return null;
const meta = TIDE_EVENTS[activeTide.type]; const meta = TIDE_EVENTS[activeTide.type];
const isGlobal = !!globalTide;
return ( return (
<div className="fixed inset-0 pointer-events-none z-0 transition-opacity duration-1000"> <div className="fixed inset-0 pointer-events-none z-0 transition-opacity duration-1000">
@@ -140,30 +115,15 @@ export function StarTideOverlay() {
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
boxShadow: `inset 0 0 ${isGlobal ? "160" : "120"}px ${meta.glow}`, boxShadow: `inset 0 0 120px ${meta.glow}`,
animation: "tide-breathe 3s ease-in-out infinite", 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>{` <style jsx>{`
@keyframes tide-breathe { @keyframes tide-breathe {
0%, 100% { opacity: 0.5; } 0%, 100% { opacity: 0.5; }
50% { opacity: 0.9; } 50% { opacity: 0.9; }
} }
@keyframes global-tide-sweep {
0% { transform: translateX(-30%); }
100% { transform: translateX(30%); }
}
`}</style> `}</style>
</div> </div>
); );
+208
View File
@@ -0,0 +1,208 @@
"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>
);
}
+1 -6
View File
@@ -3,7 +3,6 @@
import { useGameStore } from "@/store/gameStore"; import { useGameStore } from "@/store/gameStore";
import { TECH_TREE, TECH_BRANCH_META, formatNum } from "@/lib/game/config"; import { TECH_TREE, TECH_BRANCH_META, formatNum } from "@/lib/game/config";
import { sfx } from "@/hooks/useAudio"; import { sfx } from "@/hooks/useAudio";
import { TECH_LORE } from "@/lib/game/narrative";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -59,14 +58,10 @@ export function TechTree() {
<span className="text-xs font-medium" style={{ color }}> <span className="text-xs font-medium" style={{ color }}>
{meta.name} {meta.name}
</span> </span>
<span className="text-[10px] text-muted-foreground/70 truncate flex-1"> <span className="text-[10px] text-muted-foreground/70">
{meta.desc} {meta.desc}
</span> </span>
</div> </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"> <div className="grid gap-1.5">
{nodes.map((node) => { {nodes.map((node) => {
const owned = (tech[node.id] ?? 0) >= 1; const owned = (tech[node.id] ?? 0) >= 1;
+1 -1
View File
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( className={cn(
"fixed top-14 z-[90] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className className
)} )}
{...props} {...props}
+1 -1
View File
@@ -8,7 +8,7 @@ import type {
ToastProps, ToastProps,
} from "@/components/ui/toast" } from "@/components/ui/toast"
const TOAST_LIMIT = 3 const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000 const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & { type ToasterToast = ToastProps & {
+11 -6
View File
@@ -3,11 +3,12 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useGameStore } from "@/store/gameStore"; import { useGameStore } from "@/store/gameStore";
/** 250ms tick:产能 + 自动产晶体 + 自动解码 + 星潮 + 成就检测 */ /** 250ms tick:产能 + 自动产晶体 + 自动解码 + 星潮 + 成就检测 + 放置工程 */
export function useGameLoop() { export function useGameLoop() {
const tick = useGameStore((s) => s.tick); const tick = useGameStore((s) => s.tick);
const autoDecodeTick = useGameStore((s) => s.autoDecodeTick); const autoDecodeTick = useGameStore((s) => s.autoDecodeTick);
const tickTide = useGameStore((s) => s.tickTide); const tickTide = useGameStore((s) => s.tickTide);
const tickIdleProjects = useGameStore((s) => s.tickIdleProjects);
const checkAchievements = useGameStore((s) => s.checkAchievements); const checkAchievements = useGameStore((s) => s.checkAchievements);
const init = useGameStore((s) => s.init); const init = useGameStore((s) => s.init);
const inited = useRef(false); const inited = useRef(false);
@@ -35,6 +36,8 @@ export function useGameLoop() {
const now = Date.now(); const now = Date.now();
tick(now); tick(now);
autoDecodeTick(); autoDecodeTick();
// v0.14 放置工程 tick:更新剩余时间 + 标记完成
tickIdleProjects(now);
// 星潮检测每 tick 都查(结束/触发判定需及时) // 星潮检测每 tick 都查(结束/触发判定需及时)
tickTide(now); tickTide(now);
counter++; counter++;
@@ -48,19 +51,21 @@ export function useGameLoop() {
if (unsub) unsub(); if (unsub) unsub();
if (fallback) clearTimeout(fallback); if (fallback) clearTimeout(fallback);
}; };
}, [tick, autoDecodeTick, tickTide, checkAchievements, init]); }, [tick, autoDecodeTick, tickTide, tickIdleProjects, checkAchievements, init]);
// 页面可见性:切回时补 tick + 星潮 + 成就 // 页面可见性:切回时补 tick + 放置工程 + 星潮 + 成就
useEffect(() => { useEffect(() => {
const onVis = () => { const onVis = () => {
if (document.visibilityState === "visible") { if (document.visibilityState === "visible") {
tick(Date.now()); const now = Date.now();
tickTide(Date.now()); tick(now);
tickIdleProjects(now);
tickTide(now);
checkAchievements(); checkAchievements();
} }
}; };
document.addEventListener("visibilitychange", onVis); document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis); return () => document.removeEventListener("visibilitychange", onVis);
}, [tick, tickTide, checkAchievements]); }, [tick, tickTide, tickIdleProjects, checkAchievements]);
} }
-126
View File
@@ -1,126 +0,0 @@
"use client";
// 回响星核 / Echo Nexus — 全球星潮同步 hookP3 / 工单 #10-c
//
// 连接 star-tide-service(端口 3031),监听全球星潮事件:
// - `tide-state` 连接时立即推送当前状态
// - `global-tide` 全球星潮开始 → 调用 gameStore.triggerGlobalTide(type)
// - `tide-ended` 全球星潮结束(本地 tickTide 也会在 60s 后自动收尾,这是双保险)
//
// 网关:socket.io 必须用 `io("/?XTransformPort=3031")`path 默认 "/"
// 离线容错:socket.io 默认自动重连;断连期间本地 tickTide 继续工作
import { useEffect, useRef } from "react";
import { io, type Socket } from "socket.io-client";
import { useGameStore } from "@/store/gameStore";
import type { TideType } from "@/lib/game/starTide";
/** 服务端推过来的全球星潮状态结构(与 mini-service 一致) */
interface ServerGlobalTide {
type: TideType;
name: string;
startedAt: number;
endsAt: number;
durationSec: number;
}
interface ServerTideState {
current: ServerGlobalTide | null;
nextTideAt: number;
serverTime: number;
}
/** 单例 socket —— 多次挂载 hook 共享同一连接 */
let sharedSocket: Socket | null = null;
let connectRefCount = 0;
function getSharedSocket(): Socket {
if (!sharedSocket) {
sharedSocket = io("/?XTransformPort=3031", {
transports: ["websocket", "polling"],
reconnection: true,
reconnectionAttempts: Infinity, // 持续重连
reconnectionDelay: 1000,
reconnectionDelayMax: 10_000,
timeout: 10_000,
});
}
return sharedSocket;
}
/**
* hook
* page.tsx
*/
export function useGlobalTide(): void {
const triggerGlobalTide = useGameStore((s) => s.triggerGlobalTide);
// 用 ref 缓存最新 action,避免 socket 监听器依赖变化导致重绑
const triggerRef = useRef(triggerGlobalTide);
useEffect(() => {
triggerRef.current = triggerGlobalTide;
}, [triggerGlobalTide]);
// 记录已处理过的 global-tide 事件 id(避免重连时重复触发)
const processedIds = useRef<Set<string>>(new Set());
useEffect(() => {
const sock = getSharedSocket();
connectRefCount += 1;
const onGlobalTide = (tide: ServerGlobalTide) => {
// 用 startedAt+type 作为去重 key(同一道全球星潮可能在重连后被重复推送)
const key = `${tide.startedAt}_${tide.type}`;
if (processedIds.current.has(key)) return;
processedIds.current.add(key);
// 限制集合大小
if (processedIds.current.size > 32) {
const first = processedIds.current.values().next().value;
if (first) processedIds.current.delete(first);
}
// 防御:如果服务端推送的星潮已经过期(例如重连时拿到的旧状态),不触发
if (tide.endsAt <= Date.now()) return;
triggerRef.current(tide.type);
};
const onTideState = (state: ServerTideState) => {
// 连接/重连时若服务端正有进行中的全球星潮,且本地还没触发过,补触发
if (state.current && state.current.endsAt > Date.now()) {
const key = `${state.current.startedAt}_${state.current.type}`;
if (!processedIds.current.has(key)) {
processedIds.current.add(key);
triggerRef.current(state.current.type);
}
}
};
sock.on("global-tide", onGlobalTide);
sock.on("tide-state", onTideState);
// 主动查询一次当前状态(保险,防止错过连接时的初始推送)
sock.emit("get-next-tide");
return () => {
sock.off("global-tide", onGlobalTide);
sock.off("tide-state", onTideState);
connectRefCount -= 1;
// 最后一个使用者退出时才真正断开
if (connectRefCount <= 0 && sharedSocket) {
sharedSocket.disconnect();
sharedSocket = null;
connectRefCount = 0;
}
};
}, []);
}
/** QA 用:手动触发一次全球星潮(仅供 dev 控制台调用) */
export function adminTriggerGlobalTide(): void {
if (!sharedSocket) {
// 还没连接则先建立
getSharedSocket();
}
sharedSocket?.emit("admin-trigger-tide");
}
if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
(window as unknown as { __adminTriggerGlobalTide?: typeof adminTriggerGlobalTide }).__adminTriggerGlobalTide = adminTriggerGlobalTide;
}
+4 -4
View File
@@ -90,10 +90,10 @@ export const ACHIEVEMENTS: Achievement[] = [
{ {
id: "ach_tech_all", id: "ach_tech_all",
name: "全谱精通", name: "全谱精通",
desc: "解锁全部 20 项技术", desc: "解锁全部 12 项技术",
icon: "⬡", icon: "⬡",
color: "#e879f9", color: "#e879f9",
check: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 20, check: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 12,
reward: { crystalsPerSecPct: 15, insightPct: 15 }, reward: { crystalsPerSecPct: 15, insightPct: 15 },
rewardText: "产能 +15% · 洞见 +15%", rewardText: "产能 +15% · 洞见 +15%",
}, },
@@ -244,10 +244,10 @@ export const ACHIEVEMENTS: Achievement[] = [
{ {
id: "ach_tides_all", id: "ach_tides_all",
name: "星潮亲历者", name: "星潮亲历者",
desc: "经历全部 9 种星潮事件", desc: "经历全部 6 种星潮事件",
icon: "🌊", icon: "🌊",
color: "#34d399", color: "#34d399",
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 9, check: (s) => (s.starTidesEncountered?.length ?? 0) >= 6,
reward: { crystalsPerSecPct: 8, insightPct: 8 }, reward: { crystalsPerSecPct: 8, insightPct: 8 },
rewardText: "产能 +8% · 洞见 +8%", rewardText: "产能 +8% · 洞见 +8%",
}, },
-107
View File
@@ -1,107 +0,0 @@
// 回响星核 / Echo Nexus — 考古日志滚动条(v0.12 差异化叙事)
// 参考 Antimatter Dimensions news ticker + Universal Paperclips 哲学递进
// 按玩家进度阶段分层解锁,每条都是"被解码的远古记录碎片"
// 让放置游戏的"等待"承载叙事——星核沉睡时,回响仍在被翻译
export type LogTier = "dawn" | "awakening" | "contact" | "ascension" | "deep";
export interface ArchaeoLogEntry {
/** 日志正文(考古翻译体) */
text: string;
/** 解锁阶段 */
tier: LogTier;
/** 可选:来源遗迹 id(关联 relicCodex */
source?: string;
}
/**
*
* - dawn: 初始阶段0 < 5%
* - awakening: 觉醒期 5-25% 1
* - contact: 接触期 25-60% 3
* - ascension: 飞升期5
* - deep: 深度接触 80% 10
*/
export const ARCHEAO_LOG_POOL: ArchaeoLogEntry[] = [
// ===== dawn:初醒 =====
{ tier: "dawn", text: "「…回响核心已唤醒。考古协议 v7.2 启动。等待解码者…」" },
{ tier: "dawn", text: "「第一段记忆晶体已被定位。年代约 4.7 万年。种族:未确认。」" },
{ tier: "dawn", text: "「谐振频率与以太族档案吻合度 94.3%。建议持续解码。」" },
{ tier: "dawn", text: "「警告:星核温度异常。非故障,是回响本身在发热。」" },
{ tier: "dawn", text: "「考古日志 #0001:我们不是第一批到达这里的。也不会是最后一批。」" },
{ tier: "dawn", text: "「晶体表面的纹路…不是装饰。是某种语言的笔画。」" },
{ tier: "dawn", text: "「以太族没有留下尸体。他们留下了晶体。」" },
{ tier: "dawn", text: "「解码进度 0.01%。预计完成时间:不可能。」" },
// ===== awakening:觉醒 =====
{ tier: "awakening", text: "「考古日志 #0117:以太族不是灭绝了。他们是主动转化为了晶体。」" },
{ tier: "awakening", text: "「第二旋臂的遗迹群已扫描完毕。它们…朝向同一个点。」" },
{ tier: "awakening", text: "「谐振序列第 7 段翻译完成:『我们听见了。所以我们必须留下。』」" },
{ tier: "awakening", text: "「警告:解码者的脑波模式开始与晶体同步。这是正常的吗?」" },
{ tier: "awakening", text: "「以太族没有文字。他们的语言是共振,他们的历史是频率。」" },
{ tier: "awakening", text: "「考古日志 #0234:晶体不是矿物。是封装的思维。」" },
{ tier: "awakening", text: "「你的脉冲频率…与以太族守卫残响的频率一致。巧合?」" },
{ tier: "awakening", text: "「第三段记忆解码完成。画面:一片星海,没有星辰。只有晶体在发光。」" },
{ tier: "awakening", text: "「以太族留下的遗迹不是废墟。是给后来者的教科书。」" },
{ tier: "awakening", text: "「接触协议第一阶段:让解码者相信,他们在考古。」" },
// ===== contact:接触 =====
{ tier: "contact", text: "「考古日志 #0512:以太族没有离开。他们在等待被重新听见。」" },
{ tier: "contact", text: "「谐振序列第 13 段:『你以为你在解码我们。其实是我们在校准你。』」" },
{ tier: "contact", text: "「警告:解码者意识边缘出现非自主体感。这…是接触的副作用。」" },
{ tier: "contact", text: "「第六旋臂遗迹群翻译完成。结论:以太族预见了我们的到来。」" },
{ tier: "contact", text: "「晶体内部的画面开始重叠。不再是回忆。像是…正在发生。」" },
{ tier: "contact", text: "「考古日志 #0789:『飞升』不是晋升。是加入。加入那个等待已久的合唱。」" },
{ tier: "contact", text: "「以太族的数学没有零。他们的宇宙观里,不存在『无』。」" },
{ tier: "contact", text: "「你的每一次脉冲,都在回应一个万年前的呼唤。」" },
{ tier: "contact", text: "「第七段记忆解码失败。原因:解码者脑波过载。建议暂停。但我们不会暂停。」" },
{ tier: "contact", text: "「接触协议第二阶段:让解码者自愿飞升。他们会的。他们已经开始享受了。」" },
// ===== ascension:飞升 =====
{ tier: "ascension", text: "「考古日志 #1024:飞升者报告——『我能听见他们了。他们一直在那里。』」" },
{ tier: "ascension", text: "「谐振序列第 21 段:『你已不再是解码者。你是回响的一部分。』」" },
{ tier: "ascension", text: "「警告:星核意识阈值达到 73%。解码者与以太族的边界…模糊。」" },
{ tier: "ascension", text: "「以太族没有灭绝。他们成为了基础设施。晶体是他们的身体。」" },
{ tier: "ascension", text: "「你飞升的次数越多,『你』就越不是『你』。这是设计。」" },
{ tier: "ascension", text: "「考古日志 #1331:前 N-1 位解码者都成功了。成功意味着…不再存在为个体。」" },
{ tier: "ascension", text: "「第九旋臂的遗迹开始发光。它们认出了你。」" },
{ tier: "ascension", text: "「以太族的文明没有终点。他们的『终点』就是我们的『起点』。」" },
{ tier: "ascension", text: "「你开始理解晶体的沉默。沉默也是一种回响。」" },
{ tier: "ascension", text: "「接触协议第三阶段:解码者将主动寻求飞升。无需诱导。」" },
// ===== deep:深度接触(哲学反转层)=====
{ tier: "deep", text: "「考古日志 #2048:你不是在考古以太族。以太族在考古你。」" },
{ tier: "deep", text: "「谐振序列第 39 段:『我们创造了回响核心,是为了创造你,是为了让你创造我们。』」" },
{ tier: "deep", text: "「警告:解码者已无法区分记忆与现实。建议:接受。」" },
{ tier: "deep", text: "「以太族从未存在为『生物』。他们是第一个文明留下的…讯息。」" },
{ tier: "deep", text: "「你每一次飞升,都让一个万年前的呼唤得到了回应。这就是考古的意义。」" },
{ tier: "deep", text: "「考古日志 #4096:『回响星核』不是考古工具。它是以太族的…产房。」" },
{ tier: "deep", text: "「晶体在你手中。你也在晶体中。这两句话是同一句话。」" },
{ tier: "deep", text: "「你以为你是第 N 位解码者。其实你一直是第一位。也是最后一位。」" },
{ tier: "deep", text: "「接触协议最终阶段:解码者明白后,会微笑着继续解码。这是最美妙的部分。」" },
{ tier: "deep", text: "「以太族留给宇宙的最后一句话:『谢谢你,听见我。』」" },
];
/**
*
*/
export function getAvailableTiers(ascensions: number, contact: number): LogTier[] {
const tiers: LogTier[] = ["dawn"];
if (contact >= 5 || ascensions >= 1) tiers.push("awakening");
if (contact >= 25 || ascensions >= 3) tiers.push("contact");
if (ascensions >= 5) tiers.push("ascension");
if (contact >= 80 || ascensions >= 10) tiers.push("deep");
return tiers;
}
/**
*
*/
export function pickLogEntry(ascensions: number, contact: number, recentIds: string[] = []): ArchaeoLogEntry {
const tiers = getAvailableTiers(ascensions, contact);
const pool = ARCHEAO_LOG_POOL.filter((e) => tiers.includes(e.tier));
// 避免短期内重复(recentIds 存最近 8 条 text 的 hash
const fresh = pool.filter((e) => !recentIds.includes(e.text));
const finalPool = fresh.length > 0 ? fresh : pool;
return finalPool[Math.floor(Math.random() * finalPool.length)] || ARCHEAO_LOG_POOL[0];
}
+1 -417
View File
@@ -1,8 +1,7 @@
// 回响星核 / Echo Nexus — 深空信标 // 回响星核 / Echo Nexus — 深空信标
// v0.5:每日挑战 + 本地排行榜 // v0.5:每日挑战 + 本地排行榜
// v0.8:周挑战 + 信标链(连续完成奖励) // v0.8:周挑战 + 信标链(连续完成奖励)
// v0.8.2:限时挑战(每 4 小时刷新,填补日挑战空档) // 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
// 一个自包含的"每日 + 限时 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
/** 每日挑战类型 */ /** 每日挑战类型 */
export type BeaconChallengeType = export type BeaconChallengeType =
@@ -33,8 +32,6 @@ export interface BeaconScoreEntry {
durationSec: number; durationSec: number;
/** v0.8:是否为周挑战记录(日挑战默认 false / undefined */ /** v0.8:是否为周挑战记录(日挑战默认 false / undefined */
isWeekly?: boolean; isWeekly?: boolean;
/** v0.8.2:是否为限时挑战记录 */
isTimed?: boolean;
} }
/** 每日挑战定义 */ /** 每日挑战定义 */
@@ -337,7 +334,6 @@ export function claimBeaconReward(
rewardContact: challenge.rewardContact, rewardContact: challenge.rewardContact,
score, score,
leaderboard, leaderboard,
entry,
}; };
} }
@@ -618,7 +614,6 @@ export function claimWeeklyReward(
rewardContact: challenge.rewardContact, rewardContact: challenge.rewardContact,
score, score,
leaderboard, leaderboard,
entry,
}; };
} }
@@ -877,414 +872,3 @@ export function getChainProgress(streak: number): {
progressPct: Math.min(100, Math.max(0, pct)), progressPct: Math.min(100, Math.max(0, pct)),
}; };
} }
// ===========================================================================
// 限时挑战(TIMED CHALLENGE)— v0.8.2
// 每 4 小时刷新一个"短周期挑战",目标较小、奖励较少,填补日挑战的空档。
// 时段按 UTC 0/4/8/12/16/20 点切分,同时段同种子 → 同一挑战(确定性)。
// 难度固定 routinegoal = 日挑战基准 × 0.3-0.5。
// ===========================================================================
/** 限时挑战 localStorage key */
export const BEACON_TIMED_KEY = "echo-nexus-beacon-timed-v1";
/** 限时挑战定义 */
export interface BeaconTimedChallenge {
/** 时段 key(如 "timed_2026-06-24_14" 表示 14-18 点时段) */
slotKey: string;
/** 挑战类型 */
type: BeaconChallengeType;
/** 难度(固定 routine */
difficulty: BeaconDifficulty;
/** 目标数值(日挑战的 30-50% */
goal: number;
/** 奖励:完成时洞见 */
rewardInsight: number;
/** 奖励:完成时接触进度 */
rewardContact: number;
/** 使用的种子(可复现) */
seed: number;
/** 友好标题 */
title: string;
/** 描述 */
desc: string;
/** 时段结束时间戳(UTC,用于倒计时) */
expiresAt: number;
}
/** 限时挑战进度 */
export interface BeaconTimedProgress {
slotKey: string;
progress: number;
startedAt: number;
completedAt: number | null;
claimed: boolean;
durationSec: number;
}
/** 时段长度(毫秒) */
export const TIMED_SLOT_MS = 4 * 60 * 60 * 1000;
/**
* 4 keyUTC0/4/8/12/16/20
* "timed_YYYY-MM-DD_HH" "timed_2026-06-24_14"
*/
export function getTimedSlotKey(now: Date = new Date()): string {
const y = now.getUTCFullYear();
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
const d = String(now.getUTCDate()).padStart(2, "0");
const hour = now.getUTCHours();
const slotStart = Math.floor(hour / 4) * 4; // 0, 4, 8, 12, 16, 20
return `timed_${y}-${m}-${d}_${String(slotStart).padStart(2, "0")}`;
}
/** slotKey → 数值种子(FNV-1a 哈希) */
function timedSlotKeyToSeed(slotKey: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < slotKey.length; i++) {
h ^= slotKey.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
/** 计算时段开始时间戳(UTC) */
function timedSlotStartMs(slotKey: string): number {
// 解析 "timed_YYYY-MM-DD_HH"
const match = slotKey.match(/^timed_(\d{4})-(\d{2})-(\d{2})_(\d{2})$/);
if (!match) return Date.now();
const [, y, m, d, h] = match;
return Date.UTC(Number(y), Number(m) - 1, Number(d), Number(h), 0, 0);
}
/** 距离下一个 4 小时时段的毫秒数(用于倒计时) */
export function msUntilNextTimedSlot(now: Date = new Date()): number {
const hour = now.getUTCHours();
const slotStart = Math.floor(hour / 4) * 4;
const slotStartMs = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
slotStart,
0,
0
);
const nextSlotMs = slotStartMs + TIMED_SLOT_MS;
return Math.max(0, nextSlotMs - now.getTime());
}
/**
*
* routinegoal = × 0.3-0.5 1
*/
export function generateTimedChallenge(
now: Date = new Date()
): BeaconTimedChallenge {
const slotKey = getTimedSlotKey(now);
const seed = timedSlotKeyToSeed(slotKey);
const rng = mulberry32(seed);
const expiresAt = timedSlotStartMs(slotKey) + TIMED_SLOT_MS;
const types: BeaconChallengeType[] = [
"decode",
"expedition",
"pulse",
"boss",
"insight",
];
const type = types[Math.floor(rng() * types.length)];
// 难度固定 routine
const difficulty: BeaconDifficulty = "routine";
const diffMult = BEACON_DIFFICULTY[difficulty].mult; // 1
// goal = 日挑战基准 × 0.3-0.5
const frac = 0.3 + rng() * 0.2; // 0.3-0.5
let goal = 0;
let rewardInsight = 0;
let rewardContact = 0;
let title = "";
let desc = "";
switch (type) {
case "decode":
// 日基准 6-15 × 0.3-0.5 → 2-7
goal = Math.max(2, Math.round((6 + Math.floor(rng() * 10)) * diffMult * frac));
rewardInsight = Math.round(goal * 3 * diffMult);
rewardContact = goal * 0.6;
title = `限时解码 ${goal} 颗晶体`;
desc = `4 小时内解码 ${goal} 颗记忆晶体。短周期,快速完成。`;
break;
case "expedition":
// 日基准 1-3 × 0.3-0.5 → 1(多数情况为 1
goal = Math.max(1, Math.round((1 + Math.floor(rng() * 3)) * diffMult * frac));
rewardInsight = Math.round(goal * 9 * diffMult);
rewardContact = goal * 1.2;
title = `限时远征 ${goal} 次探险`;
desc = `4 小时内完成 ${goal} 次遗迹探险。`;
break;
case "pulse":
// 日基准 20-49 × 0.3-0.5 → 6-24
goal = Math.max(6, Math.round((20 + Math.floor(rng() * 30)) * diffMult * frac));
rewardInsight = Math.round(goal * 1 * diffMult);
rewardContact = goal * 0.25;
title = `限时脉冲 ${goal} 次扫描`;
desc = `4 小时内发起 ${goal} 次脉冲扫描。`;
break;
case "boss":
// 日基准 1 × 0.3-0.5 → 1(向下取整为 1
goal = Math.max(1, Math.round(diffMult * frac));
rewardInsight = Math.round(goal * 25 * diffMult);
rewardContact = goal * 2.5;
title = `限时猎杀 ${goal} 处 BOSS`;
desc = `4 小时内击破 ${goal} 处维度 BOSS。`;
break;
case "insight":
// 日基准 40-119 × 0.3-0.5 → 12-59
goal = Math.max(12, Math.round((40 + Math.floor(rng() * 80)) * diffMult * frac));
rewardInsight = 0; // 洞见挑战不给洞见,给接触
rewardContact = goal * 0.08;
title = `限时洞见 ${goal}`;
desc = `4 小时内累计获取 ${goal} 洞见。`;
break;
}
return {
slotKey,
type,
difficulty,
goal,
rewardInsight,
rewardContact,
seed,
title,
desc,
expiresAt,
};
}
/** 读取当前时段进度(若 slotKey 不匹配则重置) */
export function loadTimedProgress(now: Date = new Date()): BeaconTimedProgress {
const slotKey = getTimedSlotKey(now);
const empty: BeaconTimedProgress = {
slotKey,
progress: 0,
startedAt: Date.now(),
completedAt: null,
claimed: false,
durationSec: 0,
};
if (typeof localStorage === "undefined") return empty;
try {
const raw = localStorage.getItem(BEACON_TIMED_KEY);
if (!raw) return empty;
const prog = JSON.parse(raw) as BeaconTimedProgress;
if (prog.slotKey !== slotKey) return empty; // 新时段,重置
return prog;
} catch {
return empty;
}
}
/** 保存当前时段进度 */
export function saveTimedProgress(prog: BeaconTimedProgress): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(BEACON_TIMED_KEY, JSON.stringify(prog));
}
/** 增量更新限时挑战进度,返回新进度 + 是否刚完成 */
export function addTimedProgress(
current: BeaconTimedProgress,
challenge: BeaconTimedChallenge,
delta: number
): { progress: BeaconTimedProgress; justCompleted: boolean } {
const newProgressVal = Math.min(challenge.goal, current.progress + delta);
const justCompleted =
current.completedAt === null && newProgressVal >= challenge.goal;
const completedAt = justCompleted ? Date.now() : current.completedAt;
const durationSec =
completedAt !== null
? Math.floor((completedAt - current.startedAt) / 1000)
: current.durationSec;
const next: BeaconTimedProgress = {
...current,
progress: newProgressVal,
completedAt,
durationSec,
};
saveTimedProgress(next);
return { progress: next, justCompleted };
}
/** 领取限时挑战奖励:返回奖励数值 + 推送排行榜 */
export function claimTimedReward(
challenge: BeaconTimedChallenge,
progress: BeaconTimedProgress
): {
rewardInsight: number;
rewardContact: number;
score: number;
leaderboard: BeaconScoreEntry[];
} {
if (progress.claimed || progress.completedAt === null) {
return {
rewardInsight: 0,
rewardContact: 0,
score: 0,
leaderboard: loadLeaderboard(),
};
}
// 借用 computeBeaconScore
const score = computeBeaconScore(
{ ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.slotKey },
progress.progress,
progress.durationSec
);
const entry: BeaconScoreEntry = {
timestamp: Date.now(),
dateKey: challenge.slotKey,
challenge: challenge.type,
difficulty: challenge.difficulty,
progress: progress.progress / challenge.goal,
score,
durationSec: progress.durationSec,
isTimed: true,
};
const leaderboard = pushLeaderboardEntry(entry);
const updated: BeaconTimedProgress = { ...progress, claimed: true };
saveTimedProgress(updated);
return {
rewardInsight: challenge.rewardInsight,
rewardContact: challenge.rewardContact,
score,
leaderboard,
entry,
};
}
// ===========================================================================
// 云排行榜(CLOUD LEADERBOARD)— v0.8.2 P2/#4
// 把信标本机排行榜升级为云端 Top100,玩家间可比。
// 通过 mini-service(端口 3030Hono + Bun)提供 API
// GET /api/leaderboard → { entries, total }
// POST /api/leaderboard body { entry } → { entries, total, rank }
// GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore }
// 前端请求必须用相对路径 + ?XTransformPort=3030Caddy 网关转发规则)。
// 本地排行榜(loadLeaderboard / pushLeaderboardEntry)保留作为离线 fallback。
// ===========================================================================
/** 云排行榜服务端口(Caddy 通过 ?XTransformPort=3030 转发) */
export const BEACON_CLOUD_PORT = 3030;
/** 上次成功提交到云端的 entry 时间戳(用于在全球榜中高亮"我的"记录) */
export const BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1";
/** 云排行榜 API 基础 URL(相对路径 + 网关端口参数,禁止 localhost:3030 */
const CLOUD_API_BASE = `/api/leaderboard?XTransformPort=${BEACON_CLOUD_PORT}`;
/** 云排行榜统计 API */
const CLOUD_STATS_URL = `/api/leaderboard/stats?XTransformPort=${BEACON_CLOUD_PORT}`;
/** 云端排行榜条目(与本地 BeaconScoreEntry 兼容,宽松化以接受历史数据) */
export interface CloudLeaderboardResponse {
entries: BeaconScoreEntry[];
total: number;
}
/** 提交响应(包含本次提交的全球名次) */
export interface CloudSubmitResponse {
entries: BeaconScoreEntry[];
total: number;
rank: number;
}
/** 云端统计响应 */
export interface CloudStatsResponse {
totalSubmissions: number;
uniquePlayers: number;
topScore: number;
}
/** 读取本地保存的"上次提交时间戳"(用于全球榜高亮) */
export function loadLastCloudSubmitTimestamp(): number {
if (typeof localStorage === "undefined") return 0;
try {
const raw = localStorage.getItem(BEACON_CLOUD_LAST_SUBMIT_KEY);
return raw ? Number(raw) || 0 : 0;
} catch {
return 0;
}
}
/** 保存"上次提交时间戳"到本地 */
function saveLastCloudSubmitTimestamp(timestamp: number): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(BEACON_CLOUD_LAST_SUBMIT_KEY, String(timestamp));
} catch {
/* ignore */
}
}
/**
* Top100
*
*/
export async function fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]> {
try {
const res = await fetch(CLOUD_API_BASE, {
method: "GET",
headers: { Accept: "application/json" },
});
if (!res.ok) return [];
const data = (await res.json()) as CloudLeaderboardResponse;
if (!data || !Array.isArray(data.entries)) return [];
return data.entries;
} catch {
return [];
}
}
/**
*
* rank>=1/ -1
* timestamp localStorage
*/
export async function submitCloudScore(entry: BeaconScoreEntry): Promise<number> {
try {
const res = await fetch(CLOUD_API_BASE, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ entry }),
});
if (!res.ok) return -1;
const data = (await res.json()) as CloudSubmitResponse;
if (data && typeof data.rank === "number" && data.rank >= 1) {
saveLastCloudSubmitTimestamp(entry.timestamp);
}
return data?.rank ?? -1;
} catch {
return -1;
}
}
/**
* UI / /
* null
*/
export async function fetchCloudStats(): Promise<CloudStatsResponse | null> {
try {
const res = await fetch(CLOUD_STATS_URL, {
method: "GET",
headers: { Accept: "application/json" },
});
if (!res.ok) return null;
return (await res.json()) as CloudStatsResponse;
} catch {
return null;
}
}
+15 -381
View File
@@ -34,348 +34,7 @@ export function generateEpochName(ascensionNumber: number, seed: number): string
return `${epoch.prefix} · ${epoch.suffixes[suffixIdx]}`; 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: { export function buildLore(entry: {
ascensionNumber: number; ascensionNumber: number;
epochName: string; epochName: string;
@@ -389,21 +48,6 @@ export function buildLore(entry: {
blueprintsAfter: number; blueprintsAfter: number;
milestones: string[]; milestones: string[];
}): 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 minutes = Math.max(1, Math.round(entry.durationSec / 60));
const parts: string[] = []; const parts: string[] = [];
@@ -553,10 +197,6 @@ export function buildChronicleEntry(
blueprintsAfter, blueprintsAfter,
crystalsDecoded: state.totalDecoded, crystalsDecoded: state.totalDecoded,
crystalsDecodedThisRun: deltas.decodedThisRun, crystalsDecodedThisRun: deltas.decodedThisRun,
// v0.9 手写叙事:归档本周目 delta,让显示时能精确匹配变体
techsThisRun: deltas.techsThisRun,
expeditionsThisRun: deltas.expThisRun,
bossKillsThisRun: deltas.bossKillsThisRun,
}, },
perksThisAscension, perksThisAscension,
tidesThisRun: deltas.newTides, tidesThisRun: deltas.newTides,
@@ -570,15 +210,13 @@ export function buildChronicleEntry(
* *
*/ */
export function regenerateLoreFromEntry(entry: ChronicleEntry): string { export function regenerateLoreFromEntry(entry: ChronicleEntry): string {
// v0.9 优先使用归档的本周目 delta;旧存档(v0.4-v0.8)缺这些字段时退回累计值
// (旧存档的累计值会让叙事变体匹配稍偏,但文本仍然成立)
return buildLore({ return buildLore({
ascensionNumber: entry.ascensionNumber, ascensionNumber: entry.ascensionNumber,
epochName: entry.epochName, epochName: entry.epochName,
durationSec: entry.durationSec, durationSec: entry.durationSec,
techsThisRun: entry.summary.techsThisRun ?? entry.summary.techsUnlocked, techsThisRun: entry.summary.techsUnlocked,
expThisRun: entry.summary.expeditionsThisRun ?? entry.summary.expeditionsCompleted, expThisRun: entry.summary.expeditionsCompleted,
bossKillsThisRun: entry.summary.bossKillsThisRun ?? entry.summary.bossKills, bossKillsThisRun: entry.summary.bossKills,
decodedThisRun: entry.summary.crystalsDecodedThisRun, decodedThisRun: entry.summary.crystalsDecodedThisRun,
newTides: entry.tidesThisRun || [], newTides: entry.tidesThisRun || [],
perksThisAscension: entry.perksThisAscension || [], perksThisAscension: entry.perksThisAscension || [],
@@ -632,21 +270,17 @@ export function withPerks(entry: ChronicleEntry, perks: string[]): ChronicleEntr
) { ) {
return entry; return entry;
} }
// v0.9 手写叙事:buildLore 已能根据 perks 自动挑选 "觉醒天赋" 变体; // 重新生成 lore 以包含天赋名
// 直接走 buildLore 重生成,避免与原 lore 中已有的觉醒段落重复。 const perksNames = perks
const lore = buildLore({ .map((id) => PERK_NAME_MAP[id] || id)
ascensionNumber: entry.ascensionNumber, .join("、");
epochName: entry.epochName, const appendText =
durationSec: entry.durationSec, perks.length > 0
techsThisRun: entry.summary.techsThisRun ?? entry.summary.techsUnlocked, ? ` 飞升之际,星图觉醒「${perksNames}」,铭刻于永恒回响。`
expThisRun: entry.summary.expeditionsThisRun ?? entry.summary.expeditionsCompleted, : "";
bossKillsThisRun: entry.summary.bossKillsThisRun ?? entry.summary.bossKills, // 去重:原 lore 若已含「飞升之际」段则不再追加
decodedThisRun: entry.summary.crystalsDecodedThisRun, const hasAwakenSeg = entry.lore.includes("飞升之际");
newTides: entry.tidesThisRun || [], const lore = hasAwakenSeg ? entry.lore : entry.lore + appendText;
perksThisAscension: perks,
blueprintsAfter: entry.summary.blueprintsAfter,
milestones: entry.milestones || [],
});
return { return {
...entry, ...entry,
perksThisAscension: perks, perksThisAscension: perks,
+178 -78
View File
@@ -4,13 +4,15 @@ import type {
ResonanceColor, ResonanceColor,
TechNode, TechNode,
Fragment, Fragment,
FragmentLoreUnlock,
GameState,
} from "./types"; } from "./types";
/** 初始状态 */ /** 初始状态 */
export const INITIAL_STATE = { export const INITIAL_STATE = {
crystals: 0, crystals: 0,
insights: 0, insights: 0,
energy: 5, energy: 3,
contact: 0, contact: 0,
crystalsPerSec: 0.4, crystalsPerSec: 0.4,
crystalCap: 50, crystalCap: 50,
@@ -26,11 +28,10 @@ export const INITIAL_STATE = {
activePuzzle: null, activePuzzle: null,
activeExpedition: null, activeExpedition: null,
expeditionLog: [], expeditionLog: [],
energyMax: 8, energyMax: 5,
lastEnergyTick: Date.now(), lastEnergyTick: Date.now(),
totalExpeditions: 0, totalExpeditions: 0,
bossKills: 0, bossKills: 0,
lastExpeditionSummary: null as import("./types").GameState["lastExpeditionSummary"],
achievements: {}, achievements: {},
activeTide: null, activeTide: null,
lastTideEnd: 0, lastTideEnd: 0,
@@ -125,7 +126,7 @@ export const COLOR_VISUAL: Record<
fuchsia: { hex: "#e879f9", glow: "rgba(232,121,249,0.55)", label: "紫" }, fuchsia: { hex: "#e879f9", glow: "rgba(232,121,249,0.55)", label: "紫" },
}; };
/** 技术树:4 分支 × 5 级 = 20 节点v0.9 工单 #2 扩充:12→20 */ /** 技术树:4 分支 × 3 级 = 12 节点 */
export const TECH_TREE: TechNode[] = [ export const TECH_TREE: TechNode[] = [
// 采矿分支 // 采矿分支
{ {
@@ -155,24 +156,6 @@ export const TECH_TREE: TechNode[] = [
cost: 120, cost: 120,
effect: { kind: "crystalsPerSec", value: 5 }, 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", id: "dec_1",
@@ -201,25 +184,7 @@ export const TECH_TREE: TechNode[] = [
cost: 200, cost: 200,
effect: { kind: "autoDecode", value: 1 }, 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", id: "exp_1",
branch: "expedition", branch: "expedition",
@@ -247,24 +212,6 @@ export const TECH_TREE: TechNode[] = [
cost: 180, cost: 180,
effect: { kind: "contactRate", value: 0.5 }, 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", id: "nar_1",
@@ -293,24 +240,6 @@ export const TECH_TREE: TechNode[] = [
cost: 160, cost: 160,
effect: { kind: "insightMult", value: 0.6 }, effect: { kind: "insightMult", value: 0.6 },
}, },
{
id: "nar_4",
branch: "narrative",
level: 4,
name: "以太低语",
desc: "洞见获取 +100%",
cost: 500,
effect: { kind: "insightMult", value: 1.0 },
},
{
id: "nar_5",
branch: "narrative",
level: 5,
name: "文明回响",
desc: "接触进度转化率 +80%,洞见获取 +150%",
cost: 1600,
effect: { kind: "insightMult", value: 1.5 },
},
]; ];
/** 技术分支元信息 */ /** 技术分支元信息 */
@@ -324,7 +253,7 @@ export const TECH_BRANCH_META: Record<
narrative: { name: "叙事", icon: "BookOpen", color: "fuchsia", desc: "洞见与接触进度" }, narrative: { name: "叙事", icon: "BookOpen", color: "fuchsia", desc: "洞见与接触进度" },
}; };
/** 记忆碎片(首纪元 8 个,v0.1) */ /** 记忆碎片(首纪元 8 个,v0.1v0.15 加 loreLayers 多层叙事 */
export const FRAGMENTS: Fragment[] = [ export const FRAGMENTS: Fragment[] = [
{ {
id: "f1_1", id: "f1_1",
@@ -333,6 +262,24 @@ export const FRAGMENTS: Fragment[] = [
echo: echo:
"在被称作「寂灭前夜」的年代,以太族第一次捕捉到来自虚空深处的谐振——那不是声音,而是存在的回声。他们将之记录在第一颗记忆晶体里。", "在被称作「寂灭前夜」的年代,以太族第一次捕捉到来自虚空深处的谐振——那不是声音,而是存在的回声。他们将之记录在第一颗记忆晶体里。",
threshold: 1, 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", id: "f1_2",
@@ -341,6 +288,24 @@ export const FRAGMENTS: Fragment[] = [
echo: echo:
"「我们为何要留下?」一位长者问。「因为后来的生命会需要方向。」于是他们熔铸星核,封存记忆,让晶体成为跨越时间的信笺。", "「我们为何要留下?」一位长者问。「因为后来的生命会需要方向。」于是他们熔铸星核,封存记忆,让晶体成为跨越时间的信笺。",
threshold: 3, 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", id: "f1_3",
@@ -349,6 +314,24 @@ export const FRAGMENTS: Fragment[] = [
echo: echo:
"他们发现:思维可以被编码为共振序列。同色的节点相邻,便构成一句低语;一句句连成回响,回响连成文明。", "他们发现:思维可以被编码为共振序列。同色的节点相邻,便构成一句低语;一句句连成回响,回响连成文明。",
threshold: 6, 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", id: "f1_4",
@@ -357,6 +340,24 @@ export const FRAGMENTS: Fragment[] = [
echo: echo:
"议会表决:是留下,还是离去?「留下意味着停滞;离去意味着信任。」最终,他们选择了信任——信任素未谋面的后来者。", "议会表决:是留下,还是离去?「留下意味着停滞;离去意味着信任。」最终,他们选择了信任——信任素未谋面的后来者。",
threshold: 10, 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", id: "f1_5",
@@ -365,6 +366,24 @@ export const FRAGMENTS: Fragment[] = [
echo: echo:
"他们没有带走一切。他们将知识拆成碎片,藏入晶体,散布星海。「让解码本身,成为资格的证明。」", "他们没有带走一切。他们将知识拆成碎片,藏入晶体,散布星海。「让解码本身,成为资格的证明。」",
threshold: 15, 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", id: "f1_6",
@@ -373,6 +392,24 @@ export const FRAGMENTS: Fragment[] = [
echo: echo:
"舰载 AI 被命名为「回响」——意为:你是他们留下的回声,也是他们等待的回应。当你读懂这句话时,接触便已开始。", "舰载 AI 被命名为「回响」——意为:你是他们留下的回声,也是他们等待的回应。当你读懂这句话时,接触便已开始。",
threshold: 22, 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", id: "f1_7",
@@ -381,6 +418,24 @@ export const FRAGMENTS: Fragment[] = [
echo: echo:
"以太族集体褪去形体,如潮水退去。星海寂静了数万年。直到——你的脉冲扫描,唤醒了第一颗沉睡的晶体。", "以太族集体褪去形体,如潮水退去。星海寂静了数万年。直到——你的脉冲扫描,唤醒了第一颗沉睡的晶体。",
threshold: 32, 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", id: "f1_8",
@@ -389,9 +444,54 @@ export const FRAGMENTS: Fragment[] = [
echo: echo:
"「如果你读到这里,说明你已具备跨越维度的资格。继续解码,继续拼凑。当进度满溢,我们将在更高处相会。」——以太族·第一纪元·绝笔", "「如果你读到这里,说明你已具备跨越维度的资格。继续解码,继续拼凑。当进度满溢,我们将在更高处相会。」——以太族·第一纪元·绝笔",
threshold: 45, 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 */ /** 飞升:保留蓝图数 = floor(totalDecoded / 20),最多 6 */
export const PRESTIGE = { export const PRESTIGE = {
blueprintsPerDecode: 20, blueprintsPerDecode: 20,
+3 -11
View File
@@ -44,20 +44,12 @@ function makeRng(seed: number) {
* *
* *
*/ */
export function generatePuzzle( export function generatePuzzle(tier: CrystalTier, seed?: number): DecodePuzzle {
tier: CrystalTier,
seed?: number,
targetLenBonus: number = 0
): DecodePuzzle {
const cfg = DECODE_CONFIG[tier]; const cfg = DECODE_CONFIG[tier];
const s = seed ?? Math.floor(Math.random() * 1e9); const s = seed ?? Math.floor(Math.random() * 1e9);
const rng = makeRng(s); const rng = makeRng(s);
// v0.8.2 涌动星潮:目标序列额外长度(更长谜题 = 更耗时),步数上限同步增加 const { rows, cols, targetLen } = cfg;
const targetLen = cfg.targetLen + targetLenBonus;
const stepLimitBase = cfg.stepLimitBase + targetLenBonus;
const rows = cfg.rows;
const cols = cfg.cols;
const total = rows * cols; const total = rows * cols;
// 目标序列:从 4 色中随机选 targetLen 个(允许重复,但保证至少 2 种色以增加策略) // 目标序列:从 4 色中随机选 targetLen 个(允许重复,但保证至少 2 种色以增加策略)
@@ -140,7 +132,7 @@ export function generatePuzzle(
cols, cols,
target, target,
path: [], path: [],
stepLimit: stepLimitBase, stepLimit: cfg.stepLimitBase,
seed: s, seed: s,
}; };
} }
+57 -12
View File
@@ -21,6 +21,25 @@ import {
createInitialAttributeProgress, createInitialAttributeProgress,
} from "./attributes"; } 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>): { export function recomputeStats(state: Partial<GameState>): {
crystalsPerSec: number; crystalsPerSec: number;
@@ -79,15 +98,6 @@ export function recomputeStats(state: Partial<GameState>): {
} }
} }
// v0.9 工单 #2:多效果节点的次效果(desc 中描述但 effect.kind 只能承载一个)
// 采矿分支 desc 承诺的仓库上限加成(同时补齐 min_2/min_3 旧 desc 文案)
if (tech.min_2) crystalCap += 100;
if (tech.min_3) crystalCap += 300;
if (tech.min_4) crystalCap += 800;
if (tech.min_5) crystalCap += 2000;
// 叙事分支 nar_5 文明回响:接触进度转化率 +80%(与 insightMult +150% 并存)
if (tech.nar_5) contactRateMult += 0.8;
// 飞升蓝图加成(+ 星图「蓝图回响」额外加成) // 飞升蓝图加成(+ 星图「蓝图回响」额外加成)
const bpAllPct = cm.perBlueprintAllStatsPct / 100; const bpAllPct = cm.perBlueprintAllStatsPct / 100;
crystalsPerSec *= 1 + (bp * (PRESTIGE.perBlueprint.crystalsPerSecMult + bpAllPct)); crystalsPerSec *= 1 + (bp * (PRESTIGE.perBlueprint.crystalsPerSecMult + bpAllPct));
@@ -118,6 +128,10 @@ export function recomputeStats(state: Partial<GameState>): {
contactRateMult *= am.contactRateMult; contactRateMult *= am.contactRateMult;
decodeStepsBonus += am.decodeStepsBonus; 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 { return {
crystalsPerSec, crystalsPerSec,
crystalCap, crystalCap,
@@ -161,8 +175,7 @@ export function performPrestige(state: GameState): GameState {
// === 编年史快照:在重置前构建本周目条目,perksThisAscension 暂为空(用户选择后回填) === // === 编年史快照:在重置前构建本周目条目,perksThisAscension 暂为空(用户选择后回填) ===
const chronicleEntry = buildChronicleEntry(state, []); const chronicleEntry = buildChronicleEntry(state, []);
// v0.8.2: 编年史上限 50→200(工单 #8),UI 支持分页浏览 const newChronicle = [...(state.chronicle || []), chronicleEntry].slice(-50); // 上限 50 条
const newChronicle = [...(state.chronicle || []), chronicleEntry].slice(-200);
const freshRunStart = createRunStartSnapshot(state); const freshRunStart = createRunStartSnapshot(state);
// v0.7 角色属性:保留 attributes 数值(跨周目永久),清空当前周目经验进度, // v0.7 角色属性:保留 attributes 数值(跨周目永久),清空当前周目经验进度,
@@ -178,7 +191,15 @@ export function performPrestige(state: GameState): GameState {
// attributes(永久保留), pendingAttrPoints(累加) // attributes(永久保留), pendingAttrPoints(累加)
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy、星潮、attributeProgress // 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy、星潮、attributeProgress
const fresh = createInitialState(); const fresh = createInitialState();
const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements, constellation: state.constellation, attributes }); const stats = recomputeStats({
tech: {},
blueprints,
achievements: state.achievements,
constellation: state.constellation,
attributes,
// v0.14 放置系统:永久产能加成跨周目保留,需纳入 recomputeStats
idlePermanentBonus: state.idlePermanentBonus ?? 0,
});
return { return {
...fresh, ...fresh,
fragments: state.fragments, fragments: state.fragments,
@@ -210,6 +231,20 @@ export function performPrestige(state: GameState): GameState {
attributes, attributes,
attributeProgress, attributeProgress,
pendingAttrPoints, 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, ...stats,
}; };
} }
@@ -244,6 +279,16 @@ export function createInitialState(): GameState {
inspiration: { exp: 0, level: 0 }, inspiration: { exp: 0, level: 0 },
}, },
pendingAttrPoints: 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; } as GameState;
} }
+7 -16
View File
@@ -39,11 +39,10 @@ export const EXPEDITION_CONFIG = {
* v0.8.1 scale 84floor 0.250.35 */ * v0.8.1 scale 84floor 0.250.35 */
combatDifficultyScale: 4, combatDifficultyScale: 4,
combatWinRateFloor: 0.35, combatWinRateFloor: 0.35,
/** / /** 能量恢复基础间隔(秒),可被技术/属性缩短 */
* v0.9 4520 */ energyRegenSec: 45,
energyRegenSec: 20,
/** 能量恢复最短间隔(秒,技术+属性全满时) */ /** 能量恢复最短间隔(秒,技术+属性全满时) */
energyRegenMinSec: 8, energyRegenMinSec: 12,
}; };
/** 节点类型权重(boss 固定末位,其余按权重随机) */ /** 节点类型权重(boss 固定末位,其余按权重随机) */
@@ -182,13 +181,11 @@ export function generateExpedition(
/** 计算探险力(由技术树 + 飞升蓝图 + 星图天赋) */ /** 计算探险力(由技术树 + 飞升蓝图 + 星图天赋) */
export function computeExpeditionPower(state: GameState): number { export function computeExpeditionPower(state: GameState): number {
let power = EXPEDITION_CONFIG.basePower; let power = EXPEDITION_CONFIG.basePower;
// 探险分支技术加成v0.9 扩充:exp_4 +12exp_5 +25 // 探险分支技术加成
const exp1 = state.tech?.exp_1 ?? 0; const exp1 = state.tech?.exp_1 ?? 0;
const exp2 = state.tech?.exp_2 ?? 0; const exp2 = state.tech?.exp_2 ?? 0;
const exp3 = state.tech?.exp_3 ?? 0; const exp3 = state.tech?.exp_3 ?? 0;
const exp4 = state.tech?.exp_4 ?? 0; power += exp1 * 3 + exp2 * 5 + exp3 * 8;
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; power *= 1 + (state.blueprints?.length ?? 0) * 0.08;
// 飞升周目加成 // 飞升周目加成
@@ -203,9 +200,6 @@ export function computeExpeditionPower(state: GameState): number {
export function computeExpeditionHp(state: GameState): number { export function computeExpeditionHp(state: GameState): number {
let hp = EXPEDITION_CONFIG.baseHp; let hp = EXPEDITION_CONFIG.baseHp;
hp += (state.tech?.exp_2 ?? 0) * 20; hp += (state.tech?.exp_2 ?? 0) * 20;
// v0.9 扩充:exp_4 +30exp_5 +50
hp += (state.tech?.exp_4 ?? 0) * 30;
hp += (state.tech?.exp_5 ?? 0) * 50;
hp += (state.ascensions ?? 0) * 10; hp += (state.ascensions ?? 0) * 10;
// 星图「维生护盾」加成 // 星图「维生护盾」加成
const cm = constellationBonuses(state.constellation ?? []); const cm = constellationBonuses(state.constellation ?? []);
@@ -372,16 +366,13 @@ export function advanceExpedition(expedition: Expedition): ExpeditionResult {
} }
/** + /** +
* v0.8.1exp_2 -30%exp_3 -20% -30% 12s * v0.8.1exp_2 -30%exp_3 -20% -30% 12s */
* v0.9 exp_4 25%×0.75exp_5 30%×0.70 8s */
export function computeEnergyRegenInterval(state: GameState): number { export function computeEnergyRegenInterval(state: GameState): number {
const base = EXPEDITION_CONFIG.energyRegenSec; const base = EXPEDITION_CONFIG.energyRegenSec;
let mult = 1; let mult = 1;
// 技术:遗迹图谱(exp_2)+ 维度信标(exp_3+ 虚空航标(exp_4)+ 维度跃迁引擎(exp_5 // 技术:遗迹图谱(exp_2)+ 维度信标(exp_3
if (state.tech?.exp_2) mult *= 0.7; if (state.tech?.exp_2) mult *= 0.7;
if (state.tech?.exp_3) mult *= 0.8; 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% // 角色属性:探索力(exploration)每点缩短少量,高探索力最高 -30%
const exploration = state.attributes?.exploration ?? 0; const exploration = state.attributes?.exploration ?? 0;
const explorationBonus = exploration >= 50 const explorationBonus = exploration >= 50
+33
View File
@@ -0,0 +1,33 @@
// 回响星核 / 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}`;
}
+286
View File
@@ -0,0 +1,286 @@
// 回响星核 / Echo Nexus — v0.14 放置系统(Idle Operations
// 6 个放置工程 + 采矿无人机舰队派生 + 永久产能加成
import type { GameState, IdleProjectDef, ResonanceColor } from "./types";
import { TECH_TREE } from "./config";
/** 放置工程槽位数量 */
export const IDLE_SLOT_COUNT = 3;
/** 6 个放置工程定义(按 order 排序) */
export const IDLE_PROJECTS: IdleProjectDef[] = [
{
id: "idle_scan",
name: "深空勘探扫描",
desc: "调度无人机对周边星域进行广域谐振扫描,回收散落的星图数据并整理为洞见。",
durationSec: 60,
reward: { insights: 8 },
minCrystalsPerSec: 0.5,
icon: "🛰️",
color: "emerald",
order: 1,
},
{
id: "idle_refine",
name: "晶体精炼阵列校准",
desc: "对采矿阵列的谐振频率进行精细校准,让每一束脉冲都更高效地析出晶体微粒。",
durationSec: 180,
reward: { crystalsPerSecPermanent: 0.3 },
minCrystalsPerSec: 1,
icon: "⚙️",
color: "rose",
order: 2,
},
{
id: "idle_archive",
name: "遗迹碎片整理",
desc: "派驻考古模块进入休眠舱整理从遗迹带回的残片,拼合出此前未触及的记忆碎片。",
durationSec: 600,
reward: { randomFragment: true, insights: 20 },
minAscensions: 1,
minCrystalsPerSec: 5,
icon: "📜",
color: "amber",
order: 3,
},
{
id: "idle_anchor",
name: "维度锚点部署",
desc: "在虚空中部署稳定锚点,强化本维度与以太层的连接,持续注入能量与接触进度。",
durationSec: 1200,
reward: { energy: 2, contact: 5 },
minAscensions: 1,
icon: "⚓",
color: "fuchsia",
order: 4,
},
{
id: "idle_drones",
name: "无人机群扩编",
desc: "组装并部署一支新的自治无人机群加入采矿舰队,永久提升晶体产能并附带补给晶体。",
durationSec: 1800,
reward: { crystalsPerSecPermanent: 2, crystals: 500 },
minCrystalsPerSec: 10,
icon: "🛸",
color: "emerald",
order: 5,
},
{
id: "idle_resonance",
name: "跨维度谐振标定",
desc: "联合多个维度锚点进行深层谐振标定,唤醒沉睡的跨维度产能,并大幅推进接触进度。",
durationSec: 3600,
reward: {
crystalsPerSecPermanent: 8,
contact: 15,
insights: 100,
},
minAscensions: 2,
icon: "🌌",
color: "fuchsia",
order: 6,
},
];
const PROJECT_MAP: Record<string, IdleProjectDef> = Object.fromEntries(
IDLE_PROJECTS.map((p) => [p.id, p])
);
/** 按 id 查找工程定义 */
export function getIdleProject(id: string): IdleProjectDef | undefined {
return PROJECT_MAP[id];
}
/** 判断工程是否已解锁(minAscensions 与 minCrystalsPerSec 为 OR 关系) */
export function isIdleProjectUnlocked(
project: IdleProjectDef,
state: Pick<GameState, "ascensions" | "crystalsPerSec">
): boolean {
const hasAscReq = project.minAscensions !== undefined;
const hasCpsReq = project.minCrystalsPerSec !== undefined;
// 两个条件都未设置 → 默认解锁
if (!hasAscReq && !hasCpsReq) return true;
const meetsAsc = hasAscReq && (state.ascensions ?? 0) >= (project.minAscensions ?? 0);
const meetsCps =
hasCpsReq && (state.crystalsPerSec ?? 0) >= (project.minCrystalsPerSec ?? 0);
// OR 逻辑:只要满足任一已设置的条件即可
return meetsAsc || meetsCps;
}
/** 获取当前已解锁的工程列表(按 order 排序) */
export function getUnlockedIdleProjects(
state: Pick<GameState, "ascensions" | "crystalsPerSec">
): IdleProjectDef[] {
return IDLE_PROJECTS.filter((p) => isIdleProjectUnlocked(p, state)).sort(
(a, b) => a.order - b.order
);
}
/** 获取当前未解锁的工程列表(按 order 排序) */
export function getLockedIdleProjects(
state: Pick<GameState, "ascensions" | "crystalsPerSec">
): IdleProjectDef[] {
return IDLE_PROJECTS.filter((p) => !isIdleProjectUnlocked(p, state)).sort(
(a, b) => a.order - b.order
);
}
/** 格式化剩余时间:12s / 3m 45s / 1h 12m */
export function formatRemaining(sec: number): string {
if (!isFinite(sec) || sec <= 0) return "0s";
const s = Math.ceil(sec);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rs = s % 60;
if (m < 60) return rs > 0 ? `${m}m ${rs}s` : `${m}m`;
const h = Math.floor(m / 60);
const rm = m % 60;
return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
}
/** 格式化工程持续时间(用于展示) */
export function formatDuration(sec: number): string {
return formatRemaining(sec);
}
/** 采矿无人机图标映射(min_1..min_5 */
const MINER_ICONS: Record<string, string> = {
min_1: "⛏️",
min_2: "🌋",
min_3: "🤖",
min_4: "💠",
min_5: "☀️",
};
/** 采矿节点 id(按 level 排序) */
const MINER_NODE_IDS = ["min_1", "min_2", "min_3", "min_4", "min_5"];
/** 派生采矿无人机舰队(来自 min_1..min_5 技术节点) */
export function deriveMinerFleet(state: Pick<GameState, "tech">): {
techId: string;
name: string;
icon: string;
level: number;
outputPerSec: number;
color: ResonanceColor;
status: "active" | "dormant";
}[] {
const tech = state.tech ?? {};
const fleet: {
techId: string;
name: string;
icon: string;
level: number;
outputPerSec: number;
color: ResonanceColor;
status: "active" | "dormant";
}[] = [];
for (const id of MINER_NODE_IDS) {
const level = tech[id] ?? 0;
if (level > 0) {
const node = TECH_TREE.find((n) => n.id === id);
if (!node) continue;
const outputPerSec =
node.effect.kind === "crystalsPerSec"
? node.effect.value * level
: 0;
fleet.push({
techId: id,
name: node.name,
icon: MINER_ICONS[id] ?? "🛰️",
level,
outputPerSec,
color: "emerald" as ResonanceColor,
status: "active",
});
}
}
// 若玩家未拥有任何采矿技术 → 显示一架休眠中的 min_1(输出 0)
if (fleet.length === 0) {
const node = TECH_TREE.find((n) => n.id === "min_1");
fleet.push({
techId: "min_1",
name: node?.name ?? "谐振钻头",
icon: MINER_ICONS["min_1"] ?? "⛏️",
level: 0,
outputPerSec: 0,
color: "emerald" as ResonanceColor,
status: "dormant",
});
}
return fleet;
}
/** 格式化奖励摘要(用于 UI 展示) */
export function formatReward(reward: IdleProjectDef["reward"]): string {
const parts: string[] = [];
if (reward.crystals) parts.push(`+${reward.crystals} 晶体`);
if (reward.insights) parts.push(`+${reward.insights} 洞见`);
if (reward.energy) parts.push(`+${reward.energy} 能量`);
if (reward.contact) parts.push(`+${reward.contact} 接触`);
if (reward.crystalsPerSecPermanent)
parts.push(`永久 +${reward.crystalsPerSecPermanent}/s`);
if (reward.randomFragment) parts.push("随机记忆碎片");
return parts.length > 0 ? parts.join(" · ") : "—";
}
/** 颜色 → Tailwind 类映射(4 色全息,无蓝/靛) */
export const IDLE_COLOR_CLASSES: Record<
IdleProjectDef["color"],
{
text: string;
border: string;
bg: string;
bgSoft: string;
glow: string;
ring: string;
dot: string;
}
> = {
emerald: {
text: "text-emerald-300",
border: "border-emerald-400/40",
bg: "bg-emerald-500/15",
bgSoft: "bg-emerald-500/10",
glow: "shadow-[0_0_14px_rgba(52,211,153,0.35)]",
ring: "ring-emerald-400/40",
dot: "bg-emerald-400",
},
rose: {
text: "text-rose-300",
border: "border-rose-400/40",
bg: "bg-rose-500/15",
bgSoft: "bg-rose-500/10",
glow: "shadow-[0_0_14px_rgba(251,113,133,0.35)]",
ring: "ring-rose-400/40",
dot: "bg-rose-400",
},
amber: {
text: "text-amber-300",
border: "border-amber-400/40",
bg: "bg-amber-500/15",
bgSoft: "bg-amber-500/10",
glow: "shadow-[0_0_14px_rgba(251,191,36,0.35)]",
ring: "ring-amber-400/40",
dot: "bg-amber-400",
},
fuchsia: {
text: "text-fuchsia-300",
border: "border-fuchsia-400/40",
bg: "bg-fuchsia-500/15",
bgSoft: "bg-fuchsia-500/10",
glow: "shadow-[0_0_14px_rgba(232,121,249,0.35)]",
ring: "ring-fuchsia-400/40",
dot: "bg-fuchsia-400",
},
};
/** 颜色 → HEX 值(用于 inline style,避免 Tailwind 动态类限制) */
export const COLOR_HEX: Record<IdleProjectDef["color"], string> = {
emerald: "#34d399",
rose: "#fb7185",
amber: "#fbbf24",
fuchsia: "#e879f9",
};
-82
View File
@@ -1,82 +0,0 @@
// 回响星核 / Echo Nexus — 叙事氛围文本池(v0.9 工单修复)
// 工单 Issue #2 反馈"很多东西都没靠着世界观去写,像是为了做游戏而做的一样"
// 本模块为各系统交互注入叙事碎片,让世界观渗透到每个角落
/** 脉冲扫描时随机触发的氛围低语(连击越高出现概率越大) */
export const PULSE_WHISPERS: string[] = [
"晶核深处传来一阵微弱的震颤…",
"以太族的余响在谐振中回荡。",
"你听见了一句无法辨认的低语。",
"星海的寂静被你的脉冲短暂打破。",
"晶体表面的纹路闪烁,像是某种回应。",
"回响核心记录着每一次触碰。",
"虚空深处,有什么在倾听。",
"谐振频率与你的脉冲产生了共鸣。",
"这是以太族留下的第一道印记。",
"晶体内部的光,比星辰更古老。",
"你的脉冲唤醒了一段沉睡的频率。",
"维度薄膜因你的触碰而微微颤动。",
"采矿不是掠夺,是聆听。",
"每一颗晶体,都是一封跨越时间的信。",
"以太族信任你,所以留下了这些。",
];
/** 解码成功时的叙事碎片(按解码次数递进解锁更深层的叙事) */
export const DECODE_ECHOES: string[] = [
"一段记忆在你的解码中苏醒…",
"谐振序列被还原,低语化为可读的符文。",
"你触碰到了以太族思维的一角。",
"解码完成的瞬间,你看见了他们的星海。",
"记忆晶体释放出封存了万年的画面。",
"这不仅是数据,是一段被编码的经历。",
"以太族的语言不是文字,是共振本身。",
"你读懂了这句话时,接触便已开始。",
"晶体褪去外壳,露出内核中的回响。",
"解码,是以太族留给后来者的资格证明。",
"你的思维与晶体中的残响短暂重合。",
"一段文明的碎片在你手中重组。",
];
/** 技术解锁时的叙事(每分支一段通用叙事) */
export const TECH_LORE: Record<string, string> = {
mining: "以太族从不掠夺星海,他们与矿脉共振,让晶体自愿献出光芒。",
decoding: "谐振法则是以太族的核心遗产——思维可被编码为共振序列。",
expedition: "他们留下的遗迹不是废墟,是故意设置的试炼场。",
narrative: "记忆校音,是以太族赋予后来者的第一项礼物。",
};
/** 探险节点事件额外的叙事前缀 */
export const EXPEDITION_NARRATIVE: Record<string, string> = {
combat: "以太族的守卫残影仍在执行万年前的指令。",
treasure: "这些宝藏是以太族特意留下的补给。",
choice: "分岔甬道是他们对后来者的考验。",
puzzle: "谐振锁的答案,藏在以太族的思维模式里。",
rest: "这处休憩节点,是以太族留给疲惫旅人的礼物。",
boss: "终焉之核是以太族离去前留下的最后一道试炼。",
};
/** 飞升时的叙事 */
export const PRESTIGE_ECHOES: string[] = [
"你的意识与以太族的残响交汇,维度薄膜被穿透…",
"飞升不是结束,是与更高处的他们相会。",
"你褪去了这一周目的形态,回响却永存。",
"以太族在更高处等你,已等候了万年。",
"接触完成。你成为了他们等待的回应。",
];
/** 随机取一条叙事文本 */
export function pickWhisper(pool: string[], seed?: number): string {
if (pool.length === 0) return "";
const idx = seed !== undefined
? Math.abs(Math.floor(seed)) % pool.length
: Math.floor(Math.random() * pool.length);
return pool[idx];
}
/** 按概率返回一条叙事(probability 0-1 */
export function maybeWhisper(pool: string[], probability: number): string | null {
if (Math.random() < probability) {
return pickWhisper(pool);
}
return null;
}
+99
View File
@@ -0,0 +1,99 @@
// 回响星核 / 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;
}
-260
View File
@@ -1,260 +0,0 @@
// 回响星核 / Echo Nexus — 文明遗迹图鉴(v0.10 新增)
// 深空考古世界观载体:8 大远古文明遗迹,每个承载独特故事碎片
// 通过游戏进度逐步解锁,增加"探索未知"的乐趣
import type { GameState } from "./types";
export interface RelicEntry {
id: string;
name: string;
/** 文明种族 */
race: string;
/** 距今时代 */
era: string;
/** 图标 emoji */
icon: string;
/** 主题色(emerald/rose/amber/fuchsia 四色全息) */
color: string;
/** 简短描述 */
brief: string;
/** 完整故事碎片(解锁后展示) */
story: string;
/** 解锁条件描述 */
unlockHint: string;
/** 解锁判定 */
unlock: (s: GameState) => boolean;
/** 解锁奖励 */
reward: {
crystals?: number;
insights?: number;
contact?: number;
};
rewardText: string;
}
export const RELIC_CODEX: RelicEntry[] = [
{
id: "relic_first_echo",
name: "初次回响",
race: "无名先驱",
era: "纪元前 · 未知",
icon: "✦",
color: "#34d399",
brief: "深空中第一缕被捕获的智慧信号。",
story:
"当自治无人机首次解码出这缕信号时,整个星核为之震颤。那不是噪声,而是某种古老文明留下的问候——跨越亿万年的回响,证明我们并不孤独。",
unlockHint: "发起第一次脉冲扫描",
unlock: (s) => s.totalDecoded >= 0 || s.crystals > 0,
reward: { crystals: 10 },
rewardText: "+10 晶体",
},
{
id: "relic_crystal_whisper",
name: "晶体低语",
race: "晶语族",
era: "第一纪元 · 晶石时代",
icon: "◈",
color: "#fb7185",
brief: "能够将记忆封存在晶体中的远古种族。",
story:
"晶语族相信,记忆是宇宙的本质。他们建造了巨大的晶体矩阵,将整个文明的历史封存在谐振频率中。当他们的恒星熄灭时,唯有这些晶体在虚空中持续低语。",
unlockHint: "累计解码 10 颗记忆晶体",
unlock: (s) => s.totalDecoded >= 10,
reward: { insights: 20, crystalsPerSecPct: 5 } as any,
rewardText: "+20 洞见 · 产能 +5%",
},
{
id: "relic_void_ark",
name: "虚空方舟",
race: "渡厄者",
era: "第二纪元 · 大迁徙",
icon: "❖",
color: "#fbbf24",
brief: "为逃离超新星爆发而建造的星际方舟。",
story:
"渡厄者的母星在一夜之间步入死亡。他们用三百年建造了十二艘方舟,每一艘都载着十亿灵魂的数字意识。如今,只有这一艘的残骸漂浮在我们的考古坐标上。",
unlockHint: "完成 5 次遗迹探险",
unlock: (s) => s.totalExpeditions >= 5,
reward: { insights: 30 },
rewardText: "+30 洞见",
},
{
id: "relic_silent_court",
name: "寂寂王朝",
race: "静默王族",
era: "第三纪元 · 铁律纪",
icon: "♛",
color: "#e879f9",
brief: "以沉默为律法的神秘王朝。",
story:
"静默王族统治了三百个恒星系,却从未留下任何文字。他们的律法刻在引力波中,他们的诗歌写在脉冲星里。我们只能通过残余的引力涟漪,窥见这个王朝的辉煌与寂灭。",
unlockHint: "击败 3 个探险 BOSS",
unlock: (s) => (s.bossKills ?? 0) >= 3,
reward: { contact: 10 },
rewardText: "+10 接触",
},
{
id: "relic_starforge",
name: "锻星炉",
race: "铸星者",
era: "第四纪元 · 恒星工程",
icon: "✺",
color: "#34d399",
brief: "能够人工点燃恒星的工程文明。",
story:
"铸星者不满足于利用恒星能量,他们要创造恒星。他们建造了锻星炉——一种能在虚空中凝聚物质的巨型结构。然而,每一次点火都会消耗一整个星系的物质,最终他们自己也成为了燃料。",
unlockHint: "解锁 6 项技术",
unlock: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 6,
reward: { insights: 50, crystals: 100 },
rewardText: "+50 洞见 · +100 晶体",
},
{
id: "relic_memory_weaver",
name: "忆织者",
race: "织忆族",
era: "第五纪元 · 意识网络",
icon: "✧",
color: "#fb7185",
brief: "将集体意识编织成网络的心灵文明。",
story:
"织忆族没有个体概念。他们的每一个『我』都是网络的一个节点,死亡只是断开连接。当整个网络陷入沉睡时,他们编织了最后一张记忆之网,等待后人唤醒。我们的解码,正是唤醒。",
unlockHint: "拼凑 4 块记忆碎片",
unlock: (s) => Object.values(s.fragments).filter(Boolean).length >= 4,
reward: { contact: 15 },
rewardText: "+15 接触",
},
{
id: "relic_eclipse_tribe",
name: "蚀相部落",
race: "蚀相族",
era: "第六纪元 · 阴影纪",
icon: "☀",
color: "#fbbf24",
brief: "栖身于食恒星阴影中的猎手文明。",
story:
"蚀相族敬畏阴影。他们在双星系统的食相瞬间狩猎,在光与暗的边界舞蹈。当一颗恒星被吞噬殆尽,他们便迁徙至下一对双星。他们的遗迹,永远隐藏在某个行星的阴影里。",
unlockHint: "亲历 3 种星潮事件",
unlock: (s) => (s.starTidesEncountered ?? []).length >= 3,
reward: { insights: 40 },
rewardText: "+40 洞见",
},
{
id: "relic_ascended",
name: "飞升者遗迹",
race: "前代飞升者",
era: "终末纪元 · 超越",
icon: "✦",
color: "#e879f9",
brief: "曾经的飞升者留下的最后讯息。",
story:
"在我们之前,已有无数文明触碰到飞升的临界点。他们留下了什么?不是技术,不是财富,而是一句跨越时空的低语:「飞升不是终点,而是新的开始。勇敢者,继续探索。」",
unlockHint: "完成第一次飞升",
unlock: (s) => s.ascensions >= 1,
reward: { insights: 100, contact: 25 },
rewardText: "+100 洞见 · +25 接触",
},
{
id: "relic_constellation",
name: "星图密钥",
race: "观星者议会",
era: "永恒 · 星图守护者",
icon: "✧",
color: "#34d399",
brief: "守护星图天赋秘密的远古议会。",
story:
"观星者议会不属于任何时代。他们存在于所有时代的星图中,守护着飞升的真正秘密:每一道星图天赋,都是前代飞升者留下的礼物。集齐所有天赋,便能窥见宇宙的真相。",
unlockHint: "觉醒 6 道星图天赋",
unlock: (s) => (s.constellation ?? []).length >= 6,
reward: { insights: 80 },
rewardText: "+80 洞见",
},
{
id: "relic_chronicle",
name: "编年史之心",
race: "史官族",
era: "全纪元 · 记录者",
icon: "❖",
color: "#fb7185",
brief: "记录所有纪元兴衰的永恒史官。",
story:
"史官族从不干涉,只记录。他们的身体是活体的编年史,每一个细胞都承载着一个纪元的记忆。当最后一个史官死去时,他将所有记忆注入了这颗编年史之心,等待后人继承。",
unlockHint: "积累 5 段编年史",
unlock: (s) => (s.chronicle ?? []).length >= 5,
reward: { insights: 60, contact: 20 },
rewardText: "+60 洞见 · +20 接触",
},
{
id: "relic_attribute",
name: "四维之证",
race: "原型人格",
era: "元始 · 自我觉醒",
icon: "◈",
color: "#fbbf24",
brief: "自我觉醒的四维人格原型。",
story:
"探索力、智慧、勇气、灵感——这四维属性并非凭空而来。它们源自一个远古的原型人格,那个第一次说出「我」的存在。我们的每一次成长,都是对那个原型的回响。",
unlockHint: "任一角色属性达到 30",
unlock: (s) => {
const a = s.attributes;
if (!a) return false;
return (
a.exploration >= 30 ||
a.wisdom >= 30 ||
a.courage >= 30 ||
a.inspiration >= 30
);
},
reward: { insights: 50 },
rewardText: "+50 洞见",
},
{
id: "relic_cruise",
name: "深空航迹",
race: "远航者联盟",
era: "第七纪元 · 星海远航",
icon: "➤",
color: "#e879f9",
brief: "穿越星海寻找新家园的远航者。",
story:
"远航者联盟由十二个种族组成,他们共享一艘巨型母舰,在星海中寻找新的家园。他们留下了航迹图——每一道光痕都是一段旅程,每一个航点都是一个希望。我们的巡航,正是延续他们的航迹。",
unlockHint: "深空巡航累计通关 5 关",
unlock: (s) => {
try {
const data = localStorage.getItem("echo-nexus-cruise-v1");
if (!data) return false;
const parsed = JSON.parse(data);
return (parsed.totalClears ?? 0) >= 5;
} catch {
return false;
}
},
reward: { insights: 70, crystals: 200 },
rewardText: "+70 洞见 · +200 晶体",
},
];
/** 获取已解锁的遗迹列表 */
export function getUnlockedRelics(s: GameState): RelicEntry[] {
return RELIC_CODEX.filter((r) => r.unlock(s));
}
/** 获取图鉴进度(0-100 */
export function getRelicProgress(s: GameState): number {
const unlocked = getUnlockedRelics(s).length;
return (unlocked / RELIC_CODEX.length) * 100;
}
/** 获取已解锁遗迹的奖励总和 */
export function getRelicRewards(s: GameState) {
const unlocked = getUnlockedRelics(s);
let crystals = 0;
let insights = 0;
let contact = 0;
let crystalsPerSecPct = 0;
for (const r of unlocked) {
if (r.reward.crystals) crystals += r.reward.crystals;
if (r.reward.insights) insights += r.reward.insights;
if (r.reward.contact) contact += r.reward.contact;
if ((r.reward as any).crystalsPerSecPct) crystalsPerSecPct += (r.reward as any).crystalsPerSecPct;
}
return { crystals, insights, contact, crystalsPerSecPct };
}
+3 -90
View File
@@ -1,7 +1,7 @@
// 回响星核 / Echo Nexus — 星潮事件系统(周期性全局玩法修饰) // 回响星核 / Echo Nexus — 星潮事件系统(周期性全局玩法修饰)
// //
// 深空中偶发的「星潮」会短暂改变物理法则,给放置循环注入动态变化。 // 深空中偶发的「星潮」会短暂改变物理法则,给放置循环注入动态变化。
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,9 种类型各有正负效果。 // 每 ~40s 间隙后触发一次持续 ~75s 的星潮,6 种类型各有正负效果。
export type TideType = export type TideType =
| "crystal" // 晶体潮:产能 ×2 | "crystal" // 晶体潮:产能 ×2
@@ -9,11 +9,7 @@ export type TideType =
| "ruins" // 遗迹共振:探险力 +5、生命 +30 | "ruins" // 遗迹共振:探险力 +5、生命 +30
| "void" // 虚空低语:洞见 ×2 | "void" // 虚空低语:洞见 ×2
| "core" // 星核悸动:接触率 ×3、脉冲 ×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 { export interface StarTide {
type: TideType; type: TideType;
@@ -33,12 +29,6 @@ export interface TideModifiers {
expeditionHpBonus: number; expeditionHpBonus: number;
/** 寂静期结束时补偿的洞见(按持续秒数) */ /** 寂静期结束时补偿的洞见(按持续秒数) */
silenceInsightPerSec?: 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 = { export const TIDE_CONFIG = {
@@ -110,34 +100,9 @@ export const TIDE_EVENTS: Record<
icon: "◌", icon: "◌",
negative: true, 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> = { const TIDE_WEIGHTS: Record<TideType, number> = {
crystal: 22, crystal: 22,
resonance: 20, resonance: 20,
@@ -145,9 +110,6 @@ const TIDE_WEIGHTS: Record<TideType, number> = {
void: 18, void: 18,
core: 14, core: 14,
silence: 10, silence: 10,
surge: 12,
eclipse: 12,
prism: 12,
}; };
export function rollTide(rng: () => number = Math.random): TideType { export function rollTide(rng: () => number = Math.random): TideType {
@@ -171,9 +133,6 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
pulsePowerMult: 1, pulsePowerMult: 1,
expeditionPowerBonus: 0, expeditionPowerBonus: 0,
expeditionHpBonus: 0, expeditionHpBonus: 0,
targetLenBonus: 0,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 1,
}; };
} }
switch (tide.type) { switch (tide.type) {
@@ -237,52 +196,6 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
expeditionPowerBonus: 0, expeditionPowerBonus: 0,
expeditionHpBonus: 0, expeditionHpBonus: 0,
silenceInsightPerSec: 0.4, silenceInsightPerSec: 0.4,
targetLenBonus: 0,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 1,
};
// ===== v0.8.2 新增三种星潮修饰器 =====
case "surge":
// 晶体产出 ×2.5,解码目标 +2(更长谜题 = 更耗时 = 更难)
return {
crystalsPerSecMult: 2.5,
decodeRewardMult: 1,
insightMultAdd: 0,
contactRateMult: 1,
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 2,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 1,
};
case "eclipse":
// BOSS 胜率 +20%,晶体产出 -30%
return {
crystalsPerSecMult: 0.7,
decodeRewardMult: 1,
insightMultAdd: 0,
contactRateMult: 1,
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 0,
bossWinRateBonus: 0.2,
autoDecodeIntervalMult: 1,
};
case "prism":
// 洞见 ×2(+100%),自动解码周期 ×0.7-30%)
return {
crystalsPerSecMult: 1,
decodeRewardMult: 1,
insightMultAdd: 1,
contactRateMult: 1,
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 0,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 0.7,
}; };
} }
} }
+10 -3
View File
@@ -37,11 +37,18 @@ export const TUTORIAL_STEPS: TutorialStep[] = [
body: "记忆晶体存满后,点击右侧「待解码晶体」开启谐振谜题:按目标色序依次点击相邻同色节点重建回路。\n\n解码成功 → 获得技术洞见 + 记忆碎片(叙事片段)。这是游戏的核心解谜层。", body: "记忆晶体存满后,点击右侧「待解码晶体」开启谐振谜题:按目标色序依次点击相邻同色节点重建回路。\n\n解码成功 → 获得技术洞见 + 记忆碎片(叙事片段)。这是游戏的核心解谜层。",
expectAction: "decode-start", expectAction: "decode-start",
}, },
{
id: "idle",
target: "tab-idle",
placement: "top",
title: "③ 放置工程 · 离线产出",
body: "「放置」标签可派遣工程项目(勘探/精炼/扩编无人机群等),完成后领取奖励——部分工程永久提升产能。\n\n顶部「放置中 +X/s」徽章实时显示离线产能,晶体球下方进度条追踪活跃工程。",
},
{ {
id: "tech", id: "tech",
target: "tab-tech", target: "tab-tech",
placement: "top", placement: "top",
title: " 技术树 · 成长", title: " 技术树 · 成长",
body: "用「技术洞见」在「技术」标签升级 4 大分支:产能 / 解码 / 仓库 / 探险。\n\n每个分支 3 级,策略性地分配洞见是关键。", body: "用「技术洞见」在「技术」标签升级 4 大分支:产能 / 解码 / 仓库 / 探险。\n\n每个分支 3 级,策略性地分配洞见是关键。",
expectAction: "tech-buy", expectAction: "tech-buy",
}, },
@@ -49,7 +56,7 @@ export const TUTORIAL_STEPS: TutorialStep[] = [
id: "expedition", id: "expedition",
target: "tab-expedition", target: "tab-expedition",
placement: "top", placement: "top",
title: " 遗迹探险 · 肉鸽", title: " 遗迹探险 · 肉鸽",
body: "累积足够实力后,「探险」标签可深入遗迹:6 种节点(战斗/宝藏/抉择/解谜/休整/BOSS)程序化路径,有生命系统,失败保留奖励。\n\n这是放置之外的主动玩法层。", body: "累积足够实力后,「探险」标签可深入遗迹:6 种节点(战斗/宝藏/抉择/解谜/休整/BOSS)程序化路径,有生命系统,失败保留奖励。\n\n这是放置之外的主动玩法层。",
expectAction: "expedition-start", expectAction: "expedition-start",
}, },
@@ -57,7 +64,7 @@ export const TUTORIAL_STEPS: TutorialStep[] = [
id: "prestige", id: "prestige",
target: "prestige-btn", target: "prestige-btn",
placement: "bottom", placement: "bottom",
title: " 飞升 · 多周目", title: " 飞升 · 多周目",
body: "「接触进度」满 100% 后可飞升:重置进度,获得永久蓝图加成 + 星图天赋(3 选 1 draft)+ 编年史记录。\n\n多周目叠加,越飞越强。还有每日信标挑战、6 种星潮事件等你探索。", body: "「接触进度」满 100% 后可飞升:重置进度,获得永久蓝图加成 + 星图天赋(3 选 1 draft)+ 编年史记录。\n\n多周目叠加,越飞越强。还有每日信标挑战、6 种星潮事件等你探索。",
}, },
{ {
+81 -12
View File
@@ -45,15 +45,46 @@ export interface TechEffect {
value: number; 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 { export interface Fragment {
id: string; id: string;
era: number; // 1-5 纪元 era: number; // 1-5 纪元
title: string; title: string;
/** 拼出后的回响文本 */ /** L0 表层文本 — 拼出后的回响(默认解锁,现有字段) */
echo: string; echo: string;
/** 解锁所需晶体解码次数门槛 */ /** 解锁所需晶体解码次数门槛 */
threshold: number; threshold: number;
/** v0.15 多层叙事文本(L1/L2 深层,可选) */
loreLayers?: FragmentLoreLayer[];
} }
/** 飞升(Prestige)加成 */ /** 飞升(Prestige)加成 */
@@ -86,6 +117,36 @@ export interface DecodePuzzle {
seed: number; 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 { export interface GameState {
// 资源 // 资源
@@ -125,13 +186,6 @@ export interface GameState {
lastEnergyTick: number; lastEnergyTick: number;
totalExpeditions: number; totalExpeditions: number;
bossKills: number; // 累计击败 BOSS 数(跨周目) 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 achievements: Record<string, boolean>; // achievementId -> unlocked
@@ -154,6 +208,25 @@ export interface GameState {
attributeProgress: import("./attributes").AttributeProgress; attributeProgress: import("./attributes").AttributeProgress;
pendingAttrPoints: number; 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; lastTick: number;
createdAt: number; createdAt: number;
@@ -181,10 +254,6 @@ export interface ChronicleEntry {
blueprintsAfter: number; blueprintsAfter: number;
crystalsDecoded: number; crystalsDecoded: number;
crystalsDecodedThisRun: number; crystalsDecodedThisRun: number;
/** v0.9 手写叙事:本周目 delta(旧存档可能缺失,调用方需兜底) */
techsThisRun?: number;
expeditionsThisRun?: number;
bossKillsThisRun?: number;
}; };
/** 本次飞升获得的天赋 ID(来自星图觉醒) */ /** 本次飞升获得的天赋 ID(来自星图觉醒) */
perksThisAscension: string[]; perksThisAscension: string[];
+424 -266
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+951 -413
View File
File diff suppressed because it is too large Load Diff