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 置完成。
322 lines
14 KiB
Python
322 lines
14 KiB
Python
# -*- 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()
|