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:
@@ -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