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:
+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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user