用户系统(B,游戏零改动): - POST /api/device 首访 0 交互静默建档(自动玩家一/二…,per-IP 频控);断网降级纯本地照玩 - 大厅档案切换器(切换=flush 上云→快照回灌→倒灌→服务器回拉,profileId 分桶修同设备串档;aria menu 编号可听) - claim 可选认领(命名+scrypt PIN);旧 users/sessions 单向迁移(PIN 明文即刻哈希,已真实数据验证);login 兼容+claim-merge 桶级 LWW;logout;/server/ 静态封禁 - PUT baseUpdatedAt 守卫(legacy 放行+警告);GET 无参全桶回拉(修 7/8 游戏跨设备只 PUT 不 GET) - web/profiles.js 分桶纯函数 + api-smoke.mjs 7/7(建档/LWW/claim-merge/迁移/频控/封禁/分桶 H1-H4) 键盘双端(A,游戏代码零改动): - gesture.js 键盘通道 +137 行:←↑↓→/WASD=滑动、空格=单击(走 300ms 双击窗与触摸共享虚拟指针)、回车=即时 doubletap、按住空格 ≥450ms=长按;repeat 抑制/表单守卫/方向键 preventDefault - settings.input(auto/touch/key)+ gesture.inputMode();遮罩键盘解锁与文案自适应(按空格开始/轻触开始) - 8 游戏遮罩改用自适应默认文案;大厅设置面板「操作方式」+页脚键位提示 - live control.mjs 键盘注入通道(CDP dispatchKeyEvent);engine-input.test 61 断言 回归:12 套件全绿(8 playtest + guide + engine-voice + engine-input + api-smoke 7/7);8001 服务器已重启加载新 API,/api/device 端到端验证通过 P1 待排:153 句×双性 TTS 键盘语音变体 / 6 位配对码跨设备 / 认领 UI 打磨(research/platform/00-synthesis.md §3)
147 lines
5.0 KiB
JavaScript
147 lines
5.0 KiB
JavaScript
/**
|
||
* settings.js — 全局用户设置(纯音频游戏合集共享)。
|
||
*
|
||
* 五项设置:
|
||
* | 键 | 取值 | 默认 | 作用 |
|
||
* |--------|-------------------------------------|----------|----------------------------------------|
|
||
* | 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 实时读取)|
|
||
* | input | 'auto' \| 'touch' \| 'key' | 'auto' | 提示文案输入模式(gesture.inputMode():
|
||
* | | | | auto=跟随最近使用的通道;只影响提示文案,
|
||
* | | | | 不关闭任何输入通道) |
|
||
*
|
||
* localStorage 持久化(key: 'soundgame.settings'),同域下大厅与所有游戏
|
||
* 页面天然共享;跨标签页修改通过 'storage' 事件同步给订阅者。
|
||
*
|
||
* 用法:
|
||
* import { settings } from '../engine/settings.js';
|
||
* settings.get(); // -> { voice, speed, bgm, guide }(副本)
|
||
* settings.set({ speed: 1.35 }); // 部分更新,持久化并通知订阅者
|
||
* const off = settings.subscribe((s, changed) => { ... }); // off() 退订
|
||
*
|
||
* 游戏侧一般**不需要**直接用本模块:audio.js 已自动应用 voice/speed/bgm,
|
||
* 向导层(engine/guide.js)实时读 guide。需要自定义响应(如改 UI)才 subscribe。
|
||
*/
|
||
|
||
const STORAGE_KEY = 'soundgame.settings';
|
||
|
||
/** @typedef {{ voice: 'male'|'female', speed: number, bgm: number, guide: 'full'|'standard'|'off', input: 'auto'|'touch'|'key' }} SoundGameSettings */
|
||
|
||
/** @type {SoundGameSettings} */
|
||
const DEFAULTS = { voice: 'female', speed: 1.0, bgm: 1.0, guide: 'full', input: 'auto' };
|
||
|
||
const VALID = {
|
||
voice: ['male', 'female'],
|
||
speed: [1.0, 1.35, 1.7, 2.2],
|
||
bgm: [1.0, 0.3, 0],
|
||
guide: ['full', 'standard', 'off'],
|
||
input: ['auto', 'touch', 'key'],
|
||
};
|
||
|
||
/** @type {SoundGameSettings} */
|
||
let current = { ...DEFAULTS };
|
||
|
||
/** @type {Set<(s: SoundGameSettings, changed: Partial<SoundGameSettings>) => 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<SoundGameSettings>} 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<SoundGameSettings>) => void} fn
|
||
* @returns {() => void} 退订函数
|
||
*/
|
||
subscribe(fn) {
|
||
listeners.add(fn);
|
||
return () => listeners.delete(fn);
|
||
},
|
||
};
|
||
|
||
export default settings;
|