/** * 节拍行者(rhythm)鼓手模式 —— 屏幕分区打击乐(24-rhythm-drummer.md 实现; * 语音 id 见 24-rhythm-voice-ids.md)。 * * 玩法:整面屏幕是一面 3列×2行 的鼓组(列=声像左中右,行=音高上亮下沉), * 六区六种打击乐。无鼓伴奏 BGM 上,系统(领奏)敲一小节节奏型,玩家在下一 * 小节的相同拍点敲相同的鼓;3 首曲目(90/105/120 BPM)解锁制:曲目 n 拿到 * B 级以上解锁 n+1(localStorage 记进度,已登录 PUT 同步服务器)。 * * 判定(24 文档 §5;旧作 live round1 §13 校准口径沿用): * Perfect ±0.15s(该区音色 + dm_accent 亮音叠加) * Good ±0.35s(只播该区音色;蓝牙延迟由此吸收) * Miss 异分区半窗内被敲 / 超时未应答:闷镲反馈音(dm_hat 降调 0.5×)+ error 震 * 全部音符 = 分区点按:immediateTap(index.html 开启)按下即判,零识别补偿; * 旧作的 TAP_DELAY/SWIPE_DELAY/DOUBLE_DELAY、双击序曲撤销、超时复活窗整体退役。 * 序曲 tap 治理(immediateTap 代价):按下先发 tap(出声+可认领),转滑动/长按/ * 触摸取消时引擎补 tapcancel → 撤销认领不计 Miss(声音收不回当 ghost 音); * 双击 = 同区两连击,两下各自独立成立,无撤销。 * 计分:Perfect=2 / Good=1 / Miss=0;rate = 得分/(2×总音符); * S≥0.95 / A≥0.85 / B≥0.70 / C 其余。不掉血不中断,歌照唱完。 * * 时序核心(旧作遗产):全部节拍事件 AudioContext.currentTime 预排程(领奏音符、 * 数拍、BGM 起点 source.start(when) 对网格,40ms 泵 + LOOKAHEAD 0.6s 前瞻); * 玩家敲击不走预排程,出手即播(sfx 即发即弃,pan 随行)。 * * 分区心智(教学核心,24 文档 §6):T0 摸鼓认区(≥3 区自动进阶,区名每区播一次) * → T1 底鼓家区 → T2 左右下排 → T3 上排三音;单步重教 ≤2(连 2 败降速 25% 重演 * 一次再跳过,G2 DDA 沿用);归因真话律(没出手 → gd_miss_none)。 * * 加花(fill):领奏 1 小节八分密集 + 尾音恒 TR 吊镲;应答窗口同普句;曲目一 * 终曲加花前 1 小节播 tut_fill(每账号一次)。 * * 菜单(下滑无条件呼出——音符全是点按,无旧作 down 音符冲突):模态暂停、 * 泵停 BGM 断点续播、恢复全网格平移 + 4 拍 count-in(guide full 档先播 * gd_count 每曲限 1 次);开菜单 1s 去抖独占期(旧作 round2 P1 沿用)。 * * 状态 BGM(10 文档 §5)/旁白跳过与长按快进豁免(12 文档)/向导层(14 文档 + * round2-4 修正:gd_answer 排窗口前 1 小节、教学归因四档、gd_miss 连 2 一次、 * count-in 恢复、教学 DDA、结算 _speakGuarded + menu pad 看门狗)——全部沿用。 * 键盘平权:Digit1-6(及小键盘)= 六区,按下即发零延迟;引擎空格 tap 落元素 * 中心 → 判 BC 家区;选曲/菜单/结算态数字键不生效。 */ import { Game } from '../engine/game.js'; import { Guide } from '../engine/guide.js'; import { vibrate } from '../engine/haptics.js'; import { settings } from '../engine/settings.js'; // ---- 判定与调度常量(24 文档 §5;旧作 live 校准口径) ---- const PERFECT = 0.15; // Perfect 窗口 ±150ms const GOOD = 0.35; // Good 窗口 ±350ms(蓝牙延迟由它吸收) const MISS_MARGIN = 0.10; // Miss 超时放宽 = GOOD + 0.10 const STRAY_TAP_MS = 500; // immediateTap 序曲 tap 在非演奏阶段的延迟裁决窗 const TAPCANCEL_REVOKE_S = 0.6; // tapcancel 撤销窗(覆盖长按 450ms 成立点) const LOOKAHEAD = 0.6; const PUMP_MS = 40; const MENU_DEBOUNCE_MS = 1000; // 菜单去抖独占期(旧作 round2 P1) const MISS_SFX = 'sfx/dm_hat'; // 闷镲反馈音:踩镲降调 0.5× 复用,零新资产 const MISS_RATE = 0.5; // ---- 分区表(24 文档 §2:列=声像,行=音高;sfx 程序合成 tools/gen_rhythm_syn.py) ---- const ZONES = { TL: { sfx: 'sfx/dm_hat', pan: -0.6, name: 'zone_tl' }, // 左上 踩镲 TC: { sfx: 'sfx/dm_tomhi', pan: 0, name: 'zone_tc' }, // 上中 高音嗵 TR: { sfx: 'sfx/dm_crash', pan: 0.6, name: 'zone_tr' }, // 右上 吊镲 BL: { sfx: 'sfx/dm_snare', pan: -0.6, name: 'zone_bl' }, // 左下 军鼓 BC: { sfx: 'sfx/inst_kick', pan: 0, name: 'zone_bc' }, // 下中 底鼓(家;沿用旧 id,guide.test G2/G3 锚点) BR: { sfx: 'sfx/dm_tomlo', pan: 0.6, name: 'zone_br' }, // 右下 低音嗵 }; const ACCENT = 'sfx/dm_accent'; const CLICK = 'sfx/dm_accent'; // 教学数拍木鱼(与 Perfect 亮音同资产零新增) const KICK = 'sfx/inst_kick'; // 底鼓/正片数拍(沿用旧 id) // ---- 谱面(24 文档 §4 表格直转;fill=加花句;restAfter=应答后休止小节数) ---- const SONGS = [ { id: 'song1', title: '启程', bpm: 90, bgm: 'bgm/track90', nameVoice: 'voice/song1_name', unlockVoice: null, units: [ { notes: [[1, 'BC'], [3, 'BC']], restAfter: 1 }, { notes: [[1, 'BL'], [3, 'BR']], restAfter: 1 }, { notes: [[1, 'BC'], [2, 'BC'], [3, 'BL']], restAfter: 1 }, { notes: [[1, 'BL'], [2, 'BR'], [3, 'BC']], restAfter: 1 }, { notes: [[1, 'BC'], [2, 'BL'], [3, 'BC'], [4, 'BR']], restAfter: 0 }, { notes: [[1, 'TL'], [3, 'BL'], [4, 'BC']], restAfter: 0 }, { notes: [[1, 'BC'], [2, 'TL'], [3, 'BC'], [4, 'TR']], restAfter: 0 }, { notes: [[1, 'BL'], [2, 'BR'], [3, 'TL'], [4, 'BC']], restAfter: 0 }, { notes: [[1, 'BC'], [1.5, 'BC'], [2, 'BC'], [2.5, 'BL'], [3, 'BL'], [3.5, 'BC'], [4, 'TR']], fill: true, restAfter: 0 }, ], }, { id: 'song2', title: '追风', bpm: 105, bgm: 'bgm/track105', nameVoice: 'voice/song2_name', unlockVoice: 'voice/unlock_song2', units: [ { notes: [[1, 'BC'], [3, 'BR']], restAfter: 1 }, { notes: [[1, 'BL'], [2, 'BL'], [3, 'BC']], restAfter: 1 }, { notes: [[1, 'TL'], [3, 'BL'], [4, 'BR']], restAfter: 0 }, { notes: [[1, 'BC'], [2, 'BL'], [3, 'BC'], [4, 'TL']], restAfter: 0 }, { notes: [[1, 'BC'], [2.5, 'BR'], [3, 'BC']], restAfter: 0 }, { notes: [[1, 'BL'], [1.5, 'BL'], [2, 'BC'], [2.5, 'BC'], [3, 'BR'], [3.5, 'BR'], [4, 'TR']], fill: true, restAfter: 0 }, { notes: [[1, 'BL'], [2, 'TL'], [3.5, 'BR'], [4, 'BC']], restAfter: 0 }, { notes: [[1, 'BC'], [2, 'BL'], [3, 'BR'], [3.5, 'TL']], restAfter: 0 }, { notes: [[1, 'BR'], [1.5, 'BL'], [3, 'BC'], [4, 'TR']], restAfter: 0 }, { notes: [[1, 'BC'], [2.5, 'TL'], [3, 'BC'], [4, 'BL']], restAfter: 0 }, { notes: [[1, 'BL'], [2, 'BR'], [3, 'TL'], [4, 'BC']], restAfter: 0 }, { notes: [[1, 'BC'], [1.5, 'BL'], [2, 'BC'], [2.5, 'BL'], [3, 'BC'], [3.5, 'BR'], [4, 'TR']], fill: true, restAfter: 0 }, ], }, { id: 'song3', title: '星爆', bpm: 120, bgm: 'bgm/track120', nameVoice: 'voice/song3_name', unlockVoice: 'voice/unlock_song3', units: [ { notes: [[1, 'BC'], [2, 'BL'], [3, 'BC'], [4, 'BL']], restAfter: 0 }, { notes: [[1, 'TL'], [2, 'BL'], [3, 'TL'], [4, 'BC']], restAfter: 0 }, { notes: [[1, 'BR'], [2, 'TR'], [3, 'BR'], [4, 'BL']], restAfter: 0 }, { notes: [[1, 'BC'], [2.5, 'TL'], [3, 'BC'], [4, 'BR']], restAfter: 0 }, { notes: [[1, 'BL'], [1.5, 'BR'], [2.5, 'BL'], [3, 'BC']], restAfter: 0 }, { notes: [[1, 'BC'], [2, 'TL'], [2.5, 'BL'], [3, 'BC'], [4, 'TR']], restAfter: 0 }, { notes: [[1, 'TL'], [1.5, 'TL'], [2, 'BL'], [2.5, 'BL'], [3, 'BC'], [3.5, 'BC'], [4, 'TR']], fill: true, restAfter: 0 }, { notes: [[1, 'BR'], [2, 'BC'], [3, 'BL'], [3.5, 'BR'], [4, 'TL']], restAfter: 0 }, { notes: [[1, 'BC'], [1.5, 'BC'], [2.5, 'BL'], [3, 'BC'], [3.5, 'TL'], [4, 'TR']], restAfter: 0 }, { notes: [[1, 'BL'], [2, 'BC'], [2.5, 'BR'], [3, 'TL'], [4, 'BL']], restAfter: 0 }, { notes: [[1, 'BC'], [2, 'BR'], [3, 'BC'], [3.5, 'TL'], [4, 'BL']], restAfter: 0 }, { notes: [[1, 'BC'], [1.5, 'BL'], [2, 'BC'], [2.5, 'BL'], [3, 'BC'], [3.5, 'BC'], [4, 'TR']], fill: true, restAfter: 0 }, ], }, ]; // 教学 3 步(T0 摸鼓认区之后;每步领奏前有 1 小节底鼓数拍) const TUT_STEPS = [ { voice: 'voice/tut_z_kick', notes: [[1, 'BC']] }, { voice: 'voice/tut_z_sides', notes: [[1, 'BL'], [3, 'BR']] }, { voice: 'voice/tut_z_patt', notes: [[1, 'TL'], [2, 'TC'], [3, 'TR']] }, ]; const ALL_SFX = [...new Set([...Object.values(ZONES).map((z) => z.sfx), ACCENT])]; const MENU_ITEMS = ['voice/menu_resume', 'voice/menu_lobby']; const GRADE_RANK = { s: 4, a: 3, b: 2, c: 1 }; const PROGRESS_KEY = 'soundgame.progress.rhythm'; // 引擎全项目约定(profiles.js/control 会话种子同款) const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html) // 键盘平权:Digit/Numpad 1-6 = 六区(引擎不拦截数字键,游戏层补充;按住重复抑制) const KEY_ZONE = { Digit1: 'TL', Digit2: 'TC', Digit3: 'TR', Digit4: 'BL', Digit5: 'BC', Digit6: 'BR', Numpad1: 'TL', Numpad2: 'TC', Numpad3: 'TR', Numpad4: 'BL', Numpad5: 'BC', Numpad6: 'BR', }; export class RhythmGame extends Game { constructor(audio, root) { super(audio, root); this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op) this.phase = 'idle'; // idle | intro | tut | select | main | menu | end | leaving this._runId = 0; this._timer = null; this._sched = []; // 待排程领奏/数拍事件 { time, sfx, volume, pan? } this._timed = []; // 定时动作 { time, fn } this._cues = []; // 应答期望 { zone, expected, judged, result, phrase } this._bgmSrc = null; this._bgmGain = null; this._bgmT0 = 0; this._song = null; this._songIdx = 0; this._stats = null; this._progress = this._loadProgress(); // 教学状态 this._tutStage = 'idle'; // probe | step(T0 摸鼓认区 | 跟打步) this._tutIdx = 0; this._tutFails = 0; this._tutAttempts = []; this._tutRate = 1.0; this._tutSlowUsed = false; this._tutPassedAny = false; this._probeHeard = new Set(); // T0 已报名过的区(每区每轮教学一次) // 菜单/暂停状态 this._menuIdx = 0; this._pauseAt = 0; this._bgmOffset = 0; this._resumeSeq = 0; this._gdCountUsed = false; this._answerArmed = false; this._consecMiss = 0; this._pendingTap = null; // 非演奏阶段延迟裁决的 tap this._probeTimer = null; // 探针态防呆自动进阶定时器 this._tapJudg = []; // 序曲 tap 判定账簿(tapcancel 撤销用) this._menuOpenedAt = -Infinity; this._leadBus = null; this._midDuckTimer = null; this._goingHome = false; this._voiceSeq = 0; // 键盘平权(Digit1-6);mock 环境 window.addEventListener 为 no-op,安全 if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { window.addEventListener('keydown', (e) => this._onKeyDown(e)); } } // ---------------- 生命周期 ---------------- start() { this.audio.prewarm([...ALL_SFX, 'voice/welcome', 'voice/welcome_back', 'voice/tut_map', 'voice/tut_probe', 'voice/select_hint', 'voice/mid_song', 'bgm/menu', ...SONGS.map((s) => s.bgm)]); const rid = ++this._runId; this._cleanup(); this._tutRate = 1.0; this._tutSlowUsed = false; this._gdCountUsed = false; this._tutPassedAny = false; this._probeHeard = new Set(); this._goingHome = false; if (this._progress.tutorialDone) { this.phase = 'intro'; this._speakGuarded(['voice/welcome_back'], () => this._enterSelect(rid)); } else { // 首次:welcome → 摸鼓认区(BGM 第 6 秒起,教学在曲目一网格上进行) this.phase = 'intro'; this._startBgm(SONGS[0], this.audio.ctx.currentTime + 6.0); this._speakGuarded(['voice/welcome', 'voice/tut_map', 'voice/tut_probe'], () => this._beginProbe(rid), 14000); } } stop() { this._runId++; this._cleanup(); this.audio.setRateBoostEnabled(true); this.audio.stopAll(); } _cleanup() { if (this._timer) { clearInterval(this._timer); this._timer = null; } if (this._pendingTap) { clearTimeout(this._pendingTap); this._pendingTap = null; } if (this._probeTimer) { clearTimeout(this._probeTimer); this._probeTimer = null; } if (this._midDuckTimer) { clearTimeout(this._midDuckTimer); this._midDuckTimer = null; } this._duckLeads(false); this._voiceSeq++; this._tapJudg = []; this._sched = []; this._timed = []; this._cues = []; this._stopBgmSrc(); this.audio.stopVoice(); } _stopBgmSrc() { if (this._bgmSrc) { try { this._bgmSrc.stop(); } catch (_) { /* 已停止 */ } this._bgmSrc = null; this._bgmGain = null; } } // ---------------- 分区映射 ---------------- /** * 触点 → 分区(24 文档 §2.1:3 列 × 2 行,列为三等分、行对半)。 * @param {{x:number,y:number}|null|undefined} info 引擎第二参(视口像素) * @returns {'TL'|'TC'|'TR'|'BL'|'BC'|'BR'|null} 无布局环境(mock)返回 null */ zoneOf(info) { if (!info || !this.root || typeof this.root.getBoundingClientRect !== 'function') return null; const r = this.root.getBoundingClientRect(); if (!r || !r.width || !r.height) return null; const col = info.x < r.left + r.width / 3 ? 0 : info.x < r.left + (2 * r.width) / 3 ? 1 : 2; const row = info.y < r.top + r.height / 2 ? 0 : 1; return [['TL', 'TC', 'TR'], ['BL', 'BC', 'BR']][row][col]; } /** 敲击分发:payload 缺区(无布局环境)兜底 BC 家区。 */ _zoneFrom(info) { return this.zoneOf(info) || 'BC'; } // ---------------- 键盘平权(Digit1-6 = 六区) ---------------- _onKeyDown(e) { const zone = KEY_ZONE[e.code]; if (!zone || e.repeat) return; if (typeof e.preventDefault === 'function') e.preventDefault(); // 演奏输入语境生效(探针态=摸鼓报名,跟打/正片=判定);选曲/菜单/结算态 // 数字键不生效(那些态沿用手势导航) if (this.phase === 'tut' && this._tutStage === 'probe') this._onProbe(zone); else if (this.phase === 'main' || this.phase === 'tut') this._hit(zone); } // ---------------- BGM(手动 source 对齐节拍网格) ---------------- async _startBgm(song, t0, offset = 0) { const rid = this._runId; const buf = await this.audio._load(song.bgm); if (!buf || rid !== this._runId || !this.audio.ctx) return; if (offset < 0) { t0 -= offset; offset = 0; } // 负偏移:推迟起点 this._stopBgmSrc(); const src = this.audio.ctx.createBufferSource(); src.buffer = buf; src.loop = true; const g = this.audio.ctx.createGain(); g.gain.value = this.audio.bgmVolume; src.connect(g); g.connect(this.audio._bgmBus); this._bgmSrc = src; this._bgmGain = g; this._bgmT0 = t0 - offset; src.start(t0, offset % buf.duration); } // ---------------- 节拍调度泵 ---------------- /** 在绝对时间 when 播放 sfx(走领奏总线;zone 提供声像)。 */ async _scheduleSfx(id, when, volume = 1, pan = 0) { const buf = await this.audio._load(id); if (!buf || !this.audio.ctx) return; const src = this.audio.ctx.createBufferSource(); src.buffer = buf; const g = this.audio.ctx.createGain(); g.gain.value = this.audio.sfxVolume * volume; src.connect(g); const panner = this.audio._makePanner(pan); if (panner) { g.connect(panner); panner.connect(this._leadBusNode()); } else { g.connect(this._leadBusNode()); } src.start(when); } _leadBusNode() { if (!this._leadBus) { this._leadBus = this.audio.ctx.createGain(); this._leadBus.connect(this.audio._sfxBus); } return this._leadBus; } /** 领奏 ducking:提示语音期间压到 1/4。 */ _duckLeads(on) { if (!this.audio.ctx || !this._leadBus) return; const t = this.audio.ctx.currentTime; const g = this._leadBus.gain; g.cancelScheduledValues(t); g.setValueAtTime(g.value, t); g.linearRampToValueAtTime(on ? 0.25 : 1.0, t + 0.2); } /** mid_song 提示:播前领奏 duck 到 1/4,按估算时长恢复。 */ async _playMidSong() { if (!this.audio.ctx) return; this._duckLeads(true); if (this._midDuckTimer) clearTimeout(this._midDuckTimer); const buf = await this.audio._load('voice/mid_song'); const speed = settings.get().speed || 1; const ms = buf ? Math.min(4500, Math.max(1200, (buf.duration / speed + 0.5) * 1000)) : 2500; this._midDuckTimer = setTimeout(() => this._duckLeads(false), ms); this.audio.play('voice/mid_song'); } /** Miss 反馈音:踩镲降调 0.5×(闷镲;复用资产零新文件)。 */ async _playMissSfx() { const buf = await this.audio._load(MISS_SFX); if (!buf || !this.audio.ctx) return; const src = this.audio.ctx.createBufferSource(); src.buffer = buf; if (src.playbackRate) src.playbackRate.value = MISS_RATE; const g = this.audio.ctx.createGain(); g.gain.value = this.audio.sfxVolume * 0.9; src.connect(g); g.connect(this.audio._sfxBus); src.start(); } /** 当前秒/拍(教学计降速)。 */ _spb() { if (this.phase === 'tut') return (60 / SONGS[0].bpm) / this._tutRate; return this._song ? 60 / this._song.bpm : 60 / SONGS[0].bpm; } _pump(rid) { if (this._runId !== rid || !this.audio.ctx) return; const now = this.audio.ctx.currentTime; while (this._sched.length && this._sched[0].time <= now + LOOKAHEAD) { const ev = this._sched.shift(); this._scheduleSfx(ev.sfx, ev.time, ev.volume, ev.pan || 0); const delay = Math.max(0, (ev.time - now) * 1000); setTimeout(() => { if (this._runId === rid) vibrate('beat'); }, delay); } while (this._timed.length && this._timed[0].time <= now) { const ev = this._timed.shift(); ev.fn(); } if (this.phase === 'main') { for (const cue of this._cues) { if (cue.judged) continue; if (now > cue.expected + GOOD + MISS_MARGIN) this._applyMiss(cue); } if (now >= this._phaseEnd) this._finish(rid); } else if (this.phase === 'tut' && this._tutStage === 'step') { if (now >= this._phaseEnd) this._tutEvaluate(rid); } } _startPump(rid) { if (this._timer) clearInterval(this._timer); this._timer = setInterval(() => this._pump(rid), PUMP_MS); } _sortSched() { this._sched.sort((a, b) => a.time - b.time); this._timed.sort((a, b) => a.time - b.time); } /** 在绝对时刻排 1 小节 4 记数拍。正片用底鼓(guide.test G3 的 _sched 锚点), * 教学步用木鱼 click(G2 的 inst_kick 锚点要求教学 _sched 里 kick=领奏音)。 * BGM 无鼓,数拍全靠鼓手自己。 */ _countIn(t0, spb, sfx = KICK, volume = 1.0) { for (let i = 0; i < 4; i++) { this._sched.push({ time: t0 + i * spb, sfx, volume, pan: 0 }); } } // ---------------- 教学(T0 摸鼓认区 + T1-T3 跟打) ---------------- _beginProbe(rid) { if (this._runId !== rid) return; this.phase = 'tut'; this._tutStage = 'probe'; this._probeHeard = new Set(); this.guide.state('explore'); // 状态签名:探索(摸鼓认区) // 防呆自动进阶: narration 已播完才进探针。8s 无触摸 → 直接进 T1(不卡流程, // guide.test G2 等无触摸驱动依赖此路径);摸过 1-2 区再给 8s 宽限,之后无论 // 几区都开始(探索是礼物不是门槛)。 const arm = (grace) => { this._probeTimer = setTimeout(() => { this._probeTimer = null; if (this._runId !== rid || this.phase !== 'tut' || this._tutStage !== 'probe') return; if (this._probeHeard.size === 0 && grace) { arm(false); return; } this._tutStage = 'idle'; this._beginTutorialStep(rid, 0); }, 8000); }; arm(true); } /** 清理探针自动进阶定时器(进阶/跳过/清理时)。 */ _clearProbeTimer() { if (this._probeTimer) { clearTimeout(this._probeTimer); this._probeTimer = null; } } /** T0:任意 tap → 播该区音色 + 区名(每区每轮教学一次);≥3 区进 T1。 */ _onProbe(zone) { this.audio.play(ZONES[zone].sfx, { pan: ZONES[zone].pan }); if (!this._probeHeard.has(zone)) { this._probeHeard.add(zone); this.audio.play(`voice/${ZONES[zone].name}`, { queue: true }); if (this._probeHeard.size >= 3) { this._clearProbeTimer(); // R1 F2:不再 stopVoice 掐断正在播的区名——教学步语音 queue 排在 // 区名链后播。延迟 150ms 起步:引擎 _playVoice 的入队判定在 await // 加载之前(voice token 尚未挂上),同步连发会让教学句误判「通道 // 空闲」而顶掉区名(live R1 实录 zone_bl 被 tut_z_kick 截杀)。 this._tutStage = 'idle'; // 防止泵与后续 tap 再进探针分支 setTimeout(() => { if (this.phase === 'tut' && this._tutStage === 'idle') { this._speakGuarded([], () => this._beginTutorialStep(this._runId, 0)); } }, 150); } } } _beginTutorialStep(rid, idx) { if (this._runId !== rid) return; this._tutIdx = idx; this._tutFails = 0; this._cues = []; this._tutAttempts = []; this._speakGuarded([TUT_STEPS[idx].voice], () => this._tutLead(rid), 10000, true); } /** 教学领奏:数拍 1 小节 + 领奏 1 小节(对齐 BGM 网格),应答 = 领奏 + 1 小节。 */ _tutLead(rid) { if (this._runId !== rid) return; this.phase = 'tut'; this._tutStage = 'step'; const song = SONGS[0]; const spb = 60 / song.bpm; const now = this.audio.ctx.currentTime; // 下一小节头起数拍(至少 1.2s 缓冲,不早于 BGM 网格起点;数拍 1 小节 + 领奏 1 小节) const countBar = this._bgmT0 + Math.max(0, Math.ceil((now + 1.2 - this._bgmT0) / (4 * spb))) * 4 * spb; const leadBar = countBar + 4 * spb; const stepSpb = spb / this._tutRate; // G2:降速只拉伸领奏/应答网格 this._countIn(countBar, spb, CLICK, 0.9); // 教学数拍=木鱼(kick 锚点留给领奏音) this._tutAttempts = []; this._cues = []; for (const [beat, z] of TUT_STEPS[this._tutIdx].notes) { const t = leadBar + (beat - 1) * stepSpb; const zn = ZONES[z]; this._sched.push({ time: t, sfx: zn.sfx, volume: 1, pan: zn.pan }); this._cues.push({ zone: z, expected: t + 4 * stepSpb, judged: false, phrase: -1 }); } this._sortSched(); const last = this._cues[this._cues.length - 1]; this._phaseEnd = last.expected + GOOD + 0.4; this._startPump(rid); } /** 教学应答评估:全部音窗口内敲对区 → 通过;否则纠正重教。 */ _tutEvaluate(rid) { if (this._runId !== rid || this.phase !== 'tut' || this._tutStage !== 'step') return; this._phaseEnd = Infinity; // 一次性(语音间隙期泵不会重入级联连过) const pass = this._cues.every((cue) => { const a = this._tutAttempts.find((x) => x.cue === cue); return a && a.correct && Math.abs(a.dt) <= GOOD; }); if (pass) { this._tutPassedAny = true; this._tutRate = 1.0; if (this._tutIdx + 1 < TUT_STEPS.length) { this._beginTutorialStep(rid, this._tutIdx + 1); } else { this._finishTutorial(rid); } return; } this._tutFails++; vibrate('error'); if (this._tutFails >= 2) { // G2 DDA:连 2 败 → 降速 25% 重演(每教学局 1 次;off 档维持跳步原路径) if (this.guide.level() !== 'off' && !this._tutSlowUsed) { this._tutSlowUsed = true; this._tutRate = 0.75; this._tutFails = 0; if (this.guide.level() === 'full') { this._speakGuarded(['voice/gd_slow'], () => this._tutLead(rid)); } else { this._tutLead(rid); } return; } this._tutRate = 1.0; this._speakGuarded(['voice/retry_skip'], () => { if (this._tutIdx + 1 < TUT_STEPS.length) this._beginTutorialStep(rid, this._tutIdx + 1); else this._finishTutorial(rid); }); return; } // H5 归因真话律:无窗内尝试优先 gd_miss_none;真出手再分早/晚/错区。 const noShow = this._cues.some((cue) => { const a = this._tutAttempts.find((x) => x.cue === cue); return !a || Math.abs(a.dt) > GOOD; }); const wrong = this._tutAttempts.find((a) => !a.correct); const offbeat = this._tutAttempts.find((a) => a.correct && Math.abs(a.dt) > GOOD); const missGrade = noShow ? 'gd_miss_none' : wrong ? 'gd_miss_wrong' : offbeat ? (offbeat.dt < 0 ? 'gd_miss_early' : 'gd_miss_late') : 'gd_miss_late'; let correction = 'voice/retry_listen'; if (wrong) correction = 'voice/retry_wrong'; else if (offbeat) correction = offbeat.dt < 0 ? 'voice/retry_early' : 'voice/retry_late'; // R1 F3:归因句本身已含「再听」语义时不追加 retry_listen(防二连复读)。 const chain2 = correction === 'voice/retry_listen' ? [correction] : [correction, 'voice/retry_listen']; this._speakGuarded(chain2, () => { this.guide.error({ voice: missGrade, skipHaptic: true }); this._tutLead(rid); }); } _finishTutorial(rid) { this._progress.tutorialDone = true; this._saveProgress(); const line = this._tutPassedAny ? 'voice/tut_done' : 'voice/retry_skip'; this._speakGuarded([line], () => this._startSong(rid, 0)); } // ---------------- 选曲 ---------------- _enterSelect(rid) { if (this._runId !== rid) return; this.phase = 'select'; this.audio.setRateBoostEnabled(true); this.audio.play('bgm/menu', { fadeMs: 800 }); this.guide.state('menu'); this._speak(['voice/select_hint', SONGS[this._songIdx].nameVoice]); } _onSelectGesture(rid, g) { if (g === 'left' || g === 'right') { const n = SONGS.length; this._songIdx = (this._songIdx + (g === 'right' ? 1 : n - 1)) % n; const song = SONGS[this._songIdx]; this._speak([this._songIdx < this._progress.unlocked ? song.nameVoice : 'voice/song_locked']); } else if (g === 'tap') { if (this._songIdx < this._progress.unlocked) { this._speak([SONGS[this._songIdx].nameVoice]); this._startSong(rid, this._songIdx); } else { this._speak(['voice/song_locked']); } } else if (g === 'down') { this._goHome(); } else if (g === 'up') { this._speak(['voice/rules']); } } // ---------------- 正式曲目 ---------------- _startSong(rid, songIdx) { if (this._runId !== rid) return; this._cleanup(); this.audio.setRateBoostEnabled(false); // 演奏期豁免长按快进 this.audio.stopBgm(400); this.phase = 'main'; this.guide.state('battle'); this._songIdx = songIdx; this._song = SONGS[songIdx]; this._stats = { perfect: 0, good: 0, miss: 0, combo: 0, maxCombo: 0 }; this._consecMiss = 0; const spb = 60 / this._song.bpm; const now = this.audio.ctx.currentTime; const bgmT0 = now + 1.0; this._startBgm(this._song, bgmT0); // 数拍 1 小节(4 记底鼓)→ 逐乐句/加花(领奏 1 小节 + 应答 1 小节 + 休止) this._countIn(bgmT0, spb); let bar = 1; this._cues = []; const midUnit = Math.floor(this._song.units.length / 2); this._song.units.forEach((unit, i) => { const leadBarT0 = bgmT0 + bar * 4 * spb; for (const [beat, z] of unit.notes) { const t = leadBarT0 + (beat - 1) * spb; const zn = ZONES[z]; this._sched.push({ time: t, sfx: zn.sfx, volume: 1, pan: zn.pan }); this._cues.push({ zone: z, expected: t + 4 * spb, judged: false, result: null, phrase: i }); } if (unit.fill) { // 加花:领奏小节头落 turn earcon(应答密集的预警) this._timed.push({ time: leadBarT0, fn: () => this.guide.turn({}) }); } if (i === midUnit) { this._timed.push({ time: leadBarT0, fn: () => this._playMidSong() }); } if (i === 0) { // G1:首个应答窗口前整整 1 小节(= 领奏小节头)落 gd_answer+turn this._answerArmed = true; this._timed.push({ time: leadBarT0, fn: () => this._fireAnswerCue() }); } if (unit.fill && this._songIdx === 0 && !this._progress.tutFillDone && i === this._song.units.length - 1) { // 加花预告:曲目一终曲加花领奏前 1 小节,每账号一次 this._timed.push({ time: leadBarT0 - 4 * spb, fn: () => { this._progress.tutFillDone = true; this._saveProgress(); this.audio.play('voice/tut_fill'); }, }); } bar += 2 + (unit.restAfter || 0); }); this._sortSched(); const last = this._cues[this._cues.length - 1]; this._phaseEnd = last.expected + GOOD + MISS_MARGIN + 0.5; this._startPump(rid); } _fireAnswerCue() { this._answerArmed = false; if (this.guide.level() === 'full') this.audio.play('voice/gd_answer'); this.guide.turn({}); } _armAnswerCue(rid) { if (this._answerArmed) return; if (this._runId !== rid || !this._song || !this.audio.ctx) return; const now = this.audio.ctx.currentTime; const next = this._cues.find((c) => !c.judged && c.expected > now); if (!next) return; const spb = 60 / this._song.bpm; const t = Math.max(now + 0.15, next.expected - 4 * spb); this._answerArmed = true; this._timed.push({ time: t, fn: () => this._fireAnswerCue() }); this._sortSched(); } // ---------------- 手势与判定 ---------------- /** * 手势入口(引擎坐标扩展后带 info 第二参,见 docs/web-engine-api.md §gesture)。 * tap = 分区敲击(info 决定敲哪区);滑动 = 导航;双击在演奏期无角色(两下 * tap 已各自成立);tapcancel 撤销序曲 tap 的判定认领。 */ onGesture(g, info) { const rid = this._runId; if (g === 'tapcancel') { if (this._pendingTap) { clearTimeout(this._pendingTap); this._pendingTap = null; } if (this._probeTimer) { clearTimeout(this._probeTimer); this._probeTimer = null; } this._revokeTap(TAPCANCEL_REVOKE_S); return; } if (g === 'tap' && !this._isPlayInput()) { // 非演奏输入语境:序曲 tap 延迟裁决(防滑动/长按起点误触发) if (this._pendingTap) clearTimeout(this._pendingTap); this._pendingTap = setTimeout(() => { this._pendingTap = null; this._dispatch(rid, 'tap', info); }, STRAY_TAP_MS); return; } if (g !== 'tap' && this._pendingTap) { clearTimeout(this._pendingTap); this._pendingTap = null; } this._dispatch(rid, g, info); } /** 演奏输入语境(摸鼓探针、教学跟打步、正片;导航态不算)。 */ _isPlayInput() { return this.phase === 'main' || this.phase === 'tut'; } _dispatch(rid, g, info) { switch (this.phase) { case 'tut': if (this._tutStage === 'probe') { if (g === 'down') { // 下滑跳过教学 this._clearProbeTimer(); this.audio.stopVoice(); // 清排队区名,防 tut_skip 播完后漏响 this._progress.tutorialDone = true; this._saveProgress(); this._speakGuarded(['voice/tut_skip'], () => this._startSong(rid, 0)); return; } // 探针态单击 = 摸鼓(区名 queue 串播不互掐;探针态无旁白可跳—— // 引导语音都在 intro 阶段播完才进来) if (g === 'tap') this._onProbe(this._zoneFrom(info)); return; } // step:语音播放中单击 = 跳句;无语音时才是跟打敲击 if (g === 'tap' && this.audio.isVoicePlaying()) { this.audio.skipVoice(); return; } if (g === 'down') { // 教学期下滑 = 跳过教学(无 down 音符冲突) this._progress.tutorialDone = true; this._saveProgress(); this._speakGuarded(['voice/tut_skip'], () => this._startSong(rid, 0)); return; } if (g === 'tap') this._hit(this._zoneFrom(info)); return; case 'select': if (this._trySkipVoice(g)) return; this._onSelectGesture(rid, g); return; case 'main': if (g === 'down') { this._openMenu(rid); return; } // 无条件(音符全是点按) if (g === 'tap') { this._hit(this._zoneFrom(info)); return; } if (g === 'longpress') { this._playMissSfx(); vibrate('error'); return; } // 死输入给定义 if (g === 'longpressend') return; return; case 'menu': this._onMenuGesture(rid, g); return; case 'end': if (this._trySkipVoice(g)) return; if (g === 'tap') this._startSong(rid, this._songIdx); else if (g === 'right') { if (this._songIdx + 1 < this._progress.unlocked) this._startSong(rid, this._songIdx + 1); else if (this._songIdx + 1 >= SONGS.length) this._speak(['voice/restart']); else this._speak(['voice/song_locked']); } else if (g === 'down') this._goHome(); return; default: this._trySkipVoice(g); // intro/leaving:只收单击跳旁白 return; } } _trySkipVoice(g) { if (g !== 'tap' || !this.audio.isVoicePlaying()) return false; this.audio.skipVoice(); return true; } // ---------------- 敲击与判定 ---------------- /** * 分区敲击(tap / 键盘数字键共用)。判定时刻 = 回调/按键时刻(零补偿)。 * 窗口外 = 自由演奏(出声不判定);教学步全程记录尝试供评估与归因。 */ _hit(zone) { if (!this.audio.ctx || !ZONES[zone]) return; const zn = ZONES[zone]; const t = this.audio.ctx.currentTime; const inTut = this.phase === 'tut'; // 归属①:同区最近未判定音符 let best = null; let bestDt = Infinity; for (const cue of this._cues) { if (cue.judged || cue.zone !== zone) continue; const dt = Math.abs(t - cue.expected); if (dt < bestDt) { bestDt = dt; best = cue; } } if (best && bestDt <= GOOD) { this.audio.play(zn.sfx, { pan: zn.pan }); const perfect = bestDt <= PERFECT; if (perfect) this.audio.play(ACCENT, { volume: 0.8 }); this._recordTapJudg(best, perfect); if (inTut) this._tutAttempts.push({ cue: best, dt: t - best.expected, correct: true }); this._applyHit(best, perfect); return; } // 归属②:异分区音符半窗内被敲 = 错鼓 Miss let wrong = null; let wrongDt = Infinity; for (const cue of this._cues) { if (cue.judged || cue.zone === zone) continue; const dt = Math.abs(t - cue.expected); if (dt < wrongDt) { wrongDt = dt; wrong = cue; } } if (wrong && wrongDt <= GOOD * 0.5) { this.audio.play(zn.sfx, { pan: zn.pan }); // 自由演奏语义:声照出 if (inTut) this._tutAttempts.push({ cue: wrong, dt: t - wrong.expected, correct: false }); this._recordTapJudg(null, false); this._applyMiss(wrong, 'wrong'); return; } this.audio.play(zn.sfx, { pan: zn.pan }); // 窗口外自由演奏 this._recordTapJudg(null, false); if (inTut) { // 教学:最近未判定音符(不限区)记一次尝试,供「没接/抢拍/慢拍」归因 let near = null; let nearDt = Infinity; for (const cue of this._cues) { if (cue.judged) continue; const dt = Math.abs(t - cue.expected); if (dt < nearDt) { nearDt = dt; near = cue; } } if (near && nearDt <= GOOD * 2) { this._tutAttempts.push({ cue: near, dt: t - near.expected, correct: near.zone === zone }); } } } /** 序曲 tap 判定账簿:tapcancel 到达时按账撤销(声音已出收不回)。 */ _recordTapJudg(cue, perfect) { this._tapJudg.push({ at: this.audio.ctx.currentTime, cue, perfect, prevCombo: this._stats ? this._stats.combo : 0, prevConsec: this._consecMiss, }); if (this._tapJudg.length > 8) this._tapJudg.shift(); } _revokeTap(maxAgeS) { if (!this.audio.ctx) return; const now = this.audio.ctx.currentTime; for (let i = this._tapJudg.length - 1; i >= 0; i--) { const j = this._tapJudg[i]; if (now - j.at > maxAgeS) break; this._tapJudg.splice(i, 1); if (j.cue && j.cue.judged && j.cue.result !== 'miss' && this._stats) { j.cue.judged = false; j.cue.result = null; if (j.perfect) this._stats.perfect--; else this._stats.good--; this._stats.combo = j.prevCombo; this._consecMiss = j.prevConsec; } break; // 一次 tapcancel 只撤销一笔(引擎每按下一发改判只补发一次) } } _applyHit(cue, perfect) { if (cue.judged) return; cue.judged = true; cue.result = perfect ? 'perfect' : 'good'; this._consecMiss = 0; const s = this._stats; if (s) { if (perfect) s.perfect++; else s.good++; s.combo++; if (s.combo > s.maxCombo) s.maxCombo = s.combo; if (s.combo % 16 === 0) vibrate('success'); } this._checkPhraseAllPerfect(cue.phrase); } _applyMiss(cue, kind = 'timeout') { if (cue.judged) return; cue.judged = true; cue.result = 'miss'; cue.missKind = kind; cue.comboBefore = this._stats ? this._stats.combo : 0; cue.consecBefore = this._consecMiss; if (this._stats) { this._stats.miss++; this._stats.combo = 0; } this._playMissSfx(); vibrate('error'); this._consecMiss++; if (this._consecMiss === 2) { // R1 F1:曲尾 3s 内不再插播——否则一句「慢了半拍。」播 1.1s 就被结算 // 评级语音顶掉(live A 实录半句截断)。宁可不说,不说半句。 const remain = this._phaseEnd && this.audio.ctx ? this._phaseEnd - this.audio.ctx.currentTime : 99; if (remain > 3) this.guide.error({ voice: 'gd_miss', onceKey: 'rhythm_miss', skipHaptic: true }); } this._checkPhraseAllPerfect(cue.phrase); } _checkPhraseAllPerfect(phraseIdx) { if (phraseIdx < 0 || !this._stats) return; const notes = this._cues.filter((c) => c.phrase === phraseIdx); if (notes.length && notes.every((c) => c.judged) && notes.every((c) => c.result === 'perfect')) { vibrate([20, 40, 20]); } } // ---------------- 菜单(模态暂停,BGM 断点续播) ---------------- _openMenu(rid) { if (this._runId !== rid) return; this.phase = 'menu'; this._menuIdx = 0; this._menuOpenedAt = performance.now(); this.audio.setRateBoostEnabled(true); this.guide.state('menu'); if (this._timer) { clearInterval(this._timer); this._timer = null; } if (this._midDuckTimer) { clearTimeout(this._midDuckTimer); this._midDuckTimer = null; } this._duckLeads(false); this._resumeSeq++; this._pauseAt = this.audio.ctx.currentTime; if (this._bgmSrc) this._bgmOffset = this._pauseAt - this._bgmT0; this._stopBgmSrc(); this._speak(['voice/menu_main', MENU_ITEMS[0]]); } _onMenuGesture(rid, g) { if (performance.now() - this._menuOpenedAt < MENU_DEBOUNCE_MS) return; 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/rules', 'voice/settings_hint']); } else if (g === 'down') { this._closeMenu(rid); } else if (g === 'doubletap') { if (this._menuIdx === 0) this._closeMenu(rid); else this._goHome(); } } _closeMenu(rid) { if (this._runId !== rid) return; this.phase = 'main'; this.audio.setRateBoostEnabled(false); this.audio.stopVoice(); // 关菜单先停菜单语音链 const token = ++this._resumeSeq; const resume = () => { if (this._runId !== rid || token !== this._resumeSeq) return; const resumeAt = this.audio.ctx.currentTime + 0.1; // G3:断点续播前插 1 小节 4 拍 count-in(网格与 BGM 整体后移) const countIn = this.guide.level() !== 'off' ? 4 * (60 / this._song.bpm) : 0; const bgmAt = resumeAt + countIn; const shift = bgmAt - this._pauseAt; for (const ev of this._sched) ev.time += shift; for (const ev of this._timed) ev.time += shift; for (const cue of this._cues) cue.expected += shift; this._phaseEnd += shift; if (countIn > 0) this._countIn(resumeAt, 60 / this._song.bpm, KICK, 1.0); this._sortSched(); this._startBgm(this._song, bgmAt, this._bgmOffset); this._startPump(rid); this._armAnswerCue(rid); }; if (this.guide.level() === 'full' && !this._gdCountUsed) { this._gdCountUsed = true; this._speakGuarded(['voice/gd_count'], resume); } else { resume(); } } _goHome() { if (this._goingHome) return; this._goingHome = true; const rid = this._runId; this.phase = 'leaving'; if (this._timer) { clearInterval(this._timer); this._timer = null; } this._stopBgmSrc(); this.audio.stopBgm(300); this._speakGuarded(['voice/menu_lobby'], () => { if (rid !== this._runId) return; this.stop(); window.location.href = '../'; }, 2000); } // ---------------- 结算 ---------------- _grade() { const s = this._stats; const total = s.perfect + s.good + s.miss; const rate = total ? (s.perfect * 2 + s.good) / (2 * total) : 0; if (rate >= 0.95) return 's'; if (rate >= 0.85) return 'a'; if (rate >= 0.70) return 'b'; return 'c'; } _finish(rid) { if (this._runId !== rid) return; this.phase = 'end'; this.guide.state('settle'); this.audio.setRateBoostEnabled(true); if (this._timer) { clearInterval(this._timer); this._timer = null; } this._sched = []; this._timed = []; if (this._bgmSrc && this.audio.ctx) { const now = this.audio.ctx.currentTime; this._bgmGain.gain.cancelScheduledValues(now); this._bgmGain.gain.setValueAtTime(this._bgmGain.gain.value, now); this._bgmGain.gain.linearRampToValueAtTime(0, now + 0.6); const src = this._bgmSrc; setTimeout(() => { try { src.stop(); } catch (_) { /* 已停止 */ } }, 700); this._bgmSrc = null; this._bgmGain = null; } const grade = this._grade(); const p = this._progress; const ids = [`voice/grade_${grade}`]; const prevGrade = p.grades[this._song.id]; if (!prevGrade || GRADE_RANK[grade] > GRADE_RANK[prevGrade]) { p.grades[this._song.id] = grade; } const isComboRecord = this._stats.maxCombo > (p.bestCombo || 0); if (isComboRecord) { p.bestCombo = this._stats.maxCombo; ids.push('voice/new_best_combo'); } let unlockedNew = null; if (GRADE_RANK[grade] >= GRADE_RANK.b && this._songIdx + 1 < SONGS.length && this._songIdx + 1 >= p.unlocked) { p.unlocked = this._songIdx + 2; unlockedNew = SONGS[this._songIdx + 1].unlockVoice; } this._saveProgress(); this._putProgress(); if (grade === 's' || grade === 'a') vibrate('success'); if (unlockedNew) { vibrate('success'); vibrate([20, 40, 20]); ids.push(unlockedNew); } ids.push('voice/restart'); console.log('[RhythmGame] 结算:', this._stats, '评级:', grade.toUpperCase()); this._speakGuarded(ids, () => { if (this.phase === 'end') this.audio.play('bgm/menu', { fadeMs: 800 }); }, 5000 + ids.length * 7000); } // ---------------- 进度存储 ---------------- _loadProgress() { try { const p = JSON.parse(localStorage.getItem(PROGRESS_KEY)); if (p && typeof p === 'object') { return { tutorialDone: !!p.tutorialDone, unlocked: Math.max(1, Math.min(3, p.unlocked || 1)), grades: p.grades || {}, bestCombo: p.bestCombo || 0, tutFillDone: !!p.tutFillDone, }; } } catch (_) { /* 损坏则重置 */ } return { tutorialDone: false, unlocked: 1, grades: {}, bestCombo: 0, tutFillDone: false }; } _saveProgress() { try { localStorage.setItem(PROGRESS_KEY, JSON.stringify(this._progress)); } catch (_) { /* 隐私模式 */ } } /** PUT /api/progress(未登录/离线静默跳过)。summary 取最高评级曲目。 */ _putProgress() { const p = this._progress; let bestSong = this._songIdx; let bestRank = 0; SONGS.forEach((song, i) => { const g = p.grades[song.id]; if (g && GRADE_RANK[g] > bestRank) { bestRank = GRADE_RANK[g]; bestSong = i; } }); const gradeText = bestRank ? `评级${{ 1: 'C', 2: 'B', 3: 'A', 4: 'S' }[bestRank]}` : '未评级'; const data = { summary: `《${SONGS[bestSong].title}》${gradeText} · 已解锁${p.unlocked}/3首`, unlocked: p.unlocked, grades: p.grades, bestCombo: p.bestCombo, tutorialDone: p.tutorialDone, schema: 1, updatedAt: new Date().toISOString(), }; try { const auth = JSON.parse(localStorage.getItem(AUTH_KEY)); if (!auth || !auth.token) return; fetch('/api/progress', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + auth.token, }, body: JSON.stringify({ game: 'rhythm', data }), }).catch(() => { /* 离线静默 */ }); } catch (_) { /* 静默 */ } } // ---------------- 工具 ---------------- /** * 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。 */ _speak(ids, onDone, _chainSeq, queue = false) { if (!ids.length) { if (onDone) onDone(); return; } const rid = this._runId; const seq = _chainSeq ?? ++this._voiceSeq; const [head, ...rest] = ids; this.audio.play(head, { queue, onComplete: () => { if (rid !== this._runId) return; this._speak(rest, onDone, seq, queue); }, }); } /** 带看门狗的语音链:链完或 guardMs 超时二者先到先触发 onDone 且只一次。 */ _speakGuarded(ids, onDone, guardMs = 10000, queue = false) { const rid = this._runId; const seq = ++this._voiceSeq; let fired = false; const fire = () => { if (fired || rid !== this._runId) return; if (seq !== this._voiceSeq) return; // 链已被更新的语音链取代:滞留看门狗作废 fired = true; if (onDone) onDone(); }; this._speak(ids, fire, seq, queue); setTimeout(fire, guardMs); } }