Files

314 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<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 档语音。
* 语音为 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;