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:
+24
-1
@@ -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);
|
||||
|
||||
@@ -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
@@ -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} */
|
||||
|
||||
Reference in New Issue
Block a user