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:
xsl
2026-09-14 20:16:58 +08:00
parent 4b39ae2e49
commit ddada6980c
50 changed files with 9469 additions and 2055 deletions
+61 -6
View File
@@ -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 --