Files
SoundGame/web/harness/mock.mjs
T
SoundGame Dev 18f299f446 research: 交互研究项目立项(章程/5路调研4份/游戏交互清点/玩家感知转写harness)
- research/00-charter.md 研究章程:RQ1-8、多人设×场景矩阵、成功标准
- research/01-literature/ 01/03/04/05 四份调研报告(02 sonification 进行中)
- research/03-schemes/game-inventory.md 6游戏状态机与交互接线清单(带行号)
- web/harness/ 玩家感知转写器:虚拟时钟mock+台词全文转写+静默/缺资产如实标注
- tools/extract_voice_text.py 台词表→JSON 提取(主表+旧flavor AST解析)
2026-08-30 21:10:06 +08:00

170 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 无头试玩 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); };
globalThis.clearInterval = (id) => { timers.delete(id); };
globalThis.setInterval = globalThis.setTimeout; // 转写场景无真实 interval 语义需求
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.currentTime = 0; this.destination = {};
this.sampleRate = 44100;
}
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,
};
}