/** * 《暗室》Web 版 R2 —— 3x3 黑暗房间网格密室逃脱(按 docs/design/01-escape-redesign.md 实现)。 * * 流程:start → REOPEN(二周目询问,可选)→ GUIDE(G0-G7 交互式引导; * G0 intro 播完 1.2s 无操作自动推进 G1——g1_move 必播;下滑跳过仅限答案 * 不含滑动的步骤 G0/G3/G5/G6,G1/G2/G4 下滑 = 作答) * → PLAY(自由探索 + 卡关三级提示计时器)→ ENDING → 单击重开 / 下滑菜单。 * * 手势映射(偏离全局默认,引导中讲清): * - 四向滑动:在相邻房间间移动,撞墙播闷响 + ERROR 震动 + wall 语音(每次都播; * PLAY 阶段 wall 播完接播陈述式方位句 wall_dir_「左边是墙。」(escape-state * round2 B1,同向 10s 限频,被打断自然作废);教学腔 guide_blunt_* 仅限引导期) * - 单击:重播当前房间环境音 + 检查描述(储物间拾钥匙); * voice 播放中单击 = 跳过本句(audio.skipVoice(),链播继续,12 号文档) * 并补发检查——环境音即时重播、描述进待播队列等链播播完(不顶句)。 * 不可跳:引导 G0-G7(引导期单击是答题)、关键获得(found_key/ * unlock_cabinet/fix_box/escape 结算链)、L3 带路(stuck_*_3 + nav_*) * - 双击:使用物品(钥匙开柜 / 保险丝修电箱 / 通电后开大门); * 无可用目标时播报背包/use_fail + L1 级意象暗示(escape-state round2 B4: * 原直通 L2 明示把答案白送给没卡关的玩家,降级为 L1;答案仍锁在计时器 * L2/L3 与进展链里) * - 引导阶段下滑:跳过引导(仅限答案不含滑动的步骤 G0/G3/G5/G6;G1/G2 任意 * 滑动即答案、G4 的答案就是下滑——这些步骤下滑 = 作答,不可跳过); * ENDING 下滑:菜单 * * 状态 BGM(10 号文档):引导/探索 = bgm/drone;菜单 = bgm/menu(400ms); * 逃脱结算 = escape 语音链播完淡入 bgm/menu(volume 0.5,1500ms)。 * * 卡关检测:按"无操作时长"计时——PLAY 阶段任何有效手势重置计时 * (round1-live 修复:原按"无进展"计时,玩家活跃操作也每 35~40s 机关枪式 * 报答案,还三次顶掉进行中的语音)。60s 无操作 L1 暗示(stuck_ask + * stuck_*_1)、120s L2 明示(stuck_*_2)、180s L3 带路(stuck_*_3 + 按当前 * 坐标算路链播 nav_*,此后每 60s 无操作重复);进展事件(拾钥匙/开柜/ * 修电箱/首到关键房间/逃脱)清空已播级别,下段静默期从 L1 重新分级。 * 提示到点只进待播队列:等当前语音自然播完再播、绝不顶掉进行中的语音; * 待播期间玩家任何操作即作废该提示(未播级别不计入已播)。 * escape-state round2 新增平行通道(B3):无进展 150s 且期间 ≥5 次有效手势 * (活跃但零进展,挂机仍走 60s L1 体系)→ 播不泄题的进度重述 gd_prog_1/2/3 * (stage=cabinet/box/gate,key 阶段不走本通道),每进展段限 1 次, * 队列语义与卡关提示同款(不顶句、待播期间有操作即作废)。 * * round4 H1(docs/design/14-guide-layer.md「round4 v1.3」):阶段目标句 * (found_key / gd_next_fuse / fix_box)播完后,按房间图计算目标房间相对当前 * 房间的真实方位接播方位句;escape-state round2 B2 把三处固定接线点升级为 * "目标名+方位"一体句(gd_cabinet_dir/gd_box_dir/gd_gate_dir),异常路径方位 * 与枚举不符时回落 gd_room_l/r/u/d(原句保留)。引导期 g7_goal 不接(引导链 * 被 playtest 断言冻结);但跳过引导/REOPEN 直进路径补播 g7_goal(round2 B8: * 任何路径进 PLAY 都至少听过一次目标句)。 * * escape-state round2 逻辑修复:B6 引导 busy 窗口缓存首个非跳过分手势、步骤 * 解锁后按新步骤补判(合法=作答/不合法=答错路径,缓存手势不触发下滑跳过); * B7 结算链播完前 ENDING 单击不重开(_settleBusy 守卫,链完/30s 兜底解除); * B5a 空房首访补播 empty_* 一次性听觉锚(不算进展事件)。 * * 语音 id 权威清单:docs/audio/round2-voice-ids.md(已删除的 n0–n9 数字拼接链、 * hint_ 四级提示、intro/tutorial 旧开场不再引用)。震动分工:滑动/点按的识别反馈由 gesture.js 内置,本层只发语义震动 * (error/success/warning/pickup)。 * * 房间布局(x: 0西..2东, y: 0北..2南): * (0,0) 电箱房·amb_power (1,0) 空房·amb_wind (2,0) 空房·amb_hum * (0,1) 空房·amb_hum (1,1) 起点·amb_drip (2,1) 铁柜房·amb_cabinet * (0,2) 储物间·amb_storage (1,2) 空房·amb_wind (2,2) 大门·amb_gate(通电后 amb_gate_live) */ import { Game } from '../engine/game.js'; import { Guide } from '../engine/guide.js'; import { vibrate } from '../engine/haptics.js'; const Phase = { REOPEN: 'REOPEN', // 二周目:上滑重听引导 / 单击直接开始 GUIDE: 'GUIDE', // G0-G7 交互式引导 PLAY: 'PLAY', ENDING: 'ENDING', MENU: 'MENU', }; const G = { UP: 'up', DOWN: 'down', LEFT: 'left', RIGHT: 'right', TAP: 'tap', DOUBLE_TAP: 'doubletap' }; // 谜题链阶段 → L3 带路目标房间坐标 const STAGES = { key: { target: [0, 2] }, // 找钥匙 → 储物间 cabinet: { target: [2, 1] }, // 开铁柜 → 铁柜房 box: { target: [0, 0] }, // 修电箱 → 电箱房 gate: { target: [2, 2] }, // 开大门 → 大门 }; // 关键房间:坐标 → { 环境音, 首次到达描述 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' }; // 空房首访描述(escape-state round2 B5a:复用现有 empty_*,进入时一次性播; // 与 _inspect 单击检查映射一致——两间"远处水滴"空房 (1,2)/(2,0) 对称分布, // 共用 empty_3 也是合法的记忆点) const EMPTY_FIRST_VOICE = { '1,0': 'voice/empty_1', '0,1': 'voice/empty_2', '1,2': 'voice/empty_3', '2,0': 'voice/empty_3', }; // B3 无进展进度重述:stage → gd_prog id(key 阶段不走本通道) const GD_PROG = { cabinet: 'voice/gd_prog_1', box: 'voice/gd_prog_2', gate: 'voice/gd_prog_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 STATS_KEY = 'soundgame.escape.stats'; const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html) export class EscapeGame extends Game { constructor(audio, root) { super(audio, root); this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op) this._session = 0; // 会话令牌:重开/跳过时使旧计时器与语音链回调失效 this._timers = []; this._stuckTimer = null; this._resetState(); this.phase = Phase.REOPEN; } _resetState() { this.x = 1; this.y = 1; this.hasKey = false; this.hasFuse = false; this.cabinetOpen = false; this.powerOn = false; this.visited = new Set(); // 首到关键房间记录(进展事件 + room_* 描述) this.emptyVisited = new Set(); // 首到空房记录(B5a 首访描述,不算进展事件) this._freshEmpty = null; // 首访句刚播过的空房 key(本房间内单击不重复同一句) this._wallDirAt = {}; // B1 撞墙方位句同向 10s 限频:dir -> 上次播出时刻 this._settleBusy = false; // B7 结算链守卫:播完前 ENDING 单击不重开 this.startElapsedMs = 0; // 单击跳过:当前 voice 句是否可跳(每次 voice 播放时更新,见 _playVoice) this._voiceSkippable = false; // 引导状态 this.guideStep = 0; // 0..7 对应 G0..G7 this.guideSwipes = 0; // G2 撞墙计数 this.guideErrs = 0; // 当前步骤连续错误数 this._guideBusy = false; // 步骤推进语音播放中(忽略非跳过分手势) this._guidePending = null; // B6:busy 窗口缓存的首个非跳过分手势(仅 1 个、不跨步存活) // 卡关检测(按"无操作时长"计时,见 _checkStuck) this._lastActiveAt = Date.now(); this._stuckShown = new Set(); // 当前进展段内已自动播过的级别 'L1'|'L2'|'L3' this._lastL3At = 0; // B3 无进展计时(与无操作计时并存互不复位) this._lastProgressAt = Date.now(); // 上一次进展事件时刻 this._gesturesSinceProgress = 0; // 进展段内有效手势数(≥5 才触发 gd_prog) this._progShown = new Set(); // 已播过 gd_prog 的阶段(每进展段限 1 次) this._pendingVoice = []; // 待播语音队列:排队等当前语音播完,绝不顶句 this._playSeq = 0; // voice 播放请求序号(onComplete 内判断是否链播新句) // 菜单 this._menuReturn = Phase.ENDING; this._menuIdx = 0; this._endingBgmStarted = false; // 结算 pad 是否已起(_escape 幂等守护) } // ---------- Game ---------- start() { this.audio.prewarm(PREWARM_SFX); this.audio.prewarm(['voice/g0_intro', 'voice/g1_move', 'voice/skip_hint', 'voice/reopen_prompt', 'voice/start_room', 'bgm/menu']); this._session++; this._clearTimers(); this._startStuckTimer(); this.audio.play('bgm/drone'); this.guide.state('explore'); // 状态签名:探索(与状态 BGM 同点) const guided = localStorage.getItem(GUIDE_DONE_KEY) === '1'; if (guided) { // 二周目:上滑重听引导 / 单击直接开始 this.phase = Phase.REOPEN; // 抉择前提示必须听完;播完给 turn cue(向导层:REOPEN 静默等待的"轮到你") this._playVoice('voice/reopen_prompt', { skippable: false, onComplete: () => this.guide.turn({}), }); } else { this._startGuide(); } } onGesture(g) { console.log('[EscapeGame] gesture:', g, 'phase=', this.phase, 'pos=', this.x, this.y); this.guide.disarm(); // 任何手势复位向导状态(guide=off 时 no-op) if (this.phase === Phase.PLAY) { // 卡关提示按"无操作时长"计时:任何有效手势重置计时,并作废待播提示 this._lastActiveAt = Date.now(); this._gesturesSinceProgress++; // B3:活跃手势计数(无进展 150s 通道的闸门) this._dropPendingStuck(); } switch (this.phase) { case Phase.REOPEN: if (g === G.UP) { this.audio.stopVoice(); this._startGuide(); } // B8:REOPEN 单击直进路径在 start_room 后接播目标句 g7_goal else if (g === G.TAP) { this.audio.stopVoice(); this._enterPlay(true, true); } break; case Phase.GUIDE: this._onGuideGesture(g); break; case Phase.PLAY: this._onPlayGesture(g); break; case Phase.ENDING: // B7(escape-state round2):结算链播完前单击不立即重开——time_band/again // 的完整到达权(live round1 S7:习惯性单击把结算链截断,两句被永久吞掉) if (g === G.TAP) { if (!this._settleBusy) this._restart(); } else if (g === G.DOWN) this._openMenu(Phase.ENDING); break; case Phase.MENU: this._onMenuGesture(g); break; } } stop() { this._session++; this._clearTimers(); if (this._stuckTimer) { clearInterval(this._stuckTimer); this._stuckTimer = null; } this.audio.stopAll(); } // ---------- 引导(G0-G7) ---------- _startGuide() { this._session++; this._clearTimers(); this._resetState(); this.phase = Phase.GUIDE; this.guideStep = 0; // round2-live P1 回归修复:intro 自然播完后若玩家没有单击,1.2s 自动推进 // G1——g1_move(「先活动一下。朝任意方向滑一下…」)必须播出。原实现干等 // "单击继续"却从无任何一句语音告知该手势(第 1 轮靠解锁单击漏进游戏误推 // 一步被掩盖,解锁修复后暴露为 intro 后 17~91s 死寂、引导链整体进不去)。 this._speak(['voice/g0_intro'], () => { this._timer(() => { if (this.phase === Phase.GUIDE && this.guideStep === 0 && !this._guideBusy) { this._advance(1, ['voice/g1_move', 'voice/skip_hint']); } }, 1200); }, { skippable: false }); } /** * 推进到引导第 step 步:播推进语音,播完才切换步骤号并解锁输入。 * 语音缺失时 20s 看门狗兜底推进,防卡死。 */ _advance(step, ids, onDone) { this._guideBusy = true; const s = this._session; const fromStep = this.guideStep; // 看门狗仅在仍停留在原步骤时兜底,防误触发后续步骤 const done = () => { if (s !== this._session) return; this.guideStep = step; this.guideErrs = 0; this.guideSwipes = 0; this._guideBusy = false; this.guide.turn({}); // turn cue:引导步骤解锁输入(W2 播完即静默,向导补标记) if (onDone) onDone(); // B6(escape-state round2):busy 窗口缓存的手势在步骤解锁后按新步骤补判 // (onDone 若已离开 GUIDE——G6 链尾进 PLAY——缓存直接作废,不跨阶段存活) if (this._guidePending !== null) { const p = this._guidePending; this._guidePending = null; if (this.phase === Phase.GUIDE) this._onGuideGesture(p, { fromCache: true }); } }; this._timer(() => { if (this._guideBusy && this.guideStep === fromStep) done(); }, 20000); // 看门狗 // 引导句不可单击跳过(12 号文档:引导句即操作指令) if (ids && ids.length) this._speak(ids, done, { skippable: false }); else done(); } _onGuideGesture(g, { fromCache = false } = {}) { // 下滑跳过仅限"答案不含滑动"的步骤(G0/G3/G5/G6):G1/G2 的正确答案是 // 任意滑动、G4 的答案就是下滑——这些步骤下滑 = 作答,不可跳过(13 号文档 // 裁定;round2-live:g1_move 明说"朝任意方向滑一下",玩家下滑却被保送 // 自由探索,教学链就此断裂)。B6:busy 缓存补判的手势不再触发下滑跳过 // (live round1 S6:答案在 busy 窗口被吞后,玩家紧接着的下滑被当成跳过引导) if (!fromCache && g === G.DOWN && this.guideStep !== 1 && this.guideStep !== 2 && this.guideStep !== 4) { this._skipGuide(); return; } if (this._guideBusy) { // B6:busy 窗口缓存首个非跳过分手势,推进语音播完(_advance done)后按 // 新步骤补判——合法则直接作答,不合法走答错路径(有纠正语音,可归因)。 // 仅缓存 1 个、只存活到本步骤解锁,杜绝跨步幽灵输入(00 §5.4:做了手势 // 3 秒内必须有反馈——原 busy 静默吞手势违反此条,本缓存是合规修复) if (!fromCache && this._guidePending === null) this._guidePending = g; return; } switch (this.guideStep) { case 0: // G0:单击继续(intro 播完 1.2s 未操作也会自动推进 G1,见 _startGuide) if (g === G.TAP) this._advance(1, ['voice/g1_move', 'voice/skip_hint']); // skip_hint 首局插播一次 // intro 播放中的滑动忽略(不拿"先跟我走"顶掉开场白)——intro 播完自动 // 接 g1_move;intro 已播完(自动推进前的 1.2s 空档)才温和拦回 else if (this._isSwipe(g) && !this.audio.isVoicePlaying()) { this._playVoice('voice/guide_offpath', { skippable: false }); // "先跟我走" } break; case 1: // G1:任意滑动,走一小步 if (this._isSwipe(g)) { this.audio.play('sfx/step', { volume: 0.8 }); this._advance(2, ['voice/g1_done', 'voice/g2_wall']); } else this._guideWrong(null); break; case 2: // G2:再滑两下碰碰墙(引导期位置固定,滑动即撞墙反馈,每次都播 wall) if (this._isSwipe(g)) { this.audio.play('sfx/wall'); vibrate('error'); this.guideSwipes++; if (this.guideSwipes >= 2) this._advance(3, ['voice/wall', 'voice/g3_left']); else this._playVoice('voice/wall', { skippable: false }); } else this._guideWrong(null); break; case 3: // G3:向左滑 → (0,1) if (g === G.LEFT) { this.x = 0; this.y = 1; this.audio.play('sfx/step', { volume: 0.8 }); this._advance(4, ['voice/g4_down']); // g4_down 兼作 G3 反馈与 G4 指令 } else if (this._isSwipe(g)) this._guideWrong(G.LEFT); else this._guideWrong(null); break; case 4: // G4:向下滑 → (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._advance(5, ['voice/g5_inspect']); } else if (this._isSwipe(g)) this._guideWrong(G.DOWN); else this._guideWrong(null); break; case 5: // G5:单击检查 → 拾得钥匙(谜题链第一环) if (g === G.TAP) { this.hasKey = true; this.audio.play('sfx/pickup'); vibrate('pickup'); this._advance(6, ['voice/g5_key', 'voice/g6_doubletap']); } else this._guideWrong(null); break; case 6: // G6:双击(用物品/查看背包的手势) if (g === G.DOUBLE_TAP) { this._advance(7, ['voice/inv_key', 'voice/g6_done', 'voice/g7_goal'], () => this._finishGuide()); } else this._guideWrong(null); break; default: // G7:等待 g7_goal 播完(_advance 的 onDone 驱动),手势忽略 break; } } _finishGuide() { try { localStorage.setItem(GUIDE_DONE_KEY, '1'); } catch (_) { /* 隐私模式 */ } this._enterPlay(false); } _skipGuide() { this._session++; // 作废进行中的引导语音链 this._clearTimers(); this.audio.stopVoice(); this._guidePending = null; try { localStorage.setItem(GUIDE_DONE_KEY, '1'); } catch (_) { /* 隐私模式 */ } this.guideStep = 7; // 跳过链期间视作 G7 等待:滑动手势全部忽略(防链中误触误答/二次跳过) // B8(escape-state round2):跳过确认句后、进 PLAY 前补播目标句 g7_goal—— // 目标陈述原只在引导 G7 链尾出现一次,跳过的玩家(恰是最需要目标的一群) // 永远听不到(live round1 S4:跳过后目标维度完全空白) this._speak(['voice/guide_skip'], () => { this._playVoice('voice/g7_goal', { onComplete: () => this._enterPlay(true) }); }, { skippable: false }); // 语音缺失兜底(两句链:覆盖 guide_skip/g7_goal 缺失情形) this._timer(() => { if (this.phase === Phase.GUIDE) this._enterPlay(true); }, 9000); } /** * 引导纠错:expected 为期望的滑动方向(guide_wrong_),null 表示手势 * 类型错(guide_wrong_gesture)。同一步错 3 次给直白重述 * (guide_blunt_,仅方向步骤有)+ WARNING 震动。 */ _guideWrong(expected) { this.guideErrs++; if (this.guideErrs >= 3) { vibrate('warning'); this._playVoice(expected ? `voice/guide_blunt_${expected}` : 'voice/guide_wrong_gesture', { skippable: false }); this.guideErrs = 0; return; } vibrate('error'); this._playVoice(expected ? `voice/guide_wrong_${expected}` : 'voice/guide_wrong_gesture', { skippable: false }); } // ---------- 进入 / 重开 PLAY ---------- /** * @param {boolean} announce 是否播 start_room(跳过引导/二周目直接进入时) * @param {boolean} withGoal B8:start_room 后接播目标句 g7_goal(REOPEN 直进 * 路径;跳过引导路径的 g7_goal 已在 _enterPlay 前播过,此处不重复) */ _enterPlay(announce, withGoal = false) { this._session++; this._clearTimers(); this.phase = Phase.PLAY; this.startElapsedMs = Date.now(); this._resetStuck(); if (announce) { if (withGoal) { // B8:start_room(可跳)自然播完/被跳过后接播 g7_goal(可跳, // 二周目老手可单击跳过——教学义务句已随首局完成,此处是状态告知) this._playVoice('voice/start_room', { onComplete: () => this._playVoice('voice/g7_goal') }); } else { this._playVoice('voice/start_room'); } } this._playAmbient(); } _restart() { this._session++; this._clearTimers(); this.audio.stopVoice(); this._resetState(); this.phase = Phase.PLAY; this.startElapsedMs = Date.now(); this._resetStuck(); this.audio.play('bgm/drone'); this.guide.state('explore'); this._playVoice('voice/replay'); this._playAmbient(); } // ---------- 探索 ---------- _onPlayGesture(g) { switch (g) { case G.UP: this._move(0, -1); break; case G.DOWN: this._move(0, 1); break; case G.LEFT: this._move(-1, 0); break; case G.RIGHT: this._move(1, 0); break; case G.TAP: // voice 播放中:可跳句单击 = 跳过本句(链播继续)+ 补发检查(round1-live // 修复:原把整个单击当"跳过键"吃掉,检查动作无声丢失)——环境音即时重播, // 描述进待播队列等链播播完,不顶句。不可跳句(关键获得/L3 带路)单击 = // 忽略——若照旧触发检查,描述会顶掉该句。无 voice 时单击 = 原检查语义。 if (this.audio.isVoicePlaying()) { if (!this._voiceSkippable) break; this.audio.skipVoice(); this._inspect({ queue: true }); break; } this._inspect(); break; case G.DOUBLE_TAP: this._useItem(); break; } } _move(dx, dy) { const nx = this.x + dx; const ny = this.y + dy; if (nx < 0 || nx > 2 || ny < 0 || ny > 2) { this.audio.play('sfx/wall'); vibrate('error'); // 撞墙反馈:先播「那边是墙,换个方向。」,播完接播陈述式方位句 // wall_dir_「左边是墙。」(escape-state round2 B1:round1-live 回退掉的 // 是教学腔措辞,方位信息本身保留;复用 14-F2.3 原接线模式——被下一次语音 // 顶掉则自然作废不迟到误导;同方向 10s 限频 1 次防贴墙连撞连播)。 // 方位 = 滑动方向(escape 移动坐标系),按真实撞墙方向取句,归因真话。 const dir = dy < 0 ? 'u' : dy > 0 ? 'd' : dx < 0 ? 'l' : 'r'; const allowDir = !this._wallDirAt[dir] || Date.now() - this._wallDirAt[dir] >= 10000; this._playVoice('voice/wall', { onComplete: allowDir ? () => { this._wallDirAt[dir] = Date.now(); this._playVoice(`voice/wall_dir_${dir}`); } : undefined, }); return; } this.x = nx; this.y = ny; this.audio.play('sfx/step', { volume: 0.8 }); this._playAmbient(); this._freshEmpty = null; // 离开首访句刚播过的空房:单击检查恢复播 empty_* // 首次到达关键房间:进展事件 + 房间描述 const key = `${nx},${ny}`; if (KEY_ROOMS[key] && !this.visited.has(key)) { this.visited.add(key); this._progressEvent(); this._playVoice(KEY_ROOMS[key].room); } else if (EMPTY_FIRST_VOICE[key] && !this.emptyVisited.has(key)) { // B5a(escape-state round2):空房首访补播 empty_* 一次性听觉锚 // (不算进展事件,不清卡关已播级别;重访零语音与关键房间同款) this.emptyVisited.add(key); this._freshEmpty = key; this._playVoice(EMPTY_FIRST_VOICE[key]); } } /** 当前房间环境音 id(大门通电后切 amb_gate_live)。 */ _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 }); } /** * 单击 = 重播当前房间环境音 + 检查描述。 * queue=true(语音播放中单击跳过后的补发):描述进待播队列等当前语音播完, * 不顶句;queue=false(无语音时的原检查语义):描述照常 800ms 后直接播。 */ _inspect({ queue = false } = {}) { this._playAmbient(); const s = this._session; const emit = (id, skippable, onComplete) => { if (queue) this._enqueueVoice('describe', [id], { skippable, onDone: onComplete }); else this._playVoice(id, { skippable, onComplete }); }; // skippable:found_key 是关键获得(不可跳),其余检查描述可跳 const describe = (id, skippable = true) => this._timer(() => { if (s === this._session) emit(id, skippable); }, 800); // 先让环境音进耳朵再播描述 const { x, y } = this; if (x === 0 && y === 2) { // 储物间 if (!this.hasKey && !this.cabinetOpen) { this.hasKey = true; this._progressEvent(); this.audio.play('sfx/pickup'); vibrate('pickup'); // H1(round4)/ B2(escape-state round2):钥匙到手句后接播铁柜房 // "目标名+方位"一体句 gd_cabinet_dir(下一目标 = cabinet (2,1),固定为右)。 this._timer(() => { if (s !== this._session) return; emit('voice/found_key', false, () => 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) { // 铁柜房 describe(this.cabinetOpen ? 'voice/empty_1' : 'voice/inspect_cabinet_locked'); } else if (x === 0 && y === 0) { // 电箱房 describe(this.powerOn ? 'voice/box_fixed' : 'voice/inspect_box_dead'); } else if (x === 2 && y === 2) { // 大门 describe(this.powerOn ? 'voice/inspect_gate_ready' : 'voice/inspect_gate'); } else if (x === 1 && y === 1) { describe('voice/start_room'); } else { // 空房 (1,0)/(0,1)/(1,2)/(2,0):B5a 首访句刚播过的房间内,单击不再重复 // 同一句(_freshEmpty 离开即清;重访后单击检查语义不变,照常播 empty_*) const key = `${x},${y}`; if (this._freshEmpty !== key) describe(EMPTY_FIRST_VOICE[key] || 'voice/empty_3'); } } _useItem() { const { x, y } = this; if (x === 2 && y === 1 && this.hasKey && !this.cabinetOpen) { // 钥匙开铁柜 -> 得保险丝(钥匙消耗) 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'); // G4(round3):拾取反馈链尾接下一目标句「保险丝到手。接下来,去找电箱。」 // (游戏反馈层全档位,gd_economy 先例——走游戏语音链不走向导通道)——堵上 // P2-round2 P0「保险丝到手后目标悬空:没有新指令、没有下文」。 // H1(round4)/ B2(escape-state round2):目标句播完接播"目标名+方位"一体句 // gd_box_dir(电箱房 (0,0) 固定为左)。 this._playVoice('voice/unlock_cabinet', { skippable: false, // 关键获得不可跳 onComplete: () => this._playVoice('voice/gd_next_fuse', { onComplete: () => this._playGoalDir(STAGES.box.target, 'voice/gd_box_dir', 'voice/gd_room_l'), }), }); } else if (x === 0 && y === 0 && this.hasFuse && !this.powerOn) { // 保险丝修电箱 -> 恢复供电(保险丝消耗) this.powerOn = true; this.hasFuse = false; this._progressEvent(); this.audio.play('sfx/spark'); this.audio.play('sfx/power_on', { volume: 0.8 }); vibrate('success'); // H1(round4)/ B2(escape-state round2):通电目标句后接播"目标名+方位" // 一体句 gd_gate_dir(大门 (2,2) 固定为右)。 this._playVoice('voice/fix_box', { skippable: false, // 关键获得不可跳 onComplete: () => this._playGoalDir(STAGES.gate.target, 'voice/gd_gate_dir', 'voice/gd_room_r'), }); } else if (x === 2 && y === 2 && this.powerOn) { // 通电后开大门 -> 逃脱 this._escape(); } else { // 兜底:ERROR 震动 + 背包/use_fail + L1 级意象暗示(escape-state round2 B4: // 原直通 L2 明示把答案白送给没卡关的玩家——live round1 两次实录;降级为 L1 // 意象句(不点明房间与动作,零泄漏),答案仍锁在计时器 L2/L3 与进展链里。 // 本进展段已播过 L1 则不重复;播了即记账(沿用 _stuckShown 既有去重)) vibrate('error'); const inKeyRoom = `${x},${y}` in KEY_ROOMS; const ids = [inKeyRoom ? 'voice/use_fail' : this._inventoryVoice()]; if (!this._stuckShown.has('L1')) { this._stuckShown.add('L1'); ids.push(`voice/stuck_${this._stage()}_1`); } this._speak(ids); } } _inventoryVoice() { if (this.hasKey && this.hasFuse) return 'voice/inv_both'; if (this.hasKey) return 'voice/inv_key'; if (this.hasFuse) return 'voice/inv_fuse'; return 'voice/inv_empty'; } /** 当前谜题链阶段:找钥匙 → 开铁柜 → 修电箱 → 开大门。 */ _stage() { if (!this.hasKey && !this.cabinetOpen) return 'key'; if (!this.cabinetOpen) return 'cabinet'; if (!this.powerOn) return 'box'; return 'gate'; } /** * H1(round4):目标房间相对当前房间的方位句(gd_room_l/r/u/d 之一)。 * 方向语义用本游戏移动坐标系:up/down/left/right 滑动方向即方位 * (y 0北..2南、x 0西..2东,向上滑 dy=-1)。斜对角只播一句,轴序与 * _navVoices 一致(先东西后南北);目标在同一房间不播(返回 null)。 */ _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'; } /** * H1(round4)/ B2(escape-state round2):目标句播完后接播目标房间方位句 * (游戏反馈层全档位,gd_next_fuse 先例)。B2:三处固定接线点传 namedId + * expectedId,计算方位与枚举一致时播"目标名+方位"一体句;异常路径不符时 * 回落原 gd_room_*(归因真话,原句保留不删)。 */ _playGoalDir(target, namedId = null, expectedId = null) { const dir = this._roomDirVoice(target); if (!dir) return; this._playVoice(namedId && dir === expectedId ? namedId : dir); } // ---------- 卡关检测与三级提示 ---------- _startStuckTimer() { if (this._stuckTimer) clearInterval(this._stuckTimer); this._stuckTimer = setInterval(() => this._checkStuck(), 5000); } _resetStuck() { this._lastActiveAt = Date.now(); this._stuckShown = new Set(); this._lastL3At = 0; // B3 无进展计时:进入/重开 PLAY 时起算点与闸门一并复位 this._lastProgressAt = Date.now(); this._gesturesSinceProgress = 0; this._progShown = new Set(); } /** 进展事件:清空已播提示级别,下段静默期从 L1 重新分级(计时由手势重置)。 */ _progressEvent() { this._stuckShown.clear(); this._lastL3At = 0; // B3:无进展计时起算点与活跃手势计数复位(_progShown 按阶段记账,不清) this._lastProgressAt = Date.now(); this._gesturesSinceProgress = 0; } /** * 卡关轮询(5s):按"无操作时长"分级触发提示(60s 暗示 / 120s 明示 / 180s 带路)。 * 到点只进待播队列(_enqueueVoice):语音在播则排队等其自然播完,绝不顶句; * 待播期间玩家任何操作即作废(见 _dropPendingStuck)。 */ _checkStuck() { if (this.phase !== Phase.PLAY) return; const idleMs = Date.now() - this._lastActiveAt; const stage = this._stage(); if (idleMs >= 180000) { // L3 带路:首次或每 60s 重复,按当前坐标重算路线(带路语音不可单击跳过) if (!this._stuckShown.has('L3') || Date.now() - this._lastL3At >= 60000) { this._stuckShown.add('L3'); this._lastL3At = Date.now(); vibrate('warning'); this._enqueueVoice('stuck', [`voice/stuck_${stage}_3`, ...this._navVoices(STAGES[stage].target)], { skippable: false, level: 'L3' }); } } else if (idleMs >= 120000 && !this._stuckShown.has('L2')) { this._stuckShown.add('L2'); vibrate('warning'); this._enqueueVoice('stuck', [`voice/stuck_${stage}_2`], { level: 'L2' }); } else if (idleMs >= 60000 && !this._stuckShown.has('L1')) { this._stuckShown.add('L1'); vibrate('warning'); this._enqueueVoice('stuck', ['voice/stuck_ask', `voice/stuck_${stage}_1`], { level: 'L1' }); } // B3(escape-state round2):无进展 150s 活跃计时器(与无操作计时并存互不复位) // ——活跃但零进展的玩家收到一条不泄题的进度重述 gd_prog_*(完成数由 _stage() // 反推,归因真话)。闸门:期间 ≥5 次有效手势(=活跃探索;挂机交给 60s L1 // 体系);key 阶段不走本通道(L1/L2/L3 已覆盖"找不到钥匙",避免双计时器 // 对同一阶段轰炸);每进展段限 1 次(触发即记账,待播被操作作废也不重发); // 队列语义与卡关提示同款(绝不顶句,待播期间有操作即作废),warning 震同款。 if (stage !== 'key' && !this._progShown.has(stage) && Date.now() - this._lastProgressAt >= 150000 && this._gesturesSinceProgress >= 5) { this._progShown.add(stage); vibrate('warning'); this._enqueueVoice('stuck', [GD_PROG[stage]], { level: 'PROG' }); } } /** 从当前坐标到目标房间的导航语音(每轴一句,最多两句)。 */ _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; } // ---------- 菜单 ---------- _openMenu(returnPhase) { this._menuReturn = returnPhase; this._menuIdx = 0; this.phase = Phase.MENU; // 状态 BGM:菜单 = bgm/menu(10 号文档,400ms crossfade;与结算 pad 同音量 0.5, // 同一首引擎不重启,关菜单回 ENDING 时无缝保持) this.audio.play('bgm/menu', { volume: 0.5, fadeMs: 400 }); this.guide.state('menu'); this._speak(['voice/menu_open', 'voice/menu_restart'], null, { skippable: false }); } _onMenuGesture(g) { const items = ['voice/menu_restart', 'voice/menu_home', 'voice/menu_back']; if (g === G.TAP) { this._menuIdx = (this._menuIdx + 1) % items.length; this._playVoice(items[this._menuIdx], { skippable: false }); } else if (g === G.UP) { this._playVoice('voice/help_ops', { skippable: false }); // 操作说明 } else if (g === G.DOWN) { this._closeMenu(); // 下滑 = 关闭菜单(13 号文档 §7.5 菜单约定) } else if (g === G.DOUBLE_TAP) { const sel = this._menuIdx; if (sel === 0) { this.phase = this._menuReturn; this._restart(); } // 重开:_restart 内切回 bgm/drone else if (sel === 1) this._goHome(); else this._closeMenu(); // "返回" } } /** 关闭菜单回到来源状态,并按来源恢复状态 BGM(ENDING 保持 menu pad)。 */ _closeMenu() { this.phase = this._menuReturn; if (this._menuReturn === Phase.ENDING) { // 返回结算:结算态 BGM 即 bgm/menu(同 id 引擎不重启,无缝保持) this.audio.play('bgm/menu', { volume: 0.5 }); } else { this.audio.play('bgm/drone'); // 关闭菜单切回主题曲 this.guide.state('explore'); } } _goHome() { this.stop(); window.location.href = '../'; } // ---------- 结局 ---------- _escape() { this.phase = Phase.ENDING; this._progressEvent(); vibrate('success'); this.audio.stopBgm(2500); this.guide.state('settle'); // 状态签名:结算(逃脱) this._endingBgmStarted = false; // B7(escape-state round2):结算链守卫——播完前 ENDING 单击不立即重开 // (_startEndingBgm 处解除:链自然播完或 30s 资产缺失兜底,两条路径汇合) this._settleBusy = true; const totalSec = Math.floor((Date.now() - this.startElapsedMs) / 1000); this._saveEscape(totalSec); // 结算语音链(关键获得,不可跳);播完淡入 menu pad(10 号文档:volume 0.5,1500ms) this._speak(['voice/escape', this._timeBandVoice(totalSec), 'voice/again'], () => this._startEndingBgm(), { skippable: false }); // 开门/胜利音效后移 2.6s(round2-live P2 修复:原与 escape 人声同帧对撞, // gate_open 2.8s 轰鸣恰好盖住台词头句「你扳动开关。电机轰鸣,」,三次 live // 开头全丢;后移后人声头部无遮挡,轰鸣落在「电机轰鸣,铁门缓缓升起」附近) this._timer(() => { this.audio.play('sfx/gate_open'); this.audio.play('sfx/win_chime', { volume: 0.8 }); }, 2600); // 语音资产缺失时链不推进,30s 兜底起结算 pad(escape+time_band+again 正常 ~10s 内播完) this._timer(() => this._startEndingBgm(), 30000); } /** 逃脱结算 BGM:drone 淡出后淡入 bgm/menu(幂等,仅在仍是结算态时生效)。 */ _startEndingBgm() { if (this._endingBgmStarted || this.phase !== Phase.ENDING) return; this._settleBusy = false; // B7:结算链播完(或 30s 兜底)后解除单击守卫 this._endingBgmStarted = true; this.audio.play('bgm/menu', { volume: 0.5, fadeMs: 1500 }); } /** 用时播报:4 段整句(time_band1..4),替代数字拼接。 */ _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'; } // ---------- 进度存档 ---------- _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(), }); } /** PUT /api/progress(未登录/服务器不可达静默跳过)。 */ _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 (_) { /* 静默 */ } } // ---------- 工具 ---------- _isSwipe(g) { return g === G.UP || g === G.DOWN || g === G.LEFT || g === G.RIGHT; } /** * 对白包装:登记本句是否允许单击跳过(12 号文档裁定表,默认可跳)。 * 本句自然播完(含被单击跳过)且没有链播新句时,补播待播队列里的语音。 */ _playVoice(id, { skippable = true, onComplete } = {}) { this._playSeq++; this._voiceSkippable = skippable; this.audio.play(id, { onComplete: () => { const seq = this._playSeq; if (onComplete) onComplete(); if (seq === this._playSeq && !this.audio.isVoicePlaying()) this._pumpPending(); }, }); } /** * 待播语音队列:排队等当前语音自然播完,绝不顶掉进行中的语音(round1-live * 修复:卡关提示/补发检查原直接开播,三次顶掉进行中的语音)。 * kind='stuck'(卡关提示,带 level):待播期间玩家任何操作即作废; * kind='describe'(跳过语音后补发的检查描述):连点时只补最新一次。 */ _enqueueVoice(kind, ids, { skippable = true, onDone = null, level = null } = {}) { if (kind === 'describe') { this._pendingVoice = this._pendingVoice.filter((p) => p.kind !== 'describe'); } this._pendingVoice.push({ kind, level, ids, skippable, onDone, s: this._session }); this._pumpPending(); } /** 当前无语音在播时播出队首待播句(仅限 PLAY 阶段;跨会话条目直接作废)。 */ _pumpPending() { if (this.phase !== Phase.PLAY || this.audio.isVoicePlaying()) return; while (this._pendingVoice.length) { const item = this._pendingVoice.shift(); if (item.s !== this._session) continue; this._speak(item.ids, item.onDone, { skippable: item.skippable }); return; } } /** 玩家有操作了:作废待播的卡关提示;未播级别不计入已播,下段静默期可重新触发。 */ _dropPendingStuck() { this._pendingVoice = this._pendingVoice.filter((p) => { if (p.kind !== 'stuck') return true; this._stuckShown.delete(p.level); if (p.level === 'L3') this._lastL3At = 0; return false; }); } /** 对白排队依次播放(onComplete 链式触发;被打断即作废整条链)。 */ _speak(ids, onDone, { skippable = true } = {}) { if (ids.length === 0) { if (onDone) onDone(); return; } const s = this._session; this._playVoice(ids[0], { skippable, onComplete: () => { if (s !== this._session) return; if (ids.length > 1) this._speak(ids.slice(1), onDone, { skippable }); else if (onDone) onDone(); }, }); } _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; } }