Files
SoundGame/web/escape/game.js
T
xsl 963dfa1cc8 fix(escape): 暗室状态引导方案落地(25号策划文档 B1-B8 → F1-F9 实施)
策划轮:25-escape-map-flow.md as-built地图/玩家流程/信息账本+盲区清单(S1-S7)+方案B1-B8
实施(玩法零改动,只加状态引导层):
- F1 撞墙方位句「左边是墙」(wall_dir_*×4,同向10s限频)
- F2 目标句并入方位「铁柜,在右面的房间」(gd_*_dir×3)
- F3 无进展150s活跃计时器+gd_prog_1..3进度重述(只重述不泄题,专治活跃迷路玩家——修卡关计时从「无进展」退化为「无操作」的既定偏差)
- F4 双击兜底直通L2降级L1(不再白送答案)
- F5 空房首访描述(4空房可区分)
- F7 busy窗口手势缓存补判(原静默吞手势)
- F8 结算链单击守卫(time_band/again完整到达)
- F9 跳过引导补播目标句
- F6 空房方位特征句按25号裁定留档后续轮
新增语音10句×2性别(flock串行QC无离群,ASR逐字正确)
验证:escape playtest 235/235(+85新断言)+ guide 132/132 + 终检全绿 + 14游戏回归零回退
live复跑10.4分钟通关:零截断,S1-S7盲区逐条核销
档案 research/escape-state/round1-2;01号文档追加修订记录R1
2026-09-15 00:37:41 +08:00

961 lines
43 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 阶段 wall 播完接播陈述式方位句 wall_dir_<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_<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(跳过引导/二周目直接进入时)
* @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_<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;
}
}