- 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断言)+资产终检全绿
229 lines
9.3 KiB
JavaScript
229 lines
9.3 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> [--out <path>]');
|
||
process.exit(2);
|
||
}
|
||
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 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',
|
||
}[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_')) {
|
||
// 向导语音经事件钩子渲染(▶ 向导语音),此处不重复记行
|
||
} else if (isVoice) {
|
||
pending.push(t
|
||
? `${fmtT()} ▶ 语音:「${t}」`
|
||
: `${fmtT()} ▶ 语音 voice/${id.slice(6)}(无台词表文本)`);
|
||
} else {
|
||
pending.push(`${fmtT()} ♪ 音效 ${id}`);
|
||
}
|
||
return orig(id, opts);
|
||
};
|
||
}
|
||
|
||
/** 向导实例接入事件流(每个 earcon/插播语音都转写成玩家可感知的一行)。 */
|
||
function captureGuide(game) {
|
||
if (!game.guide) return;
|
||
game.guide.setEventHook((evt) => {
|
||
if (evt.voiceId) {
|
||
const t = textOf(`voice/${evt.voiceId}`);
|
||
pending.push(t
|
||
? `${fmtT()} ▶ 向导语音:「${t}」`
|
||
: `${fmtT()} ▶ 向导语音 ${evt.voiceId}(无台词表文本)`);
|
||
} else {
|
||
const label = GUIDE_LABEL[evt.name] || '';
|
||
pending.push(`${fmtT()} ♪ 向导音:${evt.name}${label ? `(${label})` : ''}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
function drain() {
|
||
for (const m of mock.vibrateLog.splice(0)) {
|
||
pending.push(`${fmtT()} 〰 震动:${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(`${fmtT()} ⚠ 资产缺失 ${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');
|
||
lines.push(`${fmtT()} 【状态】${tag || ''}${d ? ` ${d}` : ''}(${v})`);
|
||
},
|
||
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) {
|
||
await sessionMod.session(makeCtx(audio, game, sessionMod.describe), mock);
|
||
} else {
|
||
// 通用新手策略:等待→单击;没反应就双击→上滑→下滑,各试一次
|
||
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)`);
|