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:
+164
-35
@@ -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 且只一次——流程绝不卡死。 */
|
||||
|
||||
+287
-4
@@ -428,10 +428,11 @@ async function scenarioA() {
|
||||
assert(t2 - t1 >= 400, 'A8: turn_player 与指令名之间留间隙不叠放(≥400ms)');
|
||||
}
|
||||
|
||||
// T0:左右滑 2 次 → attack 步
|
||||
// T0:左右滑 2 次 → attack 步(round1-live P2-3:T1 改播 tut_battle_2,不再播 tut_b3)
|
||||
g(game, 'right'); g(game, 'left');
|
||||
await tick(1500);
|
||||
assert(bt.teachStage === 'attack' && plays(audio, mark).has('voice/tut_b3'), 'A8: T0 切换 2 次 → T1 attack(tut_b3)');
|
||||
assert(bt.teachStage === 'attack' && plays(audio, mark).count('voice/tut_battle_2') >= 2,
|
||||
'A8: T0 切换 2 次 → T1 attack(重播 tut_battle_2,不再播 tut_b3)');
|
||||
// T1:选中攻击单击命中 → parry 步
|
||||
await tick(1200);
|
||||
const wolfHpBefore = bt.enemy.hp;
|
||||
@@ -448,8 +449,9 @@ async function scenarioA() {
|
||||
assert(game.player.hp < hpBeforeHit && game.player.hp >= 1, 'A8: T2 受击掉血且 ≥1');
|
||||
assert(bt.teachStage === 'defend', 'A8: T2 受击后 → T3 defend');
|
||||
// T3:选防御单击 → tut_b5 收尾;防御减伤 50% 演示(狼 base 2 → 1)
|
||||
await tick(2500); // tut_b4+tut_b6 播完进 PLAYER_SELECT
|
||||
assert(plays(audio, mark).orderOk('voice/tut_b4', 'voice/tut_b6'), 'A8: 格挡失败反馈 tut_b4 + 防御教学 tut_b6');
|
||||
await tick(4200); // tut_b4+tut_battle_4+tut_b6 三句播完进 PLAYER_SELECT(round1-live P1 链扩为 3 句)
|
||||
assert(plays(audio, mark).orderOk('voice/tut_b4', 'voice/tut_battle_4', 'voice/tut_b6'),
|
||||
'A8: 格挡失败反馈 tut_b4 + 减伤教学 tut_battle_4 + 防御教学 tut_b6(round1-live P1 明说减伤经济)');
|
||||
selectCmd(game, 'defend');
|
||||
await tick(1200);
|
||||
g(game, 'tap');
|
||||
@@ -469,6 +471,8 @@ async function scenarioA() {
|
||||
assert(p.orderOk('sfx/enemy_die', 'voice/btl_enemy_die', 'sfx/victory', 'voice/btl_win', 'voice/btl_loot_small'),
|
||||
'A8: 胜利序列 enemy_die → victory sfx → btl_win → loot 有序');
|
||||
assert(game.flags.tutorial_battle_done === true, 'A8: tutorial_battle_done 置位');
|
||||
assert(plays(audio, mark).count('voice/tut_b3') === 0,
|
||||
'A8: 教学全程不播 tut_b3(「挡住了!」与未格挡实况矛盾,round1-live P2-3 退出教学链)');
|
||||
assert(game.stats.battlesWon === 1, 'A8: battlesWon 0→1(难度档位依据)');
|
||||
{
|
||||
const stop = audio.log.filter((e) => e.id === '(stop)').at(-1);
|
||||
@@ -842,6 +846,278 @@ async function scenarioB6() {
|
||||
assert(!plays(audio, mark).has('voice/boss1_warn'), 'B6: Lv3 到寨门不播 boss1_warn');
|
||||
}
|
||||
|
||||
async function scenarioB7() {
|
||||
origConsoleLog('\n=== 场景 B7:Boss 战全格挡擦伤上限(round1-live P1) ===');
|
||||
localStorageMock.clear();
|
||||
const { game, audio } = newGame();
|
||||
game.start();
|
||||
await tick(2000);
|
||||
// 设计标准出装(11 文档 §3 数值表):Lv3 + 铁剑 + 皮甲,HP36
|
||||
game.player.lv = 3;
|
||||
game.player.hp = game.maxHp;
|
||||
game.player.weapon = 'iron_sword';
|
||||
game.player.armor = 'leather';
|
||||
game._startBattle('enc_boss1'); // 钩子:直接进 Boss 战
|
||||
await battlePump(game, audio);
|
||||
const mark = audio.log.length;
|
||||
const logsBefore = consoleLogs.length;
|
||||
await fightSmart(game, audio, { maxRounds: 30 }); // 全格挡打法(每次前摇内双击,不喝药)
|
||||
assert(game.flags.boss1_done === true, 'B7: 全格挡可击杀 Boss(章节 flags 置位)');
|
||||
const dmgs = consoleLogs.slice(logsBefore)
|
||||
.map((l) => l.match(/敌方攻击 dmg=(\d+) 格挡=(true|false)/))
|
||||
.filter(Boolean);
|
||||
assert(dmgs.length >= 3, `B7: Boss 战发生多次敌方攻击(${dmgs.length} 次)`);
|
||||
assert(dmgs.every((m) => m[2] === 'true'), 'B7: 全部敌方攻击均被格挡(fightSmart 全双击)');
|
||||
assert(dmgs.every((m) => Number(m[1]) >= 1 && Number(m[1]) <= 3),
|
||||
`B7: 格挡擦伤全部 ∈[1,3](11 文档 §3「格挡成功 2-3 伤」+ 钳顶,实测 ${dmgs.map((m) => m[1]).join(',')})`);
|
||||
assert(!plays(audio, mark).has('voice/st_hp_low'),
|
||||
'B7: 全格挡 Boss 战不播 st_hp_low(血量播报与实际战况匹配)');
|
||||
assert(game.player.hp / game.maxHp >= 0.3,
|
||||
`B7: 全格挡战后血量不掉 low 档(${game.player.hp}/${game.maxHp})`);
|
||||
// 确定性探针:base 12 的一刀被格挡 → 擦伤恒为 3(原实现 round(12×0.3)=4 超设计上限)
|
||||
await battlePump(game, audio); // 章结算链走完回村
|
||||
game._dmg = () => 12; // 钩子:伤害确定化
|
||||
game._startBattle('enc_boss1');
|
||||
await battlePump(game, audio, 5000);
|
||||
g(game, 'tap'); // 攻击(敌 45→33)
|
||||
g(game, 'doubletap'); // 前摇内双击格挡
|
||||
await tick(500);
|
||||
assert(lastEnemyDmg() === 3, `B7: 擦伤钳顶确定性探针(base 12 格挡 → ${lastEnemyDmg()},期望 3)`);
|
||||
}
|
||||
|
||||
async function scenarioB8() {
|
||||
origConsoleLog('\n=== 场景 B8:闲置提示与结算语音不互掐 + 返回项末位提示(round1-live P2/P3,guide=full) ===');
|
||||
localStorageMock.clear();
|
||||
settings.set({ guide: 'full' });
|
||||
try {
|
||||
const { game, audio } = newGame();
|
||||
const guideEvents = [];
|
||||
game.guide.setEventHook((e) => guideEvents.push(e));
|
||||
await setupWolfBattle(game);
|
||||
await battlePump(game, audio, 5000);
|
||||
const b = game._battle;
|
||||
assert(b && b.state === 'PLAYER_SELECT', 'B8: 野狼战进玩家回合');
|
||||
// 对照组:无闲置提示在播时击杀 → btl_enemy_die 不排队(默认顶句语义不变)
|
||||
b.enemy.hp = 1; // 钩子:一刀死
|
||||
g(game, 'tap');
|
||||
await tick(500);
|
||||
const diePlain = audio.log.filter((e) => e.id === 'voice/btl_enemy_die').at(-1);
|
||||
assert(diePlain && !diePlain.opts.queue, 'B8: 无闲置提示在播时击杀语音不排队(对照)');
|
||||
await battlePump(game, audio); // 胜利链走完回探索
|
||||
assert(game.phase === 'explore', 'B8: 首狼击杀回探索态');
|
||||
// 第二只狼:PLAYER_SELECT 闲置 30s → gd_stall 开播 → 立即出手
|
||||
game._wolfDone = false; // 钩子:允许再次遇狼
|
||||
await reenterStream(game);
|
||||
await battlePump(game, audio, 5000);
|
||||
const b2 = game._battle;
|
||||
assert(b2 && b2.state === 'PLAYER_SELECT', 'B8: 第二只狼进玩家回合');
|
||||
guideEvents.length = 0;
|
||||
let waited = 0;
|
||||
while (!guideEvents.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_stall' && !e.skipped)
|
||||
&& waited < 45000) { await tick(500); waited += 500; }
|
||||
assert(waited < 45000, 'B8: 闲置 30s 看门狗播 gd_stall(未跳过)');
|
||||
assert(audio._voice && audio._voice.id === 'voice/gd_stall', 'B8: gd_stall 正在播');
|
||||
b2.enemy.hp = 1; // 钩子:一刀死
|
||||
g(game, 'tap'); // 结算链首句应排到 gd_stall 之后播完,不拦腰顶断
|
||||
await tick(300);
|
||||
const dieQueued = audio.log.filter((e) => e.id === 'voice/btl_enemy_die').at(-1);
|
||||
assert(dieQueued && dieQueued.opts.queue === true,
|
||||
'B8: gd_stall 开播中结算语音排队等播完(queue:true,不顶断)');
|
||||
await battlePump(game, audio); // 胜利回探索
|
||||
// P3:探索态右滑落到"返回"项 → 报名后接末位提示(每场景一次)
|
||||
g(game, 'right'); await tick(1200); // go_hunter
|
||||
g(game, 'tap'); await tick(3000); // 猎人小屋
|
||||
const m3 = audio.log.length;
|
||||
g(game, 'right'); await tick(1200); // go_deepwood
|
||||
g(game, 'right'); await tick(1200); // back(末项)
|
||||
assert(plays(audio, m3).orderOk('voice/opt_back', 'voice/gd_wrap_last'),
|
||||
'B8: 落到"返回"项 → 报名 opt_back 后接末位提示 gd_wrap_last(每场景一次)');
|
||||
g(game, 'left'); await tick(1200);
|
||||
g(game, 'right'); await tick(1200); // 再次落到 back
|
||||
assert(plays(audio, m3).count('voice/gd_wrap_last') === 1,
|
||||
'B8: 同一场景内末位提示不重复(_wrapShown 去重)');
|
||||
} finally {
|
||||
settings.set({ guide: 'off' }); // 复位:其余场景契约是 guide=off
|
||||
}
|
||||
}
|
||||
|
||||
async function scenarioB9() {
|
||||
origConsoleLog('\n=== 场景 B9:指令名去重防口吃(round1-live P2「攻攻击。」) ===');
|
||||
localStorageMock.clear();
|
||||
const { game, audio } = newGame();
|
||||
await setupWolfBattle(game);
|
||||
await battlePump(game, audio, 5000);
|
||||
const b = game._battle;
|
||||
assert(b && b.state === 'PLAYER_SELECT', 'B9: 野狼战进玩家回合');
|
||||
// 折返滑 + 滞留 650ms 定时器:指令名不双播
|
||||
game._playerSelect(); // 钩子:回合重进(650ms 指令名定时器挂载中)
|
||||
const m1 = audio.log.length;
|
||||
g(game, 'right'); g(game, 'left'); // 攻击→破空斩→攻击(<650ms 折返)
|
||||
await tick(900);
|
||||
assert(plays(audio, m1).count('voice/cmd_attack') === 1,
|
||||
'B9: 折返滑回攻击+滞留定时器 → cmd_attack 不双播(1.2s 去重跳过本次)');
|
||||
// 相邻两回合连进:旧回合定时器作废
|
||||
await tick(1500); // 让过去重窗口,隔离下一段
|
||||
game._playerSelect();
|
||||
const m2 = audio.log.length;
|
||||
game._playerSelect(); // 钩子:相邻两回合连进(旧回合 650ms 定时器滞留)
|
||||
await tick(900);
|
||||
assert(plays(audio, m2).count('voice/cmd_attack') === 1,
|
||||
'B9: 连进两回合 → 旧回合指令名定时器作废(cmd_attack 只播一次)');
|
||||
}
|
||||
|
||||
async function scenarioB10() {
|
||||
origConsoleLog('\n=== 场景 B10:受伤播报只挂实质受击 + 档位一致 + 同档节流(round2-live P1) ===');
|
||||
localStorageMock.clear();
|
||||
const { game, audio } = newGame();
|
||||
await setupWolfBattle(game);
|
||||
game.stats.battlesWon = 5; // 钩子:标准档(前摇 1.0s、×1.0)
|
||||
game._battle.tier = { telSec: 1.0, dmgMul: 1.0 };
|
||||
game._dmg = (a, d) => Math.max(1, a - d); // 伤害确定化:狼 atk4 → 实击 4、擦伤 1
|
||||
await battlePump(game, audio, 5000);
|
||||
const b = game._battle;
|
||||
assert(b && b.state === 'PLAYER_SELECT', 'B10: 野狼战进玩家回合');
|
||||
game.player.hp = 15; // 钩子:15/20=0.75 高档,一刀擦伤即跨 0.7 边界
|
||||
b.enemy.hp = 99; // 钩子:全程打不死,专测受击播报
|
||||
assert(b.hpBandVoice === 'high', 'B10: 开战"最后被告知档"=当前实际档(high)');
|
||||
const mark = audio.log.length;
|
||||
const attackRound = async (parry) => {
|
||||
selectCmd(game, 'attack');
|
||||
await tick(1200);
|
||||
g(game, 'tap');
|
||||
if (parry) g(game, 'doubletap'); // 前摇内即时双击格挡
|
||||
await tick(2200); // 前摇 1.0s 结算 + 可能的降档句(1s)+ 回合交接
|
||||
};
|
||||
const itemRound = async () => {
|
||||
selectCmd(game, 'item');
|
||||
await tick(1200);
|
||||
g(game, 'tap');
|
||||
await tick(2200); // 喝药 + 敌方一个前摇回合(硬吃)
|
||||
};
|
||||
// ① 格挡擦伤跨档(15→14,0.70 跌进 mid):静默走血,不播任何受伤播报
|
||||
await attackRound(true);
|
||||
assert(game.player.hp === 14, `B10: 格挡擦伤 15→14(实际 ${game.player.hp})`);
|
||||
assert(plays(audio, mark).count('voice/st_hp_mid') === 0 && plays(audio, mark).count('voice/st_hp_low') === 0,
|
||||
'B10: 擦伤跨档不触发"受伤"播报(addendum §1:st_hp_* 是受击附属,非血量全局钩子)');
|
||||
assert(b.hpBandVoice === 'high', 'B10: 擦伤静默走血不改写"最后被告知档"');
|
||||
// ② 实质受击降档(14→10,mid):播当前实际档 st_hp_mid(不多不少)
|
||||
await attackRound(false);
|
||||
assert(game.player.hp === 10 && plays(audio, mark).count('voice/st_hp_mid') === 1,
|
||||
'B10: 实质受击降档 → 播 st_hp_mid(与实际档位一致)');
|
||||
assert(plays(audio, mark).count('voice/st_hp_low') === 0 && b.hpBandVoice === 'mid',
|
||||
'B10: 播报档不高估(mid 不喊 low),"最后被告知档"同步为 mid');
|
||||
// ③ 同档再受击(10→6,0.30 仍 mid):不重复喊
|
||||
await attackRound(false);
|
||||
assert(game.player.hp === 6 && plays(audio, mark).count('voice/st_hp_mid') === 1,
|
||||
'B10: 同档再受击不重复播(节流:mid 仍 1 次)');
|
||||
// ④ 再受击跌 low(6→2,0.10):播 st_hp_low
|
||||
await attackRound(false);
|
||||
assert(game.player.hp === 2 && plays(audio, mark).count('voice/st_hp_low') === 1 && b.hpBandVoice === 'low',
|
||||
'B10: 实质受击跌 low → 播 st_hp_low(命悬一线与实际档位一致)');
|
||||
// ⑤ 同档再受击(钩子回血到 5 → 1,仍 low):不重复喊
|
||||
game.player.hp = 5;
|
||||
await attackRound(false);
|
||||
assert(game.player.hp === 1 && plays(audio, mark).count('voice/st_hp_low') === 1,
|
||||
'B10: low 档内再受击不重复播(节流:low 仍 1 次)');
|
||||
// ⑥ 喝药回血(1→9)后硬吃一刀(9→5,回 low):与"最后被告知档"同档 → 不重播
|
||||
game.player.potions = 3; // 钩子:药管够
|
||||
await itemRound();
|
||||
assert(game.player.hp === 5 && plays(audio, mark).count('voice/st_hp_low') === 1,
|
||||
'B10: 回血后一刀直接跌回 low(无中间档):同档不重播(low 仍 1 次)');
|
||||
// ⑦ 再喝药(5→13)硬吃一刀(13→9,mid):升档静默同步;再受击(9→5)跌 low = 新降档 → 重播一次
|
||||
await itemRound();
|
||||
assert(game.player.hp === 9 && plays(audio, mark).count('voice/st_hp_mid') === 1 && b.hpBandVoice === 'mid',
|
||||
'B10: 回血升档静默(mid 不重播),"最后被告知档"经实击同步回 mid');
|
||||
await attackRound(false);
|
||||
assert(game.player.hp === 5 && plays(audio, mark).count('voice/st_hp_low') === 2 && b.hpBandVoice === 'low',
|
||||
'B10: 经中间档再次跌破 low = 新降档,重播一次 st_hp_low(不属"狼来了")');
|
||||
}
|
||||
|
||||
async function scenarioB11() {
|
||||
origConsoleLog('\n=== 场景 B11:探索菜单语音播放中单击=跳句+补发确认(round2-live P1) ===');
|
||||
localStorageMock.clear();
|
||||
const { game, audio } = newGame();
|
||||
game.start();
|
||||
await tick(60000); // 序章+进村,选项报名已播完
|
||||
// ① 选项报名在播中单击 → 跳句 + 执行当前选项(live 1264.6s 证据场景)
|
||||
g(game, 'right'); // 切到 shop:opt_shop 开播(mock 1s/句)
|
||||
assert(audio._voice && audio._voice.id === 'voice/opt_shop', 'B11: 选项报名在播(opt_shop)');
|
||||
const skips0 = audio.skipCount;
|
||||
g(game, 'tap');
|
||||
assert(audio.skipCount === skips0 + 1, 'B11: 报名在播中单击触发跳句(skipVoice)');
|
||||
assert(game.phase === 'shop', 'B11: 跳句同时补发确认语义:进商店(动作不石沉大海)');
|
||||
// ② desc 链在播中单击 → 只跳句不确认(12 文档 §1 跳句惯例不回退)
|
||||
g(game, 'down'); await tick(2000); // 离店回探索(选项报名播完)
|
||||
g(game, 'left'); await tick(1200); // 切回 go_path
|
||||
g(game, 'tap'); // 确认 go_path → desc_path 开播
|
||||
assert(game.scene === 'path' && audio._voice, 'B11: 确认 go_path 进 path(desc 在播)');
|
||||
const skips1 = audio.skipCount;
|
||||
g(game, 'tap');
|
||||
assert(audio.skipCount === skips1 + 1 && game.scene === 'path',
|
||||
'B11: desc 在播中单击=只跳句不确认(场景不变,A5 契约不回退)');
|
||||
await tick(3000); // 链走完 → 选项报名
|
||||
// ③ 边界提示在播中单击"返回" → 跳句+执行返回(live 1370.1s 证据场景,guide=full)
|
||||
settings.set({ guide: 'full' });
|
||||
try {
|
||||
g(game, 'left'); await tick(1200); // path 首项左滑反绕到末项 back → opt_back 播完接 gd_wrap_last
|
||||
assert(audio._voice && audio._voice.id === 'voice/gd_wrap_last', 'B11: 边界提示 gd_wrap_last 在播');
|
||||
const skips2 = audio.skipCount;
|
||||
g(game, 'tap');
|
||||
assert(audio.skipCount === skips2 + 1 && game.scene === 'village',
|
||||
'B11: 边界提示在播中单击"返回"=跳句+执行返回(回 village,动作不丢)');
|
||||
} finally {
|
||||
settings.set({ guide: 'off' }); // 复位:其余场景契约是 guide=off
|
||||
}
|
||||
}
|
||||
|
||||
async function scenarioB12() {
|
||||
origConsoleLog('\n=== 场景 B12:教学自动轮播"播完再上"不互掐(round2-live P2) ===');
|
||||
localStorageMock.clear();
|
||||
const { game, audio } = newGame();
|
||||
await walkToTeachBattle(game);
|
||||
const b = game._battle;
|
||||
// 等教学链播完进 PLAYER_SELECT(T0 menu,看门狗挂载点)
|
||||
let waited = 0;
|
||||
while (!(b.state === 'PLAYER_SELECT' && b.teachStage === 'menu') && waited < 30000) { await tick(500); waited += 500; }
|
||||
assert(b.state === 'PLAYER_SELECT' && b.teachStage === 'menu', 'B12: 教学 T0 进玩家回合(看门狗已挂载)');
|
||||
await tick(2500); // 让 650ms 指令名定时器播完(隔离声道,避免顶掉占用钩子)
|
||||
// 声道占用钩子:blocked 的 voice 永不触发 onComplete(模拟"上一句仍在播")
|
||||
audio.blocked.add('voice/tut_b5');
|
||||
audio.play('voice/tut_b5');
|
||||
await tick(9000); // 越过 8s 重复提示触发点
|
||||
assert(plays(audio).count('voice/tut_b0') === 1,
|
||||
'B12: 语音在播时 8s 重复提示跳过(自动轮播不顶句,tut_b0 仍 1 次)');
|
||||
await tick(8000); // 越过 16s 兜底推进点
|
||||
assert(b.teachStage === 'menu' && plays(audio).count('voice/tut_battle_2') === 1,
|
||||
'B12: 语音在播时 16s 兜底延后推进(不拦腰斩句,live 333.09s 互掐场景)');
|
||||
const tFree = vnow;
|
||||
audio.stopVoice(); // 模拟在播句播完(声道空出)
|
||||
await tick(2500); // 1.5s 重试间隔内应推进
|
||||
assert(b.teachStage === 'attack', 'B12: 声道空出后兜底推进 menu→attack(延后不死锁)');
|
||||
const tAdv = audio.log.filter((e) => e.id === 'voice/tut_battle_2').at(-1).t;
|
||||
assert(tAdv >= tFree, `B12: 推进语音等声道空出才开播("播完再上",${tAdv} ≥ ${tFree})`);
|
||||
}
|
||||
|
||||
async function scenarioB13() {
|
||||
origConsoleLog('\n=== 场景 B13:杀敌结算语音不腰斩刚开播的指令名(round2-live P2) ===');
|
||||
localStorageMock.clear();
|
||||
const { game, audio } = newGame();
|
||||
await setupWolfBattle(game);
|
||||
await battlePump(game, audio, 5000);
|
||||
const b = game._battle;
|
||||
assert(b && b.state === 'PLAYER_SELECT', 'B13: 野狼战进玩家回合');
|
||||
game._playCmdVoice(); // 钩子:指令名刚开播(复现 live 843.9s:「攻击。」播到一半出手)
|
||||
assert(audio._voice && audio._voice.id === 'voice/cmd_attack', 'B13: 指令名在播');
|
||||
b.enemy.hp = 1; // 钩子:一刀死
|
||||
g(game, 'tap');
|
||||
await tick(300);
|
||||
const die = audio.log.filter((e) => e.id === 'voice/btl_enemy_die').at(-1);
|
||||
assert(die && die.opts.queue === true,
|
||||
'B13: 指令名开播 1.6s 内击杀语音 queue 排队等播完(不拦腰斩句)');
|
||||
await battlePump(game, audio);
|
||||
assert(game.phase === 'explore', 'B13: 排队后胜利链正常走完回探索(不死锁)');
|
||||
}
|
||||
|
||||
// ================================ 资产引用校验 ================================
|
||||
|
||||
function assetAudit() {
|
||||
@@ -896,6 +1172,13 @@ try {
|
||||
await scenarioB4();
|
||||
await scenarioB5();
|
||||
await scenarioB6();
|
||||
await scenarioB7();
|
||||
await scenarioB8();
|
||||
await scenarioB9();
|
||||
await scenarioB10();
|
||||
await scenarioB11();
|
||||
await scenarioB12();
|
||||
await scenarioB13();
|
||||
} catch (e) {
|
||||
failures.push(`未捕获异常: ${e.stack}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user