live试玩round1+round2修复: 引擎gesture新增tapcancel(immediateTap误判定回滚,默认关闭零行为变化)+ rhythm判定账簿/超时Miss复活/菜单1s去抖/正片下滑永不弹菜单 + space判定冷静期800ms/暂停恢复3颗缓冲/回大厅leaving收尾/hit_last在播标记 + storyline intro满播不掐/抉择25s兜底 + simon免费重听两层账 + escape教学链自动接播/90s高频零提示 + rpg擦伤零误报/tap不丢 + guide.test适配去抖期(语义不变)+ 文档同步(13号§9-§19/web-engine-api/README)。回归:storyline82/escape140/simon132/rhythm141/space155/rpg188/guide122共921断言全绿
This commit is contained in:
+147
-57
@@ -1,25 +1,36 @@
|
||||
/**
|
||||
* 《暗室》Web 版 R2 —— 3x3 黑暗房间网格密室逃脱(按 docs/design/01-escape-redesign.md 实现)。
|
||||
*
|
||||
* 流程:start → REOPEN(二周目询问,可选)→ GUIDE(G0-G7 交互式引导,下滑可跳过)
|
||||
* 流程:start → REOPEN(二周目询问,可选)→ GUIDE(G0-G7 交互式引导;
|
||||
* G0 intro 播完 1.2s 无操作自动推进 G1——g1_move 必播;下滑跳过仅限答案
|
||||
* 不含滑动的步骤 G0/G3/G5/G6,G1/G2/G4 下滑 = 作答)
|
||||
* → PLAY(自由探索 + 卡关三级提示计时器)→ ENDING → 单击重开 / 下滑菜单。
|
||||
*
|
||||
* 手势映射(偏离全局默认,引导中讲清):
|
||||
* - 四向滑动:在相邻房间间移动,撞墙播闷响 + ERROR 震动 + wall 语音(每次都播)
|
||||
* - 四向滑动:在相邻房间间移动,撞墙播闷响 + ERROR 震动 + wall 语音(每次都播;
|
||||
* PLAY 阶段只播「那边是墙」,教学腔方位句 guide_blunt_* 仅限引导期)
|
||||
* - 单击:重播当前房间环境音 + 检查描述(储物间拾钥匙);
|
||||
* voice 播放中单击 = 跳过本句(audio.skipVoice(),链播继续,12 号文档)。
|
||||
* voice 播放中单击 = 跳过本句(audio.skipVoice(),链播继续,12 号文档)
|
||||
* 并补发检查——环境音即时重播、描述进待播队列等链播播完(不顶句)。
|
||||
* 不可跳:引导 G0-G7(引导期单击是答题)、关键获得(found_key/
|
||||
* unlock_cabinet/fix_box/escape 结算链)、L3 带路(stuck_*_3 + nav_*)
|
||||
* - 双击:使用物品(钥匙开柜 / 保险丝修电箱 / 通电后开大门);
|
||||
* 无可用目标时播报背包 + L2 级明示提示
|
||||
* - 引导阶段下滑:跳过引导(G4 除外——G4 的答案就是下滑);ENDING 下滑:菜单
|
||||
* - 引导阶段下滑:跳过引导(仅限答案不含滑动的步骤 G0/G3/G5/G6;G1/G2 任意
|
||||
* 滑动即答案、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 重复)。
|
||||
* 卡关检测:按"无操作时长"计时——PLAY 阶段任何有效手势重置计时
|
||||
* (round1-live 修复:原按"无进展"计时,玩家活跃操作也每 35~40s 机关枪式
|
||||
* 报答案,还三次顶掉进行中的语音)。60s 无操作 L1 暗示(stuck_ask +
|
||||
* stuck_*_1)、120s L2 明示(stuck_*_2)、180s L3 带路(stuck_*_3 + 按当前
|
||||
* 坐标算路链播 nav_*,此后每 60s 无操作重复);进展事件(拾钥匙/开柜/
|
||||
* 修电箱/首到关键房间/逃脱)清空已播级别,下段静默期从 L1 重新分级。
|
||||
* 提示到点只进待播队列:等当前语音自然播完再播、绝不顶掉进行中的语音;
|
||||
* 待播期间玩家任何操作即作废该提示(未播级别不计入已播)。
|
||||
*
|
||||
* round4 H1(docs/design/14-guide-layer.md「round4 v1.3」):阶段目标句
|
||||
* (found_key / gd_next_fuse / fix_box)播完后,按房间图计算目标房间相对当前
|
||||
@@ -107,11 +118,12 @@ export class EscapeGame extends Game {
|
||||
this.guideSwipes = 0; // G2 撞墙计数
|
||||
this.guideErrs = 0; // 当前步骤连续错误数
|
||||
this._guideBusy = false; // 步骤推进语音播放中(忽略非跳过分手势)
|
||||
// 卡关检测
|
||||
this._lastProgressAt = Date.now();
|
||||
// 卡关检测(按"无操作时长"计时,见 _checkStuck)
|
||||
this._lastActiveAt = Date.now();
|
||||
this._stuckShown = new Set(); // 当前进展段内已自动播过的级别 'L1'|'L2'|'L3'
|
||||
this._lastL3At = 0;
|
||||
this._wallBumps = 0; // 连续撞墙计数(向导层提前一级提示用)
|
||||
this._pendingVoice = []; // 待播语音队列:排队等当前语音播完,绝不顶句
|
||||
this._playSeq = 0; // voice 播放请求序号(onComplete 内判断是否链播新句)
|
||||
// 菜单
|
||||
this._menuReturn = Phase.ENDING;
|
||||
this._menuIdx = 0;
|
||||
@@ -145,6 +157,11 @@ export class EscapeGame extends Game {
|
||||
onGesture(g) {
|
||||
console.log('[EscapeGame] gesture:', g, 'phase=', this.phase, 'pos=', this.x, this.y);
|
||||
this.guide.disarm(); // 任何手势复位向导状态(guide=off 时 no-op)
|
||||
if (this.phase === Phase.PLAY) {
|
||||
// 卡关提示按"无操作时长"计时:任何有效手势重置计时,并作废待播提示
|
||||
this._lastActiveAt = Date.now();
|
||||
this._dropPendingStuck();
|
||||
}
|
||||
switch (this.phase) {
|
||||
case Phase.REOPEN:
|
||||
if (g === G.UP) { this.audio.stopVoice(); this._startGuide(); }
|
||||
@@ -181,7 +198,17 @@ export class EscapeGame extends Game {
|
||||
this._resetState();
|
||||
this.phase = Phase.GUIDE;
|
||||
this.guideStep = 0;
|
||||
this._speak(['voice/g0_intro'], null, { skippable: false });
|
||||
// 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,14 +237,23 @@ export class EscapeGame extends Game {
|
||||
}
|
||||
|
||||
_onGuideGesture(g) {
|
||||
// 任何步骤可下滑跳过——G4 除外:该步的正确答案就是下滑(01 号文档"G4=下滑"
|
||||
// 与"任何步骤下滑跳过"冲突,以 13 号试玩标准为准:G4 下滑 = 作答,不可跳过)
|
||||
if (g === G.DOWN && this.guideStep !== 4) { this._skipGuide(); return; }
|
||||
// 下滑跳过仅限"答案不含滑动"的步骤(G0/G3/G5/G6):G1/G2 的正确答案是
|
||||
// 任意滑动、G4 的答案就是下滑——这些步骤下滑 = 作答,不可跳过(13 号文档
|
||||
// 裁定;round2-live:g1_move 明说"朝任意方向滑一下",玩家下滑却被保送
|
||||
// 自由探索,教学链就此断裂)
|
||||
if (g === G.DOWN && this.guideStep !== 1 && this.guideStep !== 2 && this.guideStep !== 4) {
|
||||
this._skipGuide();
|
||||
return;
|
||||
}
|
||||
if (this._guideBusy) return; // 推进语音播放中,忽略
|
||||
switch (this.guideStep) {
|
||||
case 0: // G0:单击继续
|
||||
case 0: // G0:单击继续(intro 播完 1.2s 未操作也会自动推进 G1,见 _startGuide)
|
||||
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 }); // "先跟我走"
|
||||
// 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)) {
|
||||
@@ -339,11 +375,14 @@ export class EscapeGame extends Game {
|
||||
case G.LEFT: this._move(-1, 0); break;
|
||||
case G.RIGHT: this._move(1, 0); break;
|
||||
case G.TAP:
|
||||
// voice 播放中:可跳句单击 = 跳过本句(链播继续);不可跳句(关键获得/
|
||||
// L3 带路)单击 = 忽略——若照旧触发检查,800ms 后的描述会顶掉该句。
|
||||
// 无 voice 时单击 = 原检查语义。
|
||||
// voice 播放中:可跳句单击 = 跳过本句(链播继续)+ 补发检查(round1-live
|
||||
// 修复:原把整个单击当"跳过键"吃掉,检查动作无声丢失)——环境音即时重播,
|
||||
// 描述进待播队列等链播播完,不顶句。不可跳句(关键获得/L3 带路)单击 =
|
||||
// 忽略——若照旧触发检查,描述会顶掉该句。无 voice 时单击 = 原检查语义。
|
||||
if (this.audio.isVoicePlaying()) {
|
||||
if (this._voiceSkippable) this.audio.skipVoice();
|
||||
if (!this._voiceSkippable) break;
|
||||
this.audio.skipVoice();
|
||||
this._inspect({ queue: true });
|
||||
break;
|
||||
}
|
||||
this._inspect();
|
||||
@@ -358,15 +397,10 @@ export class EscapeGame extends Game {
|
||||
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 提前一级)
|
||||
// 撞墙极简反馈:只播「那边是墙,换个方向。」(round1-live 修复:原接播
|
||||
// guide_blunt_<dir>「手指贴着屏幕,向 X 推一下」教学腔方位句,中后期出戏;
|
||||
// 教学腔仅限引导期 G2/G3 纠错路径,见 _onGuideGesture/_guideWrong)
|
||||
this._playVoice('voice/wall');
|
||||
return;
|
||||
}
|
||||
this.x = nx;
|
||||
@@ -396,13 +430,21 @@ export class EscapeGame extends Game {
|
||||
this.audio.play(this._ambientId(), { volume: 0.9 });
|
||||
}
|
||||
|
||||
/** 单击 = 重播当前房间环境音 + 检查描述。 */
|
||||
_inspect() {
|
||||
/**
|
||||
* 单击 = 重播当前房间环境音 + 检查描述。
|
||||
* 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) this._playVoice(id, { skippable });
|
||||
if (s === this._session) emit(id, skippable);
|
||||
}, 800); // 先让环境音进耳朵再播描述
|
||||
|
||||
const { x, y } = this;
|
||||
@@ -415,10 +457,7 @@ export class EscapeGame extends Game {
|
||||
// H1(round4):钥匙到手句后接播铁柜房方位(下一目标 = cabinet (2,1))。
|
||||
this._timer(() => {
|
||||
if (s !== this._session) return;
|
||||
this._playVoice('voice/found_key', {
|
||||
skippable: false, // 关键获得不可跳
|
||||
onComplete: () => this._playGoalDir(STAGES.cabinet.target),
|
||||
});
|
||||
emit('voice/found_key', false, () => this._playGoalDir(STAGES.cabinet.target));
|
||||
}, 800); // 先让环境音进耳朵再播描述
|
||||
} else {
|
||||
describe('voice/empty_2');
|
||||
@@ -531,43 +570,44 @@ export class EscapeGame extends Game {
|
||||
}
|
||||
|
||||
_resetStuck() {
|
||||
this._lastProgressAt = Date.now();
|
||||
this._lastActiveAt = Date.now();
|
||||
this._stuckShown = new Set();
|
||||
this._lastL3At = 0;
|
||||
this._wallBumps = 0;
|
||||
}
|
||||
|
||||
/** 进展事件:重置卡关计时与已播级别。 */
|
||||
/** 进展事件:清空已播提示级别,下段静默期从 L1 重新分级(计时由手势重置)。 */
|
||||
_progressEvent() {
|
||||
this._resetStuck();
|
||||
this._stuckShown.clear();
|
||||
this._lastL3At = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡关轮询(5s):按"无操作时长"分级触发提示(60s 暗示 / 120s 明示 / 180s 带路)。
|
||||
* 到点只进待播队列(_enqueueVoice):语音在播则排队等其自然播完,绝不顶句;
|
||||
* 待播期间玩家任何操作即作废(见 _dropPendingStuck)。
|
||||
*/
|
||||
_checkStuck() {
|
||||
if (this.phase !== Phase.PLAY) return;
|
||||
const idleMs = Date.now() - this._lastProgressAt;
|
||||
const idleMs = Date.now() - this._lastActiveAt;
|
||||
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) {
|
||||
if (idleMs >= 180000) {
|
||||
// L3 带路:首次或每 60s 重复,按当前坐标重算路线(带路语音不可单击跳过)
|
||||
if (!this._stuckShown.has('L3') || Date.now() - this._lastL3At >= 60000) {
|
||||
this._stuckShown.add('L3');
|
||||
this._lastL3At = Date.now();
|
||||
vibrate('warning');
|
||||
this._speak([`voice/stuck_${stage}_3`, ...this._navVoices(STAGES[stage].target)], null, { skippable: false });
|
||||
this._enqueueVoice('stuck',
|
||||
[`voice/stuck_${stage}_3`, ...this._navVoices(STAGES[stage].target)],
|
||||
{ skippable: false, level: 'L3' });
|
||||
}
|
||||
} else if (idleMs >= T_L2 && !this._stuckShown.has('L2')) {
|
||||
} else if (idleMs >= 120000 && !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._enqueueVoice('stuck', [`voice/stuck_${stage}_2`], { level: 'L2' });
|
||||
} else if (idleMs >= 60000 && !this._stuckShown.has('L1')) {
|
||||
this._stuckShown.add('L1');
|
||||
vibrate('warning');
|
||||
this._speak(['voice/stuck_ask', `voice/stuck_${stage}_1`]);
|
||||
this._enqueueVoice('stuck', ['voice/stuck_ask', `voice/stuck_${stage}_1`], { level: 'L1' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,8 +674,6 @@ export class EscapeGame extends Game {
|
||||
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;
|
||||
@@ -644,6 +682,13 @@ export class EscapeGame extends Game {
|
||||
// 结算语音链(关键获得,不可跳);播完淡入 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);
|
||||
}
|
||||
@@ -713,10 +758,55 @@ export class EscapeGame extends Game {
|
||||
return g === G.UP || g === G.DOWN || g === G.LEFT || g === G.RIGHT;
|
||||
}
|
||||
|
||||
/** 对白包装:登记本句是否允许单击跳过(12 号文档裁定表,默认可跳)。 */
|
||||
/**
|
||||
* 对白包装:登记本句是否允许单击跳过(12 号文档裁定表,默认可跳)。
|
||||
* 本句自然播完(含被单击跳过)且没有链播新句时,补播待播队列里的语音。
|
||||
*/
|
||||
_playVoice(id, { skippable = true, onComplete } = {}) {
|
||||
this._playSeq++;
|
||||
this._voiceSkippable = skippable;
|
||||
this.audio.play(id, { onComplete });
|
||||
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 链式触发;被打断即作废整条链)。 */
|
||||
|
||||
+225
-24
@@ -334,9 +334,13 @@ section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单
|
||||
const playSecAtEscape = Math.floor((now - game.startElapsedMs) / 1000);
|
||||
await gs(game, 'doubletap');
|
||||
assert(game.phase === 'ENDING', '双击大门逃脱,进入 ENDING');
|
||||
assert(rec.count('sfx/gate_open') === 1 && rec.count('sfx/win_chime') === 1, '逃脱音效 gate_open + win_chime');
|
||||
assert(rec.count('sfx/gate_open') === 0 && rec.count('sfx/win_chime') === 0,
|
||||
'逃脱音效后移:人声头部 2.6s 内无 gate_open/win_chime 对撞(round2-live P2)');
|
||||
assert(rec.count('voice/escape') === 1, 'escape 语音播出');
|
||||
assert(playSecAtEscape < 120, `本次用时 ${playSecAtEscape}s < 120s`);
|
||||
await tick(2600);
|
||||
assert(rec.count('sfx/gate_open') === 1 && rec.count('sfx/win_chime') === 1,
|
||||
'逃脱音效 2.6s 后播出(人声头部避让,轰鸣落在「电机轰鸣」附近)');
|
||||
await finishVoices(1);
|
||||
assert(rec.count('voice/time_band1') === 1, 'time_band1 语音播出(<120s 档)');
|
||||
await finishVoices(2); // time_band1 + again → 链完 → 结算 pad
|
||||
@@ -386,12 +390,17 @@ section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单
|
||||
await tick(800);
|
||||
assert(rec.last('voice/start_room') && rec.last('voice/start_room').t - rec.last('sfx/amb_drip').t === 800,
|
||||
'描述在环境音 800ms 后播');
|
||||
// start_room 是检查描述(可跳):voice 播放中单击 = 跳过本句
|
||||
// start_room 是检查描述(可跳):voice 播放中单击 = 跳过本句 + 补发检查
|
||||
// (round1-live:跳过后检查意图不丢——环境音即时重播,描述 800ms 后补播)
|
||||
const skipsBefore = rec.skips();
|
||||
const ambBefore = rec.count('sfx/amb_drip');
|
||||
const startRoomBefore = rec.count('voice/start_room');
|
||||
await gs(game, 'tap');
|
||||
assert(rec.skips() === skipsBefore + 1, '检查描述播放中单击 = skipVoice');
|
||||
assert(rec.count('sfx/amb_drip') === ambBefore, '跳过时不触发检查');
|
||||
assert(rec.count('sfx/amb_drip') === ambBefore + 1, '跳过后补发检查:环境音即时重播');
|
||||
assert(rec.count('voice/start_room') === startRoomBefore, '补发的检查描述 800ms 内未播');
|
||||
await tick(800);
|
||||
assert(rec.count('voice/start_room') === startRoomBefore + 1, '跳过后补发检查:描述 800ms 后补播');
|
||||
await tick(200);
|
||||
|
||||
// ---- 边界:撞墙 (0,0) 左滑 → wall + error 震动 + 位置不变 + 无脚步声 ----
|
||||
@@ -420,6 +429,81 @@ section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 A2:冷启动引导链回归(round2-live P1)——intro 后不点不滑,
|
||||
// g1_move 必须自动播出;全程零单击(G0 不点)也能逐步走完 8 步引导
|
||||
// ================================================================
|
||||
section('A2. 冷启动引导链:intro 后 g1_move 自动播出,零单击走通(round2-live 回归)');
|
||||
{
|
||||
store.clear();
|
||||
const { game, rec } = await newGame();
|
||||
game.start();
|
||||
await flush();
|
||||
assert(rec.count('voice/g0_intro') === 1, 'g0_intro 播放');
|
||||
await tick(VOICE_END_SLACK); // intro 播完(虚拟 1s 句)
|
||||
assert(rec.count('voice/g1_move') === 0, 'intro 刚播完:g1_move 尚未播(1.2s 自动推进未到点)');
|
||||
await tick(1200); // 自动推进到点
|
||||
assert(rec.count('voice/g1_move') === 1, '无任何手势:intro 后 1.2s 自动播 g1_move(P1 回归断言)');
|
||||
await finishVoices(2); // g1_move + skip_hint
|
||||
assert(game.guideStep === 1 && !game._guideBusy, '自动进入 G1,输入解锁');
|
||||
|
||||
// intro 播放中滑动不顶开场白(对照:原"先跟我走"会 kill intro 且不再自动推进)
|
||||
store.clear();
|
||||
const g1b = await newGame();
|
||||
g1b.game.start();
|
||||
await flush();
|
||||
await tick(400); // intro 播放中
|
||||
await gs(g1b.game, 'right'); // 抢跑滑动:应被忽略,不顶 intro
|
||||
assert(g1b.rec.count('voice/guide_offpath') === 0, 'intro 播放中滑动:不播 guide_offpath 顶开场白');
|
||||
await tick(VOICE_END_SLACK + 1200);
|
||||
assert(g1b.rec.count('voice/g1_move') === 1, 'intro 播放中滑动后:g1_move 仍自动播出');
|
||||
g1b.game.stop();
|
||||
|
||||
// G1 下滑 = 作答(g1_move 明说"任意方向"),不再是跳过引导
|
||||
await gs(game, 'down');
|
||||
assert(game.phase === 'GUIDE' && game.guideStep !== 0, 'G1 下滑不跳过引导(仍在 GUIDE)');
|
||||
assert(rec.count('voice/guide_skip') === 0, 'G1 下滑不播 guide_skip');
|
||||
await finishVoices(2); // g1_done + g2_wall
|
||||
assert(game.guideStep === 2, 'G1 下滑作答进入 G2');
|
||||
|
||||
// G2 下滑也计入撞墙滑动(再补一滑凑满 2 次推进)
|
||||
await gs(game, 'down');
|
||||
await gs(game, 'up');
|
||||
await finishVoices(2); // wall + g3_left
|
||||
assert(game.guideStep === 3, 'G2 下滑作答计入,两滑后进入 G3');
|
||||
|
||||
// 零单击走完全链:G3 左 → G4 下 → G5 单击(答题单击,非 G0 继续单击)
|
||||
await gs(game, 'left');
|
||||
await finishVoices(1); // g4_down
|
||||
await gs(game, 'down'); // G4 下滑 = 作答
|
||||
await finishVoices(1); // g5_inspect
|
||||
assert(game.guideStep === 5, 'G4 下滑作答进入 G5');
|
||||
await gs(game, 'tap'); // G5 检查拾钥匙
|
||||
await finishVoices(2); // g5_key + g6_doubletap
|
||||
await gs(game, 'doubletap'); // G6
|
||||
await finishVoices(3); // inv_key + g6_done + g7_goal
|
||||
assert(game.phase === 'PLAY', '冷启动零 G0 单击:教学链逐步走通进 PLAY');
|
||||
game.stop();
|
||||
|
||||
// 下滑跳过在答案不含滑动的步骤仍生效(G3)
|
||||
store.clear();
|
||||
const g2b = await newGame();
|
||||
g2b.game.start();
|
||||
await flush();
|
||||
await gs(g2b.game, 'tap'); // G0 → G1
|
||||
await finishVoices(2);
|
||||
await gs(g2b.game, 'up'); // G1
|
||||
await finishVoices(2);
|
||||
await gs(g2b.game, 'up'); // G2 ×2
|
||||
await gs(g2b.game, 'up');
|
||||
await finishVoices(2); // → G3
|
||||
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');
|
||||
g2b.game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 B:引导错手势纠错 + 连错 3 次直白重述 + 下滑跳过引导
|
||||
// ================================================================
|
||||
@@ -468,9 +552,9 @@ section('B. 引导纠错与跳过');
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 C:卡关三级提示(60s/120s/180s + nav 带路 + 进展重置 + 跳过裁定)
|
||||
// 场景 C:卡关三级提示(无操作 60s/120s/180s + nav 带路 + 进展重置 + 跳过补发检查)
|
||||
// ================================================================
|
||||
section('C. 卡关提示计时器');
|
||||
section('C. 卡关提示计时器(无操作时长语义)');
|
||||
{
|
||||
store.clear();
|
||||
store.set('soundgame.escape.guideDone', '1'); // 二周目直达
|
||||
@@ -484,49 +568,166 @@ section('C. 卡关提示计时器');
|
||||
assert(game.phase === 'PLAY', 'REOPEN 单击直接进 PLAY');
|
||||
await finishVoices(1); // start_room
|
||||
|
||||
// 60s 无进展 → L1(stuck_ask + stuck_key_1)
|
||||
// 无操作 60s → L1(stuck_ask + stuck_key_1)
|
||||
const warn0 = vibrateCount(WARN_PAT);
|
||||
await tick(60000);
|
||||
assert(rec.count('voice/stuck_ask') === 1, '60s 播 stuck_ask');
|
||||
// L1 可跳:stuck_ask 播放中单击 → 链进 stuck_key_1
|
||||
assert(rec.count('voice/stuck_ask') === 1, '无操作 60s 播 stuck_ask');
|
||||
assert(vibrateCount(WARN_PAT) === warn0 + 1, 'L1 发 warning 震动');
|
||||
// L1 链可跳:播放中单击 = 跳过本句(链播 stuck_key_1)+ 补发检查(round1-live)
|
||||
const skips0 = rec.skips();
|
||||
const ambDrip0 = rec.count('sfx/amb_drip');
|
||||
await gs(game, 'tap');
|
||||
assert(rec.skips() === skips0 + 1, 'L1 提示可单击跳过');
|
||||
assert(rec.skips() === skips0 + 1, 'L1 提示链播放中单击 = 跳过本句');
|
||||
await flush();
|
||||
assert(rec.count('voice/stuck_key_1') === 1, '跳过后链播 stuck_key_1');
|
||||
assert(vibrateCount(WARN_PAT) === warn0 + 1, 'L1 发 warning 震动');
|
||||
await finishVoices(1);
|
||||
assert(rec.count('sfx/amb_drip') === ambDrip0 + 1, '跳过后补发检查:环境音即时重播');
|
||||
await tick(800); // 补发描述到点:stuck_key_1 仍在播 → 进待播队列
|
||||
assert(rec.count('voice/start_room') === 1, '链播未完,补发描述排队等待(不顶句)');
|
||||
await finishVoices(1); // stuck_key_1 播完 → 补发描述开播
|
||||
assert(rec.count('voice/start_room') === 2, '链播播完后补发检查描述');
|
||||
await finishVoices(1); // 补发描述播完
|
||||
|
||||
// 120s → L2;180s → L3 带路(stuck_key_3 + nav_left + nav_down)
|
||||
await tick(60000);
|
||||
assert(rec.count('voice/stuck_key_2') === 1, '120s 播 stuck_key_2');
|
||||
// 自上次操作起连续无操作 120s → L2 明示;180s → L3 带路(stuck_key_3 + nav_left + nav_down)
|
||||
await tick(125000);
|
||||
assert(rec.count('voice/stuck_key_2') === 1, '无操作 120s 播 stuck_key_2');
|
||||
await finishVoices(1);
|
||||
await tick(60000);
|
||||
assert(rec.count('voice/stuck_key_3') === 1, '180s 播 stuck_key_3');
|
||||
assert(rec.count('voice/stuck_key_3') === 1, '无操作 180s 播 stuck_key_3');
|
||||
await finishVoices(1); // stuck_key_3 播完 → nav_left 起
|
||||
assert(rec.count('voice/nav_left') === 1, 'L3 带路算路:(1,1)→(0,2) 先播 nav_left');
|
||||
await finishVoices(1); // nav_left 播完 → nav_down 起
|
||||
assert(rec.count('voice/nav_down') === 1, 'L3 带路算路:再播 nav_down');
|
||||
// L3 带路不可跳:播放中单击 = 忽略
|
||||
await finishVoices(1); // nav_down 播完
|
||||
|
||||
// L3 之后每 60s 无操作重复带路
|
||||
await tick(60000);
|
||||
assert(rec.count('voice/stuck_key_3') === 2, 'L3 每 60s 无操作重复带路');
|
||||
await finishVoices(3);
|
||||
|
||||
// L3 带路不可跳:播放中单击 = 忽略(新一轮带路起播后验证)
|
||||
await tick(60000);
|
||||
assert(rec.count('voice/stuck_key_3') === 3, '持续无操作 L3 再重复');
|
||||
const skips1 = rec.skips();
|
||||
const ambC = rec.count('sfx/amb_drip');
|
||||
await gs(game, 'tap');
|
||||
assert(rec.skips() === skips1, 'L3 带路语音不可单击跳过');
|
||||
assert(rec.count('sfx/amb_drip') === ambC, 'L3 播放中单击不触发检查');
|
||||
await finishVoices(1); // nav_down 播完
|
||||
await finishVoices(3); // 该轮带路链播完
|
||||
|
||||
// L3 之后每 60s 重复带路
|
||||
await tick(60000);
|
||||
assert(rec.count('voice/stuck_key_3') === 2, 'L3 每 60s 重复带路');
|
||||
await finishVoices(3);
|
||||
|
||||
// 进展事件重置:移动到关键房间 → _stuckShown 清空 → 再无进展约 60s 重新从 L1 起
|
||||
// 进展事件重置:移动到关键房间 → _stuckShown 清空 → 再无操作约 60s 重新从 L1 起
|
||||
// (卡关检查每 5s 一次,相位对齐需略多于 60s)
|
||||
await gs(game, 'right'); // (2,1) 铁柜房,首到 = 进展
|
||||
assert(game._stuckShown.size === 0, '进展事件后 _stuckShown 清空');
|
||||
await finishVoices(1); // room_cabinet
|
||||
await tick(66000);
|
||||
assert(rec.count('voice/stuck_ask') === 2, '进展重置后 60s 重新播 L1');
|
||||
assert(rec.count('voice/stuck_ask') === 2, '进展重置后无操作 60s 重新播 L1');
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 C2:活跃操作不触发卡关提示(round1-live:按"无操作时长"计时)
|
||||
// ================================================================
|
||||
section('C2. 活跃操作时不触发卡关提示');
|
||||
{
|
||||
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(1); // start_room
|
||||
|
||||
// 每 ~25s 一个普通移动手势(无进展事件),持续 ~250s:任何卡关提示都不应出现
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await tick(25000);
|
||||
await gs(game, i % 2 === 0 ? 'left' : 'right');
|
||||
}
|
||||
assert(game.x === 1 && game.y === 1, '往返移动 10 次回到 (1,1)');
|
||||
assert(rec.count('voice/stuck_ask') === 0, '活跃操作 250s:无 L1(stuck_ask)');
|
||||
assert(
|
||||
rec.count('voice/stuck_key_1') === 0 && rec.count('voice/stuck_key_2') === 0 && rec.count('voice/stuck_key_3') === 0,
|
||||
'活跃操作 250s:无 L2/L3 提示(机关枪回归断言)',
|
||||
);
|
||||
// 停止操作 60s 后 L1 如期而至:计时器仍武装
|
||||
await tick(65000);
|
||||
assert(rec.count('voice/stuck_ask') === 1, '停止操作 60s 后播 L1');
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 C3:提示排队礼仪——不顶掉进行中的语音;待播期间玩家操作即作废
|
||||
// ================================================================
|
||||
section('C3. 卡关提示排队:不顶语音、操作作废');
|
||||
{
|
||||
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(1); // start_room
|
||||
|
||||
// —— 语音播放中到点:提示入队,等当前句自然播完 ——
|
||||
await gs(game, 'tap'); // 单击检查(无语音 → 直接检查)
|
||||
await tick(800); // 检查描述开播(start_room,虚拟时长 1s)
|
||||
assert(audio.isVoicePlaying(), '前提:检查描述播放中');
|
||||
game._lastActiveAt = now - 61000; // 测试钩子:回拨无操作计时(模拟已 61s 无操作)
|
||||
game._checkStuck(); // 直调:等效卡关轮询到点
|
||||
assert(rec.count('voice/stuck_ask') === 0, '语音播放中 L1 只入队不开播');
|
||||
assert(audio.isVoicePlaying(), '进行中的语音未被顶掉');
|
||||
await finishVoices(1); // 描述自然播完
|
||||
assert(rec.count('voice/stuck_ask') === 1, '语音播完后 L1 补播 stuck_ask');
|
||||
const desc = rec.last('voice/start_room');
|
||||
const hint = rec.last('voice/stuck_ask');
|
||||
assert(hint.t >= desc.t + 1000, 'stuck_ask 在描述自然播完之后才开播(未 kill)');
|
||||
await finishVoices(2); // stuck_ask + stuck_key_1 播完
|
||||
|
||||
// —— 待播期间玩家操作:提示作废,且不按已播计入 ——
|
||||
await gs(game, 'tap'); // 再次检查(无语音 → 直接检查)
|
||||
await tick(800); // 描述开播
|
||||
game._lastActiveAt = now - 121000; // 钩子:121s 无操作 → L2 到点
|
||||
game._checkStuck();
|
||||
assert(rec.count('voice/stuck_key_2') === 0, '语音播放中 L2 只入队不开播');
|
||||
await gs(game, 'tap'); // 玩家操作(跳过描述 + 补发检查)→ 待播 L2 作废
|
||||
await finishVoices(2);
|
||||
assert(rec.count('voice/stuck_key_2') === 0, '玩家操作后待播 L2 不再播出');
|
||||
assert(!game._stuckShown.has('L2'), '作废的 L2 不计入已播级别');
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 场景 C4:重开后高频操作零卡关提示(round2-live:复测 stuck_key_2 实为
|
||||
// 双击兜底主动求提示,非计时器——本场景冻结计时器语义:重开重置无操作计时)
|
||||
// ================================================================
|
||||
section('C4. 重开后高频操作 90s 零卡关提示(round2-live 回归)');
|
||||
{
|
||||
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(1);
|
||||
|
||||
game._restart(); // 结算/菜单重开路径:计时必须重置
|
||||
await flush();
|
||||
assert(rec.count('voice/replay') === 1, '重开播 replay');
|
||||
await finishVoices(1);
|
||||
// 每秒一个操作(滑动/单击交替,不用双击——双击无目标是设计内主动求提示),
|
||||
// 持续 90s:任何卡关提示都不应出现
|
||||
for (let i = 0; i < 90; i++) {
|
||||
await tick(1000);
|
||||
await gs(game, i % 2 === 0 ? 'left' : 'tap');
|
||||
if (i % 2 === 1) await tick(900); // 单击补发描述到点播出,下一句前消化
|
||||
}
|
||||
assert(rec.count('voice/stuck_ask') === 0, '重开后高频操作 90s:无 L1(stuck_ask)');
|
||||
assert(
|
||||
rec.count('voice/stuck_key_1') === 0 && rec.count('voice/stuck_key_2') === 0 && rec.count('voice/stuck_key_3') === 0,
|
||||
'重开后高频操作 90s:无 L2/L3(复测 stuck_key_2 = 双击兜底,非计时器)',
|
||||
);
|
||||
// 停手 60s 后 L1 如期而至:重开后计时器仍武装
|
||||
await tick(66000);
|
||||
assert(rec.count('voice/stuck_ask') === 1, '重开后停手 60s 播 L1(计时器仍武装)');
|
||||
game.stop();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user