/**
* 盲航(space)R4 —— 太空盲飞躲避,纯音频(按 docs/design/04-space-rebuild.md 实现)。
*
* R4 核心变化:
* - **同向闪避**:听到哪边来就向哪边滑("挥手把陨石拨开"),方位声直达同侧手势,
* 消除旧版"定位→取反→出手"的推理链。whoosh_* 四资产复用,只改映射。
* - **D0-D5 交互式演练**:一句一练,演练陨石 4.5s 慢速无伤害;做错 ERROR 震动 +
* 当场重教(drill_retry_*),同一步失败 2 次 drill_moveon 自动带过不卡流程。
* 演练期(无演练陨石悬置时)下滑=跳过;二周目开场 second_run_ask 询问。
* 演练陨石呼啸响起时同窗播 dir_
,与实战"听声即滑"的配对一致(round4 R1)。
* - **前 60s 保护期**:逼近 4.0s / 刷怪 4.0s 恒定;撞击不掉血——训练护盾吸收
* (shield_block 音 + WARNING 震动 + protect_ 归因句)。60s 播 protect_end
* 后进入正常难度曲线:刷怪 3.2s→1.2s、逼近 2.8s→1.8s、120s 爬满。
* 过渡不断崖:保护结束后的前 3 颗裸飞陨石保持 4.0s 逼近 / 4.0s 刷怪,
* 第 4 颗起才接入难度曲线(round4 R1:protect_end 后第一颗疑似必中、10s 三连撞)。
* - **闪避窗口覆盖呼啸全程**(round4 R1:早滑被静默吞掉):同向滑在陨石呼啸响起
* (spawn)到撞击点 +250ms 宽限之间都有效,不再只开撞击前 1.5s;spawn 之前的
* "预言家"滑动不算(按 ob.spawnAt 下界)。
* - **单击/双击分批引入(仅首局)**:第 20s 暂停刷怪教单击加速(1.8s 持续增益),
* 第 45s 教双击护盾(6s,15s 充能,shield_ready 语音排队播);8s 内没做不等待
* 直接继续。introDone 标记挂在实例上(不随 _resetRunState 重置),第 2 局起
* 不再重播教学、机制开局即用(round4 R1:feat_* 每局重播)。护盾不可用
* (未引入/充能中)时双击有即时应答(feat_shield_done 现成句 + HEARTBEAT 震,
* 4s 去抖;round4 R1:panic 双击零反馈)。
* - **死亡即教学**:保护期后被撞播 hit_ 归因句,第 2 次追加 hit_last
* (呼喊接力——归因被 dir_ 呼喊顶断时 hit_last 挂起,由呼喊播完的
* onComplete 自动接播,"最后一命"不再被静默吞掉,round4 R1);3 次撞击坠毁。
* 结算 time_ 与 dodge_ 分档整句 + new_record + restart_hint。
* round2 R2(高速段警告播不出/叠播):**hit_last 在播期间方向呼喊抑制**
* (警告播完前不被下一颗呼喊顶断;归因在播期间呼喊保留生存优先级——
* 顶断 → 呼喊播完接力,警告低延迟路径不变);里程碑/护盾不可用应答在
* 警告链期间排队不顶链;被菜单/分批引入顶断的警告恢复对局即补播;
* **坠毁即掐断警告链**,结算链 1.2s 后干净开场不叠播(live B4)。
* - **里程碑**:躲过 10/25/50/100 颗各一句固定整句(ms_*),CONFIRM 震动。
* - **菜单**(round2-review 全球约定):下滑呼出(模态暂停)→ 单击循环选项 →
* 双击确认 → 上滑帮助。下滑与"下滑闪避"冲突的折中:**仅当无下方陨石悬置时**
* 下滑才呼出菜单(有下方陨石时下滑永远是闪避),见 README。
* round2 R2 误暂停修复(live ta=664.99/1670.73:高速段撞击瞬间的下滑躲避
* 被当成开菜单):**最近一次判定(撞/躲/格挡/吸收)后 800ms 冷静期内的下滑
* 一律按躲避意图忽略,绝不开菜单**(其余时刻保留旧折中,暂停可达性不变);「继续飞行」恢复缓冲——前 3 颗裸飞陨石回
* 4.0s 慢节奏(复用断崖过渡机制,不再 3 秒暴毙);「回大厅」改 rhythm 同款收尾:
* leaving 态停刷怪/BGM、播确认句后再跳转(旧版直接跳,跳转失败落无声死界面)。
* - **状态 BGM**(10 号文档 §6):菜单切 `bgm/menu`(crossfade 300ms)、回对局淡回
* `bgm/engine`(500ms)、坠毁结算语音链播完淡入 `bgm/menu`(800ms)。
* - **单击跳过旁白**(12 号文档):演练旁白/feat 引入句/对局归因与里程碑/结算播报
* 播放中单击 = `skipVoice()` 跳下一句;无语音在播时单击维持原义(加速/重开)。
* 首局 D0 在 drill_welcome 后插播一次 skip_hint。
*
* 震动分工:手势识别反馈引擎内置(swipe/confirm),本层只发语义事件——
* 撞击 hit / 护盾格挡 success / 警报两档 danger / 护盾就绪 pickup / 里程碑 confirm /
* 演练做错 error / 保护期吸收 warning / 坠毁 error+hit / 破纪录 success /
* 护盾不可用 heartbeat(round4 R1)。
*
* 语音 id 权威清单:docs/design/04-space-voice-ids.md(52 句双性别);
* 旧 num 拼接件、n_ 前缀件、intro1-4/hit1/hit2/gameover/restart 全部弃用。
*
* 向导层(docs/design/14-guide-layer.md + round2 synthesis F2.2/F6 + round3 G7):
* 对局第 1 次受击(保护期外)在归因链播完后补指令式 gd_hit_hint、第 2 次补
* gd_last_life(idle-only:挂在语音空闲点);警报音效 600ms 去重、danger 震
* ≥400ms 去抖;dir_ 超短方向呼喊在陨石呼啸响起瞬间同窗直出(round4 R1
* 改口:G7 原挂在 loud 警报档=撞击前 500ms,实测"听语音再滑"永远来不及;
* 提前到 spawn 后语音指引与反应窗口同起点。撞击点落在保护期内的陨石不播——
* 保护期有 protect_ 长句教学,呼喊会把它顶断);结算链换 _speakGuarded
* (资产缺失不卡结算 BGM);对局期长按快进豁免(setRateBoostEnabled);
* 状态签名 earcon 与状态 BGM 同点。
*
* voice 播放安全门(round4 R1 活体复验追加):_playVoice 令牌异步落位,同 tick
* 第二个 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_/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';
import { vibrate } from '../engine/haptics.js';
// ---- 方位表:来袭方向 -> 逼近音效 id(闪避手势 = 来袭方向本身,同向) ----
const DIRS = ['left', 'right', 'up', 'down'];
const APPROACH_SFX = {
left: 'sfx/whoosh_left',
right: 'sfx/whoosh_right',
up: 'sfx/whoosh_up',
down: 'sfx/whoosh_down',
};
// ---- 调参 ----
const HULL_MAX = 3; // 撞击上限(保护期后)
const DODGE_GRACE_MS = 250; // 判定宽限:spawn 前/撞击点后各让 250ms(窗口 = 呼啸全程,见头注)
const ALARM_LEAD_MS = 1000; // 撞击前多久开始警报(两档:1s / 0.5s)
const SHIELD_DURATION_MS = 6000;
const SHIELD_COOLDOWN_MS = 15000; // round4 R1:30s→15s(冷却过长 + 失败零反馈是 P1)
const DENY_DEBOUNCE_MS = 4000; // R5:护盾/加速不可用应答(语音+震动)共用去抖
const BOOST_DURATION_MS = 1800;
const BOOST_COOLDOWN_MS = 3000;
const RAMP_MS = 120000; // 难度爬升到满所需时长(保护期后起算)
const PROTECTION_MS = 60000; // 保护期
const PROTECT_APPROACH_MS = 4000;
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; // 分批引入等待玩家操作上限
/** 难度插值:t = 保护期结束后已存活 ms,返回 [0,1] */
function ramp(t) { return Math.min(1, Math.max(0, t) / RAMP_MS); }
/** 刷怪间隔:3.2s -> 1.2s */
function spawnIntervalMs(t) { return 3200 - 2000 * ramp(t); }
/** 逼近时长:2.8s -> 1.8s */
function approachMs(t) { return 2800 - 1000 * ramp(t); }
// ---- 演练步骤(D1-D4;D0 开场两句、D5 收尾句) ----
const DRILLS = [
{ dir: 'left', say: 'voice/drill_left', retry: 'voice/drill_retry_left', good: true },
{ dir: 'right', say: 'voice/drill_right', retry: 'voice/drill_retry_right', good: false },
{ dir: 'up', say: 'voice/drill_up', retry: 'voice/drill_retry_up', good: true },
{ dir: 'down', say: 'voice/drill_down', retry: 'voice/drill_retry_down', good: false },
];
// 里程碑:躲过数 -> 播报 id
const MILESTONES = { 10: 'voice/ms_10', 25: 'voice/ms_25', 50: 'voice/ms_50', 100: 'voice/ms_100' };
const MENU_ITEMS = ['voice/menu_resume', 'voice/menu_lobby', 'voice/menu_help'];
const DRILL_DONE_KEY = 'soundgame.space.drillDone';
const BEST_KEY = 'soundgame.space.best';
const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
export class SpaceGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this.phase = 'idle'; // idle | ask | drill | playing | feature | menu | over
this._session = 0; // 会话令牌:重开/跳过时使旧计时器与语音链失效
this._timers = [];
this._voiceGate = Promise.resolve(); // voice 播放安全门(见 _voicePlay)
// voice 播放安全门接线(round4 R1 活体实测发现,详见 _voicePlay):
// 本层(含向导层)所有 voice id 播放一律走串行门,bgm/sfx 直通透传。
// 与 playtest/guide.test 的 play 包装兼容(它们先包,本门包在最外层)。
this._origPlay = audio.play.bind(audio);
this.audio.play = (id, opts) => (
typeof id === 'string' && id.startsWith('voice/')
? this._voicePlay(id, opts)
: this._origPlay(id, opts)
);
// 单击/双击引入教学标记:挂实例不挂局(_resetRunState 不重置)——
// 只在首次进入后的首局各教一次,第 2 局起机制开局即用(round4 R1 P3-4)
this.boostIntroDone = false;
this.shieldIntroDone = false;
this._goingHome = false; // "回大厅收尾中"标记(_goHome 幂等 + leaving 态)
this._resetRunState();
}
_resetRunState() {
this.hits = 0;
this.dodges = 0;
this.runStart = 0;
this.protectionUntil = 0;
this._protectAnnounced = false;
this._protectSoonAnnounced = false; // R5:50s 预告已播标记
this.swipes = [];
this.obstacles = new Set();
this.drillOb = null;
this.drillIdx = -1;
this.drillFails = 0;
this.shieldUpUntil = 0;
this.shieldReadyAt = 0;
this.boostUntil = 0;
this.boostReadyAt = 0;
this.featureKind = null;
this._menuIdx = 0;
this._bareCount = 0; // 保护期结束后已刷裸飞陨石数(断崖过渡用)
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;
this._alarmLoudAt = -Infinity;
this._dangerAt = -Infinity;
}
// ---------------- 生命周期 ----------------
start() {
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/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/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',
'voice/menu_help', 'voice/help_recap', 'voice/settings_hint',
'voice/drill_welcome', 'voice/drill_rule', 'voice/second_run_ask',
'voice/skip_hint',
]);
this._session++;
this._clearTimers();
this._goingHome = false; // 页面重进时复位"回大厅收尾中"标记(leaving 态才可再入)
const drilled = localStorage.getItem(DRILL_DONE_KEY) === '1';
if (drilled) {
// 二周目:上滑重听演练 / 单击直接出发
this.phase = 'ask';
this.audio.play('voice/second_run_ask');
} else {
this._startDrill(true); // 首局:D0 插播一次 skip_hint
}
}
stop() {
this._session++;
this._clearTimers();
this.audio.setRateBoostEnabled(true); // 离开对局,恢复长按快进
this.audio.stopAll();
}
// ---------------- 手势 ----------------
onGesture(g) {
this.guide.disarm(); // 任何手势复位向导状态(guide=off 时 no-op)
switch (this.phase) {
case 'ask':
if (g === 'up') { this.audio.stopVoice(); this._startDrill(); }
else if (g === 'tap') { this.audio.stopVoice(); this._startRun(); }
break;
case 'drill':
this._onDrillGesture(g);
break;
case 'playing':
this._onPlayGesture(g);
break;
case 'feature':
this._onFeatureGesture(g);
break;
case 'menu':
this._onMenuGesture(g);
break;
case 'leaving':
// 回大厅收尾中:仅受理"单击跳确认句"(链自然推进跳转,看门狗兜底)
if (g === 'tap' && this._voiceBusy()) this.audio.skipVoice();
break;
case 'over':
// 结算语音在播时单击 = 跳过本句(链继续),无语音时 = 再飞一次
if (g === 'tap') {
if (this._voiceBusy()) this.audio.skipVoice();
else this._startRun();
} else if (g === 'down') this._goHome(); // 下滑回大厅(restart_hint 口径)
break;
}
}
// ---------------- D0-D5 交互式演练 ----------------
_startDrill(withSkipHint = false) {
this._session++;
this._clearTimers();
this.audio.stopVoice();
this._resetRunState();
this.phase = 'drill';
// D0:建立预期(无陨石)。首局在 welcome 后插播一次 skip_hint(12 号文档 §3 预留 id)。
const d0 = withSkipHint
? ['voice/drill_welcome', 'voice/skip_hint', 'voice/drill_rule']
: ['voice/drill_welcome', 'voice/drill_rule'];
this._speakGuarded(d0, () => this._nextDrill());
}
_nextDrill() {
this.drillIdx++;
this.drillFails = 0;
if (this.drillIdx >= DRILLS.length) {
// D5:收尾 → 进保护期
this._speakGuarded(['voice/drill_done'], () => {
try { localStorage.setItem(DRILL_DONE_KEY, '1'); } catch (_) { /* 隐私模式 */ }
this._startRun();
});
return;
}
// 每步旁白先播完,再 spawn 演练陨石(语音先、音效后,不叠放)
this._speakGuarded([DRILLS[this.drillIdx].say], () => this._spawnDrillOb());
}
_spawnDrillOb() {
const d = DRILLS[this.drillIdx];
const now = performance.now();
const ob = { dir: d.dir, impactAt: now + DRILL_APPROACH_MS, spawnAt: now };
this.obstacles.add(ob);
this.drillOb = ob;
// 呼啸 + dir_ 同窗:演练就建立"声音一出立刻滑"的配对(round4 R1),
// 此处 voice 层必空闲(演练陨石只在旁白播完/被跳过后 spawn)
this.audio.play(APPROACH_SFX[d.dir]);
this.audio.play(`voice/dir_${d.dir}`);
this._after(DRILL_APPROACH_MS, () => this._resolveDrill(ob));
}
_onDrillGesture(g) {
if (!this.drillOb) {
// 无演练陨石悬置(旁白/反馈播放中):下滑 = 跳过演练;
// 旁白在播时单击 = 跳过本句(语音链跳下一句),其余忽略
if (g === 'down') this._skipDrill();
else if (g === 'tap' && this._voiceBusy()) this.audio.skipVoice();
return;
}
const d = DRILLS[this.drillIdx];
if (g === d.dir) {
// 做对:dodge 音(识别震动引擎内置)+ 短肯定(仅 D1/D3,避免啰嗦)
this.obstacles.delete(this.drillOb);
this.drillOb = null;
this.audio.play('sfx/dodge');
if (d.good) this._speakGuarded(['voice/drill_good'], () => this._nextDrill());
else this._nextDrill();
} else {
this._drillFail(); // 做错(含超时外的任何错误手势):当场重教
}
}
/** 演练陨石到点未拨开 = 超时做错。 */
_resolveDrill(ob) {
if (!this.obstacles.has(ob) || this.phase !== 'drill') return;
this._drillFail();
}
_drillFail() {
if (this.drillOb) {
this.obstacles.delete(this.drillOb);
this.drillOb = null;
}
this.drillFails++;
vibrate('error');
if (this.drillFails >= 2) {
// 同一步失败 2 次自动演示通过,不卡流程
this._speakGuarded(['voice/drill_moveon'], () => this._nextDrill());
} else {
this._speakGuarded([DRILLS[this.drillIdx].retry], () => this._spawnDrillOb());
}
}
_skipDrill() {
this._session++;
this._clearTimers();
this.audio.stopVoice();
try { localStorage.setItem(DRILL_DONE_KEY, '1'); } catch (_) { /* 隐私模式 */ }
this._speakGuarded(['voice/drill_skip'], () => this._startRun());
}
// ---------------- 对局 ----------------
_startRun() {
this._session++;
this._clearTimers();
this.audio.stopVoice(); // 打断结算/菜单语音链,避免残留到下一局
this._resetRunState();
this.phase = 'playing';
this.audio.setRateBoostEnabled(false); // 对局期豁免长按快进(演奏/闪避冲突)
this.runStart = performance.now();
this.protectionUntil = this.runStart + PROTECTION_MS;
this.audio.play('bgm/engine');
this.guide.state('battle'); // 状态签名:对局(与状态 BGM 同点)
this._scheduleSpawn(1500); // 首颗稍缓,给玩家进入状态
// 单击/双击引入教学仅首局(标记在实例上,重开不再调度,第 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());
}
_inProtection() {
return performance.now() < this.protectionUntil;
}
_onPlayGesture(g) {
const now = performance.now();
switch (g) {
case 'up': case 'left': case 'right':
this.swipes.push({ dir: g, t: now });
this._trimSwipes(now);
this._whiffIfUnmatched(g);
break;
case 'down':
// 下滑冲突折中:有下方陨石悬置时是闪避,否则呼出菜单。
// round2 R2 误暂停修复(live ta=664.99/1670.73):高速段玩家在撞击瞬间
// 下滑躲避(陨石已判定出集合)被当成开菜单——加冷静期:最近一次判定
// (撞/躲/格挡/吸收)后 MENU_CALM_MS 内的下滑一律按躲避意图忽略,绝不开菜单;
// 其余时刻保留旧折中(无下方陨石悬置即可开菜单),主动暂停可达性不变。
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();
}
break;
case 'tap':
// 归因/里程碑等播报在播时单击 = 跳过本句(12 号文档裁定:space 对局语音可跳)
if (this._voiceBusy()) { this.audio.skipVoice(); break; }
// 加速是持续型增益(~1.8s),期间的撞击一律算冲过空隙,
// 因此 tap 的 ~300ms 判定延迟不影响手感。第 20s 引入后才解锁。
if (this.boostIntroDone && now >= this.boostReadyAt) {
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':
// 第 45s 引入后才解锁(仅首局引入,第 2 局起开局即用)
if (this.shieldIntroDone && now >= this.shieldReadyAt) {
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._deny(now, 'voice/feat_shield_done'); // 不可用必须有回音(round4 R1:panic 双击零反馈是 P1)
}
break;
}
}
/**
* R5:无效手势轻反馈(归因真话)。滑向没有任何悬置陨石的方向 = 挥空——
* 引擎 swipe 识别震动已有("收到了"),这里补一声轻叹("但这下没用"),
* 与成功闪避的 dodge 音形成对偶,避免玩家怀疑输入失灵。音量压低不抢戏。
*/
_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');
this.audio.play(voiceId, this._warnActive() ? { queue: true } : undefined);
}
/** 护盾充能完毕播报 + PICKUP 震动(若仍在这一局)。语音排队播,不顶 protect_end/归因句。 */
_scheduleShieldReady(delay = SHIELD_COOLDOWN_MS) {
this._after(delay, () => {
if (this.phase === 'playing') {
vibrate('pickup');
this.audio.play('voice/shield_ready', { queue: true });
}
});
}
// ---------------- 单击/双击分批引入(F 组) ----------------
/** 暂停刷怪,播引入旁白,等玩家操作(8s 上限,不等待直接继续)。 */
_beginFeature(kind) {
if (this.phase !== 'playing') return;
if (kind === 'boost' && this.boostIntroDone) return;
if (kind === 'shield' && this.shieldIntroDone) return;
if (kind === 'boost') this.boostIntroDone = true;
else this.shieldIntroDone = true;
this.phase = 'feature';
this.featureKind = kind;
this._clearTimers(); // 暂停刷怪与进行中陨石
this.obstacles.clear();
this.swipes = [];
this._speak([kind === 'boost' ? 'voice/feat_boost' : 'voice/feat_shield']);
this._after(FEATURE_WAIT_MS, () => this._endFeature());
}
_onFeatureGesture(g) {
// 引入旁白在播时单击 = 跳过本句(12 号文档:feat_* 引入句可跳);
// 单击加速教学需旁白播完(或被跳过)后再单击一次触发
if (g === 'tap' && this._voiceBusy()) { this.audio.skipVoice(); return; }
const want = this.featureKind === 'boost' ? 'tap' : 'doubletap';
if (g !== want) return; // 其他手势忽略,等超时自动继续
const now = performance.now();
if (this.featureKind === 'boost') {
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 重排充能播报
}
}
_endFeature() {
if (this.phase !== 'feature') return;
this.featureKind = null;
this._clearTimers(); // 清掉 8s 等待
this.phase = 'playing';
this._scheduleSpawn(2500); // 恢复刷怪稍缓,旁白还在播
this._rearmRunTimers();
}
/**
* 恢复被菜单/分批引入清掉的局内一次性调度:单击/双击引入、保护期结束宣告、
* 护盾充能播报。phase 必须为 'playing'。
*/
_rearmRunTimers() {
const now = performance.now();
if (!this.boostIntroDone) {
this._after(Math.max(0, this.runStart + BOOST_INTRO_MS - now), () => this._beginFeature('boost'));
}
if (!this.shieldIntroDone) {
this._after(Math.max(0, this.runStart + SHIELD_INTRO_MS - now), () => this._beginFeature('shield'));
}
if (!this._protectAnnounced) {
const remain = this.protectionUntil - now;
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);
}
// "最后一命"警告链被菜单/分批引入旁白顶断时,恢复对局立即补播(round2 R2)
if (this._hitLastPending) this._drainHitLast();
}
// ---------------- 保护期 ----------------
/** 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._defendVoice('voice/protect_end');
}
/**
* R5 防顶断登记:生存语义长句(hit_/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;
}
// ---------------- 陨石调度 ----------------
_difficultyNow() {
return performance.now() - this.protectionUntil; // 保护期后为正
}
_scheduleSpawn(delay) {
this._after(delay, () => {
if (this.phase !== 'playing') return;
this._spawnObstacle();
this._scheduleSpawn(
// 断崖过渡:保护期后前 BARE_GRACE_METEORS 颗保持保护期刷怪节奏(round4 R1)
this._inProtection() || this._bareCount < BARE_GRACE_METEORS
? PROTECT_SPAWN_MS
: spawnIntervalMs(this._difficultyNow()),
);
});
}
_spawnObstacle() {
const dir = DIRS[Math.floor(Math.random() * DIRS.length)];
const now = performance.now();
const inProt = this._inProtection();
// 断崖过渡:保护期后前 BARE_GRACE_METEORS 颗逼近时长固定 4.0s 不收缩(round4 R1)
const grace = !inProt && this._bareCount < BARE_GRACE_METEORS;
const impactAt = now + (inProt || grace ? PROTECT_APPROACH_MS : approachMs(this._difficultyNow()));
const ob = { dir, impactAt, spawnAt: now };
this.obstacles.add(ob);
if (!inProt) this._bareCount++;
this.audio.play(APPROACH_SFX[dir]);
// dir_ 超短方向呼喊与呼啸同窗直出(round4 R1 改口,原 G7 挂在撞击前 500ms
// 的 loud 警报档,"听语音再滑"永远来不及;提前到 spawn 后语音指引 = 反应窗口起点)。
// 撞击点落在保护期内的不播:protect_ 长句教学在播时呼喊会把它顶断。
// onComplete 挂 hit_last 接力:呼喊顶断归因句时,呼喊播完自动接播"最后一命"。
// round2 R2:hit_last 在播期间(_hitLastPlaying)**不呼喊**——警告播完前不再
// 被下一颗的呼喊顶断(live B5:高速段警告刚起播就被顶没);归因在播期间呼喊
// 保留生存优先级(顶断 → 接力,低延迟警告路径不变)。
if (impactAt > this.protectionUntil && !this._hitLastPlaying) {
// 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 窗口去重(双陨石同时逼近只起一波警报,
// 按 soft/loud 各自去重,单颗的两段渐强不受影响);danger 震 ≥400ms 去抖。
this._after(impactAt - ALARM_LEAD_MS - now, () => {
if (this.obstacles.has(ob)) this._alarm(0.5);
});
this._after(impactAt - ALARM_LEAD_MS / 2 - now, () => {
if (this.obstacles.has(ob)) this._alarm(1);
});
this._after(impactAt - now, () => this._resolve(ob));
}
/** 一段警报:同档 600ms 内只起一波(F6);danger 震全局限 ≥400ms 间隔。 */
_alarm(volume) {
const now = performance.now();
const key = volume < 1 ? '_alarmSoftAt' : '_alarmLoudAt';
if (now - this[key] < 600) return;
this[key] = now;
if (now - this._dangerAt >= 400) {
this._dangerAt = now;
vibrate('danger');
}
this.audio.play('sfx/alarm', { volume });
}
/** 撞击点判定:加速增益 > 同向滑动 > 护盾 > 保护期训练护盾 > 撞击。 */
_resolve(ob) {
if (!this.obstacles.has(ob) || this.phase !== 'playing') return;
this.obstacles.delete(ob);
const now = performance.now();
this._lastResolveAt = now; // 下滑开菜单冷静期锚点(任何结局都算,round2 R2)
// 1) 加速增益期间:冲过空隙
if (now <= this.boostUntil + DODGE_GRACE_MS) {
this._onDodge();
return;
}
// 2) 同向滑动拨开:呼啸响起(spawn)到撞击点 + 宽限之间的同向滑都有效——
// 窗口覆盖呼啸全程(round4 R1:旧版只开撞击前 1.5s,呼啸起点 2.8~4s,
// 早滑被静默吞掉);spawn 前的"预言家"滑动不算(spawnAt 下界)。
const hit = this.swipes.find((s) =>
s.dir === ob.dir
&& s.t >= (ob.spawnAt || 0) - DODGE_GRACE_MS
&& s.t - ob.impactAt <= DODGE_GRACE_MS);
if (hit) {
this.swipes.splice(this.swipes.indexOf(hit), 1); // 一次滑动只躲一颗
this._onDodge();
return;
}
// 3) 护盾挡下
if (now <= this.shieldUpUntil) {
this.shieldUpUntil = 0;
this.audio.play('sfx/shield_block');
vibrate('success'); // 护盾格挡成功
this.audio.play('voice/shield_block', { queue: true });
return;
}
// 4) 保护期:训练护盾吸收,不掉血,语音解释方位(死亡教学前置)
if (this._inProtection()) {
this.audio.play('sfx/shield_block');
vibrate('warning');
// 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 次加"只剩最后一次")
this.hits += 1;
this.audio.play('sfx/hit');
vibrate('hit');
if (this.hits >= HULL_MAX) {
this._gameOver();
} else {
// hit_last 接力机制(round4 R1:旧版 _speak 链被 dir_ 呼喊顶断时
// onComplete 不触发、hit_last 静默丢失——"最后一命"警告从没被听到;queue:true
// 也靠不住——_playVoice 异步设令牌,同步连调时 queue 检查落空会变同播)。
// 正常路径:归因自然播完/被跳过 → onComplete → _drainHitLast 接播;
// 顶断路径:归因被呼喊顶掉 → 呼喊播完的 onComplete(_spawnObstacle 挂的)
// → _drainHitLast 接播。重开/回大厅由 _resetRunState 清挂起。
if (this.hits === 2) this._hitLastPending = true;
// 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_ 已归因方位,这句补
// "该怎么拨",onceKey 同局一次)
this.guide.error({ voice: 'gd_hit_hint', onceKey: 'space_hit_hint', skipHaptic: true });
}
this._drainHitLast();
});
}
}
/**
* "最后一命"接播:hits===2 时挂起,归因播完或顶断它的呼喊播完两条路径汇到这里。
* 播完补 gd_last_life(F2.2:生存经济透明,与 hit_last「船体只剩最后一次」互补
* "撑住"口径),已震 hit 免重复;被打断跳过可接受(向导层 idle-only 语义不变)。
* round2 R2:接播期间置 _hitLastPlaying(此期间 spawn 呼喊抑制,警告不再被
* 顶断);已离开对局(菜单/坠毁/收尾)不再接播。
*/
_drainHitLast() {
if (!this._hitLastPending) return;
this._hitLastPending = false;
if (this.phase !== 'playing') return;
this._hitLastPlaying = true;
this.audio.play('voice/hit_last', {
onComplete: () => {
this._hitLastPlaying = false;
this.guide.error({ voice: 'gd_last_life', skipHaptic: true });
},
});
}
/** "最后一命"警告链进行中(归因在播待接播 / hit_last 在播)——此时旁白排队不顶链。 */
_warnActive() {
return this._hitLastPending || this._hitLastPlaying;
}
_onDodge() {
this.dodges += 1;
this.audio.play('sfx/dodge');
const ms = MILESTONES[this.dodges];
if (ms) {
vibrate('confirm');
// 警告链在播时里程碑排队到链尾,不顶断"最后一命"(round2 R2)
this.audio.play(ms, this._warnActive() ? { queue: true } : undefined);
}
}
// ---------------- 菜单(全球约定:下滑呼出 → 单击循环 → 双击确认 → 上滑帮助) ----------------
_openMenu() {
this.phase = 'menu';
this.audio.setRateBoostEnabled(true); // 暂停期恢复长按快进
this.guide.state('menu'); // 状态签名:菜单(与状态 BGM 同点)
this._clearTimers(); // 模态暂停:刷怪与进行中陨石全部冻结清除
this.obstacles.clear();
this.swipes = [];
this._menuIdx = 0;
this.audio.play('bgm/menu', { fadeMs: 300 }); // 状态 BGM:进菜单快切(10 号文档 §6)
this._speak(['voice/paused', 'voice/menu_main', MENU_ITEMS[0]]);
}
_onMenuGesture(g) {
if (g === 'tap') {
this._menuIdx = (this._menuIdx + 1) % MENU_ITEMS.length;
this.audio.play(MENU_ITEMS[this._menuIdx]);
} else if (g === 'up') {
// 帮助:操作要点 + 设置引导
this._speak(['voice/help_recap', 'voice/settings_hint']);
} else if (g === 'down') {
this._closeMenu();
} else if (g === 'doubletap') {
if (this._menuIdx === 0) this._closeMenu();
else if (this._menuIdx === 1) this._goHome();
else this._speak(['voice/help_recap', 'voice/settings_hint']); // menu_help 项
}
}
_closeMenu() {
this.phase = 'playing';
this.audio.setRateBoostEnabled(false); // 回到对局期
this.guide.state('battle');
this.audio.play('bgm/engine', { fadeMs: 500 }); // 状态 BGM:回对局淡回 engine
// round2 R2 恢复缓冲(live B2:恢复后全速弹幕 3 秒暴毙):恢复后前
// BARE_GRACE_METEORS 颗裸飞陨石回 4.0s 慢节奏,复用保护期断崖过渡机制。
this._bareCount = 0;
this._scheduleSpawn(800);
this._rearmRunTimers();
}
/**
* 回大厅(rhythm round1 同款收尾):先进 leaving 收尾态停刷怪/BGM,播回大厅
* 确认句,播完(或 2s 看门狗兜底)再跳转——round2 R2(live B3):旧版无确认
* 直接跳,跳转失败时玩家落进无声死界面,纯听觉玩家被关黑屋。
*/
_goHome() {
if (this._goingHome) return;
this._goingHome = true;
this.phase = 'leaving'; // 收尾态:不再受理对局/菜单/结算手势(单击仍可跳确认句)
this._clearTimers(); // 停刷怪与进行中陨石调度(保留 voice 通道播确认句)
this.obstacles.clear();
this.audio.stopBgm(300);
this._speakGuarded(['voice/menu_lobby'], () => {
this.stop();
window.location.href = '../';
}, 2000);
}
// ---------------- 坠毁结算 ----------------
_gameOver() {
this.phase = 'over';
this.guide.state('settle'); // 状态签名:结算(与结算 BGM 同点)
this.audio.setRateBoostEnabled(true); // 离开对局,恢复长按快进
this._clearTimers();
this.obstacles.clear();
this.audio.stopBgm(600);
// round2 R2(live B4):坠毁即掐断"最后一命"警告链(归因/hit_last/gd_last_life
// 可能在播)——旧版 hit_last 与 1.2s 后的结算 time_* 叠播互相掐,谁都没听清。
this._hitLastPending = false;
this._hitLastPlaying = false;
this.audio.stopVoice();
vibrate('error');
this._after(300, () => vibrate('hit'));
this.audio.play('sfx/crash');
const aliveSec = Math.floor((performance.now() - this.runStart) / 1000);
const isRecord = this._saveScore(aliveSec);
// 给爆炸声留 1.2s 再开口
this._after(1200, () => {
if (isRecord) vibrate('success');
const ids = [this._timeBandVoice(aliveSec)];
if (isRecord) ids.push('voice/new_record');
ids.push(this._dodgeBandVoice(this.dodges), 'voice/restart_hint');
// 结算语音链播完淡入 menu pad(10 号文档 §6);被跳过的句仍逐句推进链。
// 链换 _speakGuarded(清点 §5.4 缺口):time_* 等资产缺失也能起结算 BGM
this._speakGuarded(ids, () => {
if (this.phase === 'over') this.audio.play('bgm/menu', { fadeMs: 800 });
});
});
}
/** 存活时间 4 段整句:<30 / 30~60 / 60~120 / ≥120。 */
_timeBandVoice(sec) {
if (sec < 30) return 'voice/time_1';
if (sec < 60) return 'voice/time_2';
if (sec < 120) return 'voice/time_3';
return 'voice/time_4';
}
/** 躲避数 4 段整句:<5 / 5~14 / 15~49 / ≥50。 */
_dodgeBandVoice(n) {
if (n < 5) return 'voice/dodge_1';
if (n < 15) return 'voice/dodge_2';
if (n < 50) return 'voice/dodge_3';
return 'voice/dodge_4';
}
// ---------------- 成绩存档 ----------------
_loadBest() {
try {
return JSON.parse(localStorage.getItem(BEST_KEY)) || { bestTimeSec: 0, bestDodges: 0 };
} catch (_) {
return { bestTimeSec: 0, bestDodges: 0 };
}
}
/**
* 结算记分:本地 best 兜底(localStorage),破存活纪录时同步服务器。
* @returns {boolean} 是否破存活时间纪录
*/
_saveScore(aliveSec) {
const best = this._loadBest();
const isRecord = aliveSec > best.bestTimeSec;
best.bestTimeSec = Math.max(best.bestTimeSec, aliveSec);
best.bestDodges = Math.max(best.bestDodges, this.dodges);
try { localStorage.setItem(BEST_KEY, JSON.stringify(best)); } catch (_) { /* 隐私模式 */ }
if (!isRecord) return false;
const m = Math.floor(best.bestTimeSec / 60);
const ss = String(best.bestTimeSec % 60).padStart(2, '0');
try {
const auth = JSON.parse(localStorage.getItem(AUTH_KEY));
if (!auth || !auth.token) return true;
fetch('/api/progress', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + auth.token,
},
body: JSON.stringify({
game: 'space',
data: {
summary: `最长存活 ${m}:${ss} / 躲避 ${best.bestDodges} 颗`,
bestTimeSec: best.bestTimeSec,
bestDodges: best.bestDodges,
schema: 1,
updatedAt: new Date().toISOString(),
},
}),
}).catch(() => { /* 离线静默 */ });
} catch (_) { /* 静默 */ }
return true;
}
// ---------------- 工具 ----------------
/** voice 层是否有对白在播(单击=跳过本句的语境判定依据)。 */
_voiceBusy() {
return this.audio.isVoicePlaying(); // 公开 API(原读私有 _voice,清点 §0)
}
/**
* voice 播放安全门(round4 R1 活体实测发现):_playVoice 的令牌要等异步加载
* 完成才落位,落位前同 tick 第二个 play() 会漏杀前一个——两条 SoundTouch 管线
* 并播(台词交叠),先落位的令牌成永久孤儿(ScriptProcessor 节点不断开、
* 探针/监听层被交替抽读污染)。本层所有 voice play 走此门串行:等上一次 play
* 的令牌落位再发下一次,恢复引擎"后播杀先播"语义。
* 等待是纯 microtask 的(不挂定时器,虚拟时钟/真浏览器行为一致):对局可能
* 并发的语音全部 prewarm 缓存热(_load 命中已解码 Promise,落位仅数个
* microtask);资产缺失等异常路径 64 轮后放行,门绝不卡死。
* 会话令牌失效(重开/回大厅)后未发的门内播放直接作废。
*/
_voicePlay(id, opts = {}) {
const s = this._session;
this._voiceGate = this._voiceGate.then(async () => {
if (s !== this._session) return;
this._origPlay(id, opts);
for (let i = 0; i < 64 && !this.audio.isVoicePlaying(); i++) await Promise.resolve();
});
return this._voiceGate;
}
/** 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。 */
_speak(ids, onDone) {
if (!ids.length) { if (onDone) onDone(); return; }
const s = this._session;
const [head, ...rest] = ids;
this.audio.play(head, {
onComplete: () => {
if (s !== this._session) return;
this._speak(rest, onDone);
},
});
}
/**
* 带看门狗的语音链:链自然播完或 guardMs 超时(语音缺失/解码失败),
* 二者先到先触发 onDone 且只触发一次——保证流程绝不卡死。
*/
_speakGuarded(ids, onDone, guardMs = 10000) {
const s = this._session;
let fired = false;
const fire = () => {
if (fired || s !== this._session) return;
fired = true;
if (onDone) onDone();
};
this._speak(ids, fire);
this._after(guardMs, fire);
}
_after(ms, fn) {
const s = this._session;
const id = setTimeout(() => {
this._timers = this._timers.filter((t) => t !== id);
if (s === this._session) fn();
}, Math.max(0, ms));
this._timers.push(id);
return id;
}
_clearTimers() {
for (const id of this._timers) clearTimeout(id);
this._timers = [];
}
/** 清掉过期的滑动记录(超过任何可能的判定窗口 = 最长逼近时长 + 宽限)。 */
_trimSwipes(now) {
this.swipes = this.swipes.filter((s) => now - s.t <= PROTECT_APPROACH_MS + DODGE_GRACE_MS + 500);
}
}