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解析)
This commit is contained in:
SoundGame Dev
2026-08-30 21:10:06 +08:00
parent 0a8b5df717
commit 18f299f446
11 changed files with 2450 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
/**
* 无头试玩 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,
};
}