Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

1536 lines
61 KiB
JavaScript
Raw Permalink 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)鼓手模式 —— 按 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';
}
}