Files
SoundGame/web/rhythm/game.js
T
xsl ae850a4ce0 feat(rhythm): 鼓手模式重构——屏幕六区鼓组 + 无鼓伴奏合拍(用户裁定重构)
- 屏幕3列×2行六区鼓组(列=声像左中右/行=音高上亮下沉),六件程序合成打击乐,三重冗余蒙眼可辨
- 无鼓伴奏BGM上跟随领奏 call&response + 加花句;3曲90/105/120 BPM解锁制,BPM实测89.99/105.00/120.01
- 引擎后向兼容扩展:onGesture补{x,y}坐标(单参回调零感知,15游戏+3引擎测试全量回归零失败)
- 判定简化:全部音符=分区点按零延迟,旧双击补偿/下滑折中退役;键盘Digit1-6+空格=家区
- 教学摸鼓认区(摸哪报哪,8s无触摸自动进阶)+重教≤2+降速DDA+归因真话律
- 保留旧作好评:跟随领奏核心/S~C评级/解锁制/判定口径/guide工程遗产
- 语音49id双性别+8key;R1+R2连续2轮4/4愿意玩P0=0裁定可交付;live直证33 Perfect dt≈0.00s
- 03号文档仅文末加取代指针;新设计24号;档案research/rhythm-drummer/;Mureka累计$1.35
2026-09-14 20:17:10 +08:00

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