storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行 (发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/ 菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。 游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio / dungeon / abyss / detective / cipher / teahouse / keeper / bridge / hunt(联机切片): - 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0); 四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt 旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore) - 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动 原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿 - 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/ 台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写 引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record 前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前 状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在 speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。 语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110 废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器 tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a 落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。 大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README / HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
248 lines
11 KiB
JavaScript
248 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',
|
||
cipher: 'CipherGame', bridge: 'BridgeGame', teahouse: 'TeahouseGame',
|
||
keeper: 'KeeperGame', hunt: 'HuntGame',
|
||
}[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)`);
|