- 屏幕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
1166 lines
46 KiB
JavaScript
1166 lines
46 KiB
JavaScript
/**
|
||
* 节拍行者(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);
|
||
}
|
||
}
|