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:
+148
-18
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* 盲航(space)无头试玩测试 —— 覆盖 docs/design/13-playtest-criteria.md §5(space 关键路径
|
||||
* + 边界断言)与 §7(全局通用断言),以及本轮改动(状态 BGM / skipVoice / skip_hint)。
|
||||
* + 边界断言)与 §7(全局通用断言),以及本轮改动(状态 BGM / skipVoice / skip_hint)、
|
||||
* §11 round4-live 修复(方向呼喊提前到 spawn / 闪避窗口=呼啸全程 / 护盾 15s+不可用应答 /
|
||||
* 保护期断崖过渡 / hit_last 呼喊接力 / feat 教学仅首局),以及 round2 R2 修复
|
||||
* (下滑开菜单冷静期防误暂停 / 暂停恢复缓冲 / 回大厅收尾流程 / 警告链呼喊抑制+坠毁掐断)。
|
||||
*
|
||||
* 用法:tools/node/bin/node web/space/test/playtest.mjs
|
||||
*
|
||||
@@ -161,13 +164,16 @@ async function advance(ms) { clock.advance(ms); await tick(); }
|
||||
|
||||
function makeHarness() {
|
||||
const audio = new WebAudioEngine('audio/');
|
||||
const plays = [], skips = [];
|
||||
const plays = [], skips = [], voiceStarts = [];
|
||||
const origPlay = audio.play.bind(audio);
|
||||
audio.play = (id, opts) => { plays.push({ id, opts: opts || {}, t: clock.now }); return origPlay(id, opts); };
|
||||
// _playVoice 记录 voice 实际起播(queue 排队件由引擎内部接力,不经 play() 调用日志)
|
||||
const origPlayVoice = audio._playVoice.bind(audio);
|
||||
audio._playVoice = (id, opts) => { voiceStarts.push({ id, t: clock.now }); return origPlayVoice(id, opts); };
|
||||
const origSkip = audio.skipVoice.bind(audio);
|
||||
audio.skipVoice = () => { skips.push(clock.now); return origSkip(); };
|
||||
const game = new SpaceGame(audio, {});
|
||||
return { audio, game, plays, skips };
|
||||
return { audio, game, plays, skips, voiceStarts };
|
||||
}
|
||||
const voices = (plays) => plays.filter((p) => p.id.startsWith('voice/')).map((p) => p.id);
|
||||
const lastVoice = (plays) => voices(plays).at(-1);
|
||||
@@ -257,6 +263,8 @@ try {
|
||||
assert(game.hits === 0, '保护期撞击不掉血(训练护盾吸收)');
|
||||
assert(lastVoice(plays) === 'voice/protect_left', 'protect_<dir> 与来袭方向一致(左)');
|
||||
assert(vibrated('30,50,30,50,30'), '保护期吸收 WARNING 震动');
|
||||
assert(!plays.some((p) => p.t >= t0 && p.id.startsWith('voice/dir_')),
|
||||
'撞击点落在保护期内的陨石 spawn 不播 dir_<dir>(§11:protect 长句教学优先)');
|
||||
|
||||
// -- §5.3 第 20s 单击引入(feat_boost 可跳) --
|
||||
await advance(t0 + 20000 - clock.now);
|
||||
@@ -284,7 +292,7 @@ try {
|
||||
assert(game.phase === 'feature' && game.featureKind === 'shield', '第 45s 教双击护盾');
|
||||
game.onGesture('doubletap'); await tick();
|
||||
assert(game.shieldUpUntil > clock.now, '双击 → 护盾生效(§5.3)');
|
||||
assert(game.shieldReadyAt === clock.now + 30000, '护盾充能 30s 锁定');
|
||||
assert(game.shieldReadyAt === clock.now + 15000, '护盾充能 15s 锁定(§11 改口,原 30s)');
|
||||
assert(game.phase === 'playing', '护盾引入结束恢复 playing');
|
||||
|
||||
// 护盾格挡一颗(消耗护盾,不掉血)
|
||||
@@ -298,6 +306,17 @@ try {
|
||||
await advance(1400); // feat_shield_done 播完 → 排队的 shield_block 语音
|
||||
assert(voices(plays).includes('voice/shield_block'), 'shield_block 排队语音播出');
|
||||
|
||||
// -- §11 护盾充能中双击:即时应答 + 4s 去抖(P1 panic 双击零反馈) --
|
||||
const featDoneCount = () => voices(plays).filter((id) => id === 'voice/feat_shield_done').length;
|
||||
const featDoneBefore = featDoneCount();
|
||||
game.onGesture('doubletap'); await tick();
|
||||
assert(game.shieldUpUntil === 0, '充能中双击不展开护盾');
|
||||
assert(featDoneCount() === featDoneBefore + 1,
|
||||
'护盾不可用即时应答:播 feat_shield_done 现成句(§11,不新增语音 id)');
|
||||
assert(vibrated('40,100,40'), '护盾不可用 HEARTBEAT 震动(与撞击/警报可区分)');
|
||||
game.onGesture('doubletap'); await tick();
|
||||
assert(featDoneCount() === featDoneBefore + 1, '护盾不可用应答 4s 去抖(连刷不重播)');
|
||||
|
||||
// -- 切断自然刷怪(测试钩子),后续手动投放,避免难度曲线干扰 --
|
||||
const naturalSpawn = game._spawnObstacle.bind(game);
|
||||
game._spawnObstacle = () => {};
|
||||
@@ -312,21 +331,46 @@ try {
|
||||
'shield_ready 充能完毕播报恰好一次');
|
||||
assert(vibrated('15'), '护盾就绪 PICKUP 震动');
|
||||
|
||||
// -- §5.4 正常闪避 3 颗(同向滑,须在撞击前 1.5s 判定窗口内出手) --
|
||||
// -- §5.4/§11 正常闪避 3 颗(同向滑;§11 起窗口=呼啸全程,此处仍在撞击前 800ms 出手) --
|
||||
// 同循环验证 §11 断崖过渡(裸飞第 1~3 颗逼近固定 4.0s)与方向呼喊时机(spawn 同窗)
|
||||
let expectDodges = game.dodges;
|
||||
let bareIdx = 0;
|
||||
for (const rv of [0, 0.25, 0.5]) { // left / right / up
|
||||
bareIdx++;
|
||||
randQueue.push(rv);
|
||||
game.obstacles.clear(); game.swipes = [];
|
||||
const tSpawn = clock.now;
|
||||
naturalSpawn();
|
||||
const ob = [...game.obstacles].at(-1);
|
||||
await tick(); // voice 播放安全门:播放经 microtask 落位(虚拟时钟不走,t 仍 = tSpawn)
|
||||
assert(ob.impactAt - tSpawn === 4000,
|
||||
`断崖过渡:裸飞第 ${bareIdx} 颗逼近 4.0s 不收缩(§11)`);
|
||||
assert(plays.some((p) => p.id === `voice/dir_${ob.dir}` && p.t === tSpawn),
|
||||
`方向呼喊 dir_${ob.dir} 与呼啸同窗(spawn 当刻,§11)`);
|
||||
await advance(ob.impactAt - clock.now - 800); // 逼近到撞击前 800ms
|
||||
game.onGesture(ob.dir); // 判定窗口内同向滑
|
||||
await advance(900); // 过撞击点
|
||||
expectDodges++;
|
||||
assert(game.dodges === expectDodges, `同向滑闪避成功(${ob.dir},dodges=${expectDodges})`);
|
||||
assert(game.hits === 0, '同向闪避不掉血');
|
||||
assert(plays.filter((p) => p.id === `voice/dir_${ob.dir}` && p.t >= tSpawn).length === 1,
|
||||
'撞击前 500ms loud 警报档不再重复方向呼喊(§11)');
|
||||
}
|
||||
|
||||
// -- §11 早滑不再被吞:呼啸响起立刻滑(距撞击 >1.5s)也判躲开(P1 核心回归) --
|
||||
randQueue.push(0.75); // down
|
||||
game.obstacles.clear(); game.swipes = [];
|
||||
const tEarly = clock.now;
|
||||
naturalSpawn();
|
||||
const obEarly = [...game.obstacles].at(-1);
|
||||
assert(obEarly.impactAt - tEarly < 4000, '断崖过渡:裸飞第 4 颗起接入难度曲线(<4.0s,§11)');
|
||||
await advance(200); // 呼啸响起 0.2s 内立刻同向滑(旧版 1.5s 窗口之外,必被吞的位置)
|
||||
game.onGesture('down');
|
||||
await advance(obEarly.impactAt - clock.now + 100); // 过撞击点
|
||||
expectDodges++;
|
||||
assert(game.dodges === expectDodges && game.hits === 0,
|
||||
'早滑(spawn+0.2s,距撞击 >1.5s)判定躲开(§11 窗口=呼啸全程)');
|
||||
|
||||
// -- 边界:反向滑 ≠ dodge(顺便作为第 1 次实打实撞击) --
|
||||
randQueue.push(0); // left
|
||||
game.obstacles.clear(); game.swipes = [];
|
||||
@@ -343,18 +387,42 @@ try {
|
||||
game.onGesture('tap'); await tick();
|
||||
assert(skips.length === skipsB2 + 1, '归因语音在播时单击 = skipVoice()');
|
||||
|
||||
// -- 边界:第 2 次被撞追加 hit_last --
|
||||
// -- 边界/§11+round2 R2:第 2 次被撞追加 hit_last;呼喊接力保留 + 接播期间呼喊抑制 --
|
||||
randQueue.push(0.25); // right
|
||||
game.obstacles.clear(); game.swipes = [];
|
||||
naturalSpawn();
|
||||
const obH2 = [...game.obstacles].at(-1);
|
||||
await advance(obH2.impactAt - clock.now + 50);
|
||||
assert(game.hits === 2 && game.phase === 'playing', 'hits===2 不坠毁');
|
||||
await advance(1300); // hit_right 播完
|
||||
await advance(1300); // hit_last 播出并播完
|
||||
const vi = voices(plays);
|
||||
const h2i = vi.lastIndexOf('voice/hit_right');
|
||||
assert(h2i >= 0 && vi[h2i + 1] === 'voice/hit_last', '第 2 次被撞追加 hit_last');
|
||||
assert(h.voiceStarts.at(-1).id === 'voice/hit_right', '归因 hit_right 开播');
|
||||
assert(game._hitLastPending === true, 'hit_last 挂起待接播(§11 呼喊接力)');
|
||||
randQueue.push(0); // left:刷下一颗裸飞陨石,其 spawn 呼喊顶断归因句(生存优先级保留)
|
||||
naturalSpawn();
|
||||
const obNext = [...game.obstacles].at(-1);
|
||||
await tick();
|
||||
assert(h.voiceStarts.at(-1).id === 'voice/dir_left', '下一颗 spawn 呼喊顶断归因(生存优先级)');
|
||||
await advance(1300); // 呼喊播完 → onComplete 接力 → hit_last 自动接播
|
||||
assert(h.voiceStarts.at(-1).id === 'voice/hit_last',
|
||||
'归因被呼喊顶断后 hit_last 仍接播(§11:最后一命不再被静默吞掉)');
|
||||
assert(game._hitLastPending === false, 'hit_last 接播后挂起标记清除');
|
||||
assert(game._hitLastPlaying === true, 'hit_last 在播标记置位(round2 R2)');
|
||||
// round2 R2:hit_last 在播期间新刷陨石的 spawn 呼喊被抑制(警告播完前不被顶断)
|
||||
const shoutMark2 = h.voiceStarts.length;
|
||||
randQueue.push(0.5); // up
|
||||
naturalSpawn();
|
||||
const obNext2 = [...game.obstacles].at(-1);
|
||||
await tick();
|
||||
assert(!h.voiceStarts.slice(shoutMark2).some((v) => v.id === 'voice/dir_up'),
|
||||
'hit_last 在播期间 spawn 不播方向呼喊(round2 R2:警告播完前不被顶断)');
|
||||
await advance(1300); // hit_last 播完 → 呼喊抑制解除
|
||||
assert(game._hitLastPlaying === false, 'hit_last 播完在播标记清除');
|
||||
randQueue.push(0.5); // up:链后再刷陨石,方向呼喊恢复
|
||||
naturalSpawn();
|
||||
await tick();
|
||||
assert(h.voiceStarts.at(-1).id === 'voice/dir_up', '警告链结束后方向呼喊恢复(round2 R2)');
|
||||
game.obstacles.delete(obNext);
|
||||
game.obstacles.delete(obNext2);
|
||||
game.obstacles.clear(); game.swipes = [];
|
||||
|
||||
// -- 边界:里程碑 dodges=10 播 ms_10 --
|
||||
game.dodges = 9; // 13 号文档允许的测试钩子
|
||||
@@ -370,14 +438,19 @@ try {
|
||||
assert(vibrated('20'), '里程碑 CONFIRM 震动');
|
||||
await advance(1300);
|
||||
|
||||
// -- §5.4 第 3 次撞击坠毁 --
|
||||
// -- §5.4 第 3 次撞击坠毁(round2 R2 B4:警告链在播时坠毁即掐断,结算不叠播) --
|
||||
randQueue.push(0.75); // down
|
||||
game.obstacles.clear(); game.swipes = [];
|
||||
naturalSpawn();
|
||||
const obH3 = [...game.obstacles].at(-1);
|
||||
const crashAt = obH3.impactAt;
|
||||
await advance(obH3.impactAt - clock.now + 50);
|
||||
await advance(obH3.impactAt - clock.now - 500); // 撞击前 500ms:重挂"最后一命"警告链
|
||||
game._hitLastPending = true; // 测试钩子:模拟第 2 撞后归因刚播完、警告待接播
|
||||
game._drainHitLast(); await tick();
|
||||
assert(h.voiceStarts.at(-1).id === 'voice/hit_last' && !!audio._voice, '坠毁前 hit_last 在播');
|
||||
await advance(600); // 过撞击点 → hits===3 → 坠毁
|
||||
assert(game.hits === 3 && game.phase === 'over', 'hits===3 才坠毁 gameover');
|
||||
assert(!audio._voice, '坠毁即掐断警告链:hit_last 不再与结算叠播(round2 R2 B4)');
|
||||
assert(plays.some((p) => p.id === 'sfx/crash'), '坠毁音 crash');
|
||||
assert(audio._bgm === null, '坠毁即 stopBgm(结算前先静)');
|
||||
assert(vibrated('60,40,60'), '坠毁 ERROR 震动');
|
||||
@@ -410,12 +483,26 @@ try {
|
||||
assert(game.phase === 'playing' && game.hits === 0 && game.dodges === 0, '结算态单击重开新局');
|
||||
assert(audio._bgm && audio._bgm.id === 'bgm/engine', '重开淡回 bgm/engine');
|
||||
|
||||
// -- §11 feat 教学仅首局:第 2 局过 20s/45s 不进 feature、不重播,机制开局即用 --
|
||||
const featMark = plays.length;
|
||||
await advance(20050); // 过单击引入时刻(保护期内陨石不伤血)
|
||||
assert(game.phase === 'playing', '第 2 局过 20s 不进 feature phase(§11)');
|
||||
game.onGesture('tap'); await tick(); // 无旁白在播 → 加速直接生效
|
||||
assert(game.boostUntil > clock.now, '第 2 局单击加速开局即用(§11)');
|
||||
game.onGesture('doubletap'); await tick();
|
||||
assert(game.shieldUpUntil > clock.now, '第 2 局双击护盾开局即用(§11)');
|
||||
await advance(25050); // 过双击引入时刻
|
||||
assert(game.phase === 'playing', '第 2 局过 45s 仍不进 feature phase(§11)');
|
||||
assert(!plays.slice(featMark).some((p) => p.id === 'voice/feat_boost' || p.id === 'voice/feat_shield'),
|
||||
'第 2 局 feat_boost/feat_shield 不再重播(§11)');
|
||||
|
||||
// ======================================================================
|
||||
section('S2 菜单:状态 BGM 切换 + 下滑折中 + 全球约定(§7.5)');
|
||||
// ======================================================================
|
||||
game.obstacles.clear();
|
||||
game.obstacles.clear(); game.swipes = [];
|
||||
game._lastResolveAt = -Infinity; // 测试钩子:跳过开菜单冷静期(round2 R2)
|
||||
game.onGesture('down'); await tick();
|
||||
assert(game.phase === 'menu', '无下方陨石时下滑 = 菜单(下滑折中)');
|
||||
assert(game.phase === 'menu', '无悬置陨石且过冷静期时下滑 = 菜单(下滑折中)');
|
||||
assert(plays.some((p) => p.id === 'bgm/menu' && p.opts.fadeMs === 300),
|
||||
'进菜单 crossfade 300ms 切 bgm/menu');
|
||||
await tick();
|
||||
@@ -435,6 +522,39 @@ try {
|
||||
await tick();
|
||||
assert(audio._bgm && audio._bgm.id === 'bgm/engine', '对局态 BGM 恢复 engine');
|
||||
|
||||
// -- round2 R2 恢复缓冲:恢复后前 3 颗裸飞陨石回 4.0s 慢节奏(软重启,不再 3 秒暴毙) --
|
||||
game.protectionUntil = clock.now - 1000; // 测试钩子:强制视为保护期已过
|
||||
game._protectAnnounced = true; // 不再补播 protect_end
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
randQueue.push(0); // left
|
||||
game.obstacles.clear(); game.swipes = [];
|
||||
const tR = clock.now;
|
||||
naturalSpawn();
|
||||
const obR = [...game.obstacles].at(-1);
|
||||
assert(obR.impactAt - tR === 4000,
|
||||
`恢复缓冲:暂停恢复后第 ${i} 颗裸飞陨石逼近 4.0s 不收缩(round2 R2)`);
|
||||
}
|
||||
randQueue.push(0);
|
||||
game.obstacles.clear(); game.swipes = [];
|
||||
const tR4 = clock.now;
|
||||
naturalSpawn();
|
||||
const obR4 = [...game.obstacles].at(-1);
|
||||
assert(obR4.impactAt - tR4 < 4000, '恢复缓冲:第 4 颗起回到难度曲线(round2 R2)');
|
||||
game.obstacles.clear(); game.swipes = [];
|
||||
|
||||
// -- round2 R2 误暂停守卫:判定后冷静期内的下滑绝不开菜单;过冷静期保留旧折中 --
|
||||
game._lastResolveAt = clock.now; // 测试钩子:模拟"刚判定完(撞/躲)"
|
||||
game.onGesture('down'); await tick();
|
||||
assert(game.phase === 'playing', '判定后 800ms 冷静期内下滑不开菜单(round2 R2 误暂停修复)');
|
||||
game._lastResolveAt = -Infinity;
|
||||
randQueue.push(0); // left:非下方陨石悬置中(过冷静期保留旧折中,仍可开菜单)
|
||||
naturalSpawn();
|
||||
await tick();
|
||||
game.onGesture('down'); await tick();
|
||||
assert(game.phase === 'menu', '非下方陨石悬置且过冷静期下滑 = 菜单(旧折中保留,round2 R2)');
|
||||
game._closeMenu(); await tick(); // 恢复对局继续后续用例
|
||||
game.obstacles.clear(); game.swipes = [];
|
||||
|
||||
// 下滑折中另一侧:有下方陨石时下滑 = 闪避
|
||||
randQueue.push(0.75); // down
|
||||
game.obstacles.clear(); game.swipes = [];
|
||||
@@ -447,13 +567,22 @@ try {
|
||||
await advance(900);
|
||||
assert(game.dodges === 1, '下滑闪避生效 dodges=1');
|
||||
|
||||
// 菜单"回大厅"
|
||||
game.obstacles.clear();
|
||||
// 菜单"回大厅"(round2 R2 收尾流程:leaving 态 + 确认句 → 跳转,不再落无声死界面)
|
||||
game.obstacles.clear(); game.swipes = [];
|
||||
game._lastResolveAt = -Infinity; // 测试钩子:跳过开菜单冷静期
|
||||
game.onGesture('down'); await tick();
|
||||
assert(game.phase === 'menu', '过冷静期后下滑再开菜单(回大厅前置)');
|
||||
game.onGesture('tap'); await tick(); // → menu_lobby
|
||||
game.onGesture('doubletap'); await tick();
|
||||
assert(window.location.href === '../', '菜单双击确认 menu_lobby = 回大厅');
|
||||
assert(game.phase === 'leaving', '回大厅确认中:leaving 收尾态(round2 R2)');
|
||||
assert(lastVoice(plays) === 'voice/menu_lobby', '先播回大厅确认句再跳转(round2 R2)');
|
||||
assert(window.location.href !== '../', '确认句播完前不跳转(round2 R2)');
|
||||
game.onGesture('left'); await tick(); // leaving 中滑动不再受理
|
||||
assert(game.phase === 'leaving', 'leaving 中滑动手势被忽略(round2 R2)');
|
||||
game.onGesture('tap'); await tick(); // leaving 中单击 = 跳过确认句 → 立即跳转
|
||||
assert(window.location.href === '../', '确认句跳过后跳转大厅(round2 R2)');
|
||||
window.location.href = '';
|
||||
game.stop();
|
||||
|
||||
// ======================================================================
|
||||
section('S3 二周目:ask 流程 + skip_hint 不重复插播');
|
||||
@@ -557,6 +686,7 @@ try {
|
||||
for (const d of ['left', 'right', 'up', 'down']) {
|
||||
ids.add(`voice/protect_${d}`);
|
||||
ids.add(`voice/hit_${d}`);
|
||||
ids.add(`voice/dir_${d}`);
|
||||
}
|
||||
const missing = [];
|
||||
for (const id of ids) {
|
||||
|
||||
Reference in New Issue
Block a user