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 链式触发;被打断即作废整条链)。 */
|
||||
|
||||
Reference in New Issue
Block a user