live试玩round1+round2修复: 引擎gesture新增tapcancel(immediateTap误判定回滚,默认关闭零行为变化)+ rhythm判定账簿/超时Miss复活/菜单1s去抖/正片下滑永不弹菜单 + space判定冷静期800ms/暂停恢复3颗缓冲/回大厅leaving收尾/hit_last在播标记 + storyline intro满播不掐/抉择25s兜底 + simon免费重听两层账 + escape教学链自动接播/90s高频零提示 + rpg擦伤零误报/tap不丢 + guide.test适配去抖期(语义不变)+ 文档同步(13号§9-§19/web-engine-api/README)。回归:storyline82/escape140/simon132/rhythm141/space155/rpg188/guide122共921断言全绿
This commit is contained in:
+271
-28
@@ -176,9 +176,16 @@ const voicePlays = () => playLog.filter((p) => p.id.startsWith('voice/')).map((p
|
||||
const lastVoice = () => { const v = voicePlays(); return v[v.length - 1] || ''; };
|
||||
const voicePlayed = (id) => voicePlays().includes(id);
|
||||
|
||||
// 按 cue 期望时刻注入 Perfect 应答;tap 模拟真实回调延迟 +0.30s(补偿判定路径)
|
||||
// 按 cue 期望时刻注入应答;模拟真实识别延迟(immediateTap:tap 按下即达 +0;
|
||||
// 滑动识别在 touchend +0.15 = SWIPE_DELAY;双击 +0.20 = DOUBLE_DELAY)——
|
||||
// game.js 判定补偿回到触摸时刻后 dt≈0(§13 替换口径)。
|
||||
// 双击前先注入序曲 tap——immediateTap 的真实行为(第一下按下即发 tap),
|
||||
// 否则游戏的旧引擎闩锁(双击前 350ms 无 tap = 旧引擎)会把测试误判成旧引擎
|
||||
async function answerCue(cue) {
|
||||
await runUntil(cue.expected + (cue.gesture === 'tap' ? 0.30 : 0));
|
||||
if (cue.judged) return;
|
||||
const lag = cue.gesture === 'tap' ? 0 : cue.gesture === 'doubletap' ? 0.20 : 0.15;
|
||||
await runUntil(cue.expected + lag);
|
||||
if (cue.gesture === 'doubletap') G('tap'); // 序曲 tap(附近无 0.35s 内未判定 tap 音符,出声不判定)
|
||||
G(cue.gesture);
|
||||
await flush();
|
||||
}
|
||||
@@ -192,20 +199,29 @@ await flush();
|
||||
assert(game.phase === 'intro', 'start 后进 intro');
|
||||
assert(voicePlayed('voice/welcome'), 'welcome 旁白开始播放');
|
||||
|
||||
// —— 旁白跳过①:intro 阶段 voice 播放中单击 = skipVoice(链推进到 tut_1,不重头)——
|
||||
// —— 旁白跳过①:intro 阶段 voice 播放中单击 = skipVoice(immediateTap 下延迟
|
||||
// 500ms 裁决——防序曲 tap;advance 后链推进到 tut_1,不重头)——
|
||||
assert(!!audio._voice, 'welcome 播放中(voice token 存在)');
|
||||
G('tap'); // 单击跳过 welcome
|
||||
await flush();
|
||||
await advance(600);
|
||||
assert(lastVoice() === 'voice/tut_1', '单击跳过 welcome → 链推进到 tut_1');
|
||||
|
||||
// —— 教学 T1-T3:按 cues.expected 做手势(tap 注入在 expected+0.30 仍应判对)——
|
||||
for (let step = 0; step < 3; step++) {
|
||||
// —— 教学 5 步(§13:单击 → 左右 → 上滑 → 下滑 → 三音带双击):按 cues.expected
|
||||
// 做手势(识别延迟注入,补偿后 dt≈0)——
|
||||
for (let step = 0; step < 5; step++) {
|
||||
await waitFor(() => game._tutIdx === step && game.phase === 'tut'
|
||||
&& game._cues.length > 0 && game._tutAttempts.length === 0, `T${step + 1} 领奏排程`);
|
||||
assert(game._tutIdx === step, `教学步进 T${step + 1}(_tutIdx=${step})`);
|
||||
const leadNoteCount = srcLog.filter((s) => s.url && s.url.includes('/sfx/inst_')).length;
|
||||
if (step === 2) { // 上滑步:先注入序曲 tap(immediateTap 副作用),不得产生尝试记录
|
||||
await runUntil(game._cues[0].expected);
|
||||
G('tap');
|
||||
await flush();
|
||||
assert(game._tutAttempts.length === 0, 'T3 序曲 tap 不产生尝试记录(tap 只认领 tap 音符)');
|
||||
}
|
||||
for (const cue of game._cues) await answerCue(cue);
|
||||
assert(game._tutAttempts.every((a) => a.correct && Math.abs(a.dt) <= 0.25), `T${step + 1} 应答全部正确且在 Good 窗口内`);
|
||||
assert(game._tutAttempts.every((a) => a.correct && Math.abs(a.dt) <= 0.35), `T${step + 1} 应答全部正确且在 Good 窗口内`);
|
||||
if (step === 3) assert(game.phase === 'tut', 'T4 下滑步:临近 down 音符时下滑 = 演奏应答,不跳教学');
|
||||
assert(srcLog.filter((s) => s.url && s.url.includes('/sfx/inst_')).length > leadNoteCount, `T${step + 1} 领奏音符已排程播放`);
|
||||
await runUntil(game._phaseEnd + 0.2); // 触发 _tutEvaluate
|
||||
await flush();
|
||||
@@ -218,9 +234,15 @@ assert(voicePlayed('voice/tut_done'), 'tut_done 播报');
|
||||
await waitFor(() => game.phase === 'main' && game._cues.length > 0, '曲目一 main 开始');
|
||||
assert(game._song.id === 'song1' && game._songIdx === 0, '教学后进入曲目一');
|
||||
assert(srcLog.some((s) => s.url && s.url.includes('/bgm/track90')), '曲目一 BGM track90 按网格启动');
|
||||
const firstTapCue = game._cues.find((c) => c.gesture === 'tap');
|
||||
// —— 旧引擎 tap 补偿(§13 闩锁回退路径):_immediateTap=false 时回调 -0.30s 判定 ——
|
||||
const legacyTapCue = game._cues.find((c) => c.gesture === 'tap');
|
||||
game._immediateTap = false;
|
||||
await runUntil(legacyTapCue.expected + 0.30);
|
||||
G('tap');
|
||||
await flush();
|
||||
assert(legacyTapCue.result === 'perfect', '旧引擎 tap 补偿:闩锁回退后 expected+0.30s 注入的 tap 仍判 Perfect');
|
||||
game._immediateTap = true;
|
||||
for (const cue of game._cues) await answerCue(cue);
|
||||
assert(firstTapCue.result === 'perfect', 'tap 补偿:expected+0.30s 注入的 tap 仍判 Perfect(回调时刻 -0.30s 判定)');
|
||||
assert(game._stats.perfect === game._cues.length, `曲目一 31 个音符全部 Perfect(perfect=${game._stats.perfect})`);
|
||||
assert(game._stats.miss === 0 && game._stats.combo === game._cues.length, '无 Miss 且连击不断');
|
||||
|
||||
@@ -231,20 +253,26 @@ assert(game._progress.unlocked === 2, 'unlocked === 2(B 以上解锁下一首
|
||||
await flush();
|
||||
assert(voicePlayed('voice/grade_s'), 'grade_s 播报(结算语音链首句)');
|
||||
|
||||
// —— 旁白跳过②:结算语音链播放中单击 = 跳下一句,不是"再来一遍" ——
|
||||
// —— 旁白跳过②:结算语音链播放中单击 = 跳下一句(immediateTap 下延迟 500ms
|
||||
// 裁决生效),不是"再来一遍" ——
|
||||
await waitFor(() => !!audio._voice, '结算语音链开始');
|
||||
const phaseBeforeSkip = game.phase;
|
||||
G('tap');
|
||||
await flush();
|
||||
assert(game.phase === 'end' && phaseBeforeSkip === 'end', '结算播报中单击 = 跳句(phase 保持 end,未误触重开)');
|
||||
await advance(600);
|
||||
assert(game.phase === 'end', '结算播报中单击 = 跳句(phase 保持 end,未误触重开)');
|
||||
assert(lastVoice() === 'voice/new_best_combo', '单击跳过 grade_s → 链推进到 new_best_combo');
|
||||
|
||||
// —— 结算 BGM:语音链播完后淡入 bgm/menu(10 文档 §5)——
|
||||
// —— 结算 BGM:语音链播完后淡入 bgm/menu(10 文档 §5);§13:起播点不早于链末句 ——
|
||||
await waitFor(() => audio._bgm && audio._bgm.id === 'bgm/menu', '结算语音链播完后 menu pad 淡入');
|
||||
assert(voicePlayed('voice/unlock_song2'), 'unlock_song2 播报(语音链后段)');
|
||||
assert(true, '结算语音链播完后 bgm/menu 播放(引擎层)');
|
||||
{
|
||||
const menuPlayIdx = playLog.findIndex((p) => p.id === 'bgm/menu');
|
||||
const restartIdx = playLog.map((p) => p.id).lastIndexOf('voice/restart');
|
||||
assert(menuPlayIdx > restartIdx && restartIdx >= 0, '结算 menu pad 在语音链末句之后起播(不骑链尾)');
|
||||
}
|
||||
|
||||
// —— 结算单击 = 同曲再来一遍;menu pad 应被 stopBgm(400) 停掉 ——
|
||||
G('tap');
|
||||
await advance(600);
|
||||
await flush();
|
||||
assert(game.phase === 'main' && game._songIdx === 0, '结算单击 = 同曲再来一遍');
|
||||
assert(audio._bgmWanted === null && audio._bgm === null, '选定曲目后 menu pad 停止(stopBgm(400))');
|
||||
@@ -271,9 +299,15 @@ await flush();
|
||||
assert(game.phase === 'menu', '下滑折中:无临近 down 音符时下滑 = 呼出菜单');
|
||||
assert(!playLog.some((p) => p.id === 'bgm/menu' && p.t >= menuOpenAt), '暂停菜单保持静默(不叠 menu pad,10 文档 §5)');
|
||||
assert(voicePlayed('voice/menu_main'), '菜单语音 menu_main 播报');
|
||||
await advance(3000); // 菜单停留 3s
|
||||
// —— 暂停真冻结(§13,live round1 P1):菜单期间判定与谱面全停 ——
|
||||
const missAtMenu = game._stats.miss;
|
||||
const srcAtMenu = srcLog.length;
|
||||
await advance(2000); // 菜单停留 2s(menu_main 播完、menu_resume 在播)
|
||||
assert(game._stats.miss === missAtMenu, '菜单开启 2s:无新 Miss(判定冻结)');
|
||||
assert(srcLog.length === srcAtMenu, '菜单开启 2s:无新排程音符(谱面冻结)');
|
||||
G('down'); // 关菜单
|
||||
await flush();
|
||||
assert(!audio.isVoicePlaying(), '关菜单即停菜单语音链(菜单语音不骑恢复后的领奏)');
|
||||
const pauseDur = (nowMs + 100 - menuOpenAt) / 1000; // resumeAt = 关菜单时刻 +0.1
|
||||
assert(game.phase === 'main', '关菜单恢复 main');
|
||||
const shift = game._bgmT0 - bgmT0Before;
|
||||
@@ -283,16 +317,18 @@ assert(Math.abs(game._phaseEnd - phaseEndBefore - shift) < 1e-6, '_phaseEnd 同
|
||||
const lastTrack105 = srcLog.filter((s) => s.url && s.url.includes('/bgm/track105')).pop();
|
||||
assert(lastTrack105 && lastTrack105.offset > 0, `BGM 断点续播(offset=${lastTrack105 && lastTrack105.offset.toFixed(2)}s > 0)`);
|
||||
|
||||
// —— Miss:窗口内做错手势 → 无乐器音 + error 震动 + combo 清零 ——
|
||||
// —— Miss:窗口内做错手势 → 不播该音符的乐器音 + 闷镲 Miss 音 + error 震动 + combo 清零 ——
|
||||
const missCue = game._cues.find((c) => !c.judged && c.gesture === 'tap');
|
||||
await runUntil(missCue.expected + 0.1);
|
||||
const instPlaysBefore = playLog.filter((p) => p.id.startsWith('sfx/inst_')).length;
|
||||
const kickPlaysBefore = playLog.filter((p) => p.id === 'sfx/inst_kick').length;
|
||||
const srcBeforeMiss = srcLog.length;
|
||||
const vibBefore = vibLog.length;
|
||||
game._stats.combo = 5; // 制造非零 combo,验证清零
|
||||
G('left'); // 错手势(该 cue 期望 tap,附近无 left cue)
|
||||
await flush();
|
||||
assert(missCue.result === 'miss', '窗口内错手势判 Miss');
|
||||
assert(playLog.filter((p) => p.id.startsWith('sfx/inst_')).length === instPlaysBefore, 'Miss 不播乐器音(错过=没出声)');
|
||||
assert(playLog.filter((p) => p.id === 'sfx/inst_kick').length === kickPlaysBefore, 'Miss 不播该音符的乐器音(错过=没出声)');
|
||||
assert(srcLog.slice(srcBeforeMiss).some((s) => s.url && s.url.includes('inst_hat_l')), 'Miss 有闷镲反馈音(inst_hat_l 降调复用,§13)');
|
||||
assert(vibLog.slice(vibBefore).some((p) => Array.isArray(p) && p[0] === 60), 'Miss 触发 error 震动');
|
||||
assert(game._stats.combo === 0, 'Miss 后 combo 清零');
|
||||
|
||||
@@ -304,13 +340,34 @@ for (const cue of game._cues) { // 把 down cue 之前的未判定音符应答
|
||||
}
|
||||
const downCue = game._cues.find((c) => !c.judged && c.gesture === 'down');
|
||||
assert(!!downCue, '找到 song2 的 down 应答音符');
|
||||
await runUntil(downCue.expected);
|
||||
await runUntil(downCue.expected + 0.15); // 真实滑动识别延迟 +0.15s(补偿后 dt≈0)
|
||||
G('down');
|
||||
await flush();
|
||||
assert(downCue.result === 'perfect', '临近 down 音符时下滑 = 演奏判定(Perfect)');
|
||||
assert(game.phase === 'main', '演奏判定后不开菜单(phase 保持 main)');
|
||||
assert(playLog.some((p) => p.id === 'sfx/inst_tom_lo'), 'down 命中播放 inst_tom_lo');
|
||||
|
||||
// —— 下滑折中③(§13,live round1 P0):慢滑(识别 +0.35s)踩点下滑仍判演奏 ——
|
||||
const downCue2 = game._cues.find((c) => !c.judged && c.gesture === 'down');
|
||||
assert(!!downCue2, '找到 song2 的第二个 down 应答音符');
|
||||
await runUntil(downCue2.expected + 0.35);
|
||||
G('down');
|
||||
await flush();
|
||||
assert(downCue2.result === 'good', '慢滑(识别 +0.35s)踩点下滑判 Good,不弹菜单');
|
||||
assert(game.phase === 'main', '慢滑下滑后 phase 保持 main(未开菜单)');
|
||||
|
||||
// —— 长按定义(§13,live round1 P0):Miss 反馈音 + error 震动,不判定不归属 ——
|
||||
const judgedBeforeLp = game._cues.filter((c) => c.judged).length;
|
||||
const srcBeforeLp = srcLog.length;
|
||||
const vibBeforeLp = vibLog.length;
|
||||
G('longpress');
|
||||
await flush();
|
||||
assert(game._cues.filter((c) => c.judged).length === judgedBeforeLp, '长按不判定任何音符');
|
||||
assert(srcLog.slice(srcBeforeLp).some((s) => s.url && s.url.includes('inst_hat_l')), '长按播 Miss 反馈音(死输入给定义)');
|
||||
assert(vibLog.slice(vibBeforeLp).some((p) => Array.isArray(p) && p[0] === 60), '长按触发 error 震动');
|
||||
G('longpressend');
|
||||
await flush();
|
||||
|
||||
// —— 结算末位:song3 结算页右滑 → 播 restart,不播 song_locked(round3-4 裁定①回归) ——
|
||||
game._startSong(game._runId, 2);
|
||||
game._finish(game._runId);
|
||||
@@ -323,6 +380,13 @@ const newVoices = playLog.slice(playMark).map((p) => p.id);
|
||||
assert(newVoices.includes('voice/restart'), '末位曲目右滑 → 播 voice/restart');
|
||||
assert(!newVoices.includes('voice/song_locked'), '末位曲目右滑 → 不播 song_locked');
|
||||
|
||||
// —— 回大厅(§13,live round1 P1):确认语音 + leaving 收尾态 + 真实跳转 ——
|
||||
G('down');
|
||||
await flush();
|
||||
assert(game.phase === 'leaving', '结算屏下滑进入 leaving(收尾态,不再受理手势)');
|
||||
assert(voicePlayed('voice/menu_lobby'), '回大厅前播确认语音 menu_lobby');
|
||||
await waitFor(() => window.location.href === '../', '确认语音后跳转大厅');
|
||||
|
||||
assert(gestures <= 200, `手势预算内(${gestures} ≤ 200)`);
|
||||
|
||||
// ================= 场景 B:二周目选曲界面(menu pad + 选曲播报跳过 + 负偏移保护) =================
|
||||
@@ -335,20 +399,23 @@ await waitFor(() => game.phase === 'select', '进入选曲界面');
|
||||
assert(audio._bgm && audio._bgm.id === 'bgm/menu', '选曲界面播放 bgm/menu(10 文档 §5)');
|
||||
|
||||
await waitFor(() => !!audio._voice, 'select_hint 播报中');
|
||||
G('tap'); // 跳过 select_hint → song1_name
|
||||
await flush();
|
||||
G('tap'); // 跳过 select_hint → song1_name(immediateTap 下延迟 500ms 裁决)
|
||||
await advance(600);
|
||||
assert(game.phase === 'select' && lastVoice() === 'voice/song1_name', '选曲播报单击 = 跳下一句(phase 保持 select)');
|
||||
G('right'); // 切到 song2(未解锁)
|
||||
await flush();
|
||||
await advance(600); // 序曲 tap 裁决窗:右滑不得误触发"选定曲目"
|
||||
assert(game.phase === 'select', '选曲右滑的序曲 tap 被作废(phase 保持 select,未误选)');
|
||||
assert(game._songIdx === 1 && lastVoice() === 'voice/song_locked', '右滑切到未解锁曲目 → song_locked');
|
||||
G('tap'); // 跳过 song_locked
|
||||
await flush();
|
||||
await advance(600);
|
||||
assert(game.phase === 'select', 'song_locked 可跳过且不离开选曲');
|
||||
G('left'); // 切回 song1
|
||||
await flush();
|
||||
assert(game._songIdx === 0, '左滑切回 song1');
|
||||
G('tap'); // 序曲 tap(模拟 immediateTap 副作用:左滑的触摸起点)
|
||||
G('left'); // 随后滑动到达 → 序曲 tap 应被作废
|
||||
await advance(600);
|
||||
assert(game.phase === 'select' && game._songIdx === 0, '序曲 tap 被随后滑动作废;左滑正常切回 song1');
|
||||
await waitFor(() => !audio._voice, '选曲播报播完');
|
||||
G('tap'); // 无语音时单击 = 确认选曲
|
||||
await advance(600);
|
||||
await flush();
|
||||
assert(game.phase === 'main' && game._song.id === 'song1', '无语音时单击 = 选定曲目进 main');
|
||||
assert(audio._bgmWanted === null && audio._bgm === null, '进曲目后 menu pad 停止');
|
||||
@@ -369,7 +436,8 @@ console.log('\n[场景 C] 教学失败重教 → 2 次失败跳过该步 → 下
|
||||
await newWorld();
|
||||
game.start();
|
||||
await flush();
|
||||
G('tap'); // 跳 welcome
|
||||
G('tap'); // 跳 welcome(immediateTap 下延迟 500ms 裁决)
|
||||
await advance(600);
|
||||
await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'T1 领奏');
|
||||
// 失败①:做错手势
|
||||
await runUntil(game._cues[0].expected + 0.3);
|
||||
@@ -392,6 +460,48 @@ assert(voicePlayed('voice/tut_skip'), '下滑跳教学播 tut_skip');
|
||||
await waitFor(() => game.phase === 'main', '跳教学后进入曲目一');
|
||||
assert(game._progress.tutorialDone === true, '跳教学同样持久化 tutorialDone');
|
||||
|
||||
// ================= 场景 C2:教学全程零通过 → 收尾播中性句不夸"很好"(§13) =================
|
||||
console.log('\n[场景 C2] 教学 5 步全部失败跳过 → retry_skip 收尾,不播 tut_done');
|
||||
await newWorld();
|
||||
game.start();
|
||||
await flush();
|
||||
G('tap'); // 跳 welcome
|
||||
await advance(600);
|
||||
for (let step = 0; step < 5; step++) {
|
||||
await waitFor(() => game._tutIdx === step && game.phase === 'tut' && game._cues.length > 0, `C2 T${step + 1} 领奏`);
|
||||
const leadMark = game._cues[0].expected;
|
||||
await runUntil(game._phaseEnd + 0.2); // 失败①:完全不出手
|
||||
await flush();
|
||||
// 零尝试时 attempts 恒为空——必须等"重教的新领奏"(cues 预期时刻刷新),
|
||||
// 否则条件瞬间为真、下一步 runUntil(Infinity) 直接跑完整个教学
|
||||
await waitFor(() => game._tutFails === 1 && game._cues.length > 0
|
||||
&& game._cues[0].expected !== leadMark && game.phase === 'tut', `C2 T${step + 1} 重教`);
|
||||
await runUntil(game._phaseEnd + 0.2); // 失败②:完全不出手 → 跳过该步
|
||||
await flush();
|
||||
}
|
||||
await waitFor(() => game.phase === 'main', 'C2 零通过后进入曲目一');
|
||||
assert(game._tutPassedAny === false, 'C2 全程零通过(_tutPassedAny === false)');
|
||||
assert(voicePlayed('voice/retry_skip'), 'C2 零通过收尾播 retry_skip(中性句)');
|
||||
assert(!voicePlayed('voice/tut_done'), 'C2 零通过不播 tut_done(不夸"很好")');
|
||||
|
||||
// ================= 场景 C3:滞留语音链看门狗作废(§13,live 实测深修回归) =================
|
||||
console.log('\n[场景 C3] 教学纠正链被跳教学顶掉后,滞留看门狗不得把游戏拖回 tut');
|
||||
await newWorld();
|
||||
game.start();
|
||||
await flush();
|
||||
G('tap'); // 跳 welcome
|
||||
await advance(600);
|
||||
await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'C3 T1 领奏');
|
||||
await runUntil(game._phaseEnd + 0.2); // 失败①:不出手 → 纠正链 [retry_listen, retry_listen]
|
||||
await flush();
|
||||
assert(game._tutFails === 1 && audio.isVoicePlaying(), 'C3 纠正链在播(fail=1)');
|
||||
G('down'); // 下滑跳教学:tut_skip 链顶掉纠正链(其看门狗 10s 后到期)
|
||||
await flush();
|
||||
assert(voicePlayed('voice/tut_skip'), 'C3 下滑跳教学播 tut_skip');
|
||||
await waitFor(() => game.phase === 'main', 'C3 跳教学后进入曲目一');
|
||||
await advance(12000); // 越过滞留看门狗的 10s 窗口
|
||||
assert(game.phase === 'main', 'C3 滞留看门狗已作废:12s 后仍在 main(未被拖回 tut)');
|
||||
|
||||
// ================= 场景 D:语音资产缺失 → 看门狗推进,不卡死不崩 =================
|
||||
console.log('\n[场景 D] 看门狗:屏蔽 welcome 资产');
|
||||
await newWorld();
|
||||
@@ -432,6 +542,139 @@ settings.set({ bgm: 1.0 });
|
||||
await flush();
|
||||
assert(audio._bgm && audio._bgm.id === 'bgm/menu', 'bgm 调回非 0 自动补播最后状态 BGM');
|
||||
|
||||
// ================= 场景 F:live round2 判定治理(序曲 tap 撤销 / 超时复活 / 下滑不弹菜单 / 菜单去抖) =================
|
||||
console.log('\n[场景 F] live round2:tapcancel 撤销 / 超时 Miss 复活 / 正片下滑不弹菜单 / 菜单去抖独占');
|
||||
// —— F3 教学:tapcancel 撤销序曲 tap 尝试记录(转滑动/长按的序曲不算出手)——
|
||||
await newWorld();
|
||||
game.start();
|
||||
await flush();
|
||||
G('tap'); // 跳 welcome(延迟 500ms 裁决)
|
||||
await advance(600);
|
||||
await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'F3 T1 领奏');
|
||||
await runUntil(game._cues[0].expected);
|
||||
G('tap'); // 序曲 tap(T1 是 tap 音符,先记尝试)
|
||||
await flush();
|
||||
assert(game._tutAttempts.length === 1, 'F3 序曲 tap 先记教学尝试');
|
||||
G('tapcancel'); // 引擎补发:本次按下转滑动/长按 → 序曲 tap 作废
|
||||
await flush();
|
||||
assert(game._tutAttempts.length === 0, 'F3 tapcancel 撤销教学尝试记录(live round2 P0)');
|
||||
|
||||
// —— 正片判定治理:钩子直进 song2(105BPM,含 3 个 down 音符)——
|
||||
await newWorld({ tutorialDone: true, unlocked: 3, grades: {}, bestCombo: 0 });
|
||||
game.start();
|
||||
await flush();
|
||||
await waitFor(() => game.phase === 'select', 'F 进入选曲');
|
||||
game._startSong(game._runId, 1);
|
||||
await flush();
|
||||
await waitFor(() => game.phase === 'main' && game._cues.length > 0, 'F song2 排程就绪');
|
||||
|
||||
// —— F1 tapcancel 撤销序曲 tap 命中(声音已出收不回,判定必须撤销)——
|
||||
const fTapCue = game._cues.find((c) => c.gesture === 'tap');
|
||||
await runUntil(fTapCue.expected);
|
||||
G('tap');
|
||||
await flush();
|
||||
assert(fTapCue.result === 'perfect', 'F1 序曲 tap 先按 tap 音符判定(Perfect)');
|
||||
assert(game._tapJudg.length === 1, 'F1 序曲 tap 判定已记账');
|
||||
G('tapcancel'); // 引擎补发:转滑动/长按 → 序曲 tap 作废
|
||||
await flush();
|
||||
assert(fTapCue.judged === false && fTapCue.result === null, 'F1 tapcancel 撤销序曲 tap 判定(音符回到未判定,不计 Miss)');
|
||||
assert(game._stats.perfect === 0 && game._stats.combo === 0, 'F1 命中统计回滚(perfect/combo 归零)');
|
||||
await runUntil(fTapCue.expected + 0.2);
|
||||
G('tap'); // 重新接打(撤销不是免判)
|
||||
await flush();
|
||||
assert(fTapCue.result === 'good', 'F1 撤销后重新接打 → Good');
|
||||
|
||||
// —— F2 双击序曲治理(tap+tap+doubletap 序列的判定归属)——
|
||||
const fDblCue = game._cues.find((c) => !c.judged && c.gesture === 'doubletap'); // phrase2 beat2
|
||||
await runUntil(fDblCue.expected - 0.2);
|
||||
G('tap'); // 序曲 tap1(双击第一下按下,自由演奏)
|
||||
await flush();
|
||||
await advance(200);
|
||||
G('tap'); // 序曲 tap2(双击第二下按下)
|
||||
await flush();
|
||||
await advance(250);
|
||||
const fMiss2 = game._stats.miss;
|
||||
G('doubletap'); // 双击改判到达(识别在第二次 touchend)
|
||||
await flush();
|
||||
assert(fDblCue.result === 'perfect', 'F2 双击改判 Perfect(序曲 tap 撤销后判定)');
|
||||
assert(game._stats.miss === fMiss2, 'F2 序曲 tap 不产生 Miss(爆音不进判定链)');
|
||||
assert(fTapCue.result === 'good', 'F2 双击撤销窗不误伤更早的真实 tap 命中');
|
||||
assert(game._tapJudg.length === 1, 'F2 两笔序曲 tap 账已撤销(账簿只剩真实命中)');
|
||||
|
||||
// —— F2c 序曲 tap 误认领命中 → 双击到达时撤销;撤销后真不接 → 超时 Miss 兜底 ——
|
||||
const fTapCue2 = game._cues.find((c) => !c.judged && c.gesture === 'tap'); // phrase2 beat3
|
||||
await runUntil(fTapCue2.expected);
|
||||
G('tap'); // 序曲 tap1 误认领(其实是双击第一下)
|
||||
await flush();
|
||||
assert(fTapCue2.result === 'perfect', 'F2c 序曲 tap 先认领命中');
|
||||
const fPerf2c = game._stats.perfect;
|
||||
await advance(200);
|
||||
G('tap'); // 序曲 tap2(自由演奏)
|
||||
await flush();
|
||||
await advance(250);
|
||||
G('doubletap'); // 双击改判 → 两笔序曲判定一并撤销(本次双击附近无 doubletap 音符,自由演奏)
|
||||
await flush();
|
||||
assert(fTapCue2.judged === false && fTapCue2.result === null, 'F2c 双击到达撤销序曲 tap 误认领的命中');
|
||||
assert(game._stats.perfect === fPerf2c - 1, 'F2c Perfect 统计回滚');
|
||||
await runUntil(fTapCue2.expected + 0.55); // 越过 tap 超时死线(+0.45)
|
||||
await flush();
|
||||
assert(fTapCue2.result === 'miss', 'F2c 撤销后真不接 → 超时 Miss 兜底(撤销不是免死)');
|
||||
|
||||
// —— F4 超时 Miss 复活:识别延迟把音符拖死、手势还在路上(live round2 事件流实证)——
|
||||
const fUpCue = game._cues.find((c) => !c.judged && c.gesture === 'up'); // phrase3 beat1
|
||||
await runUntil(fUpCue.expected + 0.64); // 超时死线 +0.60 已过:泵先判 Miss(滑动回调尚未到达)
|
||||
await flush();
|
||||
assert(fUpCue.result === 'miss' && fUpCue.missKind === 'timeout', 'F4 上滑音符先被超时拖死(事件流同款时序)');
|
||||
const fMiss4 = game._stats.miss;
|
||||
G('up'); // 慢滑回调此刻才到(触摸 expected+0.49、触程 0.15)
|
||||
await flush();
|
||||
assert(fUpCue.result === 'good', 'F4 超时误判复活改判 Good(补偿 dt=+0.49 ≤ REVIVE_DT_S)');
|
||||
assert(game._stats.miss === fMiss4 - 1, 'F4 复活回滚 Miss 统计');
|
||||
|
||||
// —— F5 正片踩点下滑不弹菜单(live round2 P1 续修:超时拖死 ≠ 无临近 down 音符)——
|
||||
const fDownCue = game._cues.find((c) => !c.judged && c.gesture === 'down'); // phrase3 beat3
|
||||
await runUntil(fDownCue.expected + 0.64); // 死线 +0.60 已过,下滑回调 +0.64 才到(round2 实测差 2~114ms)
|
||||
await flush();
|
||||
assert(fDownCue.result === 'miss' && fDownCue.missKind === 'timeout', 'F5 down 音符先被超时拖死');
|
||||
const fTomMark = playLog.length;
|
||||
G('down');
|
||||
await flush();
|
||||
assert(game.phase === 'main', 'F5 正片踩点下滑不弹菜单(live round2 P1 续修)');
|
||||
assert(fDownCue.result === 'good', 'F5 下滑复活改判 Good');
|
||||
assert(playLog.slice(fTomMark).some((p) => p.id === 'sfx/inst_tom_lo'), 'F5 下滑复活播放 inst_tom_lo');
|
||||
|
||||
// —— F5b 窗口内晚下滑(超复活时差上限):不弹菜单、Miss 维持、tom_lo 自由演奏 ——
|
||||
const fDownCue2 = game._cues.find((c) => !c.judged && c.gesture === 'down'); // phrase7 beat3
|
||||
await runUntil(fDownCue2.expected + 1.1); // 死线过 ~0.5s(账龄仍在 REVIVE_WINDOW_S 内),dt 超 REVIVE_DT_S
|
||||
await flush();
|
||||
assert(fDownCue2.result === 'miss', 'F5b down 音符先被超时拖死');
|
||||
const fTomMark2 = playLog.length;
|
||||
G('down');
|
||||
await flush();
|
||||
assert(game.phase === 'main', 'F5b 窗口内晚下滑仍不弹菜单(瞄准 down 音符证据明确)');
|
||||
assert(fDownCue2.result === 'miss', 'F5b 超复活时差上限,Miss 维持不复活');
|
||||
assert(playLog.slice(fTomMark2).some((p) => p.id === 'sfx/inst_tom_lo'), 'F5b tom_lo 自由演奏出声');
|
||||
|
||||
// —— F5c 窗口过后下滑正常呼出菜单(折中不破)——
|
||||
await advance(300); // downCue2 的 Miss 账龄 > REVIVE_WINDOW_S
|
||||
G('down');
|
||||
await flush();
|
||||
assert(game.phase === 'menu', 'F5c 窗口过后下滑正常呼出菜单');
|
||||
|
||||
// —— F6 菜单去抖独占期(live round2 P1 瞬穿):开菜单 1s 内手势不穿透 ——
|
||||
assert(audio.isVoicePlaying(), 'F6 菜单语音 menu_main 在播');
|
||||
G('doubletap'); // 开菜单 <1s:路上手势(双击)不得穿透菜单确认
|
||||
await flush();
|
||||
assert(game.phase === 'menu', 'F6 去抖期内双击不穿透菜单(phase 保持 menu)');
|
||||
assert(audio.isVoicePlaying(), 'F6 去抖期内菜单语音不被掐断');
|
||||
G('down'); // 去抖期内下滑也不得关菜单
|
||||
await flush();
|
||||
assert(game.phase === 'menu', 'F6 去抖期内下滑不穿透菜单');
|
||||
await advance(1100); // 越过 1s 去抖独占期
|
||||
G('doubletap'); // 双击确认"继续演奏"
|
||||
await flush();
|
||||
assert(game.phase === 'main', 'F6 去抖期后双击正常确认(恢复演奏)');
|
||||
|
||||
// ================= 资产引用校验(fs:ogg+m4a 双格式零缺失) =================
|
||||
console.log('\n[资产校验] game.js 引用的 id 对照 audio/ 目录');
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user