# longevity-world-text
**Repository Path**: luckSnow/longevity-world-text
## Basic Information
- **Project Name**: longevity-world-text
- **Description**: No description available
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: main
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2026-10-25
- **Last Updated**: 2026-10-28
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# 《长生界》文字对话游戏
辰东《长生界》改编的**剧情驱动型文字角色扮演**:玩家扮演萧晨,按原著顺序推进剧情;
只在关键节点做选择;全程纯文本 + 场景美术 + 立绘 + 环境音 + 十档存档。
- 引擎:Node.js(终端版) + 浏览器版(呈现层与终端版同构)
- 零运行时依赖,无框架;美术为程序化 SVG + 官方立绘
- 全书规模:辰东《长生界》683 章(原文 1–688 章中缺 5 章:110/235/398/399/426)
- 分层加载:启动器 127KB + 35 个章节分片 + 事实库按 topic 分文件 + 媒体外置
### 内容规模(实测)
| 指标 | 数值 |
|---|---|
| 章节 | 683(9 卷) |
| 场景 | 2417 |
| 关键选择点 | 887(1.30/章,上限 2) |
| 选项 | 2553(无失败态,**通用兜底模板 0 个**) |
| 旁白行 | 9772(0 行超 60 字,每屏 3 行不滚动,**截断半句 0 条**) |
| 情报(事实) | 6334(island / self / foes / world 四类),每条带原文 evidence |
| 角色 | 483(引用全部登记,立绘映射齐备,程序化 0) |
| 立绘 | 107 张不同头像(54 专属 + 2 复用 + 51 池图登记);前 50 全部专属,**50 张独立图** |
| BGM | 25 首真实音频;每卷固定一首日常曲 + 一首战斗曲(战斗结束切回),重要大战/大结局用专属曲 |
| 配音 | 1261 条角色台词(hoyoTTS 251 说话人),14.9 MB opus,内容可用率 99.2% |
> **本 README 的「制作流程手册」章节是可直接复用到下一个游戏的完整方法论**,
> 每个阶段都写明了:目标 → 产出 → 命令 → 闸门 → 实战坑。
---
## 快速开始
```bash
# 1) 终端版(逻辑基准,headless 可测)
node src/cli.js
# 2) 浏览器版(分片按需加载)
node scripts/serve.js --root dist # → http://127.0.0.1:8080/
# 3) 构建(启动器 + 35 分片 + 9 个分卷自包含页)
node scripts/build-shards.js # 章节 → 分片 + manifest
node scripts/build-web.js # → dist/index.html(卷目录,约 127KB)
node scripts/pack-volumes.js # → dist/vol-01.html … vol-09.html(双击即玩)
# 4) 闸门与回归(全部退 0 才允许提交)
node scripts/check-chapters.js # 数据闸门(19 项)
node scripts/check-media.js # 发布包媒体路径闸门(禁止 ../ 跳出发布目录)
node scripts/check-top50.js # 前 50 角色立绘审计(专属 / 不共用 / 尺寸)
node scripts/test-e2e.js # 683 章自动通关
node scripts/test-loader.js # loader 单测 12 项
node scripts/audit-book.js # 全书机械审校
# 5) 打包(dist/* 压到 zip 根目录,assets/ 与 data/ 与页面同级)
Compress-Archive -Path "dist/*" -DestinationPath "changshengjie-text-rpg-fullbook.zip" -Force
```
**双击即玩**:`dist/index.html` 是**游戏首页**(继续上次 / 读取存档 / 从头开始 / 九卷目录),
选一卷进 `vol-XX.html`;每个分卷页自包含该卷全部数据,`file://` 下也能直接打开
(`file://` 无法 fetch 本地 JSON,所以每卷内联自己的章节)。
**操作**:回车 / 空格 = 翻页继续 · 数字 = 在关键节点选项 · `F` 已知情报 · `S` 存档 · `M` 音乐
**对话形态**:微信式回话——底部固定一个对话框 + 人物立绘,**一次只有一个人在说话**,
点"继续"换下一个人;一句话超一屏时自动翻到下一屏(不会滚动、不会被裁字)。
详见「阶段 4.6」。
**手机验证**:`docs/mobile-preview.html` 是 390×844 的竖屏模拟(iframe 套真页面),
改完交互先在这里点一遍再出包。
**调试直达**:`dist/index.html?ch=ch008&scene=s2`
**配音**:1261 条角色语音在**构建期**用 [hoyoTTS](#voice) 预生成(`assets/voice/*.opus`),
运行时零合成、零联网;HUD 上的「配音」chip 可开关。安装位置与完整用法见「阶段 7.5」。
---
# 制作流程手册(可复用到下一个游戏)
> 这套流程的产线顺序是:**原料 → 量产 → 归并 → 形态 → 美术 → 立绘 → 音乐 → 配音 → 构建 → 闸门 → 发布**。
> 核心哲学只有一句话:**机械能做的全部交给脚本,只把"需要创造力的那一小段"交给模型。**
## 阶段 0 · 立项与设计规格
**目标**:把交互形态定死,写下来,后面所有实现都按它来。
| 决策点 | 本项目取值 | 为什么 |
|---|---|---|
| 类型 | 剧情驱动型文字 RPG(互动小说 × 视觉小说) | 线性、无数值、无战斗操作 |
| 交互 | **纯选项式,不接运行时 LLM** | 玩家不与模型对话;全部对话由预设选项构成 |
| 选择密度 | 只在关键节点给选择,≤2/章 | 每页都给选择会把"阅读"变成"点击" |
| 张力 | **选项无失败态** | 差异只体现在"多知道什么、多看到什么" |
| 真相源 | 事实库唯一;章节只能引用已定义的 fact id | 避免章节之间自相矛盾 |
| 忠实度 | 不动原著顺序;人物出场、事件因果与原文一致 | 改编不是重写 |
**产出**:
- `docs/specs/<日期>-<作品>-design.md` — 设计规格(玩法、界面、数据 schema、验收标准)
- `docs/bible/*.md` — 设定圣经(事实命名规范 / 章节 schema / 世界观 / 力量体系 / 角色 / 变更记录 / 排产台账)
**坑**:规格不写下来,后面每个人(和每个 agent)都会自己发明一套。**先把"不做什么"也写清楚**
(不接运行时 LLM、不做战斗数值、不做自由对话)。
## 阶段 1 · 原料层(原文 → 章节原料)
**目标**:把长篇原著切成机器可用的章节原料,并排一张生产台账。
```bash
node scripts/parse-novel.js # 原文 txt → 每章 { 正文行, 对白片段, 说话人 }
node scripts/build-chapter-map.js # 原料 → 排产台账 docs/bible/chapter-map.csv
```
**产出**:`data/novel/chNNN.json`
**规则**:
1. **原文与原料都不入库**(`data/novel/` 在 `.gitignore`)——含受版权保护的原著全文,只在本地保留
2. 原文缺章要**显式登记**(`manifest.missing`),不要假装齐全:本项目缺 110/235/398/399/426
3. 排产台账(csv)记录每章的章号、标题、字数、场景数、选择点数、状态——量产时按它盯进度
## 阶段 2 · 量产生成(最关键的一次决策)
**目标**:把 683 章原文批量变成可玩的章节。
### 混合路径(默认,必须用)
把一章拆成两层,**只有需要创造力的部分交给 LLM**:
| 层 | 做法 | 成本 | 质量 |
|---|---|---|---|
| 场景切分 | 脚本:原文按语义段均匀切 3–4 段,每段按"信息量"挑 4 行 | 0 token | 忠实原文,行长裁到 ≤40 字保证单屏不滚动 |
| art 场景图 | 脚本:关键词 → 母题/时段/情绪 | 0 token | 与场景内容吻合,32 母题全覆盖 |
| 事实 | 脚本:挑出的原文行即事实(text 与 evidence 同为原文句) | 0 token | 天然可被"evidence 必须命中原文"的闸门校验 |
| **选项与情报** | **LLM**:读场景摘要,写 1 个关键选择点(2–3 选项)+ 4–6 条事实 + 新角色 | ~3k token / ~20s | 玩家真正看到的文字,质量最高的部分 |
```bash
node scripts/gen-chapter.js --mech --range 1-100 --concurrency 16
```
实测:**19.3 秒/章**,并发 16 时 6–18 章/分,全书 40–100 分钟。
### 为什么不能整章交给 LLM(血泪)
实测把整章小说交给 LLM 改写:
```
usage: { input_tokens: 1968, output_tokens: 12000, thinking_tokens: 11995 }, stop_reason: max_tokens
```
**模型的思考预算吃掉了全部输出额度**,正文被完全挤空。按这个成本,683 章需要 ≈ 11 小时,
且约 30% 的章节因"只输出思考"直接失败。压缩 prompt 也无效。
> **通用教训**:凡是要产出大段内容的任务,先估算 thinking 预算,把"创作"部分缩到最小。
> 机械能做的都交给脚本。详见 `docs/bible/generation-strategy.md`。
### 量产纪律
1. **LLM 失败不阻塞**:调用失败就降级产出(机械切分 + 通用选项),全书不缺章
2. **并发不要贪高**:16 起步,观察限流再调
3. **草稿层不入库**(`data/drafts/` 在 `.gitignore`)——待人工审校的中间产物
4. **改写规则只改 `scripts/prompts/` 里的模板**,不要散落在代码里
## 阶段 3 · 归并(草稿 → 正式章节)
**目标**:把散落的草稿合成一本自洽的书:事实入库、角色建卡、id 冲突消解。
```bash
node scripts/merge-drafts.js # 事实按 topic 入库、角色入角色卡、id 冲突改名并改写引用
node scripts/fix-characters.js # 本地归一 + 人工别名表 + LLM 兜底判定
```
**产出**:`data/chapters/chNNN.json`(单章文件,可 diff、可审校)+ `data/world/characters.json`
### 这一阶段的四个坑
| 坑 | 现象 | 处置 |
|---|---|---|
| **改名链 O(n²)** | 逐条改名导致改名链越来越长,最后一条要改上千处 | **从基线重建 + 幂等改名**,不要增量改 |
| **数组元素没被改写** | `npcs: ["xiao_shou"]` 这种字符串数组里的旧 id 没换成新 id | 改写函数必须**直接替换数组元素**,不能只递归对象属性 |
| **同一角色多个 id** | 同一张脸被拆成多张不同的立绘(见下) | 别名表统一 + 立绘层共用 |
| **臆造角色** | 生成阶段把普通词当成角色名(例:ch209「灭杀你这个小东西」是虎奴骂人的口语,被当成角色"小东西") | 回原著核对,把无出处角色合并/重命名 |
### 「同一角色多 id」是最大的坑
本项目实测:**珂珂 / 雪白小兽 / 小兽 / 小东西是同一只兽**
(ch045 首次出现时叫"小东西",ch052 萧晨给它起名"珂珂"),却被拆成 4 个角色卡,
白占 Top 50 的 3 席,还各自配了不同的脸。
**处置**:
1. 回原著逐个核对角色首次出场与命名
2. 在 `data/design/char-aliases.manual.json` 里登记别名(确定性,不依赖 LLM)
3. 立绘层让同一角色共用同一张图,并在审计脚本里用**白名单**放行
4. 重要度排行要**基于合并后的角色**重排
结论已写进 `docs/bible/characters.md`。
## 阶段 4 · 形态与美术
**目标**:把内容套进"可读"的形态——少量选择 + 分页旁白 + 场景图。
```bash
node scripts/pick-key-choices.js # 汇总关键选择点 → data/design/key-choices.json(设计资产)
node scripts/apply-flow.js # 套用"少量关键选择 + 旁白分页"形态
node scripts/seed-art.js # 场景 art 打标(缺失时按关键词补)
```
**基准值**(下一个项目按此调整):
| 项 | 基准 | 上限 |
|---|---|---|
| 场景 / 章 | 3.3 | — |
| 关键选择点 / 章 | 1.4 | **2** |
| 选项 / 选择点 | 2.7 | — |
| 旁白每屏行数 | 3 | **单屏永不滚动** |
| 旁白行字数 | ≤40 | 0 行超 60 字 |
**美术**:程序化 SVG 场景图(`web/art.js`,32 母题 × 6 时段 × 5 情绪 × 8 特效)。
母题词表与自动打标规则在 `scripts/art-rules.js`,扩母题 = 加词表 + 加剪影函数。
### 阶段 4.5 · 量产后的两道体检(必做)
量产是**机械优先**的,"一定有"但保证不了"好不好"。这两道后处理把系统性缺陷修掉,**都不用重跑全量 LLM**:
```bash
node scripts/fix-narration.js # 旁白/事实补全
node scripts/fix-fallback-options.js # 重写通用兜底选项(要调 LLM,按章批量)
node scripts/fix-missing-evidence.js # 补齐事实缺失的 evidence(人工校订表,零 token)
```
| 脚本 | 修什么 | 本项目实测效果 |
|---|---|---|
| `fix-narration.js` | 机械切分按 40 字上限裁行,全文 **32.9%** 的旁白成了"半句 + 省略号"。消歧句常被切掉,读者会觉得自相矛盾(「逃到这里的人不过十六名,还包括了秦广王与轮回王它们三个,…」—— 后半句「只算纯粹的人不过十三人」被切了);事实库的 text/evidence 同样是半句 | 回到原文定位并补成完整句(≤60 字)→ 截断率 **32.9% → 0%**,事实字段补全 1724 个,出场角色 222 → **356** |
| `fix-fallback-options.js` | 机械层的通用选项模板("握紧兵刃,静待其变")占 **33.3%**,集中在 V6–V9;更糟的是它们 learn 指向本章前几幕**已经自动学过**的 fact,且三个选项 learn 同一条 → 选哪个都一样,"多知道什么"完全失效 | 按章批量调 LLM 重写为贴合情境的选项,每个选项配一条**独有新事实**(evidence 逐字取自本章原文)→ 兜底选项 **876 → 0**,有回话的选择 790 → **1235**,情报 3117 → **3555** |
| `fix-missing-evidence.js` | 闸门第 17 项(evidence 必须是原文原句)对**空 evidence** 只降级为告警——脚本没法凭空判断一条事实该由哪句原文支撑 | 人工回原著核对,**16 条补齐,闸门告警归零**;顺手查出 **2 条事实描述与原文矛盾**(见下) |
**通用教训**:机械层保证下限,**体检决定上限**。
脚本统计能告诉你"32.9% 的行被截断",但只有**真的读一遍**才会发现"它们三个,…"读不通。
> **后处理脚本必须幂等**:补全后的文本如果仍以「…」结尾,下次跑会被当成截断行再处理,
> 切点跟着变长而漂移 → 结果不稳定。所以降级补全**不再以省略号结尾**。
### 证据要用「行号」定位,不要手抄原文
`fix-missing-evidence.js` 的校订表写的是**原文行号**,不是抄好的句子:
```js
f_wolf_speed: { lines: [14] }, // ch017 第 14 行
f_buddha_ch304: { text: '佛陀在禁忌之海上认出无头神秀,以佛光出手相迎', lines: [92, 93, 94] },
```
理由:手抄原文必然有漏字/错字,而证据要求**逐字命中**;行号可复核、可 diff,
证据永远等于该行的原样。脚本跑之前还会拿原文自校验一遍,对不上就报错退出。
### 「补 evidence」时最容易顺手改出来的两类错
1. **事实描述与原文矛盾**(本项目实测 2 例)
- `f_jin_san_yi_disguised` 原写「金三亿乔装设局,**真实身份被识破**」,
但原文明写萧晨「**都未能识破**」——暴露身份的是金三亿自己开口自报家门。
- `f_buddha_ch304` 原写「佛陀发现**萧晨**在此,随即以佛光出手」,
但原文明写佛光冲的是**无头的神秀**,跟萧晨无关。
→ 补证据时必须**连着描述一起核**,只补不核会把错误固化成"有据可依"。
2. **证据要连续**:闸门做的是「evidence 归一化后是原文的子串」判定,
跨段落拼接两句话会判定失败。
## 阶段 4.6 · 对话形态(微信式回话)
**问题**:早期形态把"所有说话人 + 旁白 + 选项"堆在同一页里,越到后期越像一份剧本,
读者没有"有人在跟我说话"的感觉。
**改法**(`web/app.js` + `web/style.css`):
| 项 | 做法 |
|---|---|
| 一次一个人 | `state.speech = {list, idx, pages, page}`,一次只渲染 `list[idx]` 那位说话人 |
| 底部对话框 | 固定在底部:`
` = 立绘 + 气泡(微信样式,`4px 16px 16px 16px` 圆角 + 指向立绘的小尾巴) |
| 点击换人 | "继续"的优先级:**回话分页 → 换下一个说话人 → 翻旁白页 → 出选项 → 换场景** |
| 超一屏分页 | `paginateText()`:先按句读边界(`。!?…;`)切,单句超长再硬切;`speechPageChars()` 按气泡实际宽高估容量 |
| 立绘尺寸 | 桌面 96×126,手机 88×116(窄屏下再大就挤掉气泡宽度了) |
### 这一形态的四个坑
1. **屏号文案反了**:第一屏就显示"第 2 / 2 屏",读者以为已经到最后。
应该显示**当前**屏号:`继续 ▶(第 1 / 2 屏)`。
2. **容量估算必须保守**:每行字数按"视口 − 对话框内边距 − 立绘 − 间距 − 气泡内边距"算,
行数**再少算一行**——估多了字会被 `overflow:hidden` **静默裁掉**,玩家根本发现不了。
3. **换章必须归零 `state.page` 与 `state.speech`**:`loadChapter()` 原来没清。
玩家在第 2 屏点章节列表跳走,新章节会**跳过前 3 行旁白**,上一段的气泡也会残留在新场景里。
同一个 bug 也出在 `goNext()` / `advance()` 上——抽一个 `resetSpeech()` 统一收口。
4. **提示文案别只认 `pointer: coarse`**:不少安卓设备同时接着键鼠,`pointer` 仍报 `fine`,
于是手机上满屏"或按 空格 / 回车"。**窄屏(<640px)一律用点按文案**。
> 通用教训:**改完交互一定要拿手机尺寸实机走一遍**(`docs/mobile-preview.html` 是 390×844 的 iframe 模拟)。
> 上面 4 个坑里有 3 个是脚本和闸门查不出来的,只有真的点一遍才会暴露。
## 阶段 5 · 事实库与情报
**目标**:把"玩家已知的情报"做成可累积、可校验的库。
```bash
node scripts/split-facts.js # 事实库 → 按 topic 拆 4 个文件 + 索引
```
**规则**:
1. **单一真相源**:章节只能引用已定义的 fact id,闸门第 6 项硬拦
2. **evidence 必须是原文原句**:脚本自动补齐(`autoEvidence`)+ 命中校验(`evidenceMatch`,4-gram ≥70%)
3. 事实按 topic 拆分文件(island / self / foes / world),运行时惰性加载
4. 情报面板是**纯增益**机制:选项 learn 的事实越多,后面能看到的内容越多
## 阶段 6 · 立绘(最容易出事故的一环)
**目标**:让每个重要角色有一张**专属、干净、特征相符**的立绘。
### 抓取
```bash
node scripts/fetch-top50-portraits3.js --per 5 # 第三轮补抓
node scripts/fetch-portrait-one.js --id=xxx --name=xxx \
--queries="检索词1;检索词2" --keys="必须命中1,必须命中2" --deny="不要的角色名" --per=6 --start=50
node scripts/build-cand-sheet.js
# 候选对照页(人工选片)
node scripts/apply-portraits.js # 应用选片表 PICKS + 池子分配
node scripts/build-top50-sheet.js # 前 50 总览页(人工复核)
node scripts/check-top50.js # 审计闸门
```
`fetch-portrait-one.js` 是**补单个角色**的通用入口(不用改代码就能换检索词):
- `--queries` 用分号分隔多条检索词——**百度对同一批词会饱和,换词才出得来新图**
- `--keys` 标题必须命中(防张冠李戴);`--deny` 标题命中就丢(用来排除"这是某某角色")
- `--start` 决定候选编号,**不要覆盖已有编号**:`apply-portraits.js` 的 `PICKS` 按编号选片,
而且 `recrop()` 是从 `raw/-.img` 重裁的——覆盖了旧编号,旧图就再也回不来了
| 素材 | 拉取渠道 | 已验证结论 |
|---|---|---|
| 角色立绘 | **百度图片 JSON 接口**(`image.baidu.com/search/acjson`) | **Node 里直连可用**,要带 `Referer: https://image.baidu.com/` 和 `Accept-Language: zh-CN` |
| 同 IP 官方图 | 腾讯视频 / 百度百科 CDN | 百度百科页面 **Node 直连 403**(安全验证),要走浏览器 |
| 公共立绘 | 同画风的其他动漫(遮天 / 完美世界,均玄机动画作品) | Bing **Node 抓取会被代理降级**(标题命中 0),走浏览器 |
### 三个抓取陷阱
1. **`objURL` 往往是百度编码串不是 URL**——用 `middleURL` / `hoverURL` / `thumbURL`
2. **标题过滤能大幅降张冠李戴**:用 `fromPageTitleEnc` 过滤"标题命中角色名"的结果
3. **抓取会覆盖同名候选**——旧候选索引被占用后,要么先备份,要么把新原图按约定命名同步
(`scripts/sync-r3-raw.js`),否则 `recrop` 会拿旧原图覆盖掉你刚选的新图
### 补图的第四个陷阱:**用错角色的图,比用公共图更糟**
主角级检索词("遮天 叶凡 立绘")会大量返回**别的作品的具名角色**。
把它安到本角色身上,就是张冠李戴——玩家一眼就看出来。
处置顺序(**宁可风格统一的公共头像,也不用张冠李戴的错图**):
1. 先找**泛用素材库**(标题是"人物素材丨男""古风立绘男0xxxx"这类,不是某个 IP 的角色)
2. 泛用素材没有合适的,再退到**同画风的公共池图**
3. 公共池也没有 → 才考虑程序化占位(游戏内硬闸门禁止,只能留在代码兜底层)
本项目实测:王子风的专属图是一张带满设定文字的插画,直接当头像很出戏;
改用泛用古风素材(白袍持剑青年)后,既无文字、无水印,又与遮天/玄机系角色同源。
### 裁切
`scripts/apply-portraits.js` 的 `cropBox` 支持:
| 模式 | 作用 |
|---|---|
| `auto` | 居中偏上(默认) |
| `top` | 切掉底部字幕带 |
| `bottom` | 切掉顶部字幕 |
| `head` | 只取上 55% 再切 3:4——**去烧录字幕最有效** |
| `left` / `right` | 横向贴边,去掉侧边的竖排字 |
| `fx` | 水平焦点 0=左 0.5=居中 1=右(人脸不在正中时用来摆正) |
### 立绘硬约束(闸门硬拦,不是靠自觉)
1. **游戏内 0 张程序化立绘**——程序化占位只保留为代码兜底,任何角色都不得引用
2. **重要性前 50 角色每人一张专属立绘**,不得使用公共池头像
3. **不同角色不得共用同一张图**(同一角色的不同 id 走白名单)
名单:`data/design/top50.json`,排序脚本 `scripts/rank-characters.js`
(回话 2.0 / 出场 1.6 / 涉及章 1.8 / 称号 2.2 / 早晚 2.4)。
### 立绘铁律
1. **画风优先级 = 同 IP > 同动画公司 > 同题材**
2. **宁可风格统一的公共头像,也不用张冠李戴的错图**
3. **所有素材留来源直链**(`assets/portraits/sources.json`),便于追溯与替换
4. **不预先固化素材假设**——需要时实时拉取,不在任务开始时批量预拉
5. **脚本查不出烧录字幕、性别错配、非人物照**——换完图必须**逐张看**总览页
(`docs/portraits-top50-final.html`,相对路径,工作区内直接打开)
6. **改图只改 `scripts/apply-portraits.js` 的 `PICKS` 表**,不要散改产物
> 这一阶段本项目实测揪出 16 处硬伤:建筑栏杆照当人物、漫画书封当立绘、
> 男性角色配女性立绘、人角色配骷髅头、10+ 张带视频解说字幕、3D 卡通风与整体动漫风割裂。
> **所有这些,脚本一条都没查出来。**
## 阶段 7 · 环境音(BGM)
```bash
node scripts/fetch-bgm.js --list "遮天 特别篇 插曲" # 搜候选
node scripts/fetch-bgm.js --pick [--p=分P] # 抓一首
node scripts/bgm-library.js # 登记进 data/world/bgm.json 的曲库
node scripts/bgm-analyze.js # 音频特征分析(定风格用)
node scripts/assign-bgm.js # 按卷 + 战斗场景分配
```
**处理链**:B 站搜索 → `view` 取 cid → DASH `playurl` 取音频流 → ffmpeg 截取 →
**首尾 4 秒交叉淡化**(无缝循环)→ `loudnorm` 响度归一 → 128k mp3。
无网络 / 文件缺失时用 `web/audio.js` 的合成音兜底。
### 最大的坑:**抓了 BGM,但从来没在游戏里播过**
用户反馈"前 2 卷的 BGM 好像都是一个,而且太沉闷"。查下来是两个问题叠在一起:
1. **`setLibrary()` 从来没被调用过** → `fileMap` 为空 → `useFile()` 永远返回 false
→ 一路退回**程序化合成音**(几个振荡器 + 噪声 + 脉冲)。
合成音本来就是设计成"永不重复的 6 类预设",听起来单调发闷完全正常。
**5 首 mp3 抓了 7MB 进包,却一帧都没播过。**
2. **分配算法退化**:`pref.find(t => t !== prev)` 在只有 2 首曲子的情绪族里会**严格交替**,
而 calm 占了 563 章(82%),族里却只有 2 首 → 听感就是"一直那两首"。
修法:
| 环节 | 做法 |
|---|---|
| 装载 | `pack-volumes.js` 把 BGM 库内联进每个分卷页(`window.__CSJ_BGM_LIB__`:曲库 + **本卷**的 byChapter/byScene);`app.js` 在 boot 里 `GameAudio.setLibrary()` |
| 选曲 | `audio.js` 的 `setScene(scene, chapterId, sceneId)` 按 **场景 → 章节 → mood** 取曲,`byScene` 命中就用它 |
| 换曲 | 交叉淡化(淡出 → 换源 → 淡入),硬切很刺耳 |
| 预加载 | `setLibrary` 时就建好 `Audio` 元素并 preload 第一首,用户点「♪ 音」立刻出声 |
| 分配 | 族内**最少使用优先**(LRU)+ 最小复用间隔 + 相邻不同曲;**bleak 不再配抒情曲** |
> **验证方式**:浏览器 Network 面板看 `assets/bgm/*.mp3` 有没有真的 GET 成功。
> 修之前是**零 media 请求** —— 这个信号一眼就能看出"音乐没在播"。
### 切曲规则(第三轮:整卷固定 + 战斗切换)
| 诉求 | 实现 |
|---|---|
| 「整个一卷就使用一个 BGM」 | **每卷固定一首日常曲**:`assign-bgm.js` 的 `VOLUME_BGM` 表,`byChapter` 整卷同一个 slug,章与章之间不换 |
| 「战斗场景切到战斗专用」 | 场景 mood 为 `tense`/`dread` 算战斗场景 → 切到**本卷固定的战斗曲**(不再每场战斗换新曲) |
| 「战斗结束后切回原有 BGM」 | 离开战斗场景(下一个非 tense/dread 场景)立即切回本卷日常曲;场景内锁定,播完循环 |
| 「重要及悲壮的大战要有专用 BGM」 | `EPIC` 表人工指定(原著节点 + 章标题 + 战斗密度三方核对),整章专属,优先级高于卷曲 |
| 「最终决战 / 大结局」 | ch686/ch687 各用专属战斗曲;ch688 大结局整章《祖神谣》 |
**每卷两首**(`VOLUME_BGM`):
| 卷 | 日常曲 | 战斗曲 |
|---|---|---|
| V1 神岛篇 | `xxd-a` 古风清幽 | `zhetian-battle` 激战 |
| V2 九州·仙古篇 | `perfectworld-tianshan` 开阔山河 | `perfectworld-slay` 杀伐 |
| V3 半祖·祖神篇 | `xxd-b` 史诗推进 | `perfectworld-overturn` 倾覆 |
| V4 人间界·九位半祖 | `pw-moon` 静谧沉思 | `pw-chengbai` 激昂 |
| V5 战祖·至人殒落 | `pw-zhaozhao` 离别沉静 | `perfectworld-sinblood` 悲壮 |
| V6 决战半祖·祖神对决 | `pw-xuyu` 空灵缥缈 | `zhexing-duyi` 独行战意 |
| V7 撤离·神图源起 | `zhetian-insert` 离别抒情 | `zhexing-ka` 孤勇 |
| V8 太古诅咒·三皇五帝 | `pw-wanhun` 清冷诡异 | `perfectworld-arcane` 阴森压迫 |
| V9 终章 | `csj-theme` 主题曲 | `perfectworld-dawn` 破晓决战 |
实测全书 **438 次换曲 / 2417 个场景 = 平均 5.5 个场景一次**,且全部发生在「进出战斗」或 EPIC 章边界;
**章与章之间(同卷日常段)零换曲**。早前版本每章换一首(683 章 683 次切换),是「切得太频繁」的主因。
**EPIC 章**(整章专属):ch010 / ch026 / ch049 / ch322 / ch347 / ch373 / ch381 / ch396 / ch471 /
ch478 / ch508 / ch545 / ch647 / ch671 / ch686 / ch687 / ch688。以 `scripts/assign-bgm.js` 的 `EPIC` 表为准。
> **`art.mood` 不能当剧情强度用**:它是**场景美术氛围**(决定配色),
> 所以 V9 终章 28 章里 mood 几乎全是 calm,但那里恰恰是最终决战。
> 重要节点必须靠 `EPIC` 表人工指定,不能只靠 mood 自动判。
### 曲库与风格判定
`scripts/bgm-analyze.js` 用客观音频特征代替"试听":
| 指标 | 含义 |
|---|---|
| `crest` 峰均比 | 越大 = 动态越大 = 激昂 / 史诗 |
| `low` 低频占比 | 越大 = 越沉 = 战斗 / 鼓点重 |
| `high` 高频占比 | 越大 = 越亮 = 轻快 / 清澈 |
本项目实测:`perfectworld-vista` 的 crest 只有 13.8(最低档)、low 最高、high 偏低 ——
**又平又闷**,而它原来独占 303 章(44%)。这就是"沉闷"的元凶。
曲库从 5 首扩到 **25 首**:
- 《完美世界》动画音乐合集 7 个分 P(月光/风骨/顽魂/成败/我不存在的世界/须臾/朝朝如念)
- 《完美世界》歌曲合集 9 个分 P(斩十万阎罗/战魄/倾覆/星火黎明/罪血之名/火之灰烬/天下青山/荒/破天)
- 《仙侠道》原声大碟 2 段 · 遮天《逆行的路》《獨我一念》《一夜长》· 遮天预告《激战》
- 完美世界《荒天帝专属》· 长生界片头曲《世上谁人能不死?》· 治愈系风景纯音乐 · **《祖神谣》**
分配后:**每卷固定一首日常曲 + 一首战斗曲**(战斗结束切回日常),
全书 438 次换曲(全部发生在进出战斗或 EPIC 章边界);重要大战由 `EPIC` 表单独指定。
> 坑:`fetch-bgm.js` 的 `start` 参数原来**根本没被用到**(`atrim` 恒从 0 开始),
> 抓合集类视频时每首都会截到同一段人声。现在 `atrim=${start}:${start+d}`,
> 并且支持 `--p=分P` 抓合集里的具体某首(合集每首曲子是独立分 P,cid 各不相同)。
>
> 坑 2:`--p=N` 是 **1-based**(`pages[p-1]`),而看分 P 列表时习惯按 0-based 数 ——
> 一手抓了 5 首全对不上曲名(内容对、名字错位)。抓完要加一道**重命名步骤**把 slug
> 和实际曲名对齐,曲库登记的 `use` 描述也必须跟真实曲名一致。
### 目标机型的分辨率适配(2700×1224)
用户的安卓机是 **1224×2700(DPR 3 → CSS 视口 408×900)**。
用 `tmp/sim-2700.html`(408×900 的 iframe 模拟)逐屏走了一遍,改了 5 处:
| 问题 | 修法 |
|---|---|
| HUD 单行在 408px 下被挤爆:标题只剩「不死…」,最后一个按钮离右边缘只剩 15px | **窄屏改两行**:标题一行、按钮一行 |
| 竖屏刘海/药丸在顶部中间,会压住「情报 / 存档」 | 顶部安全区取 `max(env(safe-area-inset-top), 30px)`;`app.js` 用探针量出真实 inset 写进 `--safe-top-probe`(**部分安卓 ROM 的 WebView 不给 env()**) |
| 侧栏面板同时打开会叠在一起(章节面板 z-30 压住存档面板 z-10,下层按钮点不到) | 三个侧栏**互斥**:开一个就关掉其余两个 |
| 九卷卡片在 3 列下只有 ~107px 宽,「通关《九州·仙古篇》解锁」折成三行、九张卡高低不齐 | 卡面只留「🔒 未解锁」,解锁条件放 title + 点击提示 |
| 首页在 408px 下:标题折两行、三个入口折两行、九卷单列要滚很久 | 标题 25px 一行放下、入口 flex 均分一行、九卷改 2 列 |
> **CSS 顺序坑**:文件中部已有一个 `@media (max-width:640px)` 里写着 `#hud { padding: 10px 14px; }`,
> 同权重下**后写的赢**。把新规则放在它前面 → 整个 padding 声明失效 → HUD 贴到屏幕顶、标题被刘海吃掉。
> 窄屏覆盖规则一律追加到 **style.css 末尾**。
改完之后 408×900 下:旁白 3 行不溢出、3 个选项文字完整不换行、微信气泡与立绘等高、
章节/存档面板 10 槽与九卷卡都在一屏内。
## 阶段 7.5 · 语音配音(hoyoTTS)
**目标**:把 1261 条角色台词在**构建期**预生成成音频文件,运行时零合成、零联网。
用的是 [hoyoTTS](https://modelscope.cn/models/Genius-Society/hoyoTTS)(魔搭上的 Bert-VITS2 多说话人模型,
**251 个说话人**、44.1kHz)。选它的唯一理由:**能一角色一专属音色** —— Edge-TTS 只有 6 个中文音色,
251 个角色只能共用 6 套声音。
| 指标 | 数值 |
|---|---|
| 台词总数 | 1261 条 / 18627 字(只配对白与角色动作描写,场景叙述文字不配音) |
| 台词分流 | role 1183(对白 1180 + 动作 3)· narrative 14(回忆/独白 + 动作 1)· nonhuman 64(兽吼/骨响等非人声) |
| 角色 → 音色 | 251 角色 → **130 个音色**;单音色最多复用 10 次(「博士」/ 钟离);51 个角色按审计表人工纠正 |
| 引擎分布 | hoyo 1164 · hoyo_recovered 11 · edge 85 · hoyo_split 1 |
| 成品 | `assets/voice/*.opus` **14.9 MB**(平均 12 KB/条;Ogg Opus 48kHz / 32kbps / mono) |
| 内容验收 | 1242 条判定条目 **99.2% 达标**(1232 条),19 条拟声豁免 |
| 耗时 | 全量 hoyoTTS 约 78–90 分钟(含闸门);Edge-TTS 兜底 44 分钟 |
### 全角色声线审计(2026-10)
赵琳儿(女角色被旁白男声)修完后,对全部 251 个有声线的角色做了一轮审计:
角色卡里的「性别 / 年龄 / 身份」逐个对照「实际映射的 hoyoTTS 说话人」,再用音频基频做客观校验
(男声通常 < 155Hz,女声 > 175Hz;同时修正了 `重云` / `卡维` / `彦卿` / `米卡` / `托克` / `辛焱` 的说话人元信息,补上 `刃`)。
审计发现并重配 **51 个角色 / 399 条台词**(47 个高置信度问题 + 4 个单句老年角色:几位白发老古董、老僧甲、守候老人、老灵士):
| 类别 | 角色 |
|---|---|
| 男角色用了女声 | 吴明、校尉、绝刀、佛陀、火焰皇 |
| 女角色用了男声 | 夜叉天后 |
| 女角色被男旁白 | 燕倾城(46 条)、兰诺、若水、堕落天使 |
| 年龄 / 身份不符 | 珂珂(小兽→童声)、老妪(少女→老妇)、独孤剑魔、清清、中年修真者、济公、逆龙王、修罗王、修罗天王、夜叉半祖、黄钻骷髅王、七彩骷髅皇、老翁、叶老爷子、老鬼、天魔宫妖女、诸葛胖子、中年人 |
| 低沉 / 威严角色用了高音 | 柳清风、红钻王者、虎奴、白虎圣皇、战卫、李东波、光明圣龙、虎家青年、撒摩、一痴和尚、雷神王 |
| 叙事角色实际有对白 | 兰德、王通、邓玉、南荒老董、白虎生皇、骷髅君王传人、白玉骷髅王、老者 |
其余 200 个角色的音色保持不变。实现约束:
- `build-voice-map.js` 的 `VOICE_FIX` 表是唯一允许改动的角色;
- 默认 **沿用现有音色**,只有 `--rebuild-all` 才全量重排 —— 避免重跑脚本时把无关角色一起换掉;
- 修完后重新跑 hoyoTTS + ASR 闸门,再写回章节、重出 APK。
### 本地安装位置(当前这台机器的落盘路径)
> ⚠️ `tmp/` 已在 `.gitignore` 里,**模型不随仓库入库**;换机器要按下面的命令重新拉。
| 内容 | 本地路径 | 大小 | 来源 |
|---|---|---|---|
| **hoyoTTS 模型权重** | `D:/workspace/longevity-world-text/tmp/models/hoyoTTS` | 1.13 GB | 魔搭 `Genius-Society/hoyoTTS` |
| **hoyoTTS 推理代码** | `D:/workspace/longevity-world-text/tmp/models/hoyoTTS-code` | 0.3 MB | 创空间仓库(SDK 下不了,必须 git clone) |
| **中文 BERT 前端** | `D:/workspace/longevity-world-text/tmp/models/chinese-roberta-wwm-ext-large` | 3.73 GB | 魔搭 `dienstag/chinese-roberta-wwm-ext-large` |
| **本地 ASR(闸门 L1)** | `D:/workspace/longevity-world-text/tmp/models/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-pytorch` | 848 MB | FunASR paraformer-large-zh |
| **本地 ASR(闸门 L2)** | `D:/workspace/longevity-world-text/tmp/models/SenseVoiceSmall` | 897 MB | FunASR SenseVoice |
| **Whisper 裁判(只抽样)** | `D:/workspace/longevity-world-text/tmp/models/whisper-large-v3-turbo` | 1.55 GB | 魔搭 `openai-mirror/whisper-large-v3-turbo` |
| 中间 wav | `D:/workspace/longevity-world-text/tmp/voice-work/` | 1261 个 wav | 流水线中间产物 |
| 解码缓存(16k) | `D:/workspace/longevity-world-text/tmp/voice-decode/` | — | 云端复核用 |
| **成品 opus(入库)** | `D:/workspace/longevity-world-text/assets/voice/` | 14.9 MB + manifest.json 0.53 MB + verify.json 0.65 MB | 入库 |
| **流水线脚本(入库)** | `D:/workspace/longevity-world-text/scripts/voice/` | 14 个文件 | 入库 |
| 角色 → 音色映射 | `D:/workspace/longevity-world-text/data/world/voice-map.json` | 251 条 | 入库 |
| 台词清单 | `D:/workspace/longevity-world-text/data/world/voice-lines.json` | 1261 条 | 入库 |
| 设计文档 | `docs/voice-pipeline.md`(流水线 + 复现命令)· `docs/tts-voice-research.md`(选型全过程) | — | 入库 |
模型目录里的关键文件(完整路径):
```
D:\workspace\longevity-world-text\tmp\models\hoyoTTS\config.json 251 个 spk2id(n_speakers=256)
D:\workspace\longevity-world-text\tmp\models\hoyoTTS\G_78000.pth 主权重 592.9 MB
D:\workspace\longevity-world-text\tmp\models\hoyoTTS\D_78000.pth 判别器 535.1 MB
D:\workspace\longevity-world-text\tmp\models\hoyoTTS\DUR_78000.pth 时长预测 6.6 MB
D:\workspace\longevity-world-text\tmp\models\hoyoTTS-code\models.py BertVITS2 网络定义
D:\workspace\longevity-world-text\tmp\models\hoyoTTS-code\utils.py get_hparams_from_dir / load_checkpoint
D:\workspace\longevity-world-text\tmp\models\hoyoTTS-code\text\ 音素切分 + BERT 前端
D:\workspace\longevity-world-text\tmp\models\hoyoTTS-code\app.py 创空间原始推理入口
```
### 运行环境(不在项目目录里,在系统上)
| 组件 | 完整路径 | 说明 |
|---|---|---|
| Python 解释器 | `E:\software\python3.13.2\python.exe` | 3.13.2 |
| Python 依赖包 | `E:\software\python3.13.2\Lib\site-packages` | modelscope / torch(cpu) / transformers / funasr / edge-tts … |
| ffmpeg | `C:\Users\10397\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-8.1.1-full_build\bin\ffmpeg.exe` | 转 opus 必需,必须在 PATH |
| Git | 系统 PATH | 用来 clone 创空间推理代码 |
**换机自检**(把下面这段贴到 PowerShell,全 OK 才说明环境齐了):
```powershell
$root = 'D:\workspace\longevity-world-text'
$must = @(
"$root\tmp\models\hoyoTTS",
"$root\tmp\models\hoyoTTS-code",
"$root\tmp\models\chinese-roberta-wwm-ext-large",
"$root\tmp\models\speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-pytorch",
"$root\tmp\models\SenseVoiceSmall",
"$root\tmp\models\whisper-large-v3-turbo",
"$root\assets\voice\manifest.json",
"$root\assets\voice\verify.json",
"$root\scripts\voice\build-voice.py",
"$root\scripts\voice\asr_gate.py"
)
$must | ForEach-Object { '{0,-6} {1}' -f $(if (Test-Path $_) { 'OK' } else { 'MISS' }), $_ }
(Get-Command ffmpeg -ErrorAction SilentlyContinue).Source
python -c "import modelscope,torch,funasr,edge_tts,pypinyin;print('deps ok')"
```
### 安装(换机器时重建)
```bash
# 0) 依赖:Python 3.13 + ffmpeg(必须在 PATH,转 opus 用)+ Git
pip install modelscope==1.40.2 torch==2.14.1+cpu transformers==5.20.2 funasr==1.4.16 \
numpy scipy pypinyin jieba cn2an numba tqdm requests soundfile \
edge-tts==7.3.1 kaldi-native-fbank
# 1) 拉模型
python -c "from modelscope import snapshot_download as s; \
s('Genius-Society/hoyoTTS', local_dir='tmp/models/hoyoTTS'); \
s('dienstag/chinese-roberta-wwm-ext-large', local_dir='tmp/models/chinese-roberta-wwm-ext-large'); \
s('openai-mirror/whisper-large-v3-turbo', local_dir='tmp/models/whisper-large-v3-turbo')"
# 2) 拉推理代码(创空间仓库,ModelScope SDK 不支持,只能 git clone)
git clone --depth 1 https://modelscope.cn/studios/Genius-Society/hoyoTTS.git tmp/models/hoyoTTS-code
```
**脚本里写死的绝对路径(换机器必须改)**:
| 文件 | 行 | 常量 | 现值 |
|---|---|---|---|
| `D:\workspace\longevity-world-text\scripts\voice\build-voice.py` | 24–26 | `HOYO_CODE` / `HOYO_MODEL` / `BERT` | `D:/workspace/longevity-world-text/tmp/models/...` |
| `D:\workspace\longevity-world-text\scripts\voice\asr_gate.py` | 18–19 | `MODEL_DIR_PF` / `MODEL_DIR_SV` | `D:/workspace/longevity-world-text/tmp/models/...` |
| `D:\workspace\longevity-world-text\scripts\voice\build-voice-map.js` | 12 | 读 `tmp/models/hoyoTTS/config.json` 取说话人清单 | 相对项目根,无需改 |
> 坑:ASR 闸门模型走 FunASR 的 `AutoModel(model=...)`,**离线环境下它会尝试联网下载**;
> 先把 `speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-pytorch` 和 `SenseVoiceSmall`
> 手动放到 `tmp/models/` 下同名目录,才能完全离线跑。
### 使用
```bash
# ① 台词清单 + 说话人映射(后面所有步骤的输入,必须先跑)
node scripts/voice/extract-lines.js # → data/world/voice-lines.json(1261 条)
node scripts/voice/build-voice-map.js # → data/world/voice-map.json(251 角色 → 146 音色)
# ② 全量合成(断点续跑;--jobs 固定 3;--seed-offset 换采样种子)
python scripts/voice/build-voice.py --resume --audit 0 --jobs 3
# ③ 映射/分流改过之后:把受影响的条目从 manifest 剔除(供 --resume 补跑)
node scripts/voice/invalidate-changed.js
# ④ 先试音:只跑前 5 条(--limit)
python scripts/voice/build-voice.py --limit 5 --audit 0
# ⑤ 报告:已产出多少条 / 引擎分布 / 总体积
python scripts/voice/build-voice.py --report
# ⑥ 内容验收
python scripts/voice/verify-all.py scan # 全量本地双引擎 ASR,约 7 分钟
python scripts/voice/verify-all.py rejudge # 改判据后秒级重算(不重跑 ASR)
python scripts/voice/verify-all.py report
python scripts/voice/verify-all.py cloud --jobs 3 # 云端独立复核(只救回,不否决)
python scripts/voice/verify-all.py whisper --sample 30 # Whisper 抽样裁判
# ⑦ 不达标的打回重合成队列
python scripts/voice/repair-queue.py
python scripts/voice/repair-queue.py --apply
# ⑧ 对账:manifest ↔ 盘上 opus(补 file/bytes、重转过期 opus、丢弃不达标条目)
python scripts/voice/reconcile.py
python scripts/voice/reconcile.py --apply
# ⑨ 写回章节数据 → 闸门 → 出包
node scripts/voice/annotate-chapters.js # 把音频路径写进各章 reply[].voice
node scripts/voice/check-voice.js # 配音闸门(可用率 <98% 判红)
node scripts/release.js
node scripts/build-apk.js
```
**临时合成一条试听**(不改任何数据文件):
```python
import importlib.util, pathlib, numpy as np
from scipy.io import wavfile
spec = importlib.util.spec_from_file_location('bv', 'scripts/voice/build-voice.py')
bv = importlib.util.module_from_spec(spec); spec.loader.exec_module(bv)
eng = bv.HoyoEngine() # 首次会初始化 BERT + jieba,约十几秒
audio = eng.synth('你好,我是萧晨。', sid=115, sdp=0.0, noise=0.667, noise_w=0.8)
wavfile.write('tmp/试听.wav', 44100, audio) # 44.1kHz 单声道 float32
```
角色 id 从 `data/world/voice-map.json` 查(如 `xiao_chen` → sid 115 / 景元);
`spkAction` 是该角色「动作描写」用的旁白音色(sid 111 旁白)。
### 流水线与质量闸门
```
台词清单 → 参数网格合成(5 组采样,多候选取最优) → 本地双引擎 ASR 投票
├─ 通过 → 转 opus → 入 manifest
├─ 本地未过 → 云端 matrix 双通道复核(只救回,不否决)
│ └─ 确认漂移 → 拆句重合成(短句零漂移)
│ └─ 仍失败 → 降级 Edge-TTS 同档音色
└─ 成品:assets/voice/.opus + manifest.json + verify.json
```
内容闸门用 **ASR 反查 + 三判据**,任一命中即判通过:
| 判据 | 阈值 | 作用 |
|---|---|---|
| 字级 CER | 1-CER ≥ 0.82 且非硬漂移 | 主判据 |
| 音节级(去调拼音) | SyllableER ≥ 0.90 | 消除同音字误杀(萧晨→消沉、祖神有巢氏→附神有朝氏) |
| 绝对距离兜底 | `min(字距离, 音节距离) ≤ 1` 且原文 ≥3 字 | 短句兜底(「哼不过尔尔」→「哼不过二」) |
| 拟声豁免 | 整句拟声字且 ≤8 字 | ASR 结构性无法验证,不计入可用率分母 |
> 实战结论:**云端 ASR 不如本地准**。12 条逐条对照里,云端把「终于脱困了!」听成「这也太痛快了」,
> 而本地 paraformer 打 1.000。所以架构是「本地双引擎 = 唯一否决权,云端只用来救回」;
> 云端并发固定 ≤3(8 并发会整批返回空,表现为 sim 精确等于 0.0 的假漂移)。
### 产物与游戏接入
| 环节 | 做法 |
|---|---|
| 运行时定位 | 音频路径在构建期写进各章 `reply[].voice`,运行时**不需要算索引** |
| 播放器 | `web/voice.js`(复用 `