feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制

storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
This commit is contained in:
xsl
2026-10-02 12:33:20 +08:00
parent 9e4d67aa84
commit ee5d4d2544
6992 changed files with 30127 additions and 20451 deletions
+321
View File
@@ -0,0 +1,321 @@
# -*- coding: utf-8 -*-
"""26 号重构台词清单合并器:tools/voice_lines_26/<game>.md → tools/gen_voice_dual.py。
用法:
.venv-tts/bin/python tools/merge_voice_lines_26.py <game>... [--apply]
不带 --apply = 干跑,只打印计划与校验结果。
清单四节(新增/修改/删除/键盘变体)的落地规则:
新增 按 GAME_ROUTING 插入对应表(radio/abyss/cipher/keeper 的系统句插
PROD/DECK/WIND/SHORE 子表、在 `XX_LINES = A + B` 拼接行之前——
这样 GAMES 与 VOICE_SPK 都能拿到);简单游戏在 LINES 字面量闭合后
追加 `XX_LINES = XX_LINES + [...]` 块。若 id 已在本游戏表中
(清单误归类),自动降级为「修改」并警告。
修改 原地替换该 id 元组的 instruct 变量与文案(id 不变)。
删除 从主表摘除元组行;KEY_LINES 中该 id 的变体一并摘除(key_variants()
启动校验要求 key ⊆ 主集合);删除 web 双性别/key 资产与 staging wav。
键盘变体 ("id", "键改文案") 二元组:id 已有变体 = 原地改文案(重录);
没有则追加进 KEY_LINES[game]。主 id 被删的变体在「删除」里顺带摘除。
校验:合并后 ast 语法 + 模块导入(含 key_variants key⊆主集合校验)。
"""
import argparse
import ast
import importlib.util
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GVD = os.path.join(ROOT, "tools", "gen_voice_dual.py")
MANIFEST_DIR = os.path.join(ROOT, "tools", "voice_lines_26")
GAME_ROUTING = {
"rpg": {"mode": "simple", "list": "RPG_LINES"},
"rhythm": {"mode": "simple", "list": "RHYTHM_LINES"},
"space": {"mode": "simple", "list": "SPACE_LINES"},
"dungeon": {"mode": "simple", "list": "DUNGEON_LINES"},
"detective": {"mode": "simple", "list": "DETECTIVE_LINES"},
"teahouse": {"mode": "simple", "list": "TEAHOUSE_LINES"},
"hunt": {"mode": "simple", "list": "HUNT_LINES"},
"bridge": {"mode": "simple", "list": "BRIDGE_LINES"},
"radio": {"mode": "concat", "sub": "RADIO_PROD_LINES",
"anchor": "RADIO_LINES = RADIO_HOST_LINES + RADIO_PROD_LINES"},
"abyss": {"mode": "concat", "sub": "ABYSS_DECK_LINES",
"anchor": "ABYSS_LINES = ABYSS_DIVER_LINES + ABYSS_DECK_LINES"},
"cipher": {"mode": "concat", "sub": "CIPHER_WIND_LINES",
"anchor": "CIPHER_LINES = CIPHER_OP_LINES + CIPHER_WIND_LINES"},
"keeper": {"mode": "concat", "sub": "KEEPER_S_LINES",
"anchor": "KEEPER_LINES = KEEPER_K_LINES + KEEPER_S_LINES"},
}
# 音色变量名可含下划线(_RADIO_PROD / _ABY_DECK / _CIPHER_WIND / _SHORE …)
TUPLE3 = re.compile(r'\(\s*["\']([A-Za-z0-9_]+)["\']\s*,\s*(_[A-Za-z0-9_]+)\s*,\s*["\']([^"\']*)["\']\s*\)')
TUPLE2 = re.compile(r'\(\s*["\']([A-Za-z0-9_]+)["\']\s*,\s*["\']([^"\']*)["\']\s*\)')
DEL_ID = re.compile(r'^\s*\(\s*["\']([A-Za-z0-9_]+)["\']\s*[,,]')
SECTION = re.compile(r'^#{1,3}\s*.*?(新增|修改|删除|键盘变体|停用)')
SECTION_MAP = {"新增": "new", "修改": "mod", "删除": "del", "停用": "del", "键盘变体": "key"}
def parse_manifest(game):
path = os.path.join(MANIFEST_DIR, f"{game}.md")
if not os.path.exists(path):
raise SystemExit(f"缺清单:{path}")
out = {"new": [], "mod": [], "del": [], "key": []}
cur = None
for ln in open(path, encoding="utf-8").read().splitlines():
if ln.strip().startswith("```"):
continue
m = SECTION.match(ln)
if m:
cur = SECTION_MAP[m.group(1)]
continue
if cur is None:
continue
stripped = ln.lstrip()
# 划线勘误行(~~`id`~~(保留),行首可能带 |)与表头分隔行跳过
if "~~" in ln or "(保留)" in ln or stripped.startswith("|-") or stripped.startswith("| 行"):
continue
if cur in ("new", "mod"):
# 支持裸元组行与 markdown 表格行(`("id", _VAR, "text")` 反引号包裹)
for fid, var, text in TUPLE3.findall(ln):
out[cur].append((fid, var, text))
elif cur == "del":
dm = DEL_ID.match(stripped)
if dm:
out["del"].append(dm.group(1))
continue
# 表格行:| `id` | 原文 | 理由 |
for fid in re.findall(r'`([A-Za-z0-9_]+)`', ln):
out["del"].append(fid)
break # 每行只取第一个反引号 id
elif cur == "key":
for fid, text in TUPLE2.findall(ln):
out["key"].append((fid, text))
for k in ("new", "mod", "key"):
seen, uniq = set(), []
for e in out[k]:
if e[0] not in seen:
seen.add(e[0])
uniq.append(e)
out[k] = uniq
out["del"] = list(dict.fromkeys(out["del"]))
return out
def load_gvd():
spec = importlib.util.spec_from_file_location("gvd", GVD)
gvd = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gvd)
return gvd
def list_close(src, open_idx):
"""从 `[` 下标找配对 `]` 下标。"""
depth = 0
for j in range(open_idx, len(src)):
if src[j] == "[":
depth += 1
elif src[j] == "]":
depth -= 1
if depth == 0:
return j
raise SystemExit("列表闭合未找到(文件损坏?)")
def key_lines_span(src, game):
m = re.search(r'^\s*"' + game + r'":\s*\[', src, re.M)
if not m:
return None
i = src.index("[", m.start())
return (i, list_close(src, i))
def game_section_span(src, game):
"""游戏分区(`# ---- <game>` 头到下一个分区头)——主表增删改必须限定在分区内,
防止跨游戏同名 id 撞车(sys_continue_ask / gd_brief 等多游戏共有)。"""
hdr = re.search(r'^# -{10,}\s*' + re.escape(game) + r'\s*$', src, re.M)
if not hdr:
raise SystemExit(f"gen_voice_dual.py 缺分区头:{game}")
nxt = re.search(r'^# -{10,}\s*\S+\s*$', src[hdr.end():], re.M)
end = hdr.end() + nxt.start() if nxt else len(src)
return (hdr.start(), end)
def replace_main_tuple(src, fid, var, text):
pat = re.compile(
r'^(\s*\(\s*["\'])' + re.escape(fid) +
r'(["\']\s*,\s*)_[A-Za-z0-9_]+(\s*,\s*["\'])[^"\']*(["\']\s*\)[,]?)', re.M)
def sub(m):
return f'{m.group(1)}{fid}{m.group(2)}{var}{m.group(3)}{text}{m.group(4)}'
out, n = pat.subn(sub, src, count=1)
return out, n == 1
def remove_line_with_id(src, fid, where=None):
"""摘除含 ("fid", …) 的整行(限定 where=(start,end) 范围时只在该范围)。"""
seg_src, off = (src[where[0]:where[1] + 1], where[0]) if where else (src, 0)
pat = re.compile(r'^\s*\(\s*["\']' + re.escape(fid) + r'["\'][^\n]*\n', re.M)
seg2, n = pat.subn('', seg_src, count=1)
if n:
src = src[:off] + seg2 + src[where[1] + 1:] if where else seg2
return src, n == 1
def main():
ap = argparse.ArgumentParser()
ap.add_argument("games", nargs="+")
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
gvd = load_gvd()
src = open(GVD, encoding="utf-8").read()
plans, problems = [], []
for game in args.games:
route = GAME_ROUTING.get(game)
if not route:
problems.append(f"{game}: 无路由")
continue
man = parse_manifest(game)
existing = {fid for fid, _i, _t in gvd.GAMES.get(game, [])}
key_existing = {fid for fid, _t in gvd.KEY_LINES.get(game, [])}
# 新增撞已有 → 自动降级为修改(清单归类笔误)
real_new, demoted = [], []
for e in man["new"]:
(demoted if e[0] in existing else real_new).append(e)
man["new"] = real_new
man["mod"] = man["mod"] + demoted
if demoted:
print(f" ! {game}: {len(demoted)} 句从「新增」降级为「修改」(id 已在表中): {[e[0] for e in demoted]}")
# 键盘变体:已有变体 = 改文案;引用的主 id 必须存在或在本轮新增
key_add, key_mod, key_bad = [], [], []
for fid, text in man["key"]:
if fid in key_existing:
key_mod.append((fid, text))
elif fid in existing or fid in {e[0] for e in man["new"]}:
key_add.append((fid, text))
else:
key_bad.append(fid)
man["key"] = key_add
man["_keymod"] = key_mod
if key_bad:
problems.append(f"{game}: 键盘变体引用不存在的主 id {key_bad}")
miss = [f for f, _, _ in man["mod"] if f not in existing]
if miss:
problems.append(f"{game}: 修改 id 不在表中 {miss}")
missd = [f for f in man["del"] if f not in existing]
if missd:
problems.append(f"{game}: 删除 id 不在表中 {missd}")
dup = [f for f, _, _ in man["new"] if f in existing]
if dup:
problems.append(f"{game}: 新增 id 已存在 {dup}")
plans.append((game, route, man))
print(f"== {game}: 新增 {len(man['new'])} / 修改 {len(man['mod'])}"
f"(含降级 {len(demoted)})/ 删除 {len(man['del'])}"
f" / 键盘变体 +{len(key_add)} 改{len(key_mod)}")
if problems:
print("\n计划校验未过,未做任何修改:")
for p in problems:
print(" ✗", p)
sys.exit(1)
if not args.apply:
print("\n干跑通过(未修改文件)。加 --apply 执行合并。")
return
cleanup = []
for game, route, man in plans:
# 主表操作限定在本游戏分区(防跨游戏同名 id 撞车);
# 每次操作前重算分区(前一次删除会移动边界)
# 1) 删除:主表 + KEY_LINES 变体 + 资产
for fid in man["del"]:
src, ok = remove_line_with_id(src, fid, game_section_span(src, game))
if not ok:
problems.append(f"{game}: 删除未命中 {fid}")
ks = key_lines_span(src, game)
src, _ = remove_line_with_id(src, fid, ks)
for g_ in ("male", "female"):
for e in ("ogg", "m4a"):
cleanup.append(os.path.join(ROOT, "web", game, "audio", "voice", g_, f"{fid}.{e}"))
cleanup.append(os.path.join(ROOT, "web", game, "audio", "voice", g_, "key", f"{fid}.{e}"))
cleanup.append(os.path.join(ROOT, "tools", "gen_voice_dual_wav", game, g_, f"{fid}.wav"))
cleanup.append(os.path.join(ROOT, "tools", "gen_voice_dual_wav", game, g_, "key", f"{fid}.wav"))
# 2) 修改:主表原地替换(分区内);staging 作废(重录)
for fid, var, text in man["mod"]:
seg_span = game_section_span(src, game)
seg = src[seg_span[0]:seg_span[1]]
seg2, ok = replace_main_tuple(seg, fid, var, text)
if not ok:
problems.append(f"{game}: 修改替换未命中 {fid}")
src = src[:seg_span[0]] + seg2 + src[seg_span[1]:]
for g_ in ("male", "female"):
cleanup.append(os.path.join(ROOT, "tools", "gen_voice_dual_wav", game, g_, f"{fid}.wav"))
cleanup.append(os.path.join(ROOT, "tools", "gen_voice_dual_wav", game, g_, "key", f"{fid}.wav"))
# 3) 新增:插入块
block = ",\n".join(f' ("{fid}", {var}, "{text}")' for fid, var, text in man["new"])
if man["new"]:
if route["mode"] == "concat":
idx = src.index(route["anchor"])
insert = (f"# ---- 26 号重构新增(docs/design/26-apply-{game}.md;清单 tools/voice_lines_26/{game}.md)----\n"
f"{route['sub']} = {route['sub']} + [\n{block},\n]\n\n")
src = src[:idx] + insert + src[idx:]
else:
lst = route["list"]
m = re.search(r'^' + lst + r'\s*=\s*\[', src, re.M)
j = list_close(src, src.index("[", m.start()))
eol = src.index("\n", j) + 1
insert = (f"\n# ---- 26 号重构新增(docs/design/26-apply-{game}.md;清单 tools/voice_lines_26/{game}.md)----\n"
f"{lst} = {lst} + [\n{block},\n]\n")
src = src[:eol] + insert + src[eol:]
# 4) 键盘变体:改文案(重录)+ 追加
for fid, text in man.get("_keymod", []):
ks = key_lines_span(src, game)
seg = src[ks[0]:ks[1] + 1]
pat = re.compile(r'^(\s*\(\s*["\'])' + re.escape(fid) + r'(["\']\s*,\s*["\'])[^"\']*(["\']\s*\)[,]?)', re.M)
seg2, n = pat.subn(lambda m: f'{m.group(1)}{fid}{m.group(2)}{text}{m.group(3)}', seg, count=1)
if n == 0:
problems.append(f"{game}: 键盘变体改文案未命中 {fid}")
src = src[:ks[0]] + seg2 + src[ks[1] + 1:]
for g_ in ("male", "female"):
cleanup.append(os.path.join(ROOT, "tools", "gen_voice_dual_wav", game, g_, "key", f"{fid}.wav"))
if man["key"]:
ks = key_lines_span(src, game)
if ks is None:
problems.append(f"{game}: KEY_LINES 无该游戏条目,变体未插入")
else:
insert = "".join(f' ("{fid}", "{text}"),\n' for fid, text in man["key"])
src = src[:ks[1]] + "\n" + insert.rstrip("\n") + src[ks[1]:]
if problems:
print("应用中出现未命中项(其余已写入,请人工核查):")
for p in problems:
print(" ✗", p)
ast.parse(src)
open(GVD, "w", encoding="utf-8").write(src)
print("gen_voice_dual.py 已更新(ast 语法通过)")
gvd2 = load_gvd()
for g in args.games:
gvd2.key_variants(g)
print(f"模块导入自检通过(key ⊆ 主集合 ×{len(args.games)})")
removed = 0
for p in cleanup:
if p and os.path.exists(p):
os.remove(p)
removed += 1
print(f"清理待重录/废止文件 {removed} 个")
if __name__ == "__main__":
main()