- abyss:game.js 242 断言全绿(剧本A-F六路径/氧气门三形态/sonarGate仲裁/结算死窗/双轨看门狗/radio 8项修复继承);资产 148句+11key变体(电台滤波带内能量47-62%→69-80%) - detective:game.js 265 断言真资产下全绿(矩阵不变式独立重算/指证全路径/评级12组注入/VERDICT死窗/二周目变体);真资产复跑回炉3处实现修复(无进展轨墙钟化/菜单暂停记账/停止播报真停声——mock时长掩盖的正确性bug) - 资产:TTS 539条×2格式、Mureka 7首 $0.21(累计$1.08/$2.00)、SYN12+SA3 19件(QC抓出1例AAC过冲并修复)、封面×2、check_all_assets 10游戏全绿(abyss 298/298、detective 279/279) - 平台接线:大厅双卡片、BUCKETS 增 abyss/detective 桶(H4 断言更新 10 游戏)、voice_text.json 10游戏991句 - 回归:14 套件全绿(10 playtest+guide+engine-voice+engine-input+api-smoke 12/12) - 设计偏差登记:abyss 7条/detective 9条(全为设计内部冲突按承诺清单/归因真话律取优)
246 lines
11 KiB
JavaScript
246 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 玩家感知转写器:把一局游戏对玩家输出的「全部可感知事件」转写成时间轴日志。
|
||
*
|
||
* 产物是给人设玩家 agent 读的「玩家视角剧本」:每一句语音(含全文)、每个音效、
|
||
* 每次 BGM 切换、每次震动、每段静默等待、每次资产缺失(玩家听到的静默),
|
||
* 加上脚本注入的玩家手势。人无法听音频,但信息流(何时知道什么、何时轮到自己)
|
||
* 在这份日志里与真实听感一一对应(语音时长按文本长度估算,非精确)。
|
||
*
|
||
* 用法:tools/node/bin/node web/harness/transcribe.mjs <game> [--out <path>]
|
||
* 会话剧本:web/harness/sessions/<game>.mjs(describe(game) + session(ctx)),
|
||
* 无剧本时退化为「新手乱试」通用策略。
|
||
*/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import url from 'node:url';
|
||
import { installMock } from './mock.mjs';
|
||
|
||
const HARNESS_DIR = path.dirname(url.fileURLToPath(import.meta.url));
|
||
const WEB_DIR = path.resolve(HARNESS_DIR, '..');
|
||
const GAME_NAME = process.argv[2];
|
||
if (!GAME_NAME || !fs.existsSync(path.join(WEB_DIR, GAME_NAME, 'game.js'))) {
|
||
console.error('用法: node transcribe.mjs <storyline|escape|simon|rhythm|space|rpg|radio> [--session <导出函数名>] [--out <path>]');
|
||
process.exit(2);
|
||
}
|
||
// 可选:--session sessionStress(默认 session)。会话导出函数不存在时报错退出。
|
||
const GAME_DIR = path.join(WEB_DIR, GAME_NAME);
|
||
const argOf = (k, dflt) => {
|
||
const i = process.argv.indexOf(k);
|
||
return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
|
||
};
|
||
const SESSION_NAME = argOf('--session', 'session');
|
||
const OUT = argOf('--out', path.resolve(WEB_DIR, '..', `research/04-playtests/transcripts/${GAME_NAME}.md`));
|
||
|
||
const VOICE_TEXT = JSON.parse(fs.readFileSync(path.join(HARNESS_DIR, 'voice_text.json'), 'utf8'));
|
||
const textOf = (id) => {
|
||
const table = VOICE_TEXT[GAME_NAME] || {};
|
||
return table[id.replace(/^voice\//, '')] || null;
|
||
};
|
||
|
||
// 中文 TTS 正常语速 ≈ 4.5 字/秒,留 0.4s 起止余量;无文本(sfx)0.35s;bgm 90s
|
||
const estimateDuration = (id) => {
|
||
if (id.startsWith('bgm/')) return 90;
|
||
const t = textOf(id);
|
||
if (!t) return id.startsWith('voice/') ? 2.0 : 0.35;
|
||
return Math.max(0.8, t.length / 4.5 + 0.4);
|
||
};
|
||
|
||
const CLASS_NAME = {
|
||
storyline: 'StorylineGame', escape: 'EscapeGame', simon: 'SimonGame',
|
||
rhythm: 'RhythmGame', space: 'SpaceGame', rpg: 'RpgGame', radio: 'RadioGame',
|
||
dungeon: 'DungeonGame', detective: 'DetectiveGame', abyss: 'AbyssGame',
|
||
}[GAME_NAME];
|
||
|
||
const mock = installMock(GAME_DIR);
|
||
const { realLog } = mock;
|
||
|
||
// ---------------------------------------------------------------- 转写流
|
||
|
||
const lines = [];
|
||
const fmtT = () => `t+${(mock.now / 1000).toFixed(1).padStart(6)}s`;
|
||
const push = (s) => lines.push(`${fmtT()} ${s}`);
|
||
const HAPTIC_NAME = {
|
||
'[20]': 'confirm(轻点确认)', '[60,40,60]': 'error(做错了:两连短震)',
|
||
'[100]': 'success(成功长震)', '[30,50,30,50,30]': 'warning(警告三连震)',
|
||
'[10]': 'swipe(滑动识别)', '[40,100,40]': 'heartbeat(心跳/倒计时)',
|
||
'[15]': 'pickup(获得)', '[80]': 'hit(受击重震)', '[8]': 'beat(节拍点)',
|
||
'[25,40,25,40,25]': 'danger(危险逼近)',
|
||
};
|
||
const hapticName = (p) => HAPTIC_NAME[JSON.stringify(p)] || `自定义震型 ${JSON.stringify(p)}`;
|
||
|
||
// 向导层事件(engine/guide.js setEventHook)→ 玩家可感知的 earcon / 插播语音
|
||
const GUIDE_LABEL = {
|
||
menu: '系统界面', explore: '探索/安全', battle: '战斗/对抗', choice: '抉择/等待输入',
|
||
settle: '结算', danger: '危险', turn: '轮到你', nudge: '看门狗轻提醒',
|
||
error: '出错了', watchdog: '看门狗升级',
|
||
};
|
||
|
||
// ---------------------------------------------------------------- 装载引擎与游戏
|
||
|
||
const { WebAudioEngine } = await import('../engine/audio.js');
|
||
const { settings } = await import('../engine/settings.js');
|
||
const { [CLASS_NAME]: GameClass } = await import(`../${GAME_NAME}/game.js`);
|
||
|
||
let pending = []; // 本次驱动步内产生的感知事件(排序后落盘)
|
||
let lastBgm = null;
|
||
|
||
function capture(audio) {
|
||
const orig = audio.play.bind(audio);
|
||
audio.play = (id, opts) => {
|
||
const isVoice = id.startsWith('voice/');
|
||
const t = textOf(id);
|
||
if (id.startsWith('bgm/')) {
|
||
if (id !== lastBgm) {
|
||
lastBgm = id;
|
||
pending.push(`${fmtT()} ♫ BGM 切换 → ${id}${opts?.fadeMs ? `(淡入 ${opts.fadeMs}ms)` : ''}`);
|
||
}
|
||
} else if (isVoice && id.startsWith('voice/gd_')) {
|
||
// 向导语音(round2):实际播出的在本行渲染(含全文);被 idle-skip 跳过的
|
||
// 不产生 play 调用,由事件钩子单独标注(见 captureGuide)
|
||
pending.push(t
|
||
? `${fmtT()} ▶ 向导语音:「${t}」`
|
||
: `${fmtT()} ▶ 向导语音 voice/${id.slice(6)}(无台词表文本)`);
|
||
} else if (isVoice) {
|
||
pending.push(t
|
||
? `${fmtT()} ▶ 语音:「${t}」`
|
||
: `${fmtT()} ▶ 语音 voice/${id.slice(6)}(无台词表文本)`);
|
||
} else {
|
||
pending.push(`${fmtT()} ♪ 音效 ${id}`);
|
||
}
|
||
return orig(id, opts);
|
||
};
|
||
}
|
||
|
||
/** 向导实例接入事件流(每个 earcon / 被 idle-skip 的语音都转写成玩家可感知的一行;
|
||
* 实际播出的向导语音已由 audio.play 捕获渲染,此处不再重复记行)。 */
|
||
function captureGuide(game) {
|
||
if (!game.guide) return;
|
||
game.guide.setEventHook((evt) => {
|
||
if (evt.voiceId) {
|
||
if (!evt.skipped) return; // 播出的由 capture() 渲染,避免重复
|
||
const t = textOf(`voice/${evt.voiceId}`);
|
||
pending.push(t
|
||
? `${fmtT()} ▶ 向导语音(对白在播,本句按 idle-only 跳过未播):「${t}」`
|
||
: `${fmtT()} ▶ 向导语音 ${evt.voiceId}(idle-only 跳过未播)`);
|
||
} else {
|
||
const label = GUIDE_LABEL[evt.name] || '';
|
||
pending.push(`${fmtT()} ♪ 向导音:${evt.name}${label ? `(${label})` : ''}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
function drain() {
|
||
// 震动/资产缺失按「实际触发时刻」落行(v2 起不再用 drain 时刻近似——
|
||
// round2 审计指标要核 danger 震 ≥400ms 去抖、警报 600ms 去重,必须看真实时间)
|
||
for (const m of mock.vibrateLog.splice(0)) {
|
||
const t = `t+${(m.t / 1000).toFixed(1).padStart(6)}s`;
|
||
pending.push(`${t} 〰 震动:${hapticName(m.pattern)}`);
|
||
}
|
||
// 引擎按 ogg→m4a→mp3→wav、性别目录→平铺 逐个探测;只有全部路径都 404,
|
||
// 玩家才是真的什么都没听到(角色音探测性别目录 404 后平铺成功属正常回退)
|
||
for (const m of mock.missingAssets.splice(0)) {
|
||
const key = m.url.replace(/^audio\//, '').replace(/\.\w+$/, '').replace(/^voice\/(male|female)\//, 'voice/');
|
||
const everOk = mock.fetchedLog.some((f) => f.ok
|
||
&& f.url.replace(/^audio\//, '').replace(/\.\w+$/, '').replace(/^voice\/(male|female)\//, 'voice/') === key);
|
||
if (!everOk) pending.push(`t+${(m.t / 1000).toFixed(1).padStart(6)}s ⚠ 资产缺失 ${key} → 玩家此刻听到的是静默`);
|
||
}
|
||
pending.sort((a, b) => parseFloat(a.match(/t\+(\s*)(\d+)/)?.[2] ?? 0) - parseFloat(b.match(/t\+(\s*)(\d+)/)?.[2] ?? 0));
|
||
lines.push(...pending);
|
||
pending = [];
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 驱动 API
|
||
|
||
const voiceBusy = (audio) => audio._voice !== null;
|
||
|
||
async function step(audio, game, fn) {
|
||
fn();
|
||
await mock.flush();
|
||
drain();
|
||
}
|
||
|
||
function makeCtx(audio, game, describe) {
|
||
const ctx = {
|
||
game, audio, settings,
|
||
gesture: async (g) => { game.onGesture(g); await mock.flush(); drain(); },
|
||
waitIdle: async () => { await mock.advance(null); drain(); },
|
||
waitVirtual: async (ms) => {
|
||
const target = mock.now + ms;
|
||
await mock.advance(() => mock.now >= target);
|
||
drain();
|
||
},
|
||
waitCond: async (cond) => { await mock.advance(cond); drain(); },
|
||
voiceBusy: () => voiceBusy(audio),
|
||
note: (s) => { lines.push(`\n#### 【玩家动作】${s}\n`); },
|
||
status: (tag) => {
|
||
const d = describe ? describe(game) : '';
|
||
const v = voiceBusy(audio) ? '语音播放中' : (audio._bgm ? `BGM=${audio._bgm?.id ?? '无'}` : '无 BGM');
|
||
// 语速档随状态行输出(round2 F9:转写需可见语速切换生效)
|
||
lines.push(`${fmtT()} 【状态】${tag || ''}${d ? ` ${d}` : ''}(${v},语速${settings.get().speed})`);
|
||
},
|
||
silence: (sec, why) => {
|
||
lines.push(`${fmtT()} ……… 静默 ${sec}s${why ? `(${why})` : ',游戏在等待玩家输入'} ………`);
|
||
},
|
||
};
|
||
return ctx;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 会话
|
||
|
||
const sessionMod = fs.existsSync(path.join(HARNESS_DIR, 'sessions', `${GAME_NAME}.mjs`))
|
||
? await import(`./sessions/${GAME_NAME}.mjs`) : null;
|
||
|
||
const header = [
|
||
`# 试玩转写:《${GAME_NAME}》`,
|
||
'',
|
||
`> 本文件是「玩家感知日志」:一位新手玩家的整局体验转写。语音列出了玩家听到的每句话全文,`,
|
||
`> 音效/BGM/震动按发生时间列出。「静默」段 = 玩家什么也听不到。手势是剧本注入的(模拟真人手部动作),`,
|
||
`> 非最优操作:包含犹豫、误操作与乱试。语音时长按语速估算(±20%),仅供节奏参考。`,
|
||
'',
|
||
].join('\n');
|
||
|
||
async function run() {
|
||
const audio = new WebAudioEngine('audio/');
|
||
const game = new GameClass(audio, {});
|
||
capture(audio);
|
||
captureGuide(game); // 向导层事件 → 转写行(♪ 向导音 / ▶ 向导语音)
|
||
await audio.unlock();
|
||
lines.push(`${fmtT()} (玩家点开游戏,轻触屏幕开始)`);
|
||
game.start();
|
||
await mock.flush();
|
||
drain();
|
||
|
||
if (sessionMod?.[SESSION_NAME]) {
|
||
// 主线/压力等任意导出的会话函数;状态行走话统一用 describe(game)
|
||
await sessionMod[SESSION_NAME](makeCtx(audio, game, sessionMod.describe), mock);
|
||
} else {
|
||
if (sessionMod && SESSION_NAME !== 'session') {
|
||
throw new Error(`会话剧本 web/harness/sessions/${GAME_NAME}.mjs 未导出 ${SESSION_NAME}`);
|
||
}
|
||
// 通用新手策略:等待→单击;没反应就双击→上滑→下滑,各试一次
|
||
const ctx = makeCtx(audio, game, null);
|
||
for (let i = 0; i < 30 && mock.now < 10 * 60 * 1000; i++) {
|
||
await ctx.waitIdle();
|
||
ctx.status('等待中');
|
||
const before = lines.length;
|
||
await ctx.gesture('tap');
|
||
await ctx.waitIdle();
|
||
if (lines.length === before && !voiceBusy(audio)) {
|
||
await ctx.gesture('doubletap');
|
||
await ctx.waitIdle();
|
||
if (lines.length === before) await ctx.gesture('up');
|
||
if (lines.length === before) await ctx.gesture('down');
|
||
}
|
||
drain();
|
||
}
|
||
}
|
||
drain();
|
||
}
|
||
|
||
await run();
|
||
|
||
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
||
fs.writeFileSync(OUT, header + lines.join('\n') + '\n', 'utf8');
|
||
realLog(`OK ${OUT} (${lines.length} 行, 虚拟时长 ${(mock.now / 1000).toFixed(0)}s)`);
|