Files
SoundGame/web/rhythm/game.js
T

913 lines
39 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)。
*
* 状态 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 演奏期):audio.setRateBoostEnabled(false)
* per-game 开关(引擎已支持),进 main 关、进菜单/结算/退出恢复。
* 向导层(docs/design/14-guide-layer.md + round2 synthesis F2.1/F3 + round3 G1~G3
* + round4 H5):
* 每曲首次领奏→应答切换在谱面定时队列直接落 gd_answer(不走 guide queue,保证
* 窗口前听到)+ turn earcon;round3 G1 修正排程点:落在首个应答窗口**前整整
* 1 小节**(= 领奏小节头;round2 排在窗口起点本身,P4 实测窗口开了才听到),
* 菜单恢复对下一个未判定窗口重排同款标记;教学 Miss 首次即归因分档
* (round4 H5 四档:无窗内尝试 gd_miss_none 优先、真出手再分 early/late/wrong,
* 挂纠正链尾空闲点;正片连 2 Miss 仍 gd_miss);连续 2 Miss 播 gd_miss(queue
* 插播,同曲一次);暂停断点续播前插 4 拍 count-in(round3 G3:底鼓 sfx 层可辨
* 音量 + full 档 gd_count 每局恢复限 1 次 + beat 震;round2 版因 push 后未重排
* _sched 被队头卡死从未按时响过;off 档保持即时续播);教学 DDA(round3 G2):
* 同一步连 2 次失败该步领奏降速 25% 重演并播 gd_slow(full 档、每教学局 1 次,
* off 档维持 retry_skip 原路径);结算链换 _speakGuarded(资产缺失不卡结算 BGM);
* 状态签名 earcon 与状态 BGM 同点(select=menu、main=battle、end=settle)。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.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.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
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._tutRate = 1.0; // G2:教学领奏速率(0.75 = 降速 25%,做对/离开该步恢复 1.0)
this._tutSlowUsed = false; // G2:降速重演每教学局 1 次封顶
// 菜单/暂停状态
this._menuIdx = 0;
this._pauseAt = 0;
this._bgmOffset = 0;
this._resumeSeq = 0; // G3:恢复令牌(count-in 语音期间再开菜单时作废旧恢复)
this._gdCountUsed = false; // G3:gd_count 每局(曲)恢复限 1 次
this._answerArmed = false; // G1:gd_answer+turn 交接标记是否已在 _timed 里待响
this._consecMiss = 0; // 连续 Miss 计数(向导 gd_miss:连 2 次提示一次)
}
// ---------------- 生命周期 ----------------
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();
this._tutRate = 1.0;
this._tutSlowUsed = false; // G2:每教学局重新封顶
this._gdCountUsed = false; // G3:每局(曲)恢复限 1 次
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.setRateBoostEnabled(true); // 离开演奏期,恢复长按快进
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 = [];
// G2(round3):降速只拉伸该步的领奏/应答网格(间隔 ×1/0.75),领奏小节头
// 仍对齐 BGM 小节;排程照走 _sched 绝对时间 + 40ms 泵,泵与判定无需感知速率。
const stepSpb = spb / this._tutRate;
for (const [beat, g] of TUT_STEPS[this._tutIdx].notes) {
const t = leadBar + (beat - 1) * stepSpb;
this._sched.push({ time: t, sfx: INST[g], volume: 1 });
this._cues.push({ gesture: g, expected: t + 4 * stepSpb, 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) {
this._tutRate = 1.0; // G2:该步做对,恢复正常速度
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) {
// G2(round3 教学降难 DDA):同一步连续 2 次失败 → 该步领奏降速 25% 重演
// (每教学局 1 次封顶;full 档先播 gd_slow「我放慢一些,跟着试试。」)——
// P4「六连错无出路」:失败后第一次有变慢的路,而不是直接跳步。封顶用尽
// 或 off 档(零改动红线)→ 维持 round2 的 retry_skip 跳步原路径。
if (this.guide.level() !== 'off' && !this._tutSlowUsed) {
this._tutSlowUsed = true;
this._tutRate = 0.75;
this._tutFails = 0; // 降速重演 = 该步重新给两次机会(再连 2 失败才跳步)
if (this.guide.level() === 'full') {
this._speakGuarded(['voice/gd_slow'], () => this._tutLead(rid));
} else {
this._tutLead(rid);
}
return;
}
this._tutRate = 1.0; // 离开该步(跳步):降速只作用于该步
// 同一步失败 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;
}
// F2.1(round2)+ H5(round4):教学 Miss 首次即归因、按失败原因分档。
// H5 归因真话律:应答窗口无任何尝试(窗口关闭时该音未判定——出窗的自由
// 演奏不算尝试)优先播 gd_miss_none「这拍没接」,早/晚/错分档只保留给
// 真出手的情况(P1 round3:没出手被报「慢了半拍」是归因说假话——"宁可粗
// 不可假";正片连 2 Miss 仍走 gd_miss,本档只改教学归因)。
// 向导句(full 档)挂在纠正链播完后的空闲点发:idle-only 语义下链中插播会被
// 跳过;之后 _tutLead 只排 sfx,不与归因句抢 voice 通道。
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' // none 优先级最高
: wrong ? 'gd_miss_wrong'
: offbeat ? (offbeat.dt < 0 ? 'gd_miss_early' : 'gd_miss_late') : 'gd_miss_late';
// 纠正语音:手势错 > 过早 > 过晚 > 没出手(retry_listen)
let correction = 'voice/retry_listen';
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.guide.error({ voice: missGrade, skipHaptic: true });
this._tutLead(rid);
});
}
// ---------------- 选曲 ----------------
_enterSelect(rid) {
if (this._runId !== rid) return;
this.phase = 'select';
this.audio.setRateBoostEnabled(true); // 离开演奏期,恢复长按快进
// 10 文档 §5:选曲界面 = menu pad(引擎层淡入 800ms;bgm=0 时引擎只记意图)
this.audio.play('bgm/menu', { fadeMs: 800 });
this.guide.state('menu'); // 状态签名:选曲界面(与状态 BGM 同点)
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); // 演奏期豁免长按快进(12 文档 §2.2)
// 10 文档 §5:选定曲目后停 menu pad(选曲/结算态可能在播),再按网格起曲目 BGM
this.audio.stopBgm(400);
this.phase = 'main';
this.guide.state('battle'); // 状态签名:演奏/对抗(与状态 BGM 切换同点)
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; // 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') });
}
if (i === 0) {
// 向导层(round3 G1):每次进 main,首个应答窗口**前整整 1 小节**(= 领奏
// 小节头)落下 gd_answer+turn 交接标记。round2 F3 的排程点(leadBarT0 +
// 4*spb)其实是窗口起点本身——P4 实测(窗口 t+79~95,gd_answer t+93.7)
// 窗口开了才听到,起不到「快轮到你了」的预警作用。gd_answer 不走 guide
// 的 queue(对白在播时会排到链尾);语音 full 档直达,turn earcon standard
// 也发(off 全静不变)。mid_song 同款先例。
this._answerArmed = true;
this._timed.push({ time: leadBarT0, fn: () => this._fireAnswerCue() });
}
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);
}
/** G1:gd_answer+turn 交接标记(语音 full 档直达,turn earcon standard 也发)。 */
_fireAnswerCue() {
this._answerArmed = false;
if (this.guide.level() === 'full') this.audio.play('voice/gd_answer');
this.guide.turn({});
}
/**
* G1:为「下一个未判定的应答窗口」排交接标记,落在窗口前整整 1 小节(= 该句
* 领奏小节头;已迫近则尽早在 0.15s 内补上)。_startSong 已为乐句 0 排过;菜单
* 恢复(也是一次「进 main」)时若旧标记已消费、且前面还有未判定的窗口,对
* 恢复后的首个窗口重排同款标记。上一句结束后的下一句不适用(裁定原文)。
*/
_armAnswerCue(rid) {
if (this._answerArmed) return; // 旧标记仍在 _timed 里,随网格平移照常提前 1 小节响
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();
}
// ---------------- 手势与判定 ----------------
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,链播继续到下一句)。
* 用引擎公开的 isVoicePlaying() 判定(原读私有 _voice,清点 §0)。
* @returns {boolean} true = 该手势已消费(跳过了一句)
*/
_trySkipVoice(g) {
if (g !== 'tap' || !this.audio.isVoicePlaying()) 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';
this._consecMiss = 0; // 打上了就断开连续 Miss 链
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'); // 错过=没出声(不播乐器音),震动是唯一反馈
// 向导层(schemes §2):连续 2 次 Miss 播 gd_miss(同曲限 1 次;已震过 error,
// skipHaptic 免重复震;voice 走 queue 插播,不顶 mid_song/结算链)
this._consecMiss++;
if (this._consecMiss === 2) {
this.guide.error({ voice: 'gd_miss', onceKey: 'rhythm_miss', skipHaptic: true });
}
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;
this.audio.setRateBoostEnabled(true); // 暂停期不是演奏期,恢复长按快进
this.guide.state('menu'); // 状态签名:菜单(模态暂停)
// 暂停:停泵、停 BGM(记断点),已排程的 ≤0.6s 音符自然播完
if (this._timer) { clearInterval(this._timer); this._timer = null; }
this._resumeSeq++; // G3:作废还在等 gd_count 播完的旧恢复
this._pauseAt = this.audio.ctx.currentTime;
// 断点续播偏移只在 BGM 真在播时刷新(count-in 语音期间再开菜单时 BGM 未起,
// 沿用首次开的偏移,避免把暂停时长算进播放位置)
if (this._bgmSrc) 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;
this.phase = 'main';
this.audio.setRateBoostEnabled(false); // 回到演奏期
const token = ++this._resumeSeq; // G3:恢复令牌(语音等待期间再开菜单则作废)
const resume = () => {
if (this._runId !== rid || token !== this._resumeSeq) return;
const resumeAt = this.audio.ctx.currentTime + 0.1;
// G3(round3):断点续播前插一整小节 4 拍 count-in——底鼓走 sfx 层独立于
// BGM、音量提到可辨(0.7→1.0),每拍带 beat 轻震(_sched 泵同步排),网格
// 与 BGM 整体后移一小节,杜绝 P3 实测的「回来 5 秒死寂、第一句报废」。
// round2 F3 的 count-in 实际从未按时响过:底鼓 push 进 _sched 后没重排,
// 队头是数秒外的领奏音符,40ms 泵的 0.6s 前瞻窗口永远轮不到它——本次修复
// push 后 _sortSched()。off 档保持原即时续播(零改动路径)。
const countIn = this.guide.level() !== 'off' ? 4 * (60 / this._song.bpm) : 0;
const bgmAt = resumeAt + countIn;
const shift = bgmAt - this._pauseAt;
// 全部绝对时刻平移(暂停时长 + count-in 小节),节拍网格与 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;
for (let i = 0; i < 4 && countIn > 0; i++) {
this._sched.push({ time: resumeAt + i * (countIn / 4), sfx: INST.tap, volume: 1.0 });
}
this._sortSched(); // G3 修复:count-in 必须重排入队,否则被队头阻塞整段迟到
// _startBgm 内部重算 _bgmT0 = bgmAt - offset = 旧网格 + shift(负偏移保护同款成立)
this._startBgm(this._song, bgmAt, this._bgmOffset);
this._startPump(rid);
this._armAnswerCue(rid); // G1:菜单恢复也是一次「进 main」,给下一个窗口补交接标记
};
// G3:gd_count「四拍之后,继续。」full 档、每局(曲)恢复限 1 次,在 count-in
// 之前经语音链串行播出(链完或缺失看门狗兜底后才开始数拍)。
if (this.guide.level() === 'full' && !this._gdCountUsed) {
this._gdCountUsed = true;
this._speakGuarded(['voice/gd_count'], resume);
} else {
resume();
}
}
_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';
this.guide.state('settle'); // 状态签名:结算(与结算 BGM 同点)
this.audio.setRateBoostEnabled(true); // 离开演奏期,恢复长按快进
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;
// 链换 _speakGuarded(清点 §4.6 缺口):grade_* 等资产缺失也能起结算 BGM
this._speakGuarded(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);
}
}