- 程序章程 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(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
571 lines
25 KiB
JavaScript
571 lines
25 KiB
JavaScript
/**
|
||
* 《回声地窟》新手会话剧本。
|
||
*
|
||
* 规则:
|
||
* - 主线剧本 `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);
|
||
}
|