Files
SoundGame/web/harness/sessions/rhythm.mjs
T
xsl ae850a4ce0 feat(rhythm): 鼓手模式重构——屏幕六区鼓组 + 无鼓伴奏合拍(用户裁定重构)
- 屏幕3列×2行六区鼓组(列=声像左中右/行=音高上亮下沉),六件程序合成打击乐,三重冗余蒙眼可辨
- 无鼓伴奏BGM上跟随领奏 call&response + 加花句;3曲90/105/120 BPM解锁制,BPM实测89.99/105.00/120.01
- 引擎后向兼容扩展:onGesture补{x,y}坐标(单参回调零感知,15游戏+3引擎测试全量回归零失败)
- 判定简化:全部音符=分区点按零延迟,旧双击补偿/下滑折中退役;键盘Digit1-6+空格=家区
- 教学摸鼓认区(摸哪报哪,8s无触摸自动进阶)+重教≤2+降速DDA+归因真话律
- 保留旧作好评:跟随领奏核心/S~C评级/解锁制/判定口径/guide工程遗产
- 语音49id双性别+8key;R1+R2连续2轮4/4愿意玩P0=0裁定可交付;live直证33 Perfect dt≈0.00s
- 03号文档仅文末加取代指针;新设计24号;档案research/rhythm-drummer/;Mureka累计$1.35
2026-09-14 20:17:10 +08:00

113 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《节拍行者》鼓手模式新手会话剧本:首局完整走「摸鼓认区 → 教学 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 '开场旁白在播(认识你的鼓),单击可以跳过';
case 'tut':
if (game._tutStage === 'probe') {
return `摸鼓认区:随便摸,摸到 3 个不同的地方就开始(已摸 ${game._probeHeard.size} 区)`;
}
return game._cues.length
? `教学:听领奏,下一小节照样敲(${game._cues.map((c) => c.zone).join('→')})${game._tutRate < 1 ? '(领奏已放慢 25%)' : ''}`
: '教学准备中,听数拍';
case 'select': return '选曲:左右滑换曲,单击开始,下滑回大厅';
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 ?? ''}):还剩 ${left} 个应答音。领奏停了、轮到空拍,就该我出手了${fillNext ? ';有一段加花要连打' : ''}`;
}
case 'menu': return '暂停菜单:单击切选项,双击确认,上滑帮助,下滑恢复演奏';
case 'end': return '结算播报中:听完单击再来一遍,右滑下一首,下滑回大厅';
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();
};
// 开场:welcome → tut_map → tut_probe 三连旁白,逐句跳过
await ctx.waitCond(() => game.phase === 'intro' && ctx.voiceBusy());
ctx.status('开场旁白:我是鼓手,整个屏幕都是鼓。单击跳过');
for (let i = 0; i < 3; i++) { await ctx.gesture('tap'); await ctx.waitVirtual(600); }
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 步:听数拍和领奏,下一小节照着敲`);
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 === 'main');
ctx.status('教学完毕,曲目一《启程》90 拍开始。先听四下底鼓数拍,然后是「现在跟着我打」');
ctx.note('《启程》无鼓伴奏 BGM 起,四下底鼓数拍对齐小节');
// 曲中逛菜单:下滑呼出(模态暂停)→ 听选项 → 下滑恢复
//(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('tap'); // 切换选项(听到「回大厅」)
ctx.note('还是想打完这首,下滑回去');
await ctx.gesture('down'); // 恢复
await ctx.waitCond(() => game.phase === 'main');
ctx.status('听到「四拍之后,继续」,跟着四下底鼓数拍进来了');
ctx.note('BGM 从断点续播,节拍网格整体后移了一小节');
// 第一乐句故意整句不打:连 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('第一句全漏了,听到「慢了半拍,下一小节跟上我就行」');
// 之后逐音跟打(含终曲加花)
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');
await ctx.waitIdle();
ctx.status('曲子打完了(加花收尾是一记吊镲),结算播报中');
ctx.note('曲目 BGM 淡出,结算语音链播完会淡入大厅音乐');
}