/** * 《回声地窟》—— 纯音频听声辨位 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; } } }