- 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断言)+资产终检全绿
293 lines
11 KiB
JavaScript
293 lines
11 KiB
JavaScript
/**
|
||
* 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;
|