round2: 看门狗礼仪F1(不重播/单句升级/idle-skip) + 分档归因F2 + 交接count-in/answer F3 + 教学看表现F4-F5 + 警报去叠F6 + 回绕F7 + 开场瘦身F8 + 10句新语音 + 测试全绿(6playtest零改动+guide52断言+资产终检)

This commit is contained in:
SoundGame Dev
2026-08-31 00:26:25 +08:00
parent 296c996fb9
commit 902461f0f4
65 changed files with 1094 additions and 775 deletions
+41 -15
View File
@@ -35,10 +35,13 @@
* main 演奏阶段单击是演奏手势不跳(mid_song 短句按裁定不跳)。
* 长按快进豁免(12 文档 §2.2:main 演奏期):audio.setRateBoostEnabled(false)
* per-game 开关(引擎已支持),进 main 关、进菜单/结算/退出恢复。
* 向导层(research/03-schemes/schemes.md §2):每曲首次领奏→应答切换推
* gd_answer(turn cue);连续 2 次 Miss 播 gd_miss(queue 插播,同曲一次);
* 结算链换 _speakGuarded(资产缺失不卡结算 BGM);状态签名 earcon 与状态
* BGM 同点(select=menu、main=battle、end=settle)。
* 向导层(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)。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
@@ -341,7 +344,7 @@ export class RhythmGame extends Game {
this._tutFails++;
vibrate('error');
if (this._tutFails >= 2) {
// 同一步失败 2 次跳过,不卡流程
// 同一步失败 2 次跳过,不卡流程(归因已在首次失败时给过,此处不再叠加)
this._speakGuarded(['voice/retry_skip'], () => {
if (this._tutIdx + 1 < TUT_STEPS.length) this._beginTutorial(rid, this._tutIdx + 1);
else {
@@ -352,13 +355,22 @@ export class RhythmGame extends Game {
});
return;
}
// 纠正语音:手势错 > 过早 > 过晚 > 没出手(retry_listen)
let correction = 'voice/retry_listen';
// F2.1(round2):教学 Miss 首次即归因、按失败原因分档——方向错 gd_miss_wrong /
// 抢拍 gd_miss_early / 慢拍 gd_miss_late(无在窗内尝试=没跟上拍,归入慢拍)。
// 向导句(full 档)挂在纠正链播完后的空闲点发:idle-only 语义下链中插播会被
// 跳过;之后 _tutLead 只排 sfx,不与归因句抢 voice 通道。
const wrong = this._tutAttempts.find((a) => !a.correct);
const offbeat = this._tutAttempts.find((a) => a.correct && Math.abs(a.dt) > GOOD);
const missGrade = 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._tutLead(rid));
this._speakGuarded([correction, 'voice/retry_listen'], () => {
this.guide.error({ voice: missGrade, skipHaptic: true });
this._tutLead(rid);
});
}
// ---------------- 选曲 ----------------
@@ -428,11 +440,16 @@ export class RhythmGame extends Game {
this._timed.push({ time: leadBarT0, fn: () => this.audio.play('voice/mid_song') });
}
if (i === 0) {
// 向导层(schemes §2):每曲首次领奏→应答切换推 gd_answer(turn cue,
// 谱面排程处入 _timed,mid_song 同款先例;full 档语音 queue 插播不顶 BGM)
// 向导层(round2 F3):每曲首次领奏→应答切换的交接标记。gd_answer 不走
// guide 的 queue(对白在播时会排到链尾、错过首个应答窗口)——排进曲谱
// 定时队列、在窗口前直接落下;turn earcon 走向导(full 语音,standard
// 只听提示音,off 全静)。mid_song 同款先例。
this._timed.push({
time: leadBarT0 + 4 * spb,
fn: () => this.guide.turn({ voice: 'gd_answer' }),
fn: () => {
if (this.guide.level() === 'full') this.audio.play('voice/gd_answer');
this.guide.turn({});
},
});
}
bar += 2 + (this._song.restAfter[i] || 0);
@@ -625,14 +642,23 @@ export class RhythmGame extends Game {
_closeMenu(rid) {
if (this._runId !== rid) return;
const resumeAt = this.audio.ctx.currentTime + 0.1;
const shift = resumeAt - this._pauseAt;
// 全部绝对时刻平移暂停时长,节拍网格与 BGM 断点续播保持同步
// 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;
// _startBgm 内部重算 _bgmT0 = resumeAt - offset = 旧网格 + shift
this._startBgm(this._song, resumeAt, this._bgmOffset);
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);