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 置完成。
681 lines
35 KiB
JavaScript
681 lines
35 KiB
JavaScript
/**
|
||
* 《回声地窟》新手会话剧本(26 号重构版)。
|
||
*
|
||
* 交互口径(web/dungeon/game.js 26 号状态×手势表,旧剧本的旧口径全废):
|
||
* - 四向滑动 = 面朝相对移动:up=前进一格;left/right=向侧走一格并转向;down=后退一格
|
||
* (旧「长按前进」废除);swipeCore,不按新手阶梯锁。
|
||
* - 单击 = 声波脉冲(探索,冷却 1.2s)/ 下潜确认(楼梯格)/ 挥击(战斗我的回合)/
|
||
* 格挡(敌方前摇 1.2s 窗内)——旧「双击格挡」「双击免冷却重发」废除。
|
||
* - 长按 = 「我在哪」(层+血 if 伤+面朝+目标方位,≤20 字)——旧「按住前进」废除。
|
||
* - 教学链 D0-D7(不再是九步):D0 点一下听回声 → D1 巩固 → D2 上滑前进 →
|
||
* D3 左滑侧移 → D4 自由捡金币 → D5 长按问路 → D6 教学战(单击格挡+单击挥击)→
|
||
* D7 自由寻楼梯单击下潜(教学出口)。
|
||
*
|
||
* 剧本:
|
||
* - `session`(主线):一位盲眼新手玩家的完整弧线——标题页第一句就是指令 →
|
||
* D0-D7 教学逐项跟练(含教学战)→ 下潜第二层真正探窟:脉冲听路、明雷遭遇战
|
||
* (前摇单击格挡、回合单击挥击)→ 第三层窟主守梯,力战而亡听死亡归因与
|
||
* 元进度入账 → 结算走神一次听三档提示 → 逛一圈菜单(死亡后下滑才解锁)→
|
||
* 单击再探一次:第二周目遗产继承、不再上课,短程收尾。
|
||
* - `sessionStress`(乱按压力):一位手忙脚乱的好奇宝宝——标题乱划、教学里乱按、
|
||
* 狂点脉冲、战斗乱滑乱逃、探索乱撞墙,一样要走到死亡-重开循环,全程零死锁。
|
||
*
|
||
* 剧本失真红线(参照 radio/escape/abyss 剧本先例):
|
||
* - 玩家不抢话:教学作答步(D0-D3/D5)与战斗回合是 floor.state === 'YOUR_TURN'
|
||
* 的链级判据(voiceBusy 是句级信号、链内句间会闪断,不能当门控);自由行动步
|
||
* (D4/D6/D7/探索)= ACTION,等内容链播完(!floor.speaking)再出手;
|
||
* - 玩家靠耳朵玩游戏:走位前单击脉冲听回声,导航只是把「听到的方向」翻译成
|
||
* 一次四向滑动(侧移自带转向,一步一个手势);
|
||
* - 怪物游走/教学怪逼近/前摇窗(1.2s)都有定时器,禁止裸 waitIdle 空等,一律
|
||
* waitCond/waitVirtual 有界等待;前摇声一响(telegraph)立刻单击格挡;
|
||
* - 菜单里绝不单击确认(重开=破坏性二次确认、保存回大厅=结束会话):只左右滑
|
||
* 换项听报名,下滑关闭。
|
||
*/
|
||
|
||
const DIR_ORDER = ['N', 'E', 'S', 'W'];
|
||
/** 相对方位(0 前/1 右/2 后/3 左)→ 手势(四向滑动 = 面朝相对移动)。 */
|
||
const REL_GESTURE = ['up', 'right', 'down', 'left'];
|
||
const HP_BAND = { 1: '毫发无损', 2: '受了点伤', 3: '伤势不轻', 4: '命悬一线' };
|
||
|
||
const DRILL_TEXT = {
|
||
0: '教学第一步:「黑暗。点一下,听回声。」',
|
||
1: '「对。」——巩固:再点一次脉冲',
|
||
2: '「朝前,滑一下。」——上滑,前进一格',
|
||
3: '「向左滑,转身走。」——左滑,侧移并转向',
|
||
4: '「房里有金币,走过去捡。」——自由行动(四向滑动走路,单击脉冲探路)',
|
||
5: '「迷路了,按住屏幕。」——长按,听「我在哪」',
|
||
6: '「前面有东西!放心,它伤不到你。」——等教学怪扑来',
|
||
7: '「铁器的回声,就是下潜的楼梯。」——自己走到楼梯格,单击下潜',
|
||
};
|
||
|
||
const atTurn = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN';
|
||
const wpId = (game) => (game.floor.waitPoint ? game.floor.waitPoint.conf.id : null);
|
||
|
||
/**
|
||
* 教学作答步(D0-D3/D5)的真话轮判据:本步自己的等待点(id='drillN')已挂上。
|
||
* 只看 floor.state === YOUR_TURN 不够——作答后旧等待点不会立刻被摘(管道未接线),
|
||
* 旧步的等待点会「闪真」,按 id 区分才能证明新步的指令链真的播完交了话轮。
|
||
*/
|
||
const atDrillTurn = (game, step) =>
|
||
game._drill?.step === step && atTurn(game) && wpId(game) === 'drill' + step;
|
||
|
||
/** 绝对方位 → 此刻该滑哪个方向(相对面朝)。 */
|
||
const relGesture = (game, dir) =>
|
||
REL_GESTURE[(DIR_ORDER.indexOf(dir) - DIR_ORDER.indexOf(game.run.face) + 4) % 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
|
||
? `${DRILL_TEXT[game._drill.step] ?? '教学演练中'}${atTurn(game) ? '' : '(旁白在说,先听)'}`
|
||
: '教学演练中';
|
||
case 'EXPLORE': {
|
||
if (!game.run) return '……';
|
||
if (game._onStairs) return `第 ${game.run.floor} 层:铁器就在脚下——单击,下潜`;
|
||
return `第 ${game.run.floor} 层黑暗里:单击脉冲听回声,四向滑动走路,长按问路`;
|
||
}
|
||
case 'DESCEND': return '顺着楼梯,往下潜……';
|
||
case 'SETTLE': return atTurn(game)
|
||
? '结算:死亡归因与元进度入账听完了——单击再探一次,下滑开菜单(刚解锁)'
|
||
: '结算链/提示句在播,先听完';
|
||
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);
|
||
}
|
||
}
|
||
|
||
/** 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]];
|
||
const VEC = { N: { dx: 0, dy: -1 }, E: { dx: 1, dy: 0 }, S: { dx: 0, dy: 1 }, W: { dx: -1, dy: 0 } };
|
||
while (q.length) {
|
||
const [x, y] = q.shift();
|
||
for (const [dir, d] of Object.entries(VEC)) {
|
||
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));
|
||
|
||
const stairsCell = (game) => (game._floor ? (game._floor.stairs || game._floor.throne) : null);
|
||
|
||
/** 「到站」判据:_onStairs 旗标,或坐标本就在楼梯/王座格(R4 修正:怪占楼梯格时
|
||
* 遭遇先于报梯、旗标没置上——只看旗标会在楼梯上干磨几百次脉冲)。 */
|
||
const onStairsCell = (game) => {
|
||
const st = stairsCell(game);
|
||
return !!game._onStairs || (!!st && !!game.run && game.run.pos.x === st.x && game.run.pos.y === st.y);
|
||
};
|
||
|
||
const OPP_DIR = { N: 'S', S: 'N', E: 'W', W: 'E' };
|
||
|
||
/** 人味修正:踩在楼梯格却点不动(到达播报被楼梯上的遭遇战吞了)——
|
||
* 挪一步再踩回来,让游戏重新报「楼梯就在脚下」。返回是否已真正站上(旗标亮)。 */
|
||
async function retriggerStairs(ctx, mock, game) {
|
||
const st = stairsCell(game);
|
||
if (!st || !game.run) return !!game._onStairs;
|
||
if (game.run.pos.x !== st.x || game.run.pos.y !== st.y) return !!game._onStairs;
|
||
if (game._onStairs) return true;
|
||
if (game.floor.speaking) { await ctx.waitVirtual(600); return !!game._onStairs; }
|
||
const S = game._floor.size;
|
||
const VEC = { N: { dx: 0, dy: -1 }, E: { dx: 1, dy: 0 }, S: { dx: 0, dy: 1 }, W: { dx: -1, dy: 0 } };
|
||
for (const [dir, d] of Object.entries(VEC)) {
|
||
const nx = game.run.pos.x + d.dx, ny = game.run.pos.y + d.dy;
|
||
if (nx < 0 || ny < 0 || nx >= S || ny >= S || game._floor.grid[ny][nx] !== 1) continue;
|
||
if (game._floor.mobs.some((m) => m.x === nx && m.y === ny)) continue; // 旁边有怪:换一边下脚
|
||
ctx.note('铁器就在脚下却点不动——挪一步,重新踩上楼梯');
|
||
await ctx.gesture(relGesture(game, dir));
|
||
await ctx.waitVirtual(300);
|
||
if (game.state === 'BATTLE') return false; // 挪步惊动了怪:先打,下轮再试
|
||
if (!game.run) return false;
|
||
await ctx.gesture(relGesture(game, OPP_DIR[dir]));
|
||
await ctx.waitVirtual(400);
|
||
return !!game._onStairs;
|
||
}
|
||
return !!game._onStairs; // 四周都下不了脚:等游荡的怪让位
|
||
}
|
||
|
||
/**
|
||
* 探索导航(26 号):隔步脉冲听回声 → 朝目标一格一格滑。一次四向滑动 = 一步
|
||
* (侧移自带转向),不再有「转身+长按」两段式。撞进怪 = 明雷遭遇,打完再走;
|
||
* deathOnBoss 时撞进窟主转为力战而亡。
|
||
* 三重防磨死(R3 修正:个别随机布局里教学链/等待点会让导航手势全部落空,
|
||
* 原地干磨数小时——转写伪影):① 教学作答步(floor=YOUR_TURN)手势全是
|
||
* invalid,立即撤;② 连续 12 次迭代坐标没动(链卡播/被怪堵/无效循环)撤;
|
||
* ③ 单次导航虚拟时长超 90s 撤。撤回后交给上层等待点/75s 教学兜底推进。
|
||
*/
|
||
async function navigate(ctx, mock, game, target, { pulse = true, deathOnBoss = false, until = 'cell' } = {}) {
|
||
const t0 = mock.now;
|
||
let stuck = 0;
|
||
let lastPos = game.run ? game.run.pos.x + ',' + game.run.pos.y : '';
|
||
for (let guard = 0; guard < 260; guard++) {
|
||
if (game.state === 'BATTLE') {
|
||
if (deathOnBoss && game._battle?.boss) { await fightToDeath(ctx, game); return; }
|
||
await fightWell(ctx, mock, game);
|
||
continue;
|
||
}
|
||
if (game.state !== 'EXPLORE' && game.state !== 'DRILL') return;
|
||
if (!game.run || !game._floor) return;
|
||
if (game.state === 'DRILL' && atTurn(game)) return; // 作答步等话轮:导航手势在这里全是无效
|
||
if (until === 'stairs' ? onStairsCell(game)
|
||
: (game.run.pos.x === target.x && game.run.pos.y === target.y)) return;
|
||
const posKey = game.run.pos.x + ',' + game.run.pos.y;
|
||
stuck = posKey === lastPos ? stuck + 1 : 0;
|
||
lastPos = posKey;
|
||
if (stuck > 12 || mock.now - t0 > 90000) return; // 原地打转/超时:交给上层,绝不干磨
|
||
if (game.floor.speaking) { await ctx.waitVirtual(400); continue; } // 不抢话:内容链播完再动
|
||
if (pulse && guard % 2 === 0) {
|
||
ctx.note('单击发脉冲,听回声辨路');
|
||
await ctx.gesture('tap');
|
||
await ctx.waitVirtual(1700); // 听完回声组(正前延迟≤1.1s+材质尾音,侧向预 pan)
|
||
}
|
||
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) { await ctx.waitVirtual(800); continue; } // 被游走的怪堵住了:等它走开
|
||
await ctx.gesture(relGesture(game, step.dir)); // 四向滑动一步(脚步声即回执)
|
||
await ctx.waitVirtual(320);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 教学脱困:某一步迟迟不推进时,安静等一小段(不出手 = 活跃死线不再后推),
|
||
* 让游戏自己的 75s 教学兜底自动过步(教学绝不卡死的有界保证)。只等不打。
|
||
*/
|
||
async function drillUnstick(ctx, mock, game, cond, maxMs = 90000) {
|
||
const t0 = mock.now;
|
||
while (!cond() && mock.now - t0 < maxMs) {
|
||
await ctx.waitVirtual(1000); // 空手等待:不脉冲、不挪步,把死线留给兜底
|
||
}
|
||
}
|
||
|
||
/** 认真打法(26 号):前摇声一响就单击格挡,轮到自己就单击挥击,直到战斗结束。 */
|
||
async function fightWell(ctx, mock, game) {
|
||
let guard = 0;
|
||
while (game.state === 'BATTLE' && guard < 200) {
|
||
guard++;
|
||
const b = game._battle;
|
||
if (!b) break;
|
||
if (b.state === 'telegraph') {
|
||
ctx.note('前摇声起了——单击,格挡');
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => game._battle !== b || b.state !== 'telegraph', 4000); // 等窗口结算,不连点
|
||
continue;
|
||
}
|
||
if (b.state === 'player' && atTurn(game)) {
|
||
ctx.note('轮到我了——单击,挥击');
|
||
await ctx.gesture('tap');
|
||
await ctx.waitVirtual(450);
|
||
continue;
|
||
}
|
||
await ctx.waitVirtual(350);
|
||
}
|
||
await ctx.waitVirtual(2600); // 胜利链播完再走
|
||
}
|
||
|
||
/** 力战而亡:不格挡、不逃跑,喘着气隔回合挥拳——窟主的刀比你的拳快(死亡归因路线)。 */
|
||
async function fightToDeath(ctx, game) {
|
||
let turn = 0;
|
||
while (game.state === 'BATTLE' && game._battle && turn < 200) {
|
||
turn++;
|
||
const b = game._battle;
|
||
if (b.state === 'player' && atTurn(game)) {
|
||
if (turn % 2 === 0) ctx.note('咬牙又挥了一拳——挡不住了也要打');
|
||
else ctx.note('顾不上格挡,抡拳');
|
||
await ctx.gesture('tap');
|
||
await ctx.waitVirtual(500);
|
||
continue;
|
||
}
|
||
await ctx.waitVirtual(400);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 主线剧本
|
||
|
||
export async function session(ctx, mock) {
|
||
const { game } = ctx;
|
||
|
||
// —— 标题页:第一句就是 critical 指令「点一下屏幕。」(§4.10),没档 → 单击开始 ——
|
||
await waitStep(ctx, mock, () => game.state === 'TITLE');
|
||
await ctx.waitCond(() => atTurn(game));
|
||
ctx.status('标题页第一句就是指令,不是规则念白');
|
||
ctx.note('黑暗里一句「点一下屏幕」——点');
|
||
await ctx.gesture('tap'); // → sys_new_run → 教学 D0
|
||
|
||
// —— D0:黑暗。点一下,听回声 ——
|
||
await waitStep(ctx, mock, () => atDrillTurn(game, 0), 20000);
|
||
ctx.status('教学第一步就是动手听,不念规则');
|
||
ctx.note('点一下——人生第一声脉冲');
|
||
await ctx.gesture('tap'); // 回声组(正前延迟+材质尾音+侧向预 pan)就是回执
|
||
|
||
// —— D1:对。回声回来越快,墙就越近(巩固:再点一次) ——
|
||
await waitStep(ctx, mock, () => atDrillTurn(game, 1), 20000);
|
||
ctx.status('「对。」+ 一句正前规则——回声教我看墙');
|
||
ctx.note('再点一次,巩固');
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => game._drill?.step === 2, 15000); // 奖励音 + 前进教学链
|
||
|
||
// —— D2:朝前,滑一下(上滑=前进一格) ——
|
||
await waitStep(ctx, mock, () => atDrillTurn(game, 2), 20000);
|
||
ctx.status('「听到这个声音,就是轮到你了」——等待声就是话轮');
|
||
ctx.note('上滑,前进一格');
|
||
await ctx.gesture('up'); // 脚步声即回执
|
||
|
||
// —— D3:向左滑,转身走(侧移=移动+转向一步到位) ——
|
||
await waitStep(ctx, mock, () => atDrillTurn(game, 3), 20000);
|
||
ctx.status('侧移自带转向——不再是「转身+按住」两段式');
|
||
ctx.note('左滑,转身走');
|
||
await ctx.gesture('left');
|
||
|
||
// —— D4:房里有金币,走过去捡(自由行动:ACTION,没有等待声) ——
|
||
await waitStep(ctx, mock, () => game._drill?.step === 4, 15000);
|
||
ctx.status('自由行动步:玩法声音就是状态,脉冲探路自己走');
|
||
const coin = [...game._floor.items.entries()].find(([, v]) => v.type === 'coin');
|
||
if (coin) {
|
||
const [cx, cy] = coin[0].split(',').map(Number);
|
||
await navigate(ctx, mock, game, { x: cx, y: cy });
|
||
}
|
||
if (game._drill?.step === 4) { // 链/等待点打架没走成:安静等 75s 兜底过步(绝不干磨)
|
||
await drillUnstick(ctx, mock, game, () => game._drill?.step !== 4);
|
||
}
|
||
await waitStep(ctx, mock, () => game._drill?.step === 5, 15000); // 拾取 → 第一个小目标 → D5
|
||
ctx.status('金币入袋(实时进元进度)——做完第一件事,才教下一件事');
|
||
|
||
// —— D5:迷路了,按住屏幕(长按 = 我在哪) ——
|
||
await waitStep(ctx, mock, () => atDrillTurn(game, 5), 20000);
|
||
ctx.note('长按,问一次路');
|
||
await ctx.gesture('longpress'); // 层+面朝+楼梯方位,一句话
|
||
await waitStep(ctx, mock, () => game._drill?.step === 6, 15000);
|
||
ctx.status('「我在哪」一句话说完——长按从前进键变成了问路键');
|
||
|
||
// —— D6:教学战(盲眼蝠 ATK0 把门):单击格挡 + 单击挥击 ——
|
||
await waitStep(ctx, mock, () => game.state === 'BATTLE', 25000);
|
||
if (game.state !== 'BATTLE' && game._drill?.step === 6) { // 教学怪迟迟不扑来:等兜底过步
|
||
await drillUnstick(ctx, mock, game, () => game.state === 'BATTLE' || game._drill?.step !== 6);
|
||
}
|
||
ctx.status('「前面有东西!放心,它伤不到你」——它扑过来了');
|
||
await fightWell(ctx, mock, game);
|
||
if (game.state === 'BATTLE' && game._drill?.step === 6) { // 乱局里没打完:接着打完
|
||
await fightWell(ctx, mock, game);
|
||
}
|
||
if (game._drill?.step === 6) { // 战斗脱轨(怪没了/逃掉了):等兜底进 D7
|
||
await drillUnstick(ctx, mock, game, () => game._drill?.step !== 6 || game.state === 'BATTLE');
|
||
if (game.state === 'BATTLE') await fightWell(ctx, mock, game);
|
||
}
|
||
await waitStep(ctx, mock, () => game._drill?.step === 7 || game.state === 'EXPLORE', 25000);
|
||
ctx.status('「打中了,就是这样。」格挡和挥击都是单击——一个手势按状态分流');
|
||
|
||
// —— D7:铁器的回声就是楼梯,自己走过去(自由寻路 + 单击下潜出口) ——
|
||
await waitStep(ctx, mock, () => game._drill?.step === 7, 15000);
|
||
for (let round = 0; round < 3 && game.state === 'DRILL' && game._drill?.step === 7 && !onStairsCell(game); round++) {
|
||
await navigate(ctx, mock, game, game._floor.stairs, { until: 'stairs' });
|
||
if (!onStairsCell(game)) await drillUnstick(ctx, mock, game, () => onStairsCell(game) || game._drill?.step !== 7 || game.state !== 'DRILL', 30000);
|
||
}
|
||
await waitStep(ctx, mock, () => !game.floor.speaking, 8000);
|
||
if (!game._onStairs && onStairsCell(game) && game.state === 'DRILL' && game._drill?.step === 7) {
|
||
await retriggerStairs(ctx, mock, game); // 教学房的门怪若占了楼梯格:挪一步重新踩上
|
||
}
|
||
await waitStep(ctx, mock, () => !game.floor.speaking, 8000);
|
||
if (game._onStairs && (game.state === 'DRILL' || game.state === 'EXPLORE')) {
|
||
ctx.status('铁器贴着脚底响——「楼梯就在脚下。单击,下潜」');
|
||
await ctx.gesture('tap'); // D7 出口:楼梯格单击 = 下潜(教学完成)
|
||
}
|
||
await waitStep(ctx, mock, () => game.state === 'EXPLORE' && game.run?.floor === 2, 30000);
|
||
ctx.status('「教学完了。地窟很深,小心走。」第二层,真正的地窟从这里开始');
|
||
|
||
// —— 第二层:脉冲探路 → 明雷遭遇战 → 找到楼梯下潜 ——
|
||
ctx.silence(3, '在楼梯口定了定神,听这一层的动静');
|
||
await ctx.waitVirtual(3200);
|
||
for (let guard = 0; guard < 40 && game.run && game.run.floor < 3 && game.state !== 'SETTLE'; guard++) {
|
||
await navigate(ctx, mock, game, game._floor.stairs, { until: 'stairs' });
|
||
if (game.state === 'SETTLE' || !game.run || game.run.floor >= 3) break;
|
||
if (onStairsCell(game)) {
|
||
if (!game._onStairs) await retriggerStairs(ctx, mock, game); // 到达播报被楼梯上的怪吞了
|
||
if (game._onStairs && game.state !== 'BATTLE') {
|
||
ctx.note('踏上楼梯格——单击,下潜');
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => !game.run || game.run.floor >= 3 || game.state === 'SETTLE', 20000);
|
||
continue;
|
||
}
|
||
}
|
||
await ctx.waitVirtual(600); // 被打断后重整旗鼓再摸一轮
|
||
}
|
||
ctx.status(game.run?.floor === 3 ? '第三层——窟主的地界' : `还在第 ${game.run?.floor ?? '?'} 层摸路`);
|
||
|
||
// —— 第三层:长按问路 →「楼梯有大东西守着,先赢它」→ 走过去,力战而亡 ——
|
||
if (game.run && game.run.floor === 3) {
|
||
ctx.note('长按,问一次路——听说有大家伙守着楼梯');
|
||
await ctx.gesture('longpress');
|
||
await waitStep(ctx, mock, () => !game.floor.speaking, 8000);
|
||
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);
|
||
}
|
||
|
||
// —— 保底:万一第三层没走到/没死成(随机布局的意外),正经找窟主送掉这条命,
|
||
// 保证弧线以「死亡 → 结算 → 重开」收尾 ——
|
||
if (game.run && game.state !== 'SETTLE') {
|
||
for (let guard = 0; guard < 8 && game.run && game.state !== 'SETTLE'; guard++) {
|
||
if (game.state === 'BATTLE') {
|
||
if (game._battle?.boss) { await fightToDeath(ctx, game); continue; }
|
||
await fightWell(ctx, mock, game);
|
||
continue;
|
||
}
|
||
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(400); continue; }
|
||
if (game.state === 'DRILL') { // 教学脱轨残留:安静等兜底过步再走
|
||
const st = game._drill?.step;
|
||
await drillUnstick(ctx, mock, game, () => game.state !== 'DRILL' || game._drill?.step !== st, 90000);
|
||
continue;
|
||
}
|
||
if (game.state !== 'EXPLORE') { await ctx.waitVirtual(600); continue; }
|
||
if (onStairsCell(game)) {
|
||
if (!game._onStairs) await retriggerStairs(ctx, mock, game);
|
||
if (game._onStairs && game.state !== 'BATTLE') {
|
||
ctx.note('踏上楼梯格——单击,下潜');
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => !game.run || game.run.floor >= 3 || game.state === 'SETTLE', 20000);
|
||
continue;
|
||
}
|
||
}
|
||
await navigate(ctx, mock, game, game._floor.stairs || game._floor.throne, { deathOnBoss: true, until: 'stairs' });
|
||
}
|
||
}
|
||
|
||
// —— SETTLE:死亡归因真话 + 元进度入账(critical 结局链) ——
|
||
await waitStep(ctx, mock, () => game.state === 'SETTLE', 60000);
|
||
if (game.state !== 'SETTLE') { // 理论到不了这里:如实收尾,不演假结局
|
||
ctx.status(`没能走到结算(停在 ${game.state})——这一局的地窟赢了,收工`);
|
||
return;
|
||
}
|
||
await ctx.waitCond(() => atTurn(game));
|
||
ctx.status('倒下了。归因链一句句把死法说清楚:死于谁、哪里没做好、带回了多少金币');
|
||
// 走神一次:结算等待点第一档提示(20s)「单击,再探一次。」
|
||
ctx.silence(20, '瘫在黑暗里喘气,结算听完后发了一会儿呆');
|
||
await ctx.waitVirtual(20500);
|
||
ctx.status('一句「单击,再探一次」把我拉回来——先不急');
|
||
await ctx.waitCond(() => atTurn(game));
|
||
|
||
// 逛一圈菜单(第一局打完,下滑此刻才解锁;绝不单击确认,下滑关闭)
|
||
ctx.note('结算链尾教了「向下滑,可以打开菜单」——去看看');
|
||
await ctx.gesture('down');
|
||
await ctx.waitCond(() => atTurn(game));
|
||
ctx.status('菜单第一项「重新开始本周目」');
|
||
await ctx.gesture('right');
|
||
await ctx.waitCond(() => atTurn(game));
|
||
ctx.status('第二项「保存并回大厅」——不去,关掉');
|
||
await ctx.gesture('down');
|
||
await ctx.waitCond(() => atTurn(game)); // 菜单关了,回到结算等待
|
||
ctx.status('菜单关了,回结算——全程没碰破坏性确认');
|
||
|
||
// —— 再探一次:第二周目(遗产生效、不再上课),短程收尾 ——
|
||
ctx.note('单击——再探一次');
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => game.state === 'EXPLORE' && game.run?.floor === 1, 25000);
|
||
for (let tail = 0; tail < 4 && game.run; tail++) {
|
||
if (game.state === 'BATTLE') { // 复述没听完就被游荡的蝙蝠扑了:打完再收尾
|
||
await fightWell(ctx, mock, game);
|
||
continue;
|
||
}
|
||
if (game.floor.speaking) { await ctx.waitVirtual(600); continue; } // 局面复述(层+血+面朝+楼梯方位)
|
||
if (tail === 0) ctx.status('新周目直落探索:遗产生效,不用再上课');
|
||
ctx.note(tail < 2 ? '长按,再问一次路——新周目的楼梯换方向了' : '单击脉冲,听听熟悉的回声');
|
||
await ctx.gesture(tail < 2 ? 'longpress' : 'tap');
|
||
await ctx.waitVirtual(1800);
|
||
}
|
||
ctx.status('死亡-重开循环跑通,第二周目短程收工');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 乱按压力剧本
|
||
// 目标:全程零死锁、零静默死局,一样要走到死亡-重开循环。
|
||
// 剧本特征:不读提示、乱划乱点、狂点脉冲吃冷却 invalid、战斗乱滑乱逃、撞墙听闷响,
|
||
// 但仍保留「最终能走到 SETTLE」的底线。
|
||
|
||
export async function sessionStress(ctx, mock) {
|
||
const { game } = ctx;
|
||
let seed = 20261002;
|
||
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;
|
||
}
|
||
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');
|
||
|
||
// ② 教学期:一半乱按,但每步最后一定做对(跟不下来也要能走完)。
|
||
// 教学期的遭遇战(乱走撞怪/教学怪扑来)也留在这个循环里打——出了循环
|
||
// 就再没有 D4/D7 的自由寻路逻辑,会永远卡在教学里。
|
||
await waitStep(ctx, mock, () => game.state === 'DRILL' || game.state === 'EXPLORE' || game.state === 'BATTLE', 20000);
|
||
ctx.status('教学里乱按一气,旁白说到哪按到哪');
|
||
const RIGHT = { 0: 'tap', 1: 'tap', 2: 'up', 3: 'left', 5: 'longpress' };
|
||
for (let guard = 0; guard < 500 && (game.state === 'DRILL' || (game.state === 'BATTLE' && game._drill)); guard++) {
|
||
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(400); continue; }
|
||
if (game.state === 'BATTLE') { await chaosFight(ctx, mock, game, rng, pick); continue; }
|
||
const step = game._drill?.step;
|
||
if (step === 4) { // 自由捡金币:乱走两下也得真走到(活跃动作会后推兜底死线,别指望超时)
|
||
const coin = [...game._floor.items.entries()].find(([, v]) => v.type === 'coin');
|
||
if (!coin) { await ctx.waitVirtual(300); continue; }
|
||
const [cx, cy] = coin[0].split(',').map(Number);
|
||
if (game.run.pos.x === cx && game.run.pos.y === cy) { await ctx.waitVirtual(600); continue; }
|
||
if (rng() < 0.3) { await noise(); continue; }
|
||
const path = bfsPath(game._floor, game.run.pos, { x: cx, y: cy }, mobCells(game._floor));
|
||
const stp = (path[0] || bfsPath(game._floor, game.run.pos, { x: cx, y: cy }, new Set())[0]);
|
||
if (stp) { await ctx.gesture(relGesture(game, stp.dir)); await ctx.waitVirtual(300); }
|
||
continue;
|
||
}
|
||
if (step === 6) { // 等教学怪扑来,交给乱打
|
||
await waitStep(ctx, mock, () => game.state === 'BATTLE' || game._drill?.step === 7, 25000);
|
||
continue;
|
||
}
|
||
if (step === 7) { // 自由寻楼梯:乱按按到这一步也得真走过去,否则卡死在聆听里
|
||
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 path = bfsPath(game._floor, game.run.pos, st, mobCells(game._floor));
|
||
const stp = (path[0] || bfsPath(game._floor, game.run.pos, st, new Set())[0]);
|
||
if (!stp) { await ctx.gesture('tap'); await ctx.waitVirtual(1500); continue; }
|
||
if (rng() < 0.25) { await noise(); continue; }
|
||
await ctx.gesture(relGesture(game, stp.dir));
|
||
await ctx.waitVirtual(300);
|
||
continue;
|
||
}
|
||
const gs = RIGHT[step];
|
||
if (!gs) { await ctx.waitVirtual(300); continue; }
|
||
await waitStep(ctx, mock, () => atDrillTurn(game, step) || game._drill?.step !== step, 15000);
|
||
if (game._drill?.step !== step) continue; // 乱按碰巧把本步做对了:换下一步
|
||
if (rng() < 0.4) { await noise(); continue; } // 乱按:吃 error 震/冷却 invalid,不死锁
|
||
await ctx.gesture(gs);
|
||
await ctx.waitVirtual(450);
|
||
}
|
||
await waitStep(ctx, mock, () => game.state === 'EXPLORE' || game.state === 'SETTLE' || (game.state === 'BATTLE' && !game._drill), 25000);
|
||
ctx.status('总算从教学里滚出来了');
|
||
|
||
// ③ 探索乱局:狂点脉冲、乱滑撞墙、乱打乱逃,直到死一次为止(零死锁底线 = SETTLE)
|
||
let chaosGuard = 0;
|
||
while (game.run && game.state !== 'SETTLE' && chaosGuard < 600) {
|
||
chaosGuard++;
|
||
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(400); continue; }
|
||
if (game.state === 'DRILL') { // 兜底:乱按又滚回教学未完的步子(真走完它,别空转)
|
||
if (game._drill?.step === 7 && onStairsCell(game)) { await ctx.gesture('tap'); await ctx.waitVirtual(1500); continue; }
|
||
await navigate(ctx, mock, game, game._floor.stairs || game._floor.throne, { until: 'stairs' });
|
||
continue;
|
||
}
|
||
if (game.state === 'BATTLE') {
|
||
if (game._battle?.boss || game.run.floor >= 3) {
|
||
ctx.status('乱打:不格挡、逃不掉(窟主),一直挥拳');
|
||
await fightToDeath(ctx, game);
|
||
continue;
|
||
}
|
||
await chaosFight(ctx, mock, game, rng, pick);
|
||
continue;
|
||
}
|
||
if (game.state !== 'EXPLORE') { await ctx.waitVirtual(400); continue; }
|
||
if (onStairsCell(game) && !game._floor.mobs.some((m) => m.x === game.run.pos.x && m.y === game.run.pos.y)) {
|
||
if (!game._onStairs) await retriggerStairs(ctx, mock, game);
|
||
if (game._onStairs && game.state === 'EXPLORE') {
|
||
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) { // 狂点脉冲:冷却期就是一串 invalid,游戏不炸
|
||
ctx.note('手痒连点三下脉冲');
|
||
await ctx.gesture('tap');
|
||
await ctx.gesture('tap');
|
||
await ctx.gesture('tap');
|
||
await ctx.waitVirtual(1600);
|
||
continue;
|
||
}
|
||
if (r < 0.5) { await noise(); continue; }
|
||
// 乱中带正:朝楼梯挪一步
|
||
const st = game._floor.stairs || game._floor.throne;
|
||
const path = bfsPath(game._floor, game.run.pos, st, mobCells(game._floor));
|
||
const stp = (path[0] || bfsPath(game._floor, game.run.pos, st, new Set())[0]);
|
||
if (!stp) { await ctx.waitVirtual(800); continue; }
|
||
await ctx.gesture(relGesture(game, stp.dir));
|
||
await ctx.waitVirtual(300);
|
||
}
|
||
|
||
// ④ 乱按还没死透(乱逃乱挡命硬):正经找窟主送掉这条命,保证走到 SETTLE
|
||
if (game.run && game.state !== 'SETTLE') {
|
||
ctx.note('命硬——那就自己走到窟主面前了结');
|
||
for (let guard = 0; guard < 400 && game.run && game.state !== 'SETTLE'; guard++) {
|
||
if (game.state === 'BATTLE') {
|
||
if (game._battle?.boss) { await fightToDeath(ctx, game); continue; }
|
||
await fightWell(ctx, mock, game);
|
||
continue;
|
||
}
|
||
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(400); continue; }
|
||
if (game.state === 'DRILL') { // 兜底同上:先把教学走完
|
||
await navigate(ctx, mock, game, game._floor.stairs || game._floor.throne, { until: 'stairs' });
|
||
if (onStairsCell(game) && game.state === 'DRILL') {
|
||
if (!game._onStairs) await retriggerStairs(ctx, mock, game);
|
||
if (game._onStairs) { await ctx.gesture('tap'); await ctx.waitVirtual(1500); }
|
||
}
|
||
continue;
|
||
}
|
||
if (game.state !== 'EXPLORE') { await ctx.waitVirtual(400); continue; }
|
||
if (onStairsCell(game)) {
|
||
if (!game._onStairs) await retriggerStairs(ctx, mock, game);
|
||
if (game._onStairs) { await ctx.gesture('tap'); await ctx.waitVirtual(1500); continue; }
|
||
}
|
||
await navigate(ctx, mock, game, game._floor.stairs || game._floor.throne, { deathOnBoss: true, until: 'stairs' });
|
||
}
|
||
}
|
||
|
||
// ⑤ SETTLE → 再探一次(直接新周目),短程收尾
|
||
await waitStep(ctx, mock, () => game.state === 'SETTLE', 30000);
|
||
await waitStep(ctx, mock, () => atTurn(game), 30000);
|
||
if (game.state === 'SETTLE') {
|
||
ctx.status('乱打也被归因得明明白白,元进度照单全收');
|
||
await ctx.waitVirtual(2500);
|
||
await ctx.gesture('tap'); // 再探一次 → 新周目(直落探索)
|
||
await waitStep(ctx, mock, () => game.state === 'EXPLORE' && game.run, 25000);
|
||
ctx.status('第二周目照样开局(不再上课),乱划两下收工');
|
||
await noise();
|
||
await noise();
|
||
} else {
|
||
ctx.note('这一把居然没死——压力有限,收工');
|
||
}
|
||
}
|
||
|
||
/** 乱打:大多数时候出手,偶尔乱滑/上滑听战况/试图逃跑;前摇窗手忙脚乱。 */
|
||
async function chaosFight(ctx, mock, 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(400); continue; }
|
||
if (b.state === 'telegraph') {
|
||
if (rng() < 0.3) {
|
||
ctx.note('手忙脚乱中碰巧点了一下——格挡');
|
||
await ctx.gesture('tap');
|
||
}
|
||
await waitStep(ctx, mock, () => game._battle !== b || b.state !== 'telegraph', 4000);
|
||
continue;
|
||
}
|
||
if (b.state === 'player' && atTurn(game)) {
|
||
const r = rng();
|
||
if (r < 0.6) {
|
||
ctx.note('单击,抡空也打');
|
||
await ctx.gesture('tap');
|
||
} else if (r < 0.7) {
|
||
ctx.note('慌了,下滑想跑');
|
||
await ctx.gesture('down');
|
||
} else if (r < 0.85) {
|
||
await ctx.gesture(pick(['left', 'right']));
|
||
} else {
|
||
await ctx.gesture(pick(['up', 'longpress']));
|
||
}
|
||
await ctx.waitVirtual(450);
|
||
continue;
|
||
}
|
||
await ctx.waitVirtual(350);
|
||
}
|
||
await ctx.waitVirtual(2500);
|
||
}
|