feat(playbook): 26 号 P0 推广至全部 15 款游戏——兼容输入管道 + 可闻度调优
真人首测反馈「交互还是很迷惑」:大厅 15 款里只有 3 款新交互,其余 12 款 仍是旧版(无声操作、连点连跳),整体体验割裂。本次: - engine/pipeline.js 新增 makeCompatPipeline(纯增量,游戏内部逻辑零改动): · 手势后 100ms 无任何音频调度 → invalid 回执(消灭无声操作,原则 4) · 语音中的手势 → 立即 ok 回执(§4.1 打断即收到) · 语音中 tap 400ms 防连点(§4.2,点两下不连跳两句) · longpressend/tapcancel 收尾噪声直通 - engine/audio.js 加 playCount 探针(回执启发式用) - 12 款游戏 index.html 接入兼容管道(rhythm 保留 immediateTap; 双击判定窗口不动——多款游戏双击是核心玩法/菜单确认,重映射会破坏) - 可闻度调优(真人首测:笔记本外放偏轻,26 号 §3「起步值,真人测试后再调」): 回执增益 ok 0.08→0.11 / invalid 0.10→0.13 / busy 0.04→0.06; 等待声 0.03→0.055 - 大厅:三款深度重构游戏(声命线/暗室/音序回响)置顶 + 「新交互」徽标, 卡片文案直接写玩法手势,测试不再误入旧交互游戏 测试:新增 compat-pipeline.test(9 断言);全部 15 款 playtest 回归通过 (共 3900+ 断言,0 失败);引擎 input/voice/guide 回归通过。
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* compat-pipeline.test.mjs — 兼容输入管道单测(26 号 §4.2 / §5 P0 推广层)。
|
||||
*
|
||||
* 覆盖:
|
||||
* - 手势后游戏有内容音 → 不叠回执(§4.2「已有明显内容音可省 ok」);
|
||||
* - 手势后 100ms 内无任何音频调度 → invalid 回执(消灭无声操作,原则 4);
|
||||
* - 语音播放中的手势 → 立即 ok 回执(§4.1 打断即收到);
|
||||
* - 语音中 tap 的 400ms 防连点(§4.2);
|
||||
* - longpressend / tapcancel 收尾噪声直通,不吃回执;
|
||||
* - audio.playCount 探针计数。
|
||||
*
|
||||
* 运行:tools/node/bin/node web/harness/compat-pipeline.test.mjs
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import url from 'node:url';
|
||||
|
||||
const GAME_DIR = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..', 'simon');
|
||||
|
||||
const realLog = console.log.bind(console);
|
||||
let passCount = 0;
|
||||
const failures = [];
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passCount++; realLog(` ✓ ${msg}`); }
|
||||
else { failures.push(msg); realLog(` ✗ ${msg}`); }
|
||||
}
|
||||
console.error = () => {};
|
||||
console.warn = () => {};
|
||||
console.log = () => {};
|
||||
|
||||
let now = 0;
|
||||
let timerSeq = 1;
|
||||
const timers = new Map();
|
||||
const realSetImmediate = setImmediate;
|
||||
globalThis.setTimeout = (cb, ms = 0, ...args) => {
|
||||
const id = timerSeq++;
|
||||
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb, args });
|
||||
return id;
|
||||
};
|
||||
globalThis.clearTimeout = (id) => { timers.delete(id); };
|
||||
Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true });
|
||||
async function flush(rounds = 6) {
|
||||
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
|
||||
}
|
||||
async function advance(cond = null) {
|
||||
for (let n = 0; n < 10000; n++) {
|
||||
await flush(2);
|
||||
if (cond && cond()) return 'cond';
|
||||
let nextId = -1; let nextAt = Infinity;
|
||||
for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; }
|
||||
if (nextId < 0) return 'idle';
|
||||
now = nextAt;
|
||||
const t = timers.get(nextId);
|
||||
timers.delete(nextId);
|
||||
t.cb(...t.args);
|
||||
}
|
||||
throw new Error('虚拟时钟超上限');
|
||||
}
|
||||
|
||||
globalThis.window = { addEventListener: () => {}, AudioContext: null };
|
||||
const storageData = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (storageData.has(k) ? storageData.get(k) : null),
|
||||
setItem: (k, v) => { storageData.set(k, String(v)); },
|
||||
removeItem: (k) => { storageData.delete(k); },
|
||||
};
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { maxTouchPoints: 1, vibrate: () => true }, configurable: true,
|
||||
});
|
||||
class MockParam {
|
||||
constructor(v = 1) { this.value = v; }
|
||||
setValueAtTime() {} linearRampToValueAtTime() {} cancelScheduledValues() {}
|
||||
}
|
||||
class MockGainNode {
|
||||
constructor() { this.gain = new MockParam(); }
|
||||
connect() {} disconnect() {}
|
||||
}
|
||||
class MockAudioContext {
|
||||
constructor() { this.state = 'running'; this.currentTime = 0; this.destination = {}; }
|
||||
createGain() { return new MockGainNode(); }
|
||||
createDynamicsCompressor() {
|
||||
const n = new MockGainNode();
|
||||
n.threshold = new MockParam(); n.ratio = new MockParam();
|
||||
n.attack = new MockParam(); n.release = new MockParam();
|
||||
return n;
|
||||
}
|
||||
createBufferSource() { return { buffer: null, loop: false, connect() {}, start() {}, stop() {} }; }
|
||||
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
|
||||
createOscillator() { return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} }; }
|
||||
decodeAudioData() { return Promise.resolve({ duration: 2.0, numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(16) }); }
|
||||
resume() { this.state = 'running'; return Promise.resolve(); }
|
||||
}
|
||||
globalThis.window.AudioContext = MockAudioContext;
|
||||
globalThis.fetch = async (u) => {
|
||||
const p = path.join(GAME_DIR, decodeURIComponent(String(u)));
|
||||
if (!fs.existsSync(p)) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
|
||||
const data = fs.readFileSync(p);
|
||||
return { ok: true, status: 200, arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) };
|
||||
};
|
||||
|
||||
const { WebAudioEngine } = await import('../engine/audio.js');
|
||||
const { settings } = await import('../engine/settings.js');
|
||||
const { makeCompatPipeline } = await import('../engine/pipeline.js');
|
||||
const { Receipts } = await import('../engine/receipts.js');
|
||||
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
|
||||
|
||||
const audio = new WebAudioEngine('audio/');
|
||||
await audio.unlock();
|
||||
const receipts = new Receipts(audio);
|
||||
const receiptLog = [];
|
||||
const origPlay = receipts.play.bind(receipts);
|
||||
receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origPlay(kind); };
|
||||
|
||||
// 假游戏:按手势决定是否有内容音
|
||||
const fakeGame = {
|
||||
lastGesture: null,
|
||||
loudGestures: new Set(), // 这些手势会立即播 sfx
|
||||
onGesture(g) {
|
||||
this.lastGesture = g;
|
||||
if (this.loudGestures.has(g)) audio.play('sfx/tone_tap');
|
||||
return undefined; // 旧游戏通常无返回值
|
||||
},
|
||||
};
|
||||
const pipe = makeCompatPipeline({ audio, receipts, game: fakeGame });
|
||||
|
||||
realLog('== 兼容管道(P0 推广层)==');
|
||||
{
|
||||
// 1. 内容音即反馈:不叠回执
|
||||
fakeGame.loudGestures.add('left');
|
||||
const n0 = receiptLog.length;
|
||||
pipe('left'); await flush();
|
||||
await advance(() => now >= 150);
|
||||
assert(receiptLog.length === n0, '手势带内容音 → 不叠回执(内容音即反馈)');
|
||||
|
||||
// 2. 无声操作 → 100ms 时给 invalid
|
||||
const n1 = receiptLog.length;
|
||||
pipe('right'); await flush();
|
||||
assert(receiptLog.length === n1, '手势后 100ms 内不抢先出声(等结果)');
|
||||
await advance(() => receiptLog.length > n1);
|
||||
const r = receiptLog[receiptLog.length - 1];
|
||||
assert(r.kind === 'invalid' && r.t <= 100 + 500, `无声手势 → invalid 回执(实际 ${r && r.kind}@${r && r.t}ms)`);
|
||||
|
||||
// 3. playCount 探针
|
||||
const c0 = audio.playCount;
|
||||
audio.play('sfx/tone_tap');
|
||||
assert(audio.playCount === c0 + 1, 'audio.playCount 每次 play 递增(回执启发式探针)');
|
||||
|
||||
// 4. 语音中手势 → 立即 ok
|
||||
audio.play('voice/t0_new'); await flush();
|
||||
assert(audio.isVoicePlaying(), '假游戏对白在播');
|
||||
const n2 = receiptLog.length;
|
||||
pipe('up'); await flush();
|
||||
assert(receiptLog.length === n2 + 1 && receiptLog[receiptLog.length - 1].kind === 'ok',
|
||||
'语音中的手势 → 立即 ok 回执(打断即收到)');
|
||||
await advance(() => !audio.isVoicePlaying());
|
||||
|
||||
// 5. 语音中 tap 防连点:第二下只给 busy
|
||||
audio.play('voice/t0_new'); await flush();
|
||||
const n3 = receiptLog.length;
|
||||
pipe('tap'); await flush();
|
||||
assert(receiptLog[n3] && receiptLog[n3].kind === 'ok', '语音中 tap → ok');
|
||||
pipe('tap'); await flush(); // 虚拟时钟未推进 = 400ms 内
|
||||
assert(receiptLog[n3 + 1] && receiptLog[n3 + 1].kind === 'busy', '400ms 内第二下 tap → busy(不连跳)');
|
||||
now += 500; // 跨过防连点窗口
|
||||
await advance(() => !audio.isVoicePlaying());
|
||||
|
||||
// 6. 收尾噪声直通
|
||||
const n4 = receiptLog.length;
|
||||
let passed = false;
|
||||
fakeGame.loudGestures.delete('left');
|
||||
fakeGame.onGesture = (g) => { if (g === 'longpressend') passed = true; };
|
||||
pipe('longpressend'); await flush();
|
||||
await advance(() => now >= 150);
|
||||
assert(passed && receiptLog.length === n4, 'longpressend 直通游戏、不吃回执');
|
||||
}
|
||||
|
||||
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
|
||||
if (failures.length > 0) {
|
||||
realLog('失败清单:');
|
||||
for (const f of failures) realLog(` - ${f}`);
|
||||
process.exit(1);
|
||||
}
|
||||
realLog('全部断言通过。');
|
||||
Reference in New Issue
Block a user