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
+128 -25
View File
@@ -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();
});
}
}