第二批反馈:引擎改造(限幅/SoundTouch不变调变速/长按快进/跳句/iOS闪光降级) + 6游戏状态BGM与试玩测试全绿
This commit is contained in:
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* 《暗室》无头试玩测试 —— mock 浏览器环境 + 虚拟时钟,以玩家手势跑通
|
||||
* docs/design/13-playtest-criteria.md §2 的关键路径与边界断言。
|
||||
*
|
||||
* 运行:tools/node/bin/node web/escape/test/playtest.mjs
|
||||
*
|
||||
* 设计:
|
||||
* - 真实引擎(engine/audio.js + settings.js + vendor/soundtouch.js),
|
||||
* mock 的是浏览器 API 层(AudioContext/fetch/localStorage/navigator/window)。
|
||||
* - decodeAudioData 返回带 duration 的假 buffer(fetch 把时长编进 ArrayBuffer
|
||||
* 头 8 字节);voice 自然结束由引擎内部定时器驱动(duration ÷ tempo),
|
||||
* 虚拟时钟推进即触发 onComplete,语音链随之推进。
|
||||
* - 手势直接在 game 实例上注入 onGesture(不过 gesture.js 的触摸判定)。
|
||||
* - 全程虚拟时钟:setTimeout/setInterval/Date.now/performance.now 都走
|
||||
* 手动推进的逻辑时钟,测试毫秒级跑完、完全可重复(本游戏无随机)。
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url)); // web/escape/test
|
||||
const GAME_DIR = join(HERE, '..'); // web/escape
|
||||
|
||||
// ---------- 虚拟时钟(必须先于引擎模块加载安装) ----------
|
||||
let now = 0;
|
||||
let timerSeq = 1;
|
||||
const timers = new Map(); // id -> { at, fn, interval|null }
|
||||
|
||||
globalThis.setTimeout = (fn, ms = 0) => {
|
||||
const id = timerSeq++;
|
||||
timers.set(id, { at: now + ms, fn, interval: null });
|
||||
return id;
|
||||
};
|
||||
globalThis.clearTimeout = (id) => { timers.delete(id); };
|
||||
globalThis.setInterval = (fn, ms = 0) => {
|
||||
const id = timerSeq++;
|
||||
timers.set(id, { at: now + ms, fn, interval: ms });
|
||||
return id;
|
||||
};
|
||||
globalThis.clearInterval = (id) => { timers.delete(id); };
|
||||
Date.now = () => now;
|
||||
Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true });
|
||||
|
||||
/** 推进虚拟时钟 ms,按序触发到期定时器(interval 自动重排)。 */
|
||||
function advance(ms) {
|
||||
const end = now + ms;
|
||||
for (;;) {
|
||||
let nextId = null;
|
||||
let nextAt = Infinity;
|
||||
for (const [id, t] of timers) {
|
||||
if (t.at <= end && t.at < nextAt) { nextAt = t.at; nextId = id; }
|
||||
}
|
||||
if (nextId === null) break;
|
||||
const t = timers.get(nextId);
|
||||
now = t.at;
|
||||
if (t.interval !== null) t.at = now + t.interval;
|
||||
else timers.delete(nextId);
|
||||
t.fn();
|
||||
}
|
||||
now = end;
|
||||
}
|
||||
|
||||
/** 刷新微任务队列(fetch/decode/语音链接续都是 promise 驱动)。 */
|
||||
async function flush() {
|
||||
for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r));
|
||||
}
|
||||
|
||||
// ---------- 浏览器环境 mock ----------
|
||||
const vibrateLog = [];
|
||||
const fetchLog = [];
|
||||
let blockedAssets = []; // 命中即 404(模拟资产缺失)
|
||||
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { vibrate: (p) => vibrateLog.push(p) }, configurable: true,
|
||||
});
|
||||
|
||||
const store = new Map();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
AudioContext: null, // 下方填入
|
||||
location: { href: '' },
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
value: {
|
||||
createElement: () => ({
|
||||
style: { setProperty: () => {} },
|
||||
classList: { add: () => {}, remove: () => {} },
|
||||
appendChild: () => {}, remove: () => {}, addEventListener: () => {},
|
||||
}),
|
||||
body: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const LAYER_DURATION = { voice: 1.0, sfx: 0.3, bgm: 60 };
|
||||
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
value: async (url) => {
|
||||
fetchLog.push(String(url));
|
||||
if (blockedAssets.some((b) => String(url).includes(b))) {
|
||||
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
|
||||
}
|
||||
const u = String(url);
|
||||
const layer = u.includes('/voice/') ? 'voice' : u.includes('/bgm/') ? 'bgm' : 'sfx';
|
||||
const buf = new ArrayBuffer(8);
|
||||
new Float64Array(buf)[0] = LAYER_DURATION[layer];
|
||||
return { ok: true, arrayBuffer: async () => buf };
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
class MockAudioParam {
|
||||
constructor(v = 0) { this.value = v; }
|
||||
cancelScheduledValues() {}
|
||||
setValueAtTime() {}
|
||||
linearRampToValueAtTime() {}
|
||||
}
|
||||
class MockNode {
|
||||
constructor() { this.gain = new MockAudioParam(1); }
|
||||
connect() {}
|
||||
disconnect() {}
|
||||
}
|
||||
class MockAudioContext {
|
||||
constructor() {
|
||||
this.state = 'running';
|
||||
this.currentTime = 0;
|
||||
this.destination = {};
|
||||
this.sampleRate = 44100;
|
||||
}
|
||||
createGain() { return new MockNode(); }
|
||||
createDynamicsCompressor() {
|
||||
const n = new MockNode();
|
||||
for (const k of ['threshold', 'ratio', 'attack', 'release', 'knee', 'reduction']) n[k] = new MockAudioParam(0);
|
||||
return n;
|
||||
}
|
||||
createBufferSource() {
|
||||
const n = new MockNode();
|
||||
n.buffer = null; n.loop = false;
|
||||
n.start = () => {}; n.stop = () => {};
|
||||
return n;
|
||||
}
|
||||
createScriptProcessor() { const n = new MockNode(); n.onaudioprocess = null; return n; }
|
||||
decodeAudioData(raw) {
|
||||
const duration = new Float64Array(raw)[0] || 1;
|
||||
return Promise.resolve({
|
||||
duration,
|
||||
numberOfChannels: 1,
|
||||
sampleRate: 44100,
|
||||
length: Math.ceil(duration * 44100),
|
||||
getChannelData: () => new Float32Array(Math.ceil(duration * 44100)),
|
||||
});
|
||||
}
|
||||
resume() { this.state = 'running'; return Promise.resolve(); }
|
||||
}
|
||||
window.AudioContext = MockAudioContext;
|
||||
|
||||
// ---------- 加载引擎与游戏(globals 就绪后动态 import) ----------
|
||||
const { WebAudioEngine } = await import('../../engine/audio.js');
|
||||
const { settings } = await import('../../engine/settings.js');
|
||||
const { EscapeGame } = await import('../game.js');
|
||||
|
||||
// ---------- 断言与辅助 ----------
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passes++; console.log(' ✓', msg); }
|
||||
else { failures++; console.error(' ✗ FAIL:', msg); }
|
||||
}
|
||||
function section(title) { console.log('\n== ' + title); }
|
||||
|
||||
/** play 调用记录 + skipVoice 计数。 */
|
||||
function instrument(audio) {
|
||||
const log = [];
|
||||
const origPlay = audio.play.bind(audio);
|
||||
audio.play = (id, opts = {}) => { log.push({ t: now, id, opts }); return origPlay(id, opts); };
|
||||
const origSkip = audio.skipVoice.bind(audio);
|
||||
let skips = 0;
|
||||
audio.skipVoice = () => { skips++; return origSkip(); };
|
||||
return {
|
||||
log,
|
||||
skips: () => skips,
|
||||
count: (id) => log.filter((e) => e.id === id).length,
|
||||
last: (id) => [...log].reverse().find((e) => e.id === id),
|
||||
};
|
||||
}
|
||||
|
||||
async function newGame() {
|
||||
const audio = new WebAudioEngine('audio/');
|
||||
await audio.unlock();
|
||||
const game = new EscapeGame(audio, {});
|
||||
return { audio, game, rec: instrument(audio) };
|
||||
}
|
||||
async function gs(game, g) { game.onGesture(g); await flush(); }
|
||||
async function tick(ms) { advance(ms); await flush(); }
|
||||
/** 播完 n 句 voice(每句 1s + 120ms 余量)。 */
|
||||
async function finishVoices(n = 1) { for (let i = 0; i < n; i++) await tick(1300); }
|
||||
const vibrateCount = (pattern) => vibrateLog.filter((p) => JSON.stringify(p) === JSON.stringify(pattern)).length;
|
||||
const ERR_PAT = [60, 40, 60];
|
||||
const WARN_PAT = [30, 50, 30, 50, 30];
|
||||
|
||||
const VOICE_END_SLACK = 1300; // 一句 voice 的虚拟时长
|
||||
|
||||
// ================================================================
|
||||
// 场景 A:关键路径 —— 首局引导 G0-G7 → PLAY 解谜链 → 逃脱结算 → 菜单 → 重开
|
||||
// ================================================================
|
||||
section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单 → 重开');
|
||||
{
|
||||
store.clear();
|
||||
const { audio, game, rec } = await newGame();
|
||||
game.start();
|
||||
await flush();
|
||||
assert(game.phase === 'GUIDE', '启动进入 GUIDE');
|
||||
assert(rec.count('bgm/drone') === 1, '开局播 bgm/drone');
|
||||
assert(rec.count('voice/g0_intro') === 1, 'g0_intro 播放');
|
||||
await tick(VOICE_END_SLACK); // g0 播完
|
||||
|
||||
// G0:单击 → G1(链播 g1_move + skip_hint 首局插播)
|
||||
await gs(game, 'tap');
|
||||
assert(rec.count('voice/g1_move') === 1, 'G0 单击后播 g1_move');
|
||||
await tick(VOICE_END_SLACK);
|
||||
assert(rec.count('voice/skip_hint') === 1, 'skip_hint 在 G0/G1 附近插播一次');
|
||||
await tick(VOICE_END_SLACK);
|
||||
assert(game.guideStep === 1 && !game._guideBusy, '进入 G1,输入解锁');
|
||||
|
||||
// G1:任意滑动 → 脚步声
|
||||
const stepsBefore = rec.count('sfx/step');
|
||||
await gs(game, 'up');
|
||||
assert(rec.count('sfx/step') === stepsBefore + 1, 'G1 滑动播脚步声');
|
||||
await finishVoices(2); // g1_done + g2_wall
|
||||
assert(game.guideStep === 2, '进入 G2');
|
||||
|
||||
// G2:滑动 ×2 撞墙反馈(避开下滑——引导期任何步骤下滑 = 跳过引导)
|
||||
const wallsBefore = rec.count('sfx/wall');
|
||||
const errBefore = vibrateCount(ERR_PAT);
|
||||
await gs(game, 'up');
|
||||
await gs(game, 'right');
|
||||
assert(rec.count('sfx/wall') === wallsBefore + 2, 'G2 两滑各播 sfx/wall');
|
||||
assert(vibrateCount(ERR_PAT) === errBefore + 2, 'G2 两滑各发 error 震动');
|
||||
await finishVoices(2); // wall + g3_left
|
||||
assert(game.guideStep === 3, '进入 G3');
|
||||
|
||||
// G3:左滑 → (0,1)
|
||||
await gs(game, 'left');
|
||||
assert(game.x === 0 && game.y === 1, 'G3 左滑移到 (0,1)');
|
||||
await finishVoices(1); // g4_down
|
||||
assert(game.guideStep === 4, '进入 G4');
|
||||
|
||||
// G4:下滑 → (0,2) 储物间
|
||||
await gs(game, 'down');
|
||||
assert(game.x === 0 && game.y === 2, 'G4 下滑移到 (0,2)');
|
||||
assert(rec.count('sfx/amb_storage') >= 1, '储物间环境音');
|
||||
await finishVoices(1); // g5_inspect
|
||||
assert(game.guideStep === 5, '进入 G5');
|
||||
|
||||
// G5:单击拾钥匙
|
||||
await gs(game, 'tap');
|
||||
assert(game.hasKey === true, 'G5 拾得钥匙 hasKey === true');
|
||||
await finishVoices(2); // g5_key + g6_doubletap
|
||||
assert(game.guideStep === 6, '进入 G6');
|
||||
|
||||
// G6:双击 → G7 → 引导完成进 PLAY
|
||||
await gs(game, 'doubletap');
|
||||
await finishVoices(3); // inv_key + g6_done + g7_goal → onDone → _finishGuide
|
||||
assert(game.phase === 'PLAY', '引导完成进入 PLAY');
|
||||
assert(store.get('soundgame.escape.guideDone') === '1', "localStorage guideDone === '1'");
|
||||
|
||||
// ---- PLAY 解谜链:(0,2) → 铁柜房 (2,1) → 电箱房 (0,0) → 大门 (2,2) ----
|
||||
await gs(game, 'right');
|
||||
assert(game.x === 1 && game.y === 2, '右滑到 (1,2)');
|
||||
await gs(game, 'right');
|
||||
assert(game.x === 2 && game.y === 2, '右滑到 (2,2)');
|
||||
assert(rec.count('voice/room_gate') === 1, '首到大门播 room_gate');
|
||||
await finishVoices(1);
|
||||
await gs(game, 'up');
|
||||
assert(game.x === 2 && game.y === 1, '上滑到铁柜房 (2,1)');
|
||||
assert(rec.count('voice/room_cabinet') === 1, '首到铁柜房播 room_cabinet');
|
||||
await finishVoices(1);
|
||||
|
||||
// 双击开柜 → 保险丝;unlock_cabinet 为关键获得不可跳
|
||||
await gs(game, 'doubletap');
|
||||
assert(game.cabinetOpen === true && game.hasFuse === true, '开柜得保险丝 cabinetOpen && hasFuse');
|
||||
assert(game.hasKey === false, '钥匙被消耗');
|
||||
await flush();
|
||||
assert(!!audio._voice, 'unlock_cabinet 播放中');
|
||||
const skipsAtUnlock = rec.skips();
|
||||
const ambAtUnlock = rec.count('sfx/amb_cabinet');
|
||||
await gs(game, 'tap'); // 不可跳句播放中单击 = 忽略
|
||||
assert(rec.skips() === skipsAtUnlock, 'unlock_cabinet 不可单击跳过');
|
||||
assert(rec.count('sfx/amb_cabinet') === ambAtUnlock, '不可跳句播放中单击不触发检查');
|
||||
await finishVoices(1);
|
||||
|
||||
// 左滑×2 + 上滑 → 电箱房 (0,0)
|
||||
await gs(game, 'left');
|
||||
await gs(game, 'left');
|
||||
await gs(game, 'up');
|
||||
assert(game.x === 0 && game.y === 0, '到达电箱房 (0,0)');
|
||||
assert(rec.count('voice/room_power') === 1, '首到电箱房播 room_power');
|
||||
await finishVoices(1);
|
||||
|
||||
// 双击修电箱 → 通电
|
||||
await gs(game, 'doubletap');
|
||||
assert(game.powerOn === true, '修复电箱 powerOn === true');
|
||||
assert(rec.count('sfx/spark') === 1 && rec.count('sfx/power_on') === 1, '修电箱音效 spark + power_on');
|
||||
await finishVoices(1); // fix_box
|
||||
|
||||
// 右滑×2 + 下滑×2 → 大门 (2,2),通电后环境音切 amb_gate_live
|
||||
await gs(game, 'right');
|
||||
await gs(game, 'right');
|
||||
await gs(game, 'down');
|
||||
await gs(game, 'down');
|
||||
assert(game.x === 2 && game.y === 2, '到达大门 (2,2)');
|
||||
assert(rec.count('sfx/amb_gate_live') >= 1, '通电后大门播 amb_gate_live');
|
||||
|
||||
// 双击逃脱 → ENDING
|
||||
const playSecAtEscape = Math.floor((now - game.startElapsedMs) / 1000);
|
||||
await gs(game, 'doubletap');
|
||||
assert(game.phase === 'ENDING', '双击大门逃脱,进入 ENDING');
|
||||
assert(rec.count('sfx/gate_open') === 1 && rec.count('sfx/win_chime') === 1, '逃脱音效 gate_open + win_chime');
|
||||
assert(rec.count('voice/escape') === 1, 'escape 语音播出');
|
||||
assert(playSecAtEscape < 120, `本次用时 ${playSecAtEscape}s < 120s`);
|
||||
await finishVoices(1);
|
||||
assert(rec.count('voice/time_band1') === 1, 'time_band1 语音播出(<120s 档)');
|
||||
await finishVoices(2); // time_band1 + again → 链完 → 结算 pad
|
||||
const menuPad = rec.last('bgm/menu');
|
||||
assert(!!menuPad && menuPad.opts.volume === 0.5 && menuPad.opts.fadeMs === 1500,
|
||||
'逃脱语音链播完淡入 bgm/menu(volume 0.5 / fadeMs 1500)');
|
||||
|
||||
// ENDING 下滑 → 菜单:切 bgm/menu(400ms)
|
||||
await gs(game, 'down');
|
||||
assert(game.phase === 'MENU', 'ENDING 下滑打开菜单');
|
||||
const menuOpen = rec.last('bgm/menu');
|
||||
assert(menuOpen && menuOpen.opts.fadeMs === 400, '进菜单 crossfade 400ms 播 bgm/menu');
|
||||
await finishVoices(2); // menu_open + menu_restart
|
||||
|
||||
// 菜单约定:单击循环 / 上滑帮助 / 双击确认(选"返回")
|
||||
await gs(game, 'tap');
|
||||
assert(rec.count('voice/menu_home') === 1, '菜单单击循环到 menu_home');
|
||||
await gs(game, 'up');
|
||||
assert(rec.count('voice/help_ops') === 1, '菜单上滑播操作说明');
|
||||
await finishVoices(1);
|
||||
await gs(game, 'tap');
|
||||
assert(rec.count('voice/menu_back') === 1, '菜单单击循环到 menu_back');
|
||||
await finishVoices(1);
|
||||
await gs(game, 'doubletap'); // idx=2 返回
|
||||
assert(game.phase === 'ENDING', '菜单双击"返回"回到结算');
|
||||
|
||||
// 菜单约定:下滑 = 关闭菜单
|
||||
await gs(game, 'down');
|
||||
assert(game.phase === 'MENU', '再次下滑打开菜单');
|
||||
await gs(game, 'down');
|
||||
assert(game.phase === 'ENDING', '菜单下滑关闭回到结算');
|
||||
|
||||
// 结算单击重开 → PLAY + bgm/drone
|
||||
const droneBefore = rec.count('bgm/drone');
|
||||
await gs(game, 'tap');
|
||||
assert(game.phase === 'PLAY', '结算单击重开回 PLAY');
|
||||
assert(rec.count('bgm/drone') === droneBefore + 1, '重开切回 bgm/drone');
|
||||
assert(rec.count('voice/replay') === 1, '重开播 replay');
|
||||
assert(game.x === 1 && game.y === 1 && game.hasKey === false, '重开状态复位');
|
||||
await finishVoices(1);
|
||||
|
||||
// ---- 边界:单击检查 = 环境音先播、描述 800ms 后播 ----
|
||||
const ambDripBefore = rec.count('sfx/amb_drip');
|
||||
await gs(game, 'tap');
|
||||
assert(rec.count('sfx/amb_drip') === ambDripBefore + 1, '检查先播环境音 amb_drip');
|
||||
assert(rec.count('voice/start_room') === 0, '描述 800ms 内未播');
|
||||
await tick(800);
|
||||
assert(rec.last('voice/start_room') && rec.last('voice/start_room').t - rec.last('sfx/amb_drip').t === 800,
|
||||
'描述在环境音 800ms 后播');
|
||||
// start_room 是检查描述(可跳):voice 播放中单击 = 跳过本句
|
||||
const skipsBefore = rec.skips();
|
||||
const ambBefore = rec.count('sfx/amb_drip');
|
||||
await gs(game, 'tap');
|
||||
assert(rec.skips() === skipsBefore + 1, '检查描述播放中单击 = skipVoice');
|
||||
assert(rec.count('sfx/amb_drip') === ambBefore, '跳过时不触发检查');
|
||||
await tick(200);
|
||||
|
||||
// ---- 边界:撞墙 (0,0) 左滑 → wall + error 震动 + 位置不变 + 无脚步声 ----
|
||||
await gs(game, 'left'); // (0,1)
|
||||
await gs(game, 'up'); // (0,0)
|
||||
const stepCnt = rec.count('sfx/step');
|
||||
const wallCnt = rec.count('sfx/wall');
|
||||
const errCnt = vibrateCount(ERR_PAT);
|
||||
await gs(game, 'left'); // 撞墙
|
||||
assert(game.x === 0 && game.y === 0, '撞墙位置不变');
|
||||
assert(rec.count('sfx/wall') === wallCnt + 1, '撞墙播 sfx/wall');
|
||||
assert(vibrateCount(ERR_PAT) === errCnt + 1, '撞墙发 error 震动');
|
||||
assert(rec.count('sfx/step') === stepCnt, '撞墙不播脚步声');
|
||||
assert(rec.count('voice/wall') >= 1, '撞墙播 wall 语音');
|
||||
await finishVoices(1);
|
||||
|
||||
// ---- 边界:双击无目标兜底 = 背包/use_fail + L2 明示,不崩 ----
|
||||
const errCnt2 = vibrateCount(ERR_PAT);
|
||||
await gs(game, 'doubletap'); // 电箱房无保险丝
|
||||
assert(vibrateCount(ERR_PAT) === errCnt2 + 1, '双击无目标发 error 震动');
|
||||
assert(rec.count('voice/use_fail') === 1, '兜底播 use_fail(关键房间)');
|
||||
await finishVoices(1);
|
||||
assert(rec.count('voice/stuck_key_2') === 1, '兜底追加 L2 明示 stuck_key_2');
|
||||
await finishVoices(1);
|
||||
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 B:引导错手势纠错 + 连错 3 次直白重述 + 下滑跳过引导
|
||||
// ================================================================
|
||||
section('B. 引导纠错与跳过');
|
||||
{
|
||||
store.clear();
|
||||
vibrateLog.length = 0;
|
||||
const { game, rec } = await newGame();
|
||||
game.start();
|
||||
await flush();
|
||||
await tick(VOICE_END_SLACK); // g0
|
||||
await gs(game, 'tap'); // G0 → G1
|
||||
await finishVoices(2); // g1_move + skip_hint
|
||||
await gs(game, 'up'); // G1
|
||||
await finishVoices(2); // g1_done + g2_wall
|
||||
await gs(game, 'up'); // G2 ×2
|
||||
await gs(game, 'up');
|
||||
await finishVoices(2); // wall + g3_left
|
||||
assert(game.guideStep === 3, '到达 G3');
|
||||
|
||||
// G3 右滑(应为左)→ guide_wrong_left;连错 3 次 → guide_blunt_left + warning 震动
|
||||
const warnBefore = vibrateCount(WARN_PAT);
|
||||
await gs(game, 'right');
|
||||
assert(rec.count('voice/guide_wrong_left') === 1, 'G3 右滑播 guide_wrong_left');
|
||||
await gs(game, 'right');
|
||||
await gs(game, 'right');
|
||||
assert(rec.count('voice/guide_blunt_left') === 1, '连错 3 次播 guide_blunt_left');
|
||||
assert(vibrateCount(WARN_PAT) === warnBefore + 1, '连错 3 次发 warning 震动');
|
||||
assert(game.guideStep === 3, '纠错不离步(仍 G3)');
|
||||
await gs(game, 'left');
|
||||
assert(game.guideStep === 3 && game._guideBusy, '左滑后正常推进(G4 语音中)');
|
||||
game.stop();
|
||||
|
||||
// 下滑跳过引导
|
||||
store.clear();
|
||||
const g2 = await newGame();
|
||||
g2.game.start();
|
||||
await flush();
|
||||
await gs(g2.game, 'down');
|
||||
assert(g2.rec.count('voice/guide_skip') === 1, '引导期下滑播 guide_skip');
|
||||
await tick(VOICE_END_SLACK);
|
||||
assert(g2.game.phase === 'PLAY', '下滑跳过引导直达 PLAY');
|
||||
assert(store.get('soundgame.escape.guideDone') === '1', '跳过引导也写 guideDone');
|
||||
assert(g2.rec.count('voice/start_room') === 1, '跳过引导播 start_room');
|
||||
g2.game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 C:卡关三级提示(60s/120s/180s + nav 带路 + 进展重置 + 跳过裁定)
|
||||
// ================================================================
|
||||
section('C. 卡关提示计时器');
|
||||
{
|
||||
store.clear();
|
||||
store.set('soundgame.escape.guideDone', '1'); // 二周目直达
|
||||
vibrateLog.length = 0;
|
||||
const { game, rec } = await newGame();
|
||||
game.start();
|
||||
await flush();
|
||||
assert(game.phase === 'REOPEN', '二周目进 REOPEN');
|
||||
assert(rec.count('voice/reopen_prompt') === 1, '播 reopen_prompt');
|
||||
await gs(game, 'tap');
|
||||
assert(game.phase === 'PLAY', 'REOPEN 单击直接进 PLAY');
|
||||
await finishVoices(1); // start_room
|
||||
|
||||
// 60s 无进展 → L1(stuck_ask + stuck_key_1)
|
||||
const warn0 = vibrateCount(WARN_PAT);
|
||||
await tick(60000);
|
||||
assert(rec.count('voice/stuck_ask') === 1, '60s 播 stuck_ask');
|
||||
// L1 可跳:stuck_ask 播放中单击 → 链进 stuck_key_1
|
||||
const skips0 = rec.skips();
|
||||
await gs(game, 'tap');
|
||||
assert(rec.skips() === skips0 + 1, 'L1 提示可单击跳过');
|
||||
await flush();
|
||||
assert(rec.count('voice/stuck_key_1') === 1, '跳过后链播 stuck_key_1');
|
||||
assert(vibrateCount(WARN_PAT) === warn0 + 1, 'L1 发 warning 震动');
|
||||
await finishVoices(1);
|
||||
|
||||
// 120s → L2;180s → L3 带路(stuck_key_3 + nav_left + nav_down)
|
||||
await tick(60000);
|
||||
assert(rec.count('voice/stuck_key_2') === 1, '120s 播 stuck_key_2');
|
||||
await finishVoices(1);
|
||||
await tick(60000);
|
||||
assert(rec.count('voice/stuck_key_3') === 1, '180s 播 stuck_key_3');
|
||||
await finishVoices(1); // stuck_key_3 播完 → nav_left 起
|
||||
assert(rec.count('voice/nav_left') === 1, 'L3 带路算路:(1,1)→(0,2) 先播 nav_left');
|
||||
await finishVoices(1); // nav_left 播完 → nav_down 起
|
||||
assert(rec.count('voice/nav_down') === 1, 'L3 带路算路:再播 nav_down');
|
||||
// L3 带路不可跳:播放中单击 = 忽略
|
||||
const skips1 = rec.skips();
|
||||
const ambC = rec.count('sfx/amb_drip');
|
||||
await gs(game, 'tap');
|
||||
assert(rec.skips() === skips1, 'L3 带路语音不可单击跳过');
|
||||
assert(rec.count('sfx/amb_drip') === ambC, 'L3 播放中单击不触发检查');
|
||||
await finishVoices(1); // nav_down 播完
|
||||
|
||||
// L3 之后每 60s 重复带路
|
||||
await tick(60000);
|
||||
assert(rec.count('voice/stuck_key_3') === 2, 'L3 每 60s 重复带路');
|
||||
await finishVoices(3);
|
||||
|
||||
// 进展事件重置:移动到关键房间 → _stuckShown 清空 → 再无进展约 60s 重新从 L1 起
|
||||
// (卡关检查每 5s 一次,相位对齐需略多于 60s)
|
||||
await gs(game, 'right'); // (2,1) 铁柜房,首到 = 进展
|
||||
assert(game._stuckShown.size === 0, '进展事件后 _stuckShown 清空');
|
||||
await finishVoices(1); // room_cabinet
|
||||
await tick(66000);
|
||||
assert(rec.count('voice/stuck_ask') === 2, '进展重置后 60s 重新播 L1');
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 D:语音资产缺失 → 20s 看门狗兜底推进,不卡死
|
||||
// ================================================================
|
||||
section('D. 资产缺失看门狗(屏蔽 g4_down)');
|
||||
{
|
||||
store.clear();
|
||||
blockedAssets = ['g4_down'];
|
||||
const { game } = await newGame();
|
||||
game.start();
|
||||
await flush();
|
||||
await tick(VOICE_END_SLACK);
|
||||
await gs(game, 'tap'); // G0
|
||||
await finishVoices(2);
|
||||
await gs(game, 'up'); // G1
|
||||
await finishVoices(2);
|
||||
await gs(game, 'up'); // G2 ×2
|
||||
await gs(game, 'up');
|
||||
await finishVoices(2);
|
||||
await gs(game, 'left'); // G3 → _advance(4, ['voice/g4_down']) 资产缺失
|
||||
await tick(2000);
|
||||
assert(game.guideStep === 3 && game._guideBusy, 'g4_down 缺失:链不推进,停在 G3 等待');
|
||||
await tick(20000); // 看门狗
|
||||
assert(game.guideStep === 4 && !game._guideBusy, '20s 看门狗兜底推进到 G4');
|
||||
// 继续走完引导,确认不卡死
|
||||
await gs(game, 'down'); // G4
|
||||
await finishVoices(1);
|
||||
await gs(game, 'tap'); // G5
|
||||
await finishVoices(2);
|
||||
await gs(game, 'doubletap'); // G6
|
||||
await finishVoices(3);
|
||||
assert(game.phase === 'PLAY', '资产缺失下仍走完引导进 PLAY');
|
||||
blockedAssets = [];
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 E:设置边界(bgm=0 静默/恢复、voice 性别切换、speed 1.7)
|
||||
// ================================================================
|
||||
section('E. 设置边界');
|
||||
{
|
||||
store.clear();
|
||||
store.set('soundgame.escape.guideDone', '1');
|
||||
const { audio, game } = await newGame();
|
||||
game.start();
|
||||
await flush();
|
||||
await gs(game, 'tap'); // REOPEN → PLAY
|
||||
await finishVoices(1);
|
||||
assert(audio._bgm && audio._bgm.id === 'bgm/drone', 'PLAY 中 BGM 为 drone');
|
||||
|
||||
settings.set({ bgm: 0 });
|
||||
await tick(300);
|
||||
assert(audio._bgm === null, 'bgm=0:进行中的 BGM 停止');
|
||||
assert(audio._bgmWanted && audio._bgmWanted.id === 'bgm/drone', 'bgm=0:保留"想播"意图');
|
||||
settings.set({ bgm: 1.0 });
|
||||
await flush();
|
||||
assert(audio._bgm && audio._bgm.id === 'bgm/drone', 'bgm 调回自动补播 drone');
|
||||
|
||||
settings.set({ voice: 'male' });
|
||||
const fetchBase = fetchLog.length;
|
||||
await gs(game, 'tap'); // 检查 → 描述用男声目录
|
||||
await tick(800);
|
||||
await flush();
|
||||
assert(fetchLog.slice(fetchBase).some((u) => u.includes('/voice/male/')), 'voice=male 后从 male 目录加载');
|
||||
|
||||
settings.set({ speed: 1.7 });
|
||||
await tick(VOICE_END_SLACK); // 让当前句播完
|
||||
await gs(game, 'tap'); // 再触发一句检查描述
|
||||
await tick(800);
|
||||
await flush();
|
||||
assert(audio._voice && audio._voice.st.tempo === 1.7, 'speed=1.7 下一句 tempo 生效');
|
||||
|
||||
settings.set({ voice: 'female', speed: 1.0, bgm: 1.0 }); // 复位共享单例
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 资产引用校验:game.js 引用的 id 在音频目录有 ogg + m4a(voice 双性别),零缺失
|
||||
// ================================================================
|
||||
section('F. 资产引用校验');
|
||||
{
|
||||
const src = readFileSync(join(GAME_DIR, 'game.js'), 'utf8');
|
||||
const ids = new Set();
|
||||
for (const m of src.matchAll(/(voice|sfx|bgm)\/[a-z0-9_]+/g)) {
|
||||
if (!m[0].endsWith('_')) ids.add(m[0]); // 模板前缀(stuck_ 等)单独展开
|
||||
}
|
||||
// 模板字符串族展开
|
||||
for (const d of ['up', 'down', 'left', 'right']) {
|
||||
ids.add(`voice/guide_wrong_${d}`);
|
||||
ids.add(`voice/guide_blunt_${d}`);
|
||||
}
|
||||
for (const st of ['key', 'cabinet', 'box', 'gate']) {
|
||||
for (const lv of [1, 2, 3]) ids.add(`voice/stuck_${st}_${lv}`);
|
||||
}
|
||||
let missing = 0;
|
||||
for (const id of [...ids].sort()) {
|
||||
const [layer, name] = id.split('/');
|
||||
const bases = layer === 'voice'
|
||||
? [`audio/voice/male/${name}`, `audio/voice/female/${name}`]
|
||||
: [`audio/${layer}/${name}`];
|
||||
for (const base of bases) {
|
||||
for (const ext of ['.ogg', '.m4a']) {
|
||||
if (!existsSync(join(GAME_DIR, base + ext))) {
|
||||
missing++;
|
||||
console.error(' ✗ 缺失资产:', base + ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(missing === 0, `资产引用校验:${ids.size} 个 id 全部存在(ogg+m4a${''},voice 双性别),零缺失`);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
console.log(`\n========== 结果:${passes} 通过 / ${failures} 失败 ==========`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user