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