fix(space): 盲航刺耳音效重制 + 状态/反馈盲区修复(玩家代理取证迭代)
- F1 程序合成重制5件刺耳音效(alarm≥3kHz能量82%→0,全件≤-3.4dBFS)+新增挥空音 - F2 P0:撞击方位语音防顶断补播(保护期结束死亡窗 hit_* 零毫秒被杀根因) - F3 保护期教学句7连互截去重;F4 新句 protect_soon 50s预告;F5 boost_not_ready 冷却拒绝回音;F6 激活补CONFIRM震;F7 whiff - 验证:playtest 172/172 + guide 122 + 终检全绿 + 14游戏回归零回退 + live两轮复跑坐实 - 档案 research/space-polish/round1-2(取证+修复证据);04号文档追加R5修订记录
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+128
-25
@@ -68,6 +68,24 @@
|
||||
* 第二个 play 漏杀前一个 → 双管线并播 + ScriptProcessor 节点永久泄漏(live 探针
|
||||
* 实录 dir_up/hit_down 交替抽读百余次)。本层 voice 播放全部经 _voicePlay 串行门
|
||||
* (等上次令牌落位再发下一次)+ 对局语音全量 prewarm 缓存热,引擎零改动。
|
||||
*
|
||||
* R5(space polish round1/round2,research/space-polish/):
|
||||
* - **刺耳重合成**:alarm/dodge/shield_block/boost/shield 五件程序合成重制
|
||||
* (tools/gen_space_syn.py,真峰值≤-3dBFS、≥3kHz 能量近零),新增 sfx/whiff。
|
||||
* - **归因真话·接力补 replay**(live A t+151-163:保护期结束 8 秒三连撞,
|
||||
* hit_left/hit_down 被 spawn 呼喊零毫秒顶断,玩家至死不知方位):呼喊维持
|
||||
* 生存优先裁定不变,但 hit_<dir>/protect_end 起播时登记 _defendedId,
|
||||
* 呼喊播完的 onComplete 发现登记未清除(=被顶断)→ queue 接 replay,
|
||||
* 保证每句归因/保护期结束宣告完整送达(含 hit_last 排队其后)。
|
||||
* - **保护期 protect_* 去重**:6.96s 教学句对 4.0s 刷怪间隔互相截到 4.0s
|
||||
* (live A 保护期 7 连 TRUNCATED),改为上一句在播即跳过(shield_block 音 +
|
||||
* WARNING 震不变),每句教学完整播完。
|
||||
* - **加速冷却拒绝**(P1/P2/P4:冷却中单击全静默):_deny 共用去抖
|
||||
* (heartbeat + voice/boost_not_ready 新句),护盾拒绝同路径(原句不变)。
|
||||
* - **protect_soon**:50s 预告"训练护盾还剩十秒"(queue 不顶句),保护期结束
|
||||
* 前给反应慢的玩家提前量;菜单/分批引入打断由 _rearmRunTimers 补播。
|
||||
* - **激活确认震动**:boost/shield 激活补 CONFIRM 震(04 §5 承诺,此前漏装);
|
||||
* 挥空/滑错方向补 sfx/whiff 轻叹(0.55 音量,引擎 swipe 震动已有)。
|
||||
*/
|
||||
import { Game } from '../engine/game.js';
|
||||
import { Guide } from '../engine/guide.js';
|
||||
@@ -88,7 +106,7 @@ const DODGE_GRACE_MS = 250; // 判定宽限:spawn 前/撞击点后各让 25
|
||||
const ALARM_LEAD_MS = 1000; // 撞击前多久开始警报(两档:1s / 0.5s)
|
||||
const SHIELD_DURATION_MS = 6000;
|
||||
const SHIELD_COOLDOWN_MS = 15000; // round4 R1:30s→15s(冷却过长 + 失败零反馈是 P1)
|
||||
const SHIELD_DENY_DEBOUNCE_MS = 4000; // 护盾不可用应答(语音+震动)去抖
|
||||
const DENY_DEBOUNCE_MS = 4000; // R5:护盾/加速不可用应答(语音+震动)共用去抖
|
||||
const BOOST_DURATION_MS = 1800;
|
||||
const BOOST_COOLDOWN_MS = 3000;
|
||||
const RAMP_MS = 120000; // 难度爬升到满所需时长(保护期后起算)
|
||||
@@ -98,6 +116,7 @@ const PROTECT_SPAWN_MS = 4000;
|
||||
const BARE_GRACE_METEORS = 3; // 保护期结束后前 N 颗裸飞陨石保持保护期节奏(round4 R1 不断崖)
|
||||
const MENU_CALM_MS = 800; // 下滑开菜单冷静期:最近判定后 N ms 内下滑=躲避意图(round2 R2 误暂停修复)
|
||||
const DRILL_APPROACH_MS = 4500; // 演练陨石逼近(慢速、无伤害)
|
||||
const PROTECT_SOON_MS = 50000; // R5:保护期还剩 10s 预告(protect_soon)
|
||||
const BOOST_INTRO_MS = 20000; // 单击引入时刻
|
||||
const SHIELD_INTRO_MS = 45000; // 双击引入时刻
|
||||
const FEATURE_WAIT_MS = 8000; // 分批引入等待玩家操作上限
|
||||
@@ -157,6 +176,7 @@ export class SpaceGame extends Game {
|
||||
this.runStart = 0;
|
||||
this.protectionUntil = 0;
|
||||
this._protectAnnounced = false;
|
||||
this._protectSoonAnnounced = false; // R5:50s 预告已播标记
|
||||
this.swipes = [];
|
||||
this.obstacles = new Set();
|
||||
this.drillOb = null;
|
||||
@@ -169,9 +189,11 @@ export class SpaceGame extends Game {
|
||||
this.featureKind = null;
|
||||
this._menuIdx = 0;
|
||||
this._bareCount = 0; // 保护期结束后已刷裸飞陨石数(断崖过渡用)
|
||||
this._shieldDenyAt = -Infinity; // 护盾不可用应答去抖时间戳
|
||||
this._denyAt = -Infinity; // 护盾/加速不可用应答共用去抖时间戳(R5)
|
||||
this._hitLastPending = false; // hits===2 时 hit_last 待接播标记(呼喊接力用)
|
||||
this._hitLastPlaying = false; // hit_last 在播标记(警告链呼喊抑制用,round2 R2)
|
||||
this._defendedId = null; // R5:归因/保护结束句防顶断 replay 登记
|
||||
this._protectSaying = false; // R5:protect_* 教学句在播标记(去重用)
|
||||
this._lastResolveAt = -Infinity; // 最近一次陨石判定时刻(下滑开菜单冷静期,round2 R2)
|
||||
// F6:警报去重 / danger 去抖时间戳(soft/loud 分档 + danger 全局)
|
||||
this._alarmSoftAt = -Infinity;
|
||||
@@ -185,14 +207,15 @@ export class SpaceGame extends Game {
|
||||
this.audio.prewarm([
|
||||
'sfx/whoosh_left', 'sfx/whoosh_right', 'sfx/whoosh_up', 'sfx/whoosh_down',
|
||||
'sfx/alarm', 'sfx/boost', 'sfx/shield', 'sfx/shield_block',
|
||||
'sfx/dodge', 'sfx/hit', 'sfx/crash',
|
||||
'sfx/dodge', 'sfx/hit', 'sfx/crash', 'sfx/whiff',
|
||||
// 对局期可能并发的语音全量预热(voice 播放安全门配套:缓存热 = 令牌落位
|
||||
// 只需数个 microtask,门内零等待):方向呼喊/归因/保护/护盾/引入/里程碑/向导
|
||||
'voice/dir_left', 'voice/dir_right', 'voice/dir_up', 'voice/dir_down',
|
||||
'voice/hit_left', 'voice/hit_right', 'voice/hit_up', 'voice/hit_down', 'voice/hit_last',
|
||||
'voice/protect_left', 'voice/protect_right', 'voice/protect_up', 'voice/protect_down',
|
||||
'voice/protect_end', 'voice/shield_ready', 'voice/shield_block',
|
||||
'voice/protect_end', 'voice/protect_soon', 'voice/shield_ready', 'voice/shield_block',
|
||||
'voice/feat_boost', 'voice/feat_boost_done', 'voice/feat_shield', 'voice/feat_shield_done',
|
||||
'voice/boost_not_ready',
|
||||
'voice/ms_10', 'voice/ms_25', 'voice/ms_50', 'voice/ms_100',
|
||||
'voice/gd_hit_hint', 'voice/gd_last_life',
|
||||
'voice/paused', 'voice/menu_main', 'voice/menu_resume', 'voice/menu_lobby',
|
||||
@@ -365,6 +388,7 @@ export class SpaceGame extends Game {
|
||||
// 单击/双击引入教学仅首局(标记在实例上,重开不再调度,第 2 局起机制开局即用)
|
||||
if (!this.boostIntroDone) this._after(BOOST_INTRO_MS, () => this._beginFeature('boost'));
|
||||
if (!this.shieldIntroDone) this._after(SHIELD_INTRO_MS, () => this._beginFeature('shield'));
|
||||
this._after(PROTECT_SOON_MS, () => this._announceProtectionSoon()); // R5:50s 预告
|
||||
this._after(PROTECTION_MS, () => this._announceProtectionEnd());
|
||||
}
|
||||
|
||||
@@ -378,6 +402,7 @@ export class SpaceGame extends Game {
|
||||
case 'up': case 'left': case 'right':
|
||||
this.swipes.push({ dir: g, t: now });
|
||||
this._trimSwipes(now);
|
||||
this._whiffIfUnmatched(g);
|
||||
break;
|
||||
case 'down':
|
||||
// 下滑冲突折中:有下方陨石悬置时是闪避,否则呼出菜单。
|
||||
@@ -388,6 +413,7 @@ export class SpaceGame extends Game {
|
||||
if ([...this.obstacles].some((ob) => ob.dir === 'down')) {
|
||||
this.swipes.push({ dir: g, t: now });
|
||||
this._trimSwipes(now);
|
||||
this._whiffIfUnmatched(g);
|
||||
} else if (now - this._lastResolveAt > MENU_CALM_MS) {
|
||||
this._openMenu();
|
||||
}
|
||||
@@ -401,6 +427,10 @@ export class SpaceGame extends Game {
|
||||
this.boostUntil = now + BOOST_DURATION_MS;
|
||||
this.boostReadyAt = now + BOOST_COOLDOWN_MS;
|
||||
this.audio.play('sfx/boost');
|
||||
vibrate('confirm'); // R5:激活确认(04 §5 承诺,此前漏装)
|
||||
} else if (this.boostIntroDone) {
|
||||
// R5:冷却中单击必须有回音(P1/P2/P4:冷却零反馈是动作盲区)
|
||||
this._deny(now, 'voice/boost_not_ready');
|
||||
}
|
||||
break;
|
||||
case 'doubletap':
|
||||
@@ -409,25 +439,38 @@ export class SpaceGame extends Game {
|
||||
this.shieldUpUntil = now + SHIELD_DURATION_MS;
|
||||
this.shieldReadyAt = now + SHIELD_COOLDOWN_MS;
|
||||
this.audio.play('sfx/shield');
|
||||
vibrate('confirm'); // R5:激活确认(04 §5 承诺,此前漏装)
|
||||
this._scheduleShieldReady();
|
||||
} else {
|
||||
this._shieldDeny(now); // 不可用必须有回音(round4 R1:panic 双击零反馈是 P1)
|
||||
this._deny(now, 'voice/feat_shield_done'); // 不可用必须有回音(round4 R1:panic 双击零反馈是 P1)
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 护盾不可用(未引入/充能中)的即时应答:现成句 feat_shield_done
|
||||
* ("会自动充能,好了我会告诉你"语义正合)+ HEARTBEAT 震,4s 去抖防连刷。
|
||||
* 注:voice_text.json 无"护盾还没好"专用句,复用此句属 round4 R1 裁定。
|
||||
* R5:无效手势轻反馈(归因真话)。滑向没有任何悬置陨石的方向 = 挥空——
|
||||
* 引擎 swipe 识别震动已有("收到了"),这里补一声轻叹("但这下没用"),
|
||||
* 与成功闪避的 dodge 音形成对偶,避免玩家怀疑输入失灵。音量压低不抢戏。
|
||||
*/
|
||||
_shieldDeny(now) {
|
||||
if (now - this._shieldDenyAt < SHIELD_DENY_DEBOUNCE_MS) return;
|
||||
this._shieldDenyAt = now;
|
||||
_whiffIfUnmatched(dir) {
|
||||
if (![...this.obstacles].some((ob) => ob.dir === dir)) {
|
||||
this.audio.play('sfx/whiff', { volume: 0.55 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 护盾/加速不可用(未引入/充能中)的即时应答:HEARTBEAT 震 + 语音提示,
|
||||
* 4s 共用去抖防连刷(R5 起护盾/加速同路径,仅语音 id 不同)。
|
||||
* 护盾句复用 feat_shield_done("会自动充能,好了我会告诉你"语义正合,round4 R1 裁定);
|
||||
* 加速句用新句 boost_not_ready("引擎还在冷却,稍等一下。",R5)。
|
||||
* 警告链在播时排队到链尾,不顶断"最后一命"(round2 R2)。
|
||||
*/
|
||||
_deny(now, voiceId) {
|
||||
if (now - this._denyAt < DENY_DEBOUNCE_MS) return;
|
||||
this._denyAt = now;
|
||||
vibrate('heartbeat');
|
||||
// 警告链在播时排队到链尾,不顶断"最后一命"(round2 R2)
|
||||
this.audio.play('voice/feat_shield_done', this._warnActive() ? { queue: true } : undefined);
|
||||
this.audio.play(voiceId, this._warnActive() ? { queue: true } : undefined);
|
||||
}
|
||||
|
||||
/** 护盾充能完毕播报 + PICKUP 震动(若仍在这一局)。语音排队播,不顶 protect_end/归因句。 */
|
||||
@@ -469,12 +512,14 @@ export class SpaceGame extends Game {
|
||||
this.boostUntil = now + BOOST_DURATION_MS;
|
||||
this.boostReadyAt = now + BOOST_COOLDOWN_MS;
|
||||
this.audio.play('sfx/boost');
|
||||
vibrate('confirm'); // R5:激活确认(04 §5)
|
||||
this._speak(['voice/feat_boost_done']);
|
||||
this._endFeature();
|
||||
} else {
|
||||
this.shieldUpUntil = now + SHIELD_DURATION_MS;
|
||||
this.shieldReadyAt = now + SHIELD_COOLDOWN_MS;
|
||||
this.audio.play('sfx/shield');
|
||||
vibrate('confirm'); // R5:激活确认(04 §5)
|
||||
this._speak(['voice/feat_shield_done']);
|
||||
this._endFeature(); // _endFeature → _rearmRunTimers 会按 shieldReadyAt 重排充能播报
|
||||
}
|
||||
@@ -506,6 +551,15 @@ export class SpaceGame extends Game {
|
||||
if (remain <= 0) this._announceProtectionEnd(); // 已过期,立即补播
|
||||
else this._after(remain, () => this._announceProtectionEnd());
|
||||
}
|
||||
// R5:50s 预告补播(菜单/分批引入打断后的恢复)
|
||||
if (!this._protectSoonAnnounced) {
|
||||
const soonRemain = this.runStart + PROTECT_SOON_MS - now;
|
||||
if (soonRemain <= 0) {
|
||||
if (this.protectionUntil > now) this._announceProtectionSoon();
|
||||
} else {
|
||||
this._after(soonRemain, () => this._announceProtectionSoon());
|
||||
}
|
||||
}
|
||||
if (this.shieldReadyAt > now) {
|
||||
this._scheduleShieldReady(this.shieldReadyAt - now);
|
||||
}
|
||||
@@ -515,12 +569,52 @@ export class SpaceGame extends Game {
|
||||
|
||||
// ---------------- 保护期 ----------------
|
||||
|
||||
/** R5:50s 预告(保护期还剩 10 秒)。queue 不顶在播教学句;被打断由 _rearmRunTimers 补播。 */
|
||||
_announceProtectionSoon() {
|
||||
if (this._protectSoonAnnounced) return;
|
||||
if (this.phase !== 'playing') return; // 菜单/分批引入中,恢复时补播
|
||||
this._protectSoonAnnounced = true;
|
||||
this.audio.play('voice/protect_soon', { queue: true });
|
||||
}
|
||||
|
||||
/** R5:保护结束宣告走防顶断登记(live A t+151.6:被 spawn 呼喊顶得只剩 0.5s)。 */
|
||||
_announceProtectionEnd() {
|
||||
if (this._protectAnnounced) return;
|
||||
if (this.phase !== 'playing') return; // 菜单/分批引入中,恢复时由 _rearmRunTimers 补播
|
||||
this._protectAnnounced = true;
|
||||
vibrate('warning');
|
||||
this.audio.play('voice/protect_end');
|
||||
this._defendVoice('voice/protect_end');
|
||||
}
|
||||
|
||||
/**
|
||||
* R5 防顶断登记:生存语义长句(hit_<dir>/protect_end)起播时登记 id;
|
||||
* 自然播完/被跳过 → onComplete 清除登记(= 完好送达);
|
||||
* 被 spawn 呼喊顶断 → 登记残留,呼喊播完的 onComplete 走 _replayDefended
|
||||
* queue 接 replay(呼喊生存优先裁定不变,句子保证完整送达一次)。
|
||||
*/
|
||||
_defendVoice(id, onDone) {
|
||||
this._defendedId = id;
|
||||
this.audio.play(id, {
|
||||
onComplete: () => {
|
||||
if (this._defendedId === id) this._defendedId = null;
|
||||
if (onDone) onDone();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** R5:呼喊播完时发现登记残留(= 顶断了生存句)→ queue 接 replay,不顶新威胁信息。
|
||||
* @returns {boolean} 是否发生了 replay(发生了则 hit_last 由 replay 播完接力,不必此刻 drain) */
|
||||
_replayDefended() {
|
||||
const id = this._defendedId;
|
||||
if (!id) return false;
|
||||
this._defendedId = null;
|
||||
if (id.startsWith('voice/hit_')) {
|
||||
// 归因 replay 播完仍要接"最后一命"(语义链:先因后果)
|
||||
this.audio.play(id, { queue: true, onComplete: () => this._drainHitLast() });
|
||||
} else {
|
||||
this.audio.play(id, { queue: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------- 陨石调度 ----------------
|
||||
@@ -562,7 +656,9 @@ export class SpaceGame extends Game {
|
||||
// 被下一颗的呼喊顶断(live B5:高速段警告刚起播就被顶没);归因在播期间呼喊
|
||||
// 保留生存优先级(顶断 → 接力,低延迟警告路径不变)。
|
||||
if (impactAt > this.protectionUntil && !this._hitLastPlaying) {
|
||||
this.audio.play(`voice/dir_${dir}`, { onComplete: () => this._drainHitLast() });
|
||||
// onComplete 双职责(R5):① 顶断了归因/保护结束句 → queue 接 replay(replay 播完
|
||||
// 自行接力 hit_last);② 呼喊接力(round4 R1):无 replay 时 hit_last 挂起直接接播。
|
||||
this.audio.play(`voice/dir_${dir}`, { onComplete: () => { if (!this._replayDefended()) this._drainHitLast(); } });
|
||||
}
|
||||
// 警报两档渐强,DANGER 震动同步叠加。
|
||||
// F6(round2):警报音效同 600ms 窗口去重(双陨石同时逼近只起一波警报,
|
||||
@@ -625,7 +721,13 @@ export class SpaceGame extends Game {
|
||||
if (this._inProtection()) {
|
||||
this.audio.play('sfx/shield_block');
|
||||
vibrate('warning');
|
||||
this.audio.play(`voice/protect_${ob.dir}`);
|
||||
// R5 去重:6.96s 教学句对 4.0s 刷怪间隔(live A 保护期 7 连 TRUNCATED,
|
||||
// 每句都截在同一个字上)——上一句在播即跳过,保证每句完整送达;
|
||||
// 被跳过的那次仍有 shield_block 音 + WARNING 震落位。
|
||||
if (!this._protectSaying) {
|
||||
this._protectSaying = true;
|
||||
this.audio.play(`voice/protect_${ob.dir}`, { onComplete: () => { this._protectSaying = false; } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 5) 实打实撞上:HIT 震动 + 归因语音(第 2 次加"只剩最后一次")
|
||||
@@ -642,15 +744,16 @@ export class SpaceGame extends Game {
|
||||
// 顶断路径:归因被呼喊顶掉 → 呼喊播完的 onComplete(_spawnObstacle 挂的)
|
||||
// → _drainHitLast 接播。重开/回大厅由 _resetRunState 清挂起。
|
||||
if (this.hits === 2) this._hitLastPending = true;
|
||||
this.audio.play(`voice/hit_${ob.dir}`, {
|
||||
onComplete: () => {
|
||||
if (this.hits === 1) {
|
||||
// 第 1 次受击补指令式 gd_hit_hint(hit_<dir> 已归因方位,这句补
|
||||
// "该怎么拨",onceKey 同局一次)
|
||||
this.guide.error({ voice: 'gd_hit_hint', onceKey: 'space_hit_hint', skipHaptic: true });
|
||||
}
|
||||
this._drainHitLast();
|
||||
},
|
||||
// R5:归因句走防顶断登记(_defendVoice)——被 spawn 呼喊顶断时由呼喊
|
||||
// onComplete 走 _replayDefended 补 replay(live A t+155.13/159.13:
|
||||
// hit_left/hit_down 零毫秒被杀,玩家至死不知方位)。
|
||||
this._defendVoice(`voice/hit_${ob.dir}`, () => {
|
||||
if (this.hits === 1) {
|
||||
// 第 1 次受击补指令式 gd_hit_hint(hit_<dir> 已归因方位,这句补
|
||||
// "该怎么拨",onceKey 同局一次)
|
||||
this.guide.error({ voice: 'gd_hit_hint', onceKey: 'space_hit_hint', skipHaptic: true });
|
||||
}
|
||||
this._drainHitLast();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* + 边界断言)与 §7(全局通用断言),以及本轮改动(状态 BGM / skipVoice / skip_hint)、
|
||||
* §11 round4-live 修复(方向呼喊提前到 spawn / 闪避窗口=呼啸全程 / 护盾 15s+不可用应答 /
|
||||
* 保护期断崖过渡 / hit_last 呼喊接力 / feat 教学仅首局),以及 round2 R2 修复
|
||||
* (下滑开菜单冷静期防误暂停 / 暂停恢复缓冲 / 回大厅收尾流程 / 警告链呼喊抑制+坠毁掐断)。
|
||||
* (下滑开菜单冷静期防误暂停 / 暂停恢复缓冲 / 回大厅收尾流程 / 警告链呼喊抑制+坠毁掐断),
|
||||
* 以及 R5 space polish 修复(research/space-polish/:归因防顶断 replay / protect_* 去重 /
|
||||
* protect_soon 50s 预告 / 加速冷却拒绝 boost_not_ready / 激活 CONFIRM 震 / whiff 挥空音)。
|
||||
*
|
||||
* 用法:tools/node/bin/node web/space/test/playtest.mjs
|
||||
*
|
||||
@@ -179,6 +181,7 @@ function makeHarness() {
|
||||
const voices = (plays) => plays.filter((p) => p.id.startsWith('voice/')).map((p) => p.id);
|
||||
const lastVoice = (plays) => voices(plays).at(-1);
|
||||
const vibrated = (pat) => vibrations.some((v) => Array.isArray(v) && v.join() === pat);
|
||||
const confirmCount = () => vibrations.filter((v) => Array.isArray(v) && v.join() === '20').length;
|
||||
|
||||
try {
|
||||
// ======================================================================
|
||||
@@ -267,6 +270,21 @@ try {
|
||||
assert(!plays.some((p) => p.t >= t0 && p.id.startsWith('voice/dir_')),
|
||||
'撞击点落在保护期内的陨石 spawn 不播 dir_<dir>(§11:protect 长句教学优先)');
|
||||
|
||||
// -- R5:protect_* 去重——上一句教学在播时新吸收只发 shield_block 音 + WARNING 震,
|
||||
// 不再互截(live A 保护期 7 连 TRUNCATED 修复) --
|
||||
const protectVoiceCount = voices(plays).filter((id) => id.startsWith('voice/protect_')).length;
|
||||
const shieldBlockCount = plays.filter((p) => p.id === 'sfx/shield_block').length;
|
||||
game.obstacles.clear();
|
||||
const obP2 = { dir: 'right', impactAt: clock.now + 100 };
|
||||
game.obstacles.add(obP2);
|
||||
game._after(100, () => game._resolve(obP2));
|
||||
await advance(150);
|
||||
assert(game.hits === 0, 'R5 去重吸收仍不掉血(训练护盾)');
|
||||
assert(voices(plays).filter((id) => id.startsWith('voice/protect_')).length === protectVoiceCount,
|
||||
'protect_* 在播时新吸收跳过语音(R5 去重,每句教学完整播完)');
|
||||
assert(plays.filter((p) => p.id === 'sfx/shield_block').length === shieldBlockCount + 1,
|
||||
'去重吸收仍有 shield_block 音落位');
|
||||
|
||||
// -- §5.3 第 20s 单击引入(feat_boost 可跳) --
|
||||
await advance(t0 + 20000 - clock.now);
|
||||
assert(game.phase === 'feature' && game.featureKind === 'boost', '第 20s 暂停刷怪教单击');
|
||||
@@ -279,6 +297,7 @@ try {
|
||||
assert(game.boostUntil > clock.now, '旁白已跳后单击 → boost 生效(§5.3)');
|
||||
assert(game.phase === 'playing', '分批引入结束恢复 playing');
|
||||
assert(lastVoice(plays) === 'voice/feat_boost_done', 'feat_boost_done 确认句');
|
||||
assert(confirmCount() >= 1, '加速激活 CONFIRM 震(R5 兑现 04 §5)');
|
||||
|
||||
// 加速增益期间的撞击算冲过空隙
|
||||
game.obstacles.clear();
|
||||
@@ -293,6 +312,7 @@ try {
|
||||
assert(game.phase === 'feature' && game.featureKind === 'shield', '第 45s 教双击护盾');
|
||||
game.onGesture('doubletap'); await tick();
|
||||
assert(game.shieldUpUntil > clock.now, '双击 → 护盾生效(§5.3)');
|
||||
assert(confirmCount() >= 2, '护盾激活 CONFIRM 震(R5 兑现 04 §5)');
|
||||
assert(game.shieldReadyAt === clock.now + 15000, '护盾充能 15s 锁定(§11 改口,原 30s)');
|
||||
assert(game.phase === 'playing', '护盾引入结束恢复 playing');
|
||||
|
||||
@@ -325,12 +345,37 @@ try {
|
||||
// -- §5.4 保护解除播报 --
|
||||
await advance(t0 + 60050 - clock.now);
|
||||
assert(game._protectAnnounced && voices(plays).includes('voice/protect_end'), '60s 保护解除播报');
|
||||
assert(game._protectSoonAnnounced && voices(plays).includes('voice/protect_soon'),
|
||||
'50s 保护期预告 protect_soon(R5:给反应慢的玩家 10s 提前量)');
|
||||
|
||||
// -- 边界:护盾充能完毕播报,且只播一次(回归:曾双重调度播两次) --
|
||||
await advance(game.shieldReadyAt - clock.now + 50);
|
||||
assert(voices(plays).filter((id) => id === 'voice/shield_ready').length === 1,
|
||||
'shield_ready 充能完毕播报恰好一次');
|
||||
assert(vibrated('15'), '护盾就绪 PICKUP 震动');
|
||||
await advance(2000); // 静场起点
|
||||
{ // 排队的 protect_soon/protect_end/shield_ready 串行播完(虚拟时钟下语音门有落位延迟,
|
||||
// 轮询等待而非固定 advance——拒绝用例需要 voice 真正空闲)
|
||||
let guard = 0;
|
||||
while (audio.isVoicePlaying() && guard++ < 20) await advance(300);
|
||||
}
|
||||
assert(!audio.isVoicePlaying(), '静场:排队的状态句播完,拒绝用例需 voice 空闲');
|
||||
|
||||
// -- R5:加速冷却拒绝(归因真话:冷却单击必须有回音,此前全静默) --
|
||||
const notReadyCount = () => voices(plays).filter((id) => id === 'voice/boost_not_ready').length;
|
||||
const denyVoiceBefore = notReadyCount();
|
||||
const confirmBefore = confirmCount();
|
||||
game.onGesture('tap'); await tick(); // voice 空闲 → 加速直接生效
|
||||
assert(game.boostUntil > clock.now, 'R5 用例前置:加速激活');
|
||||
assert(confirmCount() === confirmBefore + 1, '对局期加速激活 CONFIRM 震(R5)');
|
||||
const boostUntilLocked = game.boostUntil;
|
||||
game.onGesture('tap'); await tick(); // 冷却中(3s)→ 拒绝
|
||||
assert(game.boostUntil === boostUntilLocked, '冷却中单击不重置加速');
|
||||
assert(notReadyCount() === denyVoiceBefore + 1, '加速冷却拒绝播报 boost_not_ready(R5)');
|
||||
assert(vibrated('40,100,40'), '加速冷却拒绝 HEARTBEAT 震(R5)');
|
||||
game.onGesture('tap'); await tick();
|
||||
assert(notReadyCount() === denyVoiceBefore + 1, '加速冷却拒绝 4s 去抖(连刷不重播)');
|
||||
await advance(3100); // 冷却结束,后续用例不受影响
|
||||
|
||||
// -- §5.4/§11 正常闪避 3 颗(同向滑;§11 起窗口=呼啸全程,此处仍在撞击前 800ms 出手) --
|
||||
// 同循环验证 §11 断崖过渡(裸飞第 1~3 颗逼近固定 4.0s)与方向呼喊时机(spawn 同窗)
|
||||
@@ -378,6 +423,7 @@ try {
|
||||
naturalSpawn();
|
||||
const obH1 = [...game.obstacles].at(-1);
|
||||
game.onGesture('right'); // 反向
|
||||
assert(plays.some((p) => p.id === 'sfx/whiff'), '滑错方向有挥空轻叹(R5 归因真话:无效手势有回音)');
|
||||
await advance(obH1.impactAt - clock.now + 50);
|
||||
assert(game.hits === 1, '反向滑不判闪避,撞击 hits=1');
|
||||
assert(lastVoice(plays) === 'voice/hit_left', 'hit_<dir> 归因方向与来袭一致');
|
||||
@@ -402,11 +448,20 @@ try {
|
||||
const obNext = [...game.obstacles].at(-1);
|
||||
await tick();
|
||||
assert(h.voiceStarts.at(-1).id === 'voice/dir_left', '下一颗 spawn 呼喊顶断归因(生存优先级)');
|
||||
await advance(1300); // 呼喊播完 → onComplete 接力 → hit_last 自动接播
|
||||
assert(game._defendedId === 'voice/hit_right', '归因被顶断后登记残留(R5 防顶断登记)');
|
||||
// R5:登记残留 → 呼喊播完的 onComplete 走 _replayDefended queue 补 replay(归因完整送达)。
|
||||
// 虚拟时钟下语音门有落位延迟,用轮询等 replay/接播事件而非固定 advance 步长。
|
||||
const hitRightStarts = () => h.voiceStarts.filter((v) => v.id === 'voice/hit_right').length;
|
||||
let guard = 0;
|
||||
while (hitRightStarts() < 2 && guard++ < 16) await advance(300);
|
||||
assert(hitRightStarts() === 2, '归因被呼喊顶断后补 replay(R5:玩家不再丢失方位归因)');
|
||||
assert(game._defendedId === null, 'replay 后登记清除');
|
||||
while (h.voiceStarts.at(-1).id !== 'voice/hit_last' && guard++ < 32) await advance(300);
|
||||
assert(h.voiceStarts.at(-1).id === 'voice/hit_last',
|
||||
'归因被呼喊顶断后 hit_last 仍接播(§11:最后一命不再被静默吞掉)');
|
||||
'归因 replay 播完接播 hit_last(§11:最后一命不再被静默吞掉,R5 先因后果)');
|
||||
assert(game._hitLastPending === false, 'hit_last 接播后挂起标记清除');
|
||||
assert(game._hitLastPlaying === true, 'hit_last 在播标记置位(round2 R2)');
|
||||
game.obstacles.delete(obNext); // R5:replay 多耗一个呼喊周期,提前摘除防其撞击点坠毁干扰抑制用例
|
||||
// round2 R2:hit_last 在播期间新刷陨石的 spawn 呼喊被抑制(警告播完前不被顶断)
|
||||
const shoutMark2 = h.voiceStarts.length;
|
||||
randQueue.push(0.5); // up
|
||||
@@ -415,14 +470,14 @@ try {
|
||||
await tick();
|
||||
assert(!h.voiceStarts.slice(shoutMark2).some((v) => v.id === 'voice/dir_up'),
|
||||
'hit_last 在播期间 spawn 不播方向呼喊(round2 R2:警告播完前不被顶断)');
|
||||
await advance(1300); // hit_last 播完 → 呼喊抑制解除
|
||||
game.obstacles.delete(obNext2); // 立即摘除(后续轮询等待会越过其撞击点)
|
||||
let guard2 = 0;
|
||||
while (game._hitLastPlaying && guard2++ < 16) await advance(300);
|
||||
assert(game._hitLastPlaying === false, 'hit_last 播完在播标记清除');
|
||||
randQueue.push(0.5); // up:链后再刷陨石,方向呼喊恢复
|
||||
naturalSpawn();
|
||||
await tick();
|
||||
assert(h.voiceStarts.at(-1).id === 'voice/dir_up', '警告链结束后方向呼喊恢复(round2 R2)');
|
||||
game.obstacles.delete(obNext);
|
||||
game.obstacles.delete(obNext2);
|
||||
game.obstacles.clear(); game.swipes = [];
|
||||
|
||||
// -- 边界:里程碑 dodges=10 播 ms_10 --
|
||||
|
||||
Reference in New Issue
Block a user