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
+11 -119
View File
@@ -186,7 +186,8 @@ function makeRig(GameClass) {
audio.play = (id, opts) => { playLog.push({ id, t: now, opts: opts || {} }); return origPlay(id, opts); };
const game = new GameClass(audio, {});
const events = [];
game.guide.setEventHook((evt) => events.push({ ...evt, t: now }));
// 26 号改造中的游戏可能已无 Guide(发言权层接管):无钩则空事件表
if (game.guide && game.guide.setEventHook) game.guide.setEventHook((evt) => events.push({ ...evt, t: now }));
const vibBase = vibrateLog.length;
return {
audio, playLog, game, events, vibBase,
@@ -200,8 +201,8 @@ async function gesture(game, g) { game.onGesture(g); await flush(); }
const waitChainDone = (game) => advance(() => !game.voicePlaying);
// ================================================================ ⓪ Guide 层单测(stub 游戏夹具)
// 26 号重构后 storyline/escape/simon 不再使用 Guide(发言权/等待点接管),
// Guide 仍服务 rhythm/space/rpg 等未改造游戏;这里用最小夹具锁 Guide 自身语义。
// 26 号重构后 storyline/escape/simon/rhythm 不再使用 Guide(发言权/等待点接管),
// Guide 仍服务 space/rpg 等未改造游戏;这里用最小夹具锁 Guide 自身语义。
realLog('== ⓪ Guide 层:状态签名 / turn 排队 / error idle-only / off 全静 ==');
{
@@ -241,122 +242,13 @@ realLog('== ⓪ Guide 层:状态签名 / turn 排队 / error idle-only / off
settings.set({ guide: 'full' });
}
// ================================================================ ⑥ G1/G3 rhythm
realLog('== ⑥ G1/G3 rhythm:gd_answer 落在首个应答窗口前整整 1 小节;菜单恢复 count-in 可感知 ==');
{
const { audio, playLog, game, events } = makeRig(RhythmGame);
const tSec = () => now / 1000;
const SPB = 60 / 90; // 曲目一 90BPM
await audio.unlock();
game.start();
await advance(() => audio.isVoicePlaying()); // welcome 开播
await gesture(game, 'tap'); // 播放中单击 = 跳过 → 教学链
await advance(() => game.phase === 'tut');
await gesture(game, 'down'); // 跳过教学 → 曲目一 main
await advance(() => game.phase === 'main' && game._cues.length > 0);
assert(game._song && game._song.id === 'song1', '前提:进入曲目一 main(90BPM)');
// G1:gd_answer(曲谱定时队列直达)在首个应答窗口起点之前整整 1 小节
await advance(() => playLog.some((e) => e.id === 'voice/gd_answer'));
const ans = playLog.find((e) => e.id === 'voice/gd_answer');
const win0 = game._cues[0].expected; // 首个应答窗口起点(秒)
assert(ans.t / 1000 < win0, `G1:gd_answer(${(ans.t / 1000).toFixed(2)}s) < 首窗口起点(${win0.toFixed(2)}s)`);
assert(Math.abs(win0 - ans.t / 1000 - 4 * SPB) < 0.06,
`G1:窗口前整整 1 小节(提前 ${(win0 - ans.t / 1000).toFixed(2)}s ≈ ${(4 * SPB).toFixed(2)}s)`);
assert(events.some((e) => e.type === 'turn'), 'G1:turn earcon 同点发出(standard 档也发,off 静默)');
// G3:曲中开菜单 → 关菜单恢复:full 档先播 gd_count,链播完才开始 4 拍 count-in
await advance(() => tSec() >= game._bgmT0 + 1.5); // BGM 已起、首窗口未到
await gesture(game, 'down');
assert(game.phase === 'menu', 'G3:曲中下滑呼出菜单(无悬置 down 音符)');
// live round2 P1:菜单 1s 去抖独占期(防瞬穿)——关菜单手势须在独占期后到达
const menuT0 = tSec();
await advance(() => tSec() >= menuT0 + 1.05);
const kicksBefore = game._sched.filter((e) => e.sfx === 'sfx/inst_kick').length;
await gesture(game, 'down');
assert(voicePlays(playLog, 'voice/gd_count') === 1, 'G3:恢复先播 gd_count(full 档)');
assert(audio.isVoicePlaying(), 'G3:count-in 在 gd_count 链播完之后才开始(语音串行)');
await advance(() => !audio.isVoicePlaying() && game._timer !== null);
const allKicks = game._sched.filter((e) => e.sfx === 'sfx/inst_kick').map((e) => e.time).sort((a, b) => a - b);
assert(allKicks.length === kicksBefore + 4, 'G3:count-in 4 记底鼓排入 _sched(sfx 层音量 1.0)');
const schedTimes = game._sched.map((e) => e.time);
assert(schedTimes.every((t, i) => i === 0 || t >= schedTimes[i - 1]), 'G3:_sched 已按时间重排(count-in 位于队头,round2 版未重排被队头阻塞的根因)');
const ciKicks = allKicks.slice(0, 4);
assert(ciKicks.every((t, i) => i === 0 || Math.abs(t - ciKicks[i - 1] - SPB) < 0.05), 'G3:底鼓按一拍间距排布');
const vibMark = vibrateLog.length;
await advance(() => tSec() >= ciKicks[3] + 0.3);
assert(vibrateLog.slice(vibMark).filter((p) => JSON.stringify(p) === '[8]').length >= 4,
'G3:4 记 count-in 底鼓各带 beat 震(按拍点准时落)');
assert(game.phase === 'main', 'G3:恢复后回 main');
// G1:恢复 = 一次「进 main」:暂停前旧标记已消费 → 对恢复后的首个未判定窗口重排
const gdCountBefore = voicePlays(playLog, 'voice/gd_answer');
await advance(() => voicePlays(playLog, 'voice/gd_answer') > gdCountBefore);
const ans2 = playLog.filter((e) => e.id === 'voice/gd_answer').pop();
assert(ans2.t / 1000 < game._cues[0].expected, 'G1:恢复后的 gd_answer 仍在首个未判定窗口起点之前');
// G3:gd_count 每局恢复限 1 次(第二次恢复直接 count-in)
await gesture(game, 'down');
const menuT1 = tSec();
await advance(() => tSec() >= menuT1 + 1.05); // live round2 P1:菜单 1s 去抖独占期后再操作
const cntBefore = voicePlays(playLog, 'voice/gd_count');
await gesture(game, 'down');
assert(voicePlays(playLog, 'voice/gd_count') === cntBefore, 'G3:gd_count 每局恢复限 1 次');
assert(game.phase === 'main', 'G3:第二次恢复不经语音直接 count-in');
game.stop();
resetTimers();
}
// ================================================================ ⑦ G2 rhythm 教学 DDA
realLog('== ⑦ G2 rhythm:教学同一步连 2 失败 → 领奏降速 25% 重演 + gd_slow;做对恢复、每局封顶 ==');
{
const { audio, playLog, game } = makeRig(RhythmGame);
const tSec = () => now / 1000;
const SPB = 60 / 90;
await audio.unlock();
game.start();
await advance(() => audio.isVoicePlaying());
await gesture(game, 'tap'); // 跳过 welcome
await advance(() => game.phase === 'tut' && game._cues.length > 0);
let lead = Math.min(...game._sched.filter((e) => e.sfx === 'sfx/inst_kick').map((e) => e.time));
assert(Math.abs(game._cues[0].expected - lead - 4 * SPB) < 0.05, '基准:T1 应答窗 = 领奏后 4 拍(正常速率)');
// 失败①:整句不出手(F2.1 归因 + 纠正重教)。用持久里程碑停表:
//(_tutEvaluate 会先把 _phaseEnd 置 Infinity 防重入,等「阶段末」类条件会跑飞)
await advance(() => game._tutFails === 1);
assert(game._tutFails === 1, '失败 1 次:纠正重教(原路径不变)');
await advance(() => game._phaseEnd < 1e9 && tSec() < game._phaseEnd - 0.5); // 纠正链播完、重新领奏
// 失败②:再整句不出手 → G2 降速重演
await advance(() => game._tutSlowUsed === true);
assert(game._tutSlowUsed === true, '同一步连续 2 次失败 → 进入降速路径');
await advance(() => voicePlays(playLog, 'voice/gd_slow') === 1);
assert(voicePlays(playLog, 'voice/gd_slow') === 1, 'G2:连 2 失败播 gd_slow(full 档)');
assert(game._tutRate === 0.75, 'G2:该步领奏降速 25%(_tutRate=0.75)');
await advance(() => game._cues.length > 0 && game._sched.some((e) => e.sfx === 'sfx/inst_kick' && e.time > tSec()));
lead = Math.min(...game._sched.filter((e) => e.sfx === 'sfx/inst_kick').map((e) => e.time));
assert(Math.abs(game._cues[0].expected - lead - 4 * SPB / 0.75) < 0.06,
`G2:降速后应答窗 = 领奏后 4 拍×1/0.75(实际 ${(game._cues[0].expected - lead).toFixed(2)}s)`);
// 降速步做对 → 恢复正常速度
await advance(() => tSec() >= game._cues[0].expected + 0.29);
await gesture(game, 'tap'); // tap 补偿 -0.30s 后 dt≈0,判对
await advance(() => game._tutIdx === 1);
assert(game._tutRate === 1.0, 'G2:该步做对恢复正常速度');
// 封顶:T2 再连 2 失败 → 不再降速,维持 retry_skip 跳步
await advance(() => game._tutIdx === 1 && game._tutFails === 1); // T2 失败 1
await advance(() => game._phaseEnd < 1e9 && tSec() < game._phaseEnd - 0.5 && game._tutIdx === 1);
await advance(() => voicePlays(playLog, 'voice/retry_skip') === 1); // T2 失败 2 → 跳步
assert(voicePlays(playLog, 'voice/gd_slow') === 1, 'G2:gd_slow 每教学局 1 次封顶');
assert(voicePlays(playLog, 'voice/retry_skip') === 1, 'G2:封顶后维持 retry_skip 跳步不卡流程');
await advance(() => game._tutIdx === 2);
assert(game._tutIdx === 2, 'G2:跳步进 T3');
game.stop();
resetTimers();
}
// ================================================================ ⑥⑦ rhythm(已迁移)
// 26 号深度重构(docs/design/26-apply-rhythm.md)后 rhythm 不再接 Guide:
// 发言权/等待点/三档提示/迷茫接管改走 engine/{floor,hints,confusion,receipts}.
// 原 ⑥ G1/G3(gd_answer 落窗前 1 小节、菜单恢复 gd_count+count-in)与
// ⑦ G2(教学连 2 败降速 25%/封顶跳步)断言已随交互层重写迁至
// web/rhythm/test/playtest.mjs(场景 H 菜单恢复 / 场景 F 教学 DDA / 场景 J
// gd_answer+交接提示音同窗)。此处保留空位防止节号漂移。
// ================================================================ ⑨ G7 space 方向呼喊