Compare commits

..
12 Commits
Author SHA1 Message Date
Super_Z cfc87ca3a1 v0.12: 版本号更新+worklog记录 2026-06-24 03:14:16 +00:00
Super_Z ff54abbf70 fix: 存档key名保持 echo-nexus-save-v1, 靠 version:2 触发 migrate
改 key 名会导致旧用户存档丢失。保持 key 名不变, version 0→2,
persist 自动检测 version 变化调用 migrate 合并字段。
2026-06-24 03:08:49 +00:00
Super_Z 4831505ef9 v0.12: 存档迁移钩子 + 考古日志滚动条(差异化叙事)
gameStore persist:
- name v1→v2, 加 version:2 + migrate 钩子
- migrate 合并旧存档与 INITIAL_STATE, 补全 bossKills/starTidesEncountered/
  chronicle/attributes/activeExpedition/constellation 等字段, 防旧存档崩溃
- 丢弃不可解的活跃谜题(旧算法生成)

archaeoLog.ts (新增):
- 5 阶段日志池 dawn/awakening/contact/ascension/deep (48 条)
- 按飞升次数+接触进度解锁阶段, deep 层含哲学反转
- pickLogEntry 避免短期重复(最近8条)

ArchaeoLogTicker.tsx (新增):
- marquee 横向滚动, 18-30s 随机推送新条目
- 阶段色标 emerald/amber/fuchsia/rose, inset glow
- 进度里程碑触发即时刷新(解锁新阶段)
- prefers-reduced-motion 降级

page.tsx:
- footer 上方集成 ArchaeoLogTicker

globals.css:
- echo-ticker-marquee/fade keyframes + reduced-motion 降级

调研落地: Antimatter Dimensions news ticker + Universal Paperclips 哲学递进
lint 零错误, 构建成功(5.7s)
2026-06-24 03:04:04 +00:00
Super_Z 3fdcd6797f restore: worklog 调研记录 + 清理工作区 2026-06-24 02:53:45 +00:00
Super_Z 43c203ebd2 v0.11: 解码晶体卡片 hover 增强 + StatsPanel selector 性能优化
DecodeArray CrystalCard:
- hover 流光边框动画 (echo-border-flow)
- hover 扫描线动画 (echo-scan)
- T标签/tierLabel 上移至顶部 z-10, 不被底部覆盖层遮挡
- hover 底部显示晶体数值 (+6/+22/+90) + 解码提示
- 晶体图形 hover 旋转 200deg + scale 110%

StatsPanel (page.tsx):
- 全量订阅 useGameStore() → shallow 聚合 selector
- 避免每 tick(250ms) 无关字段变化触发重渲染

globals.css:
- 新增 echo-scan / echo-border-flow keyframes

QA: VLM hover 效果 9/10, lint 零错误
2026-06-24 02:08:13 +00:00
Super_Z 803857298f 051da664-7c0a-4d7b-8129-36efb67f35b3 2026-06-24 01:38:08 +00:00
Super_Z 23fb451a0b 3cf83ed6-dca1-44be-bf7a-a27d19337739 2026-06-24 01:05:43 +00:00
Super_Z 7cfbc8aba0 cd570998-b36d-47ee-b022-3c0060d821d9 2026-06-24 00:43:26 +00:00
Super_Z 44b94f9325 v0.9 工单修复: 4 bug + 4 玩法改进
Gitea Issue #2 修复:
Bug:
1. 探险只能一次 → 能量恢复 45→20s, 初始能量 3→5, 上限 5→8
   新增 lastExpeditionSummary 显示上次结果
   新增 dismissExpedition action
2. 元素偏移/遮挡 → 标签页 grid-cols-8 → 响应式 grid-cols-4/sm:8
3. 成就UI遮挡 → max-h 440→520, min-h 提高
4. 提示框遮挡 → toast top-0→top-14 避开 header, TOAST_LIMIT 1→3

玩法改进:
1. 中央晶体球 → 下方新增 4 指标收益面板(产能/脉冲/连击/仓库)
2. 技术树 12→20 节点 (每分支 3→5 级)
3. 成就解锁 → 全屏居中特效通知 (framer-motion 弹窗)
4. 叙事融入 → 新建 narrative.ts, 脉冲/技术树注入世界观文本
2026-06-24 00:41:16 +00:00
Super_Z eb9836b8af docs: worklog 更新 v0.8.2 工单清零记录 2026-06-23 23:58:36 +00:00
Super_Z 191434e6ad v0.8.2: 工单大清理 - 编年史分页+限时挑战+星潮深化+云排行榜+手写叙事+socket多人星潮+UI打磨
工单 #8 编年史上限+分页:
- engine.ts: slice(-50)→slice(-200)
- ChronicleDialog.tsx: 加分页(每页10条)+上一页/下一页+页码显示

