/** * 《音序回响》新手会话剧本(round4):教学六音一步一步学(中间故意做错一次听纠错)、 * 收尾听完机会经济句;对局段制造 round4 H 组断言点—— * - 含 up 的轮次(R1):序列播完先听占用预告 gd_occ_up,僵 5s 验证被占用轮看门狗 * 只发 nudge 不建议重听(H2),期望恰为 up 时上滑=作答; * - 含 doubletap 的轮次(R2)与 up+doubletap 双占用轮(R6):gd_occ_dt / gd_occ_both; * - 无尝试超时(R5/R7):干净轮僵 5s → 看门狗建议上滑重听 → 用掉两次免费重听, * 第 1 次听到「免费重听还剩一次」(H3:余量句与真实余量一致——第 4 轮完成机会 * +1 到 2 后才重听),第 2 次听到「免费重听用完了」;整轮边界收工。 */ export function describe(game) { const GN = { up: '上滑', down: '下滑', left: '左滑', right: '右滑', tap: '单击', doubletap: '双击' }; const GESTURES = ['up', 'down', 'left', 'right', 'tap', 'doubletap']; switch (game.phase) { case 'tutorial': return game.teachAwait ? `教学:该做「${GN[GESTURES[game.teachIdx]]}」了` : '教学旁白在播,先听着'; case 'announce': return `第 ${game.round} 轮报数呢,听好了`; case 'playback': return `第 ${game.round} 轮序列演奏中(${game.sequence.length} 个音),记住顺序,先别动手`; case 'input': { const occ = []; if (game.sequence.includes('up')) occ.push('上滑'); if (game.sequence.includes('doubletap')) occ.push('双击'); const occNote = occ.length ? `(这轮${occ.join('和')}是作答,占用了)` : ''; return `第 ${game.round} 轮轮到你了:已对上 ${game.inputIdx}/${game.sequence.length},照原顺序做手势${occNote}(要重听就向上滑)`; } case 'roundend': return '刚答完一轮,播报中'; case 'gameover': return `游戏结束,完成 ${game.round - 1} 轮。单击再来一局,下滑开菜单`; case 'menu': return '菜单里:单击切选项,双击确认,上滑听规则'; default: return '开场,稍等一下'; } } export async function session(ctx, mock) { const { game } = ctx; const G = ['up', 'down', 'left', 'right', 'tap', 'doubletap']; // 教学 T0-T6:教一个练一个 for (let i = 0; i < G.length; i++) { await ctx.waitCond(() => game.phase === 'tutorial' && game.teachAwait); ctx.status(`教学第 ${i + 1}/6 个音色`); if (i === 2) { ctx.note('把「左滑」记混了,先来了个右滑'); await ctx.gesture('right'); // 听纠错语音(t_wrong_left) await ctx.waitCond(() => game.phase === 'tutorial' && game.teachAwait); } await ctx.gesture(G[i]); // 照指令做对 if (i === G.length - 1) { // 收尾链(t7 两句 + 机会经济句)播完直达对局——一到 announce 就接手, // 不裸 waitIdle(会把 input 的看门狗窗口整段空烧掉,round1 剧本失真根源) await ctx.waitCond(() => game.phase === 'announce' || game.phase === 'playback' || game.phase === 'input' || game.phase === 'gameover'); } else { await ctx.waitIdle(); } } ctx.status('教学讲完了:免费重听两次、用完扣机会,想重听就向上滑'); // 测试钩子(13 号文档允许直改实例字段):每轮报数时点把序列摆成剧本需要的 // 形状——占用音(up/doubletap)摆进指定轮,其余用不含占用音的手势补齐。 const fixSeq = (highlights) => { const fill = ['down', 'right', 'tap', 'left']; for (let i = 0; i < game.sequence.length; i++) { game.sequence[i] = i < highlights.length ? highlights[i] : fill[i % fill.length]; } }; const waitAnnounce = () => ctx.waitCond(() => game.phase === 'announce' || game.phase === 'gameover'); const answerRound = async () => { await ctx.waitCond(() => game.phase === 'input' || game.phase === 'gameover'); while (game.phase === 'input') await ctx.gesture(game.sequence[game.inputIdx]); }; // 「无尝试超时」剧本点(干净轮):僵到 input 看门狗 5s 首级的提示语音开播 //(input 态无 BGM 无别的语音,voiceBusy=提示在播;别盲等固定时长——harness 的 // advance 没有时间上限,会恰好迈过提示语音结束点、一路空烧到 25s 升级) const stallForAdvice = async (why) => { ctx.silence(5, why); await ctx.waitCond(() => ctx.voiceBusy()); }; // R1:含 up 的轮次——占用预告 + 被占用轮看门狗只 nudge(H2)+ 上滑恰为作答 await waitAnnounce(); fixSeq(['up', 'tap']); await ctx.waitCond(() => game.phase === 'input'); ctx.status('序列一播完就听到「这一轮,向上滑是作答」——重听键这轮被占了'); ctx.silence(5, '第一轮先不动手,看看它催不催'); // 被占用轮 5s 级没有提示语音可等:僵到 25s 升级(turn+confirm 震)作为里程碑 await ctx.waitVirtual(25600); ctx.note('5 秒一声轻提醒、25 秒又一声加确认震——两级提醒都没建议「向上滑重听」,它知道这轮上滑是作答'); ctx.status('先来一发上滑——期望恰为 up 时上滑就是作答'); await answerRound(); // R2:含 doubletap 的轮次——占用预告 dt(重听建议不受影响,不僵) await waitAnnounce(); fixSeq(['down', 'doubletap', 'left']); await ctx.waitCond(() => game.phase === 'input'); ctx.status('这轮预告的是「双击是作答」'); await answerRound(); // R3/R4:无占用轮直答(第 4 轮完成 → 机会 +1,免费重听凑满两次) await waitAnnounce(); fixSeq([]); await ctx.waitCond(() => game.phase === 'input'); ctx.status('这轮开场没有占用预告——上滑还能当重听键用'); await answerRound(); await waitAnnounce(); fixSeq([]); await ctx.waitCond(() => game.phase === 'input'); ctx.status('机会加一之后,免费重听真的有两次了'); await answerRound(); // R5:无尝试超时 + 第 1 次免费重听(余量如实报「还剩一次」,H3) await waitAnnounce(); fixSeq([]); await ctx.waitCond(() => game.phase === 'input'); ctx.status('第 5 轮序列有点长,先不动手'); await stallForAdvice('想不起来序列了'); ctx.status('5 秒就来了提示:需要的话,向上滑可以重新听一遍序列'); ctx.note('照提示向上滑——第 1 次免费重听用掉,跟着听到「免费重听还剩一次」'); await ctx.gesture('up'); await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input'); await answerRound(); // R6:up+doubletap 双占用轮——gd_occ_both + 被占用轮看门狗只 nudge await waitAnnounce(); fixSeq(['up', 'doubletap']); await ctx.waitCond(() => game.phase === 'input'); ctx.status('这轮说「向上滑和双击都是作答」——两个键都被占用了'); ctx.silence(5, '轮到我了,先心里过一遍序列'); await ctx.waitVirtual(25600); // 同 R1:两级提醒都只发 earcon/标记,无重听建议 ctx.note('又是两级提醒都没有重听建议——占用轮里那句建议自动闭嘴'); await answerRound(); // R7:无尝试超时 + 第 2 次免费重听(余量归 0,H3) await waitAnnounce(); fixSeq([]); await ctx.waitCond(() => game.phase === 'input'); await stallForAdvice('长序列,又想不起来了'); ctx.status('又是那句提示:向上滑可以重新听一遍序列'); ctx.note('向上滑——第 2 次免费重听用掉,这回听到「免费重听用完了。之后重听会花一次机会」'); await ctx.gesture('up'); await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input'); await answerRound(); // 轮边界收工 await ctx.waitCond(() => game.phase === 'roundend' || game.phase === 'gameover'); ctx.status(`收工:打完 ${game.round} 轮在轮边界停手(免费重听剩 ${game.chances} 次,账目和播的对上了)`); }