Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
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 置完成。
2026-10-02 12:33:20 +08:00

248 lines
11 KiB
JavaScript
Raw Permalink 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.
#!/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)`);