工单 #5 限时挑战 (subagent 10-a):
- beacon.ts: BeaconTimedChallenge + getTimedSlotKey(4h时段) + generateTimedChallenge
- BeaconPanel.tsx: amber主题限时区块 + 倒计时 + <30min紧急状态
- gameStore: trackBeacon 加 timedJustCompleted + claimTimedBeacon action

工单 #3 星潮类型深化 (subagent 10-a):
- starTide.ts: +3种星潮 surge(emerald)/eclipse(rose)/prism(fuchsia)
- TideModifiers: +targetLenBonus/bossWinRateBonus/autoDecodeIntervalMult
- decode.ts: generatePuzzle 加 targetLenBonus 参数
- achievements: ach_tides_all 阈值 6→9

工单 #4/P2 云排行榜 (subagent 10-b):
- mini-services/leaderboard-service/ (端口3030, Hono+bun, 内存1000条)
- API: GET/POST /api/leaderboard + /stats + CORS + 防刷
- beacon.ts: fetchCloudLeaderboard/submitCloudScore
- BeaconPanel: 本地Top20/全球Top100 双tab + YOU徽章高亮

工单 #9 手写叙事 (subagent 10-c):
- chronicle.ts: EPOCH_LORE 5纪元×3节点=15段手写叙事(80-150字/段)
- buildLore 优先手写节点, fallback 模板, 11个变量替换

P3 socket 多人星潮 (subagent 10-c):
- mini-services/star-tide-service/ (端口3031, socket.io)
- 每10-15min广播global-tide, 60s持续, 6种类型权重
- useGlobalTide hook + triggerGlobalTide action
- StarTideIndicator 加 🌐 全球星潮标记

P2 UI打磨:
- page.tsx: CrystalOrb 区加装饰全息环(3层旋转) + 四角标记 + 顶部状态条 + 底部铭文

