Files
SoundGame/web/rhythm/game.js
T

1269 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 节拍行者(rhythm)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);
}
}