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:
xsl
2026-09-01 22:00:00 +08:00
parent 624fec8a23
commit 4e9970e227
22 changed files with 3161 additions and 401 deletions
+164 -35
View File
@@ -8,7 +8,8 @@
* 探索态手势:左右滑=切选项(播选项名)/ 单击=确认 / 双击=重听场景描述 /
* 上滑=状态播报 / 下滑=菜单。
* 战斗态手势:左右滑=切指令(攻击→破空斩→防御→金疮药)/ 单击=执行 /
* 双击=格挡(敌方前摇窗口内,减伤 70%,即时回调规避 tap 延迟)/
* 双击=格挡(敌方前摇窗口内,减伤 70%、擦伤钳 1~3——11 文档 §3「格挡成功
* 2-3 伤」,即时回调规避 tap 延迟)/
* 上滑=敌我状态 / 下滑=逃跑(Lv<3 必成;Lv≥3 70%,失败=敌方一个正常
* 前摇回合;Boss/剧情战无效)。
* 战斗重构(docs/design/11-rpg-battle-rebalance.md):首战四步强制教学
@@ -22,8 +23,10 @@
* 快切 bgm/battle|bgm/boss(amb 停);胜利=sfx/victory → 战斗 BGM 淡出 1.5s
* → 探索音乐淡入 2s;章结算=bgm/ending。bgm=0 时引擎只记意图不播放。
* 旁白跳过(12 文档 §1):探索态 voice 播放中单击=skipVoice() 跳过本句
* (链继续);战斗指令名 cmd_* 与教学句 tut_b* 不可跳。开场插播一次
* voice/skip_hint。长按=引擎内置旁白 1.7 倍速(游戏零改动)。
* (链继续);在播句为选项报名/边界提示时单击同时=确认当前选项(跳句+
* 补发语义动作,round2-live P1 探索菜单吞 tap 修复)。战斗指令名 cmd_*
* 与教学句 tut_b* 不可跳。开场插播一次 voice/skip_hint。长按=引擎内置
* 旁白 1.7 倍速(游戏零改动)。
* 震动(addendum §1 配合总表):前摇开窗 beat / 格挡 confirm / 受击 hit /
* 击杀 success / 升级 success / 窗口内误触 error / 金疮药 pickup /
* Boss 半血 warning / 血量告急 warning。
@@ -139,6 +142,15 @@ export class RpgGame extends Game {
this._segStart = null;
// F7:回绕提示去重(同一次菜单打开内每句最多 1 次)
this._wrapShown = new Set();
// round1-live P2:向导插播句(gd_stall)开播时刻(看门狗 onEscalate 记录),
// _speak 据此让结算语音链排队等它播完(不拦腰顶断);_lastGestureAt 供教学
// 8s 重复提示的"玩家有操作则跳过"判活
this._guideVoiceAt = 0;
this._lastGestureAt = 0;
// round2-live P1:探索态当前在播语音的类别('scene' desc 链/'option' 选项报名
// 与边界提示/'status' 状态链/'event' 事件·NPC 句)——语音中单击的语义据此分流:
// 'option' = 跳句+补发确认(执行当前选项),其余 = 只跳句(12 文档 §1 跳句惯例)
this._exploreVoiceKind = null;
this._newRun();
}
@@ -225,6 +237,7 @@ export class RpgGame extends Game {
onGesture(g) {
this.guide.disarm(); // 任何手势复位 PLAYER_SELECT 看门狗(guide=off 时 no-op)
this._lastGestureAt = performance.now(); // 教学 8s 重复提示的判活依据(round1-live P2-3)
switch (this.phase) {
case 'ask':
if (g === 'tap') { const s = this._loadSave(); if (s) this._continueGame(s); else this._startNewGame(false); }
@@ -277,6 +290,7 @@ export class RpgGame extends Game {
desc = `voice/desc_${id}_re`; // gate/boss1 无 _re 句:boss1 不走此分支,gate 有 desc_gate 但无 _re——回退全句
if (id === 'gate') desc = 'voice/desc_gate';
}
this._exploreVoiceKind = 'scene'; // round2-live P1:desc 链在播=只跳句不确认
this._speakGuarded([...prepend, desc], () => {
this._save();
if (this._checkEncounter()) return;
@@ -314,6 +328,9 @@ export class RpgGame extends Game {
if (this.phase !== 'explore') return;
const opts = SCENES[this.scene].options;
if (!opts.length) return;
// round2-live P1:选项报名(及其 onComplete 接播的边界提示)在播期间,
// 单击=跳句+确认当前选项(探索菜单吞 tap 修复,见 _onExploreGesture)
this._exploreVoiceKind = 'option';
this.audio.play(`voice/opt_${opts[this.optIdx]}`, { onComplete });
}
@@ -336,17 +353,35 @@ export class RpgGame extends Game {
this.optIdx = (this.optIdx + (g === 'right' ? 1 : n - 1)) % n;
const wrap = n >= 2 && ((g === 'right' && this.optIdx === 0) || (g === 'left' && this.optIdx === n - 1))
? (this.optIdx === 0 ? 'gd_wrap_first' : 'gd_wrap_last') : null;
// 回绕提示排在本项报名播完之后;被下一次滑动打断则自然作废(不迟到误导)
this._speakOption(wrap ? () => this._wrapCue(wrap) : undefined);
// round1-live P3:右滑落到"返回"项(各场景恒为末项)也补一句末位提示
// 「最后一个了。」——给循环列表一个听觉边界,防多滑一格误选返回被弹回
// 上一张图(每场景一次,与回绕提示共用 _wrapShown 去重;序号播报无现成
// 语音资产,按任务裁定做前者,不新增语音 id)
const cue = wrap || (opts[this.optIdx] === 'back' ? 'gd_wrap_last' : null);
// 回绕/末位提示排在本项报名播完之后;被下一次滑动打断则自然作废(不迟到误导)
this._speakOption(cue ? () => this._wrapCue(cue) : undefined);
} else if (g === 'tap') {
// voice 播放中单击 = 跳过本句(12 文档 §1:场景 desc/NPC 对白/事件/状态链可跳),
// 语音链跳下一句;无 voice 时单击 = 确认选项(原语义)。
if (this._voicePlaying()) { this.audio.skipVoice(); return; }
// round2-live P1:在播句是"选项报名/边界提示"(_exploreVoiceKind==='option')时,
// 单击在跳句同时补发确认语义(执行当前选项)——live 证据:选项报名开播 2.3s 时
// 单击被整个吞掉(语音被掐、场景未切、死寂 72s,1264.6s),「最后一个了。」开播
// 0.16s 时单击"返回"同样被吞(1370.1s);战斗里语音中单击却有效(844.5s),
// 规则必须一致:动作绝不石沉大海。desc/NPC/状态链播放中单击仍只跳句(A5/B6 契约)。
if (this._voicePlaying()) {
// 先取样类别:skipVoice 同步触发 onComplete 推进链(末句会接播选项报名并
// 改写 _exploreVoiceKind),若跳句后再读类别会把"跳 desc"误判成"确认选项"
const kind = this._exploreVoiceKind;
this.audio.skipVoice();
if (kind === 'option') this._executeOption(opts[this.optIdx]);
return;
}
this._executeOption(opts[this.optIdx]);
} else if (g === 'doubletap') {
// 重听场景描述 + 当前选项(amb 循环持续,无需重播)
const id = this.scene;
const desc = id === 'boss1' ? 'voice/desc_hall' : (id === 'gate' ? 'voice/desc_gate' : `voice/desc_${id}_re`);
this._exploreVoiceKind = 'scene'; // round2-live P1:重听链视同 desc(语音中单击=只跳句)
this._speak([desc, `voice/opt_${opts[this.optIdx]}`]);
} else if (g === 'up') {
this._speakStatus();
@@ -357,13 +392,15 @@ export class RpgGame extends Game {
_executeOption(opt) {
if (GO_TARGET[opt]) { this._enterScene(GO_TARGET[opt]); return; }
if (opt === 'back') { this._enterScene(BACK_MAP[this.scene] || 'village'); return; }
// round2-live P1:以下选项原地播事件/NPC/状态语音(非选项报名)——标记后
// 语音中单击=只跳句、不重复触发动作(shop 切 phase 后本标记不再被读取)
this._exploreVoiceKind = 'event';
switch (opt) {
case 'back':
this._enterScene(BACK_MAP[this.scene] || 'village');
break;
case 'shop':
this.phase = 'shop';
this._shopIdx = 0;
this._wrapShown = new Set(); // 商店子菜单重新"打开"(round1-live P3:离开项末位提示重新可用)
this._speak(['voice/shop_open', SHOP_ITEMS[0].priceVoice]);
break;
case 'rest':
@@ -463,6 +500,7 @@ export class RpgGame extends Game {
const weaponVoice = p.weapon ? WEAPONS[p.weapon].stVoice : 'voice/st_weapon_none';
const armorVoice = p.armor ? ARMORS[p.armor].stVoice : 'voice/st_armor_none';
if (this._hpBand(p.hp, this.maxHp) === 'low') vibrate('warning');
this._exploreVoiceKind = 'status'; // round2-live P1:状态链在播=只跳句不确认
this._speak([lvVoice, hpVoice, goldVoice, weaponVoice, armorVoice], onDone);
}
@@ -472,7 +510,10 @@ export class RpgGame extends Game {
if (g === 'left' || g === 'right') {
const n = SHOP_ITEMS.length;
this._shopIdx = (this._shopIdx + (g === 'right' ? 1 : n - 1)) % n;
this.audio.play(SHOP_ITEMS[this._shopIdx].priceVoice);
const it = SHOP_ITEMS[this._shopIdx];
// round1-live P3:落到"离开"项(末项)补末位提示「最后一个了。」(每次进商店一次)
this.audio.play(it.priceVoice,
it.type === 'leave' ? { onComplete: () => this._wrapCue('gd_wrap_last') } : undefined);
} else if (g === 'tap') {
this._buy(SHOP_ITEMS[this._shopIdx]);
} else if (g === 'down') {
@@ -576,6 +617,16 @@ export class RpgGame extends Game {
// 四步强制教学(11 文档 §2):menu(认识指令)→attack→parry→defend→自由击杀
teachStage: enc.teach ? 'menu' : null,
teachSwipes: 0,
// round1-live P2:resolving=回合结算中(闲置提示禁止插入);
// selectSeq=PLAYER_SELECT 代数(旧回合滞留的指令名定时器据此作废);
// cmdVoiceIdx/cmdVoiceAt=最近一次指令名播报(1.2s 去重防"攻攻击"口吃)
resolving: false,
selectSeq: 0,
cmdVoiceIdx: -1,
cmdVoiceAt: 0,
// round2-live P1:hpBandVoice=玩家"最后被告知"的血量档(开战=当前档)——
// 受伤播报只在实质受击且比它差时播当前实际档(擦伤不触发、同档不重复)
hpBandVoice: this._hpBand(this.player.hp, this.maxHp),
// 难度档位(11 文档 §3):教学战特例前摇 1.8s;其余按 battlesWon 取档,Boss 恒标准
tier: enc.teach ? { telSec: 1.8, dmgMul: 1.0 } : this._battleTier(enc),
telRate: 1,
@@ -622,31 +673,55 @@ export class RpgGame extends Game {
const b = this._battle;
if (!b) return;
b.state = 'PLAYER_SELECT';
b.resolving = false; // round1-live P2:回合结算结束、回到等待输入,闲置提示恢复可入
b.selectSeq++; // 指令名定时器代数+1:上一回合滞留的 650ms 定时器据此作废
this._wrapShown = new Set(); // F7:指令循环重新"打开",回绕提示重新可用
// 玩家回合的听觉标点(11 文档 §5):turn_player(≤0.4s)→ 留 200ms+ 间隙 → 指令名
this.audio.play('sfx/turn_player');
// 向导层静默看门狗(round2 F1.3:60s→30s,等待默认低频但不再干晾一分钟):
// PLAYER_SELECT 挂机永远静默等——30s 无输入 → nudge + gd_stall(一级即止)
// PLAYER_SELECT 挂机永远静默等——30s 无输入 → nudge + gd_stall(一级即止)。
// round1-live P2:回合结算/语音链进行中禁止闲置提示插入(cond 加 !b.resolving);
// onEscalate 记开播时刻,_speak 据此让结算语音排队等 gd_stall 播完(不拦腰顶断)
this.guide.watchdog({
arm: true,
at: [30000],
say: ['gd_stall'],
cond: () => this.phase === 'battle' && this._battle === b && b.state === 'PLAYER_SELECT',
onEscalate: () => { this._guideVoiceAt = performance.now(); },
cond: () => this.phase === 'battle' && this._battle === b && b.state === 'PLAYER_SELECT' && !b.resolving,
});
const cmdIdx = b.cmdIdx;
const seq = b.selectSeq;
this._after(650, () => {
if (this._battle === b && b.state === 'PLAYER_SELECT' && b.cmdIdx === cmdIdx) {
this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`);
if (this._battle === b && b.state === 'PLAYER_SELECT' && b.cmdIdx === cmdIdx && b.selectSeq === seq) {
this._playCmdVoice();
}
});
}
/**
* 指令名播报(round1-live P2 口吃修复):同一指令 1.2s 内已播过则跳过本次——
* 手势打断刚开播的指令名后,滞留定时器/折返滑不立刻原样重播("攻攻击。"卡碟)。
*/
_playCmdVoice(onComplete) {
const b = this._battle;
if (!b) { if (onComplete) onComplete(); return; }
const now = performance.now();
if (b.cmdVoiceIdx === b.cmdIdx && now - b.cmdVoiceAt < 1200) return; // 去重:跳过本次
b.cmdVoiceIdx = b.cmdIdx;
b.cmdVoiceAt = now;
this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`, onComplete ? { onComplete } : undefined);
}
/** 教学步推进(11 文档 §2 四步:menu→attack→parry→defend→free)。 */
_teachAdvance(next) {
const b = this._battle;
if (!b || !b.teachStage) return;
b.teachStage = next === 'free' ? null : next;
if (next === 'attack') this._speakGuarded(['voice/tut_b3'], () => this._playerSelect(), 8000);
// round1-live P2-3:T1 不再播 tut_b3——其实录为旧两步教学(格挡→攻击)台词
// 「挡住了!轮到你了。左右滑选攻击,单击出剑。」,新顺序 T1 在格挡之前播
// 「挡住了!」与实际不符;改播 tut_battle_2(现有指令教学句,语义一致)。
// tut_b3 资产保留但退出教学链(TTS 缺失不可重录,列遗留)。
if (next === 'attack') this._speakGuarded(['voice/tut_battle_2'], () => this._playerSelect(), 8000);
else if (next === 'parry') this._speakGuarded(['voice/tut_b1'], () => this._enemyTelegraph(), 8000);
else if (next === 'defend') this._speakGuarded(['voice/tut_b6'], () => this._playerSelect(), 8000);
else this._speakGuarded(['voice/tut_b5'], () => this._playerSelect(), 8000); // free:收尾
@@ -660,22 +735,33 @@ export class RpgGame extends Game {
_armTeachWatchdog(stage) {
const b = this._battle;
if (!b || !stage) return;
const hint = { menu: ['voice/tut_b0'], attack: ['voice/tut_b3'], defend: ['voice/tut_b6'] }[stage];
const hint = { menu: ['voice/tut_b0'], attack: ['voice/tut_battle_2'], defend: ['voice/tut_b6'] }[stage];
// round1-live P2-3:挂载时刻之后玩家有任何操作 → 8s 重复提示跳过
// (玩家正在边滑边想,重复提示=循环重播打断出手);16s 兜底推进不变
const armedAt = performance.now();
this._after(8000, () => {
if (this._battle === b && b.teachStage === stage && b.state === 'PLAYER_SELECT' && hint) {
// round2-live P2:重复提示是"自动轮播"语音——有语音在播(如链尾句未播完)
// 则跳过本次,绝不拦腰顶断(统一"播完再上"治理,live 333s 互掐证据)
if (this._battle === b && b.teachStage === stage && b.state === 'PLAYER_SELECT' && hint
&& this._lastGestureAt <= armedAt && !this._voicePlaying()) {
this._speak(hint); // 重复提示一次
}
});
this._after(16000, () => {
// round2-live P2:16s 兜底到点遇语音在播(如 8s 重复提示仍在播)→ 每 1.5s
// 重试、等播完再推进,不拦腰斩句(live 333.09s:tut_b0 重复句播到 7.9s 被
// 游戏自己兜底推进的 tut_battle_2 掐断);语音通道空出即推进,不卡死
const tryAdvance = () => {
if (this._battle !== b || b.teachStage !== stage) return;
if (b.state !== 'PLAYER_SELECT') return; // 回合交换已在推进,无卡死风险
if (this._voicePlaying()) { this._after(1500, tryAdvance); return; }
if (stage === 'menu') this._teachAdvance('attack');
else if (stage === 'attack') this._teachAdvance('parry');
else if (stage === 'defend') {
b.teachStage = null;
this._speakGuarded(['voice/tut_b5'], () => this._playerSelect(), 8000);
}
});
};
this._after(16000, tryAdvance);
}
_onBattleGesture(g) {
@@ -687,14 +773,13 @@ export class RpgGame extends Game {
// F7:指令循环回绕提示(探索菜单同款,full 档,跟在本项报名之后)
const wrap = (g === 'right' && b.cmdIdx === 0) || (g === 'left' && b.cmdIdx === CMDS.length - 1)
? (b.cmdIdx === 0 ? 'gd_wrap_first' : 'gd_wrap_last') : null;
this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`,
wrap ? { onComplete: () => this._wrapCue(wrap) } : undefined);
this._playCmdVoice(wrap ? () => this._wrapCue(wrap) : undefined);
// 教学 T0:切换 2 次(听到指令名切换)即过关
if (b.teachStage === 'menu' && ++b.teachSwipes >= 2) this._teachAdvance('attack');
} else if (g === 'tap') {
this._playerExecute();
} else if (g === 'up') {
this._speakStatus(() => { if (this._battle === b && b.state === 'PLAYER_SELECT') this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`); });
this._speakStatus(() => { if (this._battle === b && b.state === 'PLAYER_SELECT') this._playCmdVoice(); });
} else if (g === 'down') {
this._fleeCheck();
}
@@ -721,6 +806,7 @@ export class RpgGame extends Game {
const p = this.player;
const e = b.enemy;
b.state = 'PLAYER_EXECUTE';
b.resolving = true; // round1-live P2:回合结算开始,闲置提示禁止插入
const cmd = CMDS[b.cmdIdx];
if (cmd === 'skill' && p.mp < 3) {
this._speakGuarded(['voice/btl_nomp'], () => this._playerSelect(), 4000);
@@ -841,34 +927,63 @@ export class RpgGame extends Game {
let dmg = this._dmg(e.atk, this.def);
dmg = Math.max(1, Math.floor(dmg * b.tier.dmgMul)); // 新手保护伤害系数(向下取整 min 1,11 文档 §3)
if (b.parry) {
dmg = Math.max(0, Math.round(dmg * 0.3)); // 格挡减伤 70%(不震 hit,已震 confirm)
// 格挡减伤 70%(不震 hit,已震 confirm)。round1-live P1:擦伤钳 1~3——
// 对齐 11 文档 §3「格挡成功 2-3 伤」(原实现 round 无封顶,Boss 二阶段
// 单发擦伤可达 4,全格挡 8 刀可把 36 血磨进 low 档,播报与实际战况矛盾);
// min 1 保住"格挡也有擦伤"的痛感(设计意图=擦伤,非全免伤,05-rpg §2 减伤 70%)
dmg = Math.min(3, Math.max(1, Math.round(dmg * 0.3)));
} else {
if (b.defending) dmg = Math.round(dmg * 0.5); // 防御减半(与格挡不叠加)
this.audio.play('sfx/hurt');
vibrate('hit');
}
b.defending = false;
const bandBefore = this._hpBand(p.hp, this.maxHp);
p.hp -= dmg;
if (b.enc.teach) p.hp = Math.max(1, p.hp); // 教学战 HP 锁底 1(必不死)
console.log(`[RpgGame] 敌方攻击 dmg=${dmg} 格挡=${b.parry} 玩家HP=${p.hp}/${this.maxHp}`);
// 教学 T2:格挡成功或受击均可通过 → 进入防御教学 T3(11 文档 §2)。
// F4(round2):格挡**成功**通过时先插 tut_b2b「挡得漂亮。」确认表现,
// 消除 P1/P4「明明挡住了还说来不及格挡」的歧义(失败路径 tut_b4 不变)。
// round1-live P1:两条路径都插 tut_battle_4「没把握就防御,伤害减半。金疮药
// 可以回血。」——在格挡体验点明说减伤经济(格挡≠免伤、防御减半、药回血)。
// 无"格挡也有擦伤"原句资产(TTS 缺失不可新录,列遗留补录),选播现有最近说明句。
if (b.teachStage === 'parry') {
b.teachStage = 'defend';
const ids = b.parry ? ['voice/tut_b2b', 'voice/tut_b6'] : ['voice/tut_b4', 'voice/tut_b6'];
this._speakGuarded(ids, () => this._playerSelect(), 10000);
this._armTeachWatchdog('defend');
const ids = b.parry
? ['voice/tut_b2b', 'voice/tut_battle_4', 'voice/tut_b6']
: ['voice/tut_b4', 'voice/tut_battle_4', 'voice/tut_b6'];
// 三句链真实时长 ~16s:兜底放宽到 20s(原 10s 会在链中误触发进 PLAYER_SELECT,
// cmd 指令名拦腰顶断 tut_b6);教学看门狗改到链播完进回合时才挂载,
// 8s/16s 计时从玩家真正可操作起算(16s 兜底不再抢在 tut_b6 播完前跳步)
this._speakGuarded(ids, () => {
this._playerSelect();
this._armTeachWatchdog('defend');
}, 20000);
return;
}
if (p.hp <= 0) { this._defeat(); return; }
// 血量降档才播(不每击都播)
const bandAfter = this._hpBand(p.hp, this.maxHp);
if (bandAfter !== bandBefore) {
if (bandAfter === 'low') vibrate('warning');
this._speakGuarded([`voice/st_hp_${bandAfter}`], () => { b.round++; this._playerSelect(); }, 5000);
return;
// round2-live P1 血量档位播报重连线(设计依据:addendum §1 受击行——st_hp_mid/
// st_hp_low 本就是"受击(未格挡)"的附属播报「降档才播」,不是血量变化的全局
// 钩子;live 事件流证据:全格挡 20 次、0 hurt 音效,播报却精确落在格挡音后
// 0.09s——962.1/984.0s 等 3 次"受伤/命悬一线"全是擦伤跨档触发,警报被驯化成
// 背景音)。裁定:
// a) 格挡擦伤(1~3)静默走血,绝不触发受伤类播报——痛感由格挡音+confirm 震动
// 承担(11 文档 §3"格挡成功 2-3 伤"是技巧收益,不该配受伤警报);
// b) 只在实质受击(格挡失败/未格挡)时播,且播报档=结算后当前实际档——
// hpBandVoice 记"玩家最后被告知档",比它差才播并同步,播报与实际档位
// 恒一致(擦伤暗中跨档后,第一刀实质受击会按当前实际档补播,不会错位);
// c) 节流:同档不重复喊(中途回血升档静默;之后经中间档再次跌破视为新降档,
// 可重播一次)。
if (!b.parry) {
const band = this._hpBand(p.hp, this.maxHp);
const WORSE = { high: 0, mid: 1, low: 2 };
if (WORSE[band] > WORSE[b.hpBandVoice]) {
b.hpBandVoice = band;
if (band === 'low') vibrate('warning');
this._speakGuarded([`voice/st_hp_${band}`], () => { b.round++; this._playerSelect(); }, 5000);
return;
}
b.hpBandVoice = band; // 未降档(含回血后升档):静默同步"最后被告知档"
}
b.round++;
this._playerSelect();
@@ -876,6 +991,7 @@ export class RpgGame extends Game {
_fleeCheck() {
const b = this._battle;
b.resolving = true; // round1-live P2:逃跑判定=回合结算,闲置提示禁止插入
if (!b.enc.flee) {
// Boss/剧情战不可逃
vibrate('error');
@@ -1109,12 +1225,25 @@ export class RpgGame extends Game {
if (!ids.length) { if (onDone) onDone(); return; }
const s = this._session;
const [head, ...rest] = ids;
this.audio.play(head, {
// round1-live P2:向导插播句(gd_stall 等)刚开播还在播时,本链首句排队等它
// 播完,不拦腰顶断("想好了吗?"说半句被结算语音掐掉);余波 6s 后自愈
// (覆盖 gd_stall 男声 ~4.9s 全长),之后恢复默认顶句语义(不影响正常链间切换)
const justGuide = this._guideVoiceAt > 0
&& performance.now() - this._guideVoiceAt < 6000 && this._voicePlaying();
// round2-live P2:刚开播的指令名(cmd_* ≤1.2s 短句)还在播时,本链首句同样
// 排队等它播完——杀敌/半血/降档等结算语音不拦腰斩指令名(live 843.9s:
// 「攻击。」开播 0.9s 被 enemy_die 掐成残句);余波 1.6s 后恢复默认顶句语义
const b = this._battle;
const justCmd = !!(b && b.cmdVoiceAt > 0
&& performance.now() - b.cmdVoiceAt < 1600 && this._voicePlaying());
const opts = {
onComplete: () => {
if (s !== this._session) return;
this._speak(rest, onDone);
},
});
};
if (justGuide || justCmd) opts.queue = true;
this.audio.play(head, opts);
}
/** 带看门狗的语音链:播完或超时先到先触发 onDone 且只一次——流程绝不卡死。 */