/** * settings.js — 全局用户设置(纯音频游戏合集共享)。 * * 三项设置: * | 键 | 取值 | 默认 | 作用 | * |--------|-----------------------|----------|----------------------------------------| * | voice | 'male' \| 'female' | 'female' | voice 层加载 audio/voice// 下资产 | * | speed | 1.0 \| 1.35 \| 1.7 | 1.0 | voice 层 source.playbackRate(语速) | * | bgm | 1.0 \| 0.3 \| 0 | 1.0 | BGM 层增益;0 = 不加载不播放 BGM | * * localStorage 持久化(key: 'soundgame.settings'),同域下大厅与所有游戏 * 页面天然共享;跨标签页修改通过 'storage' 事件同步给订阅者。 * * 用法: * import { settings } from '../engine/settings.js'; * settings.get(); // -> { voice, speed, bgm }(副本) * settings.set({ speed: 1.35 }); // 部分更新,持久化并通知订阅者 * const off = settings.subscribe((s, changed) => { ... }); // off() 退订 * * 游戏侧一般**不需要**直接用本模块:audio.js 已自动应用三项设置 * (voice 路径/语速/BGM 增益)。需要自定义响应(如改 UI)才 subscribe。 */ const STORAGE_KEY = 'soundgame.settings'; /** @typedef {{ voice: 'male'|'female', speed: number, bgm: number }} SoundGameSettings */ /** @type {SoundGameSettings} */ const DEFAULTS = { voice: 'female', speed: 1.0, bgm: 1.0 }; const VALID = { voice: ['male', 'female'], speed: [1.0, 1.35, 1.7], bgm: [1.0, 0.3, 0], }; /** @type {SoundGameSettings} */ let current = { ...DEFAULTS }; /** @type {Set<(s: SoundGameSettings, changed: Partial) => void>} */ const listeners = new Set(); function isValid(key, value) { return VALID[key] && VALID[key].includes(value); } /** 从 localStorage 读取并合并默认值(非法值逐项丢弃,保证永不炸)。 */ function load() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; const obj = JSON.parse(raw); for (const key of Object.keys(DEFAULTS)) { if (isValid(key, obj[key])) current[key] = obj[key]; } } catch (_) { // localStorage 不可用(隐私模式)或数据损坏 → 用默认值 } } function persist() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(current)); } catch (_) { // 写不进去就算了,内存值仍然生效 } } function notify(changed) { const snapshot = { ...current }; for (const fn of listeners) { try { fn(snapshot, changed); } catch (e) { console.error('[settings] 订阅者回调异常:', e); } } } load(); // 跨标签页同步(大厅和游戏页同时开着时) window.addEventListener('storage', (e) => { if (e.key !== STORAGE_KEY) return; const before = { ...current }; load(); const changed = {}; for (const key of Object.keys(DEFAULTS)) { if (before[key] !== current[key]) changed[key] = current[key]; } if (Object.keys(changed).length > 0) notify(changed); }); export const settings = { /** * 读取当前设置(返回副本,改它不影响内部状态)。 * @returns {SoundGameSettings} */ get() { return { ...current }; }, /** * 部分更新设置。非法键/值被忽略(console.warn),合法项立即持久化 * 并通知所有订阅者。 * @param {Partial} patch * @returns {SoundGameSettings} 更新后的完整设置 */ set(patch) { const changed = {}; for (const [key, value] of Object.entries(patch || {})) { if (!isValid(key, value)) { console.warn('[settings] 忽略非法设置项:', key, value); continue; } if (current[key] !== value) { current[key] = value; changed[key] = value; } } if (Object.keys(changed).length > 0) { persist(); notify(changed); } return { ...current }; }, /** * 订阅设置变更。回调签名 (snapshot, changed)。 * audio.js 靠它实现实时生效(BGM 立即调增益、voice 下一句生效)。 * @param {(s: SoundGameSettings, changed: Partial) => void} fn * @returns {() => void} 退订函数 */ subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }, }; export default settings;