Files
SoundGame/web/dungeon/game.js
T
xsl 45124a1b9b feat(new8): 批次1收口——radio/dungeon 双双标记可交付(R3 synthesis×2 + H1 热修 + 定向live复验PASS)
- radio:R3 放行(4/4 人设满分档 5/5、愿意玩、P0=0;live 四修坐实+SA3 真声入局)
- dungeon:R1 0/4→R3 4/4 翻案达成;H1 死亡结算链截断根修(真凶=双击窗幽灵 tap 死后回调路由进 SETTLE,800ms 手势死窗;7 断言含旧代码反证)+ 定向 live 复验 PASS(两局含 R3 同款幽灵场景,结算链 7 句逐句完整)
- 章程账本更新:批次 1 验收 ☑(R4-R6 经用户裁定暂停,视上手反馈续;v2 首条=漫游迷路抢救盲区)
- 断言账面:radio 246 / dungeon 1649 / 9 套件全绿
2026-09-03 03:01:45 +08:00

2003 lines
85 KiB
JavaScript
Raw 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.
/**
* 《回声地窟》—— 纯音频听声辨位 Roguelike(按 docs/design/16-dungeon.md 实现;
* 台词 id 见 16-dungeon-voice-ids.md,125 句 ×2 性别,无 NPC——怪物不说话)。
*
* 你是盲眼探窟者:单击=声波脉冲,回声"看"地形(正前距离 4 档 × 材质、左右侧
* 墙/开口,全部预 pan 资产 + setTimeout + volume 调度,引擎外零实时节点——
* playtest 虚拟时钟可逐项断言);按住=前进(450ms 起、每格 0.5s、撞墙停无伤);
* 左右滑=转身;双击=免费重发脉冲;上滑=状态链;下滑=菜单(三件套不豁免)。
*
* 状态机:TITLE / DRILL / EXPLORE / BATTLE / DESCEND / SETTLE + MENU 挂起 overlay。
* 7 层地窟程序生成:mulberry32((seed ^ (floor*0x9E3779B9))>>>0) 每层独立子种子,
* 房间→L 型走廊(链式生成树)→起点/楼梯(BFS 最远房心)→宝物→怪(BFS≥3 安全区),
* 教学局 L1 叠加硬约束(正前 2 格内墙 / 门怪 / 房内金币),拒绝采样保证可复现。
* 战斗:缠斗/绕位/Boss 三档,前摇 1.2s 内双击格挡(面对 ×0.2 / 背后 ×1.0),
* 逃跑长按必成挨一下(真话;R3 打磨:需按满 900ms,探索走步 450ms 不变),Boss 拒绝;每 4 杀 atkLv+1。
* 元进度"遗产"5 项(金币自动购置,死亡不清档):行囊药/体魄/护符/回声强化/武器。
* 死亡归因真话律:die_cause_* / die_adv_* 按 lastHit(击杀者/背刺/未格挡)与
* run.parries 真实选择;卡关双轨(R3 E0-1):无操作轨 60/120/180s(§10.3,手势重置)
* + 无进展轨 30/60/90s(只由楼层/格位置/战斗回合/拾取重置:earcon→短句→导航接管,
* 乱按不再饿死;教学态接管=滚出教学);踩上楼梯格到达即报贴身铁器回声(R3 E1-1);
* 结算入口 800ms 手势死窗(H1:死亡瞬间在途的 tap 按新状态路由会把结算链掐死在
* 半句——_enterTitle/stopVoice 截杀 die_intro,R3 live A 局 2787.057 实锤)。
*
* 豁免 E1-E4:单击=脉冲、长按=前进/左右滑=转身(E2)、对局期
* setRateBoostEnabled(false)(E3,EXPLORE/BATTLE/DRILL/DESCEND,TITLE/MENU/SETTLE
* 恢复 true)、战斗菜单=暂停(E4)。longpressend 仅作停走信号,不开 immediateTap。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
// ================================ 常量表 ================================
/** 绝对方位:DIR_ORDER 顺时针(右转 = idx+1,左转 = idx-1,N→W 为逆时针 ✓)。 */
const DIR_ORDER = ['N', 'E', 'S', 'W'];
const DIR_VEC = { N: { dx: 0, dy: -1 }, E: { dx: 1, dy: 0 }, S: { dx: 0, dy: 1 }, W: { dx: -1, dy: 0 } };
const DIR_KEY = { N: 'n', E: 'e', S: 's', W: 'w' };
/** 相对方位(战斗/脚步共用):0 前 / 1 右 / 2 后 / 3 左。 */
const REL = { FRONT: 0, RIGHT: 1, BEHIND: 2, LEFT: 3 };
const PULSE_CD = 1200; // 脉冲冷却(§3.1)
const TEL_WINDOW = 1200; // 敌方攻击前摇 = 格挡窗口(§7.2)
const WALK_STEP_MS = 500; // 长按每格耗时(§2.1)
const STUCK_LEVELS = [60000, 120000, 180000]; // 卡关三级·无操作轨(§10.3:任何有效手势重置)
const PROG_LEVELS = [30000, 60000, 90000]; // 无进展三级(R3 E0-1):earcon → 短句 → 导航接管
const NAV_REPEAT_MS = 60000; // L3 带路重复周期
const NAV_MIN_GAP_MS = 1500; // 同向导航句最小间隔(R2 D1-3:短窗不重复,保险丝友好)
const DENSITY_STEP = 12; // 主路径内容密度下限(R2 D0-3①):每 ≤12 格必有遭遇
const AMB_AFTER_MS = 20000; // 探索呼吸层(R2 D0-3②):静默超 20s 播环境垫
const AMB_VOLUME = 0.35;
const FLEE_HOLD_MS = 900; // 战斗逃跑长按最短时长(R3 打磨:探索走步 450ms 不变)
const LONGPRESS_MS = 450; // 引擎 longpress 判定阈值(gesture.js cfg,只读参照)
// H1:进入结算后的手势死窗。gesture.js 非即时模式单击要等双击窗(300ms)关闭才回调——
// 致命一击落地瞬间仍在途的 tap 会在死后 ~300ms 才派发,按「当时」状态路由进 SETTLE:
// tap=离开结算 → _enterTitle 的 stopVoice() 把 die_intro 掐死在半句、整条结算链作废
// (R3 A 局 2787.057 实锤:die_intro vstart 2786.88 → vend kill 2787.057)。手势从按下
// 到回调最迟 touchstart+450ms(长按判定)+300ms(双击窗)≈ 750ms,800ms 死窗全盖住;
// 死窗后结算手势照常(tap 回标题 / 双击重听链 / 下滑菜单)。_settleWin 同栅。
const SETTLE_GRACE_MS = 800;
/** 回声编码(§3.1):正前距离 4 档 → 延迟 / 音量。 */
const ECHO_DELAY = [0, 250, 500, 800, 1100];
const ECHO_VOL = [0, 0.9, 0.7, 0.5, 0.35];
const SIDE_AT = 350; // 侧向回声延后
const MAT_TAIL_AT = 120; // 材质尾音跟在回声到达后
/** 敌型表(§7.2/§8.1;def 全 0,cls=脚步/前摇音色类,gold=掉落)。 */
const MOBS = {
bat: { hp: 4, atk: 0, def: 0, cls: 'small', gold: 3, start: 'btl_bat' },
rat: { hp: 6, atk: 1, def: 0, cls: 'small', gold: 5, start: 'btl_rat' },
mush: { hp: 8, atk: 2, def: 0, cls: 'bug', gold: 8, start: 'btl_mush' },
crab: { hp: 20, atk: 3, def: 0, cls: 'heavy', gold: 50, start: 'btl_crab', boss: true, mover: true, half: 'btl_half_crab' },
crawler: { hp: 10, atk: 2, def: 0, cls: 'bug', gold: 10, start: 'btl_crawler', mover: true },
spider: { hp: 12, atk: 3, def: 0, cls: 'bug', gold: 12, start: 'btl_spider' },
leech: { hp: 26, atk: 4, def: 0, cls: 'heavy', gold: 80, start: 'btl_leech', boss: true, mover: true, half: 'btl_half_leech' },
guard: { hp: 16, atk: 4, def: 0, cls: 'heavy', gold: 15, start: 'btl_guard', mover: true },
king: { hp: 36, atk: 5, def: 0, cls: 'heavy', gold: 0, start: 'btl_king', boss: true, mover: true, half: 'btl_half_king' },
};
/** 层规格(§6.2):尺寸 / 怪物(型×数)/ 宝物 / 窟主 / 终局。 */
const FLOOR_SPECS = {
1: { size: 7, mobs: { bat: 2 }, items: ['coin'], drill: true },
2: { size: 7, mobs: { rat: 3 }, items: ['coin', 'potion'] },
3: { size: 9, mobs: { mush: 3, crab: 1 }, items: ['coin', 'weapon1'], boss: 'crab' },
4: { size: 9, mobs: { crawler: 2, spider: 2 }, items: ['coin', 'armor1'] },
5: { size: 11, mobs: { spider: 3, crawler: 2 }, items: ['coin', 'potion'] },
6: { size: 11, mobs: { spider: 2, crawler: 2, leech: 1 }, items: ['coin', 'weapon2'], boss: 'leech' },
7: { size: 11, mobs: { guard: 3, king: 1 }, items: ['coin'], final: true },
};
/** 元进度遗产(§7.4):按价格升序自动购置(金币累计,死亡不清档)。 */
const LEGACIES = [
{ id: 'potion', price: 90, voice: 'unlock_potion' },
{ id: 'vigor', price: 150, voice: 'unlock_vigor' },
{ id: 'veil', price: 220, voice: 'unlock_veil' },
{ id: 'echo', price: 300, voice: 'unlock_echo' },
{ id: 'blade', price: 450, voice: 'unlock_blade' },
];
const MENU_ITEMS = ['sys_menu_resume', 'sys_menu_save', 'sys_menu_help', 'sys_menu_restart'];
const TITLE_MENU = ['sys_menu_help', 'sys_menu_resume']; // §2.3:标题/结算菜单(帮助/返回两项)
const HP_BAND_TEXT = { 1: '毫发无损', 2: '受了点伤', 3: '伤势不轻', 4: '命悬一线' };
const SAVE_KEY = 'soundgame.progress.dungeon';
const AUTH_KEY = 'soundgame.auth';
const PREWARM_SFX = [
'sfx/pulse', 'sfx/echo_1', 'sfx/echo_2', 'sfx/echo_3', 'sfx/echo_4',
'sfx/mat_stone', 'sfx/mat_door', 'sfx/mat_stair', 'sfx/mat_water',
'sfx/side_wall_l', 'sfx/side_wall_r', 'sfx/side_open_l', 'sfx/side_open_r',
'sfx/step_own', 'sfx/step_turn', 'sfx/bump',
'sfx/coin', 'sfx/potion', 'sfx/gear', 'sfx/stairs_down',
'sfx/mob_step_small_l', 'sfx/mob_step_small_c', 'sfx/mob_step_small_r',
'sfx/mob_step_bug_l', 'sfx/mob_step_bug_c', 'sfx/mob_step_bug_r',
'sfx/mob_step_heavy_l', 'sfx/mob_step_heavy_c', 'sfx/mob_step_heavy_r',
'sfx/tel_small', 'sfx/tel_small_l', 'sfx/tel_small_r',
'sfx/tel_bug', 'sfx/tel_bug_l', 'sfx/tel_bug_r',
'sfx/tel_heavy', 'sfx/tel_heavy_l', 'sfx/tel_heavy_r',
'sfx/atk_swing', 'sfx/atk_hit', 'sfx/parry', 'sfx/hurt', 'sfx/enemy_die', 'sfx/levelup',
'sfx/amb_cave', 'sfx/turn_tick', // R2:探索呼吸层垫(D0-3②)+ 战斗回合确认音(D1-6)
];
// ================================ 工具 ================================
const key = (x, y) => x + ',' + y;
const range = (a, b) => { const r = []; const s = a <= b ? 1 : -1; for (let i = a; a <= b ? i <= b : i >= b; i += s) r.push(i); return r; };
// ================================ 主类 ================================
export class DungeonGame extends Game {
constructor(audio, root, opts = {}) {
super(audio, root);
this.guide = new Guide(audio); // guide='off' 时全部 API 静默 no-op 且零定时器
this._fixedSeed = opts.seed != null ? opts.seed : null; // playtest 注入(仅首个新周目生效)
this.state = 'boot'; // TITLE / DRILL / EXPLORE / BATTLE / DESCEND / SETTLE
this.menuOpen = false; // MENU = 挂起 overlay(记录返回态 menuFrom)
this.menuFrom = null;
this._session = 0; // 会话令牌:状态大跳转作废旧计时器
this._timers = [];
this.meta = this._defaultMeta();
this.run = null;
this._floor = null;
this._battle = null;
this._drill = null;
this._walk = null;
this._pulseAt = -1e9;
this._lastHit = null; // 归因真话律依据(击杀者/背刺/未格挡)
this._settleGraceUntil = 0; // H1:结算手势死窗截止时刻(performance.now() 时基)
this._lastEcho = null; // 最近一次脉冲回声(双击重发一致性断言用)
this._onStairs = false;
this._stairsSpokenFloor = 0;
this._dangerFloorDone = false;
this._menuPause = null;
this._menuItems = MENU_ITEMS;
this._menuIdx = 0;
// R3 E0-1 双轨制:lastAct=无操作轨(任何有效手势重置,§10.3 现状);
// lastProg=无进展轨(只由状态推进重置:楼层/格位置/战斗回合/拾取)——乱按不再饿死抢救链
this._stuck = { lastAct: 0, lastProg: 0, shown: new Set(), progShown: new Set(), nav: false, lastFire: 0, pending: [] };
this._stuckArmed = false;
this._lastAmbAt = 0; // 探索呼吸层上次播放时刻(R2 D0-3②)
this._navLastDir = null; // 导航模式上次播的方向(同向短窗节流,R2 D1-3)
this._navLastAt = 0;
this._titleChain = [];
}
_defaultMeta() {
return { gold: 0, unlocks: [], bestFloor: 0, runs: 0, kills: 0, drillDone: false, pending: [] };
}
// ---------------- 派生数值(§8.1) ----------------
_has(id) { return this.meta.unlocks.includes(id); }
_maxHp() { return 20 + (this._has('vigor') ? 6 : 0); }
_playerAtk() { return 2 + this.run.atkLv + this.run.weapon; }
_armor() { return this.run.armor; }
_hpBand(hp, max) {
const r = hp / max;
if (r > 0.75) return 1;
if (r > 0.5) return 2;
if (r > 0.25) return 3;
return 4;
}
_goldBand(gold) { return gold < 50 ? 1 : gold <= 200 ? 2 : gold <= 600 ? 3 : 4; }
/** R3 E1-2 金币档账目:band1(0<金<50)播新句 gold_some(「带回来一点,不多」)——
* 修「捡到金币→口袋空空/这趟没带回什么」的档位谎报(P2/P4 共识);band0(=0)
* 保留「空手」措辞。上滑状态链与 SETTLE/TITLE(元进度)共用同一分档与账本。 */
_stGoldVoice() {
if (this.meta.gold > 0 && this._goldBand(this.meta.gold) === 1) return 'voice/gold_some';
return 'voice/st_gold_' + this._goldBand(this.meta.gold);
}
_metaGoldVoice() {
if (this.meta.gold > 0 && this._goldBand(this.meta.gold) === 1) return 'voice/gold_some';
return 'voice/meta_gold_' + this._goldBand(this.meta.gold);
}
// ================================ 地窟生成(§6) ================================
/** seed 派生公式(§6.1,逐字照设计):每层独立子种子。 */
_floorSeedOf(seed, floor) {
return (seed ^ (floor * 0x9E3779B9)) >>> 0;
}
_mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
_bfs(grid, start) {
const S = grid.length;
const dist = Array.from({ length: S }, () => new Array(S).fill(-1));
const q = [[start.x, start.y]];
dist[start.y][start.x] = 0;
while (q.length) {
const [x, y] = q.shift();
for (const d of Object.values(DIR_VEC)) {
const nx = x + d.dx, ny = y + d.dy;
if (nx < 0 || ny < 0 || nx >= S || ny >= S || grid[ny][nx] !== 1 || dist[ny][nx] >= 0) continue;
dist[ny][nx] = dist[y][x] + 1;
q.push([nx, ny]);
}
}
return dist;
}
/**
* 生成一层:外层拒绝采样(教学局 L1 硬约束优先于随机,§6.1 注),
* 内层全走子种子 rng → 同 seed 两次构造逐格相等(§12.2)。
* 走廊 = 相邻房间两两 L 型连通(链式 = 生成树,§6.1 ③ 的等价实现)。
*/
_generateFloor(seed, floor) {
const spec = FLOOR_SPECS[floor];
const base = this._mulberry32(this._floorSeedOf(seed, floor));
for (let attempt = 0; attempt < 80; attempt++) {
const rng = this._mulberry32((base() * 4294967296) >>> 0);
const f = this._tryGenFloor(rng, floor, spec);
if (f) return f;
}
throw new Error(`dungeon: floor ${floor} generation failed (seed=${seed})`);
}
_tryGenFloor(rng, floor, spec) {
const S = spec.size;
const grid = Array.from({ length: S }, () => new Array(S).fill(0));
const rooms = [];
if (spec.drill) rooms.push({ x: 2, y: 2, w: 3, h: 3 }); // 教学房固定:起点恒 (3,3)
const want = 3 + Math.floor(rng() * 4); // 3..6 间(§6.1 ②)
for (let guard = 0; rooms.length < want && guard < 300; guard++) {
const w = 2 + Math.floor(rng() * 2); // 2..3
const h = 2 + Math.floor(rng() * 3); // 2..4
const x = Math.floor(rng() * (S - w + 1));
const y = Math.floor(rng() * (S - h + 1));
if (rooms.some((r) => x < r.x + r.w && r.x < x + w && y < r.y + r.h && r.y < y + h)) continue; // 不重叠
rooms.push({ x, y, w, h });
}
if (rooms.length < 3) return null;
for (const r of rooms) {
for (let yy = r.y; yy < r.y + r.h; yy++) for (let xx = r.x; xx < r.x + r.w; xx++) grid[yy][xx] = 1;
}
const centerOf = (r) => ({ x: r.x + (r.w >> 1), y: r.y + (r.h >> 1) });
const carve = (x, y) => { if (x >= 0 && y >= 0 && x < S && y < S) grid[y][x] = 1; };
for (let i = 1; i < rooms.length; i++) { // L 型走廊链式连通(§6.1 ③)
const a = centerOf(rooms[i - 1]);
const b = centerOf(rooms[i]);
if (rng() < 0.5) {
for (const x of range(a.x, b.x)) carve(x, a.y);
for (const y of range(a.y, b.y)) carve(b.x, y);
} else {
for (const y of range(a.y, b.y)) carve(a.x, y);
for (const x of range(a.x, b.x)) carve(x, b.y);
}
}
const start = spec.drill ? { x: 3, y: 3 } : centerOf(rooms[0]);
if (spec.drill) {
carve(5, 3); // 东侧走道:D4 长按前进 ≥2 格后撞边界 bump
carve(3, 5); // 南门:教学怪把门(D8 走向它触发战斗)
if (grid[1][3] !== 0) return null; // 约束①:起点正前 2 格内有墙(D1 回声演示)
if (grid[2][3] !== 1 || grid[4][3] !== 1) return null;
}
const dist = this._bfs(grid, start);
const floorCells = [];
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
if (grid[y][x] === 1) {
if (dist[y][x] < 0) return null; // 连通性不变式
floorCells.push({ x, y, d: dist[y][x] });
}
}
// 楼梯 = BFS 距起点最远房间中心(§6.1 ④);终局层无楼梯,王座=最远房心
let far = null, farD = -1;
for (const r of rooms) {
const c = centerOf(r);
if (dist[c.y][c.x] > farD) { farD = dist[c.y][c.x]; far = c; }
}
const stairs = spec.final ? null : { x: far.x, y: far.y };
const throne = spec.final ? { x: far.x, y: far.y } : null;
// 宝物撒入房间格(§6.1 ⑤)
const items = new Map();
const occupied = new Set([key(start.x, start.y)]);
if (stairs) occupied.add(key(stairs.x, stairs.y));
if (throne) occupied.add(key(throne.x, throne.y));
for (const it of spec.items) {
let placed = null;
for (let g2 = 0; g2 < 120 && !placed; g2++) {
let c;
if (spec.drill && it === 'coin') {
// 约束③:教学金币固定在起点房内(拾取体验)
const cells = [];
for (let yy = 2; yy <= 4; yy++) for (let xx = 2; xx <= 4; xx++) {
if (!(xx === 3 && yy === 3) && grid[yy][xx] === 1) cells.push({ x: xx, y: yy });
}
if (!cells.length) return null;
c = cells[Math.floor(rng() * cells.length)];
} else {
const room = rooms[Math.floor(rng() * rooms.length)];
c = { x: room.x + Math.floor(rng() * room.w), y: room.y + Math.floor(rng() * room.h) };
}
if (grid[c.y]?.[c.x] !== 1 || dist[c.y][c.x] < 1 || occupied.has(key(c.x, c.y))) continue;
placed = c;
}
if (!placed) return null;
occupied.add(key(placed.x, placed.y));
items.set(key(placed.x, placed.y), it === 'coin'
? { type: 'coin', gold: 10 + Math.floor(rng() * 21) } // 堆 10-30 金(§7.4 经济核算)
: { type: it });
}
// 怪物:窟主守楼梯格 / 终局王座;普通怪 BFS ≥3 安全区(教学局非门怪 ≥4,
// 避免 D4/D8 演练路径撞上第二只怪)
const mobs = [];
const mobTypes = [];
for (const [type, n] of Object.entries(spec.mobs)) for (let i = 0; i < n; i++) mobTypes.push(type);
for (let i = mobTypes.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[mobTypes[i], mobTypes[j]] = [mobTypes[j], mobTypes[i]];
}
let doorBatPlaced = false;
const minMobDist = spec.drill ? 4 : 3;
for (const type of mobTypes) {
const ms = MOBS[type];
let cell = null;
let isDoorBat = false;
if (ms.boss) cell = spec.final ? { ...throne } : { ...stairs };
else if (spec.drill && type === 'bat' && !doorBatPlaced) { cell = { x: 3, y: 5 }; isDoorBat = true; doorBatPlaced = true; }
if (!cell) {
for (let g3 = 0; g3 < 150 && !cell; g3++) {
const c = floorCells[Math.floor(rng() * floorCells.length)];
if (c.d < minMobDist || occupied.has(key(c.x, c.y))) continue;
cell = { x: c.x, y: c.y };
}
if (!cell) return null;
}
occupied.add(key(cell.x, cell.y));
mobs.push({ type, x: cell.x, y: cell.y, hp: ms.hp, maxHp: ms.hp, drill: isDoorBat });
}
this._fillMainPathDensity(spec, { grid, dist, start, stairs, throne, mobs, occupied, rng, minMobDist });
return { floor, size: S, grid, rooms, start, stairs, throne, items, mobs, dist };
}
/**
* 主路径内容密度下限(R2 D0-3①):start→楼梯(终局层=王座)的最短路上每
* ≤ DENSITY_STEP 格必有内容遭遇(怪/宝/楼梯/起点)。生成后沿 BFS 距离梯度
* 回溯最短路,最长连续无内容段超限 → 段中点补一只本层已有的普通怪型
* (结构性密度,不加新怪种;教学局补的仍是 ATK0 蝠)。确定性:只用同一 rng。
*/
_fillMainPathDensity(spec, ctx) {
const { grid, dist, start, stairs, throne, mobs, occupied, rng, minMobDist } = ctx;
const goal = spec.final ? throne : stairs;
if (!goal) return;
const S = grid.length;
const path = [{ x: goal.x, y: goal.y }];
let cur = { x: goal.x, y: goal.y };
while (cur.x !== start.x || cur.y !== start.y) { // 距离梯度回溯 = 最短路(固定扫描序,确定)
for (const d of Object.values(DIR_VEC)) {
const nx = cur.x + d.dx, ny = cur.y + d.dy;
if (nx < 0 || ny < 0 || nx >= S || ny >= S || grid[ny][nx] !== 1 || dist[ny][nx] !== dist[cur.y][cur.x] - 1) continue;
cur = { x: nx, y: ny };
break;
}
path.push(cur);
}
const extraTypes = Object.keys(spec.mobs).filter((t) => !MOBS[t].boss);
for (let round = 0; round < 8 && extraTypes.length; round++) {
let bestA = -1, bestLen = 0, runA = 0;
for (let i = 0; i <= path.length; i++) {
const filled = i < path.length && occupied.has(key(path[i].x, path[i].y));
if (!filled) continue; // 段在内容格(或路径尽头)闭合
if (i - runA > bestLen) { bestLen = i - runA; bestA = runA; }
runA = i + 1;
}
if (bestLen <= DENSITY_STEP) return; // 密度达标
let mid = bestA + (bestLen >> 1);
while (mid < path.length - 1 && dist[path[mid].y][path[mid].x] < minMobDist) mid++; // 安全区内偏后
const cell = path[mid];
if (dist[cell.y][cell.x] < minMobDist || occupied.has(key(cell.x, cell.y))) return; // 放不下:保底放弃
const type = extraTypes[round % extraTypes.length];
const ms = MOBS[type];
occupied.add(key(cell.x, cell.y));
mobs.push({ type, x: cell.x, y: cell.y, hp: ms.hp, maxHp: ms.hp, drill: false });
}
}
/** 进入/重生成一层(下潜、读档重放共用);已拾宝物与已清窟主按 run 过滤。 */
_enterFloor(floor) {
const r = this.run;
r.floor = floor;
this._floor = this._generateFloor(r.seed, floor);
r.pos = { ...this._floor.start };
r.face = 'N';
r.veilUsedFloor = false;
this._dangerFloorDone = false;
this._pulseAt = -1e9;
this._onStairs = false;
this._stairsSpokenFloor = 0;
for (const k of r.looted) this._floor.items.delete(k);
if (FLOOR_SPECS[floor].boss && r.cleared.includes(floor)) {
this._floor.mobs = this._floor.mobs.filter((m) => !MOBS[m.type].boss);
}
}
// ================================ 生命周期 ================================
start() {
this.audio.prewarm(PREWARM_SFX);
const save = this._loadSave();
if (save) {
this.meta = { ...this._defaultMeta(), ...save.meta, unlocks: [...(save.meta?.unlocks || [])], pending: [...(save.meta?.pending || [])] };
if (save.run) {
this.run = { ...save.run, pos: { x: save.run.pos[0], y: save.run.pos[1] } };
}
}
this._enterTitle();
}
stop() {
this._session++;
this._clearTimers();
this._stopWalk();
this.audio.stopAll();
}
_goHome() {
this.stop();
window.location.href = '../';
}
// ---------------- TITLE(§2.3) ----------------
_enterTitle() {
this._session++;
this._clearTimers();
this._stopWalk();
this._battle = null;
this.menuOpen = false;
this.menuFrom = null;
this.state = 'TITLE';
this.audio.stopVoice();
this.audio.setRateBoostEnabled(true); // E3:非对局恢复长按快进
this.audio.play('bgm/menu');
this.guide.state('menu');
this._titleChain = this.run ? ['voice/sys_continue_ask'] : [];
if (this._titleChain.length) this._speak(this._titleChain);
}
_titleUpChain() {
const ids = [this._metaGoldVoice()];
const best = this.meta.bestFloor;
ids.push(best < 3 ? 'voice/st_best_1' : best < 6 ? 'voice/st_best_2' : 'voice/st_best_3');
if (this.meta.unlocks.length === 0) ids.push('voice/unlock_none');
else for (const u of this.meta.unlocks) ids.push('voice/unlock_' + u);
return ids;
}
_onTitleGesture(g) {
if (g === 'tap') {
if (this.run) this._resumeRun();
else this._newRunFlow();
} else if (g === 'right') {
this._newRunFlow(); // 重掷新周目(meta 保留,§2.3)
} else if (g === 'doubletap') {
const chain = this._titleChain.length ? this._titleChain : this._titleUpChain();
this._speak(chain); // 重听 continue_ask / 元进度播报
} else if (g === 'up') {
this._titleChain = this._titleUpChain();
this._speak(this._titleChain);
} else if (g === 'down') {
this._openMenu();
} else {
this.guide.error({ voice: 'gd_invalid', onceKey: 'dungeon_invalid' }); // error 震 + full 档纠错
}
}
// ---------------- 新周目 / 继续(§4.2、§11) ----------------
_newRunFlow() {
this._session++;
this._clearTimers();
this._stopWalk();
this._battle = null;
this._drill = null;
this.menuOpen = false;
this.audio.stopVoice();
const seed = this._fixedSeed != null ? this._fixedSeed : Math.floor(Math.random() * 1e9);
this._fixedSeed = null;
this.run = {
seed, floor: 1, pos: { x: 0, y: 0 }, face: 'N',
hp: this._maxHp(), atkLv: 0,
weapon: this._has('blade') ? 1 : 0, armor: 0,
potion: this._has('potion') ? 1 : 0,
veilUsedFloor: false, killsRun: 0, looted: [], cleared: [],
parries: 0, flees: 0, gold: 0,
};
this._lastHit = null;
this._enterFloor(1);
this._save();
const ids = ['voice/sys_new_run'];
if (this.meta.pending.length) {
ids.push('voice/unlock_lead');
for (const u of this.meta.pending) ids.push('voice/unlock_' + u);
}
this._speakGuarded(ids, () => {
this.meta.pending = [];
this._save();
if (!this.meta.drillDone) this._enterDrill();
else this._resumeExplore(this._recapChain()); // 新周目直落探索也带局面复述(P3)
}, 20000);
}
_restartRun(seed) { // 菜单"重新开始本周目":同 seed 重置
this.run = null;
this._fixedSeed = seed;
this._newRunFlow();
}
_recapChain() { // 局面复述(§11):层 → 血量 → 面朝 → 威胁(金币只在显式上滑状态链)
return ['voice/st_floor_' + this.run.floor,
'voice/st_hp_' + this._hpBand(this.run.hp, this._maxHp()),
'voice/st_face_' + DIR_KEY[this.run.face],
'voice/st_threat_' + this._threatKey()];
}
_resumeRun() {
const saved = { pos: { ...this.run.pos }, face: this.run.face, veil: this.run.veilUsedFloor };
this._enterFloor(this.run.floor); // 重放该层 seed 生成(§11)
this.run.pos = saved.pos;
this.run.face = saved.face;
this.run.veilUsedFloor = saved.veil;
this._resumeExplore(this._recapChain());
}
// ================================ 状态接入 ================================
/** 进入探索态(DRILL 完成后 / 战斗后 / 读档后共用)。chain = 前置语音链(可空)。 */
_resumeExplore(chain) {
this.state = 'EXPLORE';
this._session++;
this._clearTimers();
this.menuOpen = false;
this.audio.play('bgm/explore');
this.audio.setRateBoostEnabled(false); // E3 豁免
this.guide.state('explore');
this._startMobs();
this._stuck.lastAct = performance.now();
this._progressTrack(); // R3 E0-1:进入探索 = 无进展轨从零起算
this._lastAmbAt = performance.now(); // 呼吸层从进入探索起算静默(R2 D0-3②)
this._stuckArm();
this._armExploreWatchdog();
if (chain && chain.length) this._speak(chain);
}
// ---------------- 手势总入口 ----------------
onGesture(g) {
this.guide.disarm(); // 任何手势复位向导看门狗(off 档 no-op)
if (g === 'longpressend') { this._stopWalk(); this._battleFleeCancel(); return; } // 仅停走/取消逃跑,不承载第二语义
if (this.menuOpen) { this._onMenuGesture(g); return; }
// H1:结算死窗。死亡/通关瞬间仍在途的手势(引擎双击窗 300ms 延迟派发)落进
// SETTLE 会被当成本态手势——tap=离开结算 → stopVoice 把结算链掐死在半句。
// 死窗内一律吞掉(ghost 手势不算玩家输入,_stuck 队列也不作废)。
if (this.state === 'SETTLE' && !this.menuOpen && performance.now() < this._settleGraceUntil) return;
this._stuck.pending.length = 0; // 玩家操作即作废未播的卡关提示(§10.3)
switch (this.state) {
case 'TITLE': this._onTitleGesture(g); break;
case 'DRILL': this._onDrillGesture(g); break;
case 'EXPLORE': this._onExploreGesture(g); break;
case 'BATTLE': this._onBattleGesture(g); break;
case 'SETTLE': this._onSettleGesture(g); break;
default: break; // DESCEND 演出窗口:不收手势
}
// 探索静止看门狗:每个手势后仍在探索态则重新武装([10s, 25s] 两级)
if (this.state === 'EXPLORE' && !this.menuOpen) this._armExploreWatchdog();
if (this.state === 'EXPLORE' && !this.menuOpen) this._stuck.lastAct = performance.now();
}
// ================================ 探索态(§2.1) ================================
_onExploreGesture(g) {
if (g === 'tap') {
if (this._onStairs && this._stairsUsable()) { this._descend(false); return; }
this._pulse();
} else if (g === 'doubletap') {
this._pulse(); // 免费重发脉冲(同受 1.2s 冷却闸,§3.1)
} else if (g === 'left') {
this._turn(-1);
} else if (g === 'right') {
this._turn(1);
} else if (g === 'up') {
this._speakStatus();
} else if (g === 'down') {
this._openMenu();
} else if (g === 'longpress') {
this._startWalk();
}
}
_stairsUsable() {
const spec = FLOOR_SPECS[this.run.floor];
if (!this._floor.stairs || !this._onStairs) return false;
if (spec.boss && !this.run.cleared.includes(this.run.floor)) return false; // 窟主未清
return true;
}
_turn(d) {
const i = DIR_ORDER.indexOf(this.run.face);
this.run.face = DIR_ORDER[(i + d + 4) % 4];
this.audio.play('sfx/step_turn'); // swipe 震由 gesture.js 内置,不重复
}
/** 声波脉冲(§3.1):全部预生成资产 + setTimeout + volume,可被虚拟时钟断言。 */
_pulse() {
const now = performance.now();
if (now - this._pulseAt < PULSE_CD) { vibrate('error'); return null; } // 冷却期:仅 error 震
this._pulseAt = now;
this.audio.play('sfx/pulse', { volume: 0.8 });
const s = this._session;
const f = this._echoScan(this.run.pos.x, this.run.pos.y, this.run.face);
const band = f.dist <= 1 ? 1 : f.dist === 2 ? 2 : f.dist <= 4 ? 3 : 4;
const delay = ECHO_DELAY[band];
const vol = ECHO_VOL[band];
this._after(delay, () => { if (s === this._session) this.audio.play('sfx/echo_' + band, { volume: vol }); });
this._after(delay + MAT_TAIL_AT, () => { if (s === this._session) this.audio.play('sfx/mat_' + f.mat, { volume: 0.8 }); });
const L = this._sideScan(-1);
const R = this._sideScan(1);
this._after(SIDE_AT, () => {
if (s !== this._session) return;
this.audio.play('sfx/side_' + L.kind + '_l', { volume: this._sideVol(L) });
this.audio.play('sfx/side_' + R.kind + '_r', { volume: this._sideVol(R) });
});
this._lastEcho = { band, dist: f.dist, mat: f.mat, left: L.kind, right: R.kind, delay, vol };
return this._lastEcho;
}
/** 正前扫描:最近反射体决定距离与材质(楼梯=铁梯;身后方向永不进入扫描)。
* R2 D0-2:站在楼梯格上脉冲 → 贴身铁器回声(教学「铁器=楼梯」的兑现:
* R1 live 165 脉冲 0 次 mat_stair,连站在楼梯上都报石墙)。 */
_echoScan(x, y, face) {
const st = this._floor.stairs;
if (st && x === st.x && y === st.y) return { dist: 1, mat: 'stair' };
const d = DIR_VEC[face];
const S = this._floor.size;
for (let i = 1; i <= S * 2; i++) {
const cx = x + d.dx * i, cy = y + d.dy * i;
if (cx < 0 || cy < 0 || cx >= S || cy >= S || this._floor.grid[cy][cx] === 0) return { dist: i, mat: 'stone' };
if (st && cx === st.x && cy === st.y) return { dist: i, mat: 'stair' };
// 木门/水地形当前生成器不产出;材质表保留(mat_door/mat_water 供未来层)
}
return { dist: S * 2, mat: 'stone' };
}
/**
* 侧向扫描(side = -1 左 / 1 右):开口(第一格可通行=能走进去)/墙(闷,含距离),
* 身后不报。R2 D0-2 重接 grid 单源:R1 旧实现把「整条侧射线到边界全通」才判
* 开口——房间/走廊地型下恒为假,40+ 脉冲 side_open 0 次、明明能走的方向报墙。
* 设计 §3.1 口径:侧向 2 档(墙=闷 / 开口=空旷回灵),开一格就是开口。
*/
_sideScan(side) {
const fi = DIR_ORDER.indexOf(this.run.face);
const d = DIR_VEC[DIR_ORDER[(fi + side + 4) % 4]];
const S = this._floor.size;
let x = this.run.pos.x, y = this.run.pos.y;
for (let i = 1; i <= S; i++) {
x += d.dx; y += d.dy;
if (x < 0 || y < 0 || x >= S || y >= S || this._floor.grid[y][x] === 0) {
return { kind: 'wall', dist: i };
}
if (i === 1) return { kind: 'open', dist: 1 }; // 侧向第一格是地板 = 开口(真话)
}
return { kind: 'open', dist: S };
}
/** 侧向音量:回声强化解锁后按远近换 2 组资产音量(§7.4 unlock_echo)。 */
_sideVol(s) {
if (this._has('echo')) return s.dist <= 1 ? 0.8 : 0.45;
return 0.6;
}
// ---------------- 长按前进(§2.1:450ms 起、每格 0.5s、撞墙停无伤) ----------------
_startWalk() {
if (this._walk || this.state !== 'EXPLORE' && this.state !== 'DRILL') return;
if (this.menuOpen) return;
this._walk = { steps: 0, bumped: false };
this._walkStep();
}
_stopWalk() { this._walk = null; }
_walkStep() {
if (!this._walk) return;
const s = this._session;
const d = DIR_VEC[this.run.face];
const nx = this.run.pos.x + d.dx;
const ny = this.run.pos.y + d.dy;
const S = this._floor.size;
if (nx < 0 || ny < 0 || nx >= S || ny >= S || this._floor.grid[ny][nx] !== 1) {
this.audio.play('sfx/bump'); // 撞墙:无伤自动停(§2.1)
this._walk.bumped = true;
this._afterWalkDrill();
this._stopWalk();
return;
}
this.run.pos = { x: nx, y: ny };
this._progressTrack(); // 格位置变化 = 无进展轨重置(R3 E0-1:走动是真进展)
this._walk.steps++;
this.audio.play('sfx/step_own', { volume: 0.7 });
vibrate('swipe');
// 导航模式(卡关 L3):每步播方向
if (this._stuck.nav && this.state === 'EXPLORE') this._navSpeakStep();
// 踏格判定:怪 → 遭遇;宝物 → 拾取;楼梯 → 提示/锁
const mob = this._floor.mobs.find((m) => m.x === nx && m.y === ny);
if (mob) { this._encounter(mob); return; } // _encounter 内部停走(session 令牌)
const k = key(nx, ny);
const item = this._floor.items.get(k);
if (item) this._pickup(item, k);
this._onStairCell(nx, ny);
this._afterWalkDrill();
this._after(WALK_STEP_MS, () => { if (s === this._session && this._walk) this._walkStep(); });
}
_afterWalkDrill() {
const d = this._drill;
if (!d || d.step !== 4 || !this._walk) return;
if (this._walk.steps >= 2 || this._walk.bumped) this._drillPass(); // §5 D4:≥2 格(撞墙也算过)
}
_onStairCell(x, y) {
const st = this._floor.stairs;
if (!st || st.x !== x || st.y !== y) { this._onStairs = false; return; }
this._onStairs = true;
this._progress(); // 踏上楼梯格 = 进展事件(§10.3)
// R3 E1-1 到达即报:踩上楼梯格瞬间自动播贴身铁器回声(编码同脉冲贴身档:
// echo_1 +120ms 材质尾音)。live 证据——站楼梯格 tap 被下潜模态截走,「铁器=楼梯」
// 只能靠相邻格脉冲兑现(P1-1 输入层不可达)。不占输入通道:单击仍是下潜确认。
const s = this._session;
this.audio.play('sfx/echo_1', { volume: ECHO_VOL[1] });
this._after(MAT_TAIL_AT, () => { if (s === this._session) this.audio.play('sfx/mat_stair', { volume: 0.8 }); });
const spec = FLOOR_SPECS[this.run.floor];
if (spec.boss && !this.run.cleared.includes(this.run.floor)) {
this._speak(['voice/evt_stairs_lock']);
return;
}
if (this._stairsSpokenFloor !== this.run.floor) {
this._stairsSpokenFloor = this.run.floor;
this._speak(['voice/tut_stairs_on']); // 楼梯就在脚下。单击,下潜。
}
}
// ---------------- 拾取(§6.2/§7.4) ----------------
_pickup(item, k) {
if (item.type === 'coin') {
this.meta.gold += item.gold; // 金币实时入元进度(死亡不清档)
this.run.gold += item.gold;
this._floor.items.delete(k);
this.run.looted.push(k);
this.audio.play('sfx/coin');
vibrate('pickup');
const band = item.gold < 16 ? 1 : item.gold < 24 ? 2 : 3;
this._speak(['voice/evt_coin_' + band]);
this._progress();
this._save();
} else if (item.type === 'potion') {
if (this.run.potion >= 1) {
this._speak(['voice/evt_potion_full']); // 腰带满:药留在原地
return;
}
this.run.potion = 1;
this._floor.items.delete(k);
this.run.looted.push(k);
this.audio.play('sfx/potion');
vibrate('pickup');
this._speak(['voice/evt_potion']);
this._progress();
this._save();
} else if (item.type === 'weapon1' || item.type === 'weapon2') {
const lvl = item.type === 'weapon1' ? 1 : 2;
if (this.run.weapon >= lvl) return; // 已有更优:留在原地
this.run.weapon = lvl;
this._floor.items.delete(k);
this.run.looted.push(k);
this.audio.play('sfx/gear');
vibrate('pickup');
this._speak(['voice/evt_weapon']);
this._progress();
this._save();
} else if (item.type === 'armor1') {
if (this.run.armor >= 1) return;
this.run.armor = 1;
this._floor.items.delete(k);
this.run.looted.push(k);
this.audio.play('sfx/gear');
vibrate('pickup');
this._speak(['voice/evt_armor']);
this._progress();
this._save();
}
}
// ---------------- 状态播报(§2.1/§2.2/§3.2) ----------------
_threatKey() {
let left = 0, right = 0;
const fi = DIR_ORDER.indexOf(this.run.face);
for (const m of this._floor.mobs) {
const dx = m.x - this.run.pos.x, dy = m.y - this.run.pos.y;
if (Math.abs(dx) + Math.abs(dy) > 5) continue;
const dirIdx = dx > 0 ? 1 : dx < 0 ? 3 : dy > 0 ? 2 : 0;
const rel = (dirIdx - fi + 4) % 4;
if (rel === REL.LEFT) left++;
else if (rel === REL.RIGHT) right++;
// 前方/身后不报(同回声降维,§3.2)
}
return left && right ? 'both' : left ? 'left' : right ? 'right' : 'none';
}
_speakStatus() { // 上滑:层 → 血量 → 金币 → 面朝 → 威胁(5 段链)
const band = this._hpBand(this.run.hp, this._maxHp());
if (band === 4) vibrate('warning');
this._speak(['voice/st_floor_' + this.run.floor,
'voice/st_hp_' + band,
this._stGoldVoice(),
'voice/st_face_' + DIR_KEY[this.run.face],
'voice/st_threat_' + this._threatKey()]);
}
// ================================ 怪物游走与遭遇(§7.1) ================================
_startMobs() {
for (const m of this._floor.mobs) {
if (MOBS[m.type].boss) continue; // 窟主守楼梯格,不游走
this._mobLoop(m);
}
}
_mobLoop(m) {
const s = this._session;
this._after(1500 + Math.floor(Math.random() * 1000), () => {
if (s !== this._session) return;
if (this.state === 'EXPLORE' && !this.menuOpen) this._mobStep(m);
this._mobLoop(m);
});
}
_mobStep(m) {
const S = this._floor.size;
const opts = [];
for (const d of Object.values(DIR_VEC)) {
const nx = m.x + d.dx, ny = m.y + d.dy;
if (nx >= 0 && ny >= 0 && nx < S && ny < S && this._floor.grid[ny][nx] === 1) opts.push({ x: nx, y: ny });
}
if (!opts.length) return;
const into = opts.find((c) => c.x === this.run.pos.x && c.y === this.run.pos.y);
if (into) { this._encounter(m); return; } // 怪走进你格
const c = opts[Math.floor(Math.random() * opts.length)];
m.x = c.x; m.y = c.y;
this._mobStepSound(m.type, m.x, m.y);
// R2 D1-3:相邻即开战(互视/贴身必扑)——杜绝 R1 live L3 蘑菇「贴身绕圈、
// 堵格不开战」的软封锁;脚步+心跳预警在前,贴身必然接战(报名=它在面前)。
if (Math.abs(m.x - this.run.pos.x) + Math.abs(m.y - this.run.pos.y) === 1) this._encounter(m);
}
/** 脚步报位(§7.1):pan 按相对方位、音量按距离档;背后=中央资产音量减半。 */
_mobStepSound(type, x, y) {
const dx = x - this.run.pos.x, dy = y - this.run.pos.y;
const dist = Math.abs(dx) + Math.abs(dy);
if (dist > 8) return; // 更远不播
const fi = DIR_ORDER.indexOf(this.run.face);
const dirIdx = dx > 0 ? 1 : dx < 0 ? 3 : dy > 0 ? 2 : 0;
const rel = (dirIdx - fi + 4) % 4;
const pan = rel === REL.LEFT ? 'l' : rel === REL.RIGHT ? 'r' : 'c';
let vol = dist <= 2 ? 0.9 : dist <= 4 ? 0.6 : 0.35;
if (rel === REL.BEHIND) vol *= 0.5;
this.audio.play('sfx/mob_step_' + MOBS[type].cls + '_' + pan, { volume: vol });
if (dist <= 2) vibrate('heartbeat'); // 威胁前置预警窗(方法论 L2)
}
/** 遭遇(明雷 §7.1):播报名即=它在面前(自动面对),BATTLE 三档。 */
_encounter(mob) {
this._stopWalk();
const ms = MOBS[mob.type];
this._floor.mobs = this._floor.mobs.filter((m) => m !== mob);
this._session++;
this._clearTimers();
this._walk = null;
this.state = 'BATTLE';
this._battle = {
type: mob.type, spec: ms, hp: mob.hp, maxHp: ms.hp, atk: ms.atk, def: ms.def,
cls: ms.cls, boss: !!ms.boss, mover: !!ms.mover, cell: { x: mob.x, y: mob.y },
bearing: DIR_ORDER.indexOf(this.run.face), moveN: 0, phase2: false,
state: 'intro', windowEnd: 0, parry: false, noTurnSaid: false,
fleeArm: false, noFleeHintSaid: false, // R3 打磨:逃跑长按 ≥900ms(短松=取消+区分提示一句)
drill: !!(this._drill && this._drill.step === 8),
drillMob: !!mob.drill,
};
this.audio.play(ms.boss ? 'bgm/boss' : 'bgm/battle', { fadeMs: 400 });
this.guide.state('battle');
this._speakGuarded(['voice/' + ms.start], () => {
if (this._battle?.drill) {
// 教学战(§5 D8):先由教学怪出手,练格挡
this._speakGuarded(['voice/tut_parry'], () => this._enemyTurn(), 8000);
} else {
this._playerTurn();
}
}, 10000);
}
// ================================ 战斗(§7) ================================
_battleRel() {
return (this._battle.bearing - DIR_ORDER.indexOf(this.run.face) + 4) % 4;
}
_playerTurn() {
const b = this._battle;
if (!b) return;
b.state = 'player';
b.parry = false;
b.fleeArm = false; // 新回合作废未满时的逃跑长按(R3 打磨)
this._progressTrack(); // 战斗回合推进 = 无进展轨重置(R3 E0-1:战斗本身是内容)
this.guide.turn({ haptic: true }); // turn earcon + confirm 震(§10.2)
this.guide.watchdog({
arm: true, at: [15000, 30000], say: ['gd_turn', null],
cond: () => this.state === 'BATTLE' && this._battle === b && b.state === 'player' && !this.menuOpen,
onEscalate: (n) => { // R2 D1-6:15s 无操作播合成回执音(standard 档没有 gd_turn 语音也有"还到你"的确认)
if (n === 1 && this.state === 'BATTLE' && !this.menuOpen) this.audio.play('sfx/turn_tick', { volume: 0.7 });
},
});
}
_onBattleGesture(g) {
const b = this._battle;
if (!b) return;
if (b.state === 'player') {
if (g === 'tap') this._swing();
else if (g === 'doubletap') vibrate('error'); // 窗口外双击:error 震不生效(§7.2)
else if (g === 'left') this._battleTurnG(-1);
else if (g === 'right') this._battleTurnG(1);
else if (g === 'longpress') this._fleeArm(); // R3 打磨:先武装,按满 900ms 才真逃
else if (g === 'up') this._speakBattleStatus();
else if (g === 'down') this._openMenu();
} else if (b.state === 'telegraph') {
if (g === 'doubletap' && performance.now() <= b.windowEnd) {
b.parry = true; // 面对与否在结算时按实际方位判(§7.2);
// parries 只在结算判成"面对且格挡成功"时计数(归因真话律:背对双击没挡下)
this.audio.play('sfx/parry');
vibrate('confirm');
} else if (g === 'down') {
this._openMenu(); // E4:战斗任意时刻可暂停——前摇内开菜单 → 冻结剩余窗口
} else {
vibrate('error'); // 窗口内做错手势 / 窗口已过:留在窗口不生效
}
}
// 其他状态(intro/enemyMove/busy):手势忽略
}
_swing() {
const b = this._battle;
b.state = 'busy';
this.audio.play('sfx/atk_swing');
if (this._battleRel() !== REL.FRONT) {
// 挥空(归因真话,§2.2):它不在你面前
this._speakGuarded(['voice/btl_swish'], () => this._enemyTurn(), 6000);
return;
}
this.audio.play('sfx/atk_hit');
const dmg = Math.max(0, this._playerAtk() - b.def);
b.hp -= dmg;
if (b.hp <= 0) { this._victory(); return; }
this._halfCheck(() => this._enemyTurn());
}
_halfCheck(next) {
const b = this._battle;
if (!b.phase2 && b.spec.half && b.hp <= b.maxHp / 2) {
b.phase2 = true;
b.atk += 1; // 半血 ATK+1(§7.2;王 5→6)
this.guide.state('danger');
vibrate('warning');
this._speakGuarded(['voice/' + b.spec.half], next, 8000);
return;
}
next();
}
/** 转身(战斗):只对绕位怪/Boss 有意义;缠斗怪纠错(每场 1 次,§7.2)。 */
_battleTurnG(d) {
const b = this._battle;
if (!b.mover) {
vibrate('error');
if (!b.noTurnSaid) {
b.noTurnSaid = true;
this._speakGuarded(['voice/btl_no_turn'], () => {}, 5000);
}
return;
}
const i = DIR_ORDER.indexOf(this.run.face);
this.run.face = DIR_ORDER[(i + d + 4) % 4];
this.audio.play('sfx/step_turn');
}
_speakBattleStatus() { // §2.2:我方血量档 → 敌方阶段 →(绕位/Boss)方位
const b = this._battle;
const band = this._hpBand(this.run.hp, this._maxHp());
if (band === 4) vibrate('warning');
const ids = ['voice/st_hp_' + band];
if (b.spec.half && b.hp <= b.maxHp / 2) ids.push('voice/' + b.spec.half);
if (b.mover) {
const rel = this._battleRel();
if (rel === REL.BEHIND) ids.push('voice/btl_behind');
else if (rel === REL.LEFT) ids.push('voice/st_threat_left');
else if (rel === REL.RIGHT) ids.push('voice/st_threat_right');
// 正面:脚步/前摇音已报位,不占台词预算
}
this._speak(ids);
}
/** R3 打磨:逃跑长按武装(live P3:长按三义在「相邻扑击」时代误逃率升高——
* 走位中被扑→下一步长按=瞬间逃跑挨打,实录 flee×7+扫图误逃×3)。探索态
* 450ms 走步不变;战斗态需按满 FLEE_HOLD_MS(900ms)才真正尝试逃跑。 */
_fleeArm() {
const b = this._battle;
if (!b || b.state !== 'player' || b.fleeArm) return;
b.fleeArm = true;
const s = this._session;
this._after(FLEE_HOLD_MS - LONGPRESS_MS, () => { // longpress 事件已在 450ms 到手,再等差值
if (s !== this._session || this._battle !== b || !b.fleeArm || b.state !== 'player' || this.menuOpen) return;
b.fleeArm = false;
this._flee();
});
}
/** R3 打磨:长按未满 900ms 松手 = 取消逃跑(探索走步停走不受影响);
* 每场战斗播一次区分提示(教学 D 系补课:想逃就按住别松手)。 */
_battleFleeCancel() {
const b = this._battle;
if (!b || !b.fleeArm) return;
b.fleeArm = false;
if (this.state !== 'BATTLE' || b.state !== 'player' || this.menuOpen) return;
vibrate('error');
if (!b.noFleeHintSaid) {
b.noFleeHintSaid = true;
this._speakGuarded(['voice/tut_flee_hold'], () => {}, 5000);
}
}
/** 长按=尝试逃跑(§7.3):普通/绕位必成挨一下;Boss 拒绝。 */
_flee() {
const b = this._battle;
if (b.boss) {
vibrate('error');
this._speakGuarded(['voice/btl_flee_boss'], () => this._playerTurn(), 5000);
return;
}
b.state = 'busy';
this.run.flees++;
let dmg = Math.max(0, b.atk - this._armor()); // 撤退挨一下:真实账目,无格挡
if (dmg > 0) {
if (this._applyVeil()) dmg = Math.floor(dmg / 2);
this.run.hp -= dmg;
this.audio.play('sfx/hurt');
vibrate('hit');
}
this._lastHit = { type: b.type, boss: b.boss, behind: false, parried: false, unparried: true, flee: true };
if (this.run.hp <= 0) { this._die(); return; }
const afterFlee = () => {
this._battle = null;
const restored = { type: b.type, x: b.cell.x, y: b.cell.y, hp: b.hp, maxHp: b.maxHp, drill: b.drillMob };
this._floor.mobs.push(restored); // 怪留原格
if (this._drill?.bat) this._drill.bat = restored;
this._progress();
this._save();
if (this._drill) {
this.state = 'DRILL';
this.audio.play('bgm/explore', { fadeMs: 400 }); // R2 D1-4:教学战逃跑同样回探索曲
if (this._drill.step === 8) this._drillApproach();
return;
}
this._resumeExplore();
};
// 归因真话律:ATK0 怪(L1 蝠)根本没碰到你,「被抓了一下」是假话 → 静默脱战
if (dmg > 0) this._speakGuarded(['voice/btl_flee'], afterFlee, 8000);
else afterFlee();
}
/** 敌方回合:绕位怪每 2 回合移动(脚步报新方位)→ 攻击前摇 1.2s → 结算。 */
_enemyTurn() {
const b = this._battle;
if (!b) return;
b.state = 'enemyMove';
b.parry = false;
b.moveN++;
if (b.mover && b.moveN % 2 === 0) {
const seq = [REL.RIGHT, REL.BEHIND, REL.LEFT]; // 每 2 回合换位(确定性轮换)
const rel = seq[(b.moveN / 2 - 1) % seq.length];
b.bearing = (DIR_ORDER.indexOf(this.run.face) + rel) % 4;
// 脚步报新方位(§7.2 绕位怪先移动):pan 按相对方位,背后=中央资产音量减半
const pan = rel === REL.LEFT ? 'l' : rel === REL.RIGHT ? 'r' : 'c';
const vol = rel === REL.BEHIND ? 0.45 : 0.9;
this.audio.play('sfx/mob_step_' + b.cls + '_' + pan, { volume: vol });
}
this._telegraph();
}
_telegraph() {
const b = this._battle;
b.state = 'telegraph';
b.windowEnd = performance.now() + TEL_WINDOW;
const rel = this._battleRel();
const pan = rel === REL.LEFT ? '_l' : rel === REL.RIGHT ? '_r' : '';
this.audio.play('sfx/tel_' + b.cls + pan);
vibrate('beat'); // "格挡窗口已开"通道标识
const s = this._session;
this._after(TEL_WINDOW, () => {
if (s === this._session && this._battle === b && b.state === 'telegraph') this._enemyResolve();
});
}
_applyVeil() { // 护符:每层首次受击减半(§7.4)
if (this._has('veil') && !this.run.veilUsedFloor) {
this.run.veilUsedFloor = true;
return true;
}
return false;
}
_enemyResolve() {
const b = this._battle;
b.state = 'resolve';
const rel = this._battleRel();
const facing = rel === REL.FRONT;
const behind = rel === REL.BEHIND;
let dmg = Math.max(0, b.atk - this._armor());
const parried = b.parry && facing; // 面对攻击源 ×0.2,未面对 ×1.0(§2.2)
if (parried) this.run.parries++; // 全程格挡数(§11):只计真正挡下的(面对+窗口内)
if (parried) dmg = Math.floor(dmg * 0.2);
if (dmg > 0 && this._applyVeil()) dmg = Math.floor(dmg / 2);
this.run.hp -= dmg;
this._lastHit = { type: b.type, boss: b.boss, behind, parried, unparried: !parried, flee: false };
if (!parried) {
if (dmg > 0) {
this.audio.play('sfx/hurt');
vibrate('hit');
}
}
if (this.run.hp <= 0) { this._die(); return; }
if (b.drill) { // 教学战(§5 D8):格挡成/败都继续 → 攻击教学
const ids = [parried ? 'voice/tut_parry_ok' : 'voice/tut_parry_miss', 'voice/tut_attack'];
this._speakGuarded(ids, () => this._playerTurn(), 12000);
return;
}
this._dangerCheck();
const drank = this._autoPotion();
const ids = [];
if (parried) ids.push('voice/btl_parried'); // 格挡成功(减伤 80%,§9.3 B 组 trigger)
if (behind) ids.push('voice/btl_behind'); // 背后挨刀归因真话(§7.2)
if (drank) ids.push('voice/st_hp_' + this._hpBand(this.run.hp, this._maxHp()));
if (ids.length) this._speakGuarded(ids, () => this._playerTurn(), 8000);
else this._playerTurn();
}
_dangerCheck() { // danger 签名:每层首次 ≤25% + Boss 半血(§10.1)
if (!this._dangerFloorDone && this._hpBand(this.run.hp, this._maxHp()) === 4) {
this._dangerFloorDone = true;
this.guide.state('danger');
vibrate('warning');
}
}
_autoPotion() { // HP≤25% 且腰带有力 → 自动喝(§7.2)
if (this.run.hp > 0 && this._hpBand(this.run.hp, this._maxHp()) === 4 && this.run.potion > 0) {
this.run.potion = 0;
this.run.hp = Math.min(this._maxHp(), this.run.hp + Math.ceil(this._maxHp() * 0.4));
this.audio.play('sfx/potion');
vibrate('pickup');
return true;
}
return false;
}
_victory() {
const b = this._battle;
this.audio.play('sfx/enemy_die');
vibrate('success');
this.run.killsRun++;
this.meta.kills++;
const atkBefore = this.run.atkLv;
if (this.run.killsRun % 4 === 0 && this.run.atkLv < 4) this.run.atkLv++;
const justLeveled = this.run.atkLv > atkBefore;
this.meta.gold += b.spec.gold; // 金币实时入元进度
this.run.gold += b.spec.gold;
if (b.spec.gold > 0) this.audio.play('sfx/coin');
if (b.boss) this.run.cleared.push(this.run.floor);
this._progress();
this._save();
if (b.type === 'king') { this._settleWin(); return; } // 终 Boss = 通关
const lootBand = b.spec.gold < 6 ? 1 : b.spec.gold < 20 ? 2 : 3;
const ids = b.drill ? ['voice/tut_kill', 'voice/btl_loot_' + lootBand] : ['voice/btl_kill', 'voice/btl_loot_' + lootBand];
if (justLeveled) ids.push('voice/btl_lvup');
this._speakGuarded(ids, () => {
this._battle = null;
if (this._drill && this._drill.step === 8) {
// R2 D1-4:教学战胜利回教学也必须切回探索曲(R1 转写 t=35.8 起战斗鼓点
// 泡全程的根因之一——bgm/explore 此前只在菜单关闭路径恢复)
this.state = 'DRILL';
this.audio.play('bgm/explore', { fadeMs: 400 });
this._drillAdvance(9);
return;
}
if (this._drill) { // D9 途中遇怪等:战后回教学态继续(否则状态卡在探索、drillDone 丢失)
this.state = 'DRILL';
this.audio.play('bgm/explore', { fadeMs: 400 });
this._drillWatchdog(this._drill.step);
return;
}
this._resumeExplore();
}, 12000);
}
// ================================ 死亡 / 通关结算(§11/§12.7) ================================
_buyUnlocks() {
const bought = [];
for (const l of LEGACIES) {
if (this.meta.unlocks.includes(l.id)) continue;
if (this.meta.gold >= l.price) {
this.meta.gold -= l.price;
this.meta.unlocks.push(l.id);
bought.push(l.id);
} else break; // 按价格升序,买不起即止
}
return bought;
}
_die() {
const info = this._lastHit || { type: '?', boss: false, behind: false, unparried: true, parried: false };
// 归因真话律(§11/§12 失败即不通过项):句必须与真实死因对应
const cause = info.behind ? 3 : (info.boss && info.unparried ? 2 : 1);
const adv = info.behind ? 3 : (this.run.parries === 0 ? 1 : (this.run.flees === 0 ? 2 : 1));
this._deathInfo = { cause, adv, lastHit: { ...info }, parries: this.run.parries, flees: this.run.flees };
this._battle = null;
this._stopWalk();
this.state = 'SETTLE';
this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; // H1:吞掉在途手势(见常量注)
this._session++;
this._clearTimers();
this.audio.setRateBoostEnabled(true); // E3:结算恢复
this.audio.play('bgm/menu', { volume: 0.5 });
this.guide.state('settle');
vibrate('hit');
this.meta.bestFloor = Math.max(this.meta.bestFloor, this.run.floor);
this.meta.runs++;
const newUnlocks = this._buyUnlocks();
this.meta.pending.push(...newUnlocks); // 下一局开场播报(§7.4)
const floorN = this.run.floor;
// R2 D1-5:金币档与 TITLE 同一分档函数、同一本账(meta.gold,遗产购置后)。
// R1 live:SETTLE 用 run 金币自设阈值报 meta_gold_3,TITLE 用 _goldBand(meta.gold)
// 报 meta_gold_2——同一句 id 两套口径,「这趟」的账对不上。
// R2 D1-5:金币档与 TITLE 同一分档函数、同一本账(meta.gold,遗产购置后);
// R3 E1-2:band1(0<金<50)再换成 gold_some 真话句(经 _metaGoldVoice 统一出口)。
// R1 live:SETTLE 用 run 金币自设阈值报 meta_gold_3,TITLE 用 _goldBand(meta.gold)
// 报 meta_gold_2——同一句 id 两套口径,「这趟」的账对不上。
this.run = null; // 死亡清 run 不清 meta
this._save();
this._settleChain = ['voice/die_intro', 'voice/die_cause_' + cause, 'voice/die_adv_' + adv,
'voice/st_floor_' + floorN, this._metaGoldVoice(), 'voice/settle_stronger', 'voice/settle_again'];
this._speakGuarded(this._settleChain, () => {}, 30000);
}
_settleWin() {
this._battle = null;
this._stopWalk();
this.state = 'SETTLE';
this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; // H1:同 _die(在途 tap 会误离开结算)
this._session++;
this._clearTimers();
this.audio.setRateBoostEnabled(true);
this.audio.play('bgm/menu', { volume: 0.5 });
this.guide.state('settle');
vibrate('success');
this.meta.bestFloor = Math.max(this.meta.bestFloor, 7);
this.meta.runs++;
const bought = this._buyUnlocks();
const ids = ['voice/settle_win_1', 'voice/settle_win_2', this._metaGoldVoice()]; // R2 D1-5/R3 E1-2:与 TITLE 同函数同账本
if (bought.length) for (const u of bought) ids.push('voice/unlock_' + u);
ids.push('voice/settle_again');
this.run = null;
this._save();
this._settleChain = ids; // SETTLE 双击=重听结算链(§2.3)
this._speakGuarded(ids, () => {}, 30000);
}
_onSettleGesture(g) {
if (g === 'tap') {
this._enterTitle();
} else if (g === 'doubletap') {
this._speak(this._settleChain && this._settleChain.length
? this._settleChain : ['voice/settle_again']); // 重听结算链(重听入口)
} else if (g === 'up') {
this._speak(this._titleUpChain()); // 重听元进度段
} else if (g === 'down') {
this._openMenu();
} else {
this.guide.error({ voice: 'gd_invalid', onceKey: 'dungeon_invalid' });
}
}
// ================================ 下潜(§4.2 DESCEND) ================================
_descend(fromDrill) {
this.state = 'DESCEND';
this._session++;
this._clearTimers();
this._stopWalk();
this.audio.play('sfx/stairs_down');
this._speakGuarded(['voice/evt_descend'], () => {
const next = this.run.floor + 1;
this._enterFloor(next);
if (fromDrill) {
this.meta.drillDone = true;
this._drill = null;
}
this._save();
const ids = fromDrill ? ['voice/tut_done', 'voice/st_floor_' + next] : ['voice/st_floor_' + next];
this._speakGuarded(ids, () => this._resumeExplore(), 8000);
}, 8000);
}
// ================================ 演练教学 DRILL(§5) ================================
_enterDrill() {
this.state = 'DRILL';
this._session++;
this._clearTimers();
this.audio.play('bgm/explore');
this.audio.setRateBoostEnabled(false); // E3 豁免(教学=对局)
this.guide.state('explore');
this._drill = {
step: -1, fails: 0, upDone: false, menuDone: false, turnedL: false, turnedR: false,
bat: null, stairsSpoken: false,
};
this._stuck.lastAct = performance.now();
this._progressTrack(); // R3 E0-2:教学态无进展轨从零起算
this._stuckArm(); // 教学态 5s 轮询(无进展轨接管=滚出教学)
this._drillEnter(0);
}
_drillWatchdog(step) {
this.guide.watchdog({
arm: true, at: [8000], say: [null], // 教学句不重播:纯 nudge(§10.2)
cond: () => this.state === 'DRILL' && this._drill?.step === step,
});
}
_drillEnter(step) {
const d = this._drill;
d.step = step;
d.fails = 0;
this._progressTrack(); // 步骤推进 = 无进展轨重置(R3 E0-2:做对了就不会被接管)
if (step === 0) {
this._speakGuarded(['voice/intro_1', 'voice/intro_2', 'voice/tut_d0'], () => this._drillWatchdog(0), 20000);
} else if (step === 1) {
// D1 首次脉冲等待:旁白表为「—」,仍挂教学看门狗(§10.2 每步等待 [8s] 纯 nudge)
this._drillWatchdog(1);
} else if (step === 2) {
this._speakGuarded(['voice/tut_front_rule'], () => this._drillWatchdog(2), 8000);
} else if (step === 3) {
this._speakGuarded(['voice/tut_side_rule'], () => this._drillWatchdog(3), 8000);
} else if (step === 4) {
// 自动面向东侧走道(走道 2 格 + 撞边界),保证"按住走 ≥2 格"可达成
while (this.run.face !== 'E') this._turn(1);
this._speakGuarded(['voice/tut_walk'], () => this._drillWatchdog(4), 8000);
} else if (step === 5) {
this._speakGuarded(['voice/tut_turn'], () => this._drillWatchdog(5), 8000);
} else if (step === 6) {
this._speakGuarded(['voice/tut_replay'], () => this._drillWatchdog(6), 8000);
} else if (step === 7) {
this._speakGuarded(['voice/tut_status', 'voice/tut_menu'], () => this._drillWatchdog(7), 12000);
} else if (step === 8) {
this._drillEnter8();
} else if (step === 9) {
this._speakGuarded(['voice/tut_stairs'], () => this._drillWatchdog9(), 8000);
}
}
/** D9 看门狗(R2 D1-2):8s nudge、30s 升级播楼梯暗示(铁器回声提示)。
* R1 live:D9 是唯一要求自行寻路的步骤,静置 72s 零事件零兜底——它是
* 教学链里唯一没有救援出口的一步。 */
_drillWatchdog9() {
this.guide.watchdog({
arm: true, at: [8000, 30000], say: [null, 'stair_hint_a'],
cond: () => this.state === 'DRILL' && this._drill?.step === 9,
});
}
_drillEnter8() {
const d = this._drill;
const bat = this._floor.mobs.find((m) => m.drill) || this._floor.mobs.find((m) => m.type === 'bat');
if (!bat) { this._drillAdvance(9); return; } // 边界:门怪已亡(乱走触发过)→ 自动过
d.bat = bat;
while (this.run.face !== this._dominantDirTo(bat.x, bat.y)) this._turn(1); // 遭遇自动面对语义
this._speakGuarded(['voice/tut_mob'], () => {
this._drillWatchdog(8);
this._drillApproach(); // 兜底:教学怪主动扑来,绝不等死
}, 8000);
}
_dominantDirTo(tx, ty) {
const dx = tx - this.run.pos.x, dy = ty - this.run.pos.y;
if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'E' : 'W';
return dy >= 0 ? 'S' : 'N';
}
_stepTowards(m, target) {
const S = this._floor.size;
const cand = [];
for (const dd of Object.values(DIR_VEC)) {
const nx = m.x + dd.dx, ny = m.y + dd.dy;
if (nx < 0 || ny < 0 || nx >= S || ny >= S || this._floor.grid[ny][nx] !== 1) continue;
cand.push({ x: nx, y: ny, d: Math.abs(nx - target.x) + Math.abs(ny - target.y) });
}
cand.sort((a, b) => a.d - b.d);
return cand[0] || null;
}
_drillApproach() { // 教学怪每 1.2s 向玩家逼近一步,走进玩家格即触发战斗
const s = this._session;
this._after(1200, () => {
if (s !== this._session) return;
const d = this._drill;
if (!d || d.step !== 8 || this.state !== 'DRILL' || this.menuOpen) return;
const bat = d.bat;
if (!bat || !this._floor.mobs.includes(bat)) return;
const step = this._stepTowards(bat, this.run.pos);
if (step) {
if (step.x === this.run.pos.x && step.y === this.run.pos.y) { this._encounter(bat); return; }
bat.x = step.x; bat.y = step.y;
this._mobStepSound('bat', bat.x, bat.y);
}
this._drillApproach();
});
}
_drillWrong() {
const d = this._drill;
if (!d) return;
d.fails++;
vibrate('error');
if (d.fails >= 2) this._drillPass(); // 做错 ≤2 次后自动演示通过,不卡流程(§5)
}
_drillPass() {
const d = this._drill;
if (!d) return;
d.fails = 0;
this._drillAdvance(d.step + 1);
}
_drillAdvance(step) {
if (step > 9) { this._drillDone(); return; }
this._drillEnter(step);
}
_drillDone() { // 兜底:D9 未走楼梯而步数耗尽(极少见)——按跳过处理
this.meta.drillDone = true;
this._drill = null;
this.guide.disarm(); // R2 D0-1:教学收尾注销全部教学再调度器(看门狗等)
this._speakGuarded(['voice/tut_skip'], () => this._resumeExplore(), 8000);
}
_drillSkip() { // 下滑跳过教学(§5):标志存进度,二周目不再进 DRILL
this.meta.drillDone = true;
this._drill = null;
this._stopWalk();
this._save();
this.guide.disarm(); // R2 D0-1:同上,收尾即注销教学态挂件
this._speakGuarded(['voice/tut_skip'], () => this._resumeExplore(), 8000);
}
_onDrillGesture(g) {
const d = this._drill;
if (!d) { this._onExploreGesture(g); return; }
// D7 的下滑是"开菜单"教学动作本身;其余步骤下滑=跳过教学(§5)
if (g === 'down' && d.step !== 7) { this._drillSkip(); return; }
if (g === 'longpressend') return;
switch (d.step) {
case 0:
case 2:
case 3:
if (g === 'tap') this._drillPass();
else this._drillWrong();
break;
case 1: // 首次脉冲(FTUE 首输入点,≈t+15s)
if (g === 'tap') {
this._pulse();
// 反馈句与下一步指令句串行(R2 D0-1:同 tick 双 play 会在引擎
// _load 竞态下互相顶号——反馈句并入推进链,播完或 8s 兜底再进下一步)
this._speakGuarded(['voice/tut_echo_come'], () => this._drillPass(), 8000);
} else this._drillWrong();
break;
case 4: // 长按走(_walkStep 钩子里判定通过)
if (g !== 'longpress') this._drillWrong();
else this._startWalk();
break;
case 5: // 左滑+右滑各 1
if (g === 'left') { d.turnedL = true; this._turn(-1); }
else if (g === 'right') { d.turnedR = true; this._turn(1); }
else { this._drillWrong(); break; }
if (d.turnedL && d.turnedR) this._drillPass();
break;
case 6: // 双击重发(免费)
if (g === 'doubletap') {
this._pulse();
// 同 D1:反馈句并入推进链,绝不与下一步旁白同 tick 双 play(D0-1)
this._speakGuarded(['voice/tut_echo_come'], () => this._drillPass(), 8000);
} else this._drillWrong();
break;
case 7: // 上滑状态 + 下滑开菜单再关(下滑已被跳过占用,关菜单用菜单内下滑)
if (g === 'up') { d.upDone = true; this._speakStatus(); }
else if (g === 'down') { this._openMenu(); break; }
else { this._drillWrong(); break; }
if (d.upDone && d.menuDone) this._drillPass();
break;
case 8: // 走向教学怪触发战斗(战斗内流程见 _encounter/_victory)
if (g === 'longpress') this._startWalk();
else if (g === 'tap') this._pulse();
else if (g === 'up') this._speakStatus(); // 状态键 D7 已教:教学后可用(§4.3)
else this._drillWrong();
break;
case 9: // 脉冲听铁器回声 → 自行走到楼梯 → 踏上后单击下潜
if (g === 'tap') {
if (this._onStairs && this._stairsSpokenFloor === this.run.floor) this._descend(true);
else this._pulse();
} else if (g === 'doubletap') this._pulse();
else if (g === 'left') this._turn(-1);
else if (g === 'right') this._turn(1);
else if (g === 'up') this._speakStatus();
else if (g === 'longpress') this._startWalk();
// R2 D1-2:D9 每次操作后重挂看门狗(onGesture 的 disarm 会清掉它,
// 而 D9 的脉冲/转身/走位不推进步骤——不重挂等于看门狗只在静置时活 8s)
if (this.state === 'DRILL' && this._drill?.step === 9) this._drillWatchdog9();
break;
default:
break;
}
}
// ================================ 模态菜单(§2.4/§5.2 全球约定) ================================
_openMenu() {
if (this.menuOpen) return;
this.menuFrom = this.state;
this.menuOpen = true;
this._menuItems = (this.state === 'TITLE' || this.state === 'SETTLE') ? TITLE_MENU : MENU_ITEMS;
this._menuIdx = 0;
this._session++;
this._clearTimers();
this._stopWalk();
this._menuPause = null;
if (this.menuFrom === 'BATTLE' && this._battle?.state === 'telegraph') {
this._menuPause = { remain: Math.max(0, this._battle.windowEnd - performance.now()) };
}
this.audio.setRateBoostEnabled(true); // E3:菜单恢复快进
this.audio.play('bgm/menu');
this.guide.state('menu');
this._speak(['voice/sys_menu', 'voice/' + this._menuItems[0]]);
}
_closeMenu() {
this.menuOpen = false;
const from = this.menuFrom;
this.menuFrom = null;
// R2 D1-1:关闭回执。R1 live 证据——打开有 menu 签名+sys_menu,关闭零反馈,
// 盲手玩家只能靠下滑在开/关之间盲赌(「3 次下滑关不掉」实为关了又被下次
// 下滑重开)。关闭补状态签名 earcon:回到哪个状态就报哪个签名。
this.guide.state(from === 'BATTLE' ? 'battle'
: from === 'SETTLE' ? 'settle'
: (from === 'TITLE' ? 'menu' : 'explore'));
if (from === 'BATTLE' && this._battle) {
this.state = 'BATTLE';
this.audio.play(this._battle.boss ? 'bgm/boss' : 'bgm/battle', { fadeMs: 400 });
this.audio.setRateBoostEnabled(false);
if (this._menuPause != null) { // 前摇暂停续播(剩余窗口)
const b = this._battle;
const remain = this._menuPause.remain;
this._menuPause = null;
b.windowEnd = performance.now() + remain;
const s = this._session;
this._after(remain, () => {
if (s === this._session && this._battle === b && b.state === 'telegraph') this._enemyResolve();
});
} else {
this._playerTurn();
}
return;
}
if (from === 'DRILL' && this._drill) {
this.state = 'DRILL';
this.audio.play('bgm/explore');
this.audio.setRateBoostEnabled(false);
this._stuck.lastAct = performance.now();
this._progressTrack(); // 菜单挂起时间不计入无进展(R3 E0-2)
this._stuckArm();
const d = this._drill;
if (d.step === 7) {
d.menuDone = true;
if (d.upDone) this._drillPass();
}
if (d.step === 8) this._drillApproach(); // 菜单期间逼近暂停过 → 恢复重挂
return;
}
this.state = from || 'EXPLORE';
if (from === 'EXPLORE') {
this.audio.play('bgm/explore');
this.audio.setRateBoostEnabled(false);
this._startMobs();
this._stuck.lastAct = performance.now();
this._progressTrack(); // R3 E0-1:菜单挂起时间不计入无进展
this._lastAmbAt = performance.now();
this._stuckArm();
} else {
this.audio.play('bgm/menu'); // TITLE / SETTLE
}
}
_onMenuGesture(g) {
const items = this._menuItems;
if (g === 'tap' || g === 'right') {
this._menuIdx = (this._menuIdx + 1) % items.length; // 单击循环切项(每切播项名)
this.audio.play('voice/' + items[this._menuIdx]);
} else if (g === 'left') {
this._menuIdx = (this._menuIdx + items.length - 1) % items.length;
this.audio.play('voice/' + items[this._menuIdx]);
} else if (g === 'doubletap') {
this._menuExec(items[this._menuIdx]);
} else if (g === 'up') {
this._speak(['voice/help_explore', 'voice/help_battle', 'voice/settings_hint']);
} else if (g === 'down') {
this._closeMenu(); // 下滑=关闭
}
}
_menuExec(item) {
if (item === 'sys_menu_resume') {
this._closeMenu();
} else if (item === 'sys_menu_save') {
this._save();
this._speakGuarded(['voice/sys_saved'], () => this._goHome(), 4000);
} else if (item === 'sys_menu_help') {
this._speak(['voice/help_explore', 'voice/help_battle', 'voice/settings_hint']);
} else if (item === 'sys_menu_restart') {
const seed = this.run ? this.run.seed : null;
this.menuOpen = false;
this.menuFrom = null;
if (seed != null) this._restartRun(seed);
else this._closeMenu();
}
}
// ================================ 卡关三级提示(§10.3) ================================
_progress() { // 进展事件:拾取/战斗胜利/踏上楼梯格/击败窟主/下潜 → 重置双轨分级
this._stuck.shown.clear();
this._stuck.progShown.clear();
this._stuck.nav = false;
this._navLastDir = null;
this._stuck.lastAct = performance.now();
this._stuck.lastProg = this._stuck.lastAct;
}
/** R3 E0-1:无进展轨重置——只由状态推进调用(楼层/格位置/战斗回合/拾取)。
* 任何手势都不重置本轨:连续乱按(无操作轨被喂饱)依旧会升级到人话与接管。 */
_progressTrack() {
this._stuck.lastProg = performance.now();
this._stuck.progShown.clear();
}
_stuckArm() {
if (this._stuckArmed) return;
this._stuckArmed = true;
const s = this._session;
const loop = () => {
if (s !== this._session) { this._stuckArmed = false; return; }
// R3 E0-2:教学态同享无进展轨(乱按困在教学 39 分钟的解药);呼吸层仍仅探索态
if ((this.state === 'EXPLORE' || this.state === 'DRILL') && !this.menuOpen) this._stuckCheck();
if (this.state === 'EXPLORE' && !this.menuOpen) this._ambBreath();
this._after(5000, loop);
};
this._after(5000, loop);
}
/** 探索呼吸层(R2 D0-3②):静默超 20s(无手势、无人声)播一段洞穴环境垫,
* 消灭 R1 转写「教学后 350s 零人声零事件」的真空体感。游戏级定时器,
* guide=off 契约不受影响(off 只静向导层;环境垫是内容层)。 */
_ambBreath() {
const now = performance.now();
if (now - this._lastAmbAt < AMB_AFTER_MS) return;
if (now - this._stuck.lastAct < AMB_AFTER_MS) return; // 玩家仍在操作 = 不算静默
if (this.audio.isVoicePlaying()) return; // 有人声就不垫
this._lastAmbAt = now;
this.audio.play('sfx/amb_cave', { volume: AMB_VOLUME });
}
_stuckCheck() {
this._stuckFlush();
const now = performance.now();
const idle = now - this._stuck.lastAct;
if (this._stuck.nav) {
// 导航模式重复带路:与无操作轨无关(R3 E0-1:乱按期间也照常重报)
if (now - this._stuck.lastFire >= NAV_REPEAT_MS) this._stuckFire(3);
return;
}
if (this._stuck.pending.length) return; // 待播中:等声道空出,不重复入队
// R3 E0-1 无进展轨(先查,阈值更紧):只由状态推进重置——层级/格位置/战斗
// 回合/拾取。30s earcon → 60s 短句(stair_hint_a 类)→ 90s 导航接管。
const noprogress = now - this._stuck.lastProg;
if (noprogress >= PROG_LEVELS[2] && !this._stuck.progShown.has('L3')) { this._stuckFire(3, true); return; }
if (noprogress >= PROG_LEVELS[1] && !this._stuck.progShown.has('L2')) { this._stuckFire(2, true); return; }
if (noprogress >= PROG_LEVELS[0] && !this._stuck.progShown.has('L1')) { this._stuckFire(1, true); return; }
// §10.3 无操作轨(现状口径:任何有效手势重置;仅探索态——教学态的静止兜底
// 是 §10.2 教学看门狗 + 上面的无进展轨,避免教学期播错内容的指路句)
if (this.state !== 'EXPLORE') return;
if (idle >= STUCK_LEVELS[2] && !this._stuck.shown.has('L3')) this._stuckFire(3);
else if (idle >= STUCK_LEVELS[1] && !this._stuck.shown.has('L2')) this._stuckFire(2);
else if (idle >= STUCK_LEVELS[0] && !this._stuck.shown.has('L1')) this._stuckFire(1);
}
/** 卡关 L1 短句选句:窟主未清/终局层 → 「大东西守着」;否则 → 铁器暗示。 */
_stairHintId() {
const spec = FLOOR_SPECS[this.run.floor];
const bossAlive = (spec.boss && !this.run.cleared.includes(this.run.floor)) || !!spec.final;
return bossAlive ? 'voice/stair_hint_b' : 'voice/stair_hint_a';
}
_stuckStairDir() { // 楼梯(终局层=王座)绝对方位(与上滑面朝句同坐标系,自洽)
const st = this._floor.stairs || this._floor.throne;
if (!st) return 'e';
const dx = st.x - this.run.pos.x, dy = st.y - this.run.pos.y;
if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'e' : 'w';
return dy >= 0 ? 's' : 'n';
}
/**
* 卡关升级发射。fromProg=false:无操作轨(§10.3,60/120/180s:短句→明示→带路);
* fromProg=true:R3 E0-1 无进展轨(30/60/90s:earcon → 短句 → 接管)——
* 探索态接管=导航模式(最短路首步方向+楼梯指向);教学态接管=滚出教学
* (E0-2:恢复 R1 乱按局 ~75s 即出教学的口径,压进 90-120s)。
*/
_stuckFire(level, fromProg = false) {
if (fromProg && this.state === 'DRILL' && level === 2 && this._drill?.step !== 9) {
// 非寻路步骤无方向话可给(教学句不重播,§10.2):短句级跳过登记,等 90s 接管
this._stuck.progShown.add('L2');
return;
}
this._stuck.lastFire = performance.now();
vibrate('warning');
if (fromProg && level === 1) {
// 第 1 级 earcon:不占台词通道、不排队(语音在播也照发,sfx 不顶人声)
this._stuck.progShown.add('L1');
this.audio.play('sfx/turn_tick', { volume: 0.7 }); // 复用 R2 D1-6 合成回执音当注意 earcon
return;
}
let ids = [];
if (fromProg) {
if (this.state === 'DRILL') {
if (level === 2) {
// 教学句不重播(§10.2);唯 D9(自行寻楼梯)有现成方向话可给
if (this._drill?.step === 9) ids = ['voice/stair_hint_a'];
} else {
this._drillSkip(); // 90s 零进展:教学接管=自动跳过(tut_skip → 探索态)
return;
}
} else if (level === 2) {
ids = [this._stairHintId()];
} else {
const st = this._floor.stairs || this._floor.throne;
if (st && st.x === this.run.pos.x && st.y === this.run.pos.y) ids = ['voice/nav_done'];
else ids = this._navRouteVoices();
}
} else if (level === 1) {
ids = [this._stairHintId()];
} else if (level === 2) {
ids = ['voice/stair_dir_' + this._stuckStairDir()];
} else {
// 终局层目标是王座(stairs 为 null 时回退 throne,与 L2 明示同口径)
const st = this._floor.stairs || this._floor.throne;
if (st && st.x === this.run.pos.x && st.y === this.run.pos.y) {
ids = ['voice/nav_done'];
} else {
ids = this._navRouteVoices();
}
}
if (!ids.length) return; // 无话可播(如目标不可达的退化边):不入队不登记,下轮再查
if (this.audio.isVoicePlaying()) {
this._stuck.pending.push({ level, ids, fromProg }); // 语音在播 → 入待播队列(escape round2 礼仪)
// 未播级别不计入 shown:玩家操作作废后,下段静默期可重新触发(§10.3)
} else {
this._stuckSpoken(level, ids, fromProg);
}
}
_stuckSpoken(level, ids, fromProg = false) { // 真正播出才算"已提示"(入队被作废的不算,§10.3)
if (!ids.length) return;
if (level === 3) {
if (ids[0] === 'voice/nav_done') { this._progress(); return; }
this._stuck.nav = true; // L3 带路:进入导航模式,每步播方向,抵达播 nav_done
this._stuck.shown.add('L3');
this._stuck.progShown.add('L3');
} else {
// 短句两轨内容同级(无操作轨 L1 = 无进展轨 L2):交叉登记防重复播;
// 明示级(stair_dir)只由无操作轨 L2 兑现登记,无进展轨短句不得冒领
this._stuck.shown.add((level === 2 && !fromProg) ? 'L2' : 'L1');
this._stuck.progShown.add('L2'); // 短句已给:无进展轨的短句级不再重复
}
this._speak(ids);
}
/**
* 带路第一步方向(R2 D1-3 导航目标格校验):沿最短路(从目标反向 BFS)的
* 第一步给方向,不再按直线差值指墙(R1 live:nav_n 咬定北边而北边实测撞墙)。
* @returns {'N'|'E'|'S'|'W'|null} null = 不可达/已抵达
*/
_navFirstStepDir(target) {
if (!target) return null;
if (target.x === this.run.pos.x && target.y === this.run.pos.y) return null;
const dist = this._bfs(this._floor.grid, target);
const d0 = dist[this.run.pos.y][this.run.pos.x];
if (d0 < 0) return null;
const S = this._floor.size;
for (const [dir, dv] of Object.entries(DIR_VEC)) {
const nx = this.run.pos.x + dv.dx, ny = this.run.pos.y + dv.dy;
if (nx < 0 || ny < 0 || nx >= S || ny >= S || this._floor.grid[ny][nx] !== 1) continue;
if (dist[ny][nx] === d0 - 1) return dir;
}
return null;
}
_navRouteVoices() { // L3 触发时的带路(R2:只报最短路第一步,后续每步校正)
const st = this._floor.stairs || this._floor.throne;
const dir = this._navFirstStepDir(st);
if (!dir) return [];
this._navLastDir = dir; // 首报计入节流:步行第一步同向不再重复
this._navLastAt = performance.now();
return ['voice/nav_' + DIR_KEY[dir]];
}
_navSpeakStep() { // 导航模式:按最短路逐步校正方向;抵达播 nav_done(终局层指向王座)
const st = this._floor.stairs || this._floor.throne;
if (!st) return;
if (st.x === this.run.pos.x && st.y === this.run.pos.y) {
this._speak(['voice/nav_done']);
this._progress();
return;
}
const dir = this._navFirstStepDir(st);
if (!dir) return;
const now = performance.now();
// R2:同向短窗不重复(导航句既是反馈也是保险丝计数——500ms/步连报同向
// 会撞引擎风暴熔断,且对玩家是噪声;改向立即播,同向 ≥1.5s 提醒一次)
if (dir === this._navLastDir && now - this._navLastAt < NAV_MIN_GAP_MS) return;
this._navLastDir = dir;
this._navLastAt = now;
this._speak(['voice/nav_' + DIR_KEY[dir]]);
}
_stuckFlush() {
if (!this._stuck.pending.length) return;
if (this.audio.isVoicePlaying()) return;
const { level, ids, fromProg } = this._stuck.pending.shift();
this._stuckSpoken(level, ids, fromProg);
}
_stuckDiscardPending() { // 玩家操作即作废未播级别(§10.3)
this._stuck.pending.length = 0;
}
// ================================ 向导看门狗(§10.2) ================================
_armExploreWatchdog() {
this.guide.watchdog({
arm: true, at: [10000, 25000], say: ['gd_pulse', null],
cond: () => this.state === 'EXPLORE' && !this.menuOpen,
});
}
// ================================ 语音链 / 计时器基础设施 ================================
_speak(ids, onDone) { // 串行链:自然播完才接下一句;被顶掉/打断则整条作废
if (!ids.length) { if (onDone) onDone(); return; }
const s = this._session;
const [head, ...rest] = ids;
this.audio.play(head, {
onComplete: () => {
if (s !== this._session) return;
this._speak(rest, onDone);
},
});
}
_speakGuarded(ids, onDone, guardMs = 10000) { // 播完或超时先到先触发,流程绝不卡死
const s = this._session;
let fired = false;
const fire = () => {
if (fired || s !== this._session) return;
fired = true;
if (onDone) onDone();
};
this._speak(ids, fire);
this._after(guardMs, fire);
}
_after(ms, fn) {
const s = this._session;
const id = setTimeout(() => {
this._timers = this._timers.filter((t) => t !== id);
if (s === this._session) fn();
}, ms);
this._timers.push(id);
return id;
}
_clearTimers() {
for (const id of this._timers) clearTimeout(id);
this._timers = [];
this._stuckArmed = false;
}
// ================================ 存档(§11) ================================
_summary() {
const n = this.meta.unlocks.length;
if (!this.run) return `深处归来 · 金币${this.meta.gold} · 遗产${n}/5`;
const band = this._hpBand(this.run.hp, this._maxHp());
return `第${this.run.floor}层 · ${HP_BAND_TEXT[band]} · 金币${this.meta.gold} · 遗产${n}/5`;
}
_save() {
const r = this.run;
const data = {
schema: 1,
game: 'dungeon',
updatedAt: new Date().toISOString(),
summary: this._summary(),
meta: {
gold: this.meta.gold, unlocks: [...this.meta.unlocks], bestFloor: this.meta.bestFloor,
runs: this.meta.runs, kills: this.meta.kills, drillDone: this.meta.drillDone,
pending: [...this.meta.pending],
},
run: r ? {
seed: r.seed, floor: r.floor, pos: [r.pos.x, r.pos.y], face: r.face,
hp: r.hp, atkLv: r.atkLv, weapon: r.weapon, armor: r.armor, potion: r.potion,
veilUsedFloor: r.veilUsedFloor, killsRun: r.killsRun, looted: [...r.looted],
cleared: [...r.cleared], parries: r.parries, flees: r.flees, gold: r.gold,
} : null,
};
try { localStorage.setItem(SAVE_KEY, JSON.stringify(data)); } catch (_) { /* 隐私模式 */ }
try {
const auth = JSON.parse(localStorage.getItem(AUTH_KEY));
if (!auth || !auth.token) return;
fetch('/api/progress', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + auth.token },
body: JSON.stringify({ game: 'dungeon', data }),
}).catch(() => { /* 离线静默 */ });
} catch (_) { /* 静默 */ }
}
_loadSave() {
try {
const data = JSON.parse(localStorage.getItem(SAVE_KEY));
if (!data || data.schema !== 1 || !data.meta) return null;
return data;
} catch (_) {
return null;
}
}
}