/** * 节拍行者(rhythm)鼓手模式 —— 按 docs/design/26-audio-interaction-playbook.md 深度重构 * (设计单 docs/design/26-apply-rhythm.md;玩法权威 docs/design/24-rhythm-drummer.md, * 语音 id 见 24-rhythm-voice-ids.md + tools/voice_lines_26/rhythm.md)。 * * 玩法核心不动:3×2 分区鼓组、领奏/应答一小节轮换、Perfect ±0.15 / Good ±0.35、 * Miss = GOOD+0.10 死线或异分区半窗内被敲、S~C 评级(rate = (P*2+G)/(2×总音符))、 * B 级解锁下一首、无鼓伴奏 BGM、AudioContext.currentTime 预排程 + 40ms 泵 + * LOOKAHEAD 0.6s 前瞻;判定时刻 = 触摸/数字键按下(immediateTap 零补偿), * tapcancel 撤销序曲 tap 的判定认领(ghost 音收不回,统计回滚且不计 Miss)。 * * 交互层(26 号模式,六模块接线,参考实现 web/simon/game.js): * - 发言权 Floor:演奏期(摸鼓探针 / 教学跟打应答窗之外的正片全曲)= ACTION, * 点按即敲、无等待声(24 号 A27);演出(教学指令 SPEAKING critical、数拍与 * 领奏示范小节 BUSY)手势只给 busy 回执;选曲 / 结算 / 菜单 / 续玩问询 = * YOUR_TURN(交接提示音 + 等待声 + 等待点)。 * - 教学跟打等待点:应答窗开时 yieldTurn(交接提示音响)但 bedEnabled=false * (等待声不响——演奏语义);三档提示 full→short→提示音;迷茫三级接管 * (L1 短句 → L2 只留 tap → L3 听我做一次:游戏把本步音符敲一遍再交回)。 * - 输入回执:敲鼓 = 内容音(silent-ok,省 ok 回执);错鼓 / 非音符滑动 = * invalid;演出与关键句 = busy。无效操作解释一次(原则 7):演奏态只给回执 * 不解释(应答内零语音),等待态首次附一句。 * - 长按 = 状态查询(原则 3:当前曲目段落 / 连击,一句话 ≤20 字);演奏期 * audio.setRateBoostEnabled(false) 豁免长按快进(既有约定保留)。 * - 失败表(§4.9):归因真话(没出手→gd_miss_none;错鼓→retry_wrong+gd_miss_wrong; * 早/晚→retry_early/late),feedback 类 2 秒内播出、应答小节内播不出就丢; * 教学 2 败降速 25%(每局 1 次),3 败跳步不卡死;正片连 2 Miss 才 gd_miss * 一次(曲尾 3s 不插)。 * - 手势阶梯(novice.js):第一次成功跟打 onGoal() 解锁上滑;一首曲子打完 * onGameDone() 解锁下滑(菜单)/ 双击(重听)。 * - 断点复述(§4.12):进曲写 progress.lastSong,回来 sys_continue_ask 问 * 继续 / 重选;暂停恢复 = 全网格平移 + 4 拍 count-in(guide full 先播 * gd_count,每曲 1 次)。 * * 状态 × 手势矩阵(新手期;详见设计单 §4): * | 状态 \ 手势 | 单击(分区) | 左右滑 | 上滑 | 下滑 | 长按 | 双击(未解锁) | 连点 | * |--------------------|-------------------|--------------|----------|----------------|----------|--------------|---------------| * | 开场指令(SPEAKING) | busy | busy | busy | busy | busy | busy | busy | * | 摸鼓探针(ACTION) | 敲区出声+首摸报名 | invalid 回执 | invalid | invalid | 状态句 | 敲区 | 每下都出声 | * | 指令/数拍/示范(BUSY)| busy | busy | busy | busy | busy | busy | busy | * | 跟打应答窗(YOUR_TURN)| 敲区判定/错鼓归因 | invalid | 状态句 | invalid | 状态句 | 敲区 | 合法连击 | * | 正片(ACTION) | 敲区判定/自由演奏 | invalid | 状态句 | 阶段2=菜单 | 状态句 | 敲区 | 合法连击 | * | 选曲(YOUR_TURN) | 开始/未解锁解释 | 切曲报名 | 帮助 | 阶段2=回大厅 | 帮助 | 折算单击 | 400ms busy | * | 菜单(YOUR_TURN) | 确认当前项 | 换项报名 | 重听说明 | 关闭恢复 | 重听说明 | 折算单击 | 400ms busy | * | 结算(YOUR_TURN) | 同曲再来 | 右=下一首 | 重听评级 | 阶段2=回大厅 | 重听评级 | 折算单击 | 只处理第一下 | */ import { Game } from '../engine/game.js'; import { Floor } from '../engine/floor.js'; import { Receipts } from '../engine/receipts.js'; import { Confusion } from '../engine/confusion.js'; import { WaitPoint, InvalidHints, markHintSaid, hintSaidRecently } from '../engine/hints.js'; import { GestureLadder } from '../engine/novice.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 摸鼓认区之后):voice=指令句(critical ≤12 字); // echoFull/echoShort=跟打等待点三档提示(24 号 §0.5)。 const TUT_STEPS = [ { voice: 'voice/tut_z_kick', echoFull: 'voice/tut_kick_full', echoShort: 'voice/tut_kick_short', notes: [[1, 'BC']], }, { voice: 'voice/tut_z_sides', echoFull: 'voice/tut_sides_full', echoShort: 'voice/tut_sides_short', notes: [[1, 'BL'], [3, 'BR']], }, { voice: 'voice/tut_z_patt', echoFull: 'voice/tut_top_full', echoShort: 'voice/tut_top_short', notes: [[1, 'TL'], [2, 'TC'], [3, 'TR']], }, ]; // 长按状态查询(原则 3,一句话 ≤20 字):曲目+段落 / 连击档(整句预生成,不拼接) const ST_POS = { song1: ['voice/st_p1a', 'voice/st_p1b'], song2: ['voice/st_p2a', 'voice/st_p2b'], song3: ['voice/st_p3a', 'voice/st_p3b'], }; 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); // ---- 26 号六模块接线(对齐 simon) ---- this.receipts = new Receipts(audio); this.floor = new Floor(audio); this.confusion = new Confusion({ // 三级接管交给当前等待点;演奏期(ACTION,无等待点)不接管——玩法即反馈 onConfused: (lvl) => { const wp = this.floor.waitPoint; if (wp) wp.takeover(lvl); }, }); this.invalidHints = new InvalidHints().bind(this.floor); this.ladder = new GestureLadder(); this.phase = 'idle'; // idle | intro | tut | select | main | menu | end | leaving this._runId = 0; this._pumpTimer = null; this._flowTimers = []; // 流程看门狗/演示等 setTimeout 账本(cleanup 清) 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 | idle(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._goalGiven = false; // 第一次成功跟打 → ladder.onGoal(上滑解锁) this._selectHintSaid = false; // select_hint 每会话只说一次(原则 9) // 菜单/暂停状态 this._menuIdx = 0; this._pauseAt = 0; this._bgmOffset = 0; this._resumeSeq = 0; this._gdCountUsed = false; this._answerArmed = false; this._consecMiss = 0; this._missVoiceSaid = false; // gd_miss 同曲一次 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; // 旧语音链序(_speak 遗留,占位防误用) this._flowSeq = 0; // 流程链序(新 _speakFlow) this._reopenMode = false; // 断点复述问询中 this._lastAnnounce = null; // 最近播报句(阶段 3 双击/上滑重听用) // 键盘平权(Digit1-6);mock 环境 window.addEventListener 为 no-op,安全 if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { window.addEventListener('keydown', (e) => this._onKeyDown(e)); } } /** 管道选项:长按是本作核心手势(状态查询/帮助);上下滑按阶梯锁(菜单/查询另有长按)。 */ get PIPELINE_OPTS() { return { longpressCore: true }; } // ---------------- 生命周期 ---------------- start() { this.audio.prewarm([...ALL_SFX, 'bgm/menu', 'voice/sys_first_tap', 'voice/welcome_back', 'voice/sys_continue_ask', 'voice/sys_turn_teach', 'voice/sys_say_again', 'voice/tut_probe', 'voice/tut_ok', 'voice/tut_done', 'voice/retry_early', 'voice/retry_late', 'voice/retry_wrong', 'voice/retry_listen', 'voice/retry_skip', 'voice/select_hint', 'voice/select_full', 'voice/select_short', 'voice/settle_full', 'voice/settle_short', 'voice/rules', 'voice/menu_main', 'voice/menu_help', 'voice/menu_resume', 'voice/menu_lobby', 'voice/restart', 'voice/mid_song', 'voice/tut_fill', ...TUT_STEPS.flatMap((s) => [s.voice, s.echoFull, s.echoShort]), ...SONGS.map((s) => s.bgm), ...SONGS.map((s) => s.nameVoice)]); const rid = ++this._runId; this._cleanup(); this._tutRate = 1.0; this._tutSlowUsed = false; this._gdCountUsed = false; this._tutPassedAny = false; this._goalGiven = false; this._probeHeard = new Set(); this._goingHome = false; this._reopenMode = false; this._selectHintSaid = false; if (this._progress.tutorialDone) { this.phase = 'intro'; if (this._progress.lastSong !== null) { // 断点复述(§4.12):上次的曲子没打完 → 问继续还是重选 this._reopenMode = true; this._speakFlow(['voice/sys_continue_ask'], { guardMs: 6000, onDone: () => this.floor.yieldTurn(this._reopenWp(rid)), }); } else { this._speakFlow(['voice/welcome_back'], { guardMs: 6000, onDone: () => this._enterSelect(rid), }); } } else { // §4.10 首 3 分钟:第一句就是 critical 指令句,让玩家动手(≤3s) this.phase = 'intro'; this._startBgm(SONGS[0], this.audio.ctx.currentTime + 6.0); // 教学在曲目一网格上进行 this.floor.speak(['voice/sys_first_tap'], { critical: true, onDone: () => this._beginProbe(rid), }); } } stop() { this._runId++; this._cleanup(); this.audio.setRateBoostEnabled(true); this.audio.stopAll(); } _cleanup() { if (this._pumpTimer) { clearInterval(this._pumpTimer); this._pumpTimer = null; } if (this._pendingTap) { clearTimeout(this._pendingTap); this._pendingTap = null; } this._clearProbeTimer(); if (this._midDuckTimer) { clearTimeout(this._midDuckTimer); this._midDuckTimer = null; } this._duckLeads(false); this.floor.interrupt(); this._flowSeq++; for (const id of this._flowTimers) clearTimeout(id); this._flowTimers = []; 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 提示:空闲才播(§4.6 应答内零语音),播前领奏 duck 到 1/4。 */ async _playMidSong() { if (!this.audio.ctx) return; if (this.audio.isVoicePlaying()) 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._pumpTimer) clearInterval(this._pumpTimer); this._pumpTimer = 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 }); } } // ---------------- 手势入口(pipeline 之后) ---------------- /** * 手势入口(引擎坐标扩展后带 info 第二参)。tap = 分区敲击(info 决定敲哪区); * 演奏语境立即判定,非演奏语境延迟 500ms 裁决(防 immediateTap 的滑动/长按 * 序曲误触);tapcancel 撤销序曲 tap 的判定认领。 * @returns {'ok'|'hold'|'invalid'|'silent-ok'|undefined} */ onGesture(g, info) { if (g === 'tapcancel') { if (this._pendingTap) { clearTimeout(this._pendingTap); this._pendingTap = null; } this._revokeTap(TAPCANCEL_REVOKE_S); return; } if (g === 'longpressend') return; const rid = this._runId; if (g === 'tap' && !this._isPlayInput()) { // 非演奏输入语境:序曲 tap 延迟裁决(防滑动/长按起点误触发) if (this._pendingTap) clearTimeout(this._pendingTap); this._pendingTap = setTimeout(() => { this._pendingTap = null; this._dispatch(rid, 'tap', info); this._settleAfterGesture(); }, STRAY_TAP_MS); return; } if (g !== 'tap' && this._pendingTap) { clearTimeout(this._pendingTap); this._pendingTap = null; } const r = this._dispatch(rid, g, info); this._settleAfterGesture(); return r; } /** * 打断播报后游戏未起新链(如导航报名被台账去重成提示音):回到等待态, * 等待声重新响起——不悬在 BUSY 里把手势全部挡成 busy 回执(原则 1)。 */ _settleAfterGesture() { if (this.floor.state === 'BUSY' && this.floor.waitPoint && !this.audio.isVoicePlaying()) { this.floor.resumeTurn(); } } /** 演奏输入语境(摸鼓探针、教学跟打步、正片;导航态不算)。 */ _isPlayInput() { return this.phase === 'main' || this.phase === 'tut'; } stateName() { if (this.phase === 'tut') return this._tutStage === 'probe' ? 'probe' : 'tut'; return this.phase; } /** 无效操作解释一次(原则 7):演奏态只给回执不解释(应答内零语音)。 */ invalidHint(state, g) { if (this.phase === 'main' || this.phase === 'tut') return null; if (state === 'locked') { if (g === 'down') return 'voice/inv_down_locked'; if (g === 'up') return 'voice/inv_up_locked'; return null; } if (state === 'menu') return 'voice/menu_help'; return 'voice/inv_generic'; } _dispatch(rid, g, info) { switch (this.phase) { case 'tut': if (this._tutStage === 'probe') { // 探针态单击 = 摸鼓(区名 queue 串播不互掐;首摸每区报名一次) if (g === 'tap') { this._onProbe(this._zoneFrom(info)); return 'silent-ok'; } return 'invalid'; } // step/idle:应答敲击(示范期手势在 floor BUSY 层已被 busy 回执拦下) if (g === 'tap' || g === 'doubletap') { return this._hit(this._zoneFrom(info)) ? 'silent-ok' : 'invalid'; } if (g === 'longpress' || g === 'up') { this._statusQuery(); return 'ok'; } return 'invalid'; case 'select': return this._onSelectGesture(rid, g); case 'main': // 无条件(音符全是点按);'hold':开菜单手势不结算刚挂上的菜单等待点 if (g === 'down') { this._openMenu(rid); return 'hold'; } if (g === 'tap' || g === 'doubletap') { return this._hit(this._zoneFrom(info)) ? 'silent-ok' : 'invalid'; } if (g === 'longpress' || g === 'up') { this._statusQuery(); return 'ok'; } return 'invalid'; case 'menu': return this._onMenuGesture(g); case 'end': return this._onEndGesture(rid, g); case 'intro': if (this._reopenMode) { if (g === 'tap') { this._reopenMode = false; this._startSong(rid, this._progress.lastSong || 0); return 'ok'; } if (g === 'right') { this._reopenMode = false; this._enterSelect(rid); return 'ok'; } } return 'invalid'; default: return 'invalid'; // idle/leaving } } // ---------------- 教学(T0 摸鼓认区 + T1-T3 跟打) ---------------- _beginProbe(rid) { if (this._runId !== rid) return; this.phase = 'tut'; this._tutStage = 'probe'; this._probeHeard = new Set(); this.floor.enterAction(); // 摸鼓 = 探索:鼓声即状态声(§4.1 ACTION) this.confusion.resetForWaitPoint(); // 防呆自动进阶:8s 无触摸 → 直接进 T1(不卡流程,guide.test G2 等无触摸 // 驱动依赖此路径);摸过 1-2 区再给 8s 宽限(探索是礼物不是门槛)。 const arm = (grace) => { this._probeTimer = this._timer(() => { this._probeTimer = null; if (this._runId !== rid || this.phase !== 'tut' || this._tutStage !== 'probe') return; if (this._probeHeard.size === 0 && grace) { arm(false); return; } this._advanceFromProbe(rid); }, 8000); }; arm(true); } /** 清理探针自动进阶定时器(进阶/跳过/清理时)。 */ _clearProbeTimer() { if (this._probeTimer) { clearTimeout(this._probeTimer); this._probeTimer = null; } } /** 探针 → T1:等区名 queue 链播完(R1 F2 不掐断)再起教学指令。 */ _advanceFromProbe(rid) { this._clearProbeTimer(); this._tutStage = 'idle'; // 防止泵与后续 tap 再进探针分支 this._waitVoiceIdleThen(() => { if (this._runId !== rid || this.phase !== 'tut') return; this._beginTutorialStep(rid, 0, false); }); } /** T0:任意 tap → 播该区音色 + 区名(每区每轮教学一次);≥3 区进 T1。 */ _onProbe(zone) { if (!ZONES[zone]) return; this.audio.play(ZONES[zone].sfx, { pan: ZONES[zone].pan }); if (this._probeHeard.has(zone)) return; this._probeHeard.add(zone); // 区名 queue 串播:不打断正在播的上一句(live R1 实录 zone_bl 被截杀的教训) this.audio.play(`voice/${ZONES[zone].name}`, { queue: true }); if (this._probeHeard.size === 1) this.audio.play('voice/tut_probe', { queue: true }); if (this._probeHeard.size >= 3) this._advanceFromProbe(this._runId); } _beginTutorialStep(rid, idx, spoken) { if (this._runId !== rid) return; this._tutIdx = idx; this._tutFails = 0; this._cues = []; this._tutAttempts = []; if (spoken) { this._tutLead(rid); return; } // 教学指令 = 规则的唯一一次说明(§4.6 critical,≤3s):抢拍手势给 busy 回执 this.floor.speak([TUT_STEPS[idx].voice], { critical: true, onDone: () => this._tutLead(rid), }); } /** 教学领奏:数拍 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 }); } // 应答窗开(首个可认领拍点前 0.45s):交出「教学跟打」等待点。 // 等待声关(bedEnabled=false)——演奏语义(24 号 A27);交接提示音响(原则 1)。 const firstExpected = this._cues[0].expected; this._timed.push({ time: firstExpected - 0.45, fn: () => this._openTutEcho(rid) }); this._sortSched(); const last = this._cues[this._cues.length - 1]; this._phaseEnd = last.expected + GOOD + 0.4; this._startPump(rid); } _openTutEcho(rid) { if (this._runId !== rid || this.phase !== 'tut' || this._tutStage !== 'step') return; this.floor.bedEnabled = false; this.floor.yieldTurn(this._tutWp(rid, this._tutIdx)); } /** 教学跟打等待点(演奏期外的等待点,设计单 §5)。 */ _tutWp(rid, idx) { return this._wp({ id: `tut_${idx}`, expected: ['tap'], full: TUT_STEPS[idx].echoFull, short: TUT_STEPS[idx].echoShort, silenceMs: [8000, 16000], onDemo: () => this._tutDemo(rid, idx), }); } /** 迷茫第 3 级(听我做一次):游戏把本步音符敲一遍,再把话轮交回。 */ _tutDemo(rid, idx) { const notes = TUT_STEPS[idx].notes; notes.forEach(([, z], i) => { this._timer(() => { if (this._runId !== rid || this.phase !== 'tut') return; this.audio.play(ZONES[z].sfx, { pan: ZONES[z].pan }); }, 250 + i * 450); }); this._timer(() => { if (this._runId !== rid || this.phase !== 'tut' || this._tutStage !== 'step') return; this.floor.yieldTurn(this._tutWp(rid, idx)); }, 250 + notes.length * 450 + 400); } /** 教学应答评估:全部音窗口内敲对区 → 通过;否则归因 + 纠正重教。 */ _tutEvaluate(rid) { if (this._runId !== rid || this.phase !== 'tut' || this._tutStage !== 'step') return; this._phaseEnd = Infinity; // 一次性(语音间隙期泵不会重入级联连过) this.floor.bedEnabled = true; // 恢复等待声(后续等待点用) if (this.floor.waitPoint) this.floor.settleWaitPoint(true); // 摘下跟打等待点 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._goalGiven) { this._goalGiven = true; this.ladder.onGoal(); // 第一次成功跟打 → 解锁上滑(§4.3 阶段 1) } const nextIdx = this._tutIdx + 1; if (nextIdx < TUT_STEPS.length) { // 「对了。」(≤4 字肯定,原则 11)+ 下一条教学指令,同一条 critical 链 this.floor.speak(['voice/tut_ok', TUT_STEPS[nextIdx].voice], { critical: true, onDone: () => this._beginTutorialStep(rid, nextIdx, true), }); } else { this.floor.speak(['voice/tut_ok', 'voice/tut_done'], { critical: true, onDone: () => this._finishTutorial(rid), }); } return; } this._tutFails++; vibrate('error'); if (this._tutFails >= 2) { // G2 DDA:连 2 败 → 降速 25% 重演(每教学局 1 次);封顶后跳步不卡死 if (!this._tutSlowUsed) { this._tutSlowUsed = true; this._tutRate = 0.75; this._tutFails = 0; this._speakFlow(this._guideFull() ? ['voice/gd_slow'] : [], { onDone: () => this._tutLead(rid), }); return; } this._tutRate = 1.0; const nextIdx = this._tutIdx + 1; if (nextIdx < TUT_STEPS.length) { this._speakFlow(['voice/retry_skip', TUT_STEPS[nextIdx].voice], { onDone: () => this._beginTutorialStep(rid, nextIdx, true), }); } else { this._speakFlow(['voice/retry_skip', 'voice/tut_done'], { onDone: () => this._finishTutorial(rid), }); } return; } // H5 归因真话律:无窗内尝试优先 gd_miss_none;真出手再分早/晚/错区。 // 归因 feedback 类:玩家一动手就作废(§4.6),链被打断由 _speakFlow 看门狗续走。 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 ids = correction === 'voice/retry_listen' ? [correction] : [correction, 'voice/retry_listen']; if (this._guideFull()) ids.push(`voice/${missGrade}`); this._speakFlow(ids, { onDone: () => this._tutLead(rid) }); } _finishTutorial(rid) { if (this._runId !== rid) return; this._progress.tutorialDone = true; this._saveProgress(); this._enterSelect(rid); } // ---------------- 选曲 ---------------- _enterSelect(rid) { if (this._runId !== rid) return; this.phase = 'select'; this.audio.setRateBoostEnabled(true); if (this._pumpTimer) { clearInterval(this._pumpTimer); this._pumpTimer = null; } this._stopBgmSrc(); // 教学 BGM 手动 source 停掉,换 menu pad this.audio.play('bgm/menu', { fadeMs: 800 }); const ids = []; if (!this._selectHintSaid) { ids.push('voice/select_hint'); // 入口指令每会话一次(原则 9:不原样重复) this._selectHintSaid = true; } ids.push(this._songIdx < this._progress.unlocked ? SONGS[this._songIdx].nameVoice : 'voice/song_locked'); for (const id of ids) markHintSaid(id); // 台账:被滑动打断后 30s 内切回来不重播 this._lastAnnounce = ids[ids.length - 1]; // 先交话轮再播报:播报被滑动打断时等待点仍在(不出现「BUSY 到看门狗才救」的空窗) this.floor.yieldTurn(this._selectWp(rid)); this._announce(ids); } _selectWp(rid) { return this._wp({ id: 'select', expected: ['tap'], full: 'voice/select_full', short: 'voice/select_short', silenceMs: [18000, 40000], onDemo: () => this.floor.chime(), }); } _onSelectGesture(rid, g) { if (g === 'left' || g === 'right') { const n = SONGS.length; this._songIdx = (this._songIdx + (g === 'right' ? 1 : n - 1)) % n; const unlocked = this._songIdx < this._progress.unlocked; // 导航报名是对玩家滑动的回应;30s 内重复路过同一首只给提示音(原则 9) this._announceFresh(unlocked ? SONGS[this._songIdx].nameVoice : 'voice/song_locked'); return 'hold'; } if (g === 'tap') { if (this._songIdx < this._progress.unlocked) { this._startSong(rid, this._songIdx); return 'ok'; } this._announceFresh('voice/song_locked'); return 'invalid'; } if (g === 'down') { this._goHome(); return 'ok'; } if (g === 'up' || g === 'longpress') { this._announce(['voice/rules'], { kind: 'hint' }); return 'hold'; } if (g === 'doubletap') { this._replayLast(); return 'hold'; } return 'invalid'; } // ---------------- 正式曲目 ---------------- _startSong(rid, songIdx) { if (this._runId !== rid) return; this._cleanup(); this.audio.setRateBoostEnabled(false); // 演奏期豁免长按快进(长按=状态查询) this.audio.stopBgm(400); this.phase = 'main'; this.floor.enterAction(); // 演奏期 = ACTION:玩法声即状态,无等待声(24 号 A27) this.confusion.resetForWaitPoint(); this._songIdx = songIdx; this._song = SONGS[songIdx]; this._stats = { perfect: 0, good: 0, miss: 0, combo: 0, maxCombo: 0 }; this._consecMiss = 0; this._missVoiceSaid = false; this._progress.lastSong = songIdx; // 断点存档(§4.12 曲目粒度) this._saveProgress(); 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) { // 加花:领奏小节头落交接提示音(应答密集的预警) this._timed.push({ time: leadBarT0, fn: () => this.floor.chime() }); } if (i === midUnit) { this._timed.push({ time: leadBarT0, fn: () => this._playMidSong() }); } if (i === 0) { // G1:首个应答窗前整整 1 小节(= 领奏小节头)落「轮到你了」教学 + 交接提示音 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._idleVoice('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); } /** 首个应答窗:教「轮到你了」状态声(每账号一次,原则 2)+ 交接提示音。 */ _fireAnswerCue() { this._answerArmed = false; if (!this._progress.turnTaught) { this._progress.turnTaught = true; this._saveProgress(); this._idleVoice('voice/sys_turn_teach'); } else if (this._guideFull()) { this._idleVoice('voice/gd_answer'); } this.floor.chime(); } _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(); } // ---------------- 敲击与判定 ---------------- /** * 分区敲击(tap / 键盘数字键共用)。判定时刻 = 回调/按键时刻(零补偿)。 * 窗口外 = 自由演奏(出声不判定);教学步全程记录尝试供评估与归因。 * @returns {boolean} true=正常敲击(出声);false=错鼓 Miss(invalid 回执) */ _hit(zone) { if (!this.audio.ctx || !ZONES[zone]) return true; 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 true; } // 归属②:异分区音符半窗内被敲 = 错鼓 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 false; } 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 }); } } return true; } /** 序曲 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(); // 闷镲:主反馈是声音(24 号 §5) vibrate('error'); this._consecMiss++; if (this.phase === 'main' && this._consecMiss === 2 && !this._missVoiceSaid) { // R1 F1:曲尾 3s 内不再插播——宁可不说,不说半句(连 2 Miss 同曲一次) const remain = this._phaseEnd && this.audio.ctx ? this._phaseEnd - this.audio.ctx.currentTime : 99; if (remain > 3 && this._guideFull()) this._missVoiceSaid = this._idleVoice('voice/gd_miss'); } 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]); } } // ---------------- 状态查询(长按 / 上滑,原则 3 一句话 ≤20 字) ---------------- _statusQuery() { let id = null; if (this.phase === 'main' && this._song) { const combo = this._stats ? this._stats.combo : 0; if (combo >= 24) id = 'voice/st_c3'; else if (combo >= 8) id = 'voice/st_c2'; else if (combo >= 1) id = 'voice/st_c1'; else { const mid = Math.floor(this._song.units.length / 2); const next = this._cues.find((c) => !c.judged); const past = next ? next.phrase : this._song.units.length; id = ST_POS[this._song.id][past > mid ? 1 : 0]; } } else if (this.phase === 'tut') { id = 'voice/rules'; } else if (this.phase === 'select') { id = 'voice/select_full'; } else if (this.phase === 'menu') { id = 'voice/menu_help'; } else if (this.phase === 'end') { this._replayLast(); return; } else { return; } this.floor.speak([id], { kind: 'hint', onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); else if (this.phase === 'main' || this.phase === 'tut') this.floor.enterAction(); }, }); } /** 阶段 3 双击 / 结算长按:重听最近播报(主动重听才播完整原句,原则 9)。 */ _replayLast() { const id = this._lastAnnounce || 'voice/restart'; this.floor.speak(['voice/sys_say_again', id], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); } // ---------------- 菜单(模态暂停,BGM 断点续播) ---------------- _openMenu(rid) { if (this._runId !== rid) return; this.phase = 'menu'; this._menuIdx = 0; this._menuOpenedAt = performance.now(); this.audio.setRateBoostEnabled(true); if (this._pumpTimer) { clearInterval(this._pumpTimer); this._pumpTimer = 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(); // 先交话轮再播报(同 _enterSelect:打断后等待点仍在) this.floor.yieldTurn(this._menuWp(rid)); this._announce(['voice/menu_main', MENU_ITEMS[0]]); } _menuWp(rid) { return this._wp({ id: 'menu', expected: ['tap', 'left', 'right'], full: 'voice/menu_help', short: 'voice/menu_help', silenceMs: [20000, 45000], onDemo: () => this.floor.chime(), }); } _onMenuGesture(g) { if (performance.now() - this._menuOpenedAt < MENU_DEBOUNCE_MS) return 'invalid'; switch (g) { case 'left': case 'right': { this._menuIdx = (this._menuIdx + (g === 'right' ? 1 : MENU_ITEMS.length - 1)) % MENU_ITEMS.length; this._announce([MENU_ITEMS[this._menuIdx]]); // 报名是对换项操作的回应:不走去重 return 'hold'; } case 'tap': // 单击确认当前项(00 号 §5.4:左右滑切换、单击确认) if (this._menuIdx === 0) this._closeMenu(this._runId); else this._goHome(); return 'ok'; case 'down': this._closeMenu(this._runId); return 'ok'; case 'up': case 'longpress': this._announce(['voice/menu_help'], { kind: 'hint' }); return 'hold'; case 'doubletap': this._replayLast(); return 'hold'; default: return 'invalid'; } } _closeMenu(rid) { if (this._runId !== rid) return; this.phase = 'main'; this.audio.setRateBoostEnabled(false); this.floor.interrupt(); // 关菜单先停菜单语音链 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 = settings.get().guide !== '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); this.floor.enterAction(); }; if (this._guideFull() && !this._gdCountUsed) { this._gdCountUsed = true; this._speakFlow(['voice/gd_count'], { onDone: resume }); } else { resume(); } } _goHome() { if (this._goingHome) return; this._goingHome = true; const rid = this._runId; this.phase = 'leaving'; if (this._pumpTimer) { clearInterval(this._pumpTimer); this._pumpTimer = null; } this._stopBgmSrc(); this.audio.stopBgm(300); this._speakFlow(['voice/menu_lobby'], { guardMs: 2000, onDone: () => { if (rid !== this._runId) return; 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.audio.setRateBoostEnabled(true); if (this._pumpTimer) { clearInterval(this._pumpTimer); this._pumpTimer = 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; } p.lastSong = null; // 一曲打完,清断点 this._saveProgress(); this._putProgress(); this._lastAnnounce = `voice/grade_${grade}`; if (grade === 's' || grade === 'a') vibrate('success'); if (unlockedNew) { vibrate('success'); vibrate([20, 40, 20]); ids.push(unlockedNew); } // 一首曲子打完 → 手势阶梯(00 号 §5.2:第一局解锁下滑、第三局解锁双击) const got = this.ladder.onGameDone(); const tail = ['voice/restart']; if (got) tail.push('voice/' + this.ladder.teachLine(got)); // teachLine 返回裸 id,补 voice/ 前缀 console.log('[RhythmGame] 结算:', this._stats, '评级:', grade.toUpperCase()); for (const id of [...ids, ...tail]) markHintSaid(id); // 台账:结算句 30s 内不被动重播 this._speakFlow(ids, { onDone: () => { if (this._runId !== rid) return; // §4.6:只把承诺段(「单击,再来一遍。」+解锁教学,各 <3s)标 critical this.floor.speak(tail, { critical: true, onDone: () => { if (this._runId !== rid) return; this.audio.play('bgm/menu', { fadeMs: 800 }); this.floor.yieldTurn(this._settleWp(rid)); }, }); }, }); } _settleWp(rid) { return this._wp({ id: 'settle', expected: ['tap'], full: 'voice/settle_full', short: 'voice/settle_short', silenceMs: [20000, 45000], onDemo: () => this.floor.chime(), }); } _onEndGesture(rid, g) { switch (g) { case 'tap': // 同曲再来(言行一致) this._startSong(rid, this._songIdx); return 'ok'; case 'right': if (this._songIdx + 1 < this._progress.unlocked) { this._startSong(rid, this._songIdx + 1); return 'ok'; } if (this._songIdx + 1 >= SONGS.length) { this._announceFresh('voice/restart'); return 'hold'; } this._announceFresh('voice/song_locked'); return 'invalid'; case 'down': this._goHome(); return 'ok'; case 'up': case 'longpress': case 'doubletap': this._replayLast(); return 'hold'; default: return 'invalid'; } } // ---------------- 断点复述(§4.12) ---------------- _reopenWp(rid) { return this._wp({ id: 'reopen', expected: ['tap', 'right'], full: 'voice/sys_continue_ask', short: 'voice/select_short', silenceMs: [18000, 40000], onDemo: () => this.floor.chime(), }); } // ---------------- 进度存储 ---------------- _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, turnTaught: !!p.turnTaught, lastSong: Number.isInteger(p.lastSong) && p.lastSong >= 0 && p.lastSong < SONGS.length ? p.lastSong : null, }; } } catch (_) { /* 损坏则重置 */ } return { tutorialDone: false, unlocked: 1, grades: {}, bestCombo: 0, tutFillDone: false, turnTaught: false, lastSong: null }; } _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, lastSong: p.lastSong, 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 (_) { /* 静默 */ } } // ---------------- 工具(26 号语音/等待点/看门狗) ---------------- _wp(conf) { return new WaitPoint(conf, { floor: this.floor, confusion: this.confusion }); } /** runId 守卫的 setTimeout(流程看门狗/演示排程;cleanup 一并清)。 */ _timer(fn, ms) { const rid = this._runId; const id = setTimeout(() => { if (rid === this._runId) fn(); }, ms); this._flowTimers.push(id); return id; } /** * 流程语音链:floor.speak + 看门狗兜底。链被玩家滑动打断(onDone 作废)时, * 看门狗保证流程照常推进(不卡死在 BUSY);被更新的 _speakFlow 取代则旧看门狗作废。 */ _speakFlow(ids, { critical = false, kind = 'content', onDone, guardMs } = {}) { const rid = this._runId; const seq = ++this._flowSeq; let fired = false; const fire = () => { if (fired || rid !== this._runId || seq !== this._flowSeq) return; fired = true; if (onDone) onDone(); }; this.floor.speak(ids, { critical, kind, onDone: fire }); this._timer(fire, guardMs ?? Math.max(3000, ids.length * 4500 + 2500)); } /** * 播报(content/hint):记台账(原则 9:30s 内不原样重复)、留最近播报句、 * 播完回到当前等待点(等待声重新响起,不留下安静态)。 */ _announce(ids, { kind = 'content', onDone } = {}) { for (const id of ids) markHintSaid(id); this._lastAnnounce = ids[ids.length - 1]; const wp = this.floor.waitPoint; this.floor.speak(ids, { kind, onDone: onDone || (() => { if (wp && this.floor.waitPoint === wp) this.floor.resumeTurn(); }), }); } /** 台账去重播报:30s 内说过只给交接提示音(导航报名/锁曲解释等回应类)。 */ _announceFresh(id, opts) { if (hintSaidRecently(id)) { this.floor.chime(); return; } this._announce([id], opts); } /** * 演奏期(ACTION)内的提示/反馈句:不夺发言权(26 号 §4.1 ACTION:玩家操作 * 直接交给游戏,不打断任何东西),空闲才播、播不出就丢(§4.6)。 */ _idleVoice(id) { if (this.audio.isVoicePlaying()) return false; markHintSaid(id); this.audio.play(id); return true; } /** 等语音(含 queue 链)真正播完再继续:两次连续空闲采样,避开引擎换句间隙。 */ _waitVoiceIdleThen(fn) { const rid = this._runId; let idleHits = 0; let polls = 0; const poll = () => { if (rid !== this._runId) return; if (this.audio.isVoicePlaying()) { idleHits = 0; } else if (++idleHits >= 2) { fn(); return; } if (++polls > 30) { fn(); return; } // ~5.4s 兜底:区名资产缺失也不卡死 this._timer(poll, 180); }; this._timer(poll, 180); } _guideFull() { return settings.get().guide === 'full'; } }