/** * 《节拍行者》新手会话剧本:首局完整走教学 T1-T3(第 2 句先整拍不出手—— * round4 H5 归因真话律:听到的是「这拍没接」而不是「慢了半拍」;再连错一遍 * 触发 round3 G2 的「降速 25% 重演 + gd_slow」,跟着慢速领奏做对后恢复正常) * → 曲目一(90 BPM);曲中下滑呼出菜单逛一圈再恢复(round3 G3:恢复先听 * gd_count「四拍之后,继续」,再跟 4 拍底鼓 count-in 进场;首个应答窗口前 * 整整 1 小节有 gd_answer+turn 交接标记);正片第一乐句故意整句漏掉(连 2 Miss * 触发向导 gd_miss「慢了半拍」——正片规则不变),之后跟着领奏把一曲打完到结算。 */ export function describe(game) { switch (game.phase) { case 'intro': return '开场旁白在播,单击可以跳过'; case 'tut': return game._cues.length ? `教学:听领奏,下一小节照样打(${game._cues.map((c) => c.gesture).join('→')})${game._tutRate < 1 ? '(领奏已放慢 25%)' : ''}` : '教学准备中,听鼓点'; case 'select': return '选曲:左右滑换曲,单击开始,下滑回大厅'; case 'main': { const left = game._cues.filter((c) => !c.judged).length; return `演奏中(${game._song?.title ?? ''}):还剩 ${left} 个应答音。领奏停了、轮到空拍,就该我出手了`; } case 'menu': return '暂停菜单:单击切选项,双击确认,上滑帮助,下滑恢复演奏'; case 'end': return '结算播报中:听完单击再来一遍,右滑下一首,下滑回大厅'; default: return '开场,稍等'; } } export async function session(ctx, mock) { const { game, audio } = ctx; const t = () => audio.ctx.currentTime; // 开场:welcome 听完进教学 await ctx.waitCond(() => game.phase === 'tut'); ctx.status('教学开始了,先听领奏'); // 教学 3 步:等领奏排程好,在应答窗口内跟打 //(注意:调度泵 interval 常驻,虚拟时钟永不"空闲"——禁止裸 waitIdle) for (let step = 0; step < 3; step++) { await ctx.waitCond(() => game.phase === 'tut' && game._cues.length > 0 && game._tutAttempts.length === 0); ctx.status(`教学第 ${step + 1}/3 句`); if (step === 1) { // round4 H5 剧本:这一句先整拍不出手——归因必须是「这拍没接」(gd_miss_none), // 不是「慢了半拍」;再连错一遍(整句不出手)听它放慢一些再带着打(G2)。 // 注意 _tutEvaluate 判定即把 _phaseEnd 置 Infinity 防重入——等待一律挂 // 「持久里程碑」条件(_tutFails/_tutSlowUsed),别挂阶段末时刻。 await ctx.waitCond(() => game._tutFails === 1); // 失败①:没出手 ctx.status('第一遍整拍没出手——听到的是「这拍没接。我停下的时候,就轮到你了」,不是「慢了半拍」'); await ctx.waitCond(() => game._phaseEnd < 1e8 && t() < game._phaseEnd - 1); // 纠正链播完、重新领奏 ctx.status('又重新领奏了一遍,还是没敢出手'); await ctx.waitCond(() => game._tutSlowUsed === true); // 失败② → 触发降速重演 await ctx.waitCond(() => game._phaseEnd < 1e8 && t() < game._phaseEnd - 1); // gd_slow 播完、慢速领奏已排 ctx.status('听到「我放慢一些,跟着试试」——领奏降速 25% 重演,这回跟着慢拍打'); for (const cue of game._cues) { const at = cue.expected - (cue.gesture === 'tap' ? -0.28 : 0.04); await ctx.waitCond(() => t() >= at || cue.judged); if (cue.judged) continue; await ctx.gesture(cue.gesture); } await ctx.waitCond(() => game._tutIdx > step || game.phase !== 'tut'); continue; } for (const cue of game._cues) { const at = cue.expected - (cue.gesture === 'tap' ? -0.28 : 0.04); // tap 补偿引擎 ~300ms 延迟 await ctx.waitCond(() => t() >= at || cue.judged); if (cue.judged) continue; await ctx.gesture(cue.gesture); } await ctx.waitCond(() => game._tutIdx > step || game.phase !== 'tut'); } await ctx.waitCond(() => game.phase === 'main'); ctx.status('教学完毕,曲目一《启程》90 拍开始。领奏响之前先听到「现在跟着我打」'); // 曲中逛菜单:下滑呼出(模态暂停)→ 听选项 → 下滑恢复 //(round3 G3:恢复先播「四拍之后,继续」,再跟 4 拍底鼓 count-in 进场) await ctx.waitCond(() => t() >= game._bgmT0 + 1.5); await ctx.gesture('down'); await ctx.waitCond(() => game.phase === 'menu'); ctx.status('曲中暂停,进了菜单'); await ctx.gesture('tap'); // 切换选项(听到「回大厅」) await ctx.waitIdle(); ctx.note('还是想打完这首,下滑回去'); await ctx.gesture('down'); // 恢复 await ctx.waitCond(() => game.phase === 'main'); ctx.status('听到「四拍之后,继续」,跟着四下底鼓数拍进来了'); // 第一乐句故意整句不打:连 2 次 Miss → 向导 gd_miss const firstPhrase = game._cues.filter((c) => c.phrase === 0); ctx.silence(5, '还在数拍子,第一句没敢出手'); await ctx.waitCond(() => firstPhrase.every((c) => c.judged)); ctx.status('第一句全漏了,听到「慢了半拍,下一小节跟上我就行」'); // 之后逐音跟打(tap 注入留出引擎 ~300ms 回调延迟) for (const cue of game._cues) { if (cue.judged) continue; const at = cue.expected - (cue.gesture === 'tap' ? -0.28 : 0.04); await ctx.waitCond(() => t() >= at || cue.judged); if (cue.judged) continue; await ctx.gesture(cue.gesture); } await ctx.waitCond(() => game.phase === 'end'); await ctx.waitIdle(); ctx.status('曲子打完了,结算播报中'); }