/** * 《节拍行者》鼓手模式新手会话剧本(26 号深度重构版,docs/design/26-apply-rhythm.md): * 首局完整走「开场指令 → 摸鼓认区(六区摸遍,区名每区一次)→ 教学 T1-T3 跟打 → * 选曲等待点(单击开始)→ 曲目一(90 BPM)」。曲中下滑逛菜单(左右滑换项、 * 单击确认;恢复 = gd_count + 4 拍底鼓 count-in 进场)、正片第一乐句故意整句漏掉 * (连 2 Miss → gd_miss 一次),之后逐音跟打(含终曲加花)到结算。 * 分区敲击带坐标注入(引擎坐标透传契约,24 号文档 §0.3)。 */ const ZC = { TL: { x: 50, y: 100 }, TC: { x: 150, y: 100 }, TR: { x: 250, y: 100 }, BL: { x: 50, y: 500 }, BC: { x: 150, y: 500 }, BR: { x: 250, y: 500 }, }; export function describe(game) { switch (game.phase) { case 'intro': return '开场指令「点一下屏幕。」在播(critical,抢拍只给 busy 回执)'; case 'tut': if (game._tutStage === 'probe') { return `摸鼓认区(ACTION):随便摸,摸到 3 个不同的地方就开始(已摸 ${game._probeHeard.size} 区)`; } if (game._tutStage === 'idle') return '区名播完,教学第一步即将开始'; return game._cues.length ? `教学跟打:听领奏,下一小节照样敲(${game._cues.map((c) => c.zone).join('→')})${game._tutRate < 1 ? '(领奏已放慢 25%)' : ''}` : '教学准备中,听数拍'; case 'select': return '选曲(YOUR_TURN):左右滑换曲听报名,单击开始当前曲'; case 'main': { const left = game._cues.filter((c) => !c.judged).length; const fillNext = game._timed.some((e) => e.fn && game._song?.units?.some((u) => u.fill)); return `演奏中(${game._song?.title ?? ''},ACTION):还剩 ${left} 个应答音。领奏停了、轮到空拍,就该我出手了${fillNext ? ';有一段加花要连打' : ''}`; } case 'menu': return '暂停菜单(YOUR_TURN):左右滑换项听报名,单击确认,下滑关闭恢复'; case 'end': return '结算(YOUR_TURN):单击同曲再来,右滑下一首(未解锁会说明)'; default: return '开场,稍等'; } } export async function session(ctx, mock) { const { game, audio } = ctx; const t = () => audio.ctx.currentTime; // transcribe 的 root 是 {}:补上布局让分区坐标生效(引擎坐标契约的游戏层语义) game.root = { getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 600 }) }; const hit = async (zone) => { game.onGesture('tap', ZC[zone]); await mock.flush(); }; // 开场(§4.10):第一句就是 critical 指令「点一下屏幕。」→ 摸鼓探针(ACTION) await ctx.waitCond(() => game.phase === 'tut' && game._tutStage === 'probe'); ctx.status('听到「点一下屏幕。」——整面屏幕都是鼓,随便摸'); ctx.note('背景《启程》无鼓伴奏轻声循环——拍子网格的底,鼓由我来打'); // T0 探索:六区全摸一遍(区名每区一次;重复摸只出声),摸到 3 区进阶 for (const zone of ['TL', 'TC', 'TR', 'BL', 'BC', 'BR']) await hit(zone); ctx.status('六面鼓摸遍了:左上踩镲、上中高嗵、右上吊镲、左下军鼓、下中底鼓、右下低嗵'); await hit('TL'); // 重复摸:只出声不报名 await hit('BR'); ctx.status('又摸了两下重复的,只响不报名'); await ctx.waitCond(() => game._tutStage === 'step' && game._cues.length > 0); ctx.status('摸到三个不同的地方,自动开始教学第一步'); // 教学 3 步:等领奏排程好,在应答窗口内跟敲 //(注意:调度泵 interval 常驻,虚拟时钟永不"空闲"——禁止裸 waitIdle) for (let step = 0; step < 3; step++) { await ctx.waitCond(() => game._tutIdx === step && game.phase === 'tut' && game._cues.length > 0 && game._tutAttempts.length === 0); ctx.status(`教学第 ${step + 1}/3 步:听数拍和领奏(示范期点按只给 busy 回执),下一小节照着敲`); for (const cue of game._cues) { await ctx.waitCond(() => t() >= cue.expected || cue.judged); if (cue.judged) continue; await hit(cue.zone); } await ctx.waitCond(() => game._tutIdx > step || game.phase !== 'tut'); } await ctx.waitCond(() => game.phase === 'select'); ctx.status('教学完毕,进选曲:左右滑换曲听报名,单击开始'); ctx.note('左右滑换曲、单击开始——新手期没有双击手势'); // 选曲:单击开始曲目一 await ctx.gesture('tap'); await ctx.waitCond(() => game.phase === 'main'); ctx.status('曲目一《启程》90 拍开始。先听四下底鼓数拍;首个应答窗前会教「轮到你了」这个声音'); ctx.note('《启程》无鼓伴奏 BGM 起,四下底鼓数拍对齐小节;演奏期 = ACTION,点按即敲'); // 曲中逛菜单:下滑呼出(模态暂停)→ 左右滑换项听报名 → 下滑恢复 //(恢复 = gd_count「四拍之后,继续」+ 4 拍底鼓 count-in 进场) await ctx.waitCond(() => t() >= game._bgmT0 + 1.5); await ctx.gesture('down'); await ctx.waitCond(() => game.phase === 'menu'); ctx.status('曲中暂停,进了菜单(左右滑换项,单击确认)'); ctx.note('BGM 在断点冻住——谱面、判定、音乐全部暂停'); await ctx.waitVirtual(1100); // 菜单 1s 去抖独占期 await ctx.gesture('left'); // 换到「回大厅」听报名 ctx.note('还是想打完这首,右滑切回「继续演奏」,再下滑回去'); await ctx.gesture('right'); await ctx.gesture('down'); // 关闭恢复 await ctx.waitCond(() => game.phase === 'main'); ctx.status('听到「四拍之后,继续」,跟着四下底鼓数拍进来了'); ctx.note('BGM 从断点续播,节拍网格整体后移了一小节'); // 第一乐句故意整句不打:连 2 次 Miss → gd_miss 一次(同曲一次,曲尾 3s 不插) const firstPhrase = game._cues.filter((c) => c.phrase === 0); ctx.silence(5, '还在数拍子,第一句没敢出手'); await ctx.waitCond(() => firstPhrase.every((c) => c.judged)); ctx.status('第一句全漏了,听到一次「慢了半拍,下一小节跟上我就行」'); // 之后逐音跟打(含终曲加花) for (const cue of game._cues) { if (cue.judged) continue; await ctx.waitCond(() => t() >= cue.expected || cue.judged); if (cue.judged) continue; await hit(cue.zone); } await ctx.waitCond(() => game.phase === 'end'); ctx.status('曲子打完了(加花收尾是一记吊镲),结算播报中(单击同曲再来,右滑下一首)'); ctx.note('曲目 BGM 淡出,结算语音链播完会淡入大厅音乐,然后交出结算等待点'); }