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:
xsl
2026-10-01 21:09:26 +08:00
parent ee9d7efe57
commit e943227136
18 changed files with 322 additions and 37 deletions
+183
View File
@@ -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('全部断言通过。');