/** * 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(等当前对白播完,绝不顶掉)。 * **error / watchdog 语音例外,为 idle-only(round2 F1.4)**:对白在播 * (audio.isVoicePlaying())时跳过语音只发事件(事件带 skipped:true)—— * 纠错/看门狗句排到长链尾部会迟到误导,宁可不说;turn 语音保持 queue 不变。 * * 游戏侧用法(构造时创建一次): * 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} 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 档语音。 * 语音为 idle-only(F1.4):对白在播时跳过只发 skipped 事件,且不占 onceKey * (玩家没听到不算说过,下次空闲再纠错仍可播一次)。 * @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 (this.level() !== 'full') return; if (this.audio.isVoicePlaying()) { this._emit('error', 'error', opts.voice, true); return; } if (opts.onceKey) { if (this._once.has(opts.onceKey)) return; this._once.add(opts.onceKey); // 只在真的播了才计数 } this._say('error', opts.voice); } /** * L4 静默看门狗:arm 后按 at 逐级计时,每级播 nudge 轻 earcon(+ say[n-1] 语音, * full 档,idle-only——对白在播时跳过只发事件),再调 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, skipped = false) { if (!this._hook) return; try { const evt = { type, name, voiceId: voiceId ?? null }; if (skipped) evt.skipped = true; this._hook(evt); } catch (_) { /* 钩子异常不影响游戏 */ } } /** * full 档语音插播:queue:true 排到当前对白之后,绝不顶掉正在播的语音。 * F1.4 idle-only:其中 error/watchdog 两类在对白在播(audio.isVoicePlaying()) * 时跳过语音只发事件(事件带 skipped:true)——排到链尾会迟到误导;turn 保持 * queue 不变。 */ _say(type, voiceId) { if (this.level() !== 'full') return; if ((type === 'error' || type === 'watchdog') && this.audio.isVoicePlaying()) { this._emit(type, type, voiceId, true); 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;