Files
SoundGame/web/escape/game.js
T

708 lines
27 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 交互式引导,下滑可跳过)
* → PLAY(自由探索 + 卡关三级提示计时器)→ ENDING → 单击重开 / 下滑菜单。
*
* 手势映射(偏离全局默认,引导中讲清):
* - 四向滑动:在相邻房间间移动,撞墙播闷响 + ERROR 震动 + wall 语音(每次都播)
* - 单击:重播当前房间环境音 + 检查描述(储物间拾钥匙);
* voice 播放中单击 = 跳过本句(audio.skipVoice(),链播继续,12 号文档)。
* 不可跳:引导 G0-G7(引导期单击是答题)、关键获得(found_key/
* unlock_cabinet/fix_box/escape 结算链)、L3 带路(stuck_*_3 + nav_*)
* - 双击:使用物品(钥匙开柜 / 保险丝修电箱 / 通电后开大门);
* 无可用目标时播报背包 + L2 级明示提示
* - 引导阶段下滑:跳过引导(G4 除外——G4 的答案就是下滑);ENDING 下滑:菜单
*
* 状态 BGM(10 号文档):引导/探索 = bgm/drone;菜单 = bgm/menu(400ms);
* 逃脱结算 = escape 语音链播完淡入 bgm/menu(volume 0.5,1500ms)。
*
* 卡关检测:进展事件(拾钥匙/开柜/修电箱/首到关键房间/逃脱)重置计时;
* 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 { 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; // 步骤推进语音播放中(忽略非跳过分手势)
// 卡关检测
this._lastProgressAt = Date.now();
this._stuckShown = new Set(); // 当前进展段内已自动播过的级别 'L1'|'L2'|'L3'
this._lastL3At = 0;
this._wallBumps = 0; // 连续撞墙计数(向导层提前一级提示用)
// 菜单
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)
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'], null, { 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) {
// 任何步骤可下滑跳过——G4 除外:该步的正确答案就是下滑(01 号文档"G4=下滑"
// 与"任何步骤下滑跳过"冲突,以 13 号试玩标准为准:G4 下滑 = 作答,不可跳过)
if (g === G.DOWN && this.guideStep !== 4) { this._skipGuide(); return; }
if (this._guideBusy) return; // 推进语音播放中,忽略
switch (this.guideStep) {
case 0: // G0:单击继续
if (g === G.TAP) this._advance(1, ['voice/g1_move', 'voice/skip_hint']); // skip_hint 首局插播一次
else if (this._isSwipe(g)) 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 播放中:可跳句单击 = 跳过本句(链播继续);不可跳句(关键获得/
// L3 带路)单击 = 忽略——若照旧触发检查,800ms 后的描述会顶掉该句。
// 无 voice 时单击 = 原检查语义。
if (this.audio.isVoicePlaying()) {
if (this._voiceSkippable) this.audio.skipVoice();
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');
// F2.3(round2):撞墙反馈接既有 guide_blunt_<dir> 方向句(纯逻辑接线,
// 资产 v1 已有)——「那边是墙」之后补一句方位("手指贴着屏幕,向上推一下"),
// 噪音环境下听不出墙音方向时也知道刚才顶到的是哪边。排在本句播完之后;
// 被下一次滑动打断则自然作废(不迟到误导)。
const dir = dy < 0 ? 'up' : dy > 0 ? 'down' : dx < 0 ? 'left' : 'right';
this._playVoice('voice/wall', {
onComplete: () => this._playVoice(`voice/guide_blunt_${dir}`),
});
this._wallBumps++; // 向导层:连续撞墙 = 卡关信号(_checkStuck 提前一级)
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 });
}
/** 单击 = 重播当前房间环境音 + 检查描述。 */
_inspect() {
this._playAmbient();
const s = this._session;
// skippable:found_key 是关键获得(不可跳),其余检查描述可跳
const describe = (id, skippable = true) => this._timer(() => {
if (s === this._session) this._playVoice(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');
describe('voice/found_key', false);
} 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「保险丝到手后目标悬空:没有新指令、没有下文」。
this._playVoice('voice/unlock_cabinet', {
skippable: false, // 关键获得不可跳
onComplete: () => this._playVoice('voice/gd_next_fuse'),
});
} 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._playVoice('voice/fix_box', { skippable: false }); // 关键获得不可跳
} 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;
this._wallBumps = 0;
}
/** 进展事件:重置卡关计时与已播级别。 */
_progressEvent() {
this._resetStuck();
}
_checkStuck() {
if (this.phase !== Phase.PLAY) return;
const idleMs = Date.now() - this._lastProgressAt;
const stage = this._stage();
// 向导层(schemes §2):连续撞墙 ≥2 次是明确的卡关信号——向导 full 档时
// 三级阈值整体提前一级(L1 即刻、L2 60s、L3 120s);off/standard 保持原阈值。
const early = this._wallBumps >= 2 && this.guide.level() === 'full';
const T_L1 = early ? 0 : 60000;
const T_L2 = early ? 60000 : 120000;
const T_L3 = early ? 120000 : 180000;
if (idleMs >= T_L3) {
// 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)], null, { skippable: false });
}
} else if (idleMs >= T_L2 && !this._stuckShown.has('L2')) {
this._stuckShown.add('L2');
vibrate('warning');
this._speak([`voice/stuck_${stage}_2`]);
} else if (idleMs >= T_L1 && !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;
// 状态 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.play('sfx/gate_open');
this.audio.play('sfx/win_chime', { volume: 0.8 });
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 });
// 语音资产缺失时链不推进,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._voiceSkippable = skippable;
this.audio.play(id, { onComplete });
}
/** 对白排队依次播放(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;
}
}