- engine/guide.js 运行时合成8个签名earcon(方法论L2硬参数),off档零定时器,插播queue不顶对白 - settings: guide三档(full/standard/off默认full) + 语速2.2视障档;audio: isVoicePlaying()/setRateBoostEnabled() 清两个已知问题 - 6游戏按schemes§2接线: storyline看门狗+无效手势纠错、simon免扣重听看门狗、escape turn cue+提前卡关提示、rhythm gd_answer/gd_miss+结算看门狗+快进豁免、space首撞指令提示+结算看门狗、rpg格挡纠错+60s催促+状态签名 - 大厅设置面板+向导文档(14-guide-layer.md)+00-global-ux §6;R5台词表补4条一致性 - 试玩转写含向导事件;6 playtest(guide=off断言零改动)+guide.test(34断言)+资产终检全绿
183 lines
6.4 KiB
JavaScript
183 lines
6.4 KiB
JavaScript
/**
|
||
* 无头试玩 mock 层(转写 harness 专用,逻辑对齐各游戏 test/playtest.mjs 的 mock)。
|
||
*
|
||
* 提供:虚拟时钟(setTimeout/clearTimeout/performance.now)、假 AudioContext、
|
||
* 读真实资产目录的 fetch(记录 404)、navigator.vibrate 记录、window 事件。
|
||
* 每个 Node 进程只 install 一次(patch 全局)。
|
||
*/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import url from 'node:url';
|
||
|
||
const VOICE_TEXT = JSON.parse(
|
||
fs.readFileSync(path.join(path.dirname(url.fileURLToPath(import.meta.url)), 'voice_text.json'), 'utf8'),
|
||
);
|
||
|
||
// 中文 TTS 正常语速 ≈ 4.5 字/秒 + 起止余量;无文本 sfx 0.35s;bgm 90s
|
||
function estimateDuration(gameName, url) {
|
||
const m = String(url).match(/audio\/(voice|sfx|bgm)\/([^/?]+?)\.\w+$/);
|
||
if (!m) return 2.0;
|
||
const [, layer, id] = m;
|
||
if (layer === 'bgm') return 90;
|
||
if (layer === 'sfx') return 0.35;
|
||
const t = (VOICE_TEXT[gameName] || {})[id] || (VOICE_TEXT[gameName] || {})[id.replace(/^(male|female)\//, '')];
|
||
return t ? Math.max(0.8, t.length / 4.5 + 0.4) : 2.0;
|
||
}
|
||
|
||
export function installMock(gameDir) {
|
||
const gameName = path.basename(gameDir);
|
||
const realLog = console.log.bind(console);
|
||
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); };
|
||
// 真·可重复的虚拟 interval:rhythm 的 40ms 调度泵、escape 的 5s 卡关轮询都依赖它
|
||
const intervals = new Set();
|
||
globalThis.setInterval = (cb, ms = 0, ...args) => {
|
||
const id = timerSeq++;
|
||
intervals.add(id);
|
||
const step = (...a) => {
|
||
if (!intervals.has(id)) return; // 已被 clearInterval
|
||
cb(...a);
|
||
if (intervals.has(id)) timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args });
|
||
};
|
||
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args });
|
||
return id;
|
||
};
|
||
globalThis.clearInterval = (id) => { intervals.delete(id); timers.delete(id); };
|
||
Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true });
|
||
|
||
async function flush(rounds = 8) {
|
||
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
|
||
}
|
||
|
||
async function advance(cond = null) {
|
||
for (let n = 0; n < 200000; 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('虚拟时钟定时器超上限(疑似死循环)');
|
||
}
|
||
const idle = () => advance(null);
|
||
|
||
const winListeners = new Map();
|
||
globalThis.window = {
|
||
addEventListener: (type, fn) => {
|
||
if (!winListeners.has(type)) winListeners.set(type, []);
|
||
winListeners.get(type).push(fn);
|
||
},
|
||
AudioContext: null,
|
||
};
|
||
const fireWindowEvent = (type) => { for (const fn of winListeners.get(type) || []) fn(); };
|
||
|
||
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); },
|
||
};
|
||
|
||
const vibrateLog = [];
|
||
Object.defineProperty(globalThis, 'navigator', {
|
||
value: { vibrate: (...a) => { vibrateLog.push({ pattern: a[0], t: now }); return true; } },
|
||
configurable: true,
|
||
});
|
||
|
||
class MockParam {
|
||
constructor(v = 1) { this.value = v; }
|
||
setValueAtTime() {}
|
||
linearRampToValueAtTime() {}
|
||
cancelScheduledValues() {}
|
||
}
|
||
class MockGainNode {
|
||
constructor() { this.gain = new MockParam(); }
|
||
connect() {}
|
||
disconnect() {}
|
||
}
|
||
class MockOscillator {
|
||
constructor() {
|
||
this.type = 'sine'; this.frequency = new MockParam();
|
||
this.connect = () => {}; this.start = () => {}; this.stop = () => {};
|
||
}
|
||
}
|
||
class MockAudioContext {
|
||
constructor() {
|
||
this.state = 'running'; this.destination = {}; this.sampleRate = 44100;
|
||
}
|
||
// 跟随虚拟时钟:rhythm 等用 ctx.currentTime 绝对排程的游戏才能在无头环境推进
|
||
get currentTime() { return now / 1000; }
|
||
createGain() { return new MockGainNode(); }
|
||
createOscillator() { return new MockOscillator(); }
|
||
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, playbackRate: new MockParam(),
|
||
connect() {}, start() {}, stop() {},
|
||
};
|
||
}
|
||
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
|
||
decodeAudioData() {
|
||
return Promise.resolve({
|
||
duration: estimateDuration(gameName, lastFetched),
|
||
numberOfChannels: 1, sampleRate: 44100,
|
||
getChannelData: () => new Float32Array(16),
|
||
});
|
||
}
|
||
resume() { this.state = 'running'; return Promise.resolve(); }
|
||
}
|
||
globalThis.window.AudioContext = MockAudioContext;
|
||
|
||
// 静默引擎日志;fetch 记录 404(= 玩家此刻听到静默,转写时如实标注)
|
||
console.error = () => {};
|
||
console.warn = () => {};
|
||
console.log = () => {};
|
||
|
||
const requestedUrls = [];
|
||
const missingAssets = [];
|
||
const fetchedLog = []; // { url, ok } 全量记录,供回退判定
|
||
let lastFetched = null;
|
||
globalThis.fetch = async (u) => {
|
||
const s = String(u);
|
||
requestedUrls.push(s);
|
||
lastFetched = s;
|
||
const p = path.join(gameDir, decodeURIComponent(s));
|
||
if (!fs.existsSync(p)) {
|
||
missingAssets.push({ url: s, t: now });
|
||
fetchedLog.push({ url: s, ok: false });
|
||
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
|
||
}
|
||
const data = fs.readFileSync(p);
|
||
fetchedLog.push({ url: s, ok: true });
|
||
return {
|
||
ok: true, status: 200,
|
||
arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
|
||
};
|
||
};
|
||
|
||
return {
|
||
get now() { return now; },
|
||
flush, advance, idle, fireWindowEvent,
|
||
vibrateLog, requestedUrls, missingAssets, fetchedLog,
|
||
realLog,
|
||
};
|
||
}
|