Files
SoundGame/web/rhythm/game.js
T
SoundGame Dev cc0f8c008d HTML5 重写:6 款游戏 + 用户系统 + 全局设置 + 5 轮迭代重构
- 新增 web/ 纯音频 HTML5 平台:共享引擎(手势/WebAudio 三层/设置/震动)、
  Node 零依赖服务器(静态 + 注册登录 + 进度存档 API)
- 游戏:剑与声(RPG 新作)、声命线、暗室(重设计)、音序回响(重设计)、
  节拍行者(跟随领奏重构)、盲航(同向闪避重构);声东击西删除
- 全局功能:男声/女声双套 TTS 资产、三档语速、BGM 音量三档、
  震动反馈语言(10 种震型)、服务器进度同步
- 资产:899 ogg + 899 m4a(Qwen3-TTS 双性别语音、Stable Audio 3 音效、
  Mureka BGM 4+3 首),终检零缺失
- 文档:docs/design/ 13 篇设计审查文档、docs/audio/ 资产管线、
  web/server/README.md API 文档
2026-08-29 21:55:44 +08:00

729 lines
26 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.
/**
* 节拍行者(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)。
*/
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', ...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;
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;
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';
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.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;
}
this._onTutGesture(g);
return;
case 'select':
this._onSelectGesture(rid, g);
return;
case 'main':
this._onMainGesture(rid, g);
return;
case 'menu':
this._onMenuGesture(rid, g);
return;
case 'end':
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:
return; // intro:不收手势
}
}
/** 教学阶段手势:全量记录尝试(评估用),乐器音照播(认识音色)。 */
_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());
this._speak(ids);
}
// ---------------- 进度存储 ----------------
_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);
}
}