/** * 《暗室》R3 —— 3x3 黑暗房间网格密室逃脱,按 docs/design/26-audio-interaction-playbook.md 全面重构。 * * 交互层(对齐 26 号文档,设计单见 docs/design/26-apply-escape.md): * - 四向滑动 = 核心玩法(§4.3 裁定:四向是玩法的游戏,帮助换到长按): * up/down/left/right 永远是移动,不按新手阶梯锁。 * - 单击 = 检查 + 使用(合并):站在铁柜前拿着钥匙,单击就是开柜;双击=用物品的 * 旧映射废除(新手期双击按一次单击处理,绑定重要操作必误触)。 * - 长按 = 「我在哪、我该干嘛」:一句话(目标 + 方位,原则 3)。 * - PLAY 阶段 = 发言权 ACTION 态(§4.1):环境音就是状态声,常响不停; * 房间描述 = SPEAKING(音乐压低跨链),播完回 ACTION。 * - 撞墙 = 失败表「方向错」:闷响 + invalid 回执 + 「那边是墙,换个方向。」+ * 方位句(归因真话);移动成功 = 脚步声(silent-ok,内容音即回执)。 * - 卡关接管(§4.5 三级):静默 40s 起、乱点(10s 内 3 次撞墙/无效)即触发—— * L1 说目标方位句 → L2 「现在只要向左滑」+只放行该方向 → L3 听我做一次 * (游戏自己走一步)+「现在你来」。进展事件清零重来。 * - 断点复述(§4.12):每步自动存档;回来问「继续上次 / 重新开始」。 * * 谜题链(不变):储物间单击找钥匙 → 铁柜房单击开柜得保险丝 → 电箱房单击修电箱 * → 大门房单击开门逃脱。 * * 状态 × 手势矩阵(新手期,完整版见设计单): * | 状态 | 单击 | 四向滑动 | 长按 | 双击(未解锁) | * |-----------|-----------------|-------------------|-----------|--------------| * | 引导答题 | 按步骤答/纠错 | 按步骤答/纠错 | 帮助一句话 | 按单击处理 | * | 探索(ACTION)| 检查+使用+ok | 移动(脚步)/撞墙(invalid) | 帮助 | 按单击处理 | * | 描述在播 | 跳过这句+ok | 打断并移动 | 打断,帮助 | 按单击处理 | * | 结算 | 重新开始+ok | invalid+解释 | 重听结算 | 按单击处理 | * | 菜单 | 换一项+播报 | 右=确认/左=上一项 | 重听菜单 | 按单击处理 | */ 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'; const Phase = { REOPEN: 'REOPEN', GUIDE: 'GUIDE', PLAY: 'PLAY', ENDING: 'ENDING', MENU: 'MENU', }; const G = { UP: 'up', DOWN: 'down', LEFT: 'left', RIGHT: 'right', TAP: 'tap', DOUBLE_TAP: 'doubletap', LONGPRESS: 'longpress' }; const SWIPES = [G.UP, G.DOWN, G.LEFT, G.RIGHT]; // 谜题链阶段 → 带路目标房间坐标 const STAGES = { key: { target: [0, 2] }, cabinet: { target: [2, 1] }, box: { target: [0, 0] }, gate: { target: [2, 2] }, }; // 长按「我在哪、我该干嘛」目标句(原则 3:目标 + 一个动作) const LP_GOAL = { key: 'voice/lp_key', cabinet: 'voice/lp_cabinet', box: 'voice/lp_box', gate: 'voice/lp_gate', }; // 关键房间:坐标 → { 环境音, 首次到达描述 voice } const KEY_ROOMS = { '0,2': { amb: 'sfx/amb_storage', room: 'voice/room_storage' }, '2,1': { amb: 'sfx/amb_cabinet', room: 'voice/room_cabinet' }, '0,0': { amb: 'sfx/amb_power', room: 'voice/room_power' }, '2,2': { amb: 'sfx/amb_gate', room: 'voice/room_gate' }, }; // 空房环境音轮换 const EMPTY_AMB = { '1,0': 'sfx/amb_wind', '0,1': 'sfx/amb_hum', '1,2': 'sfx/amb_wind', '2,0': 'sfx/amb_hum' }; // 空房首访描述 const EMPTY_FIRST_VOICE = { '1,0': 'voice/empty_1', '0,1': 'voice/empty_2', '1,2': 'voice/empty_3', '2,0': 'voice/empty_3', }; const PREWARM_SFX = [ 'sfx/step', 'sfx/wall', 'sfx/pickup', 'sfx/unlock', 'sfx/door_creak', 'sfx/spark', 'sfx/power_on', 'sfx/gate_open', 'sfx/win_chime', 'sfx/amb_storage', 'sfx/amb_cabinet', 'sfx/amb_power', 'sfx/amb_gate', 'sfx/amb_gate_live', 'sfx/amb_drip', 'sfx/amb_wind', 'sfx/amb_hum', ]; const GUIDE_DONE_KEY = 'soundgame.escape.guideDone'; const SAVE_KEY = 'soundgame.escape.save'; const STATS_KEY = 'soundgame.escape.stats'; const AUTH_KEY = 'soundgame.auth'; // 静默喂给迷茫检测的节奏:40s 首次、之后每 20s(两次即累计触发接管) const SILENCE_FIRST_MS = 40000; const SILENCE_REPEAT_MS = 20000; // 无效操作解释(原则 7) const INVALID_LINES = { 'locked:longpress': null, // 长按是本游戏核心手势,不会走到这 'ending:swipe': 'voice/inv_settle', 'menu:swipe': null, // 菜单左右滑合法 'menu:longpress': 'voice/menu_help', 'guide:swipe': null, }; export class EscapeGame extends Game { constructor(audio, root) { super(audio, root); this.receipts = new Receipts(audio); this.floor = new Floor(audio); this.confusion = new Confusion({ onConfused: (lvl) => this._takeover(lvl) }); this.invalidHints = new InvalidHints().bind(this.floor); this.ladder = new GestureLadder(); this.audio.setRateBoostEnabled(false); // 长按=帮助(核心手势),不触发旁白快进 this.confusion.silenceMs = SILENCE_FIRST_MS; this._session = 0; this._timers = []; this._silenceTimer = null; this._restrictDir = null; // 迷茫 L2:只放行的移动方向 this._lastDescribe = null; // 最近一句房间描述(双击重听用) this._menuIdx = 0; this._resetState(); this.phase = Phase.REOPEN; } /** 管道选项(index.html 读):四向滑动 + 长按都是本作核心手势。 */ get PIPELINE_OPTS() { return { longpressCore: true, swipeCore: true }; } _resetState() { this.x = 1; this.y = 1; this.hasKey = false; this.hasFuse = false; this.cabinetOpen = false; this.powerOn = false; this.visited = new Set(); this.emptyVisited = new Set(); this._freshEmpty = null; this._wallDirAt = {}; this._wallExplained = new Set(); // 撞墙归因:每方向每进展段只解释一次(原则 7) this._describedRoom = new Set(); // 房间检查描述:每房间每进展段只念一次(原则 9) this._settleBusy = false; this.startElapsedMs = 0; // 引导状态 this.guideStep = -1; this.guideSwipes = 0; this.guideErrs = 0; this._endingBgmStarted = false; } // ---------- Game ---------- start() { this.audio.prewarm(PREWARM_SFX); this._session++; this._clearTimers(); this.audio.play('bgm/drone'); const guided = this._flag(GUIDE_DONE_KEY); const save = this._loadSave(); if (guided && save && save.inProgress) { // 断点复述(§4.12) this.phase = Phase.REOPEN; this.floor.speak(['voice/sys_continue_ask'], { onDone: () => this.floor.yieldTurn(this._wp({ id: 'reopen', expected: [G.TAP, G.RIGHT], full: 'voice/sys_continue_ask', short: 'voice/lp_tap', silenceMs: [12000, 30000], onDemo: () => this.floor.chime(), })), }); } else if (guided) { this._enterPlay(true, true); // 二周目:直接开始(start_room + 目标句) } else { this._startGuide(); } } stop() { this._save(true); this._session++; this._clearTimers(); this._stopSilenceFeed(); this.floor.interrupt(); this.audio.stopAll(); } onGesture(g) { switch (this.phase) { case Phase.REOPEN: return this._onReopenGesture(g); case Phase.GUIDE: return this._onGuideGesture(g); case Phase.PLAY: return this._onPlayGesture(g); case Phase.ENDING: return this._onEndingGesture(g); case Phase.MENU: return this._onMenuGesture(g); } return 'invalid'; } stateName() { if (this.phase === Phase.PLAY) return 'play'; if (this.phase === Phase.ENDING) return 'ending'; if (this.phase === Phase.MENU) return 'menu'; if (this.phase === Phase.GUIDE) return 'guide'; return 'reopen'; } invalidHint(state, g) { if (state === 'locked') return INVALID_LINES[`locked:${g}`] || 'voice/inv_generic'; if (g === 'longpress') return INVALID_LINES[`${state}:longpress`] || 'voice/menu_help'; if (g === 'left' || g === 'right') return INVALID_LINES[`${state}:swipe`] || 'voice/inv_generic'; if (state === 'ending') return 'voice/inv_settle'; return 'voice/inv_generic'; } // ---------- 引导(首 3 分钟逐秒脚本,§4.10;设计单有完整表) ---------- _startGuide() { this._session++; this._clearTimers(); this._resetState(); this.phase = Phase.GUIDE; this.guideStep = 0; this.audio.play('sfx/amb_drip', { volume: 0.6 }); // 0~3 秒:第一句就让玩家动手(旧 g0_intro 8 秒念完才教学废除) this._guideSay(['voice/g0_new'], 0); } /** 播引导链 → 交出话轮等步骤答案。expected = 本步骤接受的答选手势。 */ _guideSay(ids, step, expected) { this.guideStep = step; this.floor.speak(ids, { onDone: () => this.floor.yieldTurn(this._wp({ id: `guide${step}`, expected: expected || SWIPES, full: null, short: 'voice/g_hint_swipe', silenceMs: [6000, 15000], onDemo: () => this.floor.chime(), })), }); } _onGuideGesture(g) { // 引导答题:期待手势之外的输入 = 纠错(原则 11:做错不惩罚、当场重教) const step = this.guideStep; const isSwipe = SWIPES.includes(g); switch (step) { case 0: // 黑暗中先滑一下(任意方向) if (isSwipe) { this.audio.play('sfx/step', { volume: 0.8 }); this._guideSay(['voice/g0b'], 1); return 'silent-ok'; // 脚步声即反馈 } this._guideWrong('voice/g_wrong_gesture'); return 'invalid'; case 1: // 再滑两下试试墙(撞墙也是反馈:教「无效操作长什么样」) if (isSwipe) { this.audio.play('sfx/wall'); vibrate('error'); this.guideSwipes++; if (this.guideSwipes >= 2) { this._guideSay(['voice/g3_new'], 2, [G.LEFT]); // wall 刚说过不重播(原则 9) } else { this.floor.speak(['voice/wall'], { onDone: () => { if (this.phase === Phase.GUIDE) this.floor.resumeTurn(); }, }); } return 'invalid'; // 撞墙 = 失败表「方向错」,invalid 回执 + 归因 } this._guideWrong('voice/g_wrong_gesture'); return 'invalid'; case 2: // 向左滑 → (0,1) if (g === G.LEFT) { this.x = 0; this.y = 1; this.audio.play('sfx/step', { volume: 0.8 }); this._guideSay(['voice/g4_down'], 3, [G.DOWN]); return 'silent-ok'; } if (isSwipe) this._guideWrong('voice/guide_wrong_left'); else this._guideWrong('voice/g_wrong_gesture'); return 'invalid'; case 3: // 向下滑 → (0,2) 储物间 if (g === G.DOWN) { this.x = 0; this.y = 2; this.audio.play('sfx/step', { volume: 0.8 }); this.visited.add('0,2'); this.audio.play('sfx/amb_storage', { volume: 0.9 }); this._guideSay(['voice/g5_inspect'], 4, [G.TAP]); return 'silent-ok'; } if (isSwipe) this._guideWrong('voice/guide_wrong_down'); else this._guideWrong('voice/g_wrong_gesture'); return 'invalid'; case 4: // 单击检查 → 拾钥匙(谜题链第一环)→ 自由探索 if (g === G.TAP) { this.guideStep = 5; // 先落步骤号:链被乱手势打断后重进不重播(幂等) this.hasKey = true; this.audio.play('sfx/pickup'); vibrate('pickup'); try { localStorage.setItem(GUIDE_DONE_KEY, '1'); } catch (_) { /* 隐私模式 */ } this.ladder.onGoal(); // 第一个小目标达成 → 手势阶梯阶段 1 this.floor.speak(['voice/g5_key', 'voice/g7_goal_new'], { onDone: () => this._enterPlay(false), }); return 'silent-ok'; // 拾取音效即反馈 } this._guideWrong('voice/g_wrong_tap'); return 'invalid'; default: return 'invalid'; } } _guideWrong(voiceId, _g) { this.guideErrs++; vibrate('error'); if (this.guideErrs >= 4) return; // 4 次起只给回执(原则 7),交给迷茫接管 const id = this.guideErrs >= 3 ? 'voice/guide_blunt_swipe' // 错 3 次给直白描述 : (voiceId || 'voice/g_wrong_gesture'); this.floor.speak([id], { onDone: () => { if (this.phase === Phase.GUIDE) this.floor.resumeTurn(); }, }); } // ---------- 进入 / 恢复 / 重开 PLAY ---------- _enterPlay(announce, withGoal = false) { this._session++; this._clearTimers(); this.phase = Phase.PLAY; this.startElapsedMs = Date.now() - (this._savedElapsedMs || 0); this.floor.enterAction(); // 探索态:环境音即状态声(§4.1 ACTION) this._startSilenceFeed(); this.confusion.resetForWaitPoint(); if (announce) { const seq = withGoal ? ['voice/start_room', 'voice/g7_goal_new'] : ['voice/start_room']; this._say(seq); } this._playAmbient(); this._save(true); } _restart() { this._session++; this._clearTimers(); this._stopSilenceFeed(); this.floor.interrupt(); this._resetState(); this._savedElapsedMs = 0; this.audio.play('bgm/drone'); this._enterPlay(false); this._say(['voice/replay', 'voice/g7_goal_new']); } _onReopenGesture(g) { const save = this._loadSave(); switch (g) { case G.TAP: // 继续上次(§4.12:先播复述句 = 长按帮助句,再交回探索) if (save) this._restoreState(save); this.phase = Phase.PLAY; this._enterPlay(false); this._longpressHelp(); return 'ok'; case G.RIGHT: // 重新开始 this._resetState(); this._savedElapsedMs = 0; this._enterPlay(true, true); return 'ok'; case G.LONGPRESS: this._longpressHelp(); return 'ok'; default: return 'invalid'; } } _restoreState(save) { this.x = save.x; this.y = save.y; this.hasKey = !!save.hasKey; this.hasFuse = !!save.hasFuse; this.cabinetOpen = !!save.cabinetOpen; this.powerOn = !!save.powerOn; this.visited = new Set(save.visited || []); this.emptyVisited = new Set(save.emptyVisited || []); this._savedElapsedMs = save.elapsedMs || 0; } // ---------- 探索(ACTION 态) ---------- _onPlayGesture(g) { let r; switch (g) { case G.UP: r = this._move(0, -1); break; case G.DOWN: r = this._move(0, 1); break; case G.LEFT: r = this._move(-1, 0); break; case G.RIGHT: r = this._move(1, 0); break; case G.TAP: r = this._interact(); break; case G.LONGPRESS: this._longpressHelp(); r = 'ok'; break; case G.DOUBLE_TAP: { // 阶段 3 解锁的重听:再说一遍最近一句房间描述 const id = this._lastDescribe || 'voice/start_room'; this._say(['voice/sys_say_again', id]); r = 'ok'; break; } default: r = 'invalid'; } // 探索态收尾:手势处理完且没起新链 → 回 ACTION(环境音即状态,§4.1) if (this.phase === Phase.PLAY && !this.floor.speaking) this.floor.enterAction(); return r; } /** 长按 = 我在哪、我该干嘛(原则 3:目标 + 方位一个动作,一句话)。 */ _longpressHelp() { const stage = this._stage(); const target = STAGES[stage].target; const atTarget = target[0] === this.x && target[1] === this.y; const ids = atTarget ? [LP_GOAL[stage], 'voice/lp_here'] : [LP_GOAL[stage], ...this._navVoices(target)]; this._say(ids); } _move(dx, dy) { // 迷茫 L2:只放行接管指定的方向(其余手势 invalid 回执,不产生效果) if (this._restrictDir) { const dir = dy < 0 ? G.UP : dy > 0 ? G.DOWN : dx < 0 ? G.LEFT : G.RIGHT; if (dir !== this._restrictDir) return 'invalid'; // pipeline 统一记账+回执 } const nx = this.x + dx; const ny = this.y + dy; if (nx < 0 || nx > 2 || ny < 0 || ny > 2) { // 失败表「方向错」:闷响 + invalid 回执;归因句每个方向只解释一次 // (原则 7:之后只给回执——闷响本身就是内容回执),10s 冷却后可再提醒方位 this.audio.play('sfx/wall'); vibrate('error'); this.confusion.recordFail(); const dir = dy < 0 ? 'u' : dy > 0 ? 'd' : dx < 0 ? 'l' : 'r'; if (!this._wallExplained.has(dir)) { const first = this._wallExplained.size === 0; this._wallExplained.add(dir); // 通用句整局一次;之后每个新方向只给方位句(渐短,原则 9) this._say(first ? ['voice/wall', `voice/wall_dir_${dir}`] : [`voice/wall_dir_${dir}`], { kind: 'feedback' }); } return 'invalid'; // 之后只给闷响 + invalid 回执(原则 7) } this.x = nx; this.y = ny; this.audio.play('sfx/step', { volume: 0.8 }); // 内容音即回执 this._playAmbient(); this._freshEmpty = null; this._save(); const key = `${nx},${ny}`; if (KEY_ROOMS[key] && !this.visited.has(key)) { this.visited.add(key); this._progressEvent(); this._say([KEY_ROOMS[key].room]); } else if (EMPTY_FIRST_VOICE[key] && !this.emptyVisited.has(key)) { this.emptyVisited.add(key); this._freshEmpty = key; this._say([EMPTY_FIRST_VOICE[key]]); } return 'silent-ok'; } /** 单击 = 检查 + 使用(先看能否用,再描述)。 */ _interact() { this._playAmbient(); const s = this._session; const roomKey = `${this.x},${this.y}` + ((this.x === 2 && this.y === 1) ? `:c${this.cabinetOpen ? 1 : 0}` : '') + ((this.x === 0 && this.y === 0) ? `:p${this.powerOn ? 1 : 0}` : '') + ((this.x === 2 && this.y === 2) ? `:p${this.powerOn ? 1 : 0}` : ''); const alreadyDescribed = this._describedRoom.has(roomKey); if (alreadyDescribed) return; // 听过描述的房间:单击 = 重听环境音(渐短档) const describe = (id, critical = false, onDone) => this._timer(() => { if (s === this._session) { this._describedRoom.add(roomKey); this._say([id], { critical, onDone }); } }, 800); // 先让环境音进耳朵再播描述 const { x, y } = this; if (x === 0 && y === 2) { // 储物间:找钥匙(状态一次性,不看 _describedRoom) if (!this.hasKey && !this.cabinetOpen) { this.hasKey = true; this._progressEvent(); this.audio.play('sfx/pickup'); vibrate('pickup'); this._timer(() => { if (s !== this._session) return; this._say(['voice/found_key'], { onDone: () => this._playGoalDir(STAGES.cabinet.target, 'voice/gd_cabinet_dir', 'voice/gd_room_r'), }); }, 800); } else describe('voice/empty_2'); } else if (x === 2 && y === 1) { // 铁柜房:单击 = 开柜(拿钥匙时) if (this.hasKey && !this.cabinetOpen) { this._unlockCabinet(); } else describe(this.cabinetOpen ? 'voice/empty_1' : 'voice/inspect_cabinet_locked'); } else if (x === 0 && y === 0) { // 电箱房:单击 = 修电箱(拿保险丝时) if (this.hasFuse && !this.powerOn) { this._fixBox(); } else describe(this.powerOn ? 'voice/box_fixed' : 'voice/inspect_box_dead'); } else if (x === 2 && y === 2) { // 大门:单击 = 开门(通电时) if (this.powerOn) { this._escape(); } else describe('voice/inspect_gate'); } else if (x === 1 && y === 1) { describe('voice/start_room'); } else { const key = `${x},${y}`; if (this._freshEmpty !== key) describe(EMPTY_FIRST_VOICE[key] || 'voice/empty_3'); } return 'silent-ok'; // 环境音/描述即内容反馈 } _unlockCabinet() { this.cabinetOpen = true; this.hasKey = false; this.hasFuse = true; this._progressEvent(); this.audio.play('sfx/unlock'); this.audio.play('sfx/door_creak', { volume: 0.7 }); vibrate('success'); this._say(['voice/unlock_cabinet', 'voice/gd_next_fuse'], { onDone: () => this._playGoalDir(STAGES.box.target, 'voice/gd_box_dir', 'voice/gd_room_l'), }); } _fixBox() { this.powerOn = true; this.hasFuse = false; this._progressEvent(); this.audio.play('sfx/spark'); this.audio.play('sfx/power_on', { volume: 0.8 }); vibrate('success'); this._say(['voice/fix_box'], { onDone: () => this._playGoalDir(STAGES.gate.target, 'voice/gd_gate_dir', 'voice/gd_room_r'), }); } _roomDirVoice([tx, ty]) { const dx = tx - this.x; const dy = ty - this.y; if (dx === 0 && dy === 0) return null; if (dx !== 0) return dx < 0 ? 'voice/gd_room_l' : 'voice/gd_room_r'; return dy < 0 ? 'voice/gd_room_u' : 'voice/gd_room_d'; } _playGoalDir(target, namedId, expectedId) { const dir = this._roomDirVoice(target); if (!dir) return; this._say([namedId && dir === expectedId ? namedId : dir]); } /** 当前谜题链阶段。 */ _stage() { if (!this.hasKey && !this.cabinetOpen) return 'key'; if (!this.cabinetOpen) return 'cabinet'; if (!this.powerOn) return 'box'; return 'gate'; } _ambientId() { const key = `${this.x},${this.y}`; if (key === '2,2' && this.powerOn) return 'sfx/amb_gate_live'; if (KEY_ROOMS[key]) return KEY_ROOMS[key].amb; if (key === '1,1') return 'sfx/amb_drip'; return EMPTY_AMB[key] || 'sfx/amb_wind'; } _playAmbient() { this.audio.play(this._ambientId(), { volume: 0.9 }); } /** 探索期说话(房间描述/归因/帮助):SPEAKING → 播完回 ACTION。 */ _say(ids, { critical = false, kind = 'content', onDone } = {}) { this._lastDescribe = ids[0].startsWith('voice/') ? ids[0] : this._lastDescribe; this.floor.speak(ids, { critical, kind, onDone: () => { if (this.phase === Phase.PLAY) this.floor.enterAction(); if (onDone) onDone(); }, }); } // ---------- 迷茫接管(§4.5 三级:提示 → 缩小 → 演示) ---------- _takeover(level) { if (this.phase !== Phase.PLAY) return; if (level === 1) { // L1:说目标方位(下一步的一个动作;30s 内说过就只给提示音——原则 9) const target = STAGES[this._stage()].target; const nav = this._navVoices(target); if (nav[0] && !hintSaidRecently(nav[0])) { markHintSaid(nav[0]); this._say(nav, { kind: 'hint' }); } else { this.floor.chime(); } } else if (level === 2) { // L2:只放行第一个导航方向 const nav = this._navVoices(STAGES[this._stage()].target); const dirVoice = nav[0]; this._restrictDir = dirVoice === 'voice/nav_left' ? G.LEFT : dirVoice === 'voice/nav_right' ? G.RIGHT : dirVoice === 'voice/nav_up' ? G.UP : G.DOWN; const ONLY = { up: 'voice/sys_only_up', down: 'voice/sys_only_down', left: 'voice/sys_only_left', right: 'voice/sys_only_right' }; const onlyId = ONLY[this._restrictDir]; markHintSaid(onlyId); this._say([onlyId], { kind: 'hint' }); } else if (level === 3) { // L3:听我做一次 —— 游戏自己朝目标走一步,再交回 this._restrictDir = null; this._say(['voice/sys_demo'], { kind: 'hint', onDone: () => { if (this.phase !== Phase.PLAY) return; const [tx, ty] = STAGES[this._stage()].target; const dx = Math.sign(tx - this.x); const dy = dx === 0 ? Math.sign(ty - this.y) : 0; this.x += dx; this.y += dy; this.audio.play('sfx/step', { volume: 0.8 }); this._playAmbient(); this._say(['voice/sys_now_you'], { kind: 'hint' }); }, }); } } /** 静默喂迷茫检测:40s 首次、之后每 20s(有操作即重排)。 */ _startSilenceFeed() { this._stopSilenceFeed(); const s = this._session; const tick = () => { if (s !== this._session || this.phase !== Phase.PLAY) return; this.confusion.recordSilence(); this._silenceTimer = setTimeout(tick, SILENCE_REPEAT_MS); }; this._silenceTimer = setTimeout(tick, SILENCE_FIRST_MS); } _stopSilenceFeed() { if (this._silenceTimer) { clearTimeout(this._silenceTimer); this._silenceTimer = null; } } /** 进展事件:迷茫计数清零、限制解除,下段从 L1 重新分级。 */ _progressEvent() { this.confusion.resetForWaitPoint(); this._restrictDir = null; } _navVoices([tx, ty]) { const ids = []; const dx = tx - this.x; const dy = ty - this.y; if (dx !== 0) ids.push(dx < 0 ? 'voice/nav_left' : 'voice/nav_right'); if (dy !== 0) ids.push(dy < 0 ? 'voice/nav_up' : 'voice/nav_down'); return ids; } // ---------- 菜单 / 结算 ---------- _onEndingGesture(g) { switch (g) { case G.TAP: if (!this._settleBusy) this._restart(); return 'ok'; case G.DOWN: this._openMenu(); return 'ok'; case G.LONGPRESS: { // 重听结算(结果一句) this._say(['voice/again']); return 'ok'; } default: return 'invalid'; } } _openMenu() { this.phase = Phase.MENU; this._menuIdx = 0; this._stopSilenceFeed(); this.floor.interrupt(); this.audio.play('bgm/menu', { volume: 0.5, fadeMs: 400 }); this.floor.speak(['voice/menu_open', 'voice/menu_item1'], { onDone: () => this.floor.yieldTurn(this._wp({ id: 'menu', expected: [G.TAP, G.LEFT, G.RIGHT], full: 'voice/menu_help', short: 'voice/menu_help', silenceMs: [20000, 45000], onDemo: () => this.floor.chime(), })), }); } _onMenuGesture(g) { const items = ['voice/menu_item1', 'voice/menu_item2', 'voice/menu_item3']; const announce = () => { this.floor.speak([items[this._menuIdx]], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); }; switch (g) { case G.TAP: this._menuIdx = (this._menuIdx + 1) % items.length; announce(); return 'hold'; case G.LEFT: this._menuIdx = (this._menuIdx + items.length - 1) % items.length; announce(); return 'hold'; case G.RIGHT: { const sel = this._menuIdx; if (sel === 0) this._restart(); else if (sel === 1) { this.stop(); window.location.href = '../'; } else this._closeMenu(); return 'ok'; } case G.LONGPRESS: this.floor.speak(['voice/menu_help'], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; default: return 'invalid'; } } _closeMenu() { this.phase = Phase.ENDING; this.audio.play('bgm/menu', { volume: 0.5 }); this.floor.yieldTurn(this._settleWp()); // 回结算等待,不留安静态(§4.1) } _settleWp() { return this._wp({ id: 'settle', expected: [G.TAP], full: 'voice/again', short: 'voice/lp_tap', silenceMs: [20000, 45000], onDemo: () => this.floor.chime(), }); } _escape() { this.phase = Phase.ENDING; this._progressEvent(); this._stopSilenceFeed(); vibrate('success'); this.audio.stopBgm(2500); this._endingBgmStarted = false; this._settleBusy = true; const totalSec = Math.floor((Date.now() - this.startElapsedMs) / 1000); this._saveEscape(totalSec); this._save(false); const got = this.ladder.onGameDone(); const tail = ['voice/again']; if (got) tail.push(this.ladder.teachLine(got)); // §4.6:critical 句必须短于 3 秒——只把「单击再来一次」承诺段标 critical this.floor.speak(['voice/escape', this._timeBandVoice(totalSec)], { onDone: () => this.floor.speak(tail, { critical: true, onDone: () => { this._startEndingBgm(); this.floor.yieldTurn(this._settleWp()); }, }), }); this._timer(() => { this.audio.play('sfx/gate_open'); this.audio.play('sfx/win_chime', { volume: 0.8 }); }, 2600); this._timer(() => this._startEndingBgm(), 30000); // 资产缺失兜底 } _startEndingBgm() { if (this._endingBgmStarted || this.phase !== Phase.ENDING) return; this._settleBusy = false; this._endingBgmStarted = true; this.audio.play('bgm/menu', { volume: 0.5, fadeMs: 1500 }); } _timeBandVoice(sec) { if (sec < 120) return 'voice/time_band1'; if (sec < 300) return 'voice/time_band2'; if (sec < 600) return 'voice/time_band3'; return 'voice/time_band4'; } // ---------- 存档 ---------- _flag(key) { try { return localStorage.getItem(key) === '1'; } catch (_) { return false; } } _save(inProgress) { try { localStorage.setItem(SAVE_KEY, JSON.stringify({ inProgress: !!inProgress && this.phase === Phase.PLAY, x: this.x, y: this.y, hasKey: this.hasKey, hasFuse: this.hasFuse, cabinetOpen: this.cabinetOpen, powerOn: this.powerOn, visited: [...this.visited], emptyVisited: [...this.emptyVisited], elapsedMs: this.startElapsedMs ? Date.now() - this.startElapsedMs : (this._savedElapsedMs || 0), })); } catch (_) { /* 隐私模式 */ } } _loadSave() { try { const raw = localStorage.getItem(SAVE_KEY); return raw ? JSON.parse(raw) : null; } catch (_) { return null; } } _loadStats() { try { return JSON.parse(localStorage.getItem(STATS_KEY)) || { escapes: 0, bestTimeSec: null }; } catch (_) { return { escapes: 0, bestTimeSec: null }; } } _saveEscape(totalSec) { const stats = this._loadStats(); stats.escapes += 1; if (stats.bestTimeSec === null || totalSec < stats.bestTimeSec) stats.bestTimeSec = totalSec; try { localStorage.setItem(STATS_KEY, JSON.stringify(stats)); } catch (_) { /* 隐私模式 */ } const best = stats.bestTimeSec; const summary = `已逃脱 ${stats.escapes} 次 / 最快 ${Math.floor(best / 60)}:${String(best % 60).padStart(2, '0')}`; this._putProgress({ summary, escapes: stats.escapes, bestTimeSec: best, schema: 1, updatedAt: new Date().toISOString(), }); } _putProgress(data) { 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: 'escape', data }), }).catch(() => { /* 离线静默 */ }); } catch (_) { /* 静默 */ } } // ---------- 工具 ---------- _wp(conf) { return new WaitPoint(conf, { floor: this.floor, confusion: this.confusion }); } _timer(fn, ms) { const s = this._session; const id = setTimeout(() => { if (s === this._session) fn(); }, ms); this._timers.push(id); return id; } _clearTimers() { for (const id of this._timers) clearTimeout(id); this._timers.length = 0; } }