QA: lint零错误 + dev HTTP200 + VLM 8/10 + 2个mini-service运行中(3030/3031)
2026-06-23 23:57:41 +00:00
Super_Z 6d7c52daa9 e106dfce-74ea-448e-99c5-22dc350e2137 2026-06-23 23:13:17 +00:00
85 changed files with 5325 additions and 5001 deletions
+1 -1
View File
@@ -1 +1 @@
1061
12512
+25 -92
View File
@@ -1,11 +1,9 @@
# 回响星核 / Echo Nexus
> 原创深空考古 **放置策略**游戏 · Original deep-space archaeology **idle strategy** game
> 原创深空考古放置策略游戏 · Original deep-space archaeology idle strategy game
你是一名深空考古指挥官,驾驶自治勘探舰回收已「飞升」的以太文明遗留的记忆晶体,解码碎片拼凑出他们的故事,并最终跨越维度发起「接触」。
**关掉浏览器,星核仍在为你工作**——采矿无人机舰队 24h 不间断采矿,放置工程在离线时持续推进,回来后一键领取积累的奖励。
---
## 🎮 在线游玩
@@ -18,7 +16,7 @@
## 项目状态
- **版本****v0.16** — 解码阈值叙事事件(全屏碎片释放叠层)+ loreLayer 已读标记 + idleHistory 详情展开 + 移动端 375px 适配审计
- **版本****v0.7** — CrystalOrb Canvas 粒子系统 + 四维角色属性
- **栈**Next.js 16 (App Router) · TypeScript · Tailwind CSS 4 · shadcn/ui · Canvas 2D · Zustand · Web Audio API
- **仓库**[https://git.atdunbg.xyz/Super_Z/echo-nexus](https://git.atdunbg.xyz/Super_Z/echo-nexus)
- **在线版**[https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/](https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/)
@@ -28,60 +26,29 @@
## 核心特色
- **📡 采矿无人机舰队**:每解锁一个采矿分支技术,就有一台自动采矿无人机加入舰队,24h 持续产出晶体(无需点击
- **⚙️ 放置工程项目**:6 种工程,派遣后**离线/在线自动推进**,完成领取奖励(晶体/洞见/能量/永久产能加成/随机碎片)
- **🔓 离线收益**:关闭浏览器后产能按 `offlineEff × crystalsPerSec` 持续累积(上限 8h),重新打开弹窗结算「沉睡期间」收益
- **🎯 始终可见的放置徽章**:Header 永远显示「放置中 +X/s」徽章,悬停查看产能构成明细——一眼就知道这是个 idle game
- **🔍 解码共振谜题**:把放置游戏的「等待」变成有节奏的主动小谜题(路径构造法生成器,100% 可解)
- **📜 碎片涌现叙事**:故事从解码中自然浮现,5 纪元循环命名 + 模板化 lore,不打断玩法
- **💎 深空全息美学**:晶体 + Canvas 粒子 + 共振波纹的差异化视觉(严格 emerald/rose/amber/fuchsia 四色全息色谱,零蓝色/靛色)
- **🔄 飞升多周目**:终局「接触」后保留蓝图开启新周目,星图天文台 18 个天赋跨周目永久生效
- **🚀 实时巡航玩法**Canvas 2D 飞船操控 + 8 种实体 + 3 层视差星空,打破纯放置节奏
- **🧬 角色 RPG 深度**:四维属性(探索力/智慧/勇气/灵感)影响所有游戏系统
- **解码共振谜题**:把放置游戏的「等待」变成有节奏的主动小谜题(路径构造法生成器,100% 可解
- **碎片涌现叙事**:故事从解码中自然浮现,5 纪元循环命名 + 模板化 lore,不打断玩法
- **深空全息美学**:晶体 + Canvas 粒子 + 共振波纹的差异化视觉(严格 emerald/rose/amber/fuchsia 四色全息色谱,零蓝色/靛色)
- **飞升多周目**:终局「接触」后保留蓝图开启新周目,星图天文台 18 个天赋跨周目永久生效
- **实时巡航玩法**Canvas 2D 飞船操控 + 8 种实体 + 3 层视差星空,打破纯放置节奏
- **角色 RPG 深度**:四维属性(探索力/智慧/勇气/灵感)影响所有游戏系统
---
## 游戏系统(11 大系统)
## 游戏系统(10 大系统)
| # | 系统 | 简介 | 类型 |
|---|------|------|------|
| 1 | **📡 放置采矿** | 中央晶体球 Canvas 粒子系统 + 主动脉冲 + 连击加成 + **自动采矿无人机舰队** | 放置 |
| 2 | **⚙️ 放置工程** | 6 种工程 × 3 槽位,**离线/在线自动推进**,完成领取奖励 | **放置** |
| 3 | **🔓 离线收益** | 关闭浏览器后产能按 `eff × cps` 累积(8h 上限),重新打开结算 | **放置** |
| 4 | 谐振解码 | 原创路径谜题,100% 可解率 | 主动 |
| 5 | 技术树 | 三层科技解锁,提升产能/解码/探险 | 主动 |
| 6 | 遗迹探险 | 肉鸽系统,6 种节点 + 程序化路径 + BOSS | 主动 |
| 7 | 星潮事件 | 6 种动态事件,全屏叠层 + 程序化音效 | 被动 |
| 8 | 星图天文台 | 飞升后 3 选 1 天赋 draft,18 个跨周目天赋 | 元进程 |
| 9 | 回响编年史 | 跨周目叙事时间轴,5 纪元循环命名 | 元进程 |
| 10 | 深空信标 | 每日挑战 + 本地排行榜 Top20 | 每日 |
| 11 | 深空巡航 | Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关 | 实时 |
| 12 | 角色属性 | 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点 | 元进程 |
---
## 放置系统设计哲学(v0.14 + v0.15 平衡)
回响星核的「放置」不是简单地把数字挂机增长,而是分层呈现 **3 种 idle 节奏**
### 节奏 1:被动产能(最慢,最持续)
- 采矿无人机舰队 = 你已解锁的采矿技术节点
- 每台无人机 24h 持续产出晶体,无需任何操作
- 晶体进入仓库(受 `crystalCap` 限制),仓库满后停止产出(防溢出)
- **永远在线,离线也累积**
### 节奏 2:放置工程(中等周期,需要决策)
- 6 种工程,时长 1min ~ 1h 不等
- 玩家选择派遣到 3 个槽位之一 → 启动后**离线/在线自动推进**
- 完成后必须**手动领取**(防玩家错过奖励时机)
- 奖励类型多样:晶体 / 洞见 / 能量 / 永久产能加成 / 随机碎片
- **核心 idle loop:派遣 → 离开 → 回来 → 领取 → 再派遣**
### 节奏 3:离线收益(最长周期,回归奖励)
- 关闭浏览器后,被动产能按 `offlineEff`(默认 50%,可通过技术提升至 100%)继续累积
- 上限 8 小时(防极端挂机)
- 重新打开页面弹窗结算:「你离开了 X 小时,星核为你产出 Y 颗晶体」
- **回归玩家的"啊我回来了"瞬间**
| # | 系统 | 简介 |
|---|------|------|
| 1 | 放置采矿 | 中央晶体球 Canvas 粒子系统 + 主动脉冲 + 连击加成 |
| 2 | 谐振解码 | 原创路径谜题,100% 可解率 |
| 3 | 技术树 | 三层科技解锁,提升产能/解码/探险 |
| 4 | 遗迹探险 | 肉鸽系统,6 种节点 + 程序化路径 + BOSS |
| 5 | 星潮事件 | 6 种动态事件,全屏叠层 + 程序化音效 |
| 6 | 星图天文台 | 飞升后 3 选 1 天赋 draft,18 个跨周目天赋 |
| 7 | 回响编年史 | 跨周目叙事时间轴,5 纪元循环命名 |
| 8 | 深空信标 | 每日挑战 + 本地排行榜 Top20 |
| 9 | 深空巡航 | Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关 |
| 10 | 角色属性 | 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点 |
---
@@ -100,13 +67,9 @@
| v0.5.1 | Gitea Pages 静态部署 + 新手教程 | ✅ |
| v0.5.2 | 离线收益报告 + 标签页 UI 重设计 | ✅ |
| v0.6 | 深空巡航 Canvas 2D 实时玩法 | ✅ |
| v0.7 | CrystalOrb Canvas 粒子系统 + 四维角色属性 | ✅ |
| v0.8 | 巡航玩法增强 / BOSS 关卡 / 道具掉落 | |
| v0.8.1 | 探险平衡 + 信标系统扩展(周挑战 + 信标链) | |
| **v0.14** | **放置系统:采矿无人机舰队 + 6 种放置工程 + 永久产能加成 + 始终可见 idle 徽章** | ✅ |
| **v0.15** | **叙事深化(8 碎片 × 2 层 = 16 层 loreLayers+ 永久加成 softcap + 批量领取 + 完成 toast + StatsPanel idle 统计** | ✅ |
| **v0.16** | **解码阈值叙事事件(5 里程碑全屏叠层)+ loreLayer 已读标记 + idleHistory 详情展开 + 移动端 375px 适配审计** | ✅ |
| v0.17 | 星潮三频段切换 + 健康设计(每日软上限 + 星核建议休眠) | 🔜 |
| **v0.7** | **CrystalOrb Canvas 粒子系统 + 四维角色属性** | ✅ |
| v0.8 | 巡航玩法增强 / BOSS 关卡 / 道具掉落 | 🔜 |
| v0.9 | 云存档 + 云排行榜 | 🔜 |
| v1.0 | 全 5 纪元手写叙事 + 多人同步星潮 | 🔜 |
---
@@ -123,9 +86,6 @@
- [`docs/08-星图天文台系统-v0.3.1.md`](docs/08-星图天文台系统-v0.3.1.md) — v0.3.1 天文台
- [`docs/09-回响编年史系统-v0.4.md`](docs/09-回响编年史系统-v0.4.md) — v0.4 编年史
- [`docs/10-深空信标系统-v0.5.md`](docs/10-深空信标系统-v0.5.md) — v0.5 信标
- [`docs/11-放置系统-v0.14.md`](docs/11-放置系统-v0.14.md) — **v0.14 放置系统(idle operations**
- [`docs/12-叙事深化-v0.15.md`](docs/12-叙事深化-v0.15.md) — **v0.15 loreLayers 多层碎片文本 + softcap + 批量领取**
- [`docs/13-叙事事件-v0.16.md`](docs/13-叙事事件-v0.16.md) — **v0.16 解码阈值里程碑叠层 + loreLayer 已读 + 移动端适配**
---
@@ -158,7 +118,7 @@ BUILD_EXPORT=true bun run build:static
cd out
git init && git checkout -b gh-pages
git add -A
git commit -m "deploy: v0.16"
git commit -m "deploy: v0.7"
git remote add origin https://git.atdunbg.xyz/Super_Z/echo-nexus.git
git push -f origin gh-pages
```
@@ -167,31 +127,4 @@ Gitea Pages 会自动从 `gh-pages` 分支提供静态网站服务。
---
## 灵感来源(Inspired by
- **Antimatter Dimensions** — 数值膨胀美学 + 终端 UI
- **Universal Paperclips** — 叙事驱动的放置游戏(Frank Lantz
- **A Dark Room** — 极简文字放置游戏(Michael Townsend
- **Melvor Idle** — 离线收益 + 多技能树
- **Outer Wilds** — 知识解锁式考古
- **Cookie Clicker** — Orteil 的放置游戏范式定义
---
## 健康游戏设计声明
放置游戏天然具有"低投入高回报"的成瘾性,可能对学业/工作压力大的青年造成时间碎片化风险。回响星核主动采取以下健康设计:
-**离线收益上限 8h**——防止 24h 挂机
-**工程完成需手动领取**——避免"打开就有奖励"的条件反射
-**无广告、无内购**——纯粹的单机体验
- 🔜 **v0.17 计划**:每日软上限 + "星核建议休眠"提醒(每 1 小时主动提示休息)
参考学术研究:
- Frank Lantz GDC 2018 "This Is Your Brain on Games"
- Reuters 2025 "Cozy video games can quell stress and anxiety"
- DOI:10.3389/fpsyg.2026.1774030 TikTok 时代学术倦怠研究
---
© Super_Z · 持续开发中
+91
View File
@@ -0,0 +1,91 @@
# Task ID: 10-a — 限时挑战 + 星潮类型深化
**Agent**: full-stack-developer
**Task**: 工单 #5 限时挑战 + 工单 #3 星潮类型深化
## Work Log
### 前置准备
- 阅读 `worklog.md` 了解 v0.8.1 项目状态(10 大系统,四色全息 emerald/rose/amber/fuchsia
- 精读 `beacon.ts`(873 行,日挑战+周挑战+信标链)、`BeaconPanel.tsx`753 行)、`starTide.ts`209 行,6 种星潮)、`gameStore.ts` trackBeacon~170 行)+ tickTide~440 行)
- 确认现有 UI 均动态读取 `TIDE_EVENTS[type]`,新增类型自动生效
### 工单 #5:限时挑战(Timed Challenge
**beacon.ts 扩展**873 → 1162 行,+289 行):
- `BeaconScoreEntry` 新增 `isTimed?: boolean` 字段
- `BeaconTimedChallenge` 接口:slotKey / type / difficulty(固定 routine) / goal / rewardInsight / rewardContact / seed / title / desc / expiresAt
- `BeaconTimedProgress` 接口:slotKey / progress / startedAt / completedAt / claimed / durationSec
- `TIMED_SLOT_MS = 4h``BEACON_TIMED_KEY = "echo-nexus-beacon-timed-v1"`
- `getTimedSlotKey(now)`UTC 0/4/8/12/16/20 点切分,格式 "timed_YYYY-MM-DD_HH"
- `timedSlotKeyToSeed`FNV-1a 哈希
- `generateTimedChallenge(now)`:确定性生成,难度 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
@@ -0,0 +1,207 @@
# Task 10-b · full-stack-developer · P2/#4 云排行榜 mini-service
> 本文件为本 agent 在 Task 10-b 的工作记录,供后续 agent 查阅。
## 任务概述
为「回响星核 / Echo Nexus」v0.8.2 实现 P2/#4 工单:把信标本机排行榜(纯 localStorage)升级为云端 Top100,让玩家间可以比较分数。
## 阅读的前置工作
- `/home/z/my-project/worklog.md`(v0.8.1 项目状态,10 大系统,四色全息规范)
- `/home/z/my-project/src/lib/game/beacon.ts`(实际 1161 行 — worklog 信息滞后,v0.8.2 已加限时挑战 `claimTimedReward`
- `/home/z/my-project/src/components/game/BeaconPanel.tsx`(实际 953 行 — 已含限时挑战 amber 主题)
- `/home/z/my-project/src/store/gameStore.ts`1241 行 — trackBeacon + 3 个 claim 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.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

-89
View File
@@ -1,89 +0,0 @@
---
Task ID: 2-b
Agent: full-stack-developer
Task: v0.16 完善 — loreLayer 已读标记 + idleProjectHistory 详情展开 + 移动端 375px 适配审计
Work Log:
- 必读上下文:worklog v0.15-release 段 + Codex.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.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

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