/** * 节拍行者(rhythm)R3 —— 跟随领奏(call & response)节奏游戏 * (按 docs/design/03-rhythm-rebuild.md 实现;语音 id 见 03-rhythm-voice-ids.md)。 * * 玩法:系统奏 1 小节节奏型(2-4 个手势乐器音,inst_* 踩拍点),玩家在下一小节 * 的相同拍点用相同手势跟打。3 首曲目(90/105/120 BPM)解锁制:曲目 n 拿到 B 级 * 以上解锁 n+1(localStorage 记解锁状态,已登录同步服务器)。 * * 判定(施工契约 §2): * Perfect ±0.12s(手势乐器音 + inst_accent 亮音叠加) * Good ±0.25s(只播乐器音;蓝牙延迟由此吸收) * Miss 窗口内手势错 / 超时应答缺失:不播乐器音(错过=没出声)+ error 震动 * tap 判定时刻 -0.30s(TAP_DELAY 引擎延迟补偿);tap Miss 超时放宽 TAP_DELAY+0.08 * 计分: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 即发即弃)。 * * 震动落点(手势识别震动引擎内置,不重复):领奏每音 beat 轻震、Miss error、 * 16 连击 success、乐句全 Perfect [20,40,20]、解锁新曲 success+[20,40,20]、 * 结算 S/A success。 * * 菜单(round2-review 全球约定):下滑呼出(模态暂停,BGM 断点续播)→ * 单击循环 → 双击确认 → 上滑帮助。下滑与"下滑=低音 tom"冲突的折中: * 仅当无悬置/临近的 down 音符时下滑才呼出菜单(见 README)。 * * 状态 BGM(docs/design/10-feedback2-state-bgm.md §5): * 选曲界面 = bgm/menu(引擎层淡入 800ms);选定曲目 stopBgm(400) 后按既有 * 网格起 track90/105/120(手动排程不变);暂停菜单保持静默(断点续播, * 不叠 menu pad);结算评级语音链播完后淡入 bgm/menu 800ms。 * 旁白跳过(docs/design/12-skip-and-longpress-ux.md §1.2):intro/tut/select/ * end 阶段 voice 播放中单击 = audio.skipVoice()(跳下一句,链不断); * main 演奏阶段单击是演奏手势不跳(mid_song 短句按裁定不跳)。 * 长按快进豁免(12 文档 §2.2:main+tut 应豁免):引擎当前无 per-game * 开关(audio.js 全局监听 soundgame:longpress),记为已知限制,不改引擎。 */ import { Game } from '../engine/game.js'; import { vibrate } from '../engine/haptics.js'; // ---- 判定与调度常量(03 文档 §2 施工契约) ---- const PERFECT = 0.12; const GOOD = 0.25; const TAP_DELAY = 0.30; const LOOKAHEAD = 0.6; const PUMP_MS = 40; // ---- 手势 → 乐器音映射(03 文档 §4;accent 为 Perfect 叠加层) ---- const INST = { tap: 'sfx/inst_kick', doubletap: 'sfx/inst_snare', left: 'sfx/inst_hat_l', right: 'sfx/inst_hat_r', up: 'sfx/inst_tom_hi', down: 'sfx/inst_tom_lo', }; const ACCENT = 'sfx/inst_accent'; // ---- 谱面(03 文档 §3 表格直转;restAfter = 每句应答后休止小节数) ---- const SONGS = [ { id: 'song1', title: '启程', bpm: 90, bgm: 'bgm/track90', nameVoice: 'voice/song1_name', unlockVoice: null, restAfter: [1, 1, 1, 1, 0, 0, 0, 0, 0, 0], phrases: [ [[1, 'tap'], [3, 'tap']], [[1, 'left'], [3, 'right']], [[1, 'tap'], [2, 'doubletap'], [3, 'tap']], [[1, 'left'], [2, 'doubletap'], [3, 'right']], [[1, 'tap'], [3, 'left'], [4, 'doubletap']], [[1, 'right'], [3, 'tap'], [4, 'doubletap']], [[1, 'up'], [3, 'down'], [4, 'doubletap']], [[1, 'tap'], [2, 'up'], [3, 'right'], [4, 'doubletap']], [[1, 'left'], [2, 'right'], [3, 'tap'], [4, 'doubletap']], [[1, 'tap'], [2, 'doubletap'], [3, 'tap'], [4, 'doubletap']], ], }, { id: 'song2', title: '追风', bpm: 105, bgm: 'bgm/track105', nameVoice: 'voice/song2_name', unlockVoice: 'voice/unlock_song2', restAfter: [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], phrases: [ [[1, 'tap'], [3, 'right']], [[1, 'left'], [2, 'doubletap'], [3, 'tap']], [[1, 'up'], [3, 'down'], [4, 'doubletap']], [[1, 'tap'], [2, 'right'], [3, 'left'], [4, 'doubletap']], [[1, 'tap'], [2.5, 'right'], [3, 'tap']], [[1, 'left'], [2, 'up'], [3.5, 'right'], [4, 'doubletap']], [[1, 'tap'], [2, 'doubletap'], [3, 'down'], [3.5, 'up']], [[1, 'right'], [1.5, 'left'], [3, 'tap'], [4, 'doubletap']], [[1, 'tap'], [2.5, 'left'], [3, 'tap'], [4, 'doubletap']], [[1, 'up'], [2, 'right'], [3, 'down'], [3.5, 'left']], [[1, 'tap'], [2, 'doubletap'], [2.5, 'right'], [3, 'tap']], [[1, 'left'], [1.5, 'right'], [2, 'doubletap'], [3, 'tap'], [4, 'doubletap']], ], }, { id: 'song3', title: '星爆', bpm: 120, bgm: 'bgm/track120', nameVoice: 'voice/song3_name', unlockVoice: 'voice/unlock_song3', restAfter: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], phrases: [ [[1, 'tap'], [2, 'doubletap'], [3, 'tap'], [4, 'doubletap']], [[1, 'left'], [2, 'right'], [3, 'left'], [4, 'doubletap']], [[1, 'up'], [2, 'down'], [3, 'up'], [4, 'doubletap']], [[1, 'tap'], [2.5, 'left'], [3, 'tap'], [4, 'right']], [[1, 'left'], [1.5, 'right'], [2.5, 'left'], [3, 'tap']], [[1, 'tap'], [2, 'up'], [2.5, 'down'], [3, 'tap'], [4, 'doubletap']], [[1, 'right'], [2, 'doubletap'], [3, 'left'], [3.5, 'right'], [4, 'up']], [[1, 'tap'], [1.5, 'doubletap'], [2.5, 'left'], [3, 'tap'], [3.5, 'right'], [4, 'doubletap']], [[1, 'left'], [2, 'doubletap'], [2.5, 'up'], [3, 'right'], [4, 'doubletap']], [[1, 'tap'], [2.5, 'down'], [3, 'tap'], [3.5, 'up'], [4, 'doubletap']], [[1, 'right'], [1.5, 'left'], [2, 'right'], [3, 'tap'], [4, 'doubletap']], [[1, 'tap'], [2, 'doubletap'], [2.5, 'left'], [3, 'tap'], [3.5, 'right'], [4, 'doubletap']], ], }, ]; // 教学 3 步(T 组台词;T1 单底鼓 → T2 左右 → T3 三音带双击) const TUT_STEPS = [ { voice: 'voice/tut_1', notes: [[1, 'tap']] }, { voice: 'voice/tut_2', notes: [[1, 'left'], [3, 'right']] }, { voice: 'voice/tut_3', notes: [[1, 'tap'], [2, 'doubletap'], [3, 'tap']] }, ]; const ALL_SFX = [...Object.values(INST), 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.rhythm.progress'; const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html) export class RhythmGame extends Game { constructor(audio, root) { super(audio, root); this.phase = 'idle'; // idle | intro | tut | select | main | menu | end this._runId = 0; // 重开/切歌时令旧的语音链/调度回调失效 this._timer = null; this._sched = []; // 待排程领奏事件 { time, sfx, volume } this._timed = []; // 定时动作 { time, fn }(mid_song 等) this._cues = []; // 应答期望 { gesture, expected, judged, result, phrase } this._bgmSrc = null; this._bgmGain = null; this._bgmT0 = 0; // 当前 BGM 网格起点(ctx 时间) this._song = null; // 当前曲目对象 this._songIdx = 0; this._stats = null; this._progress = this._loadProgress(); // 教学状态 this._tutIdx = 0; this._tutFails = 0; this._tutAttempts = []; // 菜单/暂停状态 this._menuIdx = 0; this._pauseAt = 0; this._bgmOffset = 0; } // ---------------- 生命周期 ---------------- start() { this.audio.prewarm([...ALL_SFX, 'voice/welcome', 'voice/welcome_back', 'voice/tut_1', 'voice/select_hint', 'bgm/menu', ...SONGS.map((s) => s.bgm)]); const rid = ++this._runId; this._cleanup(); if (this._progress.tutorialDone) { // 二周目起:缩减开场 → 选曲 this.phase = 'intro'; this._speakGuarded(['voice/welcome_back'], () => this._enterSelect(rid)); } else { // 首次:welcome + BGM(第 6 秒起)→ 教学 T1 this.phase = 'intro'; this._startBgm(SONGS[0], this.audio.ctx.currentTime + 6.0); // 教学用曲目一 this._speakGuarded(['voice/welcome'], () => this._beginTutorial(rid, 0), 8000); } } stop() { this._runId++; this._cleanup(); this.audio.stopAll(); } _cleanup() { if (this._timer) { clearInterval(this._timer); this._timer = null; } 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; } } // ---------------- 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; // 暂停发生在 BGM 起点前时 offset 为负:推迟起点而非传负偏移 // (Web Audio 的 start(when, 负 offset) 会抛 IndexSizeError) 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; // 网格起点(offset 用于断点续播) src.start(t0, offset % buf.duration); } // ---------------- 节拍调度泵 ---------------- /** 在绝对时间 when 播放 sfx(走引擎 sfx 总线,保持音量/duck 语义)。 */ async _scheduleSfx(id, when, volume = 1) { 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); g.connect(this.audio._sfxBus); src.start(when); } _pump(rid) { if (this._runId !== rid || !this.audio.ctx) return; const now = this.audio.ctx.currentTime; // 排程未来 LOOKAHEAD 内的领奏音符(同步排一个对齐的 BEAT 轻震) while (this._sched.length && this._sched[0].time <= now + LOOKAHEAD) { const ev = this._sched.shift(); this._scheduleSfx(ev.sfx, ev.time, ev.volume); const delay = Math.max(0, (ev.time - now) * 1000); setTimeout(() => { if (this._runId === rid) vibrate('beat'); }, delay); } // 定时动作(mid_song 等) while (this._timed.length && this._timed[0].time <= now) { const ev = this._timed.shift(); ev.fn(); } if (this.phase === 'main') { // Miss 超时检查:tap 指令回调天然晚 ~300ms 到达,放宽同样余量 for (const cue of this._cues) { if (cue.judged) continue; const margin = cue.gesture === 'tap' ? TAP_DELAY + 0.08 : 0.05; if (now > cue.expected + GOOD + margin) this._applyMiss(cue); } if (now >= this._phaseEnd) this._finish(rid); } else if (this.phase === 'tut') { 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); } // ---------------- 教学(T1-T3) ---------------- _beginTutorial(rid, idx) { if (this._runId !== rid) return; this._tutIdx = idx; this._tutFails = 0; this._speakGuarded([TUT_STEPS[idx].voice], () => this._tutLead(rid)); } /** 播教学领奏(对齐 BGM 网格的下一小节),应答 = 领奏 + 4 拍。 */ _tutLead(rid) { if (this._runId !== rid) return; this.phase = 'tut'; const song = SONGS[0]; // 教学在曲目一 BGM 上进行 const spb = 60 / song.bpm; const now = this.audio.ctx.currentTime; // 下一小节头作为领奏小节(至少留 1.2s 缓冲,且不早于 BGM 网格起点) const leadBar = this._bgmT0 + Math.max(0, Math.ceil((now + 1.2 - this._bgmT0) / (4 * spb))) * 4 * spb; this._tutAttempts = []; this._cues = []; for (const [beat, g] of TUT_STEPS[this._tutIdx].notes) { const t = leadBar + (beat - 1) * spb; this._sched.push({ time: t, sfx: INST[g], volume: 1 }); this._cues.push({ gesture: g, expected: t + 4 * spb, judged: false, phrase: -1 }); } this._sortSched(); const last = this._cues[this._cues.length - 1]; this._phaseEnd = last.expected + GOOD + TAP_DELAY + 0.4; this._startPump(rid); } /** 教学应答评估:全部音在窗口内用对手势 → 通过;否则纠正重教。 */ _tutEvaluate(rid) { if (this._runId !== rid || this.phase !== 'tut') return; // 评估是一次性的:先废掉触发条件——通过/失败后都要播语音, // 语音间隙期 phase 仍是 'tut' 且 now ≥ _phaseEnd,泵会每 40ms 重进这里, // 否则一次通过会级联"连过"剩余步骤(trace 实测:T1 通过直接跳到结算)。 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) { if (this._tutIdx + 1 < TUT_STEPS.length) { this._beginTutorial(rid, this._tutIdx + 1); } else { this._progress.tutorialDone = true; this._saveProgress(); this._speakGuarded(['voice/tut_done'], () => this._startSong(rid, 0)); } return; } this._tutFails++; vibrate('error'); if (this._tutFails >= 2) { // 同一步失败 2 次跳过,不卡流程 this._speakGuarded(['voice/retry_skip'], () => { if (this._tutIdx + 1 < TUT_STEPS.length) this._beginTutorial(rid, this._tutIdx + 1); else { this._progress.tutorialDone = true; this._saveProgress(); this._speakGuarded(['voice/tut_done'], () => this._startSong(rid, 0)); } }); return; } // 纠正语音:手势错 > 过早 > 过晚 > 没出手(retry_listen) let correction = 'voice/retry_listen'; const wrong = this._tutAttempts.find((a) => !a.correct); const offbeat = this._tutAttempts.find((a) => a.correct && Math.abs(a.dt) > GOOD); if (wrong) correction = 'voice/retry_gesture'; else if (offbeat) correction = offbeat.dt < 0 ? 'voice/retry_early' : 'voice/retry_late'; this._speakGuarded([correction, 'voice/retry_listen'], () => this._tutLead(rid)); } // ---------------- 选曲 ---------------- _enterSelect(rid) { if (this._runId !== rid) return; this.phase = 'select'; // 10 文档 §5:选曲界面 = menu pad(引擎层淡入 800ms;bgm=0 时引擎只记意图) this.audio.play('bgm/menu', { fadeMs: 800 }); 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(); // 10 文档 §5:选定曲目后停 menu pad(选曲/结算态可能在播),再按网格起曲目 BGM this.audio.stopBgm(400); this.phase = 'main'; this._songIdx = songIdx; this._song = SONGS[songIdx]; this._stats = { perfect: 0, good: 0, miss: 0, combo: 0, maxCombo: 0 }; const spb = 60 / this._song.bpm; const now = this.audio.ctx.currentTime; const bgmT0 = now + 1.0; // 1s 后 BGM 起,第 1 小节即数拍 this._startBgm(this._song, bgmT0); // 排程:数拍 1 小节(BGM 内鼓点)→ 逐乐句(领奏 1 小节 + 应答 1 小节 + 休止) let bar = 1; this._cues = []; const midPhrase = Math.floor(this._song.phrases.length / 2); this._song.phrases.forEach((notes, i) => { const leadBarT0 = bgmT0 + bar * 4 * spb; for (const [beat, g] of notes) { const t = leadBarT0 + (beat - 1) * spb; this._sched.push({ time: t, sfx: INST[g], volume: 1 }); this._cues.push({ gesture: g, expected: t + 4 * spb, judged: false, result: null, phrase: i }); } if (i === midPhrase) { this._timed.push({ time: leadBarT0, fn: () => this.audio.play('voice/mid_song') }); } bar += 2 + (this._song.restAfter[i] || 0); }); this._sortSched(); const last = this._cues[this._cues.length - 1]; this._phaseEnd = last.expected + GOOD + TAP_DELAY + 0.5; this._startPump(rid); } // ---------------- 手势与判定 ---------------- onGesture(g) { const rid = this._runId; switch (this.phase) { case 'tut': if (g === 'down') { // 下滑 = 跳过教学(T1-T3 均不含 down,无冲突) this._progress.tutorialDone = true; this._saveProgress(); this._speakGuarded(['voice/tut_skip'], () => this._startSong(rid, 0)); return; } // 教学语音播放中单击 = 跳过本句(12 文档 §1.2);无语音时才是跟打手势 if (this._trySkipVoice(g)) return; this._onTutGesture(g); return; case 'select': if (this._trySkipVoice(g)) return; // 选曲播报可逐句跳过 this._onSelectGesture(rid, g); return; case 'main': // 演奏期单击是演奏手势,永不跳过(mid_song 短句按裁定不可跳) this._onMainGesture(rid, g); 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']); // 已是最后一首(策划裁定 round3-4-review) else this._speak(['voice/song_locked']); } else if (g === 'down') this._goHome(); return; default: this._trySkipVoice(g); // intro:只收"单击跳旁白",其余手势不收 return; } } /** * voice 播放中单击 = 跳过当前句(12 文档 §1.2,链播继续到下一句)。 * 引擎暂无"voice 播放中"公开查询接口,读内部 _voice 令牌判定。 * @returns {boolean} true = 该手势已消费(跳过了一句) */ _trySkipVoice(g) { if (g !== 'tap' || !this.audio._voice) return false; this.audio.skipVoice(); return true; } /** 教学阶段手势:全量记录尝试(评估用),乐器音照播(认识音色)。 */ _onTutGesture(g) { if (!INST[g] || !this.audio.ctx) return; this.audio.play(INST[g]); let t = this.audio.ctx.currentTime; if (g === 'tap') t -= TAP_DELAY; let best = null; let bestDt = Infinity; for (const cue of this._cues) { const dt = Math.abs(t - cue.expected); if (dt < bestDt) { bestDt = dt; best = cue; } } if (best) { this._tutAttempts.push({ cue: best, gesture: g, dt: t - best.expected, correct: g === best.gesture }); } } _onMainGesture(rid, g) { if (!this.audio.ctx) return; // 下滑折中:有悬置/临近的 down 音符时是演奏,否则呼出菜单 if (g === 'down' && !this._downNotePending()) { this._openMenu(rid); return; } if (!INST[g]) return; let t = this.audio.ctx.currentTime; if (g === 'tap') t -= TAP_DELAY; // 补偿引擎 tap 回调延迟 // 归属:期望时刻最近的未判定音符(现版逻辑保留) let best = null; let bestDt = Infinity; for (const cue of this._cues) { if (cue.judged) continue; const dt = Math.abs(t - cue.expected); if (dt < bestDt) { bestDt = dt; best = cue; } } if (!best || bestDt > GOOD) { this.audio.play(INST[g]); // 窗口外自由演奏:出声音符但不判定 return; } if (g !== best.gesture) { this._applyMiss(best); // 窗口内做错动作 = Miss(不播乐器音) return; } // Perfect / Good:出手即播乐器音(演奏感),Perfect 叠 accent 亮音 const perfect = bestDt <= PERFECT; this.audio.play(INST[g]); if (perfect) this.audio.play(ACCENT, { volume: 0.8 }); this._applyHit(best, perfect); } /** 当前有无悬置或临近(1s 内)的 down 应答音符。 */ _downNotePending() { const now = this.audio.ctx.currentTime; return this._cues.some((cue) => !cue.judged && cue.gesture === 'down' && cue.expected > now - GOOD - TAP_DELAY && cue.expected < now + 1.0); } _applyHit(cue, perfect) { if (cue.judged) return; cue.judged = true; cue.result = perfect ? 'perfect' : 'good'; const s = this._stats; 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'); // 16 连击 this._checkPhraseAllPerfect(cue.phrase); } _applyMiss(cue) { if (cue.judged) return; cue.judged = true; cue.result = 'miss'; this._stats.miss++; this._stats.combo = 0; vibrate('error'); // 错过=没出声(不播乐器音),震动是唯一反馈 this._checkPhraseAllPerfect(cue.phrase); } /** 乐句全部 Perfect:乐句末震 [20,40,20]。 */ _checkPhraseAllPerfect(phraseIdx) { if (phraseIdx < 0) return; const notes = this._cues.filter((c) => c.phrase === phraseIdx); if (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; // 暂停:停泵、停 BGM(记断点),已排程的 ≤0.6s 音符自然播完 if (this._timer) { clearInterval(this._timer); this._timer = null; } this._pauseAt = this.audio.ctx.currentTime; this._bgmOffset = this._pauseAt - this._bgmT0; this._stopBgmSrc(); this._speak(['voice/menu_main', MENU_ITEMS[0]]); } _onMenuGesture(rid, 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/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; const resumeAt = this.audio.ctx.currentTime + 0.1; const shift = resumeAt - this._pauseAt; // 全部绝对时刻平移暂停时长,节拍网格与 BGM 断点续播保持同步 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; // _startBgm 内部重算 _bgmT0 = resumeAt - offset = 旧网格 + shift this._startBgm(this._song, resumeAt, this._bgmOffset); this.phase = 'main'; this._startPump(rid); } _goHome() { this.stop(); window.location.href = '../'; } // ---------------- 结算 ---------------- _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'; if (this._timer) { clearInterval(this._timer); this._timer = null; } this._sched = []; this._timed = []; // BGM 淡出 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'); } // 解锁:曲目 n ≥ B → 解锁 n+1 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()); // 10 文档 §5:曲目 BGM 已 0.6s 淡出,结算语音链播完后淡入 menu pad 800ms this._speak(ids, () => this.audio.play('bgm/menu', { fadeMs: 800 })); } // ---------------- 进度存储 ---------------- _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, }; } } catch (_) { /* 损坏则重置 */ } return { tutorialDone: false, unlocked: 1, grades: {}, bestCombo: 0 }; } _saveProgress() { try { localStorage.setItem(PROGRESS_KEY, JSON.stringify(this._progress)); } catch (_) { /* 隐私模式 */ } } /** PUT /api/progress(未登录/离线静默跳过)。summary 取最高评级曲目。 */ _putProgress() { const p = this._progress; // 最高评级的曲目作为 summary 主项 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: `曲目${bestSong + 1} ${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) { if (!ids.length) { if (onDone) onDone(); return; } const rid = this._runId; const [head, ...rest] = ids; this.audio.play(head, { onComplete: () => { if (rid !== this._runId) return; this._speak(rest, onDone); }, }); } /** * 带看门狗的语音链:链自然播完或 guardMs 超时(语音缺失/解码失败), * 二者先到先触发 onDone 且只触发一次——保证流程绝不卡死。 */ _speakGuarded(ids, onDone, guardMs = 10000) { const rid = this._runId; let fired = false; const fire = () => { if (fired || rid !== this._runId) return; fired = true; if (onDone) onDone(); }; this._speak(ids, fire); setTimeout(fire, guardMs); } }