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 文档
This commit is contained in:
SoundGame Dev
2026-08-29 21:55:44 +08:00
parent f0f0a9b50e
commit cc0f8c008d
1885 changed files with 11246 additions and 5 deletions
+728
View File
@@ -0,0 +1,728 @@
/**
* 节拍行者(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);
}
}