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
+103
View File
@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
"""voice 引用一致性校验(26 号推广工程配套)。
对每款深度重构游戏,扫描 game.js(含 index.html / sessions)里引用的全部
'voice/<id>' 字面量,对照 tools/gen_voice_dual.py 的台词表(GAMES + NPCS):
1. 引用了但台词表没有 → 多半是接线笔误(未生成就先报出来)
2. NPC 表 / 平铺目录里存在但双性别表没有 → 合法(角色音平铺),单独计数
3. 台词表里有但代码从不引用 → 死句(列出来人工裁定:可能被 session 或
链式构造间接引用,只警告不判错)
用法:.venv-tts/bin/python tools/validate_voice_refs.py [game ...]
不带参数 = 校验全部 15 款。
"""
import importlib.util
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "tools"))
spec = importlib.util.spec_from_file_location(
"gen_voice_dual", os.path.join(ROOT, "tools", "gen_voice_dual.py"))
gvd = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gvd)
VOICE_RE = re.compile(r"['\"]voice/([A-Za-z0-9_]+)['\"]")
# 每个 game 要扫描的源文件(相对 web/<game>/)
SRC_FILES = ["game.js", "index.html"]
# 会话剧本也引用 id(transcribe 用),只用于「死句」判定的豁免
SESSION_GLOB = os.path.join(ROOT, "web", "harness", "sessions", "{game}.mjs")
def scan_game(game):
refs = set()
gdir = os.path.join(ROOT, "web", game)
for name in SRC_FILES:
p = os.path.join(gdir, name)
if not os.path.exists(p):
continue
with open(p, encoding="utf-8") as f:
refs |= set(VOICE_RE.findall(f.read()))
# 会话剧本(豁免死句用)
ses = set()
sp = SESSION_GLOB.format(game=game)
if os.path.exists(sp):
with open(sp, encoding="utf-8") as f:
ses |= set(VOICE_RE.findall(f.read()))
return refs, ses
def flat_ids(game):
"""已有资产 id:顶层平铺(角色/NPC 单音色)+ male/female 目录文件名
(含早期轮次落盘但不在台词表的「在库沿用」资产,如 rhythm 的 gd_*)。"""
vdir = os.path.join(ROOT, "web", game, "audio", "voice")
if not os.path.isdir(vdir):
return set()
ids = {f.rsplit(".", 1)[0] for f in os.listdir(vdir) if "." in f}
for sub in ("male", "female"):
d = os.path.join(vdir, sub)
if os.path.isdir(d):
ids |= {f.rsplit(".", 1)[0] for f in os.listdir(d) if "." in f}
return ids
def main():
games = sys.argv[1:] or list(gvd.GAMES.keys())
bad = 0
for game in games:
# 双性别台词表 + NPC 平铺表 + 平铺资产文件 + 键盘变体(同 id)
table = {fid for fid, _i, _t in gvd.GAMES.get(game, [])}
npc = {fid for fid, _s, _in, _t in gvd.NPCS.get(game, [])}
flat = flat_ids(game)
key = {fid for fid, _t in gvd.KEY_LINES.get(game, [])}
refs, ses = scan_game(game)
# 引用解析:id 直配;动态前缀引用('voice/' + x 拼接)不在本工具能力内,
# 游戏若声明 DYNAMIC_VOICE_PREFIX 则跳过该项
# 尾下划线 = 动态前缀拼接('voice/btl_loot_' + n),不在直配能力内
unresolved = sorted(
r for r in refs
if not r.endswith("_") and r not in table and r not in npc and r not in flat)
# 死句:表里有、代码与会话都不引用
dead = sorted(
fid for fid in table
if fid not in refs and fid not in ses and fid not in key)
status = "OK " if not unresolved else "BAD"
if unresolved:
bad += 1
print(f"[{status}] {game}: 引用 {len(refs)},表内 {len(table)},NPC {len(npc)},未解析 {len(unresolved)},疑似死句 {len(dead)}")
for u in unresolved:
print(f" 未解析: voice/{u}")
for d in dead[:10]:
print(f" 死句?: {d}")
if len(dead) > 10:
print(f" …另有 {len(dead) - 10} 条")
sys.exit(1 if bad else 0)
if __name__ == "__main__":
main()