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 置完成。
1536 lines
61 KiB
JavaScript
1536 lines
61 KiB
JavaScript
/**
|
||
* 节拍行者(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';
|
||
}
|
||
}
|