Files
SoundGame/web/space/game.js
T
xsl ddada6980c 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修订记录
2026-09-14 20:16:58 +08:00

1031 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 盲航(space)R4 —— 太空盲飞躲避,纯音频(按 docs/design/04-space-rebuild.md 实现)。
*
* R4 核心变化:
* - **同向闪避**:听到哪边来就向哪边滑("挥手把陨石拨开"),方位声直达同侧手势,
* 消除旧版"定位→取反→出手"的推理链。whoosh_* 四资产复用,只改映射。
* - **D0-D5 交互式演练**:一句一练,演练陨石 4.5s 慢速无伤害;做错 ERROR 震动 +
* 当场重教(drill_retry_*),同一步失败 2 次 drill_moveon 自动带过不卡流程。
* 演练期(无演练陨石悬置时)下滑=跳过;二周目开场 second_run_ask 询问。
* 演练陨石呼啸响起时同窗播 dir_<dir>,与实战"听声即滑"的配对一致(round4 R1)。
* - **前 60s 保护期**:逼近 4.0s / 刷怪 4.0s 恒定;撞击不掉血——训练护盾吸收
* (shield_block 音 + WARNING 震动 + protect_<dir> 归因句)。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_<dir> 归因句,第 2 次追加 hit_last
* (呼喊接力——归因被 dir_<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_<dir> 超短方向呼喊在陨石呼啸响起瞬间同窗直出(round4 R1
* 改口:G7 原挂在 loud 警报档=撞击前 500ms,实测"听语音再滑"永远来不及;
* 提前到 spawn 后语音指引与反应窗口同起点。撞击点落在保护期内的陨石不播——
* 保护期有 protect_<dir> 长句教学,呼喊会把它顶断);结算链换 _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_<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';
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_<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_<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;
}
// ---------------- 陨石调度 ----------------
_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_<dir> 超短方向呼喊与呼啸同窗直出(round4 R1 改口,原 G7 挂在撞击前 500ms
// 的 loud 警报档,"听语音再滑"永远来不及;提前到 spawn 后语音指引 = 反应窗口起点)。
// 撞击点落在保护期内的不播:protect_<dir> 长句教学在播时呼喊会把它顶断。
// 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_<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_<dir> 已归因方位,这句补
// "该怎么拨",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);
}
}