/** * 《回声地窟》—— 纯音频听声辨位 Roguelike,按 docs/design/26-audio-interaction-playbook.md * 深度重构(设计单 docs/design/26-apply-dungeon.md;玩法正文 docs/design/16-dungeon.md; * 台词 id 见 16-dungeon-voice-ids.md,新句清单 tools/voice_lines_26/dungeon.md)。 * * 交互层(26 号模式,对齐 escape 参考实现): * - 四向滑动 = 面朝相对移动(§4.3 裁定:四向是玩法的游戏,帮助换到长按): * up=前进一格;left/right=向侧走一格并转向该向;down=后退一格(面朝不变)。 * swipeCore——up/down 不按新手阶梯锁,阶梯只对 doubletap 生效。 * - 单击 = 声波脉冲(探索期 floor.enterAction(),玩法声音即状态);楼梯格 = 下潜 * 确认;战斗我的回合 = 挥击;敌方前摇窗内 = 格挡(旧「双击格挡」「双击免冷却 * 重发」废除——连点不绑破坏性/重播操作)。 * - 长按 = 「我在哪」(层[+血 if 伤]+面朝+目标方位,≤20 字);旧「按住前进」废除; * 对局期旁白快进关闭(帮助占用长按)。 * - 移动成功 = 脚步声(silent-ok,内容音即回执);撞墙 = bump + invalid 回执 + * 归因句 wall + 方位真话 wall_dir_*(每方向整局一次,之后只闷响+回执,失败表 §4.9)。 * - 语音链走 floor.speak(BGM 压低跨链);描述播完回 ACTION;等待点(教学步、 * TITLE 询问、战斗回合、结算、菜单)用 WaitPoint 配 full/short/silenceMs/onDemo。 * - 开场按 §4.10 重写:第一句 = critical 指令「黑暗。点一下,听回声。」,砍规则念白。 * - 断点复述(§4.12):退出自动存档;回来 sys_continue_ask → 单击恢复 + 复述链。 * - 孤儿 BUSY 看门狗(_healOrphanBusy,800ms):invalid 解释链播完若无等待点可回, * floor 会停在 BUSY 吞手势——按当前态拉回规范态(§4.1「不存在安静态」硬规则)。 * - 迷茫接管(§4.5 三级):静默 40s 起每 20s 喂信号 + 乱点/连无效;探索态 L1 目标句 * → L2 只放行导航方向(sys_only_*)→ L3 听我做一次(游戏自己走一步); * 教学态 L1 短句 → L2 重教 → L3 游戏替你完成本步;进展事件清零重来。 * - 手势阶梯:D4 拾到金币 = 第一个小目标 → ladder.onGoal();死亡/通关 = 一局结束 * → ladder.onGameDone()(结算链尾挂解锁教学句)。 * * 玩法核心不动:7 层地窟 mulberry32 程序生成(房间→L 走廊→BFS 最远房楼梯→怪/宝 * 安全区→主路径密度)、回声编码(正前 4 档延迟×音量 + 材质尾音 + 侧向预 pan)、 * 战斗三档(缠斗/绕位/Boss,前摇 1.2s 格挡窗 ×0.2/×1.0,半血 ATK+1,逃跑必成挨 * 一下、Boss 拒绝)、遗产经济 5 项、死亡归因真话律(die_cause_N / die_adv_N 按 * lastHit 与真实格挡/逃跑账)、到达楼梯格即报贴身铁器回声、存档 summary 字段。 * * 状态 × 手势矩阵(新手期,完整版见设计单 §4): * | 状态 | 单击 | 四向滑动 | 长按 | 双击(未解锁) | * |--------------|-----------------|-----------------------|-----------|--------------| * | TITLE | 开始/继续 | 右=新周目/上=元进度 | 重听询问 | 按单击 | * | 教学作答 | 按步骤答 | 按步骤走;步骤外 invalid| 帮助 | 按单击 | * | 探索(ACTION) | 脉冲/下潜(silent-ok);冷却 invalid | 移动(脚步)/撞墙(bump+invalid+归因) | 帮助 | 按单击(=再脉冲) | * | 描述在播 | 跳过这句+ok | 打断并移动 | 打断,帮助 | 按单击 | * | 战斗·我的回合 | 挥击 | 左右=转身;下=逃跑;上=状态 | 帮助 | 按单击 | * | 战斗·敌前摇 | 格挡 | 左右=转身 | invalid | 按单击=格挡 | * | DESCEND 演出 | 跳句+ok | 打断(兜底续流程) | 同左 | 按单击 | * | 结算(critical)| busy | busy | busy | busy | * | 结算(等待) | 再探一次 | 上=元进度;左=invalid;下=菜单(阶段2) | 重听结算 | (阶段3)重听 | * | 菜单 | 确认当前项 | 左右=换项;上=帮助 | 重听菜单 | 按单击=确认 | */ import { Game } from '../engine/game.js'; import { Floor } from '../engine/floor.js'; import { Receipts } from '../engine/receipts.js'; import { Confusion } from '../engine/confusion.js'; import { WaitPoint, InvalidHints, markHintSaid, hintSaidRecently } from '../engine/hints.js'; import { GestureLadder } from '../engine/novice.js'; import { vibrate } from '../engine/haptics.js'; // ================================ 常量表 ================================ /** 绝对方位:DIR_ORDER 顺时针(右转 = idx+1,左转 = idx-1)。 */ 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 }; /** 相对方位 → 手势名(四向滑动 = 面朝相对移动,§4.3)。 */ const REL_GESTURE = ['up', 'right', 'down', 'left']; const SWIPE = ['up', 'down', 'left', 'right']; const PULSE_CD = 1200; // 脉冲冷却(16 号 §3) const TEL_WINDOW = 1200; // 敌方攻击前摇 = 格挡窗口(16 号 §5) const DENSITY_STEP = 12; // 主路径内容密度下限:每 ≤12 格必有遭遇 const AMB_AFTER_MS = 20000; // 探索呼吸层:静默超 20s 播环境垫 const AMB_VOLUME = 0.35; const SILENCE_FIRST_MS = 40000; // 迷茫静默信号:40s 首次、之后每 8s(< 10s 窗口, const SILENCE_REPEAT_MS = 8000; // 两记静默可在窗口内累计权重 2 → 纯静默也能触发接管) const DRILL_FALLBACK_MS = 75000;// 教学每步兜底:75s 未完成自动过步(有界,防卡死) /** 回声编码(16 号 §3):正前距离 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; // 材质尾音跟在回声到达后 /** 敌型表(数值不动;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' }, }; /** 层规格(数值不动):尺寸 / 怪物(型×数)/ 宝物 / 窟主 / 终局。 */ 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 }, }; /** 元进度遗产:按价格升序自动购置(金币累计,死亡不清档)。 */ 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' }, ]; /** 菜单(00 §5.4:左右滑换项 + 单击确认;重开 = 破坏性,二次确认)。 */ const TITLE_MENU = ['sys_menu_restart', 'sys_menu_help']; const SETTLE_MENU = ['sys_menu_restart', 'sys_menu_save']; 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', ]; // ================================ 工具 ================================ 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); // ---- 26 号六模块接线(engine/*,见设计单 §3)---- this.receipts = new Receipts(audio); this.floor = new Floor(audio); // 发言权管理器(this._floor 仍是「当前层」数据,勿混淆) this.confusion = new Confusion({ onConfused: (lvl) => this._onConfused(lvl) }); this.invalidHints = new InvalidHints().bind(this.floor); this.ladder = new GestureLadder(); this.audio.setRateBoostEnabled(false); // 长按 = 帮助(核心手势),不触发旁白快进 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; // 当前层生成数据(grid/items/mobs/stairs…) this._battle = null; this._drill = null; this._pulseAt = -1e9; this._lastHit = null; // 归因真话律依据(击杀者/背刺/未格挡) this._lastEcho = null; // 最近一次脉冲回声(一致性断言用) this._onStairs = false; this._stairsSpokenFloor = 0; this._dangerFloorDone = false; this._menuItems = SETTLE_MENU; this._menuIdx = 0; this._menuConfirm = false; // 菜单「重新开始」二次确认 this._askLine = null; // TITLE 询问句(有档 continue_ask / 无档 first_tap) this._restrictDir = null; // 迷茫 L2:探索态只放行的移动手势 this._wallExplained = new Set(); // 撞墙归因:每方向每局一次(原则 7) this._silenceTimer = null; this._lastActAt = 0; // 最近一次玩家操作(呼吸层静默判据) this._lastAmbAt = 0; // 呼吸层上次播放时刻 this._settleChain = []; // 结算链(重听用) this._startWatchdog(); // 孤儿 BUSY 看门狗(§4.1 硬规则:不存在安静态) } /** 管道选项(index.html 读):四向滑动 + 长按都是本作核心手势(§4.3 裁定)。 */ get PIPELINE_OPTS() { return { longpressCore: true, swipeCore: true }; } _defaultMeta() { return { gold: 0, unlocks: [], bestFloor: 0, runs: 0, kills: 0, drillDone: false, pending: [] }; } // ---------------- 派生数值(不动) ---------------- _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; } /** 金币档账目(不动):band1(0<金<50)播 gold_some 真话句——SETTLE/TITLE * (元进度链)共用同一分档与账本(旧上滑状态链已废,st_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,不动) ================================ _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 硬约束优先于随机),内层全走子种子 rng → * 同 seed 两次构造逐格相等。走廊 = 相邻房间两两 L 型连通(链式 = 生成树)。 */ _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 间 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 型走廊链式连通 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); // 东侧走道(教学自由探索余地) carve(3, 5); // 南门:教学怪把门 if (grid[1][3] !== 0) return null; // 约束①:起点正前 2 格内有墙(D0 回声演示) 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 距起点最远房间中心;终局层无楼梯,王座=最远房心 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; // 宝物撒入房间格 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') { // 约束③:教学金币固定在起点房内(D4 拾取体验) 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 金 : { type: it }); } // 怪物:窟主守楼梯格 / 终局王座;普通怪 BFS ≥3 安全区(教学局非门怪 ≥4) 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 }; } /** * 主路径内容密度下限:start→楼梯(终局层=王座)的最短路上每 ≤ DENSITY_STEP 格 * 必有内容遭遇。生成后沿 BFS 距离梯度回溯最短路,最长连续无内容段超限 → 段中点 * 补一只本层已有的普通怪型(结构性密度,不加新怪种)。确定性:只用同一 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._save(); // 退出自动存档(§4.12) this._session++; this._clearTimers(); this._stopSilenceFeed(); this._stopWatchdog(); this.floor.interrupt(); this.audio.stopAll(); } _goHome() { this.stop(); window.location.href = '../'; } // ---------------- 手势总入口(管道收口,返回 'ok'|'hold'|'invalid'|'silent-ok') ---------------- onGesture(g) { if (g === 'longpressend' || g === 'tapcancel') return; // 手势收尾/撤销噪声(直呼保护) this._lastActAt = performance.now(); this._idleHintGiven = false; // 玩家动了:本段静默提示作废 let r; if (this.menuOpen) r = this._onMenuGesture(g); else switch (this.state) { case 'TITLE': r = this._onTitleGesture(g); break; case 'DRILL': r = this._onDrillGesture(g); break; case 'EXPLORE': r = this._onExploreGesture(g); break; case 'BATTLE': r = this._onBattleGesture(g); break; case 'SETTLE': r = this._onSettleGesture(g); break; default: r = 'ok'; break; // DESCEND 演出窗口:链可能已被滑动打断,兜底定时器续流程 } // 探索态收尾:手势处理完且没起新链 → 回 ACTION(环境音即状态,§4.1) if (this.state === 'EXPLORE' && !this.menuOpen && !this.floor.speaking) this.floor.enterAction(); // 教学作答步兜底:手势处理完若不在播、不在 ACTION、等待点又不在(被管道 // settleWaitPoint(true) 摘掉 / 解释链被打断),重新挂回本步等待点——否则 // invalid 解释链播完无人恢复话轮,floor 停在 BUSY 吞掉后续手势。 // (invalid 路径管道随后 settleWaitPoint(false) 会保住它;有效作答路径本步 // 即将推进,挂上再被摘也无害。) if (this.state === 'DRILL' && !this.menuOpen && !this.floor.speaking && this.floor.state !== 'ACTION' && !this.floor.waitPoint && this._drillExpected(this._drill?.step)) { this._drillArm(this._drill.step); } return r; } stateName() { if (this.menuOpen) return 'menu'; if (this.state === 'BATTLE' && this._battle?.state === 'telegraph') return 'tel'; return { TITLE: 'title', DRILL: 'drill', EXPLORE: 'explore', BATTLE: 'battle', SETTLE: 'settle', DESCEND: 'descend' }[this.state] || 'explore'; } /** 无效操作解释句(原则 7:每「状态 × 手势」只解释一次,管道调用)。 */ invalidHint(state, g) { if (state === 'locked') return null; // 四向 + 长按都是核心手势,锁不生效 if (state === 'explore') return g === 'tap' ? 'voice/inv_pulse' : null; if (state === 'title') return g === 'down' ? 'voice/inv_menu_locked' : 'voice/inv_title'; if (state === 'settle') return g === 'down' ? 'voice/inv_menu_locked' : 'voice/inv_settle'; if (state === 'drill') return 'voice/inv_drill'; if (state === 'tel') return 'voice/inv_tel'; if (state === 'menu') return 'voice/menu_help'; return null; } // ---------------- TITLE(§2.3) ---------------- _enterTitle() { this._session++; this._clearTimers(); this._battle = null; this.menuOpen = false; this.menuFrom = null; this.state = 'TITLE'; this.audio.play('bgm/menu'); this.floor.interrupt(); // 第一句让玩家动手(§4.10):无档「点一下屏幕。」;有档 = 断点复述询问(§4.12) this._askLine = this.run ? 'voice/sys_continue_ask' : 'voice/sys_first_tap'; markHintSaid(this._askLine); // 被动提示 30s 台账(避免询问句原样重播) this.floor.speak([this._askLine], { onDone: () => { if (this.state === 'TITLE' && !this.menuOpen) this.floor.yieldTurn(this._titleWp()); }, }); } _titleWp() { return this._wp({ id: 'title', expected: ['tap', 'right'], full: this._askLine, short: 'voice/sys_first_tap_short', silenceMs: [12000, 30000], onDemo: () => this.floor.chime(), }); } _sayTitle(ids) { this.floor.speak(ids, { onDone: () => { if (this.state === 'TITLE' && !this.menuOpen) this.floor.yieldTurn(this._titleWp()); }, }); } _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) { switch (g) { case 'tap': if (this.run) this._resumeRun(); else this._newRunFlow(); return 'ok'; case 'right': this._newRunFlow(); // 重掷新周目(meta 保留) return 'ok'; case 'up': this._sayTitle(this._titleUpChain()); // 我在哪 = 元进度播报 return 'ok'; case 'longpress': this._sayTitle([this._askLine]); // 重听询问句 return 'ok'; case 'doubletap': this._sayTitle(['voice/sys_say_again', this._askLine]); // 阶段 3:重听等待点句 return 'ok'; case 'down': if (this.ladder.has('down')) { this._openMenu(); return 'ok'; } return 'invalid'; // 菜单阶段 2 才开放(00 §5.3) default: return 'invalid'; // left } } // ---------------- 新周目 / 继续(§4.12 断点复述) ---------------- _newRunFlow() { this._session++; this._clearTimers(); this._battle = null; this._drill = null; this.menuOpen = false; this.floor.interrupt(); 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._wallExplained.clear(); 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._sayFlow(ids, () => { this.meta.pending = []; this._save(); if (!this.meta.drillDone) this._enterDrill(); else this._resumeExplore(this._recapChain()); // 新周目直落探索也带局面复述 }, 20000); } _restartRun(seed) { // 菜单「重新开始本周目」:同 seed 重置 this.run = null; this._fixedSeed = seed; this._newRunFlow(); } /** 局面复述(§4.12):层 → 血量 → 面朝 → 楼梯方位(目标句计入 30s 台账,帮助链降档复用)。 */ _recapChain() { const ids = ['voice/st_floor_' + this.run.floor, 'voice/st_hp_' + this._hpBand(this.run.hp, this._maxHp()), 'voice/st_face_' + DIR_KEY[this.run.face]]; const goal = this._goalVoice(); if (goal) { markHintSaid(goal); ids.push(goal); } return ids; } /** 目标句:终局层=王座报名;窟主未清=守梯提醒;否则=楼梯绝对方位。 */ _goalVoice() { if (!this._floor) return null; const spec = FLOOR_SPECS[this.run.floor]; if (spec.final) return 'voice/btl_king'; const target = this._floor.stairs || this._floor.throne; if (!target) return null; if (spec.boss && !this.run.cleared.includes(this.run.floor)) return 'voice/stair_hint_b'; return 'voice/stair_dir_' + DIR_KEY[this._dominantDirTo(target.x, target.y)]; } _resumeRun() { const saved = { pos: { ...this.run.pos }, face: this.run.face, veil: this.run.veilUsedFloor }; this._enterFloor(this.run.floor); // 重放该层 seed 生成 this.run.pos = saved.pos; this.run.face = saved.face; this.run.veilUsedFloor = saved.veil; if (!this.meta.drillDone) { this._enterDrill(); return; } // 教学未完:从头再练(步骤不存档) this._resumeExplore(this._recapChain()); } // ================================ 探索态(ACTION:玩法声音即状态) ================================ _resumeExplore(chain) { this.state = 'EXPLORE'; this._session++; this._clearTimers(); this.menuOpen = false; this.audio.play('bgm/explore'); this.floor.enterAction(); this._startMobs(); this.confusion.resetForWaitPoint(); this._restrictDir = null; this._lastActAt = performance.now(); this._lastAmbAt = performance.now(); this._startSilenceFeed(); this._startAmbLoop(); if (chain && chain.length) this._sayContent(chain); this._save(); } _onExploreGesture(g) { switch (g) { case 'tap': case 'doubletap': { // 双击(阶段 3)= 冷却外再脉冲,不绑「免冷却重播」 if (this._onStairs && this._stairsUsable()) { this._descend(false); return 'ok'; } return this._pulse() ? 'silent-ok' : 'invalid'; // 回声组 = 明显内容音 } case 'up': case 'down': case 'left': case 'right': return this._moveRel(g); case 'longpress': this._longpressHelp(); return 'ok'; default: return 'invalid'; } } _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; } /** * 四向滑动 = 面朝相对移动(§4.3):up=前进;left/right=侧移并转向;down=后退。 * 移动成功 = 脚步声(silent-ok);撞墙 = bump + invalid + 归因 + 方位真话(§4.9)。 */ _moveRel(g) { if (this._restrictDir && g !== this._restrictDir) return 'invalid'; // 迷茫 L2:只放行接管方向 const rel = g === 'up' ? REL.FRONT : g === 'right' ? REL.RIGHT : g === 'down' ? REL.BEHIND : REL.LEFT; const fi = DIR_ORDER.indexOf(this.run.face); const dir = DIR_ORDER[(fi + rel) % 4]; const d = DIR_VEC[dir]; 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._bump(rel); return 'invalid'; } if (rel === REL.RIGHT || rel === REL.LEFT) { this.run.face = dir; // 侧移附带转向 this.audio.play('sfx/step_turn'); } this.run.pos = { x: nx, y: ny }; this._drillAlive(); this.audio.play('sfx/step_own', { volume: 0.7 }); // 内容音即回执 vibrate('swipe'); this._save(); this._arrive(nx, ny); // 怪 → 遭遇;宝物 → 拾取;楼梯 → 到达即报 return 'silent-ok'; } /** 撞墙失败表(§4.9):闷响 + invalid 回执;归因句每方向整局一次(原则 7)。 */ _bump(rel) { this.audio.play('sfx/bump'); vibrate('error'); this.confusion.recordFail(); const K = ['front', 'right', 'back', 'left'][rel]; if (this._wallExplained.has(K)) return; // 之后只给闷响 + invalid 回执 const first = this._wallExplained.size === 0; this._wallExplained.add(K); const ids = first ? ['voice/wall', 'voice/wall_dir_' + K] : ['voice/wall_dir_' + K]; this._sayContent(ids, { kind: 'feedback' }); } /** 踏格判定:怪 → 遭遇;宝物 → 拾取;楼梯 → 到达即报(不占单击通道)。 */ _arrive(nx, ny) { const mob = this._floor.mobs.find((m) => m.x === nx && m.y === ny); if (mob) { this._encounter(mob); return; } const k = key(nx, ny); const item = this._floor.items.get(k); if (item) this._pickup(item, k); this._onStairCell(nx, ny); } /** 声波脉冲(§3,不动):预生成资产 + setTimeout + volume,可被虚拟时钟断言。 * 冷却期返回 null(管道给 invalid 回执 + inv_pulse 解释一次)。 */ _pulse() { const now = performance.now(); if (now - this._pulseAt < PULSE_CD) return null; this._pulseAt = now; this._drillAlive(); 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; } /** 正前扫描:最近反射体决定距离与材质(楼梯=铁梯;身后方向永不进入扫描)。 * 站在楼梯格上脉冲 → 贴身铁器回声。 */ _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' }; } return { dist: S * 2, mat: 'stone' }; } /** * 侧向扫描(side = -1 左 / 1 右):开口(第一格可通行=能走进去)/墙(闷), * 身后不报。开一格就是开口(真话)。 */ _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 组资产音量。 */ _sideVol(s) { if (this._has('echo')) return s.dist <= 1 ? 0.8 : 0.45; return 0.6; } // ---------------- 长按 = 我在哪(原则 3:一句话 ≤20 字) ---------------- /** 玩家主动请求(§4.4):永远给完整链(降档只作用于被动提示);说完计入台账, * 随后的被动提示/接管 L1 自动变短(原则 9)。 */ _longpressHelp(onDone) { if (this.state === 'BATTLE') { this._speakBattleStatus(); return; } if (!this.run) { this._sayTitle([this._askLine || 'voice/sys_first_tap']); return; } if (this.state !== 'EXPLORE' && this.state !== 'DRILL') return; const band = this._hpBand(this.run.hp, this._maxHp()); const ids = ['voice/st_floor_' + this.run.floor]; if (band >= 2) ids.push('voice/st_hp_' + band); // 只有受伤才报血(语言经济) ids.push('voice/st_face_' + DIR_KEY[this.run.face]); if (this._onStairs) { ids.push('voice/tut_stairs_on'); markHintSaid('voice/tut_stairs_on'); } else { const goal = this._goalVoice(); if (goal) { markHintSaid(goal); ids.push(goal); } } this._sayContent(ids, { onDone }); } // ---------------- 拾取(数值/账目不动) ---------------- _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._progress(); this._save(); // D4:拾到金币 = 第一个小目标(ladder 阶段 1)→ 进长按教学。推进走有界定时: // evt_coin 链被乱手势打断也不丢推进(对齐 _sayFlow 语义,步号先守恒)。 if (this._drill && this._drill.step === 4) { this._drillDoneStep(4, () => { this.ladder.onGoal(); this._drillAdvance(5); }, 1600); } this._sayContent(['voice/evt_coin_' + band]); } else if (item.type === 'potion') { if (this.run.potion >= 1) { this._sayContent(['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._progress(); this._save(); this._sayContent(['voice/evt_potion']); } 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._progress(); this._save(); this._sayContent(['voice/evt_weapon']); } 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._progress(); this._save(); this._sayContent(['voice/evt_armor']); } } // ---------------- 楼梯格(到达即报,不占输入通道) ---------------- _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(); // 踏上楼梯格 = 进展事件 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._sayContent(['voice/evt_stairs_lock']); return; } if (this._stairsSpokenFloor !== this.run.floor) { this._stairsSpokenFloor = this.run.floor; markHintSaid('voice/tut_stairs_on'); this._sayContent(['voice/tut_stairs_on']); // 楼梯就在脚下。单击,下潜。 } } // ---------------- 威胁感知(不动) ---------------- _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++; // 前方/身后不报(同回声降维) } return left && right ? 'both' : left ? 'left' : right ? 'right' : 'none'; } // ================================ 怪物游走与遭遇(§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); // 相邻即开战(互视/贴身必扑) 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'); // 威胁前置预警窗 } /** 遭遇(明雷):播报名即=它在面前(遭遇自动面对,16 号 §5)。 */ _encounter(mob) { const ms = MOBS[mob.type]; // 遭遇自动面对:相邻扑来/走进怪格都先把怪转到正面(报名即真话) const dx = mob.x - this.run.pos.x, dy = mob.y - this.run.pos.y; if (dx !== 0 || dy !== 0) { this.run.face = dx > 0 ? 'E' : dx < 0 ? 'W' : dy > 0 ? 'S' : 'N'; } this._floor.mobs = this._floor.mobs.filter((m) => m !== mob); this._session++; this._clearTimers(); 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, drillTaught: false, swishSaid: false, parriedSaid: false, behindSaid: false, wp: null, // 归因句每场一次(原则 7) drill: !!(this._drill && this._drill.step === 6), drillMob: !!mob.drill, }; this.audio.play(ms.boss ? 'bgm/boss' : 'bgm/battle', { fadeMs: 400 }); this._sayFlow(['voice/' + ms.start], () => { if (this._battle?.drill) { // 教学战:先由教学怪出手,练单击格挡 this._sayFlow(['voice/tut_parry_tap'], () => 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; if (!b.wp) { b.wp = this._wp({ id: 'battle', expected: ['tap', 'left', 'right'], full: 'voice/gd_turn', short: 'voice/btl_turn_short', silenceMs: [8000, 15000], onDemo: () => { if (this._battle === b && b.state === 'player') this._swing(); }, // L3:替你挥一拳 }); } this.floor.yieldTurn(b.wp); // 交接提示音 → 等待声 → 三档提示 } _onBattleGesture(g) { const b = this._battle; if (!b) return 'ok'; if (b.state === 'player') { switch (g) { case 'tap': case 'doubletap': this._swing(); return 'silent-ok'; // 挥击音即回执 case 'left': return this._battleTurnG(-1); case 'right': return this._battleTurnG(1); case 'down': this._flee(); // 下滑 = 尝试逃跑(普通怪必成挨一下;Boss 拒绝) return 'ok'; case 'up': case 'longpress': this._speakBattleStatus(); // 我在哪:血量 + 敌方阶段/方位 return 'ok'; default: return 'invalid'; } } if (b.state === 'telegraph') { if ((g === 'tap' || g === 'doubletap') && performance.now() <= b.windowEnd) { b.parry = true; // 面对与否在结算时按实际方位判 this.audio.play('sfx/parry'); vibrate('confirm'); return 'silent-ok'; } if (g === 'left') return this._battleTurnG(-1); // 窗口内来得及则转身面对 if (g === 'right') return this._battleTurnG(1); return 'invalid'; // 前摇窗只留格挡与转身(up/down/longpress → inv_tel 解释一次) } return 'ok'; // intro/enemyMove/resolve:链在播则管道已处理(tap=跳句) } _swing() { const b = this._battle; b.state = 'busy'; this.audio.play('sfx/atk_swing'); if (this._battleRel() !== REL.FRONT) { // 挥空(归因真话):它不在你面前——每场一次,之后只挥空声(原则 7) vibrate('error'); if (!b.swishSaid) { b.swishSaid = true; this._sayFlow(['voice/btl_swish'], () => this._enemyTurn()); } else { this._enemyTurn(); } 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(王 5→6) vibrate('warning'); this._sayFlow(['voice/' + b.spec.half], next, 8000); return; } next(); } /** 转身(战斗):只对绕位怪/Boss 有意义;缠斗怪纠错(每场 1 次,之后仅回执)。 * 玩家回合中转身不消耗回合 → hold 保住战斗等待点(转身声即内容音,hold 只补 * ok 回执);前摇窗内(ACTION)转身仍走 silent-ok。 */ _battleTurnG(d) { const b = this._battle; if (!b.mover) { vibrate('error'); if (!b.noTurnSaid) { b.noTurnSaid = true; // 播完回到战斗等待点(无 onDone 的话链播完 floor 停 BUSY,吞后续手势) this.floor.speak(['voice/btl_no_turn'], { kind: 'feedback', onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); } return 'invalid'; } const i = DIR_ORDER.indexOf(this.run.face); this.run.face = DIR_ORDER[(i + d + 4) % 4]; this.audio.play('sfx/step_turn'); return this.floor.state === 'YOUR_TURN' ? 'hold' : 'silent-ok'; } _speakBattleStatus() { // 我在哪(战斗):血量档 → 敌方阶段 →(绕位/Boss)方位 const b = this._battle; if (!b) return; 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.floor.speak(ids, { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); } /** 下滑 = 尝试逃跑:普通/绕位必成挨一下(真实账目,无格挡);Boss 拒绝。 */ _flee() { const b = this._battle; if (b.boss) { vibrate('error'); this._sayFlow(['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 }); this._drillArm(this._drill.step); // 回教学等待态(作答步交话轮 / 自由步回 ACTION) if (this._drill.step === 6) this._drillApproach(); return; } this._resumeExplore(); }; // 归因真话律:ATK0 怪(L1 蝠)根本没碰到你,「被抓了一下」是假话 → 静默脱战 if (dmg > 0) this._sayFlow(['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; 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; this.floor.enterAction(); // 前摇窗 = 实时玩法:单击直接格挡(§4.1 ACTION) 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() { // 护符:每层首次受击减半 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 if (parried) this.run.parries++; // 只计真正挡下的(面对+窗口内) 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) { // 教学战:首次挨打讲格挡反馈 + 攻击教学;之后不再重复(原则 11) if (!b.drillTaught) { b.drillTaught = true; const ids = [parried ? 'voice/tut_parry_ok' : 'voice/tut_parry_miss', 'voice/tut_attack']; this._sayFlow(ids, () => this._playerTurn(), 12000); return; } this._playerTurn(); return; } this._dangerCheck(); const drank = this._autoPotion(); const ids = []; if (parried && !b.parriedSaid) { b.parriedSaid = true; ids.push('voice/btl_parried'); } // 每场一次 if (behind && !b.behindSaid) { b.behindSaid = true; ids.push('voice/btl_behind'); } // 每场一次 if (drank) ids.push('voice/st_hp_' + this._hpBand(this.run.hp, this._maxHp())); if (ids.length) this._sayFlow(ids, () => this._playerTurn(), 8000); else this._playerTurn(); } _dangerCheck() { // 血 ≤25%:每层首次 warning 震(战斗曲已是状态声) if (!this._dangerFloorDone && this._hpBand(this.run.hp, this._maxHp()) === 4) { this._dangerFloorDone = true; vibrate('warning'); } } _autoPotion() { // HP≤25% 且腰带有力 → 自动喝 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._sayFlow(ids, () => { this._battle = null; if (this._drill && this._drill.step === 6) { // 教学战胜利:回教学态,进入 D7(自行寻楼梯) this.state = 'DRILL'; this.audio.play('bgm/explore', { fadeMs: 400 }); this._drillAdvance(7); return; } if (this._drill) { // D4/D7 途中遇怪等:战后回教学态继续(等待态照常挂回) this.state = 'DRILL'; this.audio.play('bgm/explore', { fadeMs: 400 }); this._drillArm(this._drill.step); return; } this._resumeExplore(); }, 12000); } // ================================ 死亡 / 通关结算(§11;critical 链 = 结局,不许打断) ================================ _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 }; // 归因真话律:句必须与真实死因对应 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.state = 'SETTLE'; this._session++; this._clearTimers(); this._stopSilenceFeed(); this.audio.play('bgm/menu', { volume: 0.5 }); 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); // 下一局开场播报 const floorN = this.run.floor; this.run = null; // 死亡清 run 不清 meta this._save(); const got = this.ladder.onGameDone(); // 一局结束 → 手势阶梯(阶段 2/3 解锁) this._settleChain = ['voice/die_intro', 'voice/die_cause_' + cause, 'voice/die_adv_' + adv, 'voice/st_floor_' + floorN, this._metaGoldVoice(), 'voice/settle_stronger']; const teachD = got && this.ladder.teachLine(got); if (teachD) this._settleChain.push(teachD); // novice.js teachLine 已带 voice/ 前缀 this._settleChain.push('voice/settle_again'); // 结局 = critical(§4.6):在途手势不截杀结算链(旧 H1 问题的根治),播完交出话轮 this.floor.speak(this._settleChain, { critical: true, onDone: () => { if (this.state === 'SETTLE' && !this.menuOpen) this.floor.yieldTurn(this._settleWp()); }, }); } _settleWin() { this._battle = null; this.state = 'SETTLE'; this._session++; this._clearTimers(); this._stopSilenceFeed(); this.audio.play('bgm/menu', { volume: 0.5 }); 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()]; if (bought.length) for (const u of bought) ids.push('voice/unlock_' + u); this.run = null; this._save(); const got = this.ladder.onGameDone(); this._settleChain = ids; const teachW = got && this.ladder.teachLine(got); if (teachW) this._settleChain.push(teachW); // teachLine 已带 voice/ 前缀 this._settleChain.push('voice/settle_again'); this.floor.speak(this._settleChain, { critical: true, onDone: () => { if (this.state === 'SETTLE' && !this.menuOpen) this.floor.yieldTurn(this._settleWp()); }, }); } _settleWp() { return this._wp({ id: 'settle', expected: ['tap'], full: 'voice/settle_again', short: 'voice/sys_first_tap_short', silenceMs: [20000, 45000], onDemo: () => this.floor.chime(), }); } _saySettle(ids) { this.floor.speak(ids, { onDone: () => { if (this.state === 'SETTLE' && !this.menuOpen) this.floor.yieldTurn(this._settleWp()); }, }); } _onSettleGesture(g) { switch (g) { case 'tap': this._newRunFlow(); // 再探一次(settle_again 承诺) return 'ok'; case 'doubletap': this._saySettle(['voice/sys_say_again', 'voice/settle_again']); // 阶段 3:重听等待点句 return 'ok'; case 'up': this._saySettle(this._titleUpChain()); // 我在哪 = 元进度播报 return 'ok'; case 'longpress': this._saySettle(['voice/settle_again']); // 重听结算一句 return 'ok'; case 'down': if (this.ladder.has('down')) { this._openMenu(); return 'ok'; } return 'invalid'; // 菜单阶段 2 才开放 default: return 'invalid'; // left } } // ================================ 下潜(DESCEND 演出) ================================ _descend(fromDrill) { this.state = 'DESCEND'; this._session++; this._clearTimers(); this.audio.play('sfx/stairs_down'); this._sayFlow(['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._sayFlow(ids, () => this._resumeExplore(), 8000); }, 8000); } // ================================ 演练教学 DRILL(§5;首 3 分钟逐秒脚本见设计单 §6) ================================ _enterDrill() { this.state = 'DRILL'; this._session++; this._clearTimers(); this.audio.play('bgm/explore'); this.audio.play('sfx/amb_cave', { volume: 0.6 }); this._drill = { step: -1, bat: null, deadline: 0 }; this._lastActAt = performance.now(); this._startAmbLoop(); this._startDrillWatch(); this._drillEnter(0); } /** 每步等待点:full=指令句 / short=短句 / 静默 6s、15s / L3 演示=游戏替你做。 */ _drillWp(step, expected) { const FULL = { 0: 'voice/d0_new', 1: 'voice/sys_first_tap', 2: 'voice/tut_move', 3: 'voice/tut_side_move', 4: 'voice/tut_coin', 5: 'voice/tut_help_hold', 6: 'voice/tut_mob', 7: 'voice/tut_stairs', }; const SHORT = { 0: 'voice/sys_first_tap_short', 1: 'voice/sys_first_tap_short', 2: 'voice/hint_swipe', 3: 'voice/hint_swipe', 4: 'voice/hint_swipe', 7: 'voice/hint_swipe' }; return this._wp({ id: 'drill' + step, expected, full: FULL[step], short: SHORT[step] || FULL[step], silenceMs: [6000, 15000], onDemo: () => this._drillDemo(step), }); } _drillYield(step, expected) { this.floor.yieldTurn(this._drillWp(step, expected)); } /** 每步期待的手势:作答步 0/1/2/3/5;引导自由行动步 4/6/7 = ACTION(接管兜底)。 */ _drillExpected(step) { return { 0: ['tap'], 1: ['tap'], 2: ['up'], 3: ['left', 'right'], 5: ['longpress'] }[step] || null; } /** 教学步进入等待:作答步 = 交话轮(提示/接管);自由行动步 = ACTION。 */ _drillArm(step) { const expected = this._drillExpected(step); if (expected) this._drillYield(step, expected); else this.floor.enterAction(); } /** * 教学链 → 进入本步等待(有界):链被乱手势打断或资产缺失时,兜底定时器照样 * 把等待态挂上(流程绝不卡死;对齐旧 _speakGuarded 语义)。 */ _drillChain(ids, step, after, opts = {}) { const s = this._session; let armed = false; const arm = () => { if (armed || s !== this._session || !this._drill || this._drill.step !== step) return; armed = true; if (opts.rearm) this.floor.resumeTurn(); // 等待点已挂:链播完回等待态(不重播交接音) else this._drillArm(step); if (after) after(); }; markHintSaid(this._drillWp(step, this._drillExpected(step) || SWIPE).conf.full); // 30s 台账 this.floor.speak(ids, { critical: !!opts.critical, onDone: arm }); this._after(ids.length * 1800 + 2500, arm); } /** 教学活跃度:有效动作(脉冲/移动)把兜底死线后推——只罚「真卡住」,不罚慢玩家。 */ _drillAlive() { if (this._drill && this.state === 'DRILL') this._drill.deadline = performance.now() + DRILL_FALLBACK_MS; } /** 教学兜底轮询(5s):死线到点自动过步(有界:接管三级用尽也不永远卡死)。 */ _startDrillWatch() { const s = this._session; const d = this._drill; this._drillAlive(); const loop = () => { if (s !== this._session || this._drill !== d) return; if (this.state === 'DRILL' && !this.menuOpen && performance.now() >= d.deadline) { this._drillAdvance(d.step + 1); return; } this._after(5000, loop); }; this._after(5000, loop); } /** 步骤回调(玩家做对后的推进):只在本步仍有效时触发一次(防连点双推进)。 */ _drillDoneStep(step, fn, delayMs = 0) { const d = this._drill; const s = this._session; this._after(delayMs, () => { if (s !== this._session || this._drill !== d || d.step !== step) return; fn(); }); } _drillEnter(step) { const d = this._drill; d.step = step; this._drillAlive(); // 每步重置兜底死线 switch (step) { case 0: // 0~3 秒:第一句 = critical 指令句,让玩家动手(§4.10) this._drillChain(['voice/d0_new'], 0, null, { critical: true }); break; case 1: { // 巩固:先等回声组落地(+1.3s 不叠声),再讲「对。」+ 规则句;链播完交出 // 话轮等第二次单击。遵守 escape 契约:不在 onGesture 返回路径上同步 // yieldTurn——管道 settleWaitPoint 会把同步挂上的等待点当已完成摘掉, // 之后 invalid 解释链播完无人恢复话轮 → BUSY 卡死吞手势。 const s1 = this._session; this._after(1300, () => { if (s1 !== this._session || this._drill?.step !== 1) return; this._drillChain(['voice/tut_praise', 'voice/tut_front_rule'], 1); }); break; } case 2: // 10~30 秒档:教会「轮到你」这个状态(sys_turn_teach),再教前进 this._drillChain(['voice/sys_turn_teach', 'voice/tut_move'], 2); break; case 3: this._drillChain(['voice/tut_praise', 'voice/tut_side_move'], 3); break; case 4: // 肯定句不逐字重复(原则 9):D2/D3 已各说过一次「对。」,D4 直接布置任务 this._drillChain(['voice/tut_coin'], 4); // 自由走到金币格(ACTION + 接管兜底) break; case 5: this._drillChain(['voice/tut_help_hold'], 5); break; case 6: this._drillEnter6(); break; case 7: this._drillChain(['voice/tut_stairs'], 7); // 自由寻路(ACTION + 接管兜底) break; default: this._drillDone(); } } /** D6:教学怪把门——先听脚步,遭遇后练单击格挡 + 单击挥击(ATK0 学期零惩罚)。 */ _drillEnter6() { 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(7); return; } // 边界:门怪已亡(乱走触发过)→ 自动过 d.bat = bat; while (this.run.face !== this._dominantDirTo(bat.x, bat.y)) { const i = DIR_ORDER.indexOf(this.run.face); this.run.face = DIR_ORDER[(i + 1) % 4]; // 遭遇自动面对语义(不播转身声) } this._drillChain(['voice/tut_mob'], 6, () => this._drillApproach()); // 兜底:教学怪主动扑来,绝不等死 } _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 !== 6 || 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(); }); } /** L3 演示:游戏自己完成这一步(等价于替玩家做对一次)。 */ _drillDemo(step) { switch (step) { case 0: case 1: this._pulse(); this._after(1600, () => this._drillAdvance(step + 1)); break; case 2: this._moveRel('up'); this._drillAdvance(3); break; case 3: this._moveRel('left'); this._drillAdvance(4); break; case 4: { // 替玩家拾起教学金币(onGoal 照常触发) const entry = [...this._floor.items.entries()].find(([, v]) => v.type === 'coin'); if (!entry) { this._drillAdvance(5); break; } const [k, item] = entry; const [x, y] = k.split(',').map(Number); this.run.pos = { x, y }; this._pickup(item, k); break; } case 5: this._longpressHelp(); this._drillAdvance(6); break; case 7: { // 游戏朝楼梯走一步(脚步声演示),剩余交给玩家 const dir = this._navFirstStepDir(this._floor.stairs); if (dir) { const rel = (DIR_ORDER.indexOf(dir) - DIR_ORDER.indexOf(this.run.face) + 4) % 4; this._moveRel(REL_GESTURE[rel]); } break; } default: this._drillAdvance(step + 1); } } _drillAdvance(step) { if (step > 7) { this._drillDone(); return; } this._drillEnter(step); } _drillDone() { // 兜底:D7 未走楼梯而步数耗尽(极少见)——按跳过处理 this.meta.drillDone = true; this._drill = null; this._sayFlow(['voice/tut_skip'], () => this._resumeExplore(), 8000); } _onDrillGesture(g) { const d = this._drill; if (!d) return this._onExploreGesture(g); switch (d.step) { case 0: // 首次脉冲(做对即推进:步号先落位,规则句在下一步链里等回声落地再讲) if (g === 'tap') { if (!this._pulse()) return 'invalid'; // 冷却中(管道给 invalid 回执) this._drillAdvance(1); return 'silent-ok'; } return 'invalid'; case 1: // 巩固:再点一次 = 第一次成功(奖励音在回声之后) if (g === 'tap') { if (!this._pulse()) return 'invalid'; this._drillDoneStep(1, () => { this.audio.play('sfx/levelup', { volume: 0.5 }); this._drillAdvance(2); }, 1400); return 'silent-ok'; } return 'invalid'; case 2: // 前进一格(肯定句并入 D3 链,杜绝连点双链) if (g === 'up') { const r = this._moveRel('up'); if (r !== 'invalid') this._drillAdvance(3); return r; } return 'invalid'; case 3: // 侧移 + 转向(肯定句并入 D4 链) if (g === 'left' || g === 'right') { const r = this._moveRel(g); if (r !== 'invalid') this._drillAdvance(4); return r; } return 'invalid'; case 4: // 自由走到金币格(拾取在 _pickup 钩子推进);允许脉冲探路 if (SWIPE.includes(g)) return this._moveRel(g); if (g === 'tap') return this._pulse() ? 'silent-ok' : 'invalid'; if (g === 'longpress') { this._longpressHelp(); return 'ok'; } return 'invalid'; case 5: // 长按 = 我在哪(帮助链即答案;播完再进 D6,不顶句) if (g === 'longpress') { const s5 = this._session; let adv5 = false; const advance5 = () => { if (adv5 || s5 !== this._session || !this._drill || this._drill.step !== 5) return; adv5 = true; this._drillAdvance(6); }; this._longpressHelp(advance5); this._after(8000, advance5); // 链被打断/资产缺失兜底 return 'ok'; } return 'invalid'; case 6: // 等教学怪扑来:允许脉冲/移动/帮助 if (g === 'tap') return this._pulse() ? 'silent-ok' : 'invalid'; if (SWIPE.includes(g)) return this._moveRel(g); if (g === 'longpress') { this._longpressHelp(); return 'ok'; } return 'invalid'; case 7: // 自由寻路到楼梯:四向移动 + 脉冲;楼梯格单击 = 下潜(教学出口) if (SWIPE.includes(g)) return this._moveRel(g); if (g === 'tap') { if (this._onStairs && this._stairsUsable()) { this._descend(true); return 'ok'; } return this._pulse() ? 'silent-ok' : 'invalid'; } if (g === 'longpress') { this._longpressHelp(); return 'ok'; } return 'invalid'; default: return 'invalid'; } } // ================================ 迷茫接管(§4.5 三级) ================================ _onConfused(level) { // 教学态走游戏侧接管:等待点 takeover 第 2 级不带台账去重,会把刚说过的指令句 // 原样重播(被动原句重复,原则 9);_drillTakeover 三级全部过台账。 if (this.state === 'DRILL' && !this.menuOpen) return this._drillTakeover(level); const wp = this.floor.waitPoint; if (wp) wp.takeover(level); // 等待点自带三级(提示 → 缩小 → 演示) else this._takeover(level); // 探索态(ACTION,无等待点) } _takeover(level) { if (this.state === 'DRILL') return this._drillTakeover(level); if (this.state !== 'EXPLORE' || this.menuOpen) return; if (level === 1) { // L1:说目标(铁器暗示 / 窟主守梯);30s 台账说过就只给提示音(原则 9) const id = this._stairHintId(); if (!hintSaidRecently(id)) { markHintSaid(id); this._sayContent([id], { kind: 'hint' }); } else { this.floor.chime(); } } else if (level === 2) { // L2:只放行最短路首步方向(面朝相对换算成手势) const g = this._navGesture(); this._restrictDir = g; const only = 'voice/sys_only_' + g; markHintSaid(only); this._sayContent([only], { kind: 'hint' }); } else if (level === 3) { // L3:听我做一次——游戏自己朝楼梯走一步,再交回 this._restrictDir = null; this._sayContent(['voice/sys_demo'], { kind: 'hint', onDone: () => { if (this.state !== 'EXPLORE') return; this._demoStep(); }, }); } } /** 卡关 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'; } /** 教学态接管:L1 短句 → L2 重教指令句 → L3 游戏替你完成本步。 */ _drillTakeover(level) { const d = this._drill; if (!d || this.menuOpen) return; if (level === 1) { if (!hintSaidRecently('voice/hint_swipe')) { markHintSaid('voice/hint_swipe'); this._sayContent(['voice/hint_swipe'], { kind: 'hint' }); } else { this.floor.chime(); } } else if (level === 2) { const wp = this._drillWp(d.step, this._drillExpected(d.step) || SWIPE); const line = wp.conf.full; if (line && !hintSaidRecently(line)) { markHintSaid(line); this._sayContent([line], { kind: 'hint' }); } else { this.floor.chime(); } } else if (level === 3) { this._drillDemo(d.step); } } /** 探索态最短路首步 → 相对手势(四向滑动 = 面朝相对移动)。 */ _navGesture() { const dir = this._navFirstStepDir(this._floor.stairs || this._floor.throne); if (!dir) return 'up'; const rel = (DIR_ORDER.indexOf(dir) - DIR_ORDER.indexOf(this.run.face) + 4) % 4; return REL_GESTURE[rel]; } /** L3 演示:游戏自己朝楼梯走一步(脚步声即演示),再交回「现在,你来」。 */ _demoStep() { const dir = this._navFirstStepDir(this._floor.stairs || this._floor.throne); if (!dir) { this.floor.chime(); return; } const rel = (DIR_ORDER.indexOf(dir) - DIR_ORDER.indexOf(this.run.face) + 4) % 4; this._moveRel(REL_GESTURE[rel]); // 遇怪 → 遭遇(演示自然中断);拾取/楼梯照常 if (this.state === 'EXPLORE') this._sayContent(['voice/sys_now_you'], { kind: 'hint' }); } /** * 带路第一步方向:沿最短路(从目标反向 BFS)的第一步给方向,不按直线差值指墙。 * @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; } /** 进展事件:迷茫计数清零、限制解除,下段从 L1 重新分级。 */ _progress() { this.confusion.resetForWaitPoint(); this._restrictDir = null; this._idleHintGiven = false; } /** 静默喂迷茫检测:40s 首次、之后每 20s(有操作即重排)。 */ _startSilenceFeed() { this._stopSilenceFeed(); const s = this._session; const tick = () => { if (s !== this._session || this.state !== 'EXPLORE' || this.menuOpen) return; this.confusion.recordSilence(); this._exploreIdleHint(); this._silenceTimer = setTimeout(tick, SILENCE_REPEAT_MS); }; this._silenceTimer = setTimeout(tick, SILENCE_FIRST_MS); } /** 首段静默到点 = L1 目标句(16 号 §5「静默 40s 起」;台账去重、每段静默一次)。 */ _exploreIdleHint() { if (this.confusion.level > 0 || this._idleHintGiven) return; if (this.floor.speaking || this.menuOpen) return; this._idleHintGiven = true; const id = this._stairHintId(); if (!hintSaidRecently(id)) { markHintSaid(id); this._sayContent([id], { kind: 'hint' }); } } _stopSilenceFeed() { if (this._silenceTimer) { clearTimeout(this._silenceTimer); this._silenceTimer = null; } } /** 探索呼吸层(内容层):静默超 20s(无手势、无人声)播一段洞穴环境垫。 */ _startAmbLoop() { const s = this._session; const loop = () => { if (s !== this._session) return; if (this.state === 'EXPLORE' && !this.menuOpen) this._ambBreath(); this._after(5000, loop); }; this._after(5000, loop); } _ambBreath() { const now = performance.now(); if (now - this._lastAmbAt < AMB_AFTER_MS) return; if (now - this._lastActAt < AMB_AFTER_MS) return; // 玩家仍在操作 = 不算静默 if (this.audio.isVoicePlaying()) return; // 有人声就不垫 this._lastAmbAt = now; this.audio.play('sfx/amb_cave', { volume: AMB_VOLUME }); } // ================================ 模态菜单(00 §5.4:左右滑换项 + 单击确认) ================================ _openMenu() { if (this.menuOpen) return; this.menuFrom = this.state; this.menuOpen = true; this._menuItems = this.menuFrom === 'TITLE' ? TITLE_MENU : SETTLE_MENU; this._menuIdx = 0; this._menuConfirm = false; this._session++; this._clearTimers(); this.audio.play('bgm/menu'); this.floor.interrupt(); this.floor.speak(['voice/menu_open', 'voice/' + this._menuItems[0]], { onDone: () => { if (this.menuOpen) this.floor.yieldTurn(this._menuWp()); }, }); } _menuWp() { return this._wp({ id: 'menu', expected: ['tap', 'left', 'right'], full: 'voice/menu_help', short: 'voice/menu_help', silenceMs: [20000, 45000], onDemo: () => this.floor.chime(), }); } _onMenuGesture(g) { const items = this._menuItems; const announce = () => this.floor.speak(['voice/' + items[this._menuIdx]], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); switch (g) { case 'right': this._menuIdx = (this._menuIdx + 1) % items.length; announce(); return 'hold'; case 'left': this._menuIdx = (this._menuIdx + items.length - 1) % items.length; announce(); return 'hold'; case 'up': case 'longpress': // 重听菜单说明 this.floor.speak(['voice/menu_help'], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; case 'down': this._closeMenu(); return 'ok'; case 'tap': case 'doubletap': return this._menuExec(items[this._menuIdx]); default: return 'invalid'; } } _menuExec(item) { if (item === 'sys_menu_restart') { if (!this._menuConfirm) { // 破坏性操作二次确认(原则 6) this._menuConfirm = true; this.floor.speak(['voice/menu_restart_confirm'], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; } const from = this.menuFrom; const seed = this.run ? this.run.seed : null; this.menuOpen = false; this.menuFrom = null; if (from === 'SETTLE' || seed == null) this._newRunFlow(); else this._restartRun(seed); return 'ok'; } if (item === 'sys_menu_save') { this._save(); this._sayFlow(['voice/sys_saved'], () => this._goHome(), 4000); return 'ok'; } if (item === 'sys_menu_help') { this.floor.speak(['voice/help_explore', 'voice/help_battle', 'voice/settings_hint'], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; } return 'ok'; } _closeMenu() { this.menuOpen = false; const from = this.menuFrom; this.menuFrom = null; this._menuConfirm = false; this.floor.interrupt(); this.audio.play('bgm/menu'); if (from === 'SETTLE') this.floor.yieldTurn(this._settleWp()); // 回结算等待(不留安静态) else this.floor.yieldTurn(this._titleWp()); // 回 TITLE 等待 } // ================================ 语音链 / 计时器基础设施 ================================ /** * 内容链(描述/归因/帮助/拾取):SPEAKING → 播完回到等待态—— * EXPLORE 与教学的自由行动步(4/6/7)回 ACTION(玩法声音即状态); * 教学作答步由 _drillChain/_drillArm 交话轮,此处不越权(有等待点时不碰)。 */ _sayContent(ids, opts = {}) { this.floor.speak(ids, { kind: opts.kind || 'content', critical: !!opts.critical, onDone: () => { if (!this.menuOpen && !this.floor.waitPoint && (this.state === 'EXPLORE' || (this.state === 'DRILL' && !this._drillExpected(this._drill?.step)))) { this.floor.enterAction(); } if (opts.onDone) opts.onDone(); }, }); } /** 流程链:自然播完或超时先到先推进(打断/资产缺失都不卡死流程)。 */ _sayFlow(ids, onDone, guardMs) { const s = this._session; let fired = false; const fire = () => { if (fired || s !== this._session) return; fired = true; if (onDone) onDone(); }; this.floor.speak(ids, { onDone: fire }); this._after(guardMs != null ? guardMs : ids.length * 1800 + 3000, fire); } _wp(conf) { return new WaitPoint(conf, { floor: this.floor, confusion: this.confusion }); } _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 = []; } // ---------------- 孤儿 BUSY 看门狗(§4.1:不存在安静态) ---------------- /** * invalid 解释链(引擎 InvalidHints)播完后,只有挂着等待点才会恢复话轮; * 探索/教学自由步没有等待点,链一播完 floor 就停在 BUSY——后续手势全部 * 变 busy 回执(管道在 floor.handleInput 就拦下,游戏层永远收不到)。 * 看门狗按当前态把孤儿 BUSY 拉回规范状态:探索=ACTION(环境音即状态)、 * 等待点态=重新交话轮、战斗前摇=ACTION。800ms 周期,会话令牌守护。 */ _startWatchdog() { if (this._watchdogOn) return; this._watchdogOn = true; const step = () => { // 自持周期定时器(不经 _after:状态跳转会话令牌翻转不灭狗,只有 stop() 停) this._watchdogTid = setTimeout(() => { if (!this._watchdogOn) return; this._healOrphanBusy(); step(); }, 800); }; step(); } _stopWatchdog() { this._watchdogOn = false; if (this._watchdogTid) { clearTimeout(this._watchdogTid); this._watchdogTid = null; } } _healOrphanBusy() { const f = this.floor; if (this.menuOpen || f.state !== 'BUSY' || f.speaking) return; switch (this.state) { case 'EXPLORE': f.enterAction(); return; case 'DRILL': { if (!this._drill) return; if (this._drillExpected(this._drill.step)) this._drillArm(this._drill.step); else f.enterAction(); return; } case 'BATTLE': { const b = this._battle; if (!b) return; if (b.state === 'player') this._playerTurn(); else if (b.state === 'telegraph') f.enterAction(); return; // intro/enemyMove/resolve:链与兜底定时器自己在推进 } case 'TITLE': f.yieldTurn(this._titleWp()); return; case 'SETTLE': f.yieldTurn(this._settleWp()); return; default: return; // boot/DESCEND:演出兜底定时器负责 } } // ================================ 存档(§11;summary 字段不变) ================================ _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; } } }