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
+24 -1
View File
@@ -46,7 +46,8 @@
*
* **长按快进**:voice 播放中长按屏幕(gesture.js 派发的 window 事件
* 'soundgame:longpress')→ 旁白临时 1.7 倍速(不变调),松开
* ('soundgame:longpressend')恢复 settings.speed。游戏代码零改动。
* ('soundgame:longpressend')恢复 settings.speed。游戏代码零改动;
* 演奏类阶段可 setRateBoostEnabled(false) 按 game 豁免(见该方法)。
*/
import { settings } from './settings.js';
@@ -90,6 +91,7 @@ export class WebAudioEngine {
this._bgmWanted = null; // { id, opts } 最近一次 BGM 请求(bgm=0 时只记不播)
this._voice = null; // 当前对白 token(SoundTouch 管线,见 _playVoice)
this._voiceQueue = []; // queue:true 时的等待队列
this._rateBoostEnabled = true; // 长按快进开关(setRateBoostEnabled per-game 控制)
// 设置变更实时生效
settings.subscribe((_, changed) => this._onSettingsChange(changed));
@@ -249,6 +251,26 @@ export class WebAudioEngine {
this.stopVoice();
}
/**
* 当前是否有对白在播(含 queue 排队中的不算,只看正在播的这句)。
* 收编各游戏对私有字段 _voice 的只读判定(如"单击是否跳句"的语境判断)。
* @returns {boolean}
*/
isVoicePlaying() {
return this._voice !== null;
}
/**
* 长按快进(旁白临时 1.7 倍速)的 per-game 开关,默认开启(保持全局行为)。
* rhythm/space 等演奏/对局阶段传入 false 豁免——长按不再触发快进;
* 若快进正在生效会立即恢复设置语速。回菜单/结算时传 true 恢复。
* @param {boolean} on
*/
setRateBoostEnabled(on) {
this._rateBoostEnabled = on !== false;
if (!this._rateBoostEnabled) this._voiceBoost(false); // 已在快进则立即还原
}
// ---------------- 内部实现 ----------------
/**
@@ -449,6 +471,7 @@ export class WebAudioEngine {
_voiceBoost(on) {
const cur = this._voice;
if (!cur || cur.stopped) return;
if (on && !this._rateBoostEnabled) return; // per-game 豁免(演奏期不快进)
const target = on ? LONGPRESS_BOOST : settings.get().speed;
if (cur.st.tempo === target) return;
this._updateVoicePos(cur);
+292
View File
@@ -0,0 +1,292 @@
/**
* guide.js — 全局向导层(L3 状态签名 + A2 回合标记 + L4 静默看门狗 + A4 纠错)。
*
* 设计立场(research/03-schemes/schemes.md §1):
* - 语音承载语义,earcon 只做粗标记:8 个签名 earcon 全部**运行时 WebAudio 合成**
* (oscillator+gain 包络,代码即生成脚本,零资产、可复现、天然双平台),
* 参数守方法论 L2 硬边界:总长 0.25~0.4s、单音 ≥0.08s、频带 125Hz~5kHz、
* 家族=波形音色、子类=节奏轮廓;总增益 ≈0.12(比 BGM 背景轻但可辨)。
* - 向导默认开启(settings.guide='full'),老手可关:
* full = earcon + 语音 + 震动;standard = earcon + 震动(无语音);off = 全静。
* **off 时全部 API 静默 no-op,包括不挂任何定时器**——保证既有测试/玩法零干扰。
* - 向导绝不打断内容:插播语音一律 queue:true(等当前对白播完,绝不顶掉)。
*
* 游戏侧用法(构造时创建一次):
* import { Guide } from '../engine/guide.js';
* this.guide = new Guide(audio);
* guide.level(); // 'full' | 'standard' | 'off'(实时读 settings.guide)
* guide.state('battle'); // 进入宏观状态时播状态签名 earcon
* guide.turn({ voice, haptic }); // 轮到你了:turn earcon + 可选轻震 + full 档可选语音
* guide.watchdog({ arm: true, // 静默看门狗:到点轻 nudge 提醒 → 升级回调;
* at: [8000, 20000], say: [...], // say[n] 为第 n 级 full 档语音 id(可省);
* onEscalate: (n) => ..., cond }); // 升级回调(游戏播上下文提示);cond()=false 静默取消
* guide.error({ voice, onceKey, // 纠错:error 震(skipHaptic 可免)+ full 档语音
* skipHaptic }); //(onceKey 同局只播一次)
* guide.disarm(); // 手势入口调用:复位看门狗
* guide.setEventHook(fn); // 转写 harness 用:fn({type,name,voiceId})
*
* 状态签名 earcon 词表(6 状态 + 2 功能,全项目统一,见 schemes.md §1.4):
* | 名 | 音色家族 | 节奏轮廓 | 语义 |
* |---------|----------------|----------------|----------------|
* | menu | 柔和正弦双音 | 下行二度 | 系统界面 |
* | explore | 温暖三角波单音 | 单音长尾 | 探索/安全 |
* | battle | 略紧方波双音 | 上行纯五度短促 | 战斗/对抗 |
* | choice | 清亮正弦三音 | 上行琶音 | 抉择/等待输入 |
* | settle | 柔和三音 | 下行三连 | 结算/告一段落 |
* | danger | 低频双音 | 急促重复 | 危险 |
* | turn | 风铃质感单音 | 单音短 | 轮到你了 |
* | nudge | 极轻木鱼单音 | 单音极短 | 看门狗轻提醒 |
*/
import { settings } from './settings.js';
import { vibrate } from './haptics.js';
/** earcon 总增益峰值(比 BGM 背景轻但可辨)。 */
const BASE_GAIN = 0.12;
/**
* 签名词表:每项 = 一组音符 { t 起始秒, f 频率 Hz, d 时长秒, wave 波形, g 相对增益 }。
* 全部落在 0.25~0.4s / 单音 ≥0.08s / 125Hz~5kHz 边界内;家族=wave,子类=节奏。
*/
const EARCON_SPECS = {
// 柔和正弦双音,下行二度(系统界面)
menu: [
{ t: 0, f: 659.3, d: 0.13, wave: 'sine' },
{ t: 0.15, f: 587.3, d: 0.16, wave: 'sine' },
],
// 温暖三角波单音,单音长尾(探索/安全)
explore: [
{ t: 0, f: 329.6, d: 0.4, wave: 'triangle' },
],
// 略紧方波双音,上行纯五度短促(战斗/对抗;方波刺耳,降相对增益)
battle: [
{ t: 0, f: 220.0, d: 0.11, wave: 'square', g: 0.55 },
{ t: 0.15, f: 330.0, d: 0.12, wave: 'square', g: 0.5 },
],
// 清亮正弦三音,上行琶音(抉择/等待输入)
choice: [
{ t: 0, f: 523.3, d: 0.09, wave: 'sine' },
{ t: 0.105, f: 659.3, d: 0.09, wave: 'sine' },
{ t: 0.21, f: 784.0, d: 0.1, wave: 'sine' },
],
// 柔和三音,下行三连(结算/告一段落)
settle: [
{ t: 0, f: 784.0, d: 0.095, wave: 'sine', g: 0.9 },
{ t: 0.11, f: 659.3, d: 0.095, wave: 'sine', g: 0.85 },
{ t: 0.22, f: 523.3, d: 0.11, wave: 'sine', g: 0.8 },
],
// 低频双音,急促重复(危险)
danger: [
{ t: 0, f: 130.8, d: 0.1, wave: 'triangle' },
{ t: 0.16, f: 130.8, d: 0.12, wave: 'triangle' },
],
// 风铃质感单音,单音短(轮到你了):基音 + 高频泛音给金属光泽
turn: [
{ t: 0, f: 1318.5, d: 0.28, wave: 'sine' },
{ t: 0, f: 2637.0, d: 0.12, wave: 'sine', g: 0.25 },
],
// 极轻木鱼单音,单音极短(看门狗轻提醒)
nudge: [
{ t: 0, f: 880.0, d: 0.08, wave: 'triangle', g: 0.5 },
],
};
export class Guide {
/**
* @param {WebAudioEngine} audio 音频引擎(earcon 接其 master 总线,语音走 play queue)
*/
constructor(audio) {
this.audio = audio;
/** @type {((evt: {type: string, name: string, voiceId: string|null}) => void)|null} */
this._hook = null;
/** @type {Set<string>} onceKey → 同局只播一次 */
this._once = new Set();
/** @type {{at:number[], say:(string|null)[], onEscalate:((n:number)=>void)|null, cond:(()=>boolean)|null, idx:number, timerId:any}|null} */
this._wd = null;
}
/** 当前向导等级(实时读 settings.guide)。 @returns {'full'|'standard'|'off'} */
level() {
return settings.get().guide;
}
/** 注册事件钩子(转写 harness 用):每个动作发 {type,name,voiceId}。 */
setEventHook(fn) {
this._hook = fn;
}
/**
* 复位看门狗(手势入口调用;任何 guide API 调用内部也会复位)。
*/
disarm() {
this._clearWatchdog();
}
// ------------------------------------------------ 对外 API
/**
* L3 状态签名:进入宏观状态时播('menu'|'explore'|'battle'|'choice'|'settle'|'danger')。
* @param {'menu'|'explore'|'battle'|'choice'|'settle'|'danger'} name
*/
state(name) {
if (this.level() === 'off') return;
this._touch();
this._playEarcon(name);
this._emit('state', name, null);
}
/**
* A2 回合标记:earcon(turn)+ 可选轻震 + full 档可选语音(queue,绝不顶掉在播语音)。
* @param {{voice?: string|null, haptic?: boolean}} [opts]
*/
turn(opts = {}) {
if (this.level() === 'off') return;
this._touch();
this._playEarcon('turn');
this._emit('turn', 'turn', null);
if (opts.haptic) vibrate('confirm');
if (opts.voice) this._say('turn', opts.voice);
}
/**
* A4 纠错:error 震(除非 skipHaptic——游戏层已震过时传 true)+ full 档语音。
* @param {{voice?: string|null, onceKey?: string|null, skipHaptic?: boolean}} [opts]
*/
error(opts = {}) {
if (this.level() === 'off') return;
this._touch();
if (!opts.skipHaptic) vibrate('error');
if (!opts.voice) return;
if (opts.onceKey) {
if (this._once.has(opts.onceKey)) return;
if (this.level() === 'full') this._once.add(opts.onceKey); // 只在真的播了才计数
}
if (this.level() !== 'full') return;
this._say('error', opts.voice);
}
/**
* L4 静默看门狗:arm 后按 at 逐级计时,每级播 nudge 轻 earcon(+ say[n-1] 语音,
* full 档),再调 onEscalate(n);级数用尽自动停止。cond() 在触发时刻返回 false
* 则静默取消(游戏已离开该等待态)。任何 guide API 调用 / disarm() / 再次 arm 复位。
* @param {{arm?: boolean, at?: number[], say?: (string|null)[], onEscalate?: (n:number)=>void, cond?: ()=>boolean}} opts
*/
watchdog(opts = {}) {
if (this.level() === 'off') return; // off:绝不注册定时器
if (opts.arm === false) {
this._clearWatchdog();
return;
}
this._clearWatchdog();
this._wd = {
at: [...(opts.at || [])],
say: [...(opts.say || [])],
onEscalate: opts.onEscalate || null,
cond: opts.cond || null,
idx: 0,
timerId: null,
};
this._armNextLevel();
}
// ------------------------------------------------ 内部实现
_quiet() {
return this.level() === 'off';
}
_emit(type, name, voiceId) {
if (!this._hook) return;
try {
this._hook({ type, name, voiceId: voiceId ?? null });
} catch (_) { /* 钩子异常不影响游戏 */ }
}
/** full 档语音插播:queue:true 排到当前对白之后,绝不顶掉正在播的语音。 */
_say(type, voiceId) {
if (this.level() !== 'full') return;
this._emit(type, type, voiceId);
this.audio.play(`voice/${voiceId}`, { queue: true });
}
/** 运行时合成 earcon(oscillator + gain 包络,接引擎 master 总线吃限幅保护)。 */
_playEarcon(name) {
const spec = EARCON_SPECS[name];
const ctx = this.audio && this.audio.ctx;
if (!spec || !ctx || !this.audio._master) return;
const t0 = ctx.currentTime + 0.01;
for (const n of spec) {
const osc = ctx.createOscillator();
osc.type = n.wave;
osc.frequency.value = n.f;
const g = ctx.createGain();
const peak = BASE_GAIN * (n.g ?? 1);
// 快起 → 缓释包络(分两段 linear,听感接近指数衰减且 mock 环境安全)
g.gain.setValueAtTime(0.0001, t0 + n.t);
g.gain.linearRampToValueAtTime(peak, t0 + n.t + 0.008);
g.gain.linearRampToValueAtTime(peak * 0.55, t0 + n.t + n.d * 0.4);
g.gain.linearRampToValueAtTime(0.0001, t0 + n.t + n.d);
osc.connect(g);
g.connect(this.audio._master);
osc.start(t0 + n.t);
osc.stop(t0 + n.t + n.d + 0.02);
}
}
/** guide API 活动复位:看门狗在挂时重排当前级计时(不升级、不清除)。 */
_touch() {
const wd = this._wd;
if (!wd || this._quiet()) return;
if (wd.timerId !== null) clearTimeout(wd.timerId);
this._armNextLevel();
}
_clearWatchdog() {
const wd = this._wd;
if (wd && wd.timerId !== null) clearTimeout(wd.timerId);
this._wd = null;
}
_armNextLevel() {
const wd = this._wd;
if (!wd || this._quiet()) {
this._wd = null;
return;
}
if (wd.idx >= wd.at.length) {
this._wd = null; // 升级次数用尽:自动停止
return;
}
// at[] 以挂载时刻为基准的绝对延迟(如 [8000, 20000] = 8s 提醒 / 20s 升级),
// 逐级换算为与上一级的间隔
const prev = wd.idx > 0 ? wd.at[wd.idx - 1] : 0;
const delay = Math.max(0, wd.at[wd.idx] - prev);
wd.timerId = setTimeout(() => this._fireWatchdog(), delay);
}
_fireWatchdog() {
const wd = this._wd;
if (!wd || this._quiet()) {
this._wd = null;
return;
}
wd.timerId = null;
if (wd.cond && !wd.cond()) {
this._wd = null; // 已离开等待态:静默取消
return;
}
const level = wd.idx + 1;
wd.idx++;
this._playEarcon('nudge');
this._emit('nudge', 'nudge', null);
const voiceId = wd.say[level - 1] || null;
if (voiceId && this.level() === 'full') {
this._say('watchdog', voiceId);
} else {
this._emit('watchdog', 'watchdog', null);
}
if (wd.onEscalate) wd.onEscalate(level);
this._armNextLevel(); // 还有下一级则续链,否则自动停止
}
}
export default Guide;
+14 -12
View File
@@ -1,37 +1,39 @@
/**
* settings.js — 全局用户设置(纯音频游戏合集共享)。
*
* 三项设置:
* | 键 | 取值 | 默认 | 作用 |
* |--------|-----------------------|----------|----------------------------------------|
* | voice | 'male' \| 'female' | 'female' | voice 层加载 audio/voice/<gender>/ 下资产 |
* | speed | 1.0 \| 1.35 \| 1.7 | 1.0 | voice 层 source.playbackRate(语速) |
* | bgm | 1.0 \| 0.3 \| 0 | 1.0 | BGM 层增益;0 = 不加载不播放 BGM |
* 四项设置:
* | 键 | 取值 | 默认 | 作用 |
* |--------|-------------------------------------|----------|----------------------------------------|
* | voice | 'male' \| 'female' | 'female' | voice 层加载 audio/voice/<gender>/ 下资产 |
* | speed | 1.0 \| 1.35 \| 1.7 \| 2.2 | 1.0 | voice 层 source.playbackRate(语速;2.2 为视障档) |
* | bgm | 1.0 \| 0.3 \| 0 | 1.0 | BGM 层增益;0 = 不加载不播放 BGM |
* | guide | 'full' \| 'standard' \| 'off' | 'full' | 向导帮助等级(engine/guide.js 实时读取)|
*
* localStorage 持久化(key: 'soundgame.settings'),同域下大厅与所有游戏
* 页面天然共享;跨标签页修改通过 'storage' 事件同步给订阅者。
*
* 用法:
* import { settings } from '../engine/settings.js';
* settings.get(); // -> { voice, speed, bgm }(副本)
* settings.get(); // -> { voice, speed, bgm, guide }(副本)
* settings.set({ speed: 1.35 }); // 部分更新,持久化并通知订阅者
* const off = settings.subscribe((s, changed) => { ... }); // off() 退订
*
* 游戏侧一般**不需要**直接用本模块:audio.js 已自动应用三项设置
* (voice 路径/语速/BGM 增益)。需要自定义响应(如改 UI)才 subscribe。
* 游戏侧一般**不需要**直接用本模块:audio.js 已自动应用 voice/speed/bgm,
* 向导层(engine/guide.js)实时读 guide。需要自定义响应(如改 UI)才 subscribe。
*/
const STORAGE_KEY = 'soundgame.settings';
/** @typedef {{ voice: 'male'|'female', speed: number, bgm: number }} SoundGameSettings */
/** @typedef {{ voice: 'male'|'female', speed: number, bgm: number, guide: 'full'|'standard'|'off' }} SoundGameSettings */
/** @type {SoundGameSettings} */
const DEFAULTS = { voice: 'female', speed: 1.0, bgm: 1.0 };
const DEFAULTS = { voice: 'female', speed: 1.0, bgm: 1.0, guide: 'full' };
const VALID = {
voice: ['male', 'female'],
speed: [1.0, 1.35, 1.7],
speed: [1.0, 1.35, 1.7, 2.2],
bgm: [1.0, 0.3, 0],
guide: ['full', 'standard', 'off'],
};
/** @type {SoundGameSettings} */