Files
xsl ede522dda1 feat(platform/P0): 无感用户系统 + PC 键盘双端一致(两 agent 并行 + 集成收口)
用户系统(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)
2026-09-09 23:39:23 +08:00

147 lines
5.0 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.
/**
* 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;