Files
SoundGame/web/dungeon/game.js
T
xsl d870a75a26 feat(new8/batch1): 两款新 RPG《不眠频率》radio +《回声地窟》dungeon 全套(多agent流水线:策划→资产→开发→测试→玩家R1→修复→R2)
- 程序章程 research/new8/00-program.md(8款概念/回合协议/验收门/进度账)
- 设计:docs/design/15-radio.md+voice-ids(132句,6状态9抉择4结局,电话滤波资产侧带通)/16-dungeon.md+voice-ids(125句,脉冲回声/7层seed生成/三档战斗/元进度遗产)
- 资产:TTS 970文件(GPU flock串行)+ Mureka BGM 8首 $0.24(台账 mureka_log.md)+ 程序合成 sfx(含预pan侧墙/开口、phone_ring铃声、amb_cave呼吸层、turn_tick)+ 终检 check_all_assets 8游戏全绿
- radio:game.js+playtest 221断言(4结局矩阵/pendingChoice盲玩公平/RING两级看门狗/菜单循环/存档schema)
- dungeon:game.js+playtest 1610断言(seed全网格回声一致性/学期零死/格挡80%/死亡归因真话/元进度/菜单冻结/上滑5段状态链)
- 引擎修复:audio.js _playVoice 加载竞态补杀 token(R1真机71分钟语音风暴根因)+ 同id 3s窗口≥5次熔断保险丝 + gesture.js 长按boost事件门(+66行,9套件回归全绿)
- 大厅:新增 radio/dungeon 卡片 + GAMES 数组
- harness:sessions/radio.mjs+dungeon.mjs(主线+压力双剧本)+ transcribe 注册 + voice_text.json 刷新(8游戏704句)+ 13号文档 §20 登记
- R1 存档:4人设评审×2游戏 + 真机试玩报告×2(radio 5/5愿意玩;dungeon 0/4→确诊风暴)+ synthesis×2 + 转写8份
- R2 修复:radio F1-F5(看门狗补挂/rep_low改词重录/真铃声/ASK续玩语义/链末跳过保护)+ dungeon D0-1..D1-6(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
2026-09-02 18:52:37 +08:00

1856 lines
75 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),
* 逃跑长按必成挨一下(真话),Boss 拒绝;每 4 杀 atkLv+1。
* 元进度"遗产"5 项(金币自动购置,死亡不清档):行囊药/体魄/护符/回声强化/武器。
* 死亡归因真话律:die_cause_* / die_adv_* 按 lastHit(击杀者/背刺/未格挡)与
* run.parries 真实选择;卡关三级提示 60/120/180s(不报答案,L3 只带方向)。
*
* 豁免 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 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;
/** 回声编码(§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._lastEcho = null; // 最近一次脉冲回声(双击重发一致性断言用)
this._onStairs = false;
this._stairsSpokenFloor = 0;
this._dangerFloorDone = false;
this._menuPause = null;
this._menuItems = MENU_ITEMS;
this._menuIdx = 0;
this._stuck = { lastAct: 0, shown: 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; }
// ================================ 地窟生成(§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 = ['voice/meta_gold_' + this._goldBand(this.meta.gold)];
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._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(); return; } // 仅停走信号,不承载第二语义
if (this.menuOpen) { this._onMenuGesture(g); 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._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)
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,
'voice/st_gold_' + this._goldBand(this.meta.gold),
'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,
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;
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._flee();
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);
}
/** 长按=尝试逃跑(§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._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 两套口径,「这趟」的账对不上。
const goldBand = this._goldBand(this.meta.gold);
this.run = null; // 死亡清 run 不清 meta
this._save();
this._settleChain = ['voice/die_intro', 'voice/die_cause_' + cause, 'voice/die_adv_' + adv,
'voice/st_floor_' + floorN, 'voice/meta_gold_' + goldBand, 'voice/settle_stronger', 'voice/settle_again'];
this._speakGuarded(this._settleChain, () => {}, 30000);
}
_settleWin() {
this._battle = null;
this._stopWalk();
this.state = 'SETTLE';
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 goldBand = this._goldBand(this.meta.gold); // R2 D1-5:与 TITLE 同函数同账本
const ids = ['voice/settle_win_1', 'voice/settle_win_2', 'voice/meta_gold_' + goldBand];
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._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;
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);
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._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.nav = false;
this._navLastDir = null;
this._stuck.lastAct = performance.now();
}
_stuckArm() {
if (this._stuckArmed) return;
this._stuckArmed = true;
const s = this._session;
const loop = () => {
if (s !== this._session) { this._stuckArmed = false; return; }
if (this.state === 'EXPLORE' && !this.menuOpen) {
this._stuckCheck();
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) {
if (now - this._stuck.lastFire >= NAV_REPEAT_MS) this._stuckFire(3);
return;
}
if (this._stuck.pending.length) 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);
}
_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';
}
_stuckFire(level) {
this._stuck.lastFire = performance.now();
vibrate('warning');
let ids = [];
if (level === 1) {
const spec = FLOOR_SPECS[this.run.floor];
const bossAlive = (spec.boss && !this.run.cleared.includes(this.run.floor)) || !!spec.final;
// 终局层无楼梯(目标是王座上的窟中之王)→ 用「大东西守着」句,不说铁器
ids = [bossAlive ? 'voice/stair_hint_b' : 'voice/stair_hint_a'];
} 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 (this.audio.isVoicePlaying()) {
this._stuck.pending.push({ level, ids }); // 语音在播 → 入待播队列(escape round2 礼仪)
// 未播级别不计入 shown:玩家操作作废后,下段静默期可重新触发(§10.3)
} else {
this._stuckSpoken(level, ids);
}
}
_stuckSpoken(level, ids) { // 真正播出才算"已提示"(入队被作废的不算,§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
} else {
this._stuck.shown.add('L' + level);
}
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 } = this._stuck.pending.shift();
this._stuckSpoken(level, ids);
}
_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;
}
}
}