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
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+163
-40
@@ -8,14 +8,17 @@
|
||||
*
|
||||
* 手势映射(偏离全局默认,引导中讲清):
|
||||
* - 四向滑动:在相邻房间间移动,撞墙播闷响 + ERROR 震动 + wall 语音(每次都播;
|
||||
* PLAY 阶段只播「那边是墙」,教学腔方位句 guide_blunt_* 仅限引导期)
|
||||
* 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_*)
|
||||
* - 双击:使用物品(钥匙开柜 / 保险丝修电箱 / 通电后开大门);
|
||||
* 无可用目标时播报背包 + L2 级明示提示
|
||||
* 无可用目标时播报背包/use_fail + L1 级意象暗示(escape-state round2 B4:
|
||||
* 原直通 L2 明示把答案白送给没卡关的玩家,降级为 L1;答案仍锁在计时器
|
||||
* L2/L3 与进展链里)
|
||||
* - 引导阶段下滑:跳过引导(仅限答案不含滑动的步骤 G0/G3/G5/G6;G1/G2 任意
|
||||
* 滑动即答案、G4 的答案就是下滑——这些步骤下滑 = 作答,不可跳过);
|
||||
* ENDING 下滑:菜单
|
||||
@@ -31,12 +34,23 @@
|
||||
* 修电箱/首到关键房间/逃脱)清空已播级别,下段静默期从 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)播完后,按房间图计算目标房间相对当前
|
||||
* 房间的真实方位,接播 gd_room_l/r/u/d 之一(游戏反馈层全档位;目标在同一
|
||||
* 房间不播;方向 = escape 移动坐标系,up/down/left/right 滑动方向即方位)。
|
||||
* 引导期 g7_goal 不接(引导本身逐句带路,且引导链被 playtest 断言冻结)。
|
||||
* 房间的真实方位接播方位句;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 内置,本层只发语义震动
|
||||
@@ -80,6 +94,16 @@ const KEY_ROOMS = {
|
||||
// 空房环境音轮换(弱化版风声/嗡鸣)
|
||||
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',
|
||||
@@ -110,6 +134,10 @@ export class EscapeGame extends Game {
|
||||
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;
|
||||
@@ -118,10 +146,15 @@ export class EscapeGame extends Game {
|
||||
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 内判断是否链播新句)
|
||||
// 菜单
|
||||
@@ -160,12 +193,14 @@ export class EscapeGame extends Game {
|
||||
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(); }
|
||||
else if (g === G.TAP) { this.audio.stopVoice(); this._enterPlay(true); }
|
||||
// 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);
|
||||
@@ -174,7 +209,9 @@ export class EscapeGame extends Game {
|
||||
this._onPlayGesture(g);
|
||||
break;
|
||||
case Phase.ENDING:
|
||||
if (g === G.TAP) this._restart();
|
||||
// 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:
|
||||
@@ -227,6 +264,13 @@ export class EscapeGame extends Game {
|
||||
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();
|
||||
@@ -236,16 +280,24 @@ export class EscapeGame extends Game {
|
||||
else done();
|
||||
}
|
||||
|
||||
_onGuideGesture(g) {
|
||||
_onGuideGesture(g, { fromCache = false } = {}) {
|
||||
// 下滑跳过仅限"答案不含滑动"的步骤(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) {
|
||||
// 自由探索,教学链就此断裂)。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) 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 首局插播一次
|
||||
@@ -316,10 +368,17 @@ export class EscapeGame extends Game {
|
||||
this._session++; // 作废进行中的引导语音链
|
||||
this._clearTimers();
|
||||
this.audio.stopVoice();
|
||||
this._guidePending = null;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,14 +400,26 @@ export class EscapeGame extends Game {
|
||||
|
||||
// ---------- 进入 / 重开 PLAY ----------
|
||||
|
||||
/** @param {boolean} announce 是否播 start_room(跳过引导/二周目直接进入时) */
|
||||
_enterPlay(announce) {
|
||||
/**
|
||||
* @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) this._playVoice('voice/start_room');
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -397,22 +468,37 @@ export class EscapeGame extends Game {
|
||||
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');
|
||||
// 撞墙反馈:先播「那边是墙,换个方向。」,播完接播陈述式方位句
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,10 +540,12 @@ export class EscapeGame extends Game {
|
||||
this._progressEvent();
|
||||
this.audio.play('sfx/pickup');
|
||||
vibrate('pickup');
|
||||
// H1(round4):钥匙到手句后接播铁柜房方位(下一目标 = cabinet (2,1))。
|
||||
// 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));
|
||||
emit('voice/found_key', false,
|
||||
() => this._playGoalDir(STAGES.cabinet.target, 'voice/gd_cabinet_dir', 'voice/gd_room_r'));
|
||||
}, 800); // 先让环境音进耳朵再播描述
|
||||
} else {
|
||||
describe('voice/empty_2');
|
||||
@@ -470,12 +558,11 @@ export class EscapeGame extends Game {
|
||||
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)
|
||||
// 空房 (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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,11 +580,12 @@ export class EscapeGame extends Game {
|
||||
// G4(round3):拾取反馈链尾接下一目标句「保险丝到手。接下来,去找电箱。」
|
||||
// (游戏反馈层全档位,gd_economy 先例——走游戏语音链不走向导通道)——堵上
|
||||
// P2-round2 P0「保险丝到手后目标悬空:没有新指令、没有下文」。
|
||||
// H1(round4):目标句播完接播目标房间方位句(P3 唯一拒因:目标无方位)。
|
||||
// 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),
|
||||
onComplete: () => this._playGoalDir(STAGES.box.target, 'voice/gd_box_dir', 'voice/gd_room_l'),
|
||||
}),
|
||||
});
|
||||
} else if (x === 0 && y === 0 && this.hasFuse && !this.powerOn) {
|
||||
@@ -508,22 +596,28 @@ export class EscapeGame extends Game {
|
||||
this.audio.play('sfx/spark');
|
||||
this.audio.play('sfx/power_on', { volume: 0.8 });
|
||||
vibrate('success');
|
||||
// H1(round4):通电目标句后接播大门方位(下一目标 = gate (2,2))。
|
||||
// H1(round4)/ B2(escape-state round2):通电目标句后接播"目标名+方位"
|
||||
// 一体句 gd_gate_dir(大门 (2,2) 固定为右)。
|
||||
this._playVoice('voice/fix_box', {
|
||||
skippable: false, // 关键获得不可跳
|
||||
onComplete: () => this._playGoalDir(STAGES.gate.target),
|
||||
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 + L2 级明示(ui_deny 已退役)
|
||||
// 兜底: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;
|
||||
this._speak([
|
||||
inKeyRoom ? 'voice/use_fail' : this._inventoryVoice(),
|
||||
`voice/stuck_${this._stage()}_2`,
|
||||
]);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,10 +650,16 @@ export class EscapeGame extends Game {
|
||||
return dy < 0 ? 'voice/gd_room_u' : 'voice/gd_room_d';
|
||||
}
|
||||
|
||||
/** H1:目标句播完后接播目标房间方位句(游戏反馈层全档位,gd_next_fuse 先例)。 */
|
||||
_playGoalDir(target) {
|
||||
/**
|
||||
* 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) this._playVoice(dir);
|
||||
if (!dir) return;
|
||||
this._playVoice(namedId && dir === expectedId ? namedId : dir);
|
||||
}
|
||||
|
||||
// ---------- 卡关检测与三级提示 ----------
|
||||
@@ -573,12 +673,19 @@ export class EscapeGame extends Game {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -609,6 +716,18 @@ export class EscapeGame extends Game {
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
||||
/** 从当前坐标到目标房间的导航语音(每轴一句,最多两句)。 */
|
||||
@@ -677,6 +796,9 @@ export class EscapeGame extends Game {
|
||||
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)
|
||||
@@ -696,6 +818,7 @@ export class EscapeGame extends Game {
|
||||
/** 逃脱结算 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 });
|
||||
}
|
||||
|
||||
@@ -418,13 +418,16 @@ section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单
|
||||
assert(rec.count('voice/wall') >= 1, '撞墙播 wall 语音');
|
||||
await finishVoices(1);
|
||||
|
||||
// ---- 边界:双击无目标兜底 = 背包/use_fail + L2 明示,不崩 ----
|
||||
// ---- 边界:双击无目标兜底 = 背包/use_fail + L1 意象暗示,不崩(escape-state
|
||||
// round2 B4 变更登记:原直通 L2 明示 stuck_*_2 把答案白送给没卡关的玩家,
|
||||
// 降级为 L1 意象句 stuck_*_1——不点明房间与动作;答案仍锁在计时器 L2/L3) ----
|
||||
const errCnt2 = vibrateCount(ERR_PAT);
|
||||
await gs(game, 'doubletap'); // 电箱房无保险丝
|
||||
assert(vibrateCount(ERR_PAT) === errCnt2 + 1, '双击无目标发 error 震动');
|
||||
assert(rec.count('voice/use_fail') === 1, '兜底播 use_fail(关键房间)');
|
||||
await finishVoices(1);
|
||||
assert(rec.count('voice/stuck_key_2') === 1, '兜底追加 L2 明示 stuck_key_2');
|
||||
assert(rec.count('voice/stuck_key_1') === 1, 'B4:兜底追加 L1 意象暗示 stuck_key_1(原 L2 已降级)');
|
||||
assert(rec.count('voice/stuck_key_2') === 0, 'B4:兜底不再直通 L2 明示 stuck_key_2');
|
||||
await finishVoices(1);
|
||||
|
||||
game.stop();
|
||||
@@ -501,7 +504,16 @@ section('A2. 冷启动引导链:intro 后 g1_move 自动播出,零单击走
|
||||
await gs(g2b.game, 'down'); // G3 答案 = 左滑,下滑 = 跳过
|
||||
assert(g2b.rec.count('voice/guide_skip') === 1, 'G3 下滑仍跳过引导');
|
||||
await tick(VOICE_END_SLACK);
|
||||
assert(g2b.game.phase === 'PLAY', 'G3 下滑跳过直达 PLAY');
|
||||
// B8(escape-state round2 变更登记):跳过确认句后、进 PLAY 前补播目标句
|
||||
// g7_goal——跳过链从 1 句变 2 句,第一句播完时仍在 GUIDE
|
||||
assert(g2b.rec.count('voice/g7_goal') === 1, 'B8:guide_skip 后补播 g7_goal');
|
||||
assert(g2b.game.phase === 'GUIDE', 'B8:g7_goal 补播期间尚未进 PLAY');
|
||||
await tick(VOICE_END_SLACK);
|
||||
assert(g2b.game.phase === 'PLAY', 'G3 下滑跳过(补播 g7_goal 后)直达 PLAY');
|
||||
assert(g2b.rec.count('voice/start_room') === 1, 'B8:跳过路径 g7_goal 后播 start_room');
|
||||
const goalT = g2b.rec.last('voice/g7_goal').t;
|
||||
const startT = g2b.rec.last('voice/start_room').t;
|
||||
assert(goalT < startT, 'B8:跳过路径播报顺序 guide_skip → g7_goal → start_room');
|
||||
g2b.game.stop();
|
||||
}
|
||||
|
||||
@@ -538,7 +550,7 @@ section('B. 引导纠错与跳过');
|
||||
assert(game.guideStep === 3 && game._guideBusy, '左滑后正常推进(G4 语音中)');
|
||||
game.stop();
|
||||
|
||||
// 下滑跳过引导
|
||||
// 下滑跳过引导(B8:确认句后补播 g7_goal 再进 PLAY)
|
||||
store.clear();
|
||||
const g2 = await newGame();
|
||||
g2.game.start();
|
||||
@@ -546,7 +558,9 @@ section('B. 引导纠错与跳过');
|
||||
await gs(g2.game, 'down');
|
||||
assert(g2.rec.count('voice/guide_skip') === 1, '引导期下滑播 guide_skip');
|
||||
await tick(VOICE_END_SLACK);
|
||||
assert(g2.game.phase === 'PLAY', '下滑跳过引导直达 PLAY');
|
||||
assert(g2.rec.count('voice/g7_goal') === 1, 'B8:guide_skip 后补播 g7_goal');
|
||||
await tick(VOICE_END_SLACK);
|
||||
assert(g2.game.phase === 'PLAY', '下滑跳过引导(补播 g7_goal 后)直达 PLAY');
|
||||
assert(store.get('soundgame.escape.guideDone') === '1', '跳过引导也写 guideDone');
|
||||
assert(g2.rec.count('voice/start_room') === 1, '跳过引导播 start_room');
|
||||
g2.game.stop();
|
||||
@@ -807,6 +821,466 @@ section('E. 设置边界');
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 G:B1 撞墙方位句(wall_dir_* 接播 + 同向 10s 限频 + 打断作废;引导期不播)
|
||||
// ================================================================
|
||||
section('G. B1 撞墙方位句(escape-state round2 F1)');
|
||||
{
|
||||
// 引导期 G2 伪撞墙不播方位句(B1 仅 PLAY 阶段)
|
||||
store.clear();
|
||||
const gd = await newGame();
|
||||
gd.game.start();
|
||||
await flush();
|
||||
await tick(VOICE_END_SLACK);
|
||||
await gs(gd.game, 'tap'); // G0 → G1
|
||||
await finishVoices(2);
|
||||
await gs(gd.game, 'up'); // G1
|
||||
await finishVoices(2);
|
||||
await gs(gd.game, 'up'); // G2 ×2(伪撞墙)
|
||||
await gs(gd.game, 'left');
|
||||
await finishVoices(2);
|
||||
assert(gd.rec.count('voice/wall') >= 2, '前提:G2 撞墙播过 wall 语音');
|
||||
for (const d of ['l', 'r', 'u', 'd']) {
|
||||
assert(gd.rec.count(`voice/wall_dir_${d}`) === 0, `B1:引导期 G2 撞墙不播 wall_dir_${d}(仅 PLAY)`);
|
||||
}
|
||||
gd.game.stop();
|
||||
|
||||
store.clear();
|
||||
store.set('soundgame.escape.guideDone', '1');
|
||||
const { game, rec } = await newGame();
|
||||
game.start();
|
||||
await flush();
|
||||
await gs(game, 'tap'); // REOPEN → PLAY(start_room → g7_goal)
|
||||
await finishVoices(2);
|
||||
await gs(game, 'left'); // (0,1) B5a 首访 empty_2
|
||||
await gs(game, 'up'); // (0,0) room_power
|
||||
await finishVoices(2);
|
||||
|
||||
// ① 撞墙后 wall 播完接播方位句(顺序:wall → wall_dir_u)
|
||||
await gs(game, 'up'); // 撞北墙
|
||||
assert(rec.count('voice/wall_dir_u') === 0, 'B1:wall 播放中方位句未播(接播不抢话)');
|
||||
await finishVoices(1);
|
||||
assert(rec.count('voice/wall_dir_u') === 1, 'B1:wall 播完接播 wall_dir_u「上边是墙。」');
|
||||
assert(rec.last('voice/wall_dir_u').t >= rec.last('voice/wall').t + 1000, 'B1:方位句在 wall 自然播完之后');
|
||||
await finishVoices(1);
|
||||
|
||||
// ② 打断作废:撞左墙 wall 未播完再撞上墙——左墙方位句作废;
|
||||
// 上墙 10s 限频内(① 刚播过)不再重复
|
||||
await gs(game, 'left'); // wall(左)
|
||||
await gs(game, 'up'); // wall(上)顶掉上一句
|
||||
await finishVoices(2);
|
||||
assert(rec.count('voice/wall_dir_l') === 0, 'B1:wall 被打断则方位句自然作废(wall_dir_l 未播)');
|
||||
assert(rec.count('voice/wall_dir_u') === 1, 'B1:同向 10s 限频——上墙方位句不重复(仍 1 次)');
|
||||
|
||||
// ③ 同向限频与恢复:左墙首播 → 10s 内再撞不重复 → 窗口过后恢复
|
||||
await gs(game, 'left'); // 左墙(② 的接播被作废,未播过 → 本次接播)
|
||||
await finishVoices(2);
|
||||
assert(rec.count('voice/wall_dir_l') === 1, 'B1:被作废的方向补撞后方位句正常接播');
|
||||
await gs(game, 'left'); // 10s 窗口内再撞左墙
|
||||
await finishVoices(2);
|
||||
assert(rec.count('voice/wall_dir_l') === 1, 'B1:同向 10s 内限频 1 次(wall_dir_l 不重复)');
|
||||
await tick(10000);
|
||||
await gs(game, 'left');
|
||||
await finishVoices(2);
|
||||
assert(rec.count('voice/wall_dir_l') === 2, 'B1:限频窗口过后同向方位句恢复');
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 H:B2 目标名+方位一体句(gd_*_dir 替换三处接播,异常路径回落 gd_room_*)
|
||||
// ================================================================
|
||||
section('H. B2 目标名+方位一体句(escape-state round2 F2)');
|
||||
{
|
||||
store.clear();
|
||||
store.set('soundgame.escape.guideDone', '1');
|
||||
const { game, rec } = await newGame();
|
||||
game.start();
|
||||
await flush();
|
||||
await gs(game, 'tap'); // REOPEN → PLAY (1,1)
|
||||
await finishVoices(2); // start_room + g7_goal
|
||||
|
||||
// ① found_key 链尾 → gd_cabinet_dir(储物间 (0,2) → 铁柜房 (2,1),固定为右)
|
||||
game.x = 0; game.y = 2; // 测试钩子:瞬移储物间
|
||||
await gs(game, 'tap'); // 检查 → 拾钥匙
|
||||
await tick(800); // found_key 起播
|
||||
await finishVoices(2); // found_key → gd_cabinet_dir
|
||||
assert(rec.count('voice/found_key') === 1, '前提:拾钥匙播 found_key');
|
||||
assert(rec.count('voice/gd_cabinet_dir') === 1, 'B2:found_key 链尾播 gd_cabinet_dir「铁柜,在右面的房间。」');
|
||||
assert(rec.last('voice/gd_cabinet_dir').t > rec.last('voice/found_key').t, 'B2:一体句在 found_key 之后');
|
||||
assert(rec.count('voice/gd_room_r') === 0, 'B2:正常路径不再播无目标名的 gd_room_r');
|
||||
|
||||
// ② unlock_cabinet/gd_next_fuse 链尾 → gd_box_dir(铁柜房 → 电箱房 (0,0),固定为左)
|
||||
game.x = 2; game.y = 1; // 钩子:瞬移铁柜房
|
||||
await gs(game, 'doubletap');
|
||||
await finishVoices(3); // unlock_cabinet → gd_next_fuse → gd_box_dir
|
||||
assert(rec.count('voice/gd_box_dir') === 1, 'B2:gd_next_fuse 链尾播 gd_box_dir「电箱,在左面的房间。」');
|
||||
assert(rec.last('voice/gd_box_dir').t > rec.last('voice/gd_next_fuse').t, 'B2:一体句在 gd_next_fuse 之后');
|
||||
assert(rec.count('voice/gd_room_l') === 0, 'B2:正常路径不再播 gd_room_l');
|
||||
|
||||
// ③ fix_box 链尾 → gd_gate_dir(电箱房 → 大门 (2,2),固定为右)
|
||||
game.x = 0; game.y = 0; // 钩子:瞬移电箱房
|
||||
await gs(game, 'doubletap');
|
||||
await finishVoices(2); // fix_box → gd_gate_dir
|
||||
assert(rec.count('voice/gd_gate_dir') === 1, 'B2:fix_box 链尾播 gd_gate_dir「大门,在右面的房间。」');
|
||||
assert(rec.last('voice/gd_gate_dir').t > rec.last('voice/fix_box').t, 'B2:一体句在 fix_box 之后');
|
||||
|
||||
// ④ 异常路径回落:计算方位与枚举不符 → 回落原 gd_room_*(归因真话,原句保留接线)
|
||||
game.x = 1; game.y = 1; // 钩子:异常位置(正常链上不存在)
|
||||
game._playGoalDir([0, 1], 'voice/gd_cabinet_dir', 'voice/gd_room_r'); // 目标在西 ≠ 枚举右
|
||||
await flush();
|
||||
assert(rec.count('voice/gd_room_l') === 1, 'B2:方位与枚举不符时回落 gd_room_*(原句保留接线)');
|
||||
await finishVoices(1);
|
||||
game._playGoalDir([1, 1], 'voice/gd_cabinet_dir', 'voice/gd_room_r'); // 同房间不播(H1 原语义)
|
||||
await flush();
|
||||
assert(rec.count('voice/gd_room_l') === 1 && rec.count('voice/gd_cabinet_dir') === 1,
|
||||
'B2:目标在同一房间不播(H1 原语义不变)');
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 I:B3 无进展 150s 活跃计时器(gd_prog 进度重述)
|
||||
// 边界:key 阶段不走 / ≥5 手势闸门(挂机不触)/ 每段限 1 次 /
|
||||
// 有进展时复位 / 待播期间操作作废且计入已播
|
||||
// ================================================================
|
||||
section('I. B3 无进展活跃计时器(escape-state round2 F3)');
|
||||
{
|
||||
store.clear();
|
||||
store.set('soundgame.escape.guideDone', '1');
|
||||
const { audio, game, rec } = await newGame();
|
||||
game.start();
|
||||
await flush();
|
||||
await gs(game, 'tap'); // REOPEN → PLAY
|
||||
await finishVoices(2);
|
||||
// 往返手势一律 (1,1)↔(0,1)(空房,避开关键房间的首到进展事件)
|
||||
const wander = async (n) => { for (let i = 0; i < n; i++) await gs(game, i % 2 ? 'right' : 'left'); };
|
||||
|
||||
// ① key 阶段不走本通道:无进展 200s + 活跃 5 手势 → 无 gd_prog
|
||||
await wander(5);
|
||||
game._lastProgressAt = now - 200000; // 钩子:模拟 200s 无进展
|
||||
game._checkStuck();
|
||||
await flush();
|
||||
assert(rec.count('voice/gd_prog_1') === 0, 'B3:key 阶段不走本通道(L1/L2/L3 已覆盖)');
|
||||
await tick(1600); // 消化首访 empty_2
|
||||
|
||||
// ② 拾钥匙进 cabinet 阶段(进展事件复位计时)
|
||||
game.x = 0; game.y = 2; // 钩子:瞬移储物间
|
||||
await gs(game, 'tap');
|
||||
await tick(800);
|
||||
await finishVoices(2); // found_key + gd_cabinet_dir
|
||||
assert(game._stage() === 'cabinet' && game._gesturesSinceProgress === 0,
|
||||
'B3 前提:cabinet 阶段,进展事件已复位计数');
|
||||
|
||||
// ③ 闸门:无进展 151s 但活跃手势 <5(挂机/低活跃)→ 不触
|
||||
game._lastProgressAt = now - 151000;
|
||||
game._checkStuck();
|
||||
await flush();
|
||||
assert(rec.count('voice/gd_prog_1') === 0, 'B3:活跃手势 <5 不触(挂机交给 60s L1 体系)');
|
||||
|
||||
// ④ 活跃触发:5 手势后无进展 151s → gd_prog_1 到达一次
|
||||
game.x = 1; game.y = 1;
|
||||
await wander(5);
|
||||
await tick(1600);
|
||||
game._checkStuck(); // _lastProgressAt 仍为 ② 的钩子值(移动不复位无进展计时)
|
||||
await flush();
|
||||
assert(rec.count('voice/gd_prog_1') === 1, 'B3:活跃(≥5 手势)无进展 150s 播 gd_prog_1');
|
||||
|
||||
// ⑤ 每进展段限 1 次:再到点不重复
|
||||
await finishVoices(1);
|
||||
game._lastProgressAt = now - 200000;
|
||||
game._checkStuck();
|
||||
await flush();
|
||||
assert(rec.count('voice/gd_prog_1') === 1, 'B3:gd_prog_1 每进展段限 1 次');
|
||||
|
||||
// ⑥ 有进展时复位:开柜(进展事件)→ 计数清零、计时重起,不立即触下一段
|
||||
game.x = 2; game.y = 1;
|
||||
await gs(game, 'doubletap'); // 开柜
|
||||
assert(game._gesturesSinceProgress === 0, 'B3:进展事件后活跃手势计数清零(复位)');
|
||||
game._checkStuck(); // _lastProgressAt 刚复位,远不足 150s
|
||||
await flush();
|
||||
assert(rec.count('voice/gd_prog_2') === 0, 'B3:有进展时计时器复位(不立即触 gd_prog_2)');
|
||||
await finishVoices(3); // unlock 链
|
||||
|
||||
// ⑦ box 段可再触一次
|
||||
game.x = 1; game.y = 1;
|
||||
await wander(5);
|
||||
await tick(1600);
|
||||
game._lastProgressAt = now - 151000;
|
||||
game._checkStuck();
|
||||
await flush();
|
||||
assert(rec.count('voice/gd_prog_2') === 1, 'B3:box 阶段活跃无进展 150s 播 gd_prog_2');
|
||||
await finishVoices(1);
|
||||
|
||||
// ⑧ 待播期间操作作废且计入已播(每段限 1 次不重发)
|
||||
game.x = 0; game.y = 0;
|
||||
await gs(game, 'doubletap'); // 修电 → gate 阶段(进展复位)
|
||||
await finishVoices(2); // fix_box + gd_gate_dir
|
||||
game.x = 1; game.y = 1;
|
||||
await wander(5);
|
||||
await tick(1600);
|
||||
await gs(game, 'tap'); // 单击检查
|
||||
await tick(800); // 检查描述播放中
|
||||
assert(audio.isVoicePlaying(), 'B3 前提:检查描述播放中');
|
||||
game._lastProgressAt = now - 151000;
|
||||
game._checkStuck(); // 到点 → gd_prog_3 只入队
|
||||
assert(rec.count('voice/gd_prog_3') === 0, 'B3:语音播放中 gd_prog 只入队不开播(不顶句)');
|
||||
await gs(game, 'left'); // 玩家操作 → 待播 gd_prog_3 作废
|
||||
await finishVoices(2);
|
||||
assert(rec.count('voice/gd_prog_3') === 0, 'B3:待播期间操作即作废(不播出)');
|
||||
assert(game._progShown.has('gate'), 'B3:作废仍计入已播(每进展段限 1 次)');
|
||||
game._lastProgressAt = now - 200000;
|
||||
game._checkStuck();
|
||||
await flush();
|
||||
assert(rec.count('voice/gd_prog_3') === 0, 'B3:作废的 gd_prog 不重发');
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 J:B4 双击兜底降级(背包 + L1 意象;去重;L2 仍由计时器到达)
|
||||
// ================================================================
|
||||
section('J. B4 双击兜底降级(escape-state round2 F4)');
|
||||
{
|
||||
store.clear();
|
||||
store.set('soundgame.escape.guideDone', '1');
|
||||
const { game, rec } = await newGame();
|
||||
game.start();
|
||||
await flush();
|
||||
await gs(game, 'tap'); // REOPEN → PLAY (1,1) 非关键房
|
||||
await finishVoices(2);
|
||||
|
||||
// 非关键房兜底 = 背包 + L1 意象暗示(不再直通 L2);播了即记账
|
||||
await gs(game, 'doubletap');
|
||||
assert(rec.count('voice/inv_empty') === 1, 'B4:非关键房兜底先播背包 inv_empty');
|
||||
await finishVoices(2);
|
||||
assert(rec.count('voice/stuck_key_1') === 1, 'B4:兜底接 L1 意象暗示 stuck_key_1');
|
||||
assert(rec.count('voice/stuck_key_2') === 0, 'B4:兜底不直通 L2 明示 stuck_key_2');
|
||||
assert(game._stuckShown.has('L1'), 'B4:兜底播 L1 即记账(沿用 _stuckShown 去重)');
|
||||
|
||||
// 同进展段再次兜底不重复 L1 意象句(既有去重)
|
||||
await gs(game, 'doubletap');
|
||||
await finishVoices(2);
|
||||
assert(rec.count('voice/inv_empty') === 2, 'B4:再次兜底仍播背包');
|
||||
assert(rec.count('voice/stuck_key_1') === 1, 'B4:同进展段再次兜底不重复 L1 意象句');
|
||||
|
||||
// 降级后 L2 仍可达:L1 已记账 → 60s 不再重复(整级跳过,stuck_ask 亦不播);
|
||||
// 120s 无操作 L2 明示照常到达(难度不降)
|
||||
await tick(65000);
|
||||
assert(rec.count('voice/stuck_ask') === 0, 'B4:L1 已记账,60s 无操作不再重复 L1 级');
|
||||
await tick(60000);
|
||||
assert(rec.count('voice/stuck_key_2') === 1, 'B4:降级后 L2 仍由计时器到达(120s 无操作)');
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 K:B5a 空房首访描述(首访到达 / 重访不重复 / 单击检查语义 / 非进展事件)
|
||||
// ================================================================
|
||||
section('K. B5a 空房首访描述(escape-state round2 F5)');
|
||||
{
|
||||
store.clear();
|
||||
store.set('soundgame.escape.guideDone', '1');
|
||||
const { game, rec } = await newGame();
|
||||
game.start();
|
||||
await flush();
|
||||
await gs(game, 'tap'); // REOPEN → PLAY (1,1)
|
||||
await finishVoices(2);
|
||||
|
||||
// 首访空房不算进展事件:不清卡关已播级别
|
||||
game._stuckShown.add('L1'); // 钩子:模拟本段已播 L1
|
||||
await gs(game, 'up'); // (1,0) 首访
|
||||
assert(game._stuckShown.has('L1'), 'B5a:空房首访不算进展事件(不清 _stuckShown)');
|
||||
game._stuckShown.clear(); // 复位钩子
|
||||
|
||||
// 首访 (1,0) → empty_1;本房间内单击不再重复首访句;重访零语音;重访后单击照常
|
||||
assert(rec.count('voice/empty_1') === 1, 'B5a:空房首访补播 empty_1(一次性听觉锚)');
|
||||
await finishVoices(1);
|
||||
await gs(game, 'tap'); // 首访句刚播过的房间内单击
|
||||
await tick(900);
|
||||
assert(rec.count('voice/empty_1') === 1, 'B5a:首访句刚播过的房间内单击不重复同一句');
|
||||
await gs(game, 'down'); // (1,1)
|
||||
await gs(game, 'up'); // (1,0) 重访
|
||||
await tick(900);
|
||||
assert(rec.count('voice/empty_1') === 1, 'B5a:空房重访零语音(不重复首访句)');
|
||||
await gs(game, 'tap'); // 重访后单击检查
|
||||
await tick(900);
|
||||
assert(rec.count('voice/empty_1') === 2, 'B5a:重访后单击检查照常播 empty_1(语义不变)');
|
||||
await finishVoices(1);
|
||||
|
||||
// 四房映射:(0,1)→empty_2、(1,2)→empty_3、(2,0)→empty_3(对称水滴共用合法记忆点)
|
||||
await gs(game, 'down'); // (1,1)
|
||||
await gs(game, 'left'); // (0,1) 首访
|
||||
assert(rec.count('voice/empty_2') === 1, 'B5a:(0,1) 首访播 empty_2');
|
||||
await finishVoices(1);
|
||||
await gs(game, 'right'); // (1,1)
|
||||
await gs(game, 'down'); // (1,2) 首访
|
||||
assert(rec.count('voice/empty_3') === 1, 'B5a:(1,2) 首访播 empty_3');
|
||||
await finishVoices(1);
|
||||
game.x = 2; game.y = 1; // 钩子:瞬移铁柜房(已 visited 与否不影响下行)
|
||||
game.visited.add('2,1');
|
||||
await gs(game, 'up'); // (2,0) 首访
|
||||
assert(rec.count('voice/empty_3') === 2, 'B5a:(2,0) 首访复用 empty_3(两间"远处水滴"对称分布)');
|
||||
await finishVoices(1);
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 L:B6 引导 busy 窗口手势缓存(补判 / 答错路径 / 不触发跳过 / 仅 1 个)
|
||||
// ================================================================
|
||||
section('L. B6 引导 busy 手势缓存(escape-state round2 F7)');
|
||||
{
|
||||
store.clear();
|
||||
const { game, rec } = await newGame();
|
||||
game.start();
|
||||
await flush();
|
||||
await tick(VOICE_END_SLACK); // intro 播完(自动推进 1.2s 未到点)
|
||||
await gs(game, 'tap'); // G0 → _advance(1) busy
|
||||
|
||||
// ① busy 窗口手势缓存:不当场判定,解锁后按新步骤补判(合法 = 作答)
|
||||
await gs(game, 'up'); // busy 窗口注入滑动 → 缓存
|
||||
assert(game.guideStep === 0 && game._guideBusy, 'B6 前提:busy 窗口内步骤未解锁');
|
||||
const stepsAtBusy = rec.count('sfx/step');
|
||||
assert(rec.count('sfx/step') === stepsAtBusy, 'B6:busy 窗口手势不当场判定(无脚步声)');
|
||||
await finishVoices(2); // g1_move + skip_hint 播完 → 解锁 G1 → 缓存 up 补判
|
||||
assert(rec.count('sfx/step') === stepsAtBusy + 1, 'B6:解锁后缓存手势补判(G1 滑动作答,脚步声)');
|
||||
assert(game.guideStep === 1 && game._guideBusy, 'B6:补判作答后正常推进(G2 语音中)');
|
||||
await finishVoices(2); // g1_done + g2_wall
|
||||
assert(game.guideStep === 2, 'B6:进入 G2');
|
||||
|
||||
// ② 缓存手势对新步骤合法:G2→G3 busy 缓存左滑(G3 的答案)→ 解锁直接作答
|
||||
await gs(game, 'up'); // G2 第一滑
|
||||
await tick(VOICE_END_SLACK); // wall 播完
|
||||
await gs(game, 'right'); // G2 第二滑 → _advance(3) busy
|
||||
await gs(game, 'left'); // busy 缓存左滑
|
||||
assert(game.guideStep === 2, 'B6:busy 窗口仍停 G2');
|
||||
await finishVoices(2); // wall + g3_left 播完 → 解锁 G3 → 缓存 left 补判
|
||||
assert(game.x === 0 && game.y === 1, 'B6:缓存左滑按 G3 补判作答(移到 (0,1))');
|
||||
assert(game.guideStep === 3 && game._guideBusy, 'B6:补判作答后推进 G4');
|
||||
assert(rec.count('voice/guide_skip') === 0, 'B6:缓存手势不触发下滑跳过(S6 事故修复)');
|
||||
await finishVoices(1); // g4_down → G4 解锁
|
||||
|
||||
// ③ 缓存手势对新步骤不合法:按答错路径处理(有纠正语音,可归因)
|
||||
await gs(game, 'down'); // G4 作答 → _advance(5) busy
|
||||
await gs(game, 'up'); // busy 缓存 up
|
||||
await finishVoices(1); // 解锁 G5 → 补判:G5 期望单击,up = 答错
|
||||
assert(game.guideStep === 5, 'B6:进入 G5');
|
||||
assert(rec.count('voice/guide_wrong_gesture') === 1, 'B6:缓存手势不合法按答错路径处理(纠正语音可归因)');
|
||||
assert(game.guideErrs === 1, 'B6:答错计数照常');
|
||||
assert(rec.count('voice/guide_skip') === 0, 'B6:答错路径同样不触发跳过');
|
||||
|
||||
// ④ 仅缓存 1 个:busy 连注两个手势,解锁后只补判首个
|
||||
await gs(game, 'tap'); // G5 作答 → _advance(6) busy
|
||||
await gs(game, 'doubletap'); // 缓存(恰为 G6 答案)
|
||||
await gs(game, 'left'); // 不再缓存(仅 1 个)
|
||||
await finishVoices(2); // g5_key + g6_doubletap → 解锁 G6 → 补判 doubletap
|
||||
assert(game.guideStep === 6 && game._guideBusy, 'B6:仅首个缓存手势补判(doubletap 作答 G6)');
|
||||
await finishVoices(3); // inv_key + g6_done + g7_goal → PLAY
|
||||
assert(game.phase === 'PLAY', 'B6:引导链完整走完(缓存不破坏既有推进)');
|
||||
game.stop();
|
||||
|
||||
// ⑤ 跳过分手势不缓存:busy 窗口下滑(可跳过步骤)仍立即跳过
|
||||
store.clear();
|
||||
const g2b = await newGame();
|
||||
g2b.game.start();
|
||||
await flush();
|
||||
await gs(g2b.game, 'tap'); // G0 → _advance(1) busy
|
||||
assert(g2b.game._guideBusy, '前提:G0→G1 推进语音播放中');
|
||||
await gs(g2b.game, 'down'); // busy 窗口下滑 = 跳过分手势 → 立即跳过,不缓存
|
||||
assert(g2b.rec.count('voice/guide_skip') === 1, 'B6:busy 窗口下滑跳过语义不变(不缓存)');
|
||||
assert(g2b.game._guidePending === null, 'B6:跳过分手势不进缓存');
|
||||
g2b.game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 M:B7 结算链单击守卫(播完前不重开 / 链完整到达 / 30s 兜底解除)
|
||||
// ================================================================
|
||||
section('M. B7 结算链单击守卫(escape-state round2 F8)');
|
||||
{
|
||||
store.clear();
|
||||
store.set('soundgame.escape.guideDone', '1');
|
||||
const { game, rec } = await newGame();
|
||||
game.start();
|
||||
await flush();
|
||||
await gs(game, 'tap'); // REOPEN → PLAY
|
||||
await finishVoices(2);
|
||||
// 钩子速通:开柜 → 修电 → 大门
|
||||
game.hasKey = true; game.x = 2; game.y = 1;
|
||||
await gs(game, 'doubletap'); // 开柜
|
||||
await finishVoices(3);
|
||||
game.x = 0; game.y = 0;
|
||||
await gs(game, 'doubletap'); // 修电
|
||||
await finishVoices(2);
|
||||
game.x = 2; game.y = 2;
|
||||
await gs(game, 'doubletap'); // 逃脱
|
||||
assert(game.phase === 'ENDING' && game._settleBusy, 'B7 前提:ENDING + 结算守卫挂载');
|
||||
|
||||
// 结算链播放中单击 = 忽略(链不被截断,time_band/again 完整到达)
|
||||
await gs(game, 'tap');
|
||||
assert(game.phase === 'ENDING', 'B7:结算链播完前单击不重开(仍 ENDING)');
|
||||
assert(rec.count('voice/replay') === 0, 'B7:守卫期单击不触发重开(无 replay)');
|
||||
await finishVoices(1);
|
||||
assert(rec.count('voice/time_band1') === 1, 'B7:结算链完整——time_band 到达(不再被吞)');
|
||||
await finishVoices(1);
|
||||
assert(rec.count('voice/again') === 1, 'B7:结算链完整——again 到达');
|
||||
await finishVoices(1); // 链完 → 守卫解除
|
||||
assert(!game._settleBusy, 'B7:链播完守卫解除');
|
||||
await gs(game, 'tap');
|
||||
assert(game.phase === 'PLAY' && rec.count('voice/replay') === 1, 'B7:守卫解除后单击重开原语义不变');
|
||||
game.stop();
|
||||
|
||||
// 30s 资产缺失兜底:again 缺失 → 链停摆 → 守卫靠兜底解除(防静默死锁)
|
||||
blockedAssets = ['again'];
|
||||
store.clear();
|
||||
store.set('soundgame.escape.guideDone', '1');
|
||||
const g2 = await newGame();
|
||||
g2.game.start();
|
||||
await flush();
|
||||
await gs(g2.game, 'tap');
|
||||
await finishVoices(2);
|
||||
g2.game.hasKey = true; g2.game.x = 2; g2.game.y = 1;
|
||||
await gs(g2.game, 'doubletap');
|
||||
await finishVoices(3);
|
||||
g2.game.x = 0; g2.game.y = 0;
|
||||
await gs(g2.game, 'doubletap');
|
||||
await finishVoices(2);
|
||||
g2.game.x = 2; g2.game.y = 2;
|
||||
await gs(g2.game, 'doubletap');
|
||||
assert(g2.game.phase === 'ENDING', '前提:again 缺失下逃脱进 ENDING');
|
||||
await finishVoices(2); // escape + time_band 播完,again 缺失链停摆
|
||||
await gs(g2.game, 'tap');
|
||||
assert(g2.game.phase === 'ENDING', 'B7:again 缺失链停摆时守卫仍生效(单击不重开)');
|
||||
await tick(30000); // 兜底
|
||||
assert(!g2.game._settleBusy, 'B7:30s 资产缺失兜底解除守卫(防静默死锁)');
|
||||
await gs(g2.game, 'tap');
|
||||
assert(g2.game.phase === 'PLAY', 'B7:兜底解除后可重开');
|
||||
blockedAssets = [];
|
||||
g2.game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 N:B8 REOPEN 直进路径补播目标句(start_room → g7_goal,可单击跳过)
|
||||
// ================================================================
|
||||
section('N. B8 REOPEN 直进补播 g7_goal(escape-state round2 F9)');
|
||||
{
|
||||
store.clear();
|
||||
store.set('soundgame.escape.guideDone', '1');
|
||||
const { game, rec } = await newGame();
|
||||
game.start();
|
||||
await flush();
|
||||
assert(game.phase === 'REOPEN', '前提:二周目 REOPEN');
|
||||
await gs(game, 'tap'); // 直接开始
|
||||
assert(game.phase === 'PLAY', 'REOPEN 单击直进 PLAY');
|
||||
assert(rec.count('voice/start_room') === 1, 'B8:直进播 start_room');
|
||||
await finishVoices(1);
|
||||
assert(rec.count('voice/g7_goal') === 1, 'B8:start_room 后接播 g7_goal');
|
||||
assert(rec.last('voice/g7_goal').t > rec.last('voice/start_room').t, 'B8:直进路径顺序 start_room → g7_goal');
|
||||
// g7_goal 可单击跳过(skippable 默认 true,12 号文档——教学义务句已随首局完成,
|
||||
// 此处是状态告知不是教学)
|
||||
const skips0 = rec.skips();
|
||||
await gs(game, 'tap'); // g7_goal 播放中单击 = 跳过 + 补发检查
|
||||
assert(rec.skips() === skips0 + 1, 'B8:二周目 g7_goal 可单击跳过');
|
||||
assert(game.phase === 'PLAY', 'B8:跳过后仍在 PLAY');
|
||||
await finishVoices(2); // 补发检查描述消化
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 资产引用校验:game.js 引用的 id 在音频目录有 ogg + m4a(voice 双性别),零缺失
|
||||
// ================================================================
|
||||
@@ -822,6 +1296,7 @@ section('F. 资产引用校验');
|
||||
ids.add(`voice/guide_wrong_${d}`);
|
||||
ids.add(`voice/guide_blunt_${d}`);
|
||||
}
|
||||
for (const d of ['l', 'r', 'u', 'd']) ids.add(`voice/wall_dir_${d}`); // B1 撞墙方位句族
|
||||
for (const st of ['key', 'cabinet', 'box', 'gate']) {
|
||||
for (const lv of [1, 2, 3]) ids.add(`voice/stuck_${st}_${lv}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user