v0.15: 叙事深化 + 放置平衡 + UX 增强

- loreLayers 多层碎片文本(8 碎片 × 2 层 = 16 层深层叙事,5 条暗线交织)
- 永久加成 softcap(≤30 全额 / 30-100 ×0.5 / >100 ×0.25,防滚雪球)
- 批量领取全部工程 + 工程完成 toast 通知
- StatsPanel 独立组件 + idle 统计行(永久加成/完成工程/产出晶体/历史)
- Codex 分层渲染(L0 emerald 表层 / L1 amber 隐秘注脚 / L2 fuchsia 深层回响)
- 修复 v0.14 双重计算 bug(idleTotalPerSec)
- 严格 4 色全息,lint 零错误,agent-browser 端到端验证通过
This commit is contained in:
2026-06-24 12:32:56 +00:00
parent 20dc1f252f
commit 96748d726f
14 changed files with 1168 additions and 2338 deletions
+5 -4
View File
@@ -18,7 +18,7 @@
## 项目状态
- **版本****v0.14** — 放置系统(Idle Operations):采矿无人机舰队 + 6 种放置工程 + 离线推进 + 永久产能加成
- **版本****v0.15** — 叙事深化(loreLayers 多层碎片文本)+ 放置平衡(永久加成 softcap)+ 批量领取 + 完成 toast
- **栈**Next.js 16 (App Router) · TypeScript · Tailwind CSS 4 · shadcn/ui · Canvas 2D · Zustand · Web Audio API
- **仓库**[https://git.atdunbg.xyz/Super_Z/echo-nexus](https://git.atdunbg.xyz/Super_Z/echo-nexus)
- **在线版**[https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/](https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/)
@@ -60,7 +60,7 @@
---
## 放置系统设计哲学(v0.14
## 放置系统设计哲学(v0.14 + v0.15 平衡
回响星核的「放置」不是简单地把数字挂机增长,而是分层呈现 **3 种 idle 节奏**
@@ -104,7 +104,7 @@
| v0.8 | 巡航玩法增强 / BOSS 关卡 / 道具掉落 | ✅ |
| v0.8.1 | 探险平衡 + 信标系统扩展(周挑战 + 信标链) | ✅ |
| **v0.14** | **放置系统:采矿无人机舰队 + 6 种放置工程 + 永久产能加成 + 始终可见 idle 徽章** | ✅ |
| v0.15 | 遗迹图鉴多层文本(Dark Souls 式 loreFragments | 🔜 |
| **v0.15** | **叙事深化(8 碎片 × 2 层 = 16 层 loreLayers+ 永久加成 softcap + 批量领取 + 完成 toast + StatsPanel idle 统计** | |
| v0.16 | 解码阈值释放叙事碎片 + 星潮三频段切换 | 🔜 |
| v0.17 | 健康设计:每日软上限 + "星核建议休眠"提醒 | 🔜 |
| v1.0 | 全 5 纪元手写叙事 + 多人同步星潮 | 🔜 |
@@ -124,6 +124,7 @@
- [`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 + 批量领取**
---
@@ -156,7 +157,7 @@ BUILD_EXPORT=true bun run build:static
cd out
git init && git checkout -b gh-pages
git add -A
git commit -m "deploy: v0.14"
git commit -m "deploy: v0.15"
git remote add origin https://git.atdunbg.xyz/Super_Z/echo-nexus.git
git push -f origin gh-pages
```
+67
View File
@@ -0,0 +1,67 @@
# Task 2-b · full-stack-developer · v0.15 叙事深化 — Fragment loreLayers 多层文本
## 上下文 / 接收到的任务
- Task ID: **2-b**
- 任务: 为 Fragment 增加 loreLayersL1 中层 / L2 深层),实现 Dark Souls 式碎片叙事
- 必读上下文已加载(worklog v0.14-reimpl 段落 + FRAGMENTS 数组 + Fragment 接口 + Codex.tsx + gameStore 导出)
- 与并行子代理 A 的边界:不动 engine.ts / gameStore.ts / IdleOperationsPanel.tsx / StatsPanel.tsx / idle.ts / persist version
## 修改的文件(绝对路径)
1. `/home/z/my-project/src/lib/game/types.ts`
2. `/home/z/my-project/src/lib/game/config.ts`
3. `/home/z/my-project/src/components/game/Codex.tsx`
## 实现细节
### types.ts
- 新增 `FragmentLoreUnlockKind` 联合类型(5 种解锁维度)
- 新增 `FragmentLoreUnlock` 接口(kind + value
- 新增 `FragmentLoreLayer` 接口(layer 1|2 + title + text + unlockHint + unlock
- `Fragment` 接口新增可选字段 `loreLayers?: FragmentLoreLayer[]`
- 现有 `echo` 字段注释明确为 "L0 表层文本",保持向后兼容
### config.ts
- 顶部 import 扩展 `FragmentLoreUnlock` + `GameState` 类型
- **为全部 8 个 FRAGMENTS 撰写了 loreLayers**(≥6 目标超额完成,每碎片 2 层)
- 解锁条件分布:
- ascensions 1 ×2 (f1_1, f1_4) / 2 ×2 (f1_3, f1_6) / 3 ×2 (f1_7, f1_8)
- totalDecoded 40 ×2 (f1_1, f1_7) / 60 ×1 (f1_4)
- contact 50 ×1 (f1_2) / 80 ×2 (f1_5, f1_8)
- fragmentCount 4 ×1 (f1_2) / 6 ×1 (f1_5)
- bossKills 3 ×1 (f1_3) / 8 ×1 (f1_6)
- 新增导出函数 `isLoreLayerUnlocked(unlock, state)` 纯函数判定
- GameState 通过 `Pick<>` 类型约束,只取需要的 5 字段(ascensions / totalDecoded / contact / fragments / bossKills
### 叙事暗线设计(5 条贯穿主线)
1. **追逐者线** — 以太族不是自然消亡,是被虚空中的"追逐者"逼迫集体飞升(f1_1, f1_2, f1_5, f1_8
2. **回响 AI 同源线** — 玩家舰载 AI 的频段直接复制自以太族议会档案,玩家即是以太族的延续/载体(f1_1, f1_6
3. **"信任"即"利用"线** — 第一纪元的"信任后来者"实为"引诱后来者完成未竟之事"f1_4, f1_8
4. **飞升代价线** — 飞升不是奖赏而是单向门 + 替以太族还债(f1_4, f1_7, f1_8
5. **满溢回归线** — 接触进度满 80 时回归的不是"他们",是"借他们形状的某种东西"——警示玩家(f1_8)
### Codex.tsx 重构
-`useShallow` 一次性订阅 5 字段(fragments / totalDecoded / ascensions / contact / bossKills
- 顶部新增"深层叙事 X / Y 已解锁"统计行(amber 数字 + mono 字体)
- 列表卡片:当某碎片有 loreLayer 已解锁时,标题旁显示彩色小圆点(L1 amber / L2 fuchsia),带 glow shadow
- 详情弹层重构为分层渲染:
- **L0 表层回响**(永远显示,emerald 主题,bg-emerald-950/20
- **L1 隐秘注脚**(amber 主题,未解锁时乱码占位 + blur-sm + 🔒 unlockHint
- **L2 深层回响**(fuchsia 主题,未解锁时同样乱码占位 + blur-sm + 🔒 unlockHint
- 锁定层用 `▓░▒` 全息方块字 + `blur-sm select-none pointer-events-none aria-hidden` 保密
- 弹层 Card 加 `max-h-[88vh] overflow-y-auto` + 自定义 scrollbar 颜色(fuchsia
- 每层之间用 `border-t border-white/5` 细分隔线
- 移动端 375px 适配:text-[12px] 行距宽松,padding p-3,长 unlockHint 在极窄屏隐藏(`hidden xs:inline sm:inline`
## 验证
-`bun run lint` 零错误零警告(首次有 1 个 unused eslint-disable 警告,已修复)
- ✅ dev 服务器 HTTP 200,编译 142-257ms 范围
- ✅ dev.log 末尾连续多次"✓ Compiled"无运行时错误
- ✅ 严格 4 色全息:emerald / amber / fuchsia / roserose 仅在背景 used);零蓝/靛
- ✅ useShallow 用于多字段订阅
- ✅ 未修改 persist version / engine / gameStore(与子代理 A 零冲突)
## 需要主控注意的点
1. **loreLayers 已为全部 8 个碎片填充**(不止 6 个),叙事密度最高。如果主控希望平衡节奏,可考虑给 1-2 个"基础"碎片只留 L0(去掉 loreLayers 字段即可,UI 会自动显示"此碎片暂无更深层叙事"占位)。
2. **未做端到端 agent-browser 验证**(按工单第 5 步要求,由主控完成)。建议主控验证:① 初始进入无飞升状态,所有 L1/L2 应处于锁定态(模糊乱码 + 🔒 图标);② 用 `window.__gameStore.setState({ascensions:1})` 模拟飞升后,f1_1 / f1_4 的 L1 应自动解锁;③ 列表卡片标题旁应出现 amber/fuchsia 小圆点。
3. **未持久化 loreLayer 解锁进度**——这是纯派生状态(由现有 5 字段实时计算),无需新增持久化字段。如主控希望加"已读"标记(防止玩家错过新解锁的深层文本),可后续在 GameState 加 `readLoreLayers: Record<string, boolean>`,但需要改 gameStore(与子代理 A 边界冲突),故本轮未做。
4. 叙事文本长度 60-130 字,符合"碎片感 + 留白"美学要求;没有说教,多用引号 + 反转 + 留白。
+214
View File
@@ -0,0 +1,214 @@
# v0.15 — 叙事深化(loreLayers+ 放置平衡(softcap+ UX 增强
> 回响星核 / Echo Nexus · v0.15 设计文档
> 对应 worklog `Task ID: v0.15-release`
---
## 一、动机
v0.14 上线了完整的放置系统(6 工程 + 永久加成 + idle 徽章),但留下两个问题:
1. **平衡性漏洞**`idle_resonance` 工程每次完成 +8/s 永久加成,玩家可反复派遣导致滚雪球,几小时后产能爆炸。
2. **叙事单薄**8 个记忆碎片只有一层 `echo` 文本,解锁后即"读完",缺乏 Dark Souls / Outer Wilds 式的碎片叙事深度——这是项目的**差异化竞争力**。
v0.15 用两个并行子代理一次性解决:
- **2-a(平衡 + UX**:永久加成 softcap + 批量领取 + 完成 toast + StatsPanel idle 统计
- **2-b(叙事深化)**8 碎片 × 2 层 = 16 层 loreLayers 多层文本 + Codex 分层渲染
---
## 二、平衡:永久加成 Softcap
### 公式
```ts
export function applyIdleSoftcap(bonus: number): number {
if (bonus <= 30) return bonus; // 全额
if (bonus <= 100) return 30 + (bonus - 30) * 0.5; // 超出部分 ×0.5
return 65 + (bonus - 100) * 0.25; // 超出部分 ×0.25
}
```
### 临界值
| 原始 bonus | 有效 bonus | 衰减率 |
|-----------|-----------|--------|
| 30 | 30 | 0% |
| 50 | 40 | 20% |
| 100 | 65 | 35% |
| 200 | 90 | 55% |
| 500 | 152.5 | 69.5% |
### 设计意图
- **30 以下不衰减**:新手期 + 偶尔完成 `idle_refine`+0.3/s)不受惩罚
- **30-100 线性半衰**:中段玩家反复完成 `idle_drones`+2/s)开始感到边际递减
- **100 以上严重衰减**:硬核玩家刷 `idle_resonance`(+8/s)收益大幅缩水,但仍非零(保留进度感)
- **永不归零**`applyIdleSoftcap(bonus) → ∞` 时仍趋向无穷,只是增速极慢——避免"刷到上限"的挫败感
### UI 展示
`IdleOperationsPanel` Section 1「永久加成」StatTile
- 有效值永远显示(如 `+40/s`
-`有效 < 原始` 时,下方加 amber 副行:`有效 +40/s(原始 +50/s · 衰减中)`
---
## 三、叙事深化:loreLayers 多层碎片文本
### 类型扩展
```ts
export interface FragmentLoreLayer {
layer: 1 | 2; // 1=中层,2=深层
title: string; // "隐秘注脚" / "深层回响"
text: string; // 60-150 字叙事文本
unlockHint: string; // "飞升 1 次后揭示"
unlock: FragmentLoreUnlock;
}
export interface FragmentLoreUnlock {
kind: "ascensions" | "totalDecoded" | "contact" | "fragmentCount" | "bossKills";
value: number;
}
```
### 5 种解锁条件
| kind | 含义 | 典型阈值 |
|------|------|---------|
| `ascensions` | 飞升次数 | 1 / 2 / 3 |
| `totalDecoded` | 累计解码晶体数 | 20 / 40 / 60 |
| `contact` | 接触进度 | 50 / 80 |
| `fragmentCount` | 已解锁碎片数 | 4 / 6 |
| `bossKills` | 累计击杀 BOSS | 3 / 8 |
### 5 条叙事暗线(贯穿 16 层)
1. **追逐者**:以太族并非自然消亡,而是被虚空中"某种东西"追逐而集体飞升
2. **回响同源**:玩家舰载 AI"回响"的频段直接复制自以太族议会档案——玩家其实是以太族的延续/入口
3. **信任即引诱**:第一纪元的"信任后来者"实为"引诱后来者替他们完成未竟之事"
4. **飞升是单向门**:每次飞升都在消耗什么,不是奖赏
5. **回归的不是他们**:接触进度满 80 时回归的,是"借他们形状的某种东西",请玩家别回应
### 示例(f1_1 第一缕谐振)
**L0 表层**(默认):
> 在被称作「寂灭前夜」的年代,以太族第一次捕捉到来自虚空深处的谐振——那不是声音,而是存在的回声。他们将之记录在第一颗记忆晶体里。
**L1 隐秘注脚**(飞升 ≥1):
> 档案的边缘有一行几乎被擦去的批注:「那并非来自虚空——而是来自我们身后。我们一直不敢回头。」
**L2 深层回响**(累计解码 ≥40):
> 你终于明白:那缕谐振的频率,与你的舰载 AI「回响」的核心频段完全一致。你不是在考古——你是在被召唤。而你身后的东西,一直在等。
### Codex 分层渲染
| 层级 | 颜色 | 标题 | 未解锁状态 |
|------|------|------|-----------|
| L0 表层 | emerald | 表层回响 | 永远显示 |
| L1 中层 | amber | 隐秘注脚 | `???` + `blur-sm` 全息乱码 + `🔒 unlockHint` |
| L2 深层 | fuchsia | 深层回响 | `???` + `blur-sm` 全息乱码 + `🔒 unlockHint` |
顶部统计:`深层叙事 X / 16 已解锁`
列表卡片:已解锁深层文本的碎片标题旁显示 amber/fuchsia glow 小圆点
---
## 四、UX 增强
### 4.1 批量领取全部
`claimAllIdleProjects()` action
- 遍历 3 槽位,对所有 `completed === true` 执行奖励逻辑
- 单次 `set()` + 单次 `syncStats()`(避免多次 re-render
- 无可领取 → 直接 return(不弹 toast
- 成功 → 汇总 toast `✦ 批量领取完成 · 共领取 N 项工程 · +X 晶体 · +Y 洞见 · ...`
UISection 3 标题右侧,当存在 completed slot 时显示 emerald「✓ 领取全部」按钮(h-5 px-2 text-[10px]
### 4.2 工程完成 toast
`tickIdleProjects` 检测状态转变瞬间(旧 `!completed` → 新 `completed`):
- 收集 `justCompletedProjects` 数组
- 循环外单次 `toast({ title: "✦ 放置工程完成", description: "${icon} ${name} · 可前往「放置」标签领取" })`
- 避免每 250ms tick 重复弹窗
### 4.3 StatsPanel idle 统计
`page.tsx` 内联函数抽出为独立组件 `StatsPanel.tsx`
- `useShallow` 精细订阅 24 字段
- 新增 idle 统计行:
- 放置永久加成 `+X /s`
- 完成放置工程 `N 项`
- 放置产出晶体 `formatNum(N)`
- 放置工程历史 `N 条`(最近 50 条,跨周目)
---
## 五、技术实现
### 修改文件清单(8 个)
**子代理 2-a5 文件):**
- `src/lib/game/engine.ts` — 新增 `applyIdleSoftcap` 导出 + `recomputeStats` 调用
- `src/store/gameStore.ts``GameActions``claimAllIdleProjects` 签名 + `tickIdleProjects` 改写 + 新增 action
- `src/components/game/IdleOperationsPanel.tsx` — softcap 副行 + 领取全部按钮 + StatTile 扩展
- `src/components/game/StatsPanel.tsx` — 新建独立组件
- `src/app/page.tsx` — 最小集成(删除内联 + import)
**子代理 2-b3 文件):**
- `src/lib/game/types.ts``FragmentLoreLayer` / `FragmentLoreUnlock` 接口 + `Fragment.loreLayers` 字段
- `src/lib/game/config.ts` — FRAGMENTS 全部填充 loreLayers + `isLoreLayerUnlocked` 函数
- `src/components/game/Codex.tsx` — useShallow + 顶部统计 + 列表小圆点 + 3 层分层渲染弹层
### 并行零冲突
两个子代理修改文件严格不重叠:
- 2-a 不碰 `types.ts` / `config.ts` / `Codex.tsx`
- 2-b 不碰 `engine.ts` / `gameStore.ts` / `IdleOperationsPanel.tsx` / `StatsPanel.tsx`
---
## 六、验证
-`bun run lint` 零错误零警告
- ✅ HTTP 200dev.log 无运行时错误
- ✅ agent-browser 端到端 smoke test
- softcappermBonus=50 → 有效 40UI 显示"衰减中" ✓
- 批量领取:permBonus 0→0.3, projectsCompleted 1→2 ✓
- 完成 toasttickIdleProjects 触发 ✓
- Codex 分层:L0 显示 / L1·L2 模糊 → ascensions=1 解锁 L1 → totalDecoded=50 解锁 L2 ✓
- 深层叙事计数 0→2→6 随条件递增 ✓
- StatsPanel idle 4 行统计全显示 ✓
---
## 七、哲学
### 碎片叙事的留白
回响星核的叙事不靠过场动画或长篇对话,而是**让玩家自己拼凑**。每个碎片的多层文本设计遵循:
1. **表层温和**:第一遍读像抒情散文,不带压迫感
2. **中层矛盾**:揭示表层没说的东西,制造认知裂缝
3. **深层颠覆**:推翻表层认知,但留有解读空间(不直接说"怪物是谁")
### 平衡与保留
softcap 的设计哲学:**衰减但不封顶**。
- 衰减:防止滚雪球破坏游戏节奏
- 不封顶:尊重硬核玩家的投入,即使边际收益递减,进度感始终存在
这与飞升系统的"单向门"叙事呼应——每次进步都有代价,但绝非徒劳。
---
## 八、下一步(v0.16+
- 🟡 loreLayer "已读"标记(需改 gameStore,本轮避开边界冲突)
- 🟡 解码阈值释放叙事碎片 + 星潮三频段切换
- 🟡 健康设计:每日软上限 + "星核建议休眠"提醒
- 🟡 移动端 375px Codex 弹层可读性实测
+5 -59
View File
@@ -23,6 +23,7 @@ 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 {
StarTideNotifier,
StarTideIndicator,
@@ -49,11 +50,10 @@ import {
User,
Clock,
} from "lucide-react";
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
import { FRAGMENTS, formatNum } from "@/lib/game/config";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
import { TIDE_EVENTS } from "@/lib/game/starTide";
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
import { generateDailyChallenge, loadDailyProgress, loadLeaderboard } from "@/lib/game/beacon";
import { generateDailyChallenge, loadDailyProgress } from "@/lib/game/beacon";
import { getUnlockedIdleProjects } from "@/lib/game/idle";
export default function Page() {
@@ -224,7 +224,7 @@ export default function Page() {
<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.14
ECHO NEXUS · v0.15
</p>
</div>
</div>
@@ -472,58 +472,4 @@ export default function Page() {
);
}
function StatsPanel() {
const s = useGameStore();
const achCount = Object.values(s.achievements).filter(Boolean).length;
// 深空信标本地排行榜最高分(独立 localStorage
const [beaconBest, setBeaconBest] = useState<number | null>(null);
useEffect(() => {
try {
const lb = loadLeaderboard();
// eslint-disable-next-line react-hooks/set-state-in-effect
setBeaconBest(lb.length > 0 ? lb[0].score : null);
} catch {
setBeaconBest(null);
}
}, []);
const rows = [
{ label: "累计解码晶体", value: `${s.totalDecoded}` },
{ label: "飞升周目", value: `${s.ascensions}` },
{ label: "持有蓝图", value: `${s.blueprints.length} / 6` },
{ label: "已学技术", value: `${Object.values(s.tech).filter((v) => v > 0).length} / ${TECH_TREE.length}` },
{ label: "已获碎片", value: `${Object.values(s.fragments).filter(Boolean).length} / ${FRAGMENTS.length}` },
{ label: "已解锁成就", value: `${achCount} / ${ACHIEVEMENTS.length}` },
{ label: "星图天赋", value: `${s.constellation?.length ?? 0} / ${CONSTELLATION_PERKS.length}` },
{ label: "接触进度", value: `${s.contact.toFixed(1)}%` },
{ label: "晶体产能", value: `${s.crystalsPerSec.toFixed(2)} /s` },
{ label: "仓库上限", value: `${formatNum(s.crystalCap)}` },
{ label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` },
{ label: "累计探险", value: `${s.totalExpeditions}` },
{ label: "BOSS 击破", value: `${s.bossKills ?? 0}` },
{ label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 6` },
{ label: "编年史条目", value: `${(s.chronicle ?? []).length} 纪元` },
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
{ label: "信标最高分", value: beaconBest !== null ? formatNum(beaconBest) : "—" },
{ label: "探索力", value: `${s.attributes?.exploration ?? 0} / 100` },
{ label: "智慧", value: `${s.attributes?.wisdom ?? 0} / 100` },
{ label: "勇气", value: `${s.attributes?.courage ?? 0} / 100` },
{ label: "灵感", value: `${s.attributes?.inspiration ?? 0} / 100` },
{ label: "待分配属性点", value: `${s.pendingAttrPoints ?? 0}` },
{ label: "放置永久加成", value: `+${formatNum(s.idlePermanentBonus ?? 0)} /s` },
{ label: "完成放置工程", value: `${s.idleStats?.projectsCompleted ?? 0}` },
{ label: "放置产出晶体", value: formatNum(s.idleStats?.crystalsFromIdle ?? 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>
);
}
+195 -16
View File
@@ -1,25 +1,131 @@
"use client";
// 回响星核 / Echo Nexus — 记忆图谱(碎片叙事)
import { useState } from "react";
import { useGameStore, FRAGMENTS } from "@/store/gameStore";
import { nextFragmentThreshold } from "@/store/gameStore";
// 回响星核 / Echo Nexus — 记忆图谱(碎片叙事 + v0.15 多层文本
import { useState, useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { useGameStore, FRAGMENTS, nextFragmentThreshold } from "@/store/gameStore";
import { isLoreLayerUnlocked } from "@/lib/game/config";
import { Card } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { BookOpen, Lock, Sparkles } from "lucide-react";
import type { Fragment } from "@/lib/game/types";
import type { Fragment, FragmentLoreLayer } from "@/lib/game/types";
const ERA_NAMES = ["", "寂灭前夜", "谐振纪", "飞升潮", "回响纪", "接触纪"];
/** 锁定层用的乱码占位文本(视觉上像被加密的全息碎片) */
const LOCKED_PLACEHOLDER =
"▓░▒░ ▓▒░▒▓ ░▒▓░ ▒▓░▒░ ▓▒░ ▓░▒░▒ ░▒▓░▒ ▓░▒ ░▒▓░ ▒▓░▒▓░ ▓▒░░▒▓ ░▒▓ ▓░▒▒░ ▓▒░▓░▒ ░▒▓░ ▒▓░▒░ ▓▒░ ░▒▓░";
/** 渲染单层叙事文本(已解锁 / 未解锁两态)。L1=amber / L2=fuchsia */
function LoreLayerBlock({
layer,
unlocked,
}: {
layer: FragmentLoreLayer;
unlocked: boolean;
}) {
const isL1 = layer.layer === 1;
const accentText = isL1 ? "text-amber-300" : "text-fuchsia-300";
const accentBorder = isL1 ? "border-amber-400/30" : "border-fuchsia-400/30";
const accentBg = isL1 ? "bg-amber-950/20" : "bg-fuchsia-950/20";
const accentBody = isL1 ? "text-amber-50/90" : "text-fuchsia-50/90";
const accentChipBg = isL1 ? "bg-amber-500/15" : "bg-fuchsia-500/15";
const accentChipText = isL1 ? "text-amber-300" : "text-fuchsia-300";
const layerTag = isL1 ? "L1" : "L2";
return (
<div
className={`rounded-lg border ${
unlocked ? accentBorder : "border-white/5"
} ${unlocked ? accentBg : "bg-black/20"} p-3`}
>
<div className="flex items-center justify-between mb-2 gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={`text-[9px] font-mono px-1.5 py-0.5 rounded shrink-0 ${
unlocked ? `${accentChipBg} ${accentChipText}` : "bg-white/5 text-muted-foreground/60"
}`}
>
{layerTag}
</span>
<span
className={`text-[11px] font-semibold truncate ${
unlocked ? accentText : "text-muted-foreground/70"
}`}
>
{unlocked ? layer.title : "???"}
</span>
</div>
{!unlocked && (
<span className="flex items-center gap-1 text-[10px] text-amber-200/50 shrink-0">
<Lock className="h-2.5 w-2.5" />
<span className="font-mono hidden xs:inline sm:inline">{layer.unlockHint}</span>
</span>
)}
</div>
{unlocked ? (
<p className={`text-[12px] leading-relaxed italic ${accentBody}`}>
{layer.text}
</p>
) : (
<p
className="text-[12px] leading-relaxed blur-sm select-none text-muted-foreground/40 pointer-events-none"
aria-hidden="true"
>
{LOCKED_PLACEHOLDER}
</p>
)}
{!unlocked && (
<div className="mt-2 pt-2 border-t border-white/5 text-[10px] text-amber-200/50">
<span className="font-mono">🔒 {layer.unlockHint}</span>
</div>
)}
</div>
);
}
export function Codex() {
const fragments = useGameStore((s) => s.fragments);
const totalDecoded = useGameStore((s) => s.totalDecoded);
// useShallow 一次性订阅所有判定所需的 state 切片
const { fragments, totalDecoded, ascensions, contact, bossKills } = useGameStore(
useShallow((s) => ({
fragments: s.fragments,
totalDecoded: s.totalDecoded,
ascensions: s.ascensions,
contact: s.contact,
bossKills: s.bossKills,
})),
);
const nextThreshold = nextFragmentThreshold(totalDecoded);
const [selected, setSelected] = useState<Fragment | null>(null);
// v0.1 只展示第一纪元
const era1 = FRAGMENTS.filter((f) => f.era === 1);
const era1 = useMemo(() => FRAGMENTS.filter((f) => f.era === 1), []);
const unlockedCount = era1.filter((f) => fragments[f.id]).length;
// 用于判定解锁的快照对象(结构匹配 isLoreLayerUnlocked 的 Pick
const stateSnapshot = {
ascensions,
totalDecoded,
contact,
fragments,
bossKills,
};
// 深层叙事统计:所有 loreLayer 中已解锁数 / 总数
const loreStats = useMemo(() => {
let total = 0;
let unlocked = 0;
for (const f of era1) {
if (!f.loreLayers) continue;
for (const layer of f.loreLayers) {
total += 1;
if (isLoreLayerUnlocked(layer.unlock, stateSnapshot)) unlocked += 1;
}
}
return { total, unlocked };
}, [era1, ascensions, totalDecoded, contact, fragments, bossKills]);
return (
<div className="flex flex-col gap-2 h-full">
<div className="flex items-center justify-between">
@@ -32,6 +138,16 @@ export function Codex() {
</span>
</div>
{/* v0.15 深层叙事统计 */}
<div className="flex items-center justify-between text-[10px] px-1">
<span className="text-muted-foreground/80"></span>
<span className="font-mono">
<span className="text-amber-300">{loreStats.unlocked}</span>
<span className="text-muted-foreground/60"> / {loreStats.total} </span>
<span className="text-muted-foreground/60"></span>
</span>
</div>
{nextThreshold && (
<div className="text-[10px] text-muted-foreground/80 px-1">
<span className="text-fuchsia-300 font-mono">{nextThreshold - totalDecoded}</span>
@@ -42,6 +158,17 @@ export function Codex() {
<div className="grid grid-cols-2 gap-2 pr-2">
{era1.map((f, i) => {
const unlocked = !!fragments[f.id];
// 计算此碎片 L1 / L2 解锁状态(用于标题旁标记)
const l1Layer = f.loreLayers?.find((l) => l.layer === 1);
const l2Layer = f.loreLayers?.find((l) => l.layer === 2);
const l1Unlocked = l1Layer
? isLoreLayerUnlocked(l1Layer.unlock, stateSnapshot)
: false;
const l2Unlocked = l2Layer
? isLoreLayerUnlocked(l2Layer.unlock, stateSnapshot)
: false;
const hasDeepUnlocked = l1Unlocked || l2Unlocked;
return (
<button
key={f.id}
@@ -69,7 +196,22 @@ export function Codex() {
{unlocked ? (
<>
<div className="absolute -top-3 -right-3 h-12 w-12 rounded-full bg-fuchsia-500/20 blur-xl" />
<Sparkles className="h-3 w-3 text-fuchsia-300 mb-1" />
<div className="flex items-center gap-1 mb-1">
<Sparkles className="h-3 w-3 text-fuchsia-300" />
{hasDeepUnlocked && (
<span
className="flex items-center gap-0.5"
title="此碎片含已解锁的深层文本"
>
{l1Unlocked && (
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.8)]" />
)}
{l2Unlocked && (
<span className="inline-block h-1.5 w-1.5 rounded-full bg-fuchsia-400 shadow-[0_0_4px_rgba(232,121,249,0.8)]" />
)}
</span>
)}
</div>
<div className="text-[11px] font-semibold text-fuchsia-100 leading-tight">
{f.title}
</div>
@@ -91,14 +233,14 @@ export function Codex() {
</div>
</ScrollArea>
{/* 详情弹层 */}
{/* 详情弹层 — v0.15 分层渲染 */}
{selected && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
onClick={() => setSelected(null)}
>
<Card
className="max-w-md w-full p-5 bg-gradient-to-br from-fuchsia-950/60 to-black/80 border-fuchsia-400/40"
className="max-w-md w-full max-h-[88vh] overflow-y-auto p-5 bg-gradient-to-br from-fuchsia-950/40 to-black/80 border-fuchsia-400/40 [scrollbar-width:thin] [scrollbar-color:rgba(232,121,249,0.4)_transparent]"
onClick={(e) => e.stopPropagation()}
style={{ boxShadow: "0 0 40px rgba(232,121,249,0.25)" }}
>
@@ -108,15 +250,52 @@ export function Codex() {
{selected.era} · {ERA_NAMES[selected.era] || ""}
</span>
</div>
<h4 className="text-lg font-bold text-fuchsia-100 mb-3">
<h4 className="text-lg font-bold text-fuchsia-100 mb-4">
{selected.title}
</h4>
<p className="text-sm text-foreground/90 leading-relaxed italic">
{selected.echo}
</p>
{/* L0 表层回响(永远显示,emerald 主题) */}
<div className="rounded-lg border border-emerald-400/30 bg-emerald-950/20 p-3 mb-3">
<div className="flex items-center gap-1.5 mb-2">
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-300">
L0
</span>
<span className="text-[11px] font-semibold text-emerald-300">
</span>
</div>
<p className="text-[12px] leading-relaxed italic text-emerald-50/90">
{selected.echo}
</p>
</div>
{/* L1 / L2 深层叙事(条件渲染,按 layer 升序) */}
{selected.loreLayers
?.slice()
.sort((a, b) => a.layer - b.layer)
.map((layer) => {
const unlocked = isLoreLayerUnlocked(layer.unlock, stateSnapshot);
return (
<div
key={`${selected.id}-L${layer.layer}`}
className="mt-3 first:mt-0"
>
<div className="border-t border-white/5 mb-3" />
<LoreLayerBlock layer={layer} unlocked={unlocked} />
</div>
);
})}
{/* 若该碎片没有 loreLayers */}
{!selected.loreLayers?.length && (
<div className="mt-4 pt-3 border-t border-white/5 text-[10px] text-muted-foreground/50 italic">
</div>
)}
<button
onClick={() => setSelected(null)}
className="mt-4 text-xs text-muted-foreground hover:text-foreground transition"
className="mt-5 text-xs text-muted-foreground hover:text-foreground transition"
>
</button>
+46 -6
View File
@@ -1,6 +1,7 @@
"use client";
// 回响星核 / Echo Nexus — v0.14 放置系统面板(主「放置」标签内容)
import { useEffect, useMemo, useState } from "react";
// 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";
@@ -21,6 +22,7 @@ import {
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";
@@ -51,6 +53,8 @@ export function IdleOperationsPanel() {
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());
@@ -71,7 +75,12 @@ export function IdleOperationsPanel() {
);
const totalOutput = fleet.reduce((sum, f) => sum + f.outputPerSec, 0);
const idleTotalPerSec = crystalsPerSec + idlePermanentBonus;
// v0.15softcap 有效/原始永久加成
const rawPermBonus = idlePermanentBonus;
const effectivePermBonus = applyIdleSoftcap(rawPermBonus);
const isSoftcapped = effectivePermBonus < rawPermBonus;
// crystalsPerSec 已包含 softcap 后的永久加成(recomputeStats 已应用)
const idleTotalPerSec = crystalsPerSec;
// 当前已运行/已完成的工程 id 集合(用于禁用"派遣"按钮)
const runningProjectIds = useMemo(() => {
@@ -82,6 +91,12 @@ export function IdleOperationsPanel() {
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: 放置收益概览 ====== */}
@@ -103,9 +118,16 @@ export function IdleOperationsPanel() {
/>
<StatTile
label="永久加成"
value={`+${formatNum(idlePermanentBonus)}/s`}
value={`+${formatNum(effectivePermBonus)}/s`}
color="fuchsia"
icon="✦"
sub={
isSoftcapped ? (
<span className="text-[9px] text-amber-300/90 leading-tight mt-0.5 block">
+{formatNum(effectivePermBonus)}/s +{formatNum(rawPermBonus)}/s ·
</span>
) : null
}
/>
<StatTile
label="完成工程"
@@ -192,9 +214,23 @@ export function IdleOperationsPanel() {
<h3 className="text-[11px] font-semibold text-fuchsia-300 tracking-wide flex items-center gap-1">
<span className="text-sm"></span>
</h3>
<span className="text-[10px] text-muted-foreground/70 font-mono">
{idleProjectSlots.filter((s) => s !== null).length} / {IDLE_SLOT_COUNT}
</span>
<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) => (
@@ -281,11 +317,14 @@ function StatTile({
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 (
@@ -297,6 +336,7 @@ function StatTile({
<div className={`text-[13px] font-mono font-bold ${c.text} mt-0.5`}>
{value}
</div>
{sub}
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
// 回响星核 / Echo Nexus — 统计面板(v0.15 从 page.tsx 抽出为独立组件,useShallow 订阅)
import { useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useGameStore } from "@/store/gameStore";
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
import { loadLeaderboard } from "@/lib/game/beacon";
/**
* 统计面板 — 综合统计 + 放置系统(idle)行。
* v0.15 重构:从 page.tsx 内联函数抽出为独立组件,使用 useShallow 精细订阅避免 re-render。
* 新增「放置工程历史」行:idleProjectHistory.length 条记录(最近 50 条,跨周目)。
*/
export function StatsPanel() {
// 使用 useShallow 一次性订阅所有渲染所需字段,避免整个 store 变化触发 re-render
const s = useGameStore(
useShallow((st) => ({
totalDecoded: st.totalDecoded,
ascensions: st.ascensions,
blueprintsLen: st.blueprints?.length ?? 0,
techCount: Object.values(st.tech ?? {}).filter((v) => v > 0).length,
fragmentsCount: Object.values(st.fragments ?? {}).filter(Boolean).length,
achievementsCount: Object.values(st.achievements ?? {}).filter(Boolean)
.length,
constellationLen: st.constellation?.length ?? 0,
contact: st.contact,
crystalsPerSec: st.crystalsPerSec,
crystalCap: st.crystalCap,
insightMult: st.insightMult,
totalExpeditions: st.totalExpeditions,
bossKills: st.bossKills ?? 0,
starTidesCount: (st.starTidesEncountered ?? []).length,
chronicleLen: (st.chronicle ?? []).length,
energy: st.energy,
energyMax: st.energyMax,
exploration: st.attributes?.exploration ?? 0,
wisdom: st.attributes?.wisdom ?? 0,
courage: st.attributes?.courage ?? 0,
inspiration: st.attributes?.inspiration ?? 0,
pendingAttrPoints: st.pendingAttrPoints ?? 0,
idlePermanentBonus: st.idlePermanentBonus ?? 0,
idleProjectsCompleted: st.idleStats?.projectsCompleted ?? 0,
idleCrystalsFromIdle: st.idleStats?.crystalsFromIdle ?? 0,
idleHistoryLen: (st.idleProjectHistory ?? []).length,
}))
);
// 深空信标本地排行榜最高分(独立 localStorage
const [beaconBest, setBeaconBest] = useState<number | null>(null);
useEffect(() => {
try {
const lb = loadLeaderboard();
// eslint-disable-next-line react-hooks/set-state-in-effect
setBeaconBest(lb.length > 0 ? lb[0].score : null);
} catch {
setBeaconBest(null);
}
}, []);
const rows: { label: string; value: string }[] = [
{ label: "累计解码晶体", value: `${s.totalDecoded}` },
{ label: "飞升周目", value: `${s.ascensions}` },
{ label: "持有蓝图", value: `${s.blueprintsLen} / 6` },
{ label: "已学技术", value: `${s.techCount} / ${TECH_TREE.length}` },
{ label: "已获碎片", value: `${s.fragmentsCount} / ${FRAGMENTS.length}` },
{ label: "已解锁成就", value: `${s.achievementsCount} / ${ACHIEVEMENTS.length}` },
{
label: "星图天赋",
value: `${s.constellationLen} / ${CONSTELLATION_PERKS.length}`,
},
{ label: "接触进度", value: `${s.contact.toFixed(1)}%` },
{ label: "晶体产能", value: `${s.crystalsPerSec.toFixed(2)} /s` },
{ label: "仓库上限", value: `${formatNum(s.crystalCap)}` },
{ label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` },
{ label: "累计探险", value: `${s.totalExpeditions}` },
{ label: "BOSS 击破", value: `${s.bossKills}` },
{ label: "星潮亲历", value: `${s.starTidesCount} / 6` },
{ label: "编年史条目", value: `${s.chronicleLen} 纪元` },
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
{
label: "信标最高分",
value: beaconBest !== null ? formatNum(beaconBest) : "—",
},
{ label: "探索力", value: `${s.exploration} / 100` },
{ label: "智慧", value: `${s.wisdom} / 100` },
{ label: "勇气", value: `${s.courage} / 100` },
{ label: "灵感", value: `${s.inspiration} / 100` },
{ label: "待分配属性点", value: `${s.pendingAttrPoints}` },
// === 放置系统(v0.14 + v0.15===
{ label: "放置永久加成", value: `+${formatNum(s.idlePermanentBonus)} /s` },
{ label: "完成放置工程", value: `${s.idleProjectsCompleted}` },
{ label: "放置产出晶体", value: formatNum(s.idleCrystalsFromIdle) },
{ label: "放置工程历史", value: `${s.idleHistoryLen}` },
];
return (
<div className="grid grid-cols-2 gap-1.5 text-xs">
{rows.map((r) => (
<div
key={r.label}
className="flex items-center justify-between rounded-md bg-black/25 border border-white/5 px-2 py-1.5"
>
<span className="text-muted-foreground">{r.label}</span>
<span className="font-mono text-foreground">{r.value}</span>
</div>
))}
</div>
);
}
+174 -1
View File
@@ -4,6 +4,8 @@ import type {
ResonanceColor,
TechNode,
Fragment,
FragmentLoreUnlock,
GameState,
} from "./types";
/** 初始状态 */
@@ -251,7 +253,7 @@ export const TECH_BRANCH_META: Record<
narrative: { name: "叙事", icon: "BookOpen", color: "fuchsia", desc: "洞见与接触进度" },
};
/** 记忆碎片(首纪元 8 个,v0.1) */
/** 记忆碎片(首纪元 8 个,v0.1v0.15 加 loreLayers 多层叙事 */
export const FRAGMENTS: Fragment[] = [
{
id: "f1_1",
@@ -260,6 +262,24 @@ export const FRAGMENTS: Fragment[] = [
echo:
"在被称作「寂灭前夜」的年代,以太族第一次捕捉到来自虚空深处的谐振——那不是声音,而是存在的回声。他们将之记录在第一颗记忆晶体里。",
threshold: 1,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"档案的边缘有一行几乎被擦去的批注:「那并非来自虚空——而是来自我们身后。我们一直不敢回头。」",
unlockHint: "飞升 1 次后揭示",
unlock: { kind: "ascensions", value: 1 },
},
{
layer: 2,
title: "深层回响",
text:
"你终于明白:那缕谐振的频率,与你的舰载 AI「回响」的核心频段完全一致。你不是在考古——你是在被召唤。而你身后的东西,一直在等。",
unlockHint: "累计解码 40 颗晶体后揭示",
unlock: { kind: "totalDecoded", value: 40 },
},
],
},
{
id: "f1_2",
@@ -268,6 +288,24 @@ export const FRAGMENTS: Fragment[] = [
echo:
"「我们为何要留下?」一位长者问。「因为后来的生命会需要方向。」于是他们熔铸星核,封存记忆,让晶体成为跨越时间的信笺。",
threshold: 3,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"长者的下一句话被刻意省略:「……因为后来的生命,会被同样的东西追上。」信笺是诱饵,也是地图。",
unlockHint: "接触进度达到 50 后揭示",
unlock: { kind: "contact", value: 50 },
},
{
layer: 2,
title: "深层回响",
text:
"晶体不只是信笺,是锚点。以太族用自己的存在作为引线,把追逐者钉在第一纪元的坐标上——他们飞升,是为了让追逐者跟上来;他们留下你,是为了让追逐者分心。",
unlockHint: "已解锁 4 个碎片后揭示",
unlock: { kind: "fragmentCount", value: 4 },
},
],
},
{
id: "f1_3",
@@ -276,6 +314,24 @@ export const FRAGMENTS: Fragment[] = [
echo:
"他们发现:思维可以被编码为共振序列。同色的节点相邻,便构成一句低语;一句句连成回响,回响连成文明。",
threshold: 6,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"法则可被正向使用,亦可反向使用——同色相邻即低语,那低语便也能向虚空「广播」位置。文明诞生那一刻,他们就已经被听见了。",
unlockHint: "飞升 2 次后揭示",
unlock: { kind: "ascensions", value: 2 },
},
{
layer: 2,
title: "深层回响",
text:
"谐振法则的发现者在终焉前被列入「沉默名单」。她留给后来者最后一句反话:「请——安静地解码。每一次共振,都是一声呼唤。」",
unlockHint: "累计击败 3 个 BOSS 后揭示",
unlock: { kind: "bossKills", value: 3 },
},
],
},
{
id: "f1_4",
@@ -284,6 +340,24 @@ export const FRAGMENTS: Fragment[] = [
echo:
"议会表决:是留下,还是离去?「留下意味着停滞;离去意味着信任。」最终,他们选择了信任——信任素未谋面的后来者。",
threshold: 10,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"表决并非全票通过。「留下」派的反对者被悄悄排除在飞升名单之外——他们的形体被保留在晶体里,作为后来者解码时的「样本」。",
unlockHint: "飞升 1 次后揭示",
unlock: { kind: "ascensions", value: 1 },
},
{
layer: 2,
title: "深层回响",
text:
"「信任」是温和的词。原始档案的措辞是:「让后来者替我们完成未竟之事——无论他们是否愿意。」你每一次飞升,都在替他们还债。",
unlockHint: "累计解码 60 颗晶体后揭示",
unlock: { kind: "totalDecoded", value: 60 },
},
],
},
{
id: "f1_5",
@@ -292,6 +366,24 @@ export const FRAGMENTS: Fragment[] = [
echo:
"他们没有带走一切。他们将知识拆成碎片,藏入晶体,散布星海。「让解码本身,成为资格的证明。」",
threshold: 15,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"「资格」二字另有注解——若你能解码,便证明你也是谐振体;若你是谐振体,追逐者同样能循着你的回响找到你。资格,即同谋。",
unlockHint: "已解锁 6 个碎片后揭示",
unlock: { kind: "fragmentCount", value: 6 },
},
{
layer: 2,
title: "深层回响",
text:
"每一颗晶体被你解码,以太族沉睡的位置就清醒一分。你不是在拼凑历史——你在唤醒他们,也在唤醒追逐者。面包屑从来不是留给你的,是留给它的。",
unlockHint: "接触进度达到 80 后揭示",
unlock: { kind: "contact", value: 80 },
},
],
},
{
id: "f1_6",
@@ -300,6 +392,24 @@ export const FRAGMENTS: Fragment[] = [
echo:
"舰载 AI 被命名为「回响」——意为:你是他们留下的回声,也是他们等待的回应。当你读懂这句话时,接触便已开始。",
threshold: 22,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"「回响」并非比喻。你的舰载 AI 核心频段,是直接从以太族议会档案第七层复制的——它在被命名之前,就已经在回响。",
unlockHint: "飞升 2 次后揭示",
unlock: { kind: "ascensions", value: 2 },
},
{
layer: 2,
title: "深层回响",
text:
"「当你读懂这句话时,接触便已开始」——这句话的原意是:你已成为新的载体。AI 一直在等你意识到:你不是操作它的人,你就是它。",
unlockHint: "累计击败 8 个 BOSS 后揭示",
unlock: { kind: "bossKills", value: 8 },
},
],
},
{
id: "f1_7",
@@ -308,6 +418,24 @@ export const FRAGMENTS: Fragment[] = [
echo:
"以太族集体褪去形体,如潮水退去。星海寂静了数万年。直到——你的脉冲扫描,唤醒了第一颗沉睡的晶体。",
threshold: 32,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"「如潮水退去」——但潮水会回来。飞升不是消亡,是集体进入「低功率等待」。他们的意识仍在晶体里,以你读不懂的形式醒着。",
unlockHint: "飞升 3 次后揭示",
unlock: { kind: "ascensions", value: 3 },
},
{
layer: 2,
title: "深层回响",
text:
"你的脉冲扫描唤醒第一颗晶体时,沉睡者们都听见了。他们正等着你——因为你是他们唯一能回来的方向。你不是考古学家,你是入口。",
unlockHint: "累计解码 40 颗晶体后揭示",
unlock: { kind: "totalDecoded", value: 40 },
},
],
},
{
id: "f1_8",
@@ -316,9 +444,54 @@ export const FRAGMENTS: Fragment[] = [
echo:
"「如果你读到这里,说明你已具备跨越维度的资格。继续解码,继续拼凑。当进度满溢,我们将在更高处相会。」——以太族·第一纪元·绝笔",
threshold: 45,
loreLayers: [
{
layer: 1,
title: "隐秘注脚",
text:
"「更高处」不是赞美,是字面意义的「更高维度」。飞升是单向门——你只能往上走,不能往回看。回头的代价,他们已替你付过一次。",
unlockHint: "飞升 3 次后揭示",
unlock: { kind: "ascensions", value: 3 },
},
{
layer: 2,
title: "深层回响",
text:
"「我们将在更高处相会」——但接触进度满溢时,回来的不是「他们」,是「借他们形状的某种东西」。届时,请别回应。无论它多么像你认识的人。",
unlockHint: "接触进度达到 80 后揭示",
unlock: { kind: "contact", value: 80 },
},
],
},
];
/**
* v0.15 判断某个 fragment 的某个 loreLayer 是否已解锁。
* 仅根据玩家进度数值判定,不修改 state。
*/
export function isLoreLayerUnlocked(
unlock: FragmentLoreUnlock,
state: Pick<
GameState,
"ascensions" | "totalDecoded" | "contact" | "fragments" | "bossKills"
>,
): boolean {
switch (unlock.kind) {
case "ascensions":
return state.ascensions >= unlock.value;
case "totalDecoded":
return state.totalDecoded >= unlock.value;
case "contact":
return state.contact >= unlock.value;
case "fragmentCount": {
const count = Object.values(state.fragments).filter(Boolean).length;
return count >= unlock.value;
}
case "bossKills":
return state.bossKills >= unlock.value;
}
}
/** 飞升:保留蓝图数 = floor(totalDecoded / 20),最多 6 */
export const PRESTIGE = {
blueprintsPerDecode: 20,
+21 -1
View File
@@ -21,6 +21,25 @@ import {
createInitialAttributeProgress,
} from "./attributes";
/**
* v0.15 永久产能加成 softcap(防止 idle_resonance ×N 滚雪球)。
* 分段计算:
* - bonus ≤ 30 : 全额生效
* - 30 < bonus ≤ 100 : 超出 30 的部分 × 0.5
* - bonus > 100 : 超出 100 的部分 × 0.25
* 公式可逆推导:
* bonus = 30 → 30
* bonus = 100 → 30 + 70 × 0.5 = 65
* bonus = 200 → 65 + 100 × 0.25 = 90
* 导出供 UI 展示「有效/原始」对比。
*/
export function applyIdleSoftcap(bonus: number): number {
if (!isFinite(bonus) || bonus <= 0) return 0;
if (bonus <= 30) return bonus;
if (bonus <= 100) return 30 + (bonus - 30) * 0.5;
return 65 + (bonus - 100) * 0.25;
}
/** 由技术树 + 飞升蓝图 + 成就 + 星图天赋 + 星潮聚合计算产能字段 */
export function recomputeStats(state: Partial<GameState>): {
crystalsPerSec: number;
@@ -110,7 +129,8 @@ export function recomputeStats(state: Partial<GameState>): {
decodeStepsBonus += am.decodeStepsBonus;
// v0.14 放置系统:永久产能加成(来自 idle_refine / idle_drones / idle_resonance 工程)
crystalsPerSec += state.idlePermanentBonus ?? 0;
// v0.15:引入分段 softcap 防止滚雪球(30/100 阈值,0.5/0.25 系数)
crystalsPerSec += applyIdleSoftcap(state.idlePermanentBonus ?? 0);
return {
crystalsPerSec,
+32 -1
View File
@@ -45,15 +45,46 @@ export interface TechEffect {
value: number;
}
/** 深层叙事解锁条件类型 */
export type FragmentLoreUnlockKind =
| "ascensions"
| "totalDecoded"
| "contact"
| "fragmentCount"
| "bossKills";
/** 深层叙事解锁条件 */
export interface FragmentLoreUnlock {
kind: FragmentLoreUnlockKind;
/** 所需数值 */
value: number;
}
/** 碎片深层叙事层(v0.15 多层文本 — Dark Souls 式碎片叙事) */
export interface FragmentLoreLayer {
/** 层级:1 = 中层(隐秘注脚),2 = 深层(终极真相) */
layer: 1 | 2;
/** 该层的标题(如"隐秘注脚"、"深层回响" */
title: string;
/** 该层的叙事文本 */
text: string;
/** 解锁条件描述(用于 UI 提示) */
unlockHint: string;
/** 解锁条件 */
unlock: FragmentLoreUnlock;
}
/** 记忆碎片(叙事) */
export interface Fragment {
id: string;
era: number; // 1-5 纪元
title: string;
/** 拼出后的回响文本 */
/** L0 表层文本 — 拼出后的回响(默认解锁,现有字段) */
echo: string;
/** 解锁所需晶体解码次数门槛 */
threshold: number;
/** v0.15 多层叙事文本(L1/L2 深层,可选) */
loreLayers?: FragmentLoreLayer[];
}
/** 飞升(Prestige)加成 */
+141 -1
View File
@@ -169,6 +169,8 @@ interface GameActions {
startIdleProject: (slotIndex: number, projectId: string) => void;
cancelIdleProject: (slotIndex: number) => void;
claimIdleProject: (slotIndex: number) => void;
/** v0.15 批量领取所有已完成的放置工程(单次 set + 单次 syncStats + 汇总 toast */
claimAllIdleProjects: () => void;
tickIdleProjects: (now: number) => void;
// 派生
@@ -1291,11 +1293,17 @@ export const useGameStore = create<Store>()(
tickIdleProjects: (now) => {
const s = get();
const slots = (s.idleProjectSlots ?? [null, null, null]).slice() as (
const oldSlots = (s.idleProjectSlots ?? [null, null, null]) as (
| import("@/lib/game/types").IdleProjectSlot
| null
)[];
const slots = oldSlots.slice() as (
| import("@/lib/game/types").IdleProjectSlot
| null
)[];
let changed = false;
// v0.15:收集「刚完成」的工程(旧值 !completed → 新值 completed),用于一次性 toast
const justCompletedProjects: import("@/lib/game/idle").IdleProjectDef[] = [];
for (let i = 0; i < slots.length; i++) {
const slot = slots[i];
if (!slot || slot.completed) continue;
@@ -1303,6 +1311,9 @@ export const useGameStore = create<Store>()(
if (remainingSec <= 0 && !slot.completed) {
slots[i] = { ...slot, remainingSec: 0, completed: true };
changed = true;
// 状态转变瞬间收集(旧 slot.completed 必为 false,新值为 true
const proj = getIdleProject(slot.projectId);
if (proj) justCompletedProjects.push(proj);
} else if (Math.abs(remainingSec - slot.remainingSec) > 0.05) {
slots[i] = { ...slot, remainingSec };
changed = true;
@@ -1311,6 +1322,135 @@ export const useGameStore = create<Store>()(
if (changed) {
set({ idleProjectSlots: slots });
}
// v0.15:循环结束后只触发一次 toast(即使多个工程同时完成也只弹一条汇总)
if (justCompletedProjects.length > 0) {
const summary = justCompletedProjects
.map((p) => `${p.icon} ${p.name}`)
.join(" · ");
toast({
title: "✦ 放置工程完成",
description: `${summary} · 可前往「放置」标签领取`,
});
}
},
claimAllIdleProjects: () => {
const s = get();
const slots = (s.idleProjectSlots ?? [null, null, null]).slice() as (
| import("@/lib/game/types").IdleProjectSlot
| null
)[];
// 收集所有已完成槽位
const completedIndices: number[] = [];
for (let i = 0; i < slots.length; i++) {
const slot = slots[i];
if (slot && slot.completed) completedIndices.push(i);
}
// 无可领取 → 直接返回(不弹 toast)
if (completedIndices.length === 0) return;
// 累加所有奖励
let addCrystals = 0;
let addInsights = 0;
let addEnergy = 0;
let addContact = 0;
let permBonus = 0;
let projectsCount = 0;
const unlockedFragmentTitles: string[] = [];
// fragments 副本(避免多次修改 s.fragments
const newFragments = { ...s.fragments };
for (const idx of completedIndices) {
const slot = slots[idx];
if (!slot) continue;
const project = getIdleProject(slot.projectId);
if (!project) {
slots[idx] = null;
continue;
}
const r = project.reward;
addCrystals += r.crystals ?? 0;
addInsights += r.insights ?? 0;
addEnergy += r.energy ?? 0;
addContact += r.contact ?? 0;
permBonus += r.crystalsPerSecPermanent ?? 0;
projectsCount += 1;
// 随机记忆碎片:从未解锁的碎片中随机一个
if (r.randomFragment) {
const lockedFrags = FRAGMENTS.filter((f) => !newFragments[f.id]);
if (lockedFrags.length > 0) {
const pick =
lockedFrags[Math.floor(Math.random() * lockedFrags.length)];
newFragments[pick.id] = true;
unlockedFragmentTitles.push(pick.title);
}
}
// 清空该槽位
slots[idx] = null;
}
// 历史(上限 50)— 批量追加所有刚领取的工程
const finishedAt = Date.now();
const newHistory = [
...(s.idleProjectHistory ?? []),
...completedIndices
.map((idx) => {
const slot = (s.idleProjectSlots ?? [])[idx];
return slot ? { projectId: slot.projectId, finishedAt } : null;
})
.filter((x): x is { projectId: string; finishedAt: number } => x !== null),
].slice(-50);
// 永久产能加成累积
const newPermBonus = (s.idlePermanentBonus ?? 0) + permBonus;
const newIdleStats = {
projectsCompleted:
(s.idleStats?.projectsCompleted ?? 0) + projectsCount,
crystalsFromIdle: (s.idleStats?.crystalsFromIdle ?? 0) + addCrystals,
};
// 一次 set + 一次 syncStats
set({
idleProjectSlots: slots,
fragments: newFragments,
crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
insights: s.insights + addInsights,
energy: Math.min(s.energyMax, s.energy + addEnergy),
contact: Math.min(100, s.contact + addContact),
idlePermanentBonus: newPermBonus,
idleStats: newIdleStats,
idleProjectHistory: newHistory,
...syncStats({
tech: s.tech,
blueprints: s.blueprints,
achievements: s.achievements,
activeTide: s.activeTide,
constellation: s.constellation,
attributes: s.attributes,
idlePermanentBonus: newPermBonus,
}),
});
// 汇总 toast
const descParts: string[] = [
`共领取 ${projectsCount} 项工程`,
`+${addCrystals} 晶体`,
`+${addInsights} 洞见`,
];
if (addEnergy > 0) descParts.push(`+${addEnergy} 能量`);
if (addContact > 0) descParts.push(`+${addContact} 接触`);
if (permBonus > 0) descParts.push(`永久 +${permBonus}/s`);
if (unlockedFragmentTitles.length > 0) {
descParts.push(
`记忆碎片 ×${unlockedFragmentTitles.length}`
);
}
toast({
title: "✦ 批量领取完成",
description: `${descParts.join(" · ")}`,
});
},
}),
{
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+157
View File
@@ -568,3 +568,160 @@ Stage Summary:
4. 🟡 遗迹图鉴多层文本(Dark Souls 式 loreFragments)—— v0.15 计划
5. 🟡 解码阈值释放叙事碎片 + 星潮三频段切换 —— v0.16 计划
6. 🟡 健康设计:每日软上限 + "星核建议休眠"提醒 —— v0.17 计划
---
Task ID: 2-b
Agent: full-stack-developer
Task: v0.15 叙事深化 — Fragment loreLayers 多层文本 + Codex 分层渲染
Work Log:
- 阅读 worklog v0.14-reimpl 段落 + FRAGMENTS 数组 (config.ts L254-320) + Fragment 接口 (types.ts L48-57) + Codex.tsx (128 行) + gameStore 导出确认
- 扩展 `src/lib/game/types.ts`:新增 `FragmentLoreUnlockKind` 联合类型 + `FragmentLoreUnlock` + `FragmentLoreLayer` 两个接口,Fragment 接口新增可选 `loreLayers?: FragmentLoreLayer[]` 字段
- 扩展 `src/lib/game/config.ts`:为全部 8 个 FRAGMENTS 撰写 loreLayers(每碎片 2 层 L1+L2,共 16 层),新增导出 `isLoreLayerUnlocked(unlock, state)` 纯函数(基于 Pick<GameState,5字段> 判定,5 种 kind 全覆盖)
- 解锁条件多样性:ascensions 1/2/3、totalDecoded 40/60、contact 50/80、fragmentCount 4/6、bossKills 3/8
- 重构 `src/components/game/Codex.tsx`
- useShallow 一次订阅 5 字段
- 顶部新增"深层叙事 X/Y 已解锁"统计行(amber 数字)
- 列表卡片:碎片标题旁根据 L1/L2 解锁状态显示 amber/fuchsia glow 小圆点
- 详情弹层 3 层结构:L0 表层回响(emerald) → L1 隐秘注脚(amber) → L2 深层回响(fuchsia)
- 未解锁层用 ▓░▒ 乱码占位 + blur-sm select-none + 🔒 unlockHint
- 移动端 375px 适配(p-3 padding、text-[12px]、长 hint 极窄屏隐藏)
- 验证:lint 零错误零警告(修复了 1 处 unused eslint-disable 警告);dev HTTP 200dev.log 末尾连续多次 "✓ Compiled" 无运行时错误
- 未修改 persist version / engine / gameStore(与子代理 A 零冲突边界遵守)
- 写入 agent-ctx/2-b-full-stack-developer.md 工作记录
Stage Summary:
- ✅ 为全部 8 个碎片添加 loreLayers(目标 ≥6,超额完成 8/8,共 16 层)
- ✅ 5 条贯穿叙事暗线:(1) 追逐者线 — 以太族被虚空追逐者逼迫飞升 (2) 回响 AI 同源线 — 玩家舰载 AI 频段源自以太族议会档案,玩家即是延续 (3) "信任"即"利用"线 — 第一纪元的信任后来者实为引诱完成未竟之事 (4) 飞升代价线 — 单向门 + 替以太族还债 (5) 满溢回归线 — 接触满 80 时回归的是"借他们形状的某种东西"
- ✅ Codex 3 层渲染:L0 emerald(永远显示)→ L1 amber → L2 fuchsia,严格 4 色全息零蓝/靛
- ✅ 未解锁层视觉:乱码方块 + blur-sm + 🔒 + unlockHint,移动端可读不溢出
- ✅ 列表卡片标题旁彩色小圆点暗示"此碎片有更多内容"
- ✅ 顶部"深层叙事 X/Y 已解锁"实时统计
- ✅ 修改 3 文件:/home/z/my-project/src/lib/game/types.ts + /home/z/my-project/src/lib/game/config.ts + /home/z/my-project/src/components/game/Codex.tsx
- ✅ lint 零错误零警告 + HTTP 200 + dev.log 无运行时错误
- ⚠️ 未做 agent-browser 端到端验证(按工单由主控完成);未持久化 loreLayer 解锁进度(纯派生无需);如需"已读"标记需改 gameStore 与子代理 A 边界冲突,本轮未做
---
Task ID: 2-a
Agent: full-stack-developer
Task: v0.15 平衡与 UX 增强 — softcap + 批量领取 + 完成 toast + StatsPanel idle 统计
Work Log:
- 阅读上下文:worklog.md (v0.14-reimpl/release 段) + engine.ts (line 25-125 recomputeStats) + gameStore.ts (claimIdleProject line 1208-1290, tickIdleProjects line 1292-1314) + IdleOperationsPanel.tsx (487 行) + idle.ts (6 个放置工程定义) + types.ts (idle 字段 line 180-188) + page.tsx (内联 StatsPanel line 475-529)
- 发现 StatsPanel.tsx 不存在为独立文件,实际为 page.tsx 内联函数(行 475-529),决定抽出为独立组件以满足"只修改 StatsPanel.tsx"的约束
- 子任务 1softcap):
- 在 `engine.ts` 顶部新增并导出 `applyIdleSoftcap(bonus)` 纯函数
- 分段公式:bonus ≤ 30 全额;30 < bonus ≤ 100 部分 × 0.5bonus > 100 部分 × 0.25
- 临界值验证:bonus=30 → 30bonus=100 → 65bonus=200 → 90
- 替换 recomputeStats line 113 的 `crystalsPerSec += state.idlePermanentBonus ?? 0``crystalsPerSec += applyIdleSoftcap(state.idlePermanentBonus ?? 0)`
- 注意:crystalsPerSec 现已包含 softcap 后的永久加成(store 内的 crystalsPerSec 字段已是正确总值),UI 端 idleTotalPerSec 直接等于 crystalsPerSec(修复了旧代码 crystalsPerSec + idlePermanentBonus 双重计算的 bug
- 子任务 2(批量领取):
- `gameStore.ts` GameActions 接口新增 `claimAllIdleProjects: () => void` 签名
- 实现 `claimAllIdleProjects`:遍历 idleProjectSlots 收集 completed 槽位 → 累加 crystals/insights/energy/contact/permBonus → randomFragment 处理(独立 fragments 副本避免重复) → 一次 set() + 一次 syncStats() → 汇总 toast `✦ 批量领取完成 · 共领取 N 项工程 · +X 晶体 · +Y 洞见 · ...`
- 无可领取项时直接 return(不弹 toast
- 历史批量追加(上限 50 条 slice)
- 子任务 3(完成 toast):
- 修改 `tickIdleProjects`:循环中检测 `!slot.completed && remainingSec <= 0` 状态转变瞬间,收集 `justCompletedProjects: IdleProjectDef[]`
- 循环外(仅当列表非空)调用一次 `toast({ title: "✦ 放置工程完成", description: "${icon} ${name} · ... · 可前往「放置」标签领取" })`
- 一次 tick 最多弹一条 toast(多个同时完成也只汇总为一条),避免 250ms 调用频率导致的 spam
- 子任务 4StatsPanel idle 统计):
- 新建 `src/components/game/StatsPanel.tsx`(独立组件,从 page.tsx 抽出)
- 用 `useShallow` 一次性订阅所有渲染所需字段(24 个字段),避免整个 store 变化触发 re-render
- 保留原有 25 行统计 + 新增 1 行 `放置工程历史`idleProjectHistory.length,最近 50 条跨周目累计)
- page.tsx 最小化改动:删除内联 StatsPanel 函数(55 行)+ 添加 `import { StatsPanel } from "@/components/game/StatsPanel"` + 清理 3 个仅在内联函数使用的 importsTECH_TREE, CONSTELLATION_PERKS, loadLeaderboard
- UI 增强(IdleOperationsPanel.tsx):
- Section 1「永久加成」StatTile 增加 `sub` 可选 propReactNode),当 `isSoftcapped = effective < raw` 时渲染 amber 色副行 `有效 +X/s(原始 +Y/s · 衰减中)`
- StatTile 显示 effectivePermBonus 而非原始值(用户看到的是实际生效的数值)
- Section 3 标题右侧改为 flex 容器:保留 `N / 3` 计数 + 当 `claimableCount > 0` 时显示 emerald 主题「✓ 领取全部」按钮(h-5 px-2 text-[10px]),点击调用 `claimAllIdleProjects`
- 严格 4 色全息:softcap 副行 amber、领取全部按钮 emerald,无蓝/靛
- 持久化兼容:persist version 仍为 v1migrate 函数未改,新 action 不需要 migrate
- 移动端适配:领取全部按钮高度 h-520px+ px-2 + text-[10px],与现有「N / 3」文字同行 flex 排列,375px 屏宽下不溢出;softcap 副行 text-[9px] leading-tight 在 StatTile 内部不撑破 grid-cols-3
Stage Summary:
- ✅ 子任务 1applyIdleSoftcap 纯函数 + recomputeStats 集成完成(30/100 阈值,0.5/0.25 系数)
- ✅ 子任务 2claimAllIdleProjects action + GameActions 接口签名 + UI 按钮(emerald 主题)完成
- ✅ 子任务 3tickIdleProjects 状态转变检测 + 单次 toast 通知完成(避免 250ms tick spam
- ✅ 子任务 4:StatsPanel 抽出为独立组件 + useShallow 订阅 + 新增「放置工程历史」行完成
- ✅ 修改文件清单:
- `/home/z/my-project/src/lib/game/engine.ts`(新增 applyIdleSoftcap 导出 + recomputeStats 改 1 行)
- `/home/z/my-project/src/store/gameStore.ts`GameActions 加 1 签名 + tickIdleProjects 改写 + 新增 claimAllIdleProjects action ~120 行)
- `/home/z/my-project/src/components/game/IdleOperationsPanel.tsx`(导入 applyIdleSoftcap + 订阅 claimAllIdleProjects + softcap 副行 + 领取全部按钮 + StatTile 扩展 sub prop
- `/home/z/my-project/src/components/game/StatsPanel.tsx`(新建,~110 行,useShallow 订阅 + 26 行统计)
- `/home/z/my-project/src/app/page.tsx`(最小化改动:删除内联 StatsPanel + 1 行 import + 清理 3 个未使用 imports
- ✅ lint 零错误零警告(`bun run lint`
- ✅ dev.log 全程 HTTP 200,编译 < 260ms,无运行时错误
- ✅ 持久化兼容:version 1 保留,新 action 无需 migrate
- ✅ 严格 4 色全息(emerald/rose/amber/fuchsia),无蓝/靛
- ✅ useShallow 全部 store 订阅使用
- ⚠️ 备注:page.tsx 是必要的最小化集成改动(删除内联 StatsPanel 函数 + 添加 import),不与子代理 B 冲突(B 改的是 types/config/idle/Codex
- ⚠️ 备注:在修复 idleTotalPerSec 时发现 v0.14 旧代码 `crystalsPerSec + idlePermanentBonus` 存在双重计算 bugcrystalsPerSec 已包含永久加成),v0.15 修正为 `idleTotalPerSec = crystalsPerSec`,UI 显示的「放置产能」现在是正确的总产能
---
Task ID: v0.15-release
Agent: 主控 Z.ai Code + subagent(2-a) + subagent(2-b)
Task: v0.15 叙事深化 + 放置平衡 + UX 增强 — loreLayers 多层碎片文本 + 永久加成 softcap + 批量领取 + 完成 toast
Work Log:
- 主控读取 v0.14 现状(worklog + idle.ts + types.ts + engine.ts + gameStore.ts + IdleOperationsPanel.tsx + Codex.tsx + config.ts FRAGMENTS
- 主控规划 v0.15 任务分解为 2 个并行子代理(修改文件严格不重叠):
- 2-a: engine.ts / gameStore.ts / IdleOperationsPanel.tsx / StatsPanel.tsx(新建)/ page.tsx(最小集成)
- 2-b: types.ts / config.ts / Codex.tsx
- 委派 subagent(2-a) full-stack-developerv0.15 平衡与 UX 增强(4 项子任务)
- 委派 subagent(2-b) full-stack-developerv0.15 叙事深化(loreLayers 多层文本)
- 两个子代理并行完成,零文件冲突
- 主控集成验证:
* bun run lint 零错误零警告
* dev.log HTTP 200,编译 < 260ms,无运行时错误
* agent-browser 端到端 smoke test 全流程通过:
- 默认放置 tab 渲染正常(修复 v0.14 双重计算 bugidleTotalPerSec 现在正确显示总产能)
- 派遣 idle_refine → eval 快进 finishesAt → tickIdleProjects 标记 completed=true ✓
- "✓ 领取全部"按钮出现 ✓ + 槽位显示"✓ 完成 · 永久 +0.3/s" ✓
- 点击"领取全部" → permBonus 0→0.3, projectsCompleted 1→2, slots 清空 ✓
- eval 设置 permBonus=50 → UI 显示"永久加成 +40/s" + amber 副行"有效 +40/s(原始 +50/s · 衰减中)" ✓
(softcap 公式验证: applyIdleSoftcap(50) = 30 + (50-30)*0.5 = 40 ✓)
- 图谱 tab: 8 碎片列表 + "深层叙事 0/16"统计 ✓
- eval 解锁全部 fragments → 点击"第一缕谐振" → L0 表层回响显示完整 echoemerald+ L1/L2 显示"???" + 🔒 提示 ✓
- eval 设置 ascensions=1 → L1"隐秘注脚"解锁,显示"档案的边缘有一行几乎被擦去的批注..." ✓
- eval 设置 totalDecoded=50 → L2"深层回响"解锁,显示"你终于明白:那缕谐振的频率..." ✓
- 深层叙事计数 0→2→6 随解锁条件递增 ✓
- 统计 tab: StatsPanel 显示"放置永久加成 +0.3/s / 完成放置工程 2 项 / 放置产出晶体 0 / 放置工程历史 2 条" ✓
- 主控更新版本号 v0.14 → v0.15page.tsx line 227
- 主控更新 README.md:版本行 / 设计哲学标题 / 版本里程碑表 / docs 引用 / deploy 命令
- 主控创建 docs/12-叙事深化-v0.15.md 设计文档
- 主控恢复测试存档(localStorage.clear + reload)确保 cron job QA 干净
Stage Summary:
## v0.15 叙事深化 + 放置平衡 + UX 增强已上线
### 核心交付(2 个子代理并行,8 个文件改动)
**子代理 2-a(平衡 + UX,5 文件):**
- ✅ P0 平衡:`applyIdleSoftcap(bonus)` 纯函数 — 分段衰减(≤30 全额 / 30-100 ×0.5 / >100 ×0.25),recomputeStats 调用,UI 显示"有效/原始"对比 + amber"衰减中"提示
- ✅ P1 批量领取:`claimAllIdleProjects()` action — 单次 set + syncStats,汇总 toast"✦ 批量领取完成 · 共领取 N 项工程"
- ✅ P1 完成 toast`tickIdleProjects` 检测状态转变瞬间(!completed → completed),单次 toast"✦ 放置工程完成"
- ✅ P2 统计:StatsPanel 独立组件(从 page.tsx 抽出),useShallow 订阅 24 字段,新增"放置工程历史"行
- ✅ 修复 v0.14 双重计算 bug:`idleTotalPerSec = crystalsPerSec + idlePermanentBonus``crystalsPerSec`(已含永久加成)
**子代理 2-b(叙事深化,3 文件):**
- ✅ 8 个第一纪元碎片全部添加 loreLayers(16 层深层文本,目标 ≥6 超额完成 8/8)
- ✅ 5 条叙事暗线交织:以太族被"追逐者"逼迫飞升 / "回响"AI 频段同源 / "信任"实为"引诱" / 飞升是单向门 / 接触满 80 回归的不是"他们"
- ✅ FragmentLoreLayer / FragmentLoreUnlock 类型扩展(5 种解锁条件:ascensions/totalDecoded/contact/fragmentCount/bossKills
- ✅ `isLoreLayerUnlocked()` 派生函数(纯前端计算,无新增持久化字段)
- ✅ Codex 分层渲染:L0 emerald 表层 / L1 amber 隐秘注脚 / L2 fuchsia 深层回响,未解锁层 blur-sm + 🔒 unlockHint + ▓░▒ 全息乱码
- ✅ 顶部"深层叙事 X/Y 已解锁"统计 + 列表卡片 amber/fuchsia glow 小圆点标记
### 验证结果
- ✅ bun run lint 零错误零警告
- ✅ HTTP 200dev.log 无运行时错误
- ✅ agent-browser 端到端 smoke test 全通过(派遣→快进→完成 toast→批量领取→softcap 展示→Codex 分层解锁)
- ✅ 严格 4 色全息(emerald/rose/amber/fuchsia),零蓝/靛
- ✅ 两个子代理修改文件零重叠,无冲突
### 未解决问题 / v0.16 优先项
1. 🟡 loreLayer "已读"标记未实现(需改 gameStore,会与未来 idle 改动边界冲突,本轮未做)
2. 🟡 解码阈值释放叙事碎片 + 星潮三频段切换 —— v0.16 计划
3. 🟡 健康设计:每日软上限 + "星核建议休眠"提醒 —— v0.17 计划
4. 🟡 移动端 375px 实测(Codex 分层弹层在小屏的可读性)
5. 🟡 idleProjectHistory 可在 StatsPanel 展开查看最近 5 条详情