feat(new8/batch1): 两款新 RPG《不眠频率》radio +《回声地窟》dungeon 全套(多agent流水线:策划→资产→开发→测试→玩家R1→修复→R2)

- 程序章程 research/new8/00-program.md(8款概念/回合协议/验收门/进度账)
- 设计:docs/design/15-radio.md+voice-ids(132句,6状态9抉择4结局,电话滤波资产侧带通)/16-dungeon.md+voice-ids(125句,脉冲回声/7层seed生成/三档战斗/元进度遗产)
- 资产:TTS 970文件(GPU flock串行)+ Mureka BGM 8首 $0.24(台账 mureka_log.md)+ 程序合成 sfx(含预pan侧墙/开口、phone_ring铃声、amb_cave呼吸层、turn_tick)+ 终检 check_all_assets 8游戏全绿
- radio:game.js+playtest 221断言(4结局矩阵/pendingChoice盲玩公平/RING两级看门狗/菜单循环/存档schema)
- dungeon:game.js+playtest 1610断言(seed全网格回声一致性/学期零死/格挡80%/死亡归因真话/元进度/菜单冻结/上滑5段状态链)
- 引擎修复:audio.js _playVoice 加载竞态补杀 token(R1真机71分钟语音风暴根因)+ 同id 3s窗口≥5次熔断保险丝 + gesture.js 长按boost事件门(+66行,9套件回归全绿)
- 大厅:新增 radio/dungeon 卡片 + GAMES 数组
- harness:sessions/radio.mjs+dungeon.mjs(主线+压力双剧本)+ transcribe 注册 + voice_text.json 刷新(8游戏704句)+ 13号文档 §20 登记
- R1 存档:4人设评审×2游戏 + 真机试玩报告×2(radio 5/5愿意玩;dungeon 0/4→确诊风暴)+ synthesis×2 + 转写8份
- R2 修复:radio F1-F5(看门狗补挂/rep_low改词重录/真铃声/ASK续玩语义/链末跳过保护)+ dungeon D0-1..D1-6(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
This commit is contained in:
xsl
2026-09-02 18:52:37 +08:00
parent 3537610130
commit d870a75a26
1143 changed files with 24335 additions and 17 deletions
+570
View File
@@ -0,0 +1,570 @@
/**
* 《回声地窟》新手会话剧本。
*
* 规则:
* - 主线剧本 `session`:一位盲眼新手玩家的完整弧线——标题开始 → DRILL 九步逐项跟练
* (脉冲/前进/转身/重发/状态/菜单/教学战/下潜)→ EXPLORE 脉冲探路、明雷战斗 →
* 逐层下潜到窟主面前 → 力战而亡听死亡归因与元进度入账 → 第二周目(遗产生效、
* 不再进教学)短程收尾,完整展示死亡-重开循环。
* - 乱按压力剧本 `sessionStress`:一位手忙脚乱的好奇宝宝——标题乱划、教学乱按、
* 狂点脉冲、战斗乱滑乱逃、菜单乱逛后关掉——一样要走到死亡-重开循环,全程零死锁。
*
* 剧本失真红线(参照 radio/storyline 剧本先例):
* - 玩家不抢话:出手前等语音链播完(waitCond !voiceBusy);
* - 玩家靠耳朵玩游戏:走位前先脉冲听回声,导航只把「听到的方向」翻译成手势;
* - 绝不用 waitIdle 空等怪物/前摇(有定时器在跑,必须 waitCond/waitVirtual 有界等待);
* - 菜单里只按「下滑关闭」,绝不双击执行(防止误触「保存并回大厅」结束会话)。
*/
const ORDER = ['N', 'E', 'S', 'W'];
const DIRS = { N: { dx: 0, dy: -1 }, E: { dx: 1, dy: 0 }, S: { dx: 0, dy: 1 }, W: { dx: -1, dy: 0 } };
const HP_BAND = { 1: '毫发无损', 2: '受了点伤', 3: '伤势不轻', 4: '命悬一线' };
export function describe(game) {
if (game.menuOpen) return '菜单里:单击切换,双击确认,上滑帮助,下滑关闭';
if (game.state === 'BATTLE' && game._battle) {
const b = game._battle;
if (b.state === 'telegraph') return '敌方前摇声响起了——双击,格挡!';
if (b.state === 'player') {
return `战斗中:你的血量${HP_BAND[game._hpBand(game.run.hp, game._maxHp())]},`
+ `等你出手(单击挥击,按住屏幕逃跑)`;
}
return '战斗播报中,先听着';
}
switch (game.state) {
case 'TITLE': return game.run
? '检测到探窟记录:单击继续,右滑重掷新周目,上滑听元进度'
: '标题页:单击开始探窟,上滑听元进度';
case 'DRILL': return game._drill
? `教学演练第 ${game._drill.step} 步,跟着旁白做`
: '教学演练中';
case 'EXPLORE': return game.run
? `第 ${game.run.floor} 层黑暗里:单击发脉冲用回声看路,按住前进,左右滑转身,上滑状态,下滑菜单`
: '……';
case 'DESCEND': return '顺着楼梯,往下潜……';
case 'SETTLE': return '结算:死亡归因与元进度入账,单击再探一次';
default: return '……';
}
}
// ---------------------------------------------------------------- 会话工具
/** 有界等待:条件满足或超时即返回。时钟冻结(无定时器)时按迭代数兜底,绝不忙等挂死。 */
async function waitStep(ctx, mock, cond, maxMs = 30000) {
const t0 = mock.now;
let spins = 0;
const spinCap = Math.max(60, Math.floor(maxMs / 50));
while (!cond() && mock.now - t0 < maxMs && spins < spinCap) {
spins++;
await ctx.waitVirtual(100);
}
}
/** 转身面向 dir(左右滑,听转身声)。 */
async function faceDir(ctx, game, dir) {
const d = (ORDER.indexOf(dir) - ORDER.indexOf(game.run.face) + 4) % 4;
const gs = d === 3 ? 'left' : 'right';
for (let i = 0; i < (d === 3 ? 1 : d); i++) {
await ctx.gesture(gs);
await ctx.waitVirtual(150);
}
}
/** BFS 寻路(避开怪物格):把「听来的方位感」翻译成一步步的手势。 */
function bfsPath(floor, from, to, blocked) {
const S = floor.size;
const startK = from.x + ',' + from.y;
if (from.x === to.x && from.y === to.y) return [];
const prev = new Map();
const seen = new Set([startK]);
const q = [[from.x, from.y]];
while (q.length) {
const [x, y] = q.shift();
for (const [dir, d] of Object.entries(DIRS)) {
const nx = x + d.dx, ny = y + d.dy, k = nx + ',' + ny;
if (nx < 0 || ny < 0 || nx >= S || ny >= S) continue;
// 目标格即使被怪占着也要能走到(明雷:踏上窟主的格子触发遭遇)
if (floor.grid[ny][nx] !== 1 || seen.has(k) || (blocked.has(k) && !(nx === to.x && ny === to.y))) continue;
seen.add(k);
prev.set(k, { x, y, dir });
if (nx === to.x && ny === to.y) { q.length = 0; break; }
q.push([nx, ny]);
}
}
const steps = [];
let cur = to.x + ',' + to.y;
if (!prev.has(cur)) return steps;
while (cur !== startK) {
const p = prev.get(cur);
const [sx, sy] = cur.split(',').map(Number);
steps.unshift({ x: sx, y: sy, dir: p.dir });
cur = p.x + ',' + p.y;
}
return steps;
}
const mobCells = (floor) => new Set(floor.mobs.map((m) => m.x + ',' + m.y));
/** 认真打法:前摇声响就双击格挡,轮到自己就单击挥击,直到战斗结束。 */
async function fight(ctx, game) {
let guard = 0;
while (game.state === 'BATTLE' && game._battle && guard < 120) {
guard++;
const b = game._battle;
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(600); continue; }
if (b.state === 'telegraph') {
ctx.note('前摇声响——双击,格挡');
await ctx.gesture('doubletap');
await ctx.waitVirtual(300);
continue;
}
if (b.state === 'player') {
ctx.note('单击,挥击');
await ctx.gesture('tap');
await ctx.waitVirtual(500);
continue;
}
await ctx.waitVirtual(400);
}
await ctx.waitVirtual(3000); // 胜利链播完
}
/** 力战而亡:不逃跑、顾不上格挡,喘着气隔回合挥拳——窟主的刀比你的拳快(死亡归因路线)。 */
async function fightToDeath(ctx, game) {
let guard = 0;
let turn = 0;
while (game.state === 'BATTLE' && guard < 160) {
guard++;
const b = game._battle;
if (!b) break;
if (b.state === 'player') {
if (turn % 2 === 0) {
ctx.note('咬牙单击——挡不住了也要打');
await ctx.gesture('tap');
} else {
ctx.note('喘一口气,再挥');
}
turn++;
await ctx.waitVirtual(500);
continue;
}
await ctx.waitVirtual(400);
}
}
/** 探索导航:脉冲听路 → 一步步走向目标格;路上撞进小怪就打完再走,
* 撞进窟主(deathOnBoss)就转为力战而亡。
* R2 伪影修正:until='stairs' 时以「踏上楼梯格」为到站条件(R1 剧本按坐标
* 判到站,长按多走/被怪打断时提前收工,导致「单击下潜」点在半路变成脉冲——
* 全程零下楼演出、165 脉冲 0 次铁器回声的转写伪影根源);D9 起脉冲常开
* (玩家靠耳朵玩游戏红线:走位前先听回声)。 */
async function navigate(ctx, mock, game, target, { pulse = true, deathOnBoss = false, until = 'cell' } = {}) {
for (let guard = 0; guard < 220; guard++) {
if (game.state === 'BATTLE') {
if (deathOnBoss && game._battle?.boss) { await fightToDeath(ctx, game); continue; }
await fight(ctx, game);
continue;
}
if (game.state !== 'EXPLORE' && game.state !== 'DRILL') return;
if (!game.run) return;
if (until === 'stairs' ? !!game._onStairs
: (game.run.pos.x === target.x && game.run.pos.y === target.y)) return;
if (pulse) {
ctx.note('单击发脉冲,听回声辨路');
await ctx.gesture('tap');
await ctx.waitVirtual(2000); // 听完回声组再动身
}
const path = bfsPath(game._floor, game.run.pos, target, mobCells(game._floor));
const step = (path[0] || bfsPath(game._floor, game.run.pos, target, new Set())[0]); // 无避怪路就硬走:撞进怪=明雷遭遇,打完再走
if (!step) {
if (process.env.DUNGEON_SESS_DEBUG) mock.realLog('[sess-debug] !step @', JSON.stringify(game.run.pos), 'target=', JSON.stringify(target),
'onStairs=', game._onStairs, 'floor=', game.run.floor, 'mobs=', JSON.stringify(game._floor.mobs.map((m) => [m.type, m.x, m.y])));
await ctx.waitVirtual(800);
continue;
} // 被游走的怪堵住了:等它走开
if (process.env.DUNGEON_SESS_DEBUG && game.run.floor >= 2) {
mock.realLog('[sess-debug] iter', JSON.stringify(game.run.pos), game.run.face,
'target=', JSON.stringify(target), 'state=', game.state, 'walk=', !!game._walk,
'step=', JSON.stringify(step || null), 'menu=', game.menuOpen, 't=', mock.now);
}
await faceDir(ctx, game, step.dir);
if (game.state === 'BATTLE') continue; // 走位途中被扑进来的怪打断:先打完再走(防长按变逃跑)
await ctx.gesture('longpress');
// 立即松开,走「恰 1 格」。R2 修正:原来的 waitVirtual(120) 在虚拟时钟里会
// 越过 +500ms 的下一步计时器(advance 至少推进到下一个到期定时器),每按一次
// 多走 1+ 格——到站判断永远踩不准楼梯格,剧本在楼梯两侧来回震荡 20 分钟
//(R1 转写「165 脉冲 0 铁器、两次下楼零演出」的另一根源)。
await ctx.gesture('longpressend');
await ctx.waitVirtual(200);
}
}
/** 到站下潜:只有真踩在楼梯格上才单击(R2 伪影修正:半路的单击只是浪费的脉冲)。
* DRILL(D9) 也接受——教学最后一击 = 踏楼梯单击下潜,是设计好的出教学路径。 */
async function tapDescend(ctx, mock, game) {
if (!game._onStairs || (game.state !== 'EXPLORE' && game.state !== 'DRILL')) return false;
ctx.note('铁器就在脚下——单击,下潜');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state !== 'EXPLORE' && game.state !== 'DRILL', 4000);
return true;
}
// ---------------------------------------------------------------- 主线剧本
export async function session(ctx, mock) {
const { game } = ctx;
// —— 标题:单击开始探窟(首局无存档) ——
await waitStep(ctx, mock, () => game.state === 'TITLE');
ctx.status('盲眼探窟者睁开了耳朵,准备下窟');
ctx.note('单击,开始');
await ctx.gesture('tap');
// —— DRILL 九步逐项跟练 ——
await waitStep(ctx, mock, () => game.state === 'DRILL' && game._drill?.step === 0);
ctx.status('教学开始:三句开场白');
await waitStep(ctx, mock, () => !ctx.voiceBusy(), 12000);
ctx.note('单击回应「先学会用耳朵看」');
await ctx.gesture('tap'); // D0 → D1
await waitStep(ctx, mock, () => game._drill?.step === 1);
ctx.note('单击——人生第一声脉冲');
await ctx.gesture('tap'); // D1 脉冲
await waitStep(ctx, mock, () => game._drill?.step === 2);
ctx.status('回声回来了,听墙的距离');
await ctx.waitVirtual(2000);
await waitStep(ctx, mock, () => !ctx.voiceBusy(), 8000);
ctx.note('单击继续');
await ctx.gesture('tap'); // D2 → D3
await waitStep(ctx, mock, () => game._drill?.step === 3);
await waitStep(ctx, mock, () => !ctx.voiceBusy(), 8000);
ctx.note('单击继续(侧向规则听完了)');
await ctx.gesture('tap'); // D3 → D4
await waitStep(ctx, mock, () => game._drill?.step === 4);
ctx.note('按住屏幕不放——脚步声起,往前走');
await ctx.gesture('longpress');
await ctx.waitVirtual(1400); // 走满 2 格
ctx.note('松开,停下');
await ctx.gesture('longpressend');
await waitStep(ctx, mock, () => game._drill?.step === 5);
ctx.note('左滑,转身');
await ctx.gesture('left');
await ctx.waitVirtual(400);
ctx.note('右滑,转回来');
await ctx.gesture('right');
await waitStep(ctx, mock, () => game._drill?.step === 6);
ctx.note('双击——再发一次脉冲,不要钱');
await ctx.gesture('doubletap'); // D6 重发
await waitStep(ctx, mock, () => game._drill?.step === 7);
await ctx.waitVirtual(2000);
ctx.note('上滑,听自己的状态');
await ctx.gesture('up');
await ctx.waitVirtual(5800); // 五段状态链
ctx.note('下滑,打开菜单');
await ctx.gesture('down');
await ctx.waitVirtual(1600);
ctx.note('再下滑,关上菜单');
await ctx.gesture('down');
await waitStep(ctx, mock, () => game._drill?.step === 8);
// —— D8 教学战:盲眼蝠 ATK=0,伤不到你 ——
ctx.status('「前面有东西!放心,它伤不到你」——等它扑上来');
await waitStep(ctx, mock, () => game.state === 'BATTLE' && game._battle, 25000);
ctx.status('教学怪扑到了面前');
await waitStep(ctx, mock, () => game._battle?.state === 'telegraph', 15000);
ctx.note('听到它的动静——双击,格挡');
await ctx.gesture('doubletap');
await waitStep(ctx, mock, () => game._battle?.state === 'player', 15000);
ctx.note('单击挥拳,两拳放倒它');
for (let i = 0; i < 6 && game.state === 'BATTLE'; i++) {
if (game._battle?.state === 'telegraph') { await ctx.gesture('doubletap'); await ctx.waitVirtual(300); }
else if (game._battle?.state === 'player') { await ctx.gesture('tap'); await ctx.waitVirtual(400); }
else await ctx.waitVirtual(400);
}
await waitStep(ctx, mock, () => game._drill?.step === 9, 20000);
ctx.status('教学战获胜——「打中了,就是这样」');
// —— D9:听铁器的回声,自己走到楼梯(脉冲常开:靠耳朵找到铁器再落脚) ——
ctx.note('单击,听听楼梯的铁器回声');
await ctx.gesture('tap');
await ctx.waitVirtual(2200);
await navigate(ctx, mock, game, game._floor.stairs, { until: 'stairs' });
ctx.note('踏上楼梯格——「楼梯就在脚下」');
if (!(await tapDescend(ctx, mock, game))) {
ctx.note('(还没到楼梯,继续朝铁器回声摸过去)');
await navigate(ctx, mock, game, game._floor.stairs, { until: 'stairs' });
await tapDescend(ctx, mock, game);
}
await waitStep(ctx, mock, () => game.state === 'EXPLORE' && game.run?.floor === 2, 20000);
ctx.status(game.run?.floor === 2
? '教学完了。第二层,真正的地窟从这里开始'
: '下潜尚未完成——先把楼梯找到');
// —— EXPLORE:脉冲探路 → 明雷战斗 → 逐层下潜到第 3 层(每层走一步验一步,
// 被怪打断/没踩准则再摸一轮——R2 修正:单发 navigate+tapDescend 任何一环
// 被打断就整段脱轨,这是 R1 转写下潜零演出的第三个根源) ——
while (game.run && game.run.floor < 3 && game.state !== 'SETTLE') {
const from = game.run.floor;
ctx.note(`单击脉冲,探一探第 ${from} 层`);
await ctx.gesture('tap'); // 若正踩在楼梯上(D9 收尾),这一击就是下潜
await ctx.waitVirtual(2200);
await navigate(ctx, mock, game, game._floor.stairs, { until: 'stairs' });
if (!(await tapDescend(ctx, mock, game))) {
ctx.note('(还没踩准楼梯,朝铁器回声再摸一轮)');
await navigate(ctx, mock, game, game._floor.stairs, { until: 'stairs' });
if (!(await tapDescend(ctx, mock, game)) && game._onStairs) {
ctx.note('铁器就在脚下——单击,下潜');
await ctx.gesture('tap');
}
}
await waitStep(ctx, mock, () => !game.run || game.run.floor > from || game.state === 'SETTLE', 20000);
const done = game.run && game.run.floor === from + 1;
ctx.status(!done
? `下潜尚未完成(还在第 ${game.run?.floor ?? '?'} 层)`
: game.run.floor === 3 ? '第三层——窟主的地界' : `下潜成功,这是第 ${game.run.floor} 层`);
if (!done) break; // 两轮都没下去:不空转,如实记「没下去」
}
// —— L3:走向楼梯,窟主石甲蟹就守在上面 → 力战而亡 ——
for (let guard = 0; guard < 3 && game.run && game.run.floor === 3 && game.state !== 'SETTLE'; guard++) {
ctx.note('脉冲听路,朝楼梯摸过去');
await ctx.gesture('tap');
await ctx.waitVirtual(2200);
await navigate(ctx, mock, game, game._floor.stairs || game._floor.throne, { deathOnBoss: true, until: 'stairs' });
if (game.state === 'BATTLE' && game._battle?.boss) { await fightToDeath(ctx, game); continue; }
if (game._onStairs && game.state === 'EXPLORE') { await tapDescend(ctx, mock, game); continue; }
break;
}
// —— SETTLE:死亡归因 + 元进度入账 ——
await waitStep(ctx, mock, () => game.state === 'SETTLE', 60000);
if (game.state === 'SETTLE') {
ctx.status('倒下了。死亡归因链:死于谁、哪里没做好、带走了多少金币');
ctx.note('听完归因三问和「下个周目,你会更强」');
await ctx.waitVirtual(10000);
ctx.note('单击——再探一次');
await ctx.gesture('tap'); // → TITLE(元进度入账后)
await waitStep(ctx, mock, () => game.state === 'TITLE', 10000);
} else {
ctx.note('这一把竟然没死——那就继续往下走');
}
// —— 第二周目:遗产继承、不再进教学(只有真回到 TITLE 才单击开局) ——
if (game.state === 'TITLE') {
ctx.note('单击,开始新周目');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'EXPLORE' && game.run, 30000);
ctx.status('新周目直落探索:遗产生效,不用再上课');
ctx.note('上滑,听新周目的局面复述');
await ctx.gesture('up');
await ctx.waitVirtual(6500);
ctx.note('单击脉冲,听听熟悉的回声');
await ctx.gesture('tap');
await ctx.waitVirtual(2500);
}
ctx.status('第二周目短程收尾——死亡-重开循环跑通,收工');
}
// ---------------------------------------------------------------- 乱按压力剧本
export async function sessionStress(ctx, mock) {
const { game } = ctx;
let seed = 20260901;
const rng = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
const NOISE = ['tap', 'tap', 'doubletap', 'left', 'right', 'up']; // down/longpress 单独管,防误触
/** 乱按的安全阀:菜单里只准「下滑关闭」,绝不双击执行。 */
const noise = async () => {
if (game.menuOpen) {
ctx.note('菜单乱逛完了,下滑关掉');
await ctx.gesture('down');
await ctx.waitVirtual(500);
return;
}
if (rng() < 0.15) { // 偶尔长按(探索=前进,战斗=尝试逃跑,Boss 会拒绝)
await ctx.gesture('longpress');
await ctx.waitVirtual(300);
await ctx.gesture('longpressend');
await ctx.waitVirtual(200);
return;
}
await ctx.gesture(pick(NOISE));
await ctx.waitVirtual(450);
};
// ① 标题页乱划一通,然后开始
for (let i = 0; i < 10 && game.state === 'TITLE'; i++) {
ctx.status('标题页乱划');
await noise();
}
await ctx.gesture('tap');
// ② 教学期:一半乱按,但每步最后一定做对(跟不下来也要能走完)
await waitStep(ctx, mock, () => game.state === 'DRILL' || game.state === 'EXPLORE', 20000);
ctx.status('教学里乱按一气,旁白说到哪按到哪');
const RIGHT = { 0: 'tap', 1: 'tap', 2: 'tap', 3: 'tap', 4: 'longpress', 5: 'left', 6: 'doubletap', 9: 'tap' };
let stepGuard = 0;
while (game.state === 'DRILL' && game._drill && stepGuard < 320) {
stepGuard++;
const step = game._drill.step;
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(500); continue; }
if (game.state === 'BATTLE') { await chaosFight(ctx, game, rng, pick); continue; }
if (rng() < 0.45) {
await noise(); // 乱按:会吃 error 震、会走错路,但不死锁
continue;
}
if (step === 7) { // 上滑 + 开菜单再关
if (!game._drill.upDone) { await ctx.gesture('up'); await ctx.waitVirtual(5800); }
else { await ctx.gesture('down'); await ctx.waitVirtual(1400); await ctx.gesture('down'); await ctx.waitVirtual(600); }
continue;
}
if (step === 8) { // 等教学怪扑上来,交给乱打
await waitStep(ctx, mock, () => game.state === 'BATTLE' || game._drill?.step === 9, 25000);
if (game.state === 'BATTLE') await chaosFight(ctx, game, rng, pick);
continue;
}
if (step === 9) { // D9:乱按到这一步也得真走到楼梯,否则会卡死在聆听里
if (game._onStairs) {
await ctx.gesture('tap'); // 单击下潜
await waitStep(ctx, mock, () => game.state !== 'DRILL', 15000);
continue;
}
const st = game._floor.stairs || game._floor.throne;
const stp = (bfsPath(game._floor, game.run.pos, st, mobCells(game._floor))[0]
|| bfsPath(game._floor, game.run.pos, st, new Set())[0]);
if (!stp) { await ctx.gesture('tap'); await ctx.waitVirtual(1500); continue; }
const d = (ORDER.indexOf(stp.dir) - ORDER.indexOf(game.run.face) + 4) % 4;
await ctx.gesture(d === 3 ? 'left' : 'right');
await ctx.waitVirtual(200);
await ctx.gesture('longpress');
await ctx.waitVirtual(120);
await ctx.gesture('longpressend');
await ctx.waitVirtual(250);
continue;
}
const gs = RIGHT[step];
if (!gs) { await ctx.waitVirtual(300); continue; }
await ctx.gesture(gs);
if (step === 4) { await ctx.waitVirtual(1400); await ctx.gesture('longpressend'); }
await ctx.waitVirtual(500);
}
await waitStep(ctx, mock, () => game.state === 'EXPLORE' || game.state === 'BATTLE' || game.state === 'SETTLE', 25000);
ctx.status('总算从教学里滚出来了(可能跳了课,可能一路错过来的)');
// ③+④ 探索/下潜/送死:一路狂点脉冲、乱走乱打,直到死一次为止(零死锁底线=SETTLE)
let chaosGuard = 0;
while (game.run && game.state !== 'SETTLE' && chaosGuard < 500) {
chaosGuard++;
if (game.state === 'DRILL') { // 乱按按出「跳过教学」(下滑在 DRILL 的原义),绝不在教学里空转
await ctx.gesture('down');
await ctx.waitVirtual(2500);
continue;
}
if (game.state === 'BATTLE') {
const b = game._battle;
if (b?.boss || game.run.floor >= 3) {
ctx.status('乱打:不格挡、逃不掉(Boss),一直挥拳');
await fightToDeath(ctx, game);
continue;
}
await chaosFight(ctx, game, rng, pick);
continue;
}
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(400); continue; }
if (game.state !== 'EXPLORE') { await ctx.waitVirtual(400); continue; }
if (game._onStairs && !game._floor.mobs.some((m) => m.x === game.run.pos.x && m.y === game.run.pos.y)) {
ctx.note('正好站在楼梯上——单击下潜');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'EXPLORE' && game.run.floor >= 2, 15000);
continue;
}
const r = rng();
if (r < 0.3) { // 狂点脉冲:冷却期就是一串 error 震,游戏不炸
ctx.note('手痒连点三下脉冲');
await ctx.gesture('tap');
await ctx.gesture('tap');
await ctx.gesture('tap');
await ctx.waitVirtual(1600);
continue;
}
if (r < 0.45) { await noise(); continue; }
// 正经走两步:脉冲听路 → 朝楼梯/王座挪
await ctx.gesture('tap');
await ctx.waitVirtual(1800);
const st = game._floor.stairs || game._floor.throne;
const step = (bfsPath(game._floor, game.run.pos, st, mobCells(game._floor))[0]
|| bfsPath(game._floor, game.run.pos, st, new Set())[0]);
if (!step) { await ctx.waitVirtual(800); continue; }
const d = (ORDER.indexOf(step.dir) - ORDER.indexOf(game.run.face) + 4) % 4;
await ctx.gesture(d === 3 ? 'left' : 'right');
await ctx.waitVirtual(200);
await ctx.gesture('longpress');
await ctx.waitVirtual(120);
await ctx.gesture('longpressend');
await ctx.waitVirtual(250);
}
// ⑤ SETTLE → TITLE → 新周目,短程收尾
await waitStep(ctx, mock, () => game.state === 'SETTLE', 20000);
if (game.state === 'SETTLE') {
ctx.status('乱打也被归因得明明白白,元进度照单全收');
await ctx.waitVirtual(4000);
await ctx.gesture('tap'); // → TITLE
await waitStep(ctx, mock, () => game.state === 'TITLE', 10000);
await noise(); // 标题页再乱划一下
await ctx.gesture('tap'); // 新周目
await waitStep(ctx, mock, () => game.state === 'EXPLORE' && game.run, 25000);
ctx.status('第二周目照样开局,压力测试全程零死锁,收工');
} else {
ctx.note('这一把居然没死——压力有限,收工');
}
}
/** 乱打:大多数时候出手,偶尔乱滑/上滑/试图逃跑;前摇里手忙脚乱。 */
async function chaosFight(ctx, game, rng, pick) {
let guard = 0;
while (game.state === 'BATTLE' && game._battle && guard < 160) {
guard++;
const b = game._battle;
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(500); continue; }
if (b.state === 'telegraph') {
if (rng() < 0.25) {
ctx.note('手忙脚乱中碰巧双击了一下');
await ctx.gesture('doubletap');
await ctx.waitVirtual(300);
} else {
await ctx.waitVirtual(400);
}
continue;
}
if (b.state === 'player') {
const r = rng();
if (r < 0.62) {
ctx.note('单击,抡空也打');
await ctx.gesture('tap');
} else if (r < 0.72) {
ctx.note('慌了,按住屏幕想跑');
await ctx.gesture('longpress');
await ctx.waitVirtual(300);
await ctx.gesture('longpressend');
} else if (r < 0.88) {
await ctx.gesture(pick(['left', 'right']));
} else {
await ctx.gesture('up');
}
await ctx.waitVirtual(500);
continue;
}
await ctx.waitVirtual(400);
}
await ctx.waitVirtual(3000);
}
+209
View File
@@ -0,0 +1,209 @@
/**
* 《不眠频率》新手会话剧本。
*
* 剧本:
* - `session`(主线):一位夜间主播的完整弧线——听完开场白、接完五通电话、
* 每个抉择点都做选择(C1 走心 / C2 劝上车 / C3 问她+真话 / C4 带话+留在直播 /
* C5 陪聊 / p9 直面),听完五句尾声回响,以完整 E1「天亮之前」收尾。
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、菜单里乱逛,
* 一样要能走到 ENDING,全程零死锁、零静默死局。
*
* 剧本失真红线(参照 storyline/escape 剧本先例):
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手;
* - 玩家不干晾自己:只在 ONAIR 叙事等待态偶尔走神触发一次看门狗,链一停就接话;
* - RING/BRIDGE 有铃声/喘息定时器循环,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
* - 通话里每句之间是「链末=继续」的等待态——真人听完一句就点一下,不机械、不抢跑。
*/
const CALL_LABEL = ['一', '二', '三', '四', '五'];
export function describe(game) {
if (game.leaving) return '已确认保存并回大厅';
if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑新夜晚」的询问';
if (game.phase === 'MENU') {
const names = ['回到直播', '重新开始今晚', '保存并回大厅'];
return `菜单里,当前项「${names[game.menuIdx]}」:单击切换,双击确认,上滑帮助,下滑关闭`;
}
if (game.phase === 'INTRO') {
return game.voicePlaying ? '台标和规矩正在播' : '开场白播完了,等第一声铃';
}
if (game.phase === 'RING') return `第 ${CALL_LABEL[game.callN - 1]} 通电话打进来:单击接听`;
if (game.choiceArmed) {
return `「请选择」念完了(第 ${CALL_LABEL[game.callN - 1]} 通):向左或向右滑动做出你的决定`;
}
if (game.voicePlaying) return `第 ${CALL_LABEL[game.callN - 1]} 通通话中,正在听对方说话`;
if (game.phase === 'ONAIR') return `对方说完了,等我接话(第 ${CALL_LABEL[game.callN - 1]} 通):单击继续`;
if (game.phase === 'BRIDGE') return '幕间喘息,垫乐播放中,马上接下一通';
if (game.phase === 'ENDING') {
return game.awaitingRestart
? '结局和五条回响都听完了。单击再来一局,下滑开菜单'
: '结局播报中';
}
return '……';
}
// ---------------------------------------------------------------- 主线剧本
export async function session(ctx, mock) {
const { game } = ctx;
// 开场:INTRO 自动流(台标口播 + 一句规矩 + 今晚目标)→ 第一声铃
await ctx.waitCond(() => game.phase === 'RING');
ctx.status('开场口令和台标播完了,第一通电话响了');
ctx.note('铃一响就接——接听是单击');
await ctx.gesture('tap');
ctx.status('线路接通了');
await ctx.waitCond(() => !game.voicePlaying);
ctx.status('导播报完线,老人开口了');
let invalidTried = false;
let menuDone = false;
let walkedAway = false;
let choiceIdx = 0;
// 抉择路径:C1 走心、C2 劝上车、C3 问她+真话、C4 带话+留在直播、C5 陪聊、p9 直面
const choices = [
{ dir: 'left', why: '老人在发抖,我陪他哼那首老歌' },
{ dir: 'left', why: '留在原地陪他等巡逻队' },
{ dir: 'left', why: '小鹿妈妈等的是那声「妈」,劝她上车' },
{ dir: 'right', why: '糖糖像是憋着事,我轻轻问她怎么了' },
{ dir: 'left', why: '告诉她这不是她的错——大人的事不算在孩子头上' },
{ dir: 'left', why: '陈姐听见孩子问话了,我把话带回去' },
{ dir: 'left', why: '留在直播里,让全城等这声门铃' },
{ dir: 'left', why: '老韩眼皮在打架,我陪他聊聊闺女' },
{ dir: 'left', why: '留言机红灯在闪——直面它,替老主播把晚安播出去' },
];
for (let i = 0; i < 200 && !game.leaving; i++) {
// 只等当前语音链播完或抉择武装;RING/BRIDGE 有定时器,绝不用裸 waitIdle
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
if (game.leaving) break;
if (game.phase === 'RING') {
ctx.status(`第 ${CALL_LABEL[game.callN - 1]} 通电话响了,接`);
await ctx.gesture('tap');
await ctx.waitCond(() => !game.voicePlaying);
continue;
}
if (game.choiceArmed) {
const pick = choices[choiceIdx];
if (!pick) break; // 抉择走完(理论上到 p9 后应已进 ENDING)
if (!invalidTried) {
invalidTried = true;
ctx.note(pick.why + '——但先上滑听一遍操作和进度');
await ctx.gesture('up');
await ctx.waitCond(() => !game.voicePlaying);
ctx.status('听完帮助+状态链,游戏自动重播了选项');
await ctx.waitCond(() => game.choiceArmed);
}
if (!menuDone) {
menuDone = true;
ctx.note('下滑看看菜单有什么');
await ctx.gesture('down');
await ctx.waitCond(() => !game.voicePlaying);
ctx.status('菜单里,切一项听听');
await ctx.gesture('tap'); // 切一项听听(报名接在当前报名之后,不截断)
await ctx.waitCond(() => !game.voicePlaying);
ctx.note('听完了,下滑关闭菜单,回到选项');
await ctx.gesture('down'); // 下滑关闭(不依赖菜单项序号,绝不误触「重开/回大厅」)
await ctx.waitCond(() => game.choiceArmed);
}
ctx.status('选项念完了,做决定');
ctx.note(pick.why);
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧看门狗)
choiceIdx++;
continue;
}
if (game.phase === 'ENDING') {
await ctx.waitCond(() => game.awaitingRestart);
continue;
}
if (game.phase === 'BRIDGE') {
ctx.status('幕间,口碑播报和垫乐,等下一通');
await ctx.waitVirtual(2600);
continue;
}
// ONAIR 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。
// 注意不要在这里 waitVirtual 小憩——等待态挂着看门狗定时器,waitVirtual
// 会一口气跳到下一个定时器 tick(25s 看门狗),剧本就变成「每句干晾 25s」。
if (!walkedAway && game.phase === 'ONAIR' && !game.choiceArmed) {
walkedAway = true;
ctx.silence(25, '听到一半走了神,在想老人的老花镜');
await ctx.waitVirtual(25600); // 触发看门狗:nudge + gd_continue
ctx.status('听到一声轻响和「单击屏幕,继续」,马上点');
await ctx.gesture('tap');
continue;
}
await ctx.gesture('tap'); // 链末单击 = 继续
}
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
ctx.status('结局链走完,五条回响一句句点到我今晚的每一次选择');
ctx.note('听完尾声那笔账——今晚的人都接住了,会话结束(E1 天亮之前)');
}
// ---------------------------------------------------------------- 乱按压力剧本
// 目标:全程零死锁、零静默死局,一样能走到 ENDING。
// 剧本特征:不读提示、快速跳句、菜单里乱逛、错手势试探,但仍保留「最终能通关」的底线。
export async function sessionStress(ctx, mock) {
const { game } = ctx;
let seed = 20260901;
const rng = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const gestures = ['tap', 'left', 'right', 'up', 'down', 'doubletap'];
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
let menuOpenCount = 0;
let choiceCount = 0;
let answered = 0;
let noiseCount = 0;
for (let i = 0; i < 300; i++) {
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
if (game.leaving) break;
if (game.phase === 'RING') {
answered++;
await ctx.gesture('tap'); // 不接会一直响,乱按也照接
continue;
}
if (game.phase === 'MENU') {
menuOpenCount++;
await ctx.gesture('tap'); // 乱切换
await ctx.waitCond(() => !game.voicePlaying);
await ctx.gesture('down'); // 下滑关闭(不双击,避免误触「重开/回大厅」)
continue;
}
if (game.choiceArmed) {
choiceCount++;
if (rng() < 0.35) {
// 好奇宝宝:犹豫时乱试一个不推进剧情的手势
noiseCount++;
await ctx.gesture(pick(['up', 'tap', 'doubletap']));
await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying);
}
await ctx.gesture(pick(['left', 'right']));
continue;
}
if (game.phase === 'BRIDGE') {
await ctx.waitVirtual(2600);
continue;
}
if (game.phase === 'ENDING') {
await ctx.waitCond(() => game.awaitingRestart);
break;
}
// ONAIR 叙事等待态 / 其他静默帧:乱划一下(无效手势也要有反馈,零静默)
noiseCount++;
await ctx.gesture(pick(gestures));
}
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
ctx.status(`乱按剧本收尾:接听 ${answered} 次、${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次`);
ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
}
+11 -4
View File
@@ -20,14 +20,16 @@ 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>]');
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'));
@@ -46,7 +48,8 @@ const estimateDuration = (id) => {
const CLASS_NAME = {
storyline: 'StorylineGame', escape: 'EscapeGame', simon: 'SimonGame',
rhythm: 'RhythmGame', space: 'SpaceGame', rpg: 'RpgGame',
rhythm: 'RhythmGame', space: 'SpaceGame', rpg: 'RpgGame', radio: 'RadioGame',
dungeon: 'DungeonGame',
}[GAME_NAME];
const mock = installMock(GAME_DIR);
@@ -208,9 +211,13 @@ async function run() {
await mock.flush();
drain();
if (sessionMod?.session) {
await sessionMod.session(makeCtx(audio, game, sessionMod.describe), mock);
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++) {
+261
View File
@@ -457,5 +457,266 @@
"npc_boss1_1": "盲剑客?正好,省得挖你的眼睛。",
"npc_boss1_2": "有两下子。可惜,你听不懂死期的声音。",
"npc_boss1_die": "白无瞳……会找到你……"
},
"radio": {
"b_open": "午夜零点,红灯亮起。欢迎回到《今夜不眠》。",
"b_rain": "雨下了一整晚。热线灯,马上就要亮了。",
"z1_song": "大爷别急,这首歌我也会。咱们一句一句来。",
"z1_ask": "大爷,路口有什么招牌?念一个给我听听。",
"z1_stay": "哪儿也不去。我陪你,这首歌还长着呢。",
"z1_pol": "大爷,我把您转给派出所,他们马上就到。",
"l1_go": "你妈等的不是奖杯,是你进门喊的那声妈。",
"l1_free": "退不退,都是你的自由。夜还长,慢慢想。",
"k1_lull": "闭上眼睛。风把云朵盖在被子上,月亮替你值夜班。",
"k1_ask": "睡不着的时候,心里是不是装着事?说给我听听。",
"k1_truth": "糖糖,记住,大人的事,不是小孩的错。一个字都不是。",
"k1_lie": "睡吧。明天一睁眼,什么都会好起来的。",
"w1_say": "四十分钟前,有个孩子问我们:爸妈摔门,是不是她的错。",
"w1_say2": "今天有位小朋友,还在等爸妈回家切蛋糕。",
"w1_air": "陈姐,别挂。让他接电话——全城都在听。",
"w1_priv": "这里只有我们俩了。想说就说,想哭就哭。",
"w1_home": "吵架隔着电话吵不赢。回家吧,蛋糕别放到坏了。",
"h1_talk": "说说她。开学第一天,她跟你说的头一句话,是什么?",
"h1_stop": "听我的,前面三公里有服务区。学费急,命更急。",
"d9_face": "师父,三年了。这句晚安,我替您播。各位,晚安。",
"d9_avoid": "各位,今晚到这里。明晚零点,今夜不眠,不见不散。",
"e1_a": "天亮了,热线灯还亮着。你们的晚安,是这座城市的心跳。",
"e1_b": "这把话筒,我总算接得住了。明晚零点,接着不眠。",
"e2_a": "今晚的热线烧得发烫。全城的失眠者,都被接住了。",
"e2_b": "只是路过留言机的时候,你还是没敢看它。红灯灭得很慢。",
"e3_a": "口碑掉了一夜。可那句迟到三年的晚安,今晚说出去了。",
"e3_b": "一个人的频道,也是频道。师父,收到了吗?",
"e4_a": "今夜不眠,静音收播。有的夜班,谁也接不住谁。",
"e4_b": "留言机还在闪。你没看它,它也没怪你。明晚再说吧。",
"gd_brief": "单击继续节目。请选择的时候,向左或向右滑。",
"gd_continue": "单击屏幕,继续。",
"gd_again": "还在吗?单击继续。",
"gd_invalid": "这个手势,直播间里用不上。",
"gd_ring1": "热线在闪。单击,接听。",
"gd_ring2": "灯还亮着,听众在等。单击,接听。",
"sys_help": "单击继续,双击重新听。上滑听状态,下滑开菜单。请选择时,左右滑。",
"sys_menu": "菜单。单击切换选项,双击确认。",
"sys_menu_resume": "回到直播。",
"sys_menu_restart": "重新开始今晚。",
"sys_menu_lobby": "保存并回大厅。",
"sys_settings": "音色、语速、背景音乐,请回大厅修改。",
"sys_skip": "想跳过这句,单击就行。",
"sys_continue_ask": "检测到你的夜班记录。单击继续上次,右滑开始新的夜晚。",
"sys_new_night": "新的夜晚,开始了。",
"sys_saved": "进度已保存。",
"prog_c1": "现在是今晚第一通电话。",
"prog_c2": "现在是今晚第二通电话。",
"prog_c3": "现在是今晚第三通电话。",
"prog_c4": "现在是今晚第四通电话。",
"prog_c5": "现在是今晚第五通电话。",
"rep_high": "今晚的口碑,在听众里立住了。",
"rep_mid": "今晚的口碑,不好不坏。",
"rep_low": "今晚的口碑,还没立起来。",
"b_rule": "台里的规矩就一条:先接住人,再接电话。",
"b_goal": "今晚五通电话。说什么话,由你决定。",
"z1_ring_in": "一线来电。七十多岁,雨夜,走丢了。",
"p1": "老人在发抖。向左滑,陪他哼那首老歌。向右滑,冷静问他路口的路牌。",
"z1_sdone": "他稳住了。老歌,比路牌管用。",
"z1_adone": "他慌了。先接住情绪,再走流程。",
"p2": "雨声更密了。向左滑,留在原地陪他等巡逻队。向右滑,转接派出所。",
"z1_staye": "十五分钟后,巡逻灯停在路口。全城听见老人笑了。",
"z1_pole": "转接完成,地址报得清。干净利落。",
"z1_bade": "地址没报清就转接,雨又大。这通砸了。先稳人,再办事。",
"z1_ok1": "就这么接。记住这个手感。",
"l1_in": "二线来电。姑娘,声音很轻,像怕吵到谁。",
"p3": "向左滑,告诉她这趟车值得上。向右滑,把选择还给她。",
"l1_goe": "二线收工。这句话,值一个头条。",
"l1_freee": "她走了。没上车,也没退票。年轻人的夜,长了点。",
"k1_in": "三线来电。听呼吸,是个小孩。",
"p4": "向左滑,唱一段摇篮曲哄她睡。向右滑,轻轻问她怎么了。",
"k1_lulle": "睡着了。可你没听见,她在怕什么。",
"p5": "孩子的声音在抖。向左滑,告诉她这不是她的错。向右滑,答应她明天都会好。",
"k1_truthe": "蛋糕。记下来——四线的来电,可能跟这个家有关。",
"k1_liee": "你许了个兑不了的现。孩子睡了,谎留下了。",
"w1_in": "四线来电。户外风声大,是位大姐。",
"p6": "摔门声你听见了。向左滑,替孩子给她带句话。向右滑,劝她回家把话说开。",
"w1_saye": "她攥着电话,很久没出声。风都停了。",
"w1_say2e": "话递到了。就差最后一脚。",
"p7": "她的呼吸平了。向左滑,留在直播里,让全城等这个门铃。向右滑,换垫乐,私下陪她说完。",
"w1_aire": "电话那头,传来跑步声。今晚的高光时刻。",
"w1_prive": "灯牌是你亲手关的,故事还是完整的。稳。",
"w1_homee": "她往回走了。成不成,看他们自己的了。",
"h1_in": "五线来电。引擎声没停过——他在开车!",
"p8": "方向盘比话筒危险。向左滑,陪他聊聊闺女。向右滑,让他立刻进服务区。",
"h1_danger": "车道偏了——听!",
"h1_talke": "吓醒的,也算醒。这一趟,聊到家。",
"h1_stope": "他下了高速。单子黄了,人齐了。",
"h1_echo": "对了。头通电话那位大爷,就是这片被巡逻队找到的。这城市的夜,是接着的。",
"g9_in": "五线收工。垫乐起——零点档,还剩最后一段。",
"p9": "留言机红灯在闪,是老主播三年前没播完的那句晚安。向左滑,替他播出去。向右滑,照常收播。",
"d9_facee": "带子里其实只有电流声。但空着的那一秒,你说满了。",
"d9_avoide": "留言机的红灯,又多亮了一夜。",
"ep_lead": "收播了。导播间替你记了一笔账——今晚的人都怎样了。",
"ep_zhou_ok": "老周:孙子替他买了老花镜。回信说,谢谢那首歌。",
"ep_zhou_no": "老周在便利店屋檐下坐到天亮。没出事,也没人听见他的歌。",
"ep_deer_ok": "小鹿上车了。她妈后来打电话说,那声妈,比奖杯值钱。",
"ep_deer_no": "小鹿退了票。检票口的灯,记得那趟没上成的车。",
"ep_kid_ok": "糖糖家的门,这个月没再摔过。她说这是主播魔法。",
"ep_kid_no": "糖糖后来又打来过一次。还是那句:楼下在吵。",
"ep_wife_ok": "陈姐家切了蛋糕。那句「回来吧」,是我这晚听过最好的声音。",
"ep_wife_no": "陈姐在公车站坐到了末班车。那通电话,成了没寄出的信。",
"ep_han_ok": "老韩到家了。后视镜上,多了朵小红花。",
"ep_han_no": "老韩在服务区睡了一觉。学费齐了,那首歌没放成。",
"z1_a": "主播……我,我找不到回家的路了。",
"z1_b": "雨太大了。我本来,只想买副老花镜。",
"z1_songo": "对,对!路口修车铺,天天放这首!",
"z1_asko": "招牌……雨糊住了,我眼花,看不清啊……",
"z1_stayo": "好,好。有人说话,我就不怕了。",
"z1_polo": "好,好。谢谢你啊,谢谢。",
"z1_bad": "说不清……我真是,说不清了……",
"l1_a": "主播,退票窗口十一点半就关了。我站这儿半小时了。",
"l1_b": "票是回去看我妈的。可我试镜又黄了,两手空空……",
"l1_goo": "呜……嗯。不退了,我现在就去检票。谢谢您。",
"l1_freeo": "嗯……我再想想。窗口要关了,我先过去了。",
"k1_a": "喂……有人吗?我睡不着。",
"k1_b": "家里灯关了。可是楼下,一直有声音。",
"k1_lullo": "月亮也睡了……那我也睡。",
"k1_asko": "爸爸妈妈摔门了。是不是……我做错了什么呀。",
"k1_trutho": "嗯……那我睡了。他们明天,会一起给我切蛋糕吗?",
"k1_lieo": "真的吗?拉钩。晚安……",
"w1_a": "主播,我就想找个人说说。我家那口子,今晚要出远门的活儿。",
"w1_b": "明天孩子生日!蛋糕都订好了,他人要今晚走!",
"w1_sayo": "什么?她还没睡?这么晚了,她还醒着?",
"w1_say2o": "蛋糕……是他订的。他嘴上不说,心里都记着。",
"w1_airo": "喂!回来吧!蛋糕要化了,孩子等着呢!",
"w1_privq": "呜……其实他订完蛋糕就跟我道歉了。是我,把门摔得太响。",
"w1_homeo": "嗯……我这就往回走。谢谢你,听我瞎嚷嚷。",
"h1_a": "主播,别挂。陪我说话,我的眼皮,沉得很。",
"h1_b": "跑完这趟,闺女的学费就齐了。雨刷都快刮不动了。",
"h1_talko": "她说,爸,我在学校给你赚了朵小红花。哈哈,哈哈……",
"h1_dango": "嘶——好险!前车急刹,我擦着护栏过去了。彻底醒了!",
"h1_stopo": "行,听你的。进了服务区,我给你们放首歌听。"
},
"dungeon": {
"intro_1": "你在黑暗中醒来。这里是回声地窟。",
"intro_2": "伸手不见五指,声音就是你的眼睛。",
"sys_continue_ask": "检测到探窟记录。单击继续,右滑重新开始。",
"sys_new_run": "新的探窟,开始了。",
"tut_d0": "先学会用耳朵看。单击,发出声波脉冲。",
"tut_echo_come": "听,回声回来了。",
"tut_front_rule": "回声回来越快,墙就越近。",
"tut_side_rule": "两侧的回声,发闷是墙,空旷是通路。",
"tut_walk": "按住屏幕不放,你就往前走。",
"tut_turn": "左滑右滑,可以转身。",
"tut_replay": "双击,再发一次脉冲,不要钱。",
"tut_status": "上滑,随时听自己的状态。",
"tut_menu": "下滑,打开菜单。",
"tut_mob": "前面有东西!放心,它伤不到你。",
"tut_parry": "听到它的动静,双击格挡。",
"tut_parry_ok": "挡住了!",
"tut_parry_miss": "没挡住也别怕,它伤不到你。",
"tut_attack": "现在单击,挥拳打它。",
"tut_kill": "打中了,就是这样。",
"tut_stairs": "铁器的回声,就是下潜的楼梯。",
"tut_stairs_on": "楼梯就在脚下。单击,下潜。",
"tut_done": "教学完了。地窟很深,小心走。",
"tut_skip": "已跳过教学。单击脉冲,先听听四周。",
"sys_saved": "进度已保存。",
"sys_menu": "菜单。单击切换,双击确认。",
"sys_menu_resume": "返回探索。",
"sys_menu_save": "保存并回大厅。",
"sys_menu_help": "重新听帮助。",
"sys_menu_restart": "重新开始本周目。",
"help_explore": "单击脉冲,按住前进,左右滑转身。双击再听,上滑状态,下滑菜单。",
"help_battle": "敌人前摇声响时双击格挡,单击挥击。打不过就按住屏幕逃跑。",
"settings_hint": "音色、语速、背景音乐,请回大厅修改。",
"st_floor_1": "这是第一层。",
"st_floor_2": "这是第二层。",
"st_floor_3": "这是第三层。",
"st_floor_4": "这是第四层。",
"st_floor_5": "这是第五层。",
"st_floor_6": "这是第六层。",
"st_floor_7": "这是第七层。",
"st_hp_1": "你毫发无损。",
"st_hp_2": "你受了点伤。",
"st_hp_3": "你伤势不轻。",
"st_hp_4": "你命悬一线。",
"st_gold_1": "口袋空空。",
"st_gold_2": "有些积蓄。",
"st_gold_3": "收获可观。",
"st_gold_4": "腰包鼓鼓。",
"st_face_e": "你面朝东。",
"st_face_s": "你面朝南。",
"st_face_w": "你面朝西。",
"st_face_n": "你面朝北。",
"st_threat_none": "附近很安静。",
"st_threat_left": "左边有脚步声。",
"st_threat_right": "右边有脚步声。",
"st_threat_both": "两边都有动静。",
"st_best_1": "你还没到过第三层。",
"st_best_2": "你到过中层。",
"st_best_3": "你到过最深层。",
"evt_coin_1": "捡到几枚金币。",
"evt_coin_2": "捡到一小把金币。",
"evt_coin_3": "捡到一大袋金币。",
"evt_potion": "一瓶药水,收进腰带。",
"evt_potion_full": "腰带满了,药水留在原地。",
"evt_weapon": "捡到更快的短刃,出手更狠了。",
"evt_armor": "换上更硬的护甲,更抗打了。",
"evt_stairs_lock": "楼梯被大东西堵住了,先赢它。",
"evt_descend": "你顺着楼梯,往下潜。",
"btl_bat": "盲眼蝠扑了过来!",
"btl_rat": "窟鼠窜了出来!",
"btl_mush": "蘑菇怪拦住了你!",
"btl_crab": "窟主石甲蟹,就在你面前。",
"btl_crawler": "绕行者贴上来了,小心背后!",
"btl_spider": "洞蛛落地了!",
"btl_leech": "窟主腐沼蟒,挡住了楼梯。",
"btl_guard": "石像卫醒了!",
"btl_king": "窟中之王,就在深处。",
"btl_parried": "挡下了大半!",
"btl_behind": "它在你背后,转身再挡。",
"btl_swish": "挥空了,它不在你面前。",
"btl_no_turn": "它贴着你不放,不用转身。",
"btl_kill": "它倒下了。",
"btl_loot_1": "战利不多。",
"btl_loot_2": "小有收获。",
"btl_loot_3": "大有收获。",
"btl_lvup": "你的拳脚更利落了。",
"btl_flee": "你甩掉了它,被抓了一下。",
"btl_flee_boss": "退路塌了,跑不掉!",
"btl_half_crab": "石甲蟹的甲裂了!",
"btl_half_leech": "腐沼蟒暴怒了!",
"btl_half_king": "王怒了,脚步更重了!",
"die_intro": "你倒下了。",
"die_cause_1": "你被小怪拖垮了。",
"die_cause_2": "你没挡住那记重击。",
"die_cause_3": "你背后的偷袭,要了命。",
"die_adv_1": "下次,前摇声响时就双击。",
"die_adv_2": "打不过,就按住屏幕逃跑。",
"die_adv_3": "身后有动静,先转身。",
"gd_pulse": "单击脉冲,听听四周。",
"gd_turn": "轮到你了。单击挥击。",
"gd_invalid": "这个操作在这里用不上。",
"stair_hint_a": "找找铁器的回声,那是楼梯。",
"stair_hint_b": "楼梯有大东西守着,先赢它。",
"stair_dir_e": "楼梯在东边。",
"stair_dir_s": "楼梯在南边。",
"stair_dir_w": "楼梯在西边。",
"stair_dir_n": "楼梯在北边。",
"nav_e": "往东。",
"nav_s": "往南。",
"nav_w": "往西。",
"nav_n": "往北。",
"nav_done": "到了,就是这里。",
"meta_gold_1": "这趟没带回什么。",
"meta_gold_2": "这趟带回一些金币。",
"meta_gold_3": "这趟满载而归。",
"settle_stronger": "下个周目,你会更强。",
"settle_win_1": "你捧起了地窟之心,通关了!",
"settle_win_2": "黑暗的尽头,是光。",
"settle_again": "单击,再探一次。",
"unlock_lead": "攒够了新的遗产。",
"unlock_potion": "解锁行囊药,开局自带一瓶。",
"unlock_vigor": "解锁体魄,起始更强壮。",
"unlock_veil": "解锁护符,每层头一下减半。",
"unlock_echo": "解锁回声强化,侧向听得更远。",
"unlock_blade": "解锁武器,开局带着短刃。",
"unlock_none": "金币还不够,继续攒。"
}
}