Files
SoundGame/web/escape/game.js
T

838 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《暗室》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;
}
}