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:
+11
-119
@@ -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 方向呼喊
|
||||
|
||||
|
||||
Reference in New Issue
Block a user