/**
* 《暗室》Web 版 R2 —— 3x3 黑暗房间网格密室逃脱(按 docs/design/01-escape-redesign.md 实现)。
*
* 流程:start → REOPEN(二周目询问,可选)→ GUIDE(G0-G7 交互式引导,下滑可跳过)
* → PLAY(自由探索 + 卡关三级提示计时器)→ ENDING → 单击重开 / 下滑菜单。
*
* 手势映射(偏离全局默认,引导中讲清):
* - 四向滑动:在相邻房间间移动,撞墙播闷响 + ERROR 震动 + wall 语音(每次都播)
* - 单击:重播当前房间环境音 + 检查描述(储物间拾钥匙)
* - 双击:使用物品(钥匙开柜 / 保险丝修电箱 / 通电后开大门);
* 无可用目标时播报背包 + L2 级明示提示
* - 引导阶段下滑:跳过引导;ENDING 下滑:菜单
*
* 卡关检测:进展事件(拾钥匙/开柜/修电箱/首到关键房间/逃脱)重置计时;
* 60s 无进展 L1 暗示(stuck_ask + stuck_*_1)、120s L2 明示(stuck_*_2)、
* 180s L3 带路(stuck_*_3 + 按当前坐标算路链播 nav_*,此后每 60s 重复)。
*
* 语音 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 { 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._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;
// 引导状态
this.guideStep = 0; // 0..7 对应 G0..G7
this.guideSwipes = 0; // G2 撞墙计数
this.guideErrs = 0; // 当前步骤连续错误数
this._guideBusy = false; // 步骤推进语音播放中(忽略非跳过分手势)
// 卡关检测
this._lastProgressAt = Date.now();
this._stuckShown = new Set(); // 当前进展段内已自动播过的级别 'L1'|'L2'|'L3'
this._lastL3At = 0;
// 菜单
this._menuReturn = Phase.ENDING;
this._menuIdx = 0;
}
// ---------- Game ----------
start() {
this.audio.prewarm(PREWARM_SFX);
this.audio.prewarm(['voice/g0_intro', 'voice/g1_move', 'voice/reopen_prompt', 'voice/start_room']);
this._session++;
this._clearTimers();
this._startStuckTimer();
this.audio.play('bgm/drone');
const guided = localStorage.getItem(GUIDE_DONE_KEY) === '1';
if (guided) {
// 二周目:上滑重听引导 / 单击直接开始
this.phase = Phase.REOPEN;
this.audio.play('voice/reopen_prompt');
} else {
this._startGuide();
}
}
onGesture(g) {
console.log('[EscapeGame] gesture:', g, 'phase=', this.phase, 'pos=', this.x, this.y);
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;
this._speak(['voice/g0_intro']);
}
/**
* 推进到引导第 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;
if (onDone) onDone();
};
this._timer(() => {
if (this._guideBusy && this.guideStep === fromStep) done();
}, 20000); // 看门狗
if (ids && ids.length) this._speak(ids, done);
else done();
}
_onGuideGesture(g) {
if (g === G.DOWN) { this._skipGuide(); return; } // 任何步骤可下滑跳过
if (this._guideBusy) return; // 推进语音播放中,忽略
switch (this.guideStep) {
case 0: // G0:单击继续
if (g === G.TAP) this._advance(1, ['voice/g1_move']);
else if (this._isSwipe(g)) this.audio.play('voice/guide_offpath'); // "先跟我走"
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.audio.play('voice/wall');
} 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));
// 语音缺失兜底
this._timer(() => { if (this.phase === Phase.GUIDE) this._enterPlay(true); }, 5000);
}
/**
* 引导纠错:expected 为期望的滑动方向(guide_wrong_
),null 表示手势
* 类型错(guide_wrong_gesture)。同一步错 3 次给直白重述
* (guide_blunt_,仅方向步骤有)+ WARNING 震动。
*/
_guideWrong(expected) {
this.guideErrs++;
if (this.guideErrs >= 3) {
vibrate('warning');
this.audio.play(expected ? `voice/guide_blunt_${expected}` : 'voice/guide_wrong_gesture');
this.guideErrs = 0;
return;
}
vibrate('error');
this.audio.play(expected ? `voice/guide_wrong_${expected}` : 'voice/guide_wrong_gesture');
}
// ---------- 进入 / 重开 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.audio.play('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.audio.play('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: 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');
this.audio.play('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.audio.play(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 });
}
/** 单击 = 重播当前房间环境音 + 检查描述。 */
_inspect() {
this._playAmbient();
const s = this._session;
const describe = (id) => this._timer(() => {
if (s === this._session) this.audio.play(id);
}, 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');
describe('voice/found_key');
} 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');
this.audio.play('voice/unlock_cabinet');
} 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');
this.audio.play('voice/fix_box');
} 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';
}
// ---------- 卡关检测与三级提示 ----------
_startStuckTimer() {
if (this._stuckTimer) clearInterval(this._stuckTimer);
this._stuckTimer = setInterval(() => this._checkStuck(), 5000);
}
_resetStuck() {
this._lastProgressAt = Date.now();
this._stuckShown = new Set();
this._lastL3At = 0;
}
/** 进展事件:重置卡关计时与已播级别。 */
_progressEvent() {
this._resetStuck();
}
_checkStuck() {
if (this.phase !== Phase.PLAY) return;
const idleMs = Date.now() - this._lastProgressAt;
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._speak([`voice/stuck_${stage}_3`, ...this._navVoices(STAGES[stage].target)]);
}
} else if (idleMs >= 120000 && !this._stuckShown.has('L2')) {
this._stuckShown.add('L2');
vibrate('warning');
this._speak([`voice/stuck_${stage}_2`]);
} else if (idleMs >= 60000 && !this._stuckShown.has('L1')) {
this._stuckShown.add('L1');
vibrate('warning');
this._speak(['voice/stuck_ask', `voice/stuck_${stage}_1`]);
}
}
/** 从当前坐标到目标房间的导航语音(每轴一句,最多两句)。 */
_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;
this._speak(['voice/menu_open', 'voice/menu_restart']);
}
_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.audio.play(items[this._menuIdx]);
} else if (g === G.UP) {
this.audio.play('voice/help_ops'); // 操作说明
} else if (g === G.DOUBLE_TAP) {
const sel = this._menuIdx;
this.phase = this._menuReturn;
if (sel === 0) this._restart();
else if (sel === 1) this._goHome();
// sel === 2 返回游戏:phase 已恢复
}
}
_goHome() {
this.stop();
window.location.href = '../';
}
// ---------- 结局 ----------
_escape() {
this.phase = Phase.ENDING;
this._progressEvent();
vibrate('success');
this.audio.play('sfx/gate_open');
this.audio.play('sfx/win_chime', { volume: 0.8 });
this.audio.stopBgm(2500);
const totalSec = Math.floor((Date.now() - this.startElapsedMs) / 1000);
this._saveEscape(totalSec);
this._speak(['voice/escape', this._timeBandVoice(totalSec), 'voice/again']);
}
/** 用时播报: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;
}
/** 对白排队依次播放(onComplete 链式触发;被打断即作废整条链)。 */
_speak(ids, onDone) {
if (ids.length === 0) { if (onDone) onDone(); return; }
const s = this._session;
this.audio.play(ids[0], {
onComplete: () => {
if (s !== this._session) return;
if (ids.length > 1) this._speak(ids.slice(1), onDone);
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;
}
}