round3: G1 gd_answer提前一小节(根因=排程压窗起点) + G2教学降难DDA + G3 count-in修复(根因=未重排调度队列) + G4 escape目标链 + G5 simon双击根治(重听改上滑,重录3句) + G6轮成功/破纪录 + G7 space方向呼喊 + 22语音文件 + guide93断言全绿
This commit is contained in:
+115
-40
@@ -35,13 +35,18 @@
|
||||
* main 演奏阶段单击是演奏手势不跳(mid_song 短句按裁定不跳)。
|
||||
* 长按快进豁免(12 文档 §2.2:main 演奏期):audio.setRateBoostEnabled(false)
|
||||
* per-game 开关(引擎已支持),进 main 关、进菜单/结算/退出恢复。
|
||||
* 向导层(docs/design/14-guide-layer.md + round2 synthesis F2.1/F3):每曲首次
|
||||
* 领奏→应答切换在谱面定时队列直接落 gd_answer(不走 guide queue,保证窗口前
|
||||
* 听到)+ turn earcon;教学 Miss 首次即归因分档(gd_miss_early/late/wrong,
|
||||
* 挂纠正链尾空闲点);连续 2 Miss 播 gd_miss(queue 插播,同曲一次);暂停
|
||||
* 断点续播前插 4 拍 count-in(off 档保持即时续播);结算链换 _speakGuarded
|
||||
* (资产缺失不卡结算 BGM);状态签名 earcon 与状态 BGM 同点
|
||||
* (select=menu、main=battle、end=settle)。
|
||||
* 向导层(docs/design/14-guide-layer.md + round2 synthesis F2.1/F3 + round3 G1~G3):
|
||||
* 每曲首次领奏→应答切换在谱面定时队列直接落 gd_answer(不走 guide queue,保证
|
||||
* 窗口前听到)+ turn earcon;round3 G1 修正排程点:落在首个应答窗口**前整整
|
||||
* 1 小节**(= 领奏小节头;round2 排在窗口起点本身,P4 实测窗口开了才听到),
|
||||
* 菜单恢复对下一个未判定窗口重排同款标记;教学 Miss 首次即归因分档
|
||||
* (gd_miss_early/late/wrong,挂纠正链尾空闲点);连续 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';
|
||||
@@ -159,10 +164,15 @@ export class RhythmGame extends Game {
|
||||
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 次提示一次)
|
||||
}
|
||||
|
||||
@@ -173,6 +183,9 @@ export class RhythmGame extends Game {
|
||||
'voice/tut_1', 'voice/select_hint', '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 次
|
||||
if (this._progress.tutorialDone) {
|
||||
// 二周目起:缩减开场 → 选曲
|
||||
this.phase = 'intro';
|
||||
@@ -309,10 +322,13 @@ export class RhythmGame extends Game {
|
||||
+ 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) * spb;
|
||||
const t = leadBar + (beat - 1) * stepSpb;
|
||||
this._sched.push({ time: t, sfx: INST[g], volume: 1 });
|
||||
this._cues.push({ gesture: g, expected: t + 4 * spb, judged: false, phrase: -1 });
|
||||
this._cues.push({ gesture: g, expected: t + 4 * stepSpb, judged: false, phrase: -1 });
|
||||
}
|
||||
this._sortSched();
|
||||
const last = this._cues[this._cues.length - 1];
|
||||
@@ -332,6 +348,7 @@ export class RhythmGame extends Game {
|
||||
return a && a.correct && Math.abs(a.dt) <= GOOD;
|
||||
});
|
||||
if (pass) {
|
||||
this._tutRate = 1.0; // G2:该步做对,恢复正常速度
|
||||
if (this._tutIdx + 1 < TUT_STEPS.length) {
|
||||
this._beginTutorial(rid, this._tutIdx + 1);
|
||||
} else {
|
||||
@@ -344,6 +361,22 @@ export class RhythmGame extends Game {
|
||||
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);
|
||||
@@ -440,17 +473,14 @@ export class RhythmGame extends Game {
|
||||
this._timed.push({ time: leadBarT0, fn: () => this.audio.play('voice/mid_song') });
|
||||
}
|
||||
if (i === 0) {
|
||||
// 向导层(round2 F3):每曲首次领奏→应答切换的交接标记。gd_answer 不走
|
||||
// guide 的 queue(对白在播时会排到链尾、错过首个应答窗口)——排进曲谱
|
||||
// 定时队列、在窗口前直接落下;turn earcon 走向导(full 语音,standard
|
||||
// 只听提示音,off 全静)。mid_song 同款先例。
|
||||
this._timed.push({
|
||||
time: leadBarT0 + 4 * spb,
|
||||
fn: () => {
|
||||
if (this.guide.level() === 'full') this.audio.play('voice/gd_answer');
|
||||
this.guide.turn({});
|
||||
},
|
||||
});
|
||||
// 向导层(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);
|
||||
});
|
||||
@@ -460,6 +490,32 @@ export class RhythmGame extends Game {
|
||||
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) {
|
||||
@@ -619,8 +675,11 @@ export class RhythmGame extends Game {
|
||||
this.guide.state('menu'); // 状态签名:菜单(模态暂停)
|
||||
// 暂停:停泵、停 BGM(记断点),已排程的 ≤0.6s 音符自然播完
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
this._resumeSeq++; // G3:作废还在等 gd_count 播完的旧恢复
|
||||
this._pauseAt = this.audio.ctx.currentTime;
|
||||
this._bgmOffset = this._pauseAt - this._bgmT0;
|
||||
// 断点续播偏移只在 BGM 真在播时刷新(count-in 语音期间再开菜单时 BGM 未起,
|
||||
// 沿用首次开的偏移,避免把暂停时长算进播放位置)
|
||||
if (this._bgmSrc) this._bgmOffset = this._pauseAt - this._bgmT0;
|
||||
this._stopBgmSrc();
|
||||
this._speak(['voice/menu_main', MENU_ITEMS[0]]);
|
||||
}
|
||||
@@ -641,27 +700,43 @@ export class RhythmGame extends Game {
|
||||
|
||||
_closeMenu(rid) {
|
||||
if (this._runId !== rid) return;
|
||||
const resumeAt = this.audio.ctx.currentTime + 0.1;
|
||||
// F3(round2):断点续播前插一整小节 4 拍 count-in(复用底鼓节拍音,走既有
|
||||
// _sched 网格排程 = 每拍一个 inst_kick + beat 轻震),网格与 BGM 整体后移一小节,
|
||||
// 杜绝 P1/P2/P3 实测的「回来 5 秒死寂、第一句报废」。off 档保持原即时续播
|
||||
// (向导层 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: 0.7 });
|
||||
}
|
||||
// _startBgm 内部重算 _bgmT0 = bgmAt - offset = 旧网格 + shift(负偏移保护同款成立)
|
||||
this._startBgm(this._song, bgmAt, this._bgmOffset);
|
||||
this.phase = 'main';
|
||||
this.audio.setRateBoostEnabled(false); // 回到演奏期
|
||||
this._startPump(rid);
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user