live试玩round1+round2修复: 引擎gesture新增tapcancel(immediateTap误判定回滚,默认关闭零行为变化)+ rhythm判定账簿/超时Miss复活/菜单1s去抖/正片下滑永不弹菜单 + space判定冷静期800ms/暂停恢复3颗缓冲/回大厅leaving收尾/hit_last在播标记 + storyline intro满播不掐/抉择25s兜底 + simon免费重听两层账 + escape教学链自动接播/90s高频零提示 + rpg擦伤零误报/tap不丢 + guide.test适配去抖期(语义不变)+ 文档同步(13号§9-§19/web-engine-api/README)。回归:storyline82/escape140/simon132/rhythm141/space155/rpg188/guide122共921断言全绿

This commit is contained in:
xsl
2026-09-01 22:00:00 +08:00
parent 624fec8a23
commit 4e9970e227
22 changed files with 3161 additions and 401 deletions
+18 -10
View File
@@ -7,8 +7,10 @@
- 每个乐句 = **领奏 1 小节 + 应答 1 小节**:先听系统用打击乐器演奏
2-4 个音的节奏型,然后在下一小节的相同拍点用相同手势跟打。
- 判定:**Perfect ±120ms**(乐器音 + 亮音 accent)/ **Good ±250ms**(只播乐器音)/
**Miss**(手势错或错过拍点:不出声 + 震动提醒)。Miss 不中断不掉血,歌照唱完。
- 判定:**Perfect ±150ms**(乐器音 + 亮音 accent)/ **Good ±350ms**(只播乐器音)/
**Miss**(手势错或错过拍点:该音符不出声 + 一声闷镲 + 震动提醒)。
判定时刻按真实触摸时刻算(引擎 immediateTap 按下即发 tap;滑动/双击按
识别延迟补偿回触摸时刻)。Miss 不中断不掉血,歌照唱完。
- 计分:Perfect=2 / Good=1 / Miss=0,评级 **S≥95% / A≥85% / B≥70% / C**。
- 连击每 16 次有震动鼓励;乐句全 Perfect 有额外震动。
- **3 首曲目解锁制**:《启程》90 BPM → 《追风》105 BPM(反拍)→ 《星爆》120 BPM
@@ -24,25 +26,31 @@
| 右滑 | 开放镲 hat | 极右 |
| 上滑 | 高通 tom | 升调感 |
| 下滑 | 低通 tom | 降调感 |
| 长按 | —(非演奏手势) | Miss 反馈音 + 震动提醒 |
你的每次出手都会实时奏响对应乐器音——跟对了,就能听到自己在演奏。
## 流程
- **首次进入**:一句欢迎后进入 3 步交互教学(单底鼓 → 左右 → 三音带双击),
做错有纠正语音(早了/晚了/不是这个手势),同一步失败 2 次自动跳过;
教学中**下滑 = 跳过教学**。
- **首次进入**:一句欢迎后进入 5 步交互教学(单击 → 左右滑 → 上滑 → 下滑 →
三音带双击),做错有纠正语音(早了/晚了/不是这个手势),同一步失败 2 次
自动跳过;全程零通过时收尾播中性句(不夸"很好");教学中**下滑 = 跳过
教学**(教到下滑步、临近下滑音符时除外——那时它是演奏)。
- **选曲**(二周目起):左右滑切曲目(播曲目名/未解锁提示),单击开始,
上滑听规则,下滑回大厅。
- **结算**:播报评级(含准确率档位)与连击纪录;**单击 = 再来一遍(同曲),
右滑 = 下一首(需已解锁),下滑 = 回大厅**。
右滑 = 下一首(需已解锁),下滑 = 回大厅**(先播"回大厅"确认再跳转)。
## 菜单
演奏中**下滑呼出**(模态暂停,BGM 断点续播)→ **单击循环选项**(继续演奏/回大厅)
→ **双击确认** → **上滑 = 帮助**(规则 + 设置引导)。
注意:下滑同时是"低通 tom"的演奏手势——**仅当没有悬置或临近(1 秒内)的下滑
音符时**,下滑才会呼出菜单;有下滑音符时它永远是演奏。
演奏中**下滑呼出**(模态暂停:谱面、判定、BGM 全部冻结,BGM 断点续播)→
**单击循环选项**(继续演奏/回大厅)→ **双击确认** → **上滑 = 帮助**(规则 +
设置引导)。菜单打开后 **1 秒去抖独占期**:期内不收任何手势(防演奏途中被
弹菜单时,已在路上的手势直接穿透菜单误确认)。
注意:下滑同时是"低通 tom"的演奏手势——**自下滑音符的领奏响起到判定
死线之间**,下滑永远是演奏;音符刚被判定死线拖死的短窗内(识别延迟、手势
还在路上)同样按演奏处理,绝不弹菜单;仅当没有悬置、临近或刚拖死的下滑
音符时才呼出菜单。
## 运行
+419 -63
View File
@@ -6,11 +6,16 @@
* 的相同拍点用相同手势跟打。3 首曲目(90/105/120 BPM)解锁制:曲目 n 拿到 B 级
* 以上解锁 n+1(localStorage 记解锁状态,已登录同步服务器)。
*
* 判定(施工契约 §2):
* Perfect ±0.12s(手势乐器音 + inst_accent 亮音叠加)
* Good ±0.25s(只播乐器音;蓝牙延迟由此吸收)
* Miss 窗口内手势错 / 超时应答缺失:不播乐器音(错过=没出声)+ error 震动
* tap 判定时刻 -0.30s(TAP_DELAY 引擎延迟补偿);tap Miss 超时放宽 TAP_DELAY+0.08
* 判定(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 其余。不掉血不中断,歌照唱完。
*
@@ -24,12 +29,32 @@
*
* 菜单(round2-review 全球约定):下滑呼出(模态暂停,BGM 断点续播)→
* 单击循环 → 双击确认 → 上滑帮助。下滑与"下滑=低音 tom"冲突的折中:
* 仅当无悬置/临近的 down 音符时下滑才呼出菜单(见 README)。
* 仅当无悬置/临近的 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。
* 不叠 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 短句按裁定不跳)。
@@ -53,13 +78,25 @@
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
import { settings } from '../engine/settings.js';
// ---- 判定与调度常量(03 文档 §2 施工契约) ----
const PERFECT = 0.12;
const GOOD = 0.25;
const TAP_DELAY = 0.30;
// ---- 判定与调度常量(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 = {
@@ -131,10 +168,13 @@ const SONGS = [
},
];
// 教学 3 步(T 组台词;T1 单底鼓 → T2 左右 → T3 三音带双击)
// 教学 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']] },
];
@@ -176,18 +216,33 @@ export class RhythmGame extends Game {
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', 'bgm/menu', ...SONGS.map((s) => s.bgm)]);
'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';
@@ -209,6 +264,11 @@ export class RhythmGame extends Game {
_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 = [];
@@ -249,7 +309,7 @@ export class RhythmGame extends Game {
// ---------------- 节拍调度泵 ----------------
/** 在绝对时间 when 播放 sfx(走引擎 sfx 总线,保持音量/duck 语义)。 */
/** 在绝对时间 when 播放 sfx(走领奏总线 → 引擎 sfx 总线,保持音量/duck 语义)。 */
async _scheduleSfx(id, when, volume = 1) {
const buf = await this.audio._load(id);
if (!buf || !this.audio.ctx) return;
@@ -258,10 +318,70 @@ export class RhythmGame extends Game {
const g = this.audio.ctx.createGain();
g.gain.value = this.audio.sfxVolume * volume;
src.connect(g);
g.connect(this.audio._sfxBus);
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;
@@ -281,10 +401,10 @@ export class RhythmGame extends Game {
}
if (this.phase === 'main') {
// Miss 超时检查:tap 指令回调天然晚 ~300ms 到达,放宽同样余量
// Miss 超时检查:放宽 = 该手势的识别补偿 + 0.10s(补偿后仍超窗才判超时)
for (const cue of this._cues) {
if (cue.judged) continue;
const margin = cue.gesture === 'tap' ? TAP_DELAY + 0.08 : 0.05;
const margin = this._compFor(cue.gesture) + 0.10;
if (now > cue.expected + GOOD + margin) this._applyMiss(cue);
}
if (now >= this._phaseEnd) this._finish(rid);
@@ -309,6 +429,8 @@ export class RhythmGame extends Game {
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));
}
@@ -350,13 +472,12 @@ export class RhythmGame extends Game {
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._progress.tutorialDone = true;
this._saveProgress();
this._speakGuarded(['voice/tut_done'], () => this._startSong(rid, 0));
this._finishTutorial(rid);
}
return;
}
@@ -382,11 +503,7 @@ export class RhythmGame extends Game {
// 同一步失败 2 次跳过,不卡流程(归因已在首次失败时给过,此处不再叠加)
this._speakGuarded(['voice/retry_skip'], () => {
if (this._tutIdx + 1 < TUT_STEPS.length) this._beginTutorial(rid, this._tutIdx + 1);
else {
this._progress.tutorialDone = true;
this._saveProgress();
this._speakGuarded(['voice/tut_done'], () => this._startSong(rid, 0));
}
else this._finishTutorial(rid);
});
return;
}
@@ -416,6 +533,18 @@ export class RhythmGame extends Game {
});
}
/**
* 教学收尾:有任一步通过播 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) {
@@ -480,7 +609,8 @@ export class RhythmGame extends Game {
this._cues.push({ gesture: g, expected: t + 4 * spb, judged: false, result: null, phrase: i });
}
if (i === midPhrase) {
this._timed.push({ time: leadBarT0, fn: () => this.audio.play('voice/mid_song') });
// 提示语音不压领奏(live round1 P2):经领奏总线 ducking 到 1/4
this._timed.push({ time: leadBarT0, fn: () => this._playMidSong() });
}
if (i === 0) {
// 向导层(round3 G1):每次进 main,首个应答窗口**前整整 1 小节**(= 领奏
@@ -530,16 +660,75 @@ export class RhythmGame extends Game {
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':
if (g === 'down') { // 下滑 = 跳过教学(T1-T3 均不含 down,无冲突)
// 下滑折中(教学同款):临近 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);无语音时才是跟打手势
if (this._trySkipVoice(g)) 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':
@@ -583,58 +772,176 @@ export class RhythmGame extends Game {
_onTutGesture(g) {
if (!INST[g] || !this.audio.ctx) return;
this.audio.play(INST[g]);
let t = this.audio.ctx.currentTime;
if (g === 'tap') t -= TAP_DELAY;
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; }
}
if (best) {
this._tutAttempts.push({ cue: best, gesture: g, dt: t - best.expected, correct: g === best.gesture });
// 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;
// 下滑折中:有悬置/临近的 down 音符时是演奏,否则呼出菜单
if (g === 'down' && !this._downNotePending()) {
this._openMenu(rid);
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;
let t = this.audio.ctx.currentTime;
if (g === 'tap') t -= TAP_DELAY; // 补偿引擎 tap 回调延迟
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) continue;
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) {
this.audio.play(INST[g]); // 窗口外自由演奏:出声音符但不判定
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;
}
if (g !== best.gesture) {
this._applyMiss(best); // 窗口内做错动作 = Miss(不播乐器音)
// 归属①′(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;
}
// Perfect / Good:出手即播乐器音(演奏感),Perfect 叠 accent 亮音
const perfect = bestDt <= PERFECT;
this.audio.play(INST[g]);
if (perfect) this.audio.play(ACCENT, { volume: 0.8 });
this._applyHit(best, perfect);
// 归属②:最近的异手势音符落在错手势窗(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);
}
/** 当前有无悬置或临近(1s 内)的 down 应答音符。 */
/**
* 当前有无悬置或临近的 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 - TAP_DELAY && cue.expected < now + 1.0);
&& cue.expected > now - GOOD - SWIPE_DELAY - 0.10
&& cue.expected - 4 * spb < now + 0.2);
}
_applyHit(cue, perfect) {
@@ -650,13 +957,22 @@ export class RhythmGame extends Game {
this._checkPhraseAllPerfect(cue.phrase);
}
_applyMiss(cue) {
_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;
vibrate('error'); // 错过=没出声(不播乐器音),震动是唯一反馈
// Miss 不再无声(live round1 P1):该音符的乐器音仍不播(错过=没出声),
// 但给可区分的闷镲反馈音 + error 震动
this._playMissSfx();
vibrate('error');
// 向导层(schemes §2):连续 2 次 Miss 播 gd_miss(同曲限 1 次;已震过 error,
// skipHaptic 免重复震;voice 走 queue 插播,不顶 mid_song/结算链)
this._consecMiss++;
@@ -681,10 +997,14 @@ export class RhythmGame extends Game {
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 音符自然播完
// 暂停:停泵、停 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 未起,
@@ -695,6 +1015,10 @@ export class RhythmGame extends Game {
}
_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]);
@@ -712,6 +1036,9 @@ export class RhythmGame extends Game {
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;
@@ -750,8 +1077,20 @@ export class RhythmGame extends Game {
}
_goHome() {
this.stop();
window.location.href = '../';
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);
}
// ---------------- 结算 ----------------
@@ -820,8 +1159,13 @@ export class RhythmGame extends Game {
ids.push('voice/restart');
console.log('[RhythmGame] 结算:', this._stats, '评级:', grade.toUpperCase());
// 10 文档 §5:曲目 BGM 已 0.6s 淡出,结算语音链播完后淡入 menu pad 800ms;
// 链换 _speakGuarded(清点 §4.6 缺口):grade_* 等资产缺失也能起结算 BGM
this._speakGuarded(ids, () => this.audio.play('bgm/menu', { fadeMs: 800 }));
// 链换 _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);
}
// ---------------- 进度存储 ----------------
@@ -881,15 +1225,20 @@ export class RhythmGame extends Game {
// ---------------- 工具 ----------------
/** 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。 */
_speak(ids, onDone) {
/**
* 语音串行链:自然播完才接下一句;被 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);
this._speak(rest, onDone, seq);
},
});
}
@@ -897,16 +1246,23 @@ export class RhythmGame extends Game {
/**
* 带看门狗的语音链:链自然播完或 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);
this._speak(ids, fire, seq);
setTimeout(fire, guardMs);
}
}
+4 -1
View File
@@ -27,7 +27,10 @@
await audio.showStartOverlay({ cover: 'cover.png', text: '轻触开始' });
// 走到这里 AudioContext 已解锁,可以出声了
game.start();
attachGestures(root, (g) => game.onGesture(g));
// immediateTap:单击是演奏输入(kick),按下立即回调,不等 300ms
// 双击窗关闭;若第二下构成双击,引擎补发 doubletap 改判(多打一下
// kick 无害)。其他游戏勿开——每次按下都会先发 tap。
attachGestures(root, (g) => game.onGesture(g), { immediateTap: true });
// 切后台 AudioContext 会被系统挂起,回前台时恢复(节拍时钟基于
// ctx.currentTime,挂起期间时钟也停,回前台后排程自然续上)
+271 -28
View File
@@ -176,9 +176,16 @@ const voicePlays = () => playLog.filter((p) => p.id.startsWith('voice/')).map((p
const lastVoice = () => { const v = voicePlays(); return v[v.length - 1] || ''; };
const voicePlayed = (id) => voicePlays().includes(id);
// 按 cue 期望时刻注入 Perfect 应答;tap 模拟真实回调延迟 +0.30s(补偿判定路径)
// 按 cue 期望时刻注入应答;模拟真实识别延迟(immediateTap:tap 按下即达 +0;
// 滑动识别在 touchend +0.15 = SWIPE_DELAY;双击 +0.20 = DOUBLE_DELAY)——
// game.js 判定补偿回到触摸时刻后 dt≈0(§13 替换口径)。
// 双击前先注入序曲 tap——immediateTap 的真实行为(第一下按下即发 tap),
// 否则游戏的旧引擎闩锁(双击前 350ms 无 tap = 旧引擎)会把测试误判成旧引擎
async function answerCue(cue) {
await runUntil(cue.expected + (cue.gesture === 'tap' ? 0.30 : 0));
if (cue.judged) return;
const lag = cue.gesture === 'tap' ? 0 : cue.gesture === 'doubletap' ? 0.20 : 0.15;
await runUntil(cue.expected + lag);
if (cue.gesture === 'doubletap') G('tap'); // 序曲 tap(附近无 0.35s 内未判定 tap 音符,出声不判定)
G(cue.gesture);
await flush();
}
@@ -192,20 +199,29 @@ await flush();
assert(game.phase === 'intro', 'start 后进 intro');
assert(voicePlayed('voice/welcome'), 'welcome 旁白开始播放');
// —— 旁白跳过①:intro 阶段 voice 播放中单击 = skipVoice(链推进到 tut_1,不重头)——
// —— 旁白跳过①:intro 阶段 voice 播放中单击 = skipVoice(immediateTap 下延迟
// 500ms 裁决——防序曲 tap;advance 后链推进到 tut_1,不重头)——
assert(!!audio._voice, 'welcome 播放中(voice token 存在)');
G('tap'); // 单击跳过 welcome
await flush();
await advance(600);
assert(lastVoice() === 'voice/tut_1', '单击跳过 welcome → 链推进到 tut_1');
// —— 教学 T1-T3:按 cues.expected 做手势(tap 注入在 expected+0.30 仍应判对)——
for (let step = 0; step < 3; step++) {
// —— 教学 5 步(§13:单击 → 左右 → 上滑 → 下滑 → 三音带双击):按 cues.expected
// 做手势(识别延迟注入,补偿后 dt≈0)——
for (let step = 0; step < 5; step++) {
await waitFor(() => game._tutIdx === step && game.phase === 'tut'
&& game._cues.length > 0 && game._tutAttempts.length === 0, `T${step + 1} 领奏排程`);
assert(game._tutIdx === step, `教学步进 T${step + 1}(_tutIdx=${step})`);
const leadNoteCount = srcLog.filter((s) => s.url && s.url.includes('/sfx/inst_')).length;
if (step === 2) { // 上滑步:先注入序曲 tap(immediateTap 副作用),不得产生尝试记录
await runUntil(game._cues[0].expected);
G('tap');
await flush();
assert(game._tutAttempts.length === 0, 'T3 序曲 tap 不产生尝试记录(tap 只认领 tap 音符)');
}
for (const cue of game._cues) await answerCue(cue);
assert(game._tutAttempts.every((a) => a.correct && Math.abs(a.dt) <= 0.25), `T${step + 1} 应答全部正确且在 Good 窗口内`);
assert(game._tutAttempts.every((a) => a.correct && Math.abs(a.dt) <= 0.35), `T${step + 1} 应答全部正确且在 Good 窗口内`);
if (step === 3) assert(game.phase === 'tut', 'T4 下滑步:临近 down 音符时下滑 = 演奏应答,不跳教学');
assert(srcLog.filter((s) => s.url && s.url.includes('/sfx/inst_')).length > leadNoteCount, `T${step + 1} 领奏音符已排程播放`);
await runUntil(game._phaseEnd + 0.2); // 触发 _tutEvaluate
await flush();
@@ -218,9 +234,15 @@ assert(voicePlayed('voice/tut_done'), 'tut_done 播报');
await waitFor(() => game.phase === 'main' && game._cues.length > 0, '曲目一 main 开始');
assert(game._song.id === 'song1' && game._songIdx === 0, '教学后进入曲目一');
assert(srcLog.some((s) => s.url && s.url.includes('/bgm/track90')), '曲目一 BGM track90 按网格启动');
const firstTapCue = game._cues.find((c) => c.gesture === 'tap');
// —— 旧引擎 tap 补偿(§13 闩锁回退路径):_immediateTap=false 时回调 -0.30s 判定 ——
const legacyTapCue = game._cues.find((c) => c.gesture === 'tap');
game._immediateTap = false;
await runUntil(legacyTapCue.expected + 0.30);
G('tap');
await flush();
assert(legacyTapCue.result === 'perfect', '旧引擎 tap 补偿:闩锁回退后 expected+0.30s 注入的 tap 仍判 Perfect');
game._immediateTap = true;
for (const cue of game._cues) await answerCue(cue);
assert(firstTapCue.result === 'perfect', 'tap 补偿:expected+0.30s 注入的 tap 仍判 Perfect(回调时刻 -0.30s 判定)');
assert(game._stats.perfect === game._cues.length, `曲目一 31 个音符全部 Perfect(perfect=${game._stats.perfect})`);
assert(game._stats.miss === 0 && game._stats.combo === game._cues.length, '无 Miss 且连击不断');
@@ -231,20 +253,26 @@ assert(game._progress.unlocked === 2, 'unlocked === 2(B 以上解锁下一首
await flush();
assert(voicePlayed('voice/grade_s'), 'grade_s 播报(结算语音链首句)');
// —— 旁白跳过②:结算语音链播放中单击 = 跳下一句,不是"再来一遍" ——
// —— 旁白跳过②:结算语音链播放中单击 = 跳下一句(immediateTap 下延迟 500ms
// 裁决生效),不是"再来一遍" ——
await waitFor(() => !!audio._voice, '结算语音链开始');
const phaseBeforeSkip = game.phase;
G('tap');
await flush();
assert(game.phase === 'end' && phaseBeforeSkip === 'end', '结算播报中单击 = 跳句(phase 保持 end,未误触重开)');
await advance(600);
assert(game.phase === 'end', '结算播报中单击 = 跳句(phase 保持 end,未误触重开)');
assert(lastVoice() === 'voice/new_best_combo', '单击跳过 grade_s → 链推进到 new_best_combo');
// —— 结算 BGM:语音链播完后淡入 bgm/menu(10 文档 §5)——
// —— 结算 BGM:语音链播完后淡入 bgm/menu(10 文档 §5);§13:起播点不早于链末句 ——
await waitFor(() => audio._bgm && audio._bgm.id === 'bgm/menu', '结算语音链播完后 menu pad 淡入');
assert(voicePlayed('voice/unlock_song2'), 'unlock_song2 播报(语音链后段)');
assert(true, '结算语音链播完后 bgm/menu 播放(引擎层)');
{
const menuPlayIdx = playLog.findIndex((p) => p.id === 'bgm/menu');
const restartIdx = playLog.map((p) => p.id).lastIndexOf('voice/restart');
assert(menuPlayIdx > restartIdx && restartIdx >= 0, '结算 menu pad 在语音链末句之后起播(不骑链尾)');
}
// —— 结算单击 = 同曲再来一遍;menu pad 应被 stopBgm(400) 停掉 ——
G('tap');
await advance(600);
await flush();
assert(game.phase === 'main' && game._songIdx === 0, '结算单击 = 同曲再来一遍');
assert(audio._bgmWanted === null && audio._bgm === null, '选定曲目后 menu pad 停止(stopBgm(400))');
@@ -271,9 +299,15 @@ await flush();
assert(game.phase === 'menu', '下滑折中:无临近 down 音符时下滑 = 呼出菜单');
assert(!playLog.some((p) => p.id === 'bgm/menu' && p.t >= menuOpenAt), '暂停菜单保持静默(不叠 menu pad,10 文档 §5)');
assert(voicePlayed('voice/menu_main'), '菜单语音 menu_main 播报');
await advance(3000); // 菜单停留 3s
// —— 暂停真冻结(§13,live round1 P1):菜单期间判定与谱面全停 ——
const missAtMenu = game._stats.miss;
const srcAtMenu = srcLog.length;
await advance(2000); // 菜单停留 2s(menu_main 播完、menu_resume 在播)
assert(game._stats.miss === missAtMenu, '菜单开启 2s:无新 Miss(判定冻结)');
assert(srcLog.length === srcAtMenu, '菜单开启 2s:无新排程音符(谱面冻结)');
G('down'); // 关菜单
await flush();
assert(!audio.isVoicePlaying(), '关菜单即停菜单语音链(菜单语音不骑恢复后的领奏)');
const pauseDur = (nowMs + 100 - menuOpenAt) / 1000; // resumeAt = 关菜单时刻 +0.1
assert(game.phase === 'main', '关菜单恢复 main');
const shift = game._bgmT0 - bgmT0Before;
@@ -283,16 +317,18 @@ assert(Math.abs(game._phaseEnd - phaseEndBefore - shift) < 1e-6, '_phaseEnd 同
const lastTrack105 = srcLog.filter((s) => s.url && s.url.includes('/bgm/track105')).pop();
assert(lastTrack105 && lastTrack105.offset > 0, `BGM 断点续播(offset=${lastTrack105 && lastTrack105.offset.toFixed(2)}s > 0)`);
// —— Miss:窗口内做错手势 → 无乐器音 + error 震动 + combo 清零 ——
// —— Miss:窗口内做错手势 → 不播该音符的乐器音 + 闷镲 Miss 音 + error 震动 + combo 清零 ——
const missCue = game._cues.find((c) => !c.judged && c.gesture === 'tap');
await runUntil(missCue.expected + 0.1);
const instPlaysBefore = playLog.filter((p) => p.id.startsWith('sfx/inst_')).length;
const kickPlaysBefore = playLog.filter((p) => p.id === 'sfx/inst_kick').length;
const srcBeforeMiss = srcLog.length;
const vibBefore = vibLog.length;
game._stats.combo = 5; // 制造非零 combo,验证清零
G('left'); // 错手势(该 cue 期望 tap,附近无 left cue)
await flush();
assert(missCue.result === 'miss', '窗口内错手势判 Miss');
assert(playLog.filter((p) => p.id.startsWith('sfx/inst_')).length === instPlaysBefore, 'Miss 不播乐器音(错过=没出声)');
assert(playLog.filter((p) => p.id === 'sfx/inst_kick').length === kickPlaysBefore, 'Miss 不播该音符的乐器音(错过=没出声)');
assert(srcLog.slice(srcBeforeMiss).some((s) => s.url && s.url.includes('inst_hat_l')), 'Miss 有闷镲反馈音(inst_hat_l 降调复用,§13)');
assert(vibLog.slice(vibBefore).some((p) => Array.isArray(p) && p[0] === 60), 'Miss 触发 error 震动');
assert(game._stats.combo === 0, 'Miss 后 combo 清零');
@@ -304,13 +340,34 @@ for (const cue of game._cues) { // 把 down cue 之前的未判定音符应答
}
const downCue = game._cues.find((c) => !c.judged && c.gesture === 'down');
assert(!!downCue, '找到 song2 的 down 应答音符');
await runUntil(downCue.expected);
await runUntil(downCue.expected + 0.15); // 真实滑动识别延迟 +0.15s(补偿后 dt≈0)
G('down');
await flush();
assert(downCue.result === 'perfect', '临近 down 音符时下滑 = 演奏判定(Perfect)');
assert(game.phase === 'main', '演奏判定后不开菜单(phase 保持 main)');
assert(playLog.some((p) => p.id === 'sfx/inst_tom_lo'), 'down 命中播放 inst_tom_lo');
// —— 下滑折中③(§13,live round1 P0):慢滑(识别 +0.35s)踩点下滑仍判演奏 ——
const downCue2 = game._cues.find((c) => !c.judged && c.gesture === 'down');
assert(!!downCue2, '找到 song2 的第二个 down 应答音符');
await runUntil(downCue2.expected + 0.35);
G('down');
await flush();
assert(downCue2.result === 'good', '慢滑(识别 +0.35s)踩点下滑判 Good,不弹菜单');
assert(game.phase === 'main', '慢滑下滑后 phase 保持 main(未开菜单)');
// —— 长按定义(§13,live round1 P0):Miss 反馈音 + error 震动,不判定不归属 ——
const judgedBeforeLp = game._cues.filter((c) => c.judged).length;
const srcBeforeLp = srcLog.length;
const vibBeforeLp = vibLog.length;
G('longpress');
await flush();
assert(game._cues.filter((c) => c.judged).length === judgedBeforeLp, '长按不判定任何音符');
assert(srcLog.slice(srcBeforeLp).some((s) => s.url && s.url.includes('inst_hat_l')), '长按播 Miss 反馈音(死输入给定义)');
assert(vibLog.slice(vibBeforeLp).some((p) => Array.isArray(p) && p[0] === 60), '长按触发 error 震动');
G('longpressend');
await flush();
// —— 结算末位:song3 结算页右滑 → 播 restart,不播 song_locked(round3-4 裁定①回归) ——
game._startSong(game._runId, 2);
game._finish(game._runId);
@@ -323,6 +380,13 @@ const newVoices = playLog.slice(playMark).map((p) => p.id);
assert(newVoices.includes('voice/restart'), '末位曲目右滑 → 播 voice/restart');
assert(!newVoices.includes('voice/song_locked'), '末位曲目右滑 → 不播 song_locked');
// —— 回大厅(§13,live round1 P1):确认语音 + leaving 收尾态 + 真实跳转 ——
G('down');
await flush();
assert(game.phase === 'leaving', '结算屏下滑进入 leaving(收尾态,不再受理手势)');
assert(voicePlayed('voice/menu_lobby'), '回大厅前播确认语音 menu_lobby');
await waitFor(() => window.location.href === '../', '确认语音后跳转大厅');
assert(gestures <= 200, `手势预算内(${gestures} ≤ 200)`);
// ================= 场景 B:二周目选曲界面(menu pad + 选曲播报跳过 + 负偏移保护) =================
@@ -335,20 +399,23 @@ await waitFor(() => game.phase === 'select', '进入选曲界面');
assert(audio._bgm && audio._bgm.id === 'bgm/menu', '选曲界面播放 bgm/menu(10 文档 §5)');
await waitFor(() => !!audio._voice, 'select_hint 播报中');
G('tap'); // 跳过 select_hint → song1_name
await flush();
G('tap'); // 跳过 select_hint → song1_name(immediateTap 下延迟 500ms 裁决)
await advance(600);
assert(game.phase === 'select' && lastVoice() === 'voice/song1_name', '选曲播报单击 = 跳下一句(phase 保持 select)');
G('right'); // 切到 song2(未解锁)
await flush();
await advance(600); // 序曲 tap 裁决窗:右滑不得误触发"选定曲目"
assert(game.phase === 'select', '选曲右滑的序曲 tap 被作废(phase 保持 select,未误选)');
assert(game._songIdx === 1 && lastVoice() === 'voice/song_locked', '右滑切到未解锁曲目 → song_locked');
G('tap'); // 跳过 song_locked
await flush();
await advance(600);
assert(game.phase === 'select', 'song_locked 可跳过且不离开选曲');
G('left'); // 切回 song1
await flush();
assert(game._songIdx === 0, '左滑切回 song1');
G('tap'); // 序曲 tap(模拟 immediateTap 副作用:左滑的触摸起点)
G('left'); // 随后滑动到达 → 序曲 tap 应被作废
await advance(600);
assert(game.phase === 'select' && game._songIdx === 0, '序曲 tap 被随后滑动作废;左滑正常切回 song1');
await waitFor(() => !audio._voice, '选曲播报播完');
G('tap'); // 无语音时单击 = 确认选曲
await advance(600);
await flush();
assert(game.phase === 'main' && game._song.id === 'song1', '无语音时单击 = 选定曲目进 main');
assert(audio._bgmWanted === null && audio._bgm === null, '进曲目后 menu pad 停止');
@@ -369,7 +436,8 @@ console.log('\n[场景 C] 教学失败重教 → 2 次失败跳过该步 → 下
await newWorld();
game.start();
await flush();
G('tap'); // 跳 welcome
G('tap'); // 跳 welcome(immediateTap 下延迟 500ms 裁决)
await advance(600);
await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'T1 领奏');
// 失败①:做错手势
await runUntil(game._cues[0].expected + 0.3);
@@ -392,6 +460,48 @@ assert(voicePlayed('voice/tut_skip'), '下滑跳教学播 tut_skip');
await waitFor(() => game.phase === 'main', '跳教学后进入曲目一');
assert(game._progress.tutorialDone === true, '跳教学同样持久化 tutorialDone');
// ================= 场景 C2:教学全程零通过 → 收尾播中性句不夸"很好"(§13) =================
console.log('\n[场景 C2] 教学 5 步全部失败跳过 → retry_skip 收尾,不播 tut_done');
await newWorld();
game.start();
await flush();
G('tap'); // 跳 welcome
await advance(600);
for (let step = 0; step < 5; step++) {
await waitFor(() => game._tutIdx === step && game.phase === 'tut' && game._cues.length > 0, `C2 T${step + 1} 领奏`);
const leadMark = game._cues[0].expected;
await runUntil(game._phaseEnd + 0.2); // 失败①:完全不出手
await flush();
// 零尝试时 attempts 恒为空——必须等"重教的新领奏"(cues 预期时刻刷新),
// 否则条件瞬间为真、下一步 runUntil(Infinity) 直接跑完整个教学
await waitFor(() => game._tutFails === 1 && game._cues.length > 0
&& game._cues[0].expected !== leadMark && game.phase === 'tut', `C2 T${step + 1} 重教`);
await runUntil(game._phaseEnd + 0.2); // 失败②:完全不出手 → 跳过该步
await flush();
}
await waitFor(() => game.phase === 'main', 'C2 零通过后进入曲目一');
assert(game._tutPassedAny === false, 'C2 全程零通过(_tutPassedAny === false)');
assert(voicePlayed('voice/retry_skip'), 'C2 零通过收尾播 retry_skip(中性句)');
assert(!voicePlayed('voice/tut_done'), 'C2 零通过不播 tut_done(不夸"很好")');
// ================= 场景 C3:滞留语音链看门狗作废(§13,live 实测深修回归) =================
console.log('\n[场景 C3] 教学纠正链被跳教学顶掉后,滞留看门狗不得把游戏拖回 tut');
await newWorld();
game.start();
await flush();
G('tap'); // 跳 welcome
await advance(600);
await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'C3 T1 领奏');
await runUntil(game._phaseEnd + 0.2); // 失败①:不出手 → 纠正链 [retry_listen, retry_listen]
await flush();
assert(game._tutFails === 1 && audio.isVoicePlaying(), 'C3 纠正链在播(fail=1)');
G('down'); // 下滑跳教学:tut_skip 链顶掉纠正链(其看门狗 10s 后到期)
await flush();
assert(voicePlayed('voice/tut_skip'), 'C3 下滑跳教学播 tut_skip');
await waitFor(() => game.phase === 'main', 'C3 跳教学后进入曲目一');
await advance(12000); // 越过滞留看门狗的 10s 窗口
assert(game.phase === 'main', 'C3 滞留看门狗已作废:12s 后仍在 main(未被拖回 tut)');
// ================= 场景 D:语音资产缺失 → 看门狗推进,不卡死不崩 =================
console.log('\n[场景 D] 看门狗:屏蔽 welcome 资产');
await newWorld();
@@ -432,6 +542,139 @@ settings.set({ bgm: 1.0 });
await flush();
assert(audio._bgm && audio._bgm.id === 'bgm/menu', 'bgm 调回非 0 自动补播最后状态 BGM');
// ================= 场景 F:live round2 判定治理(序曲 tap 撤销 / 超时复活 / 下滑不弹菜单 / 菜单去抖) =================
console.log('\n[场景 F] live round2:tapcancel 撤销 / 超时 Miss 复活 / 正片下滑不弹菜单 / 菜单去抖独占');
// —— F3 教学:tapcancel 撤销序曲 tap 尝试记录(转滑动/长按的序曲不算出手)——
await newWorld();
game.start();
await flush();
G('tap'); // 跳 welcome(延迟 500ms 裁决)
await advance(600);
await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'F3 T1 领奏');
await runUntil(game._cues[0].expected);
G('tap'); // 序曲 tap(T1 是 tap 音符,先记尝试)
await flush();
assert(game._tutAttempts.length === 1, 'F3 序曲 tap 先记教学尝试');
G('tapcancel'); // 引擎补发:本次按下转滑动/长按 → 序曲 tap 作废
await flush();
assert(game._tutAttempts.length === 0, 'F3 tapcancel 撤销教学尝试记录(live round2 P0)');
// —— 正片判定治理:钩子直进 song2(105BPM,含 3 个 down 音符)——
await newWorld({ tutorialDone: true, unlocked: 3, grades: {}, bestCombo: 0 });
game.start();
await flush();
await waitFor(() => game.phase === 'select', 'F 进入选曲');
game._startSong(game._runId, 1);
await flush();
await waitFor(() => game.phase === 'main' && game._cues.length > 0, 'F song2 排程就绪');
// —— F1 tapcancel 撤销序曲 tap 命中(声音已出收不回,判定必须撤销)——
const fTapCue = game._cues.find((c) => c.gesture === 'tap');
await runUntil(fTapCue.expected);
G('tap');
await flush();
assert(fTapCue.result === 'perfect', 'F1 序曲 tap 先按 tap 音符判定(Perfect)');
assert(game._tapJudg.length === 1, 'F1 序曲 tap 判定已记账');
G('tapcancel'); // 引擎补发:转滑动/长按 → 序曲 tap 作废
await flush();
assert(fTapCue.judged === false && fTapCue.result === null, 'F1 tapcancel 撤销序曲 tap 判定(音符回到未判定,不计 Miss)');
assert(game._stats.perfect === 0 && game._stats.combo === 0, 'F1 命中统计回滚(perfect/combo 归零)');
await runUntil(fTapCue.expected + 0.2);
G('tap'); // 重新接打(撤销不是免判)
await flush();
assert(fTapCue.result === 'good', 'F1 撤销后重新接打 → Good');
// —— F2 双击序曲治理(tap+tap+doubletap 序列的判定归属)——
const fDblCue = game._cues.find((c) => !c.judged && c.gesture === 'doubletap'); // phrase2 beat2
await runUntil(fDblCue.expected - 0.2);
G('tap'); // 序曲 tap1(双击第一下按下,自由演奏)
await flush();
await advance(200);
G('tap'); // 序曲 tap2(双击第二下按下)
await flush();
await advance(250);
const fMiss2 = game._stats.miss;
G('doubletap'); // 双击改判到达(识别在第二次 touchend)
await flush();
assert(fDblCue.result === 'perfect', 'F2 双击改判 Perfect(序曲 tap 撤销后判定)');
assert(game._stats.miss === fMiss2, 'F2 序曲 tap 不产生 Miss(爆音不进判定链)');
assert(fTapCue.result === 'good', 'F2 双击撤销窗不误伤更早的真实 tap 命中');
assert(game._tapJudg.length === 1, 'F2 两笔序曲 tap 账已撤销(账簿只剩真实命中)');
// —— F2c 序曲 tap 误认领命中 → 双击到达时撤销;撤销后真不接 → 超时 Miss 兜底 ——
const fTapCue2 = game._cues.find((c) => !c.judged && c.gesture === 'tap'); // phrase2 beat3
await runUntil(fTapCue2.expected);
G('tap'); // 序曲 tap1 误认领(其实是双击第一下)
await flush();
assert(fTapCue2.result === 'perfect', 'F2c 序曲 tap 先认领命中');
const fPerf2c = game._stats.perfect;
await advance(200);
G('tap'); // 序曲 tap2(自由演奏)
await flush();
await advance(250);
G('doubletap'); // 双击改判 → 两笔序曲判定一并撤销(本次双击附近无 doubletap 音符,自由演奏)
await flush();
assert(fTapCue2.judged === false && fTapCue2.result === null, 'F2c 双击到达撤销序曲 tap 误认领的命中');
assert(game._stats.perfect === fPerf2c - 1, 'F2c Perfect 统计回滚');
await runUntil(fTapCue2.expected + 0.55); // 越过 tap 超时死线(+0.45)
await flush();
assert(fTapCue2.result === 'miss', 'F2c 撤销后真不接 → 超时 Miss 兜底(撤销不是免死)');
// —— F4 超时 Miss 复活:识别延迟把音符拖死、手势还在路上(live round2 事件流实证)——
const fUpCue = game._cues.find((c) => !c.judged && c.gesture === 'up'); // phrase3 beat1
await runUntil(fUpCue.expected + 0.64); // 超时死线 +0.60 已过:泵先判 Miss(滑动回调尚未到达)
await flush();
assert(fUpCue.result === 'miss' && fUpCue.missKind === 'timeout', 'F4 上滑音符先被超时拖死(事件流同款时序)');
const fMiss4 = game._stats.miss;
G('up'); // 慢滑回调此刻才到(触摸 expected+0.49、触程 0.15)
await flush();
assert(fUpCue.result === 'good', 'F4 超时误判复活改判 Good(补偿 dt=+0.49 ≤ REVIVE_DT_S)');
assert(game._stats.miss === fMiss4 - 1, 'F4 复活回滚 Miss 统计');
// —— F5 正片踩点下滑不弹菜单(live round2 P1 续修:超时拖死 ≠ 无临近 down 音符)——
const fDownCue = game._cues.find((c) => !c.judged && c.gesture === 'down'); // phrase3 beat3
await runUntil(fDownCue.expected + 0.64); // 死线 +0.60 已过,下滑回调 +0.64 才到(round2 实测差 2~114ms)
await flush();
assert(fDownCue.result === 'miss' && fDownCue.missKind === 'timeout', 'F5 down 音符先被超时拖死');
const fTomMark = playLog.length;
G('down');
await flush();
assert(game.phase === 'main', 'F5 正片踩点下滑不弹菜单(live round2 P1 续修)');
assert(fDownCue.result === 'good', 'F5 下滑复活改判 Good');
assert(playLog.slice(fTomMark).some((p) => p.id === 'sfx/inst_tom_lo'), 'F5 下滑复活播放 inst_tom_lo');
// —— F5b 窗口内晚下滑(超复活时差上限):不弹菜单、Miss 维持、tom_lo 自由演奏 ——
const fDownCue2 = game._cues.find((c) => !c.judged && c.gesture === 'down'); // phrase7 beat3
await runUntil(fDownCue2.expected + 1.1); // 死线过 ~0.5s(账龄仍在 REVIVE_WINDOW_S 内),dt 超 REVIVE_DT_S
await flush();
assert(fDownCue2.result === 'miss', 'F5b down 音符先被超时拖死');
const fTomMark2 = playLog.length;
G('down');
await flush();
assert(game.phase === 'main', 'F5b 窗口内晚下滑仍不弹菜单(瞄准 down 音符证据明确)');
assert(fDownCue2.result === 'miss', 'F5b 超复活时差上限,Miss 维持不复活');
assert(playLog.slice(fTomMark2).some((p) => p.id === 'sfx/inst_tom_lo'), 'F5b tom_lo 自由演奏出声');
// —— F5c 窗口过后下滑正常呼出菜单(折中不破)——
await advance(300); // downCue2 的 Miss 账龄 > REVIVE_WINDOW_S
G('down');
await flush();
assert(game.phase === 'menu', 'F5c 窗口过后下滑正常呼出菜单');
// —— F6 菜单去抖独占期(live round2 P1 瞬穿):开菜单 1s 内手势不穿透 ——
assert(audio.isVoicePlaying(), 'F6 菜单语音 menu_main 在播');
G('doubletap'); // 开菜单 <1s:路上手势(双击)不得穿透菜单确认
await flush();
assert(game.phase === 'menu', 'F6 去抖期内双击不穿透菜单(phase 保持 menu)');
assert(audio.isVoicePlaying(), 'F6 去抖期内菜单语音不被掐断');
G('down'); // 去抖期内下滑也不得关菜单
await flush();
assert(game.phase === 'menu', 'F6 去抖期内下滑不穿透菜单');
await advance(1100); // 越过 1s 去抖独占期
G('doubletap'); // 双击确认"继续演奏"
await flush();
assert(game.phase === 'main', 'F6 去抖期后双击正常确认(恢复演奏)');
// ================= 资产引用校验(fs:ogg+m4a 双格式零缺失) =================
console.log('\n[资产校验] game.js 引用的 id 对照 audio/ 目录');
{