guide层v1: 全局向导交互层落地(状态签名earcon/turn cue/静默看门狗/纠错) + 6游戏接线 + 8句向导语音 + 测试全绿

- engine/guide.js 运行时合成8个签名earcon(方法论L2硬参数),off档零定时器,插播queue不顶对白
- settings: guide三档(full/standard/off默认full) + 语速2.2视障档;audio: isVoicePlaying()/setRateBoostEnabled() 清两个已知问题
- 6游戏按schemes§2接线: storyline看门狗+无效手势纠错、simon免扣重听看门狗、escape turn cue+提前卡关提示、rhythm gd_answer/gd_miss+结算看门狗+快进豁免、space首撞指令提示+结算看门狗、rpg格挡纠错+60s催促+状态签名
- 大厅设置面板+向导文档(14-guide-layer.md)+00-global-ux §6;R5台词表补4条一致性
- 试玩转写含向导事件;6 playtest(guide=off断言零改动)+guide.test(34断言)+资产终检全绿
This commit is contained in:
SoundGame Dev
2026-08-30 22:46:05 +08:00
parent 7eb9e7f025
commit 79dffac01e
69 changed files with 2466 additions and 144 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+40 -6
View File
@@ -33,10 +33,15 @@
* 旁白跳过(docs/design/12-skip-and-longpress-ux.md §1.2):intro/tut/select/
* end 阶段 voice 播放中单击 = audio.skipVoice()(跳下一句,链不断);
* main 演奏阶段单击是演奏手势不跳(mid_song 短句按裁定不跳)。
* 长按快进豁免(12 文档 §2.2:main+tut 应豁免):引擎当前无 per-game
* 开关(audio.js 全局监听 soundgame:longpress),记为已知限制,不改引擎。
* 长按快进豁免(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)。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
// ---- 判定与调度常量(03 文档 §2 施工契约) ----
@@ -133,6 +138,7 @@ 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;
@@ -154,6 +160,7 @@ export class RhythmGame extends Game {
this._menuIdx = 0;
this._pauseAt = 0;
this._bgmOffset = 0;
this._consecMiss = 0; // 连续 Miss 计数(向导 gd_miss:连 2 次提示一次)
}
// ---------------- 生命周期 ----------------
@@ -178,6 +185,7 @@ export class RhythmGame extends Game {
stop() {
this._runId++;
this._cleanup();
this.audio.setRateBoostEnabled(true); // 离开演奏期,恢复长按快进
this.audio.stopAll();
}
@@ -358,8 +366,10 @@ export class RhythmGame extends Game {
_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]);
}
@@ -388,12 +398,15 @@ export class RhythmGame extends Game {
_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;
@@ -414,6 +427,14 @@ export class RhythmGame extends Game {
if (i === midPhrase) {
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)
this._timed.push({
time: leadBarT0 + 4 * spb,
fn: () => this.guide.turn({ voice: 'gd_answer' }),
});
}
bar += 2 + (this._song.restAfter[i] || 0);
});
this._sortSched();
@@ -466,11 +487,11 @@ export class RhythmGame extends Game {
/**
* voice 播放中单击 = 跳过当前句(12 文档 §1.2,链播继续到下一句)。
* 引擎暂无"voice 播放中"公开查询接口,读内部 _voice 令牌判定。
* 用引擎公开的 isVoicePlaying() 判定(原读私有 _voice,清点 §0)。
* @returns {boolean} true = 该手势已消费(跳过了一句)
*/
_trySkipVoice(g) {
if (g !== 'tap' || !this.audio._voice) return false;
if (g !== 'tap' || !this.audio.isVoicePlaying()) return false;
this.audio.skipVoice();
return true;
}
@@ -537,6 +558,7 @@ export class RhythmGame extends Game {
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++;
@@ -552,6 +574,12 @@ export class RhythmGame extends Game {
this._stats.miss++;
this._stats.combo = 0;
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);
}
@@ -570,6 +598,8 @@ export class RhythmGame extends Game {
if (this._runId !== rid) return;
this.phase = 'menu';
this._menuIdx = 0;
this.audio.setRateBoostEnabled(true); // 暂停期不是演奏期,恢复长按快进
this.guide.state('menu'); // 状态签名:菜单(模态暂停)
// 暂停:停泵、停 BGM(记断点),已排程的 ≤0.6s 音符自然播完
if (this._timer) { clearInterval(this._timer); this._timer = null; }
this._pauseAt = this.audio.ctx.currentTime;
@@ -604,6 +634,7 @@ export class RhythmGame extends Game {
// _startBgm 内部重算 _bgmT0 = resumeAt - offset = 旧网格 + shift
this._startBgm(this._song, resumeAt, this._bgmOffset);
this.phase = 'main';
this.audio.setRateBoostEnabled(false); // 回到演奏期
this._startPump(rid);
}
@@ -627,6 +658,8 @@ export class RhythmGame extends Game {
_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 = [];
@@ -675,8 +708,9 @@ export class RhythmGame extends Game {
}
ids.push('voice/restart');
console.log('[RhythmGame] 结算:', this._stats, '评级:', grade.toUpperCase());
// 10 文档 §5:曲目 BGM 已 0.6s 淡出,结算语音链播完后淡入 menu pad 800ms
this._speak(ids, () => this.audio.play('bgm/menu', { fadeMs: 800 }));
// 10 文档 §5:曲目 BGM 已 0.6s 淡出,结算语音链播完后淡入 menu pad 800ms;
// 链换 _speakGuarded(清点 §4.6 缺口):grade_* 等资产缺失也能起结算 BGM
this._speakGuarded(ids, () => this.audio.play('bgm/menu', { fadeMs: 800 }));
}
// ---------------- 进度存储 ----------------
+1 -1
View File
@@ -153,7 +153,7 @@ let audio = null; let game = null; let playLog = []; let gestures = 0;
async function newWorld(progress = null) {
store.clear();
if (progress) store.set(PROGRESS_KEY, JSON.stringify(progress));
settings.set({ voice: 'female', speed: 1.0, bgm: 1.0 });
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
nowMs = 0; timers.clear(); vibLog.length = 0; srcLog.length = 0; fetchLog.length = 0; blocked.length = 0;
audio = new WebAudioEngine('audio/');
playLog = [];