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
This commit is contained in:
@@ -1,22 +1,30 @@
|
||||
/**
|
||||
* 《节拍行者》新手会话剧本:首局完整走教学 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「慢了半拍」——正片规则不变),之后跟着领奏把一曲打完到结算。
|
||||
* 《节拍行者》鼓手模式新手会话剧本:首局完整走「摸鼓认区 → 教学 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': return game._cues.length
|
||||
? `教学:听领奏,下一小节照样打(${game._cues.map((c) => c.gesture).join('→')})${game._tutRate < 1 ? '(领奏已放慢 25%)' : ''}`
|
||||
: '教学准备中,听鼓点';
|
||||
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;
|
||||
return `演奏中(${game._song?.title ?? ''}):还剩 ${left} 个应答音。领奏停了、轮到空拍,就该我出手了`;
|
||||
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 '结算播报中:听完单击再来一遍,右滑下一首,下滑回大厅';
|
||||
@@ -27,60 +35,61 @@ export function describe(game) {
|
||||
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 听完进教学
|
||||
await ctx.waitCond(() => game.phase === 'tut');
|
||||
ctx.status('教学开始了,先听领奏');
|
||||
// 开场: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('背景音乐《启程》无鼓伴奏已在轻声循环——拍子网格的底,鼓由我来打');
|
||||
|
||||
// 教学 3 步:等领奏排程好,在应答窗口内跟打
|
||||
// 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.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;
|
||||
}
|
||||
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) {
|
||||
const at = cue.expected - (cue.gesture === 'tap' ? -0.28 : 0.04); // tap 补偿引擎 ~300ms 延迟
|
||||
await ctx.waitCond(() => t() >= at || cue.judged);
|
||||
await ctx.waitCond(() => t() >= cue.expected || cue.judged);
|
||||
if (cue.judged) continue;
|
||||
await ctx.gesture(cue.gesture);
|
||||
await hit(cue.zone);
|
||||
}
|
||||
await ctx.waitCond(() => game._tutIdx > step || game.phase !== 'tut');
|
||||
}
|
||||
await ctx.waitCond(() => game.phase === 'main');
|
||||
ctx.status('教学完毕,曲目一《启程》90 拍开始。领奏响之前先听到「现在跟着我打」');
|
||||
ctx.status('教学完毕,曲目一《启程》90 拍开始。先听四下底鼓数拍,然后是「现在跟着我打」');
|
||||
ctx.note('《启程》无鼓伴奏 BGM 起,四下底鼓数拍对齐小节');
|
||||
|
||||
// 曲中逛菜单:下滑呼出(模态暂停)→ 听选项 → 下滑恢复
|
||||
//(round3 G3:恢复先播「四拍之后,继续」,再跟 4 拍底鼓 count-in 进场)
|
||||
//(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'); // 切换选项(听到「回大厅」)
|
||||
await ctx.waitIdle();
|
||||
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);
|
||||
@@ -88,16 +97,16 @@ export async function session(ctx, mock) {
|
||||
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);
|
||||
await ctx.waitCond(() => t() >= cue.expected || cue.judged);
|
||||
if (cue.judged) continue;
|
||||
await ctx.gesture(cue.gesture);
|
||||
await hit(cue.zone);
|
||||
}
|
||||
|
||||
await ctx.waitCond(() => game.phase === 'end');
|
||||
await ctx.waitIdle();
|
||||
ctx.status('曲子打完了,结算播报中');
|
||||
ctx.status('曲子打完了(加花收尾是一记吊镲),结算播报中');
|
||||
ctx.note('曲目 BGM 淡出,结算语音链播完会淡入大厅音乐');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user