838 lines
34 KiB
JavaScript
838 lines
34 KiB
JavaScript
/**
|
||
* 《暗室》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 阶段只播「那边是墙」,教学腔方位句 guide_blunt_* 仅限引导期)
|
||
* - 单击:重播当前房间环境音 + 检查描述(储物间拾钥匙);
|
||
* voice 播放中单击 = 跳过本句(audio.skipVoice(),链播继续,12 号文档)
|
||
* 并补发检查——环境音即时重播、描述进待播队列等链播播完(不顶句)。
|
||
* 不可跳:引导 G0-G7(引导期单击是答题)、关键获得(found_key/
|
||
* unlock_cabinet/fix_box/escape 结算链)、L3 带路(stuck_*_3 + nav_*)
|
||
* - 双击:使用物品(钥匙开柜 / 保险丝修电箱 / 通电后开大门);
|
||
* 无可用目标时播报背包 + L2 级明示提示
|
||
* - 引导阶段下滑:跳过引导(仅限答案不含滑动的步骤 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 重新分级。
|
||
* 提示到点只进待播队列:等当前语音自然播完再播、绝不顶掉进行中的语音;
|
||
* 待播期间玩家任何操作即作废该提示(未播级别不计入已播)。
|
||
*
|
||
* round4 H1(docs/design/14-guide-layer.md「round4 v1.3」):阶段目标句
|
||
* (found_key / gd_next_fuse / fix_box)播完后,按房间图计算目标房间相对当前
|
||
* 房间的真实方位,接播 gd_room_l/r/u/d 之一(游戏反馈层全档位;目标在同一
|
||
* 房间不播;方向 = escape 移动坐标系,up/down/left/right 滑动方向即方位)。
|
||
* 引导期 g7_goal 不接(引导本身逐句带路,且引导链被 playtest 断言冻结)。
|
||
*
|
||
* 语音 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' };
|
||
|
||
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.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; // 步骤推进语音播放中(忽略非跳过分手势)
|
||
// 卡关检测(按"无操作时长"计时,见 _checkStuck)
|
||
this._lastActiveAt = Date.now();
|
||
this._stuckShown = new Set(); // 当前进展段内已自动播过的级别 'L1'|'L2'|'L3'
|
||
this._lastL3At = 0;
|
||
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._dropPendingStuck();
|
||
}
|
||
switch (this.phase) {
|
||
case Phase.REOPEN:
|
||
if (g === G.UP) { this.audio.stopVoice(); this._startGuide(); }
|
||
else if (g === G.TAP) { this.audio.stopVoice(); this._enterPlay(true); }
|
||
break;
|
||
case Phase.GUIDE:
|
||
this._onGuideGesture(g);
|
||
break;
|
||
case Phase.PLAY:
|
||
this._onPlayGesture(g);
|
||
break;
|
||
case Phase.ENDING:
|
||
if (g === G.TAP) 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();
|
||
};
|
||
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) {
|
||
// 下滑跳过仅限"答案不含滑动"的步骤(G0/G3/G5/G6):G1/G2 的正确答案是
|
||
// 任意滑动、G4 的答案就是下滑——这些步骤下滑 = 作答,不可跳过(13 号文档
|
||
// 裁定;round2-live:g1_move 明说"朝任意方向滑一下",玩家下滑却被保送
|
||
// 自由探索,教学链就此断裂)
|
||
if (g === G.DOWN && this.guideStep !== 1 && this.guideStep !== 2 && this.guideStep !== 4) {
|
||
this._skipGuide();
|
||
return;
|
||
}
|
||
if (this._guideBusy) 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();
|
||
try { localStorage.setItem(GUIDE_DONE_KEY, '1'); } catch (_) { /* 隐私模式 */ }
|
||
this._speak(['voice/guide_skip'], () => this._enterPlay(true), { skippable: false });
|
||
// 语音缺失兜底
|
||
this._timer(() => { if (this.phase === Phase.GUIDE) this._enterPlay(true); }, 5000);
|
||
}
|
||
|
||
/**
|
||
* 引导纠错:expected 为期望的滑动方向(guide_wrong_<dir>),null 表示手势
|
||
* 类型错(guide_wrong_gesture)。同一步错 3 次给直白重述
|
||
* (guide_blunt_<dir>,仅方向步骤有)+ 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(跳过引导/二周目直接进入时) */
|
||
_enterPlay(announce) {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.phase = Phase.PLAY;
|
||
this.startElapsedMs = Date.now();
|
||
this._resetStuck();
|
||
if (announce) 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');
|
||
// 撞墙极简反馈:只播「那边是墙,换个方向。」(round1-live 修复:原接播
|
||
// guide_blunt_<dir>「手指贴着屏幕,向 X 推一下」教学腔方位句,中后期出戏;
|
||
// 教学腔仅限引导期 G2/G3 纠错路径,见 _onGuideGesture/_guideWrong)
|
||
this._playVoice('voice/wall');
|
||
return;
|
||
}
|
||
this.x = nx;
|
||
this.y = ny;
|
||
this.audio.play('sfx/step', { volume: 0.8 });
|
||
this._playAmbient();
|
||
// 首次到达关键房间:进展事件 + 房间描述
|
||
const key = `${nx},${ny}`;
|
||
if (KEY_ROOMS[key] && !this.visited.has(key)) {
|
||
this.visited.add(key);
|
||
this._progressEvent();
|
||
this._playVoice(KEY_ROOMS[key].room);
|
||
}
|
||
}
|
||
|
||
/** 当前房间环境音 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):钥匙到手句后接播铁柜房方位(下一目标 = cabinet (2,1))。
|
||
this._timer(() => {
|
||
if (s !== this._session) return;
|
||
emit('voice/found_key', false, () => this._playGoalDir(STAGES.cabinet.target));
|
||
}, 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 if (x === 1 && y === 0) {
|
||
describe('voice/empty_1');
|
||
} else if (x === 0 && y === 1) {
|
||
describe('voice/empty_2');
|
||
} else {
|
||
describe('voice/empty_3'); // (1,2)、(2,0)
|
||
}
|
||
}
|
||
|
||
_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):目标句播完接播目标房间方位句(P3 唯一拒因:目标无方位)。
|
||
this._playVoice('voice/unlock_cabinet', {
|
||
skippable: false, // 关键获得不可跳
|
||
onComplete: () => this._playVoice('voice/gd_next_fuse', {
|
||
onComplete: () => this._playGoalDir(STAGES.box.target),
|
||
}),
|
||
});
|
||
} 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):通电目标句后接播大门方位(下一目标 = gate (2,2))。
|
||
this._playVoice('voice/fix_box', {
|
||
skippable: false, // 关键获得不可跳
|
||
onComplete: () => this._playGoalDir(STAGES.gate.target),
|
||
});
|
||
} else if (x === 2 && y === 2 && this.powerOn) {
|
||
// 通电后开大门 -> 逃脱
|
||
this._escape();
|
||
} else {
|
||
// 兜底:ERROR 震动 + 背包/use_fail + L2 级明示(ui_deny 已退役)
|
||
vibrate('error');
|
||
const inKeyRoom = `${x},${y}` in KEY_ROOMS;
|
||
this._speak([
|
||
inKeyRoom ? 'voice/use_fail' : this._inventoryVoice(),
|
||
`voice/stuck_${this._stage()}_2`,
|
||
]);
|
||
}
|
||
}
|
||
|
||
_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:目标句播完后接播目标房间方位句(游戏反馈层全档位,gd_next_fuse 先例)。 */
|
||
_playGoalDir(target) {
|
||
const dir = this._roomDirVoice(target);
|
||
if (dir) this._playVoice(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;
|
||
}
|
||
|
||
/** 进展事件:清空已播提示级别,下段静默期从 L1 重新分级(计时由手势重置)。 */
|
||
_progressEvent() {
|
||
this._stuckShown.clear();
|
||
this._lastL3At = 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' });
|
||
}
|
||
}
|
||
|
||
/** 从当前坐标到目标房间的导航语音(每轴一句,最多两句)。 */
|
||
_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;
|
||
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._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;
|
||
}
|
||
}
|