1269 lines
59 KiB
JavaScript
1269 lines
59 KiB
JavaScript
/**
|
||
* 节拍行者(rhythm)R3 —— 跟随领奏(call & response)节奏游戏
|
||
* (按 docs/design/03-rhythm-rebuild.md 实现;语音 id 见 03-rhythm-voice-ids.md)。
|
||
*
|
||
* 玩法:系统奏 1 小节节奏型(2-4 个手势乐器音,inst_* 踩拍点),玩家在下一小节
|
||
* 的相同拍点用相同手势跟打。3 首曲目(90/105/120 BPM)解锁制:曲目 n 拿到 B 级
|
||
* 以上解锁 n+1(localStorage 记解锁状态,已登录同步服务器)。
|
||
*
|
||
* 判定(live round1 §13 校准版;03 文档 §2 契约的窗口放宽):
|
||
* Perfect ±0.15s(手势乐器音 + inst_accent 亮音叠加)
|
||
* Good ±0.35s(只播乐器音;蓝牙延迟与滑动/双击识别延迟由此吸收)
|
||
* Miss 窗口内手势错 / 超时应答缺失:不播该音符的乐器音(错过=没出声)
|
||
* + 闷镲 Miss 反馈音(inst_hat_l 降调 0.5× 复用,零新资产)+ error 震动;
|
||
* 长按 = 死输入同款反馈(不判定不归属)
|
||
* 判定时刻 = 触摸时刻(回调时刻 - 识别补偿):immediateTap(index.html 已
|
||
* 开启,按下即发 tap)下 tap 补偿 0;旧引擎(双击窗 300ms)补偿 TAP_DELAY
|
||
* 0.30s——运行证据自动闩锁回退(见 onGesture)。滑动补偿 SWIPE_DELAY 0.15s、
|
||
* 双击 DOUBLE_DELAY 0.20s(识别都在 touchend);Miss 超时放宽 = 补偿 + 0.10
|
||
* 计分:Perfect=2 / Good=1 / Miss=0;rate = 得分/(2×总音符);
|
||
* 评级 S≥0.95 / A≥0.85 / B≥0.70 / C 其余。不掉血不中断,歌照唱完。
|
||
*
|
||
* 时序核心(沿用现版):全部节拍事件用 AudioContext.currentTime 预排程
|
||
* (领奏音符、BGM 起点按绝对时间 source.start(when) 调度,40ms 调度泵 +
|
||
* LOOKAHEAD 0.6s 前瞻);玩家乐器音不走预排程,出手即播(sfx 即发即弃)。
|
||
*
|
||
* 震动落点(手势识别震动引擎内置,不重复):领奏每音 beat 轻震、Miss error、
|
||
* 16 连击 success、乐句全 Perfect [20,40,20]、解锁新曲 success+[20,40,20]、
|
||
* 结算 S/A success。
|
||
*
|
||
* 菜单(round2-review 全球约定):下滑呼出(模态暂停,BGM 断点续播)→
|
||
* 单击循环 → 双击确认 → 上滑帮助。下滑与"下滑=低音 tom"冲突的折中:
|
||
* 仅当无悬置/临近的 down 音符时下滑才呼出菜单(见 README);临近窗口 =
|
||
* 该 down 音符领奏响起(expected-4 拍)至判定死线(live round1 P0 加宽——
|
||
* 旧 ±1s/+0.55s 窗口跟不上滑动识别延迟,踩点下滑曾必弹菜单);live round2
|
||
* 续修:down 音符刚被超时拖死(识别延迟,REVIVE_WINDOW_S 内)同样算临近——
|
||
* 下滑还在路上时音符被判死不再弹菜单(事件流实证 3/3:超时 Miss 先于下滑
|
||
* 回调 2~114ms)。暂停真冻结(泵停 = 谱面/判定/定时动作全冻结,BGM 停源记
|
||
* 断点);关菜单先停菜单语音链再恢复(菜单语音骑在恢复后领奏上是"菜单
|
||
* 不暂停"感知的主因)。菜单打开 1s 去抖独占期(live round2 P1:演奏途中
|
||
* 被弹菜单时,已在路上的手势不得穿透菜单直接确认——实测 0.6s"瞬穿")。
|
||
*
|
||
* immediateTap 兼容(引擎 opt-in,index.html 已开启,按下即发 tap):
|
||
* 每次按下都先发 tap——滑动/双击/长按的触摸起点也会先出一声 tap。演奏
|
||
* 阶段(tut/main)序曲 tap 出声(kick)但只认领 tap 音符(不抢归属不判
|
||
* Miss);live round2 P0 判定治理:序曲 tap 判定记账,转滑动/长按(引擎
|
||
* 补 tapcancel)或改判双击时按账撤销——声音已出收不回,判定/统计更正
|
||
* 且不计 Miss;识别延迟把音符超时拖死而手势仍在路上的(同手势、超时类、
|
||
* REVIVE_WINDOW_S 内、补偿 dt ≤ REVIVE_DT_S)复活改判。非演奏阶段
|
||
* (intro/select/menu/end/leaving)tap 延迟 STRAY_TAP_MS 生效、期间来了
|
||
* 其他手势或 tapcancel 即作废(否则选曲右滑会先误触发"选定曲目")。
|
||
*
|
||
* 状态 BGM(docs/design/10-feedback2-state-bgm.md §5):
|
||
* 选曲界面 = bgm/menu(引擎层淡入 800ms);选定曲目 stopBgm(400) 后按既有
|
||
* 网格起 track90/105/120(手动排程不变);暂停菜单保持静默(断点续播,
|
||
* 不叠 menu pad);结算评级语音链播完后淡入 bgm/menu 800ms——且只在仍处
|
||
* 结算态时起播,看门狗按链长缩放(live round1 P1:10s 看门狗曾早火把
|
||
* menu pad 骑到语音链尾上;重开后过期 fire 会把 menu pad 漏进正片)。
|
||
* 旁白跳过(docs/design/12-skip-and-longpress-ux.md §1.2):intro/tut/select/
|
||
* end 阶段 voice 播放中单击 = audio.skipVoice()(跳下一句,链不断);
|
||
* main 演奏阶段单击是演奏手势不跳(mid_song 短句按裁定不跳)。
|
||
* 长按快进豁免(12 文档 §2.2:main 演奏期):audio.setRateBoostEnabled(false)
|
||
* per-game 开关(引擎已支持),进 main 关、进菜单/结算/退出恢复。
|
||
* 向导层(docs/design/14-guide-layer.md + round2 synthesis F2.1/F3 + round3 G1~G3
|
||
* + round4 H5):
|
||
* 每曲首次领奏→应答切换在谱面定时队列直接落 gd_answer(不走 guide queue,保证
|
||
* 窗口前听到)+ turn earcon;round3 G1 修正排程点:落在首个应答窗口**前整整
|
||
* 1 小节**(= 领奏小节头;round2 排在窗口起点本身,P4 实测窗口开了才听到),
|
||
* 菜单恢复对下一个未判定窗口重排同款标记;教学 Miss 首次即归因分档
|
||
* (round4 H5 四档:无窗内尝试 gd_miss_none 优先、真出手再分 early/late/wrong,
|
||
* 挂纠正链尾空闲点;正片连 2 Miss 仍 gd_miss);连续 2 Miss 播 gd_miss(queue
|
||
* 插播,同曲一次);暂停断点续播前插 4 拍 count-in(round3 G3:底鼓 sfx 层可辨
|
||
* 音量 + full 档 gd_count 每局恢复限 1 次 + beat 震;round2 版因 push 后未重排
|
||
* _sched 被队头卡死从未按时响过;off 档保持即时续播);教学 DDA(round3 G2):
|
||
* 同一步连 2 次失败该步领奏降速 25% 重演并播 gd_slow(full 档、每教学局 1 次,
|
||
* off 档维持 retry_skip 原路径);结算链换 _speakGuarded(资产缺失不卡结算 BGM);
|
||
* 状态签名 earcon 与状态 BGM 同点(select=menu、main=battle、end=settle)。
|
||
*/
|
||
import { Game } from '../engine/game.js';
|
||
import { Guide } from '../engine/guide.js';
|
||
import { vibrate } from '../engine/haptics.js';
|
||
import { settings } from '../engine/settings.js';
|
||
|
||
// ---- 判定与调度常量(live round1 §13:03 文档 §2 契约的窗口放宽版) ----
|
||
const PERFECT = 0.15;
|
||
const GOOD = 0.35;
|
||
const TAP_DELAY = 0.30; // 旧引擎 tap 回调延迟(双击窗 300ms)补偿;immediateTap 下为 0
|
||
const SWIPE_DELAY = 0.15; // 滑动识别在 touchend:补平均滑程(判定回到触摸时刻)
|
||
const DOUBLE_DELAY = 0.20; // 双击识别在第二次 touchend:补首按到改判的间隔
|
||
const STRAY_TAP_MS = 500; // immediateTap 序曲 tap 裁决窗(> 引擎 longpressMs 450,长按也能抢先作废)
|
||
const MISS_SFX = 'sfx/inst_hat_l'; // Miss 反馈音:闭镲降调复用(闷镲),零新资产
|
||
const MISS_RATE = 0.5;
|
||
const LOOKAHEAD = 0.6;
|
||
const PUMP_MS = 40;
|
||
// ---- live round2 判定治理常量 ----
|
||
const MENU_DEBOUNCE_MS = 1000; // 菜单独占去抖期:开菜单 1s 内不收任何手势(防"瞬穿")
|
||
const TAPCANCEL_REVOKE_S = 0.6; // tapcancel 撤销窗(覆盖长按 450ms 成立点与滑动 slop 判定)
|
||
const DOUBLE_REVOKE_S = 0.5; // 双击序曲 tap 撤销窗(双击窗 300ms + 两段触程余量)
|
||
const REVIVE_WINDOW_S = 0.6; // 超时 Miss 可复活窗(识别延迟把音符拖死、手势还在路上)
|
||
const REVIVE_DT_S = 0.5; // 复活判定时差上限(GOOD 0.35 + 慢滑/慢双击补偿误差 0.15)
|
||
|
||
// ---- 手势 → 乐器音映射(03 文档 §4;accent 为 Perfect 叠加层) ----
|
||
const INST = {
|
||
tap: 'sfx/inst_kick',
|
||
doubletap: 'sfx/inst_snare',
|
||
left: 'sfx/inst_hat_l',
|
||
right: 'sfx/inst_hat_r',
|
||
up: 'sfx/inst_tom_hi',
|
||
down: 'sfx/inst_tom_lo',
|
||
};
|
||
const ACCENT = 'sfx/inst_accent';
|
||
|
||
// ---- 谱面(03 文档 §3 表格直转;restAfter = 每句应答后休止小节数) ----
|
||
const SONGS = [
|
||
{
|
||
id: 'song1', title: '启程', bpm: 90, bgm: 'bgm/track90',
|
||
nameVoice: 'voice/song1_name', unlockVoice: null,
|
||
restAfter: [1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
|
||
phrases: [
|
||
[[1, 'tap'], [3, 'tap']],
|
||
[[1, 'left'], [3, 'right']],
|
||
[[1, 'tap'], [2, 'doubletap'], [3, 'tap']],
|
||
[[1, 'left'], [2, 'doubletap'], [3, 'right']],
|
||
[[1, 'tap'], [3, 'left'], [4, 'doubletap']],
|
||
[[1, 'right'], [3, 'tap'], [4, 'doubletap']],
|
||
[[1, 'up'], [3, 'down'], [4, 'doubletap']],
|
||
[[1, 'tap'], [2, 'up'], [3, 'right'], [4, 'doubletap']],
|
||
[[1, 'left'], [2, 'right'], [3, 'tap'], [4, 'doubletap']],
|
||
[[1, 'tap'], [2, 'doubletap'], [3, 'tap'], [4, 'doubletap']],
|
||
],
|
||
},
|
||
{
|
||
id: 'song2', title: '追风', bpm: 105, bgm: 'bgm/track105',
|
||
nameVoice: 'voice/song2_name', unlockVoice: 'voice/unlock_song2',
|
||
restAfter: [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||
phrases: [
|
||
[[1, 'tap'], [3, 'right']],
|
||
[[1, 'left'], [2, 'doubletap'], [3, 'tap']],
|
||
[[1, 'up'], [3, 'down'], [4, 'doubletap']],
|
||
[[1, 'tap'], [2, 'right'], [3, 'left'], [4, 'doubletap']],
|
||
[[1, 'tap'], [2.5, 'right'], [3, 'tap']],
|
||
[[1, 'left'], [2, 'up'], [3.5, 'right'], [4, 'doubletap']],
|
||
[[1, 'tap'], [2, 'doubletap'], [3, 'down'], [3.5, 'up']],
|
||
[[1, 'right'], [1.5, 'left'], [3, 'tap'], [4, 'doubletap']],
|
||
[[1, 'tap'], [2.5, 'left'], [3, 'tap'], [4, 'doubletap']],
|
||
[[1, 'up'], [2, 'right'], [3, 'down'], [3.5, 'left']],
|
||
[[1, 'tap'], [2, 'doubletap'], [2.5, 'right'], [3, 'tap']],
|
||
[[1, 'left'], [1.5, 'right'], [2, 'doubletap'], [3, 'tap'], [4, 'doubletap']],
|
||
],
|
||
},
|
||
{
|
||
id: 'song3', title: '星爆', bpm: 120, bgm: 'bgm/track120',
|
||
nameVoice: 'voice/song3_name', unlockVoice: 'voice/unlock_song3',
|
||
restAfter: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||
phrases: [
|
||
[[1, 'tap'], [2, 'doubletap'], [3, 'tap'], [4, 'doubletap']],
|
||
[[1, 'left'], [2, 'right'], [3, 'left'], [4, 'doubletap']],
|
||
[[1, 'up'], [2, 'down'], [3, 'up'], [4, 'doubletap']],
|
||
[[1, 'tap'], [2.5, 'left'], [3, 'tap'], [4, 'right']],
|
||
[[1, 'left'], [1.5, 'right'], [2.5, 'left'], [3, 'tap']],
|
||
[[1, 'tap'], [2, 'up'], [2.5, 'down'], [3, 'tap'], [4, 'doubletap']],
|
||
[[1, 'right'], [2, 'doubletap'], [3, 'left'], [3.5, 'right'], [4, 'up']],
|
||
[[1, 'tap'], [1.5, 'doubletap'], [2.5, 'left'], [3, 'tap'], [3.5, 'right'], [4, 'doubletap']],
|
||
[[1, 'left'], [2, 'doubletap'], [2.5, 'up'], [3, 'right'], [4, 'doubletap']],
|
||
[[1, 'tap'], [2.5, 'down'], [3, 'tap'], [3.5, 'up'], [4, 'doubletap']],
|
||
[[1, 'right'], [1.5, 'left'], [2, 'right'], [3, 'tap'], [4, 'doubletap']],
|
||
[[1, 'tap'], [2, 'doubletap'], [2.5, 'left'], [3, 'tap'], [3.5, 'right'], [4, 'doubletap']],
|
||
],
|
||
},
|
||
];
|
||
|
||
// 教学 5 步(T 组台词 + 复用 gd_answer「现在跟着我打」——上/下滑无专属台词,
|
||
// 零新语音资产;tut_3「最后一句」要保持为真,故上/下滑步插在其前)
|
||
const TUT_STEPS = [
|
||
{ voice: 'voice/tut_1', notes: [[1, 'tap']] },
|
||
{ voice: 'voice/tut_2', notes: [[1, 'left'], [3, 'right']] },
|
||
{ voice: 'voice/gd_answer', notes: [[1, 'up']] }, // 上滑 = 高通 tom
|
||
{ voice: 'voice/gd_answer', notes: [[1, 'down']] }, // 下滑 = 低通 tom(下滑折中同款:临近时是演奏)
|
||
{ voice: 'voice/tut_3', notes: [[1, 'tap'], [2, 'doubletap'], [3, 'tap']] },
|
||
];
|
||
|
||
const ALL_SFX = [...Object.values(INST), 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.rhythm.progress';
|
||
const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
|
||
|
||
export class RhythmGame extends Game {
|
||
constructor(audio, root) {
|
||
super(audio, root);
|
||
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
|
||
this.phase = 'idle'; // idle | intro | tut | select | main | menu | end
|
||
this._runId = 0; // 重开/切歌时令旧的语音链/调度回调失效
|
||
this._timer = null;
|
||
this._sched = []; // 待排程领奏事件 { time, sfx, volume }
|
||
this._timed = []; // 定时动作 { time, fn }(mid_song 等)
|
||
this._cues = []; // 应答期望 { gesture, expected, judged, result, phrase }
|
||
this._bgmSrc = null;
|
||
this._bgmGain = null;
|
||
this._bgmT0 = 0; // 当前 BGM 网格起点(ctx 时间)
|
||
this._song = null; // 当前曲目对象
|
||
this._songIdx = 0;
|
||
this._stats = null;
|
||
this._progress = this._loadProgress();
|
||
// 教学状态
|
||
this._tutIdx = 0;
|
||
this._tutFails = 0;
|
||
this._tutAttempts = [];
|
||
this._tutRate = 1.0; // G2:教学领奏速率(0.75 = 降速 25%,做对/离开该步恢复 1.0)
|
||
this._tutSlowUsed = false; // G2:降速重演每教学局 1 次封顶
|
||
// 菜单/暂停状态
|
||
this._menuIdx = 0;
|
||
this._pauseAt = 0;
|
||
this._bgmOffset = 0;
|
||
this._resumeSeq = 0; // G3:恢复令牌(count-in 语音期间再开菜单时作废旧恢复)
|
||
this._gdCountUsed = false; // G3:gd_count 每局(曲)恢复限 1 次
|
||
this._answerArmed = false; // G1:gd_answer+turn 交接标记是否已在 _timed 里待响
|
||
this._consecMiss = 0; // 连续 Miss 计数(向导 gd_miss:连 2 次提示一次)
|
||
// immediateTap(引擎 opt-in,index.html 已开启,按下即发 tap):
|
||
// true = tap 判定零补偿 + 非演奏阶段 tap 延迟 STRAY_TAP_MS 裁决(防序曲 tap
|
||
// 误触发);运行证据自动闩锁回退 false(onGesture 双击闩锁,见注释)
|
||
this._immediateTap = true;
|
||
this._lastTapEventAt = -Infinity; // 最近一次 tap 事件(performance.now() ms,闩锁证据用)
|
||
this._pendingTap = null; // 非演奏阶段延迟裁决的 tap / 教学期延迟裁决的跳句
|
||
this._tutPassedAny = false; // 教学是否有任一步通过(零通过收尾换中性句,不夸"很好")
|
||
this._leadBus = null; // 领奏总线(提示语音 ducking 作用于此)
|
||
this._midDuckTimer = null;
|
||
this._goingHome = false;
|
||
this._voiceSeq = 0; // 语音链序号(_speakGuarded 滞留看门狗作废用,见该方法注释)
|
||
this._tapJudg = []; // 序曲 tap 判定账簿(tapcancel/doubletap 到达时撤销,live round2 P0)
|
||
this._menuOpenedAt = -Infinity; // 菜单打开时刻(performance.now ms,去抖独占期用)
|
||
}
|
||
|
||
// ---------------- 生命周期 ----------------
|
||
|
||
start() {
|
||
this.audio.prewarm([...ALL_SFX, 'voice/welcome', 'voice/welcome_back',
|
||
'voice/tut_1', 'voice/select_hint', 'voice/mid_song', 'bgm/menu', ...SONGS.map((s) => s.bgm)]);
|
||
const rid = ++this._runId;
|
||
this._cleanup();
|
||
this._tutRate = 1.0;
|
||
this._tutSlowUsed = false; // G2:每教学局重新封顶
|
||
this._gdCountUsed = false; // G3:每局(曲)恢复限 1 次
|
||
this._tutPassedAny = false;
|
||
this._goingHome = false;
|
||
if (this._progress.tutorialDone) {
|
||
// 二周目起:缩减开场 → 选曲
|
||
this.phase = 'intro';
|
||
this._speakGuarded(['voice/welcome_back'], () => this._enterSelect(rid));
|
||
} else {
|
||
// 首次:welcome + BGM(第 6 秒起)→ 教学 T1
|
||
this.phase = 'intro';
|
||
this._startBgm(SONGS[0], this.audio.ctx.currentTime + 6.0); // 教学用曲目一
|
||
this._speakGuarded(['voice/welcome'], () => this._beginTutorial(rid, 0), 8000);
|
||
}
|
||
}
|
||
|
||
stop() {
|
||
this._runId++;
|
||
this._cleanup();
|
||
this.audio.setRateBoostEnabled(true); // 离开演奏期,恢复长按快进
|
||
this.audio.stopAll();
|
||
}
|
||
|
||
_cleanup() {
|
||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||
if (this._pendingTap) { clearTimeout(this._pendingTap); this._pendingTap = null; }
|
||
if (this._midDuckTimer) { clearTimeout(this._midDuckTimer); this._midDuckTimer = null; }
|
||
this._duckLeads(false);
|
||
this._voiceSeq++; // 作废全部滞留语音链守卫
|
||
this._tapJudg = []; // 序曲 tap 账簿随相位重置(跨相位不撤销)
|
||
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;
|
||
}
|
||
}
|
||
|
||
// ---------------- 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;
|
||
// 暂停发生在 BGM 起点前时 offset 为负:推迟起点而非传负偏移
|
||
// (Web Audio 的 start(when, 负 offset) 会抛 IndexSizeError)
|
||
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; // 网格起点(offset 用于断点续播)
|
||
src.start(t0, offset % buf.duration);
|
||
}
|
||
|
||
// ---------------- 节拍调度泵 ----------------
|
||
|
||
/** 在绝对时间 when 播放 sfx(走领奏总线 → 引擎 sfx 总线,保持音量/duck 语义)。 */
|
||
async _scheduleSfx(id, when, volume = 1) {
|
||
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);
|
||
g.connect(this._leadBusNode());
|
||
src.start(when);
|
||
}
|
||
|
||
/** 领奏总线(全部预排程音符经此接引擎 sfx 总线;提示语音期间整体 duck)。 */
|
||
_leadBusNode() {
|
||
if (!this._leadBus) {
|
||
this._leadBus = this.audio.ctx.createGain();
|
||
this._leadBus.connect(this.audio._sfxBus);
|
||
}
|
||
return this._leadBus;
|
||
}
|
||
|
||
/** 领奏 ducking(live round1 P2 提示语音压领奏):压到 1/4 / 恢复,200ms 渐变。 */
|
||
_duckLeads(on) {
|
||
if (!this.audio.ctx || !this._leadBus) return;
|
||
const t = this.audio.ctx.currentTime;
|
||
const g = this._leadBus.gain;
|
||
g.cancelScheduledValues(t);
|
||
g.setValueAtTime(g.value, t);
|
||
g.linearRampToValueAtTime(on ? 0.25 : 1.0, t + 0.2);
|
||
}
|
||
|
||
/** mid_song 提示语音:播前把领奏 duck 到 1/4,语音播完(按buffer时长估算)恢复。 */
|
||
async _playMidSong() {
|
||
if (!this.audio.ctx) return;
|
||
this._duckLeads(true);
|
||
if (this._midDuckTimer) clearTimeout(this._midDuckTimer);
|
||
const buf = await this.audio._load('voice/mid_song');
|
||
const speed = settings.get().speed || 1;
|
||
const ms = buf
|
||
? Math.min(4500, Math.max(1200, (buf.duration / speed + 0.5) * 1000))
|
||
: 2500; // 资产缺失兜底:固定时长后恢复
|
||
this._midDuckTimer = setTimeout(() => this._duckLeads(false), ms);
|
||
this.audio.play('voice/mid_song');
|
||
}
|
||
|
||
/** Miss 反馈音:闭镲降调 0.5×(闷掉的踩镲;复用现有资产,零新文件)。 */
|
||
async _playMissSfx() {
|
||
const buf = await this.audio._load(MISS_SFX);
|
||
if (!buf || !this.audio.ctx) return;
|
||
const src = this.audio.ctx.createBufferSource();
|
||
src.buffer = buf;
|
||
if (src.playbackRate) src.playbackRate.value = MISS_RATE;
|
||
const g = this.audio.ctx.createGain();
|
||
g.gain.value = this.audio.sfxVolume * 0.9;
|
||
src.connect(g);
|
||
g.connect(this.audio._sfxBus);
|
||
src.start();
|
||
}
|
||
|
||
/** 手势识别补偿(判定时刻 = 回调时刻 - 补偿 ≈ 真实触摸时刻)。 */
|
||
_compFor(g) {
|
||
if (g === 'tap') return this._immediateTap ? 0 : TAP_DELAY;
|
||
if (g === 'doubletap') return DOUBLE_DELAY;
|
||
return SWIPE_DELAY; // 四向滑动
|
||
}
|
||
|
||
/** 当前阶段的秒/拍(教学计降速;无曲目上下文按曲目一)。 */
|
||
_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;
|
||
|
||
// 排程未来 LOOKAHEAD 内的领奏音符(同步排一个对齐的 BEAT 轻震)
|
||
while (this._sched.length && this._sched[0].time <= now + LOOKAHEAD) {
|
||
const ev = this._sched.shift();
|
||
this._scheduleSfx(ev.sfx, ev.time, ev.volume);
|
||
const delay = Math.max(0, (ev.time - now) * 1000);
|
||
setTimeout(() => { if (this._runId === rid) vibrate('beat'); }, delay);
|
||
}
|
||
|
||
// 定时动作(mid_song 等)
|
||
while (this._timed.length && this._timed[0].time <= now) {
|
||
const ev = this._timed.shift();
|
||
ev.fn();
|
||
}
|
||
|
||
if (this.phase === 'main') {
|
||
// Miss 超时检查:放宽 = 该手势的识别补偿 + 0.10s(补偿后仍超窗才判超时)
|
||
for (const cue of this._cues) {
|
||
if (cue.judged) continue;
|
||
const margin = this._compFor(cue.gesture) + 0.10;
|
||
if (now > cue.expected + GOOD + margin) this._applyMiss(cue);
|
||
}
|
||
if (now >= this._phaseEnd) this._finish(rid);
|
||
} else if (this.phase === 'tut') {
|
||
if (now >= this._phaseEnd) this._tutEvaluate(rid);
|
||
}
|
||
}
|
||
|
||
_startPump(rid) {
|
||
if (this._timer) clearInterval(this._timer);
|
||
this._timer = setInterval(() => this._pump(rid), PUMP_MS);
|
||
}
|
||
|
||
_sortSched() {
|
||
this._sched.sort((a, b) => a.time - b.time);
|
||
this._timed.sort((a, b) => a.time - b.time);
|
||
}
|
||
|
||
// ---------------- 教学(T1-T3) ----------------
|
||
|
||
_beginTutorial(rid, idx) {
|
||
if (this._runId !== rid) return;
|
||
this._tutIdx = idx;
|
||
this._tutFails = 0;
|
||
this._cues = []; // 步骤引导语音期间无 cue("语音播放中单击=跳句"的判定语境)
|
||
this._tutAttempts = [];
|
||
this._speakGuarded([TUT_STEPS[idx].voice], () => this._tutLead(rid));
|
||
}
|
||
|
||
/** 播教学领奏(对齐 BGM 网格的下一小节),应答 = 领奏 + 4 拍。 */
|
||
_tutLead(rid) {
|
||
if (this._runId !== rid) return;
|
||
this.phase = 'tut';
|
||
const song = SONGS[0]; // 教学在曲目一 BGM 上进行
|
||
const spb = 60 / song.bpm;
|
||
const now = this.audio.ctx.currentTime;
|
||
// 下一小节头作为领奏小节(至少留 1.2s 缓冲,且不早于 BGM 网格起点)
|
||
const leadBar = this._bgmT0
|
||
+ Math.max(0, Math.ceil((now + 1.2 - this._bgmT0) / (4 * spb))) * 4 * spb;
|
||
this._tutAttempts = [];
|
||
this._cues = [];
|
||
// G2(round3):降速只拉伸该步的领奏/应答网格(间隔 ×1/0.75),领奏小节头
|
||
// 仍对齐 BGM 小节;排程照走 _sched 绝对时间 + 40ms 泵,泵与判定无需感知速率。
|
||
const stepSpb = spb / this._tutRate;
|
||
for (const [beat, g] of TUT_STEPS[this._tutIdx].notes) {
|
||
const t = leadBar + (beat - 1) * stepSpb;
|
||
this._sched.push({ time: t, sfx: INST[g], volume: 1 });
|
||
this._cues.push({ gesture: g, expected: t + 4 * stepSpb, judged: false, phrase: -1 });
|
||
}
|
||
this._sortSched();
|
||
const last = this._cues[this._cues.length - 1];
|
||
this._phaseEnd = last.expected + GOOD + TAP_DELAY + 0.4;
|
||
this._startPump(rid);
|
||
}
|
||
|
||
/** 教学应答评估:全部音在窗口内用对手势 → 通过;否则纠正重教。 */
|
||
_tutEvaluate(rid) {
|
||
if (this._runId !== rid || this.phase !== 'tut') return;
|
||
// 评估是一次性的:先废掉触发条件——通过/失败后都要播语音,
|
||
// 语音间隙期 phase 仍是 'tut' 且 now ≥ _phaseEnd,泵会每 40ms 重进这里,
|
||
// 否则一次通过会级联"连过"剩余步骤(trace 实测:T1 通过直接跳到结算)。
|
||
this._phaseEnd = Infinity;
|
||
const pass = this._cues.every((cue) => {
|
||
const a = this._tutAttempts.find((x) => x.cue === cue);
|
||
return a && a.correct && Math.abs(a.dt) <= GOOD;
|
||
});
|
||
if (pass) {
|
||
this._tutPassedAny = true;
|
||
this._tutRate = 1.0; // G2:该步做对,恢复正常速度
|
||
if (this._tutIdx + 1 < TUT_STEPS.length) {
|
||
this._beginTutorial(rid, this._tutIdx + 1);
|
||
} else {
|
||
this._finishTutorial(rid);
|
||
}
|
||
return;
|
||
}
|
||
this._tutFails++;
|
||
vibrate('error');
|
||
if (this._tutFails >= 2) {
|
||
// G2(round3 教学降难 DDA):同一步连续 2 次失败 → 该步领奏降速 25% 重演
|
||
// (每教学局 1 次封顶;full 档先播 gd_slow「我放慢一些,跟着试试。」)——
|
||
// P4「六连错无出路」:失败后第一次有变慢的路,而不是直接跳步。封顶用尽
|
||
// 或 off 档(零改动红线)→ 维持 round2 的 retry_skip 跳步原路径。
|
||
if (this.guide.level() !== 'off' && !this._tutSlowUsed) {
|
||
this._tutSlowUsed = true;
|
||
this._tutRate = 0.75;
|
||
this._tutFails = 0; // 降速重演 = 该步重新给两次机会(再连 2 失败才跳步)
|
||
if (this.guide.level() === 'full') {
|
||
this._speakGuarded(['voice/gd_slow'], () => this._tutLead(rid));
|
||
} else {
|
||
this._tutLead(rid);
|
||
}
|
||
return;
|
||
}
|
||
this._tutRate = 1.0; // 离开该步(跳步):降速只作用于该步
|
||
// 同一步失败 2 次跳过,不卡流程(归因已在首次失败时给过,此处不再叠加)
|
||
this._speakGuarded(['voice/retry_skip'], () => {
|
||
if (this._tutIdx + 1 < TUT_STEPS.length) this._beginTutorial(rid, this._tutIdx + 1);
|
||
else this._finishTutorial(rid);
|
||
});
|
||
return;
|
||
}
|
||
// F2.1(round2)+ H5(round4):教学 Miss 首次即归因、按失败原因分档。
|
||
// H5 归因真话律:应答窗口无任何尝试(窗口关闭时该音未判定——出窗的自由
|
||
// 演奏不算尝试)优先播 gd_miss_none「这拍没接」,早/晚/错分档只保留给
|
||
// 真出手的情况(P1 round3:没出手被报「慢了半拍」是归因说假话——"宁可粗
|
||
// 不可假";正片连 2 Miss 仍走 gd_miss,本档只改教学归因)。
|
||
// 向导句(full 档)挂在纠正链播完后的空闲点发:idle-only 语义下链中插播会被
|
||
// 跳过;之后 _tutLead 只排 sfx,不与归因句抢 voice 通道。
|
||
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' // none 优先级最高
|
||
: wrong ? 'gd_miss_wrong'
|
||
: offbeat ? (offbeat.dt < 0 ? 'gd_miss_early' : 'gd_miss_late') : 'gd_miss_late';
|
||
// 纠正语音:手势错 > 过早 > 过晚 > 没出手(retry_listen)
|
||
let correction = 'voice/retry_listen';
|
||
if (wrong) correction = 'voice/retry_gesture';
|
||
else if (offbeat) correction = offbeat.dt < 0 ? 'voice/retry_early' : 'voice/retry_late';
|
||
this._speakGuarded([correction, 'voice/retry_listen'], () => {
|
||
this.guide.error({ voice: missGrade, skipHaptic: true });
|
||
this._tutLead(rid);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 教学收尾:有任一步通过播 tut_done「很好,演出开始」;全程零通过换中性句
|
||
* retry_skip(live round1 P2:零命中被夸"很好"出戏——台词文本不变,只改
|
||
* 触发逻辑)。
|
||
*/
|
||
_finishTutorial(rid) {
|
||
this._progress.tutorialDone = true;
|
||
this._saveProgress();
|
||
const line = this._tutPassedAny ? 'voice/tut_done' : 'voice/retry_skip';
|
||
this._speakGuarded([line], () => this._startSong(rid, 0));
|
||
}
|
||
|
||
// ---------------- 选曲 ----------------
|
||
|
||
_enterSelect(rid) {
|
||
if (this._runId !== rid) return;
|
||
this.phase = 'select';
|
||
this.audio.setRateBoostEnabled(true); // 离开演奏期,恢复长按快进
|
||
// 10 文档 §5:选曲界面 = menu pad(引擎层淡入 800ms;bgm=0 时引擎只记意图)
|
||
this.audio.play('bgm/menu', { fadeMs: 800 });
|
||
this.guide.state('menu'); // 状态签名:选曲界面(与状态 BGM 同点)
|
||
this._speak(['voice/select_hint', SONGS[this._songIdx].nameVoice]);
|
||
}
|
||
|
||
_onSelectGesture(rid, g) {
|
||
if (g === 'left' || g === 'right') {
|
||
const n = SONGS.length;
|
||
this._songIdx = (this._songIdx + (g === 'right' ? 1 : n - 1)) % n;
|
||
const song = SONGS[this._songIdx];
|
||
this._speak([this._songIdx < this._progress.unlocked ? song.nameVoice : 'voice/song_locked']);
|
||
} else if (g === 'tap') {
|
||
if (this._songIdx < this._progress.unlocked) {
|
||
this._speak([SONGS[this._songIdx].nameVoice]);
|
||
this._startSong(rid, this._songIdx);
|
||
} else {
|
||
this._speak(['voice/song_locked']);
|
||
}
|
||
} else if (g === 'down') {
|
||
this._goHome();
|
||
} else if (g === 'up') {
|
||
this._speak(['voice/rules']);
|
||
}
|
||
}
|
||
|
||
// ---------------- 正式曲目 ----------------
|
||
|
||
_startSong(rid, songIdx) {
|
||
if (this._runId !== rid) return;
|
||
this._cleanup();
|
||
this.audio.setRateBoostEnabled(false); // 演奏期豁免长按快进(12 文档 §2.2)
|
||
// 10 文档 §5:选定曲目后停 menu pad(选曲/结算态可能在播),再按网格起曲目 BGM
|
||
this.audio.stopBgm(400);
|
||
this.phase = 'main';
|
||
this.guide.state('battle'); // 状态签名:演奏/对抗(与状态 BGM 切换同点)
|
||
this._songIdx = songIdx;
|
||
this._song = SONGS[songIdx];
|
||
this._stats = { perfect: 0, good: 0, miss: 0, combo: 0, maxCombo: 0 };
|
||
this._consecMiss = 0;
|
||
|
||
const spb = 60 / this._song.bpm;
|
||
const now = this.audio.ctx.currentTime;
|
||
const bgmT0 = now + 1.0; // 1s 后 BGM 起,第 1 小节即数拍
|
||
this._startBgm(this._song, bgmT0);
|
||
|
||
// 排程:数拍 1 小节(BGM 内鼓点)→ 逐乐句(领奏 1 小节 + 应答 1 小节 + 休止)
|
||
let bar = 1;
|
||
this._cues = [];
|
||
const midPhrase = Math.floor(this._song.phrases.length / 2);
|
||
this._song.phrases.forEach((notes, i) => {
|
||
const leadBarT0 = bgmT0 + bar * 4 * spb;
|
||
for (const [beat, g] of notes) {
|
||
const t = leadBarT0 + (beat - 1) * spb;
|
||
this._sched.push({ time: t, sfx: INST[g], volume: 1 });
|
||
this._cues.push({ gesture: g, expected: t + 4 * spb, judged: false, result: null, phrase: i });
|
||
}
|
||
if (i === midPhrase) {
|
||
// 提示语音不压领奏(live round1 P2):经领奏总线 ducking 到 1/4
|
||
this._timed.push({ time: leadBarT0, fn: () => this._playMidSong() });
|
||
}
|
||
if (i === 0) {
|
||
// 向导层(round3 G1):每次进 main,首个应答窗口**前整整 1 小节**(= 领奏
|
||
// 小节头)落下 gd_answer+turn 交接标记。round2 F3 的排程点(leadBarT0 +
|
||
// 4*spb)其实是窗口起点本身——P4 实测(窗口 t+79~95,gd_answer t+93.7)
|
||
// 窗口开了才听到,起不到「快轮到你了」的预警作用。gd_answer 不走 guide
|
||
// 的 queue(对白在播时会排到链尾);语音 full 档直达,turn earcon standard
|
||
// 也发(off 全静不变)。mid_song 同款先例。
|
||
this._answerArmed = true;
|
||
this._timed.push({ time: leadBarT0, fn: () => this._fireAnswerCue() });
|
||
}
|
||
bar += 2 + (this._song.restAfter[i] || 0);
|
||
});
|
||
this._sortSched();
|
||
const last = this._cues[this._cues.length - 1];
|
||
this._phaseEnd = last.expected + GOOD + TAP_DELAY + 0.5;
|
||
this._startPump(rid);
|
||
}
|
||
|
||
/** G1:gd_answer+turn 交接标记(语音 full 档直达,turn earcon standard 也发)。 */
|
||
_fireAnswerCue() {
|
||
this._answerArmed = false;
|
||
if (this.guide.level() === 'full') this.audio.play('voice/gd_answer');
|
||
this.guide.turn({});
|
||
}
|
||
|
||
/**
|
||
* G1:为「下一个未判定的应答窗口」排交接标记,落在窗口前整整 1 小节(= 该句
|
||
* 领奏小节头;已迫近则尽早在 0.15s 内补上)。_startSong 已为乐句 0 排过;菜单
|
||
* 恢复(也是一次「进 main」)时若旧标记已消费、且前面还有未判定的窗口,对
|
||
* 恢复后的首个窗口重排同款标记。上一句结束后的下一句不适用(裁定原文)。
|
||
*/
|
||
_armAnswerCue(rid) {
|
||
if (this._answerArmed) return; // 旧标记仍在 _timed 里,随网格平移照常提前 1 小节响
|
||
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();
|
||
}
|
||
|
||
// ---------------- 手势与判定 ----------------
|
||
|
||
onGesture(g) {
|
||
const rid = this._runId;
|
||
// 旧引擎侦测闩锁:immediateTap 下双击前必有 ≤300ms 的序曲 tap(第一下按下即发);
|
||
// 旧引擎的双击会取消挂起的单击——双击事件前 350ms 内必无 tap 事件。据此一次性
|
||
// 回退旧补偿与旧路径(index.html 未开 immediateTap 时兜底;反过来不闩)。
|
||
if (g === 'doubletap' && this._immediateTap
|
||
&& performance.now() - this._lastTapEventAt > 350) {
|
||
this._immediateTap = false;
|
||
}
|
||
if (g === 'tap') this._lastTapEventAt = performance.now();
|
||
// tapcancel(引擎 immediateTap 补发:按下后转滑动/长按/触摸取消)——序曲 tap
|
||
// 的声音已出收不回,但判定必须撤销且不计 Miss(live round2 P0:爆音不进判定链)。
|
||
// 非演奏阶段的延迟裁决 tap 同步作废——比"后续手势到达才作废"更早(慢滑在
|
||
// 500ms 裁决窗内超 slop 即撤,选曲慢滑不再误触发"选定曲目")。
|
||
if (g === 'tapcancel') {
|
||
if (this._pendingTap) { clearTimeout(this._pendingTap); this._pendingTap = null; }
|
||
if (this._isPlayPhase()) this._revokeTaps(TAPCANCEL_REVOKE_S, 1);
|
||
return;
|
||
}
|
||
// 双击序曲治理(tap+tap+doubletap 序列的判定归属):两次按下的序曲 tap
|
||
// 判定(含可能误认领的命中)一并撤销后,本次双击再正常判定
|
||
if (g === 'doubletap' && this._immediateTap && this._isPlayPhase()) {
|
||
this._revokeTaps(DOUBLE_REVOKE_S, 2);
|
||
}
|
||
// immediateTap 序曲 tap 治理:非演奏阶段的 tap 可能是一次滑动/双击/长按的
|
||
// 触摸起点——延迟 STRAY_TAP_MS 生效;窗口内来了任何其他手势说明那是序曲,
|
||
// 作废这次 tap(窗口 > longpressMs 450ms,长按也能抢先作废;否则选曲界面
|
||
// 右滑切歌会先误触发"选定曲目")
|
||
if (g === 'tap' && this._immediateTap && !this._isPlayPhase()) {
|
||
if (this._pendingTap) clearTimeout(this._pendingTap);
|
||
this._pendingTap = setTimeout(() => {
|
||
this._pendingTap = null;
|
||
this._dispatch(rid, 'tap');
|
||
}, STRAY_TAP_MS);
|
||
return;
|
||
}
|
||
if (g !== 'tap' && this._pendingTap) {
|
||
clearTimeout(this._pendingTap);
|
||
this._pendingTap = null;
|
||
}
|
||
this._dispatch(rid, g);
|
||
}
|
||
|
||
/** 演奏阶段(tap 是 kick 演奏输入,即刻生效不延迟裁决)。 */
|
||
_isPlayPhase() {
|
||
return this.phase === 'tut' || this.phase === 'main';
|
||
}
|
||
|
||
/** 手势分派(onGesture 已完成序曲 tap 裁决与旧引擎闩锁)。 */
|
||
_dispatch(rid, g) {
|
||
switch (this.phase) {
|
||
case 'tut':
|
||
// 下滑折中(教学同款):临近 down 音符时是演奏,否则下滑 = 跳过教学
|
||
if (g === 'down' && !this._downNotePending()) {
|
||
this._progress.tutorialDone = true;
|
||
this._saveProgress();
|
||
this._speakGuarded(['voice/tut_skip'], () => this._startSong(rid, 0));
|
||
return;
|
||
}
|
||
// 教学语音播放中单击 = 跳过本句(12 文档 §1.2);无语音时才是跟打手势。
|
||
// immediateTap 下跳句延迟裁决(序曲 tap 不跳句),演奏部分照常即时处理
|
||
if (g === 'tap' && this.audio.isVoicePlaying()) {
|
||
if (!this._immediateTap) { this.audio.skipVoice(); return; }
|
||
if (this._pendingTap) clearTimeout(this._pendingTap);
|
||
this._pendingTap = setTimeout(() => {
|
||
this._pendingTap = null;
|
||
if (this._runId === rid && this.phase === 'tut' && this.audio.isVoicePlaying()) {
|
||
this.audio.skipVoice();
|
||
}
|
||
}, STRAY_TAP_MS);
|
||
}
|
||
this._onTutGesture(g);
|
||
return;
|
||
case 'select':
|
||
if (this._trySkipVoice(g)) return; // 选曲播报可逐句跳过
|
||
this._onSelectGesture(rid, g);
|
||
return;
|
||
case 'main':
|
||
// 演奏期单击是演奏手势,永不跳过(mid_song 短句按裁定不可跳)
|
||
this._onMainGesture(rid, g);
|
||
return;
|
||
case 'menu':
|
||
this._onMenuGesture(rid, g);
|
||
return;
|
||
case 'end':
|
||
if (this._trySkipVoice(g)) return; // 结算播报可逐句跳过
|
||
if (g === 'tap') this._startSong(rid, this._songIdx); // 再来一遍(同曲)
|
||
else if (g === 'right') { // 下一首(若已解锁)
|
||
if (this._songIdx + 1 < this._progress.unlocked) this._startSong(rid, this._songIdx + 1);
|
||
else if (this._songIdx + 1 >= SONGS.length) this._speak(['voice/restart']); // 已是最后一首(策划裁定 round3-4-review)
|
||
else this._speak(['voice/song_locked']);
|
||
} else if (g === 'down') this._goHome();
|
||
return;
|
||
default:
|
||
this._trySkipVoice(g); // intro:只收"单击跳旁白",其余手势不收
|
||
return;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* voice 播放中单击 = 跳过当前句(12 文档 §1.2,链播继续到下一句)。
|
||
* 用引擎公开的 isVoicePlaying() 判定(原读私有 _voice,清点 §0)。
|
||
* @returns {boolean} true = 该手势已消费(跳过了一句)
|
||
*/
|
||
_trySkipVoice(g) {
|
||
if (g !== 'tap' || !this.audio.isVoicePlaying()) return false;
|
||
this.audio.skipVoice();
|
||
return true;
|
||
}
|
||
|
||
/** 教学阶段手势:全量记录尝试(评估用),乐器音照播(认识音色)。 */
|
||
_onTutGesture(g) {
|
||
if (!INST[g] || !this.audio.ctx) return;
|
||
this.audio.play(INST[g]);
|
||
const t = this.audio.ctx.currentTime - this._compFor(g); // 判定回到触摸时刻
|
||
let best = null;
|
||
let bestDt = Infinity;
|
||
for (const cue of this._cues) {
|
||
const dt = Math.abs(t - cue.expected);
|
||
if (dt < bestDt) { bestDt = dt; best = cue; }
|
||
}
|
||
// tap 只认领 tap 音符的尝试(immediateTap 序曲 tap 不算"做错手势")
|
||
if (best && (g !== 'tap' || best.gesture === 'tap')) {
|
||
const att = { cue: best, gesture: g, dt: t - best.expected, correct: g === best.gesture };
|
||
this._tutAttempts.push(att);
|
||
if (g === 'tap') this._recordTapJudg(null, false, att); // tapcancel 可撤
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 序曲 tap 判定账簿(live round2 P0):immediateTap 下每次按下先发 tap,
|
||
* 该 tap 的判定先记账;若本次按下最终转滑动/长按(引擎补 tapcancel)或
|
||
* 改判双击(doubletap 到达),按账撤销——声音已出收不回,判定/统计必须
|
||
* 更正且不计 Miss。cue=null 表示自由演奏(无判定可撤,仅登记供对账)。
|
||
*/
|
||
_recordTapJudg(cue, perfect, tutAttempt = null) {
|
||
this._tapJudg.push({
|
||
at: this.audio.ctx.currentTime, cue, perfect, tutAttempt,
|
||
prevCombo: this._stats ? this._stats.combo : 0,
|
||
prevConsec: this._consecMiss,
|
||
});
|
||
if (this._tapJudg.length > 8) this._tapJudg.shift();
|
||
}
|
||
|
||
/** 撤销最近 maxCount 笔、账龄 ≤ maxAgeS 的 tap 判定(新→旧,超龄即停)。 */
|
||
_revokeTaps(maxAgeS, maxCount = 1) {
|
||
if (!this.audio.ctx) return;
|
||
const now = this.audio.ctx.currentTime;
|
||
let n = 0;
|
||
for (let i = this._tapJudg.length - 1; i >= 0 && n < maxCount; i--) {
|
||
const j = this._tapJudg[i];
|
||
if (now - j.at > maxAgeS) break; // 账簿按时间升序,更旧的与本手势无关
|
||
this._tapJudg.splice(i, 1);
|
||
n++;
|
||
if (j.tutAttempt) {
|
||
const k = this._tutAttempts.indexOf(j.tutAttempt);
|
||
if (k >= 0) this._tutAttempts.splice(k, 1);
|
||
}
|
||
if (j.cue && j.cue.judged && j.cue.result !== 'miss' && this._stats) {
|
||
// 序曲 tap 曾认领的命中撤销:音符回到未判定(真不接则由超时 Miss 兜底)
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 同手势音符刚被超时判 Miss(识别延迟把音符拖死、手势还在路上)——找最近
|
||
* 一个 REVIVE_WINDOW_S 内 timeout 类 Miss 的同手势 cue;只认超时类(错手势
|
||
* 类是真错误,不复活)。tap 不需要复活:immediateTap 下 tap 识别零延迟,
|
||
* 回调晚于死线 = 触摸本身已晚(且序曲 tap 不得复活音符——双击序曲会误复活)。
|
||
*/
|
||
_recentTimeoutMiss(g) {
|
||
if (!this.audio.ctx || g === 'tap') return null;
|
||
const now = this.audio.ctx.currentTime;
|
||
let best = null;
|
||
let bestDt = Infinity;
|
||
for (const cue of this._cues) {
|
||
if (!cue.judged || cue.result !== 'miss' || cue.missKind !== 'timeout'
|
||
|| cue.gesture !== g) continue;
|
||
if (now - cue.missedAt > REVIVE_WINDOW_S) continue;
|
||
const dt = Math.abs(now - cue.expected);
|
||
if (dt < bestDt) { bestDt = dt; best = cue; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/** 复活超时误判:统计回滚、音符回到未判定(闷镲 Miss 音已出收不回)。 */
|
||
_reviveMiss(cue) {
|
||
this._stats.miss--;
|
||
this._stats.combo = cue.comboBefore;
|
||
this._consecMiss = cue.consecBefore;
|
||
cue.judged = false;
|
||
cue.result = null;
|
||
}
|
||
|
||
_onMainGesture(rid, g) {
|
||
if (!this.audio.ctx) return;
|
||
// 长按 = 死输入给定义(live round1 P0):Miss 反馈音 + error 震动,不判定不归属
|
||
if (g === 'longpress') { this._playMissSfx(); vibrate('error'); return; }
|
||
if (g === 'longpressend') return;
|
||
// 下滑折中:有悬置/临近的 down 音符时是演奏,否则呼出菜单。
|
||
// live round2 P1 续修:down 音符刚被超时判 Miss(识别延迟拖死、下滑还在路上)
|
||
// 同样算"临近"——旧版此时必弹菜单(round2 事件流 3/3 复现:超时 Miss 与下滑
|
||
// 回调相差 2~114ms,_downNotePending 已判假)。aimed=true 期间下滑永不弹菜单。
|
||
let downAimed = false;
|
||
if (g === 'down') {
|
||
downAimed = !!this._recentTimeoutMiss('down');
|
||
if (!this._downNotePending() && !downAimed) {
|
||
this._openMenu(rid);
|
||
return;
|
||
}
|
||
}
|
||
if (!INST[g]) return;
|
||
const t = this.audio.ctx.currentTime - this._compFor(g); // 判定回到触摸时刻
|
||
|
||
// 归属①:同手势的最近未判定音符优先(防密集谱跨手势抢占——同句 0.5 拍相邻
|
||
// 的两个异手势音符,按正确手势略偏时刻出手不得被邻音偷走判定)
|
||
let best = null;
|
||
let bestDt = Infinity;
|
||
for (const cue of this._cues) {
|
||
if (cue.judged || cue.gesture !== g) continue;
|
||
const dt = Math.abs(t - cue.expected);
|
||
if (dt < bestDt) { bestDt = dt; best = cue; }
|
||
}
|
||
if (best && bestDt <= GOOD) {
|
||
// Perfect / Good:出手即播乐器音(演奏感),Perfect 叠 accent 亮音
|
||
const perfect = bestDt <= PERFECT;
|
||
this.audio.play(INST[g]);
|
||
if (perfect) this.audio.play(ACCENT, { volume: 0.8 });
|
||
if (g === 'tap') this._recordTapJudg(best, perfect);
|
||
this._applyHit(best, perfect);
|
||
return;
|
||
}
|
||
// 归属①′(live round2 P0):同手势音符刚被超时拖死 → 复活改判。
|
||
// 事件流实证:双击识别 +0.2s/滑动 +0.3s,超时死线 expected+0.60~0.65s 常在
|
||
// 手势到达前几十 ms 先判 Miss;补偿后 dt 仍在 GOOD+0.15 内 = 触摸其实在线内
|
||
const revive = this._recentTimeoutMiss(g);
|
||
if (revive && Math.abs(t - revive.expected) <= REVIVE_DT_S) {
|
||
this._reviveMiss(revive);
|
||
const perfect = Math.abs(t - revive.expected) <= PERFECT;
|
||
this.audio.play(INST[g]);
|
||
if (perfect) this.audio.play(ACCENT, { volume: 0.8 });
|
||
this._applyHit(revive, perfect);
|
||
return;
|
||
}
|
||
// 归属②:最近的异手势音符落在错手势窗(GOOD 的一半)内 = 窗口内做错动作
|
||
// = Miss(不播该音符的乐器音)。tap 豁免——immediateTap 下每次滑动/双击的
|
||
// 触摸起点先发序曲 tap,若认领异手势 Miss,每次滑动前那声 tap 都会把目标
|
||
// 音符误判掉;真正的错手势 tap 由该音符超时自动 Miss 兜底(同样有 Miss 音)。
|
||
// downAimed 同样豁免——冲着刚拖死的 down 音符去的下滑,不得顺杀邻音。
|
||
let wrong = null;
|
||
let wrongDt = Infinity;
|
||
if (g !== 'tap' && !downAimed) {
|
||
for (const cue of this._cues) {
|
||
if (cue.judged || cue.gesture === g) continue;
|
||
const dt = Math.abs(t - cue.expected);
|
||
if (dt < wrongDt) { wrongDt = dt; wrong = cue; }
|
||
}
|
||
}
|
||
if (wrong && wrongDt <= GOOD * 0.5) {
|
||
this._applyMiss(wrong, 'wrong');
|
||
return;
|
||
}
|
||
this.audio.play(INST[g]); // 窗口外自由演奏:出声音符但不判定
|
||
if (g === 'tap') this._recordTapJudg(null, false);
|
||
}
|
||
|
||
/**
|
||
* 当前有无悬置或临近的 down 应答音符(live round1 P0 加宽:自该音符领奏响起
|
||
* ——expected-4 拍,玩家已有出手理由——到判定死线之间都算临近。旧窗口
|
||
* (expected-1s ~ +0.55s)跟不上滑动识别延迟:识别在 touchend,踩点下滑到达
|
||
* 时音符常已被判超时,于是必弹菜单)。
|
||
*/
|
||
_downNotePending() {
|
||
if (!this.audio.ctx) return false;
|
||
const now = this.audio.ctx.currentTime;
|
||
const spb = this._spb();
|
||
return this._cues.some((cue) => !cue.judged && cue.gesture === 'down'
|
||
&& cue.expected > now - GOOD - SWIPE_DELAY - 0.10
|
||
&& cue.expected - 4 * spb < now + 0.2);
|
||
}
|
||
|
||
_applyHit(cue, perfect) {
|
||
if (cue.judged) return;
|
||
cue.judged = true;
|
||
cue.result = perfect ? 'perfect' : 'good';
|
||
this._consecMiss = 0; // 打上了就断开连续 Miss 链
|
||
const s = this._stats;
|
||
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'); // 16 连击
|
||
this._checkPhraseAllPerfect(cue.phrase);
|
||
}
|
||
|
||
_applyMiss(cue, kind = 'timeout') {
|
||
if (cue.judged) return;
|
||
cue.judged = true;
|
||
cue.result = 'miss';
|
||
// 复活账(live round2 P0):超时类 Miss 可能是识别延迟拖死的误判——手势
|
||
// 到达时若补偿时刻仍在线内则 _reviveMiss 回滚;kind=wrong 的真错误不复活
|
||
cue.missKind = kind;
|
||
cue.missedAt = this.audio.ctx ? this.audio.ctx.currentTime : 0;
|
||
cue.comboBefore = this._stats.combo;
|
||
cue.consecBefore = this._consecMiss;
|
||
this._stats.miss++;
|
||
this._stats.combo = 0;
|
||
// Miss 不再无声(live round1 P1):该音符的乐器音仍不播(错过=没出声),
|
||
// 但给可区分的闷镲反馈音 + error 震动
|
||
this._playMissSfx();
|
||
vibrate('error');
|
||
// 向导层(schemes §2):连续 2 次 Miss 播 gd_miss(同曲限 1 次;已震过 error,
|
||
// skipHaptic 免重复震;voice 走 queue 插播,不顶 mid_song/结算链)
|
||
this._consecMiss++;
|
||
if (this._consecMiss === 2) {
|
||
this.guide.error({ voice: 'gd_miss', onceKey: 'rhythm_miss', skipHaptic: true });
|
||
}
|
||
this._checkPhraseAllPerfect(cue.phrase);
|
||
}
|
||
|
||
/** 乐句全部 Perfect:乐句末震 [20,40,20]。 */
|
||
_checkPhraseAllPerfect(phraseIdx) {
|
||
if (phraseIdx < 0) return;
|
||
const notes = this._cues.filter((c) => c.phrase === phraseIdx);
|
||
if (notes.every((c) => c.judged) && notes.every((c) => c.result === 'perfect')) {
|
||
vibrate([20, 40, 20]);
|
||
}
|
||
}
|
||
|
||
// ---------------- 菜单(模态暂停,BGM 断点续播) ----------------
|
||
|
||
_openMenu(rid) {
|
||
if (this._runId !== rid) return;
|
||
this.phase = 'menu';
|
||
this._menuIdx = 0;
|
||
this._menuOpenedAt = performance.now(); // 去抖独占期起点(live round2 P1 瞬穿)
|
||
this.audio.setRateBoostEnabled(true); // 暂停期不是演奏期,恢复长按快进
|
||
this.guide.state('menu'); // 状态签名:菜单(模态暂停)
|
||
// 暂停:停泵、停 BGM(记断点),已排程的 ≤0.6s 音符自然播完;
|
||
// 提示语音 ducking 一并复位(恢复后领奏不再被压)
|
||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||
if (this._midDuckTimer) { clearTimeout(this._midDuckTimer); this._midDuckTimer = null; }
|
||
this._duckLeads(false);
|
||
this._resumeSeq++; // G3:作废还在等 gd_count 播完的旧恢复
|
||
this._pauseAt = this.audio.ctx.currentTime;
|
||
// 断点续播偏移只在 BGM 真在播时刷新(count-in 语音期间再开菜单时 BGM 未起,
|
||
// 沿用首次开的偏移,避免把暂停时长算进播放位置)
|
||
if (this._bgmSrc) this._bgmOffset = this._pauseAt - this._bgmT0;
|
||
this._stopBgmSrc();
|
||
this._speak(['voice/menu_main', MENU_ITEMS[0]]);
|
||
}
|
||
|
||
_onMenuGesture(rid, g) {
|
||
// 去抖独占期(live round2 P1):菜单打开 1s 内不收任何手势——演奏途中被
|
||
// 弹菜单时,玩家已经在路上的手势(双击/下滑)不得直接穿透菜单当成菜单
|
||
// 操作(实测 menu_main 说明语音 0.6s 未读完菜单就被"确认"没了)
|
||
if (performance.now() - this._menuOpenedAt < MENU_DEBOUNCE_MS) return;
|
||
if (g === 'tap') {
|
||
this._menuIdx = (this._menuIdx + 1) % MENU_ITEMS.length;
|
||
this.audio.play(MENU_ITEMS[this._menuIdx]);
|
||
} else if (g === 'up') {
|
||
this._speak(['voice/rules', 'voice/settings_hint']);
|
||
} else if (g === 'down') {
|
||
this._closeMenu(rid);
|
||
} else if (g === 'doubletap') {
|
||
if (this._menuIdx === 0) this._closeMenu(rid);
|
||
else this._goHome();
|
||
}
|
||
}
|
||
|
||
_closeMenu(rid) {
|
||
if (this._runId !== rid) return;
|
||
this.phase = 'main';
|
||
this.audio.setRateBoostEnabled(false); // 回到演奏期
|
||
// 关菜单先停菜单语音链(live round1 P0:menu_main/menu_resume 曾继续骑在
|
||
// 恢复后的数拍与领奏上,是"菜单不暂停"感知的主因)
|
||
this.audio.stopVoice();
|
||
const token = ++this._resumeSeq; // G3:恢复令牌(语音等待期间再开菜单则作废)
|
||
const resume = () => {
|
||
if (this._runId !== rid || token !== this._resumeSeq) return;
|
||
const resumeAt = this.audio.ctx.currentTime + 0.1;
|
||
// G3(round3):断点续播前插一整小节 4 拍 count-in——底鼓走 sfx 层独立于
|
||
// BGM、音量提到可辨(0.7→1.0),每拍带 beat 轻震(_sched 泵同步排),网格
|
||
// 与 BGM 整体后移一小节,杜绝 P3 实测的「回来 5 秒死寂、第一句报废」。
|
||
// round2 F3 的 count-in 实际从未按时响过:底鼓 push 进 _sched 后没重排,
|
||
// 队头是数秒外的领奏音符,40ms 泵的 0.6s 前瞻窗口永远轮不到它——本次修复
|
||
// push 后 _sortSched()。off 档保持原即时续播(零改动路径)。
|
||
const countIn = this.guide.level() !== 'off' ? 4 * (60 / this._song.bpm) : 0;
|
||
const bgmAt = resumeAt + countIn;
|
||
const shift = bgmAt - this._pauseAt;
|
||
// 全部绝对时刻平移(暂停时长 + count-in 小节),节拍网格与 BGM 断点续播保持同步
|
||
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;
|
||
for (let i = 0; i < 4 && countIn > 0; i++) {
|
||
this._sched.push({ time: resumeAt + i * (countIn / 4), sfx: INST.tap, volume: 1.0 });
|
||
}
|
||
this._sortSched(); // G3 修复:count-in 必须重排入队,否则被队头阻塞整段迟到
|
||
// _startBgm 内部重算 _bgmT0 = bgmAt - offset = 旧网格 + shift(负偏移保护同款成立)
|
||
this._startBgm(this._song, bgmAt, this._bgmOffset);
|
||
this._startPump(rid);
|
||
this._armAnswerCue(rid); // G1:菜单恢复也是一次「进 main」,给下一个窗口补交接标记
|
||
};
|
||
// G3:gd_count「四拍之后,继续。」full 档、每局(曲)恢复限 1 次,在 count-in
|
||
// 之前经语音链串行播出(链完或缺失看门狗兜底后才开始数拍)。
|
||
if (this.guide.level() === 'full' && !this._gdCountUsed) {
|
||
this._gdCountUsed = true;
|
||
this._speakGuarded(['voice/gd_count'], resume);
|
||
} else {
|
||
resume();
|
||
}
|
||
}
|
||
|
||
_goHome() {
|
||
if (this._goingHome) return;
|
||
this._goingHome = true;
|
||
const rid = this._runId;
|
||
this.phase = 'leaving'; // 收尾态:不再受理演奏/菜单手势(单击仍可跳确认句)
|
||
// 先停谱面与 BGM(保留 voice 通道播确认句);确认句播完(或看门狗兜底)
|
||
// 再跳大厅——live round1 P1:旧版无确认直接跳,玩家面对静默页面以为游戏死了
|
||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||
this._stopBgmSrc();
|
||
this.audio.stopBgm(300);
|
||
this._speakGuarded(['voice/menu_lobby'], () => {
|
||
if (rid !== this._runId) return;
|
||
this.stop();
|
||
window.location.href = '../';
|
||
}, 2000);
|
||
}
|
||
|
||
// ---------------- 结算 ----------------
|
||
|
||
_grade() {
|
||
const s = this._stats;
|
||
const total = s.perfect + s.good + s.miss;
|
||
const rate = total ? (s.perfect * 2 + s.good) / (2 * total) : 0;
|
||
if (rate >= 0.95) return 's';
|
||
if (rate >= 0.85) return 'a';
|
||
if (rate >= 0.70) return 'b';
|
||
return 'c';
|
||
}
|
||
|
||
_finish(rid) {
|
||
if (this._runId !== rid) return;
|
||
this.phase = 'end';
|
||
this.guide.state('settle'); // 状态签名:结算(与结算 BGM 同点)
|
||
this.audio.setRateBoostEnabled(true); // 离开演奏期,恢复长按快进
|
||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||
this._sched = [];
|
||
this._timed = [];
|
||
// BGM 淡出
|
||
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');
|
||
}
|
||
// 解锁:曲目 n ≥ B → 解锁 n+1
|
||
let unlockedNew = null;
|
||
if (GRADE_RANK[grade] >= GRADE_RANK.b
|
||
&& this._songIdx + 1 < SONGS.length
|
||
&& this._songIdx + 1 >= p.unlocked) {
|
||
p.unlocked = this._songIdx + 2;
|
||
unlockedNew = SONGS[this._songIdx + 1].unlockVoice;
|
||
}
|
||
this._saveProgress();
|
||
this._putProgress(); // 已登录时同步服务器
|
||
|
||
if (grade === 's' || grade === 'a') vibrate('success');
|
||
if (unlockedNew) {
|
||
vibrate('success');
|
||
vibrate([20, 40, 20]);
|
||
ids.push(unlockedNew);
|
||
}
|
||
ids.push('voice/restart');
|
||
console.log('[RhythmGame] 结算:', this._stats, '评级:', grade.toUpperCase());
|
||
// 10 文档 §5:曲目 BGM 已 0.6s 淡出,结算语音链播完后淡入 menu pad 800ms;
|
||
// 链换 _speakGuarded(清点 §4.6 缺口):grade_* 等资产缺失也能起结算 BGM。
|
||
// live round1 P1:① 看门狗按链长缩放——固定 10s 曾比 grade+restart 链还短,
|
||
// menu pad 骑到语音链尾上(报告 P3-15 同因);② 只在仍处结算态起播——
|
||
// 重开/下一首后过期的看门狗 fire 不得把 menu pad 漏进正片
|
||
this._speakGuarded(ids, () => {
|
||
if (this.phase === 'end') this.audio.play('bgm/menu', { fadeMs: 800 });
|
||
}, 5000 + ids.length * 7000);
|
||
}
|
||
|
||
// ---------------- 进度存储 ----------------
|
||
|
||
_loadProgress() {
|
||
try {
|
||
const p = JSON.parse(localStorage.getItem(PROGRESS_KEY));
|
||
if (p && typeof p === 'object') {
|
||
return {
|
||
tutorialDone: !!p.tutorialDone,
|
||
unlocked: Math.max(1, Math.min(3, p.unlocked || 1)),
|
||
grades: p.grades || {},
|
||
bestCombo: p.bestCombo || 0,
|
||
};
|
||
}
|
||
} catch (_) { /* 损坏则重置 */ }
|
||
return { tutorialDone: false, unlocked: 1, grades: {}, bestCombo: 0 };
|
||
}
|
||
|
||
_saveProgress() {
|
||
try { localStorage.setItem(PROGRESS_KEY, JSON.stringify(this._progress)); } catch (_) { /* 隐私模式 */ }
|
||
}
|
||
|
||
/** PUT /api/progress(未登录/离线静默跳过)。summary 取最高评级曲目。 */
|
||
_putProgress() {
|
||
const p = this._progress;
|
||
// 最高评级的曲目作为 summary 主项
|
||
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: `曲目${bestSong + 1} ${gradeText} / 已解锁 ${p.unlocked}/3 首`,
|
||
unlocked: p.unlocked,
|
||
grades: p.grades,
|
||
bestCombo: p.bestCombo,
|
||
tutorialDone: p.tutorialDone,
|
||
schema: 1,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
try {
|
||
const auth = JSON.parse(localStorage.getItem(AUTH_KEY));
|
||
if (!auth || !auth.token) return;
|
||
fetch('/api/progress', {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ' + auth.token,
|
||
},
|
||
body: JSON.stringify({ game: 'rhythm', data }),
|
||
}).catch(() => { /* 离线静默 */ });
|
||
} catch (_) { /* 静默 */ }
|
||
}
|
||
|
||
// ---------------- 工具 ----------------
|
||
|
||
/**
|
||
* 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。
|
||
* 链序号:每条新链(_chainSeq 缺省)分配新序号并作废更旧的链守卫
|
||
* (见 _speakGuarded);链内续播沿用同一序号,不算"更新的链"。
|
||
*/
|
||
_speak(ids, onDone, _chainSeq) {
|
||
if (!ids.length) { if (onDone) onDone(); return; }
|
||
const rid = this._runId;
|
||
const seq = _chainSeq ?? ++this._voiceSeq;
|
||
const [head, ...rest] = ids;
|
||
this.audio.play(head, {
|
||
onComplete: () => {
|
||
if (rid !== this._runId) return;
|
||
this._speak(rest, onDone, seq);
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 带看门狗的语音链:链自然播完或 guardMs 超时(语音缺失/解码失败),
|
||
* 二者先到先触发 onDone 且只触发一次——保证流程绝不卡死。
|
||
* live round1 深修:看门狗只在"自己这条链仍是当前链"时才可 fire——
|
||
* 链被顶掉/打断(stopVoice、更新的 _speak/_speakGuarded、_cleanup)后,
|
||
* 滞留看门狗若照常在 guardMs 后 fire,会把 onDone(如 _tutLead)砸进
|
||
* 毫不相干的相位(实测:教学纠正链被下滑跳教学顶掉后,10s 滞留看门狗
|
||
* 把游戏从 main 拖回 tut)。
|
||
*/
|
||
_speakGuarded(ids, onDone, guardMs = 10000) {
|
||
const rid = this._runId;
|
||
const seq = ++this._voiceSeq; // 本守卫链的序号(先于 _speak 分配,盖过 _speak 内部沿用它)
|
||
let fired = false;
|
||
const fire = () => {
|
||
if (fired || rid !== this._runId) return;
|
||
if (seq !== this._voiceSeq) return; // 链已被更新的语音链取代:滞留看门狗作废
|
||
fired = true;
|
||
if (onDone) onDone();
|
||
};
|
||
this._speak(ids, fire, seq);
|
||
setTimeout(fire, guardMs);
|
||
}
|
||
}
|