Files
SoundGame/web/index.html
T
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

796 lines
32 KiB
HTML
Raw 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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<title>SoundGame · 纯音频游戏大厅</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: #0b0d10;
color: #e8eaed;
font-family: system-ui, -apple-system, "PingFang SC", "Noto Sans CJK SC", sans-serif;
padding: 32px 20px 64px;
}
header { text-align: center; margin-bottom: 24px; }
header h1 { font-size: 28px; margin: 0 0 8px; letter-spacing: 0.15em; }
header p { color: #9aa0a6; font-size: 14px; margin: 0; }
.panel {
max-width: 960px;
margin: 0 auto 16px;
background: #161a20;
border: 1px solid #262c36;
border-radius: 14px;
padding: 16px 20px;
}
.panel h3 { margin: 0 0 12px; font-size: 14px; color: #9aa0a6; font-weight: normal; letter-spacing: 0.1em; }
/* 玩家区(档案切换器) */
#user-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
#profile-btn {
background: #4f8cff;
border: none;
border-radius: 8px;
color: #fff;
padding: 10px 18px;
font-size: 15px;
cursor: pointer;
}
#profile-btn[aria-expanded="true"] { background: #3c74e0; }
#user-msg { font-size: 13px; color: #9aa0a6; width: 100%; margin: 8px 0 0; min-height: 1em; }
#profile-menu {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
padding: 10px;
background: #0b0d10;
border: 1px solid #262c36;
border-radius: 10px;
}
#profile-menu button {
background: #161a20;
border: 1px solid #262c36;
border-radius: 8px;
color: #e8eaed;
padding: 10px 14px;
font-size: 15px;
text-align: left;
cursor: pointer;
}
#profile-menu button:hover { border-color: #4f8cff; }
#profile-menu button[aria-checked="true"] { color: #4f8cff; }
/* 档案管理 */
#profile-manage { margin-top: 12px; padding-top: 12px; border-top: 1px solid #262c36; }
#profile-manage h4 { margin: 0 0 10px; font-size: 13px; color: #9aa0a6; font-weight: normal; }
.manage-form { margin-bottom: 12px; }
.manage-form label { display: block; font-size: 13px; color: #9aa0a6; margin-bottom: 6px; }
.manage-row { display: flex; flex-wrap: wrap; gap: 8px; }
.manage-row input {
background: #0b0d10;
border: 1px solid #262c36;
border-radius: 8px;
color: #e8eaed;
padding: 8px 12px;
font-size: 14px;
width: 140px;
}
.manage-row input[inputmode="numeric"] { width: 110px; }
.manage-row button {
background: #4f8cff;
border: none;
border-radius: 8px;
color: #fff;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
.manage-note { font-size: 12px; color: #5f6368; margin: 4px 0 0; }
/* 设置区 */
#settings-row { display: flex; flex-wrap: wrap; gap: 16px; }
.setting-item { display: flex; flex-direction: column; gap: 6px; }
.setting-item label { font-size: 13px; color: #9aa0a6; }
.setting-item select {
background: #0b0d10;
border: 1px solid #262c36;
border-radius: 8px;
color: #e8eaed;
padding: 8px 12px;
font-size: 14px;
}
/* 游戏卡片 */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
max-width: 960px;
margin: 0 auto;
}
a.card {
display: block;
text-decoration: none;
color: inherit;
background: #161a20;
border: 1px solid #262c36;
border-radius: 14px;
padding: 22px 20px;
transition: border-color 0.15s, transform 0.15s;
}
a.card:active { transform: scale(0.98); border-color: #4f8cff; }
a.card h2 { margin: 0 0 4px; font-size: 20px; }
a.card .badge-new {
display: inline-block;
vertical-align: 3px;
margin-left: 8px;
padding: 2px 8px;
font-size: 11px;
color: #0b0d10;
background: #7ec699;
border-radius: 6px;
letter-spacing: 0.1em;
}
a.card .slug { color: #4f8cff; font-size: 12px; letter-spacing: 0.1em; }
a.card p { margin: 10px 0 0; color: #9aa0a6; font-size: 14px; line-height: 1.6; }
a.card .progress { margin: 8px 0 0; color: #7ec699; font-size: 13px; min-height: 1em; }
footer {
text-align: center;
color: #5f6368;
font-size: 12px;
margin-top: 48px;
line-height: 1.8;
}
</style>
</head>
<body>
<header>
<h1>SoundGame</h1>
<p>纯音频游戏 · 一副耳机,六种手势,无需眼看</p>
</header>
<!-- 玩家区:匿名档案切换器(首访 0 输入 0 点击自动建档,research/platform/01-identity.md 方案 B) -->
<section class="panel" id="user-panel" aria-label="玩家">
<h3>玩家</h3>
<div id="user-row">
<button id="profile-btn" aria-haspopup="menu" aria-expanded="false">玩家:…</button>
</div>
<div id="profile-menu" role="menu" aria-label="切换玩家" hidden></div>
<div id="profile-manage" role="group" aria-labelledby="manage-title" hidden>
<h4 id="manage-title">档案管理</h4>
<div class="manage-form">
<label for="rename-input">新名字(1~24 位,可用中文;玩家一这类自动名可听,自定义名仅显示)</label>
<div class="manage-row">
<input id="rename-input" maxlength="24">
<button id="rename-btn">改名</button>
</div>
</div>
<div class="manage-form" id="pin-form">
<label for="pin-input">设置 PIN(4~6 位数字;设置后可用「名字 + PIN」在其他设备登录本档案)</label>
<div class="manage-row">
<input id="pin-input" inputmode="numeric" maxlength="6" aria-describedby="pin-hint">
<button id="pin-btn">设置 PIN</button>
</div>
<p class="manage-note" id="pin-hint">PIN 是可选项,只为防家人恶作剧;不设也完整可玩。</p>
</div>
<div class="manage-form" id="login-form">
<label for="login-name-input">已有档案?用名字 + PIN 登录(本机当前进度会按游戏合并上去)</label>
<div class="manage-row">
<input id="login-name-input" maxlength="24">
<input id="login-pin-input" inputmode="numeric" maxlength="6" aria-label="PIN">
<button id="login-btn">登录</button>
</div>
</div>
<p class="manage-note" id="offline-note" hidden>当前为离线档案(无本地服务器):可改名、可切换,PIN 与登录需要家庭服务器。</p>
</div>
<p id="user-msg" role="status" aria-live="polite"></p>
</section>
<section class="panel" id="settings-panel" aria-label="设置">
<h3>设置</h3>
<div id="settings-row">
<div class="setting-item">
<label for="sel-voice">旁白音色</label>
<select id="sel-voice">
<option value="female">女声</option>
<option value="male">男声</option>
</select>
</div>
<div class="setting-item">
<label for="sel-speed">语速</label>
<select id="sel-speed">
<option value="1">正常</option>
<option value="1.35">快</option>
<option value="1.7">特快</option>
<option value="2.2">特快++</option>
</select>
</div>
<div class="setting-item">
<label for="sel-guide">向导帮助</label>
<select id="sel-guide">
<option value="full">详细</option>
<option value="standard">只听提示音</option>
<option value="off">关闭</option>
</select>
</div>
<div class="setting-item">
<label for="sel-bgm">背景音乐</label>
<select id="sel-bgm">
<option value="1">正常</option>
<option value="0.3">小</option>
<option value="0">关闭</option>
</select>
</div>
<div class="setting-item">
<label for="sel-input">操作方式</label>
<select id="sel-input">
<option value="auto">自动</option>
<option value="touch">触屏</option>
<option value="key">键盘</option>
</select>
</div>
</div>
</section>
<main class="grid">
<a class="card" href="radio/" data-game="radio">
<h2>不眠频率 <span class="badge-new">新作</span></h2>
<span class="slug">RADIO · MIDNIGHT HOTLINE</span>
<p>深夜电台热线 RPG。一晚五个来电,你接话的方式,改变五个陌生人的夜晚。</p>
<p class="progress"></p>
</a>
<a class="card" href="dungeon/" data-game="dungeon">
<h2>回声地窟 <span class="badge-new">新作</span></h2>
<span class="slug">DUNGEON · ECHO DELVE</span>
<p>听声辨位 Roguelike。单击发出声波脉冲,用回声在黑暗中走出一条生路。</p>
<p class="progress"></p>
</a>
<a class="card" href="rpg/" data-game="rpg">
<h2>剑与声</h2>
<span class="slug">RPG · SWORD &amp; SOUND</span>
<p>盲剑客武侠 RPG。眼盲,耳即眼——听声辨招,双击格挡,踏平黑风寨。</p>
<p class="progress"></p>
</a>
<a class="card" href="storyline/" data-game="storyline">
<h2>声命线</h2>
<span class="slug">STORYLINE</span>
<p>末世互动广播剧。在声音里做选择,决定故事的走向。</p>
<p class="progress"></p>
</a>
<a class="card" href="escape/" data-game="escape">
<h2>暗室</h2>
<span class="slug">ESCAPE</span>
<p>密室逃脱。一片漆黑中,只靠声音摸索出路。</p>
<p class="progress"></p>
</a>
<a class="card" href="simon/" data-game="simon">
<h2>音序回响</h2>
<span class="slug">SIMON</span>
<p>音频记忆游戏。听一段音序,用手势原样复现。</p>
<p class="progress"></p>
</a>
<a class="card" href="rhythm/" data-game="rhythm">
<h2>节拍行者</h2>
<span class="slug">RHYTHM</span>
<p>节奏打击。跟着鼓点,在正确的时刻出手。</p>
<p class="progress"></p>
</a>
<a class="card" href="space/" data-game="space">
<h2>盲航</h2>
<span class="slug">SPACE</span>
<p>太空躲避。闭眼驾驶飞船,凭引擎声与警报闪避陨石。</p>
<p class="progress"></p>
</a>
</main>
<footer>
建议佩戴耳机游玩 · 操作方式:上 / 下 / 左 / 右滑动、单击、双击(PC 键盘:方向键 / WASD、空格、回车)<br>
单击判定有约 300ms 延迟(双击识别所需),属于正常设计
</footer>
<script type="module">
import { settings } from './engine/settings.js';
import {
BUCKETS, SETTINGS_KEY,
applyAuth, decideBucket, getCurrentProfile, migrateLegacyAuth,
nextLocalProfileName, readRegistry, readStorageJSON, restoreSnapshot,
setCurrentProfile, snapshotKeyOf, stashSnapshot, syncKeyOf,
writeRegistry, writeStorageJSON,
} from './profiles.js';
// 公网站点只托管静态文件、无用户 API(docs/deploy.md):档案系统自动降级为
// 纯本地档案(research/platform/01-identity.md 方案 A = 方案 B 的本地后端),
// 不再整体隐藏玩家面板。本地 ./web/serve.sh 与服务器档案同一套代码。
const PUBLIC_STATIC =
location.protocol === 'https:' && location.hostname.endsWith('xiangsilian.com');
// settings.input 白名单值(engine/settings.js VALID.input:'auto' | 'touch' | 'key',
// 引擎侧手势分发由键盘适配工作线实现;此处只写入设置)
const INPUT_MODES = ['auto', 'touch', 'key'];
const NAME_RE = /^[A-Za-z0-9_\-一-龥]{1,24}$/;
const PIN_RE = /^\d{4,6}$/;
// ---------- 服务器 API(不可达时静默降级为纯本地档案) ----------
async function api(path, opts = {}, tokenOverride) {
const cur = getCurrentProfile(localStorage);
const token = tokenOverride || (cur && cur.token) || null;
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
const resp = await fetch('/api/' + path, { ...opts, headers });
const body = await resp.json().catch(() => ({}));
if (!resp.ok) {
const err = new Error(body.error || ('HTTP ' + resp.status));
err.status = resp.status;
err.body = body;
throw err;
}
return body;
}
// ---------- UI 元素 / 读屏播报 ----------
const profileBtn = document.getElementById('profile-btn');
const profileMenu = document.getElementById('profile-menu');
const profileManage = document.getElementById('profile-manage');
const userMsg = document.getElementById('user-msg');
const renameInput = document.getElementById('rename-input');
const renameBtn = document.getElementById('rename-btn');
const pinForm = document.getElementById('pin-form');
const pinInput = document.getElementById('pin-input');
const pinBtn = document.getElementById('pin-btn');
const loginForm = document.getElementById('login-form');
const loginNameInput = document.getElementById('login-name-input');
const loginPinInput = document.getElementById('login-pin-input');
const loginBtn = document.getElementById('login-btn');
const offlineNote = document.getElementById('offline-note');
function announce(text) { userMsg.textContent = text || ''; }
// ---------- 同步映射表(各档案已知的服务器桶 updatedAt/rev) ----------
function getSyncMap(id) { return readStorageJSON(localStorage, syncKeyOf(id), {}) || {}; }
function setSyncBucket(id, game, updatedAt, rev) {
const m = getSyncMap(id);
m[game] = { updatedAt, rev };
writeStorageJSON(localStorage, syncKeyOf(id), m);
}
function moveStorageKey(from, to) {
const v = localStorage.getItem(from);
if (v !== null) {
localStorage.setItem(to, v);
localStorage.removeItem(from);
}
}
// ---------- 桶级 LWW:倒灌 / 上传 / 409 处理(大厅统一做,游戏零改动) ----------
function refreshCard(game) {
const bucket = BUCKETS.find((b) => b.game === game);
const el = document.querySelector(`a.card[data-game="${game}"] .progress`);
if (!el || !bucket) return;
const data = readStorageJSON(localStorage, bucket.key, null);
el.textContent = data && typeof data === 'object' && typeof data.summary === 'string'
? data.summary
: '';
}
function refreshCards() {
for (const { game } of BUCKETS) refreshCard(game);
}
function pourServerBucket(bucket, sv) {
localStorage.setItem(bucket.key, JSON.stringify(sv.data));
if (bucket.key === SETTINGS_KEY) applyServerSettings(sv.data);
refreshCard(bucket.game);
}
/**
* 上传一个桶。带 baseUpdatedAt 守卫;409(别处更新过)时按 data.updatedAt
* 桶级 LWW 择优:本地确较新 → 用服务器新基线重放;否则采纳服务器副本倒灌。
*/
async function putBucket(profile, game, raw, baseUpdatedAt) {
const data = JSON.parse(raw);
const body = { game, data };
if (baseUpdatedAt) body.baseUpdatedAt = baseUpdatedAt;
try {
const r = await api('progress', { method: 'PUT', body: JSON.stringify(body) }, profile.token);
setSyncBucket(profile.id, game, r.updatedAt, r.rev);
} catch (e) {
if (e.status !== 409 || !e.body || !e.body.server) throw e;
const sv = e.body.server;
const bucket = BUCKETS.find((b) => b.game === game);
const localRaw = localStorage.getItem(bucket.key);
const decision = decideBucket(localRaw, sv);
if (decision === 'push-local') {
return putBucket(profile, game, localRaw, sv.updatedAt); // 本地确较新,用新基线重放一次
}
if (decision === 'take-server') {
pourServerBucket(bucket, sv);
setSyncBucket(profile.id, game, sv.updatedAt, sv.rev);
}
}
}
/**
* 档案同步:GET 全桶 → 逐桶 LWW(decideBucket)——本地缺/旧 → 服务器倒灌回
* 游戏 original key(修「7/8 游戏只 PUT 不 GET」存量 bug);服务器缺 → 本地
* 上云。游戏代码继续只读本地,零改动。
*/
async function syncProfile(profile) {
if (!profile || !profile.token) return; // 纯本地档案(公网静态 / 离线建档)
const r = await api('progress', { method: 'GET' }, profile.token);
const serverGames = (r && r.games) || {};
for (const bucket of BUCKETS) {
const sv = serverGames[bucket.game] || null;
const localRaw = localStorage.getItem(bucket.key);
switch (decideBucket(localRaw, sv)) {
case 'take-server':
pourServerBucket(bucket, sv);
setSyncBucket(profile.id, bucket.game, sv.updatedAt, sv.rev);
break;
case 'push-local':
try {
await putBucket(profile, bucket.game, localRaw, sv ? sv.updatedAt : undefined);
} catch (_) { /* 单桶失败不阻塞其余桶 */ }
break;
default:
if (sv) setSyncBucket(profile.id, bucket.game, sv.updatedAt, sv.rev);
}
}
}
// ---------- 档案切换(先全部 flush 完成,再回灌/倒灌,失败即中止——01 §4.6) ----------
async function switchTo(targetId) {
const list = readRegistry(localStorage);
const from = getCurrentProfile(localStorage);
const target = list.find((p) => p.id === targetId);
if (!target || (from && from.id === targetId)) return false;
if (from && from.token) {
try {
await syncProfile(from); // flush:当前档案进度全部上云
} catch (e) {
announce(`同步失败,已取消切换:${e.message}`);
return false;
}
}
if (from) stashSnapshot(localStorage, from.id); // 回灌:活动 key → 旧档案快照
restoreSnapshot(localStorage, target.id); // 倒灌:新档案快照 → 活动 key(先清空防串档)
setCurrentProfile(localStorage, target.id);
applyAuth(localStorage, target); // 游戏侧虚拟视图:soundgame.auth
let cur = getCurrentProfile(localStorage);
if (!cur.token && !PUBLIC_STATIC) {
// 纯本地档案遇到服务器可用:升级为服务器档案(首访时服务器短暂不可达的自愈)
try { await bindLocalProfile(cur); cur = getCurrentProfile(localStorage); } catch (_) {}
}
try {
await syncProfile(cur); // 回拉:服务器进度倒灌 + 本地新进度上云
} catch (e) {
announce(`已切换到${cur.name}(部分云端进度拉取失败:${e.message})`);
reflectSettings(); renderUserArea(); refreshCards();
return true;
}
reflectSettings(); renderUserArea(); refreshCards();
announce(`已切换到${cur.name}`);
return true;
}
/** 纯本地档案升级为服务器档案:快照/同步表换 id,注册表原位替换,名字保留。 */
async function bindLocalProfile(local) {
const r = await api('device', { method: 'POST', body: '{}' });
moveStorageKey(snapshotKeyOf(local.id), snapshotKeyOf(r.profileId));
moveStorageKey(syncKeyOf(local.id), syncKeyOf(r.profileId));
const list = readRegistry(localStorage).map((p) =>
p.id === local.id ? { ...p, id: r.profileId, token: r.token } : p);
writeRegistry(localStorage, list);
setCurrentProfile(localStorage, r.profileId);
if (r.name !== local.name) {
await api('profile', { method: 'PUT', body: JSON.stringify({ name: local.name }) }, r.token)
.catch(() => { /* 服务器命名失败不影响本地使用 */ });
}
applyAuth(localStorage, getCurrentProfile(localStorage));
}
function newLocalProfile() {
return {
id: 'L' + Math.random().toString(36).slice(2, 10),
name: nextLocalProfileName(readRegistry(localStorage)),
token: null,
};
}
async function createNewProfile() {
closeMenu();
let p;
if (PUBLIC_STATIC) {
p = newLocalProfile();
} else {
try {
const r = await api('device', { method: 'POST', body: '{}' });
p = { id: r.profileId, name: r.name, token: r.token };
} catch (_) {
p = newLocalProfile(); // 服务器不可达:纯本地档案,之后自动补绑
}
}
const list = readRegistry(localStorage);
list.push(p);
writeRegistry(localStorage, list);
const ok = await switchTo(p.id);
announce(ok === false ? '已取消创建' : `已创建玩家档案:${p.name}`);
}
// ---------- 首次初始化:0 输入 0 点击静默建档 ----------
async function init() {
if (readRegistry(localStorage).length === 0) {
let first = null;
let created = false;
// v1 旧登录态({username, token})收养为首个档案(01 §4.4)
const legacy = migrateLegacyAuth(localStorage);
if (legacy && legacy.token && !PUBLIC_STATIC) {
try {
const r = await api('profile', { method: 'GET' }, legacy.token);
first = { id: r.profileId, name: r.name, token: legacy.token, claimed: !!r.claimed };
} catch (_) { first = null; /* token 失效:走匿名建档,旧档可稍后名字+PIN 找回 */ }
}
if (!first) {
created = true;
if (PUBLIC_STATIC) {
first = newLocalProfile();
} else {
try {
const r = await api('device', { method: 'POST', body: '{}' });
first = { id: r.profileId, name: r.name, token: r.token };
} catch (_) {
first = newLocalProfile();
}
}
}
writeRegistry(localStorage, [first]);
setCurrentProfile(localStorage, first.id);
applyAuth(localStorage, first);
renderUserArea(); refreshCards();
if (created) announce(`已创建玩家档案:${first.name}`);
// 既有本地进度「认领」进首个档案并上云(游客数据原地升级,不丢——01 §4.4)
try { await syncProfile(first); refreshCards(); } catch (_) { /* 离线:只留本地 */ }
return;
}
let cur = getCurrentProfile(localStorage) || readRegistry(localStorage)[0];
setCurrentProfile(localStorage, cur.id);
if (!cur.token && !PUBLIC_STATIC) {
try {
await bindLocalProfile(cur);
cur = getCurrentProfile(localStorage);
} catch (_) { /* 服务器仍不可达:保持纯本地档案 */ }
}
applyAuth(localStorage, cur);
renderUserArea(); refreshCards();
try {
await syncProfile(cur);
refreshCards();
} catch (_) { /* 离线:本地主存,游戏照玩 */ }
}
// ---------- 玩家区 UI ----------
function renderUserArea() {
const cur = getCurrentProfile(localStorage);
profileBtn.textContent = cur ? `玩家:${cur.name}` : '玩家:…';
profileBtn.setAttribute('aria-label', cur ? `切换玩家,当前玩家:${cur.name}` : '切换玩家');
const online = !!(cur && cur.token);
pinForm.hidden = !online;
loginForm.hidden = !online;
offlineNote.hidden = online || !cur;
closeMenu();
}
function menuButton(text, label, onclick) {
const b = document.createElement('button');
b.textContent = text;
if (label) b.setAttribute('aria-label', label);
b.onclick = onclick;
return b;
}
function openMenu() {
const list = readRegistry(localStorage);
const cur = getCurrentProfile(localStorage);
profileMenu.innerHTML = '';
for (const p of list) {
const isCur = !!cur && p.id === cur.id;
const b = menuButton(
(isCur ? '✓ ' : '') + p.name + (p.claimed ? '(已设PIN)' : ''),
isCur ? `当前玩家:${p.name}` : `切换到玩家:${p.name}`,
() => {
closeMenu();
if (isCur) { announce(`当前就是${p.name}`); return; }
switchTo(p.id);
},
);
b.setAttribute('role', 'menuitemradio');
b.setAttribute('aria-checked', String(isCur));
profileMenu.append(b);
}
const newBtn = menuButton('+ 新玩家', '新建玩家档案', createNewProfile);
newBtn.setAttribute('role', 'menuitem');
profileMenu.append(newBtn);
const manageBtn = menuButton('档案管理(改名 / 设 PIN / 登录)', '打开档案管理', () => {
closeMenu();
profileManage.hidden = !profileManage.hidden;
if (!profileManage.hidden) renameInput.focus();
});
manageBtn.setAttribute('role', 'menuitem');
profileManage.hidden = true;
profileMenu.append(manageBtn);
profileMenu.hidden = false;
profileBtn.setAttribute('aria-expanded', 'true');
const first = profileMenu.querySelector('button');
if (first) first.focus();
}
function closeMenu() {
profileMenu.hidden = true;
profileMenu.innerHTML = '';
profileBtn.setAttribute('aria-expanded', 'false');
}
profileBtn.onclick = () => {
if (profileMenu.hidden) openMenu();
else closeMenu();
};
document.addEventListener('click', (e) => {
if (!profileMenu.hidden && !document.getElementById('user-panel').contains(e.target)) closeMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!profileMenu.hidden) { closeMenu(); profileBtn.focus(); }
else if (!profileManage.hidden) profileManage.hidden = true;
}
});
// ---------- 档案管理:改名 / 设 PIN / 名字+PIN 登录 ----------
renameBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur) return;
const name = renameInput.value.trim();
if (!NAME_RE.test(name)) { announce('名字需为 1~24 位字母/数字/中文/_/-'); return; }
try {
if (cur.token) await api('profile', { method: 'PUT', body: JSON.stringify({ name }) }, cur.token);
} catch (e) { announce(e.message); return; }
const list = readRegistry(localStorage).map((p) => (p.id === cur.id ? { ...p, name } : p));
writeRegistry(localStorage, list);
applyAuth(localStorage, getCurrentProfile(localStorage)); // soundgame.auth.username 同步
renameInput.value = '';
renderUserArea();
announce(`已改名为${name}`);
};
pinBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur || !cur.token) { announce('离线档案不支持 PIN(需要家庭服务器)'); return; }
const pin = pinInput.value.trim();
if (!PIN_RE.test(pin)) { announce('PIN 需为 4~6 位数字'); return; }
try {
await api('claim', { method: 'POST', body: JSON.stringify({ pin }) }, cur.token);
} catch (e) { announce(e.message); return; }
const list = readRegistry(localStorage).map((p) => (p.id === cur.id ? { ...p, claimed: true } : p));
writeRegistry(localStorage, list);
pinInput.value = '';
renderUserArea();
announce(`已为${cur.name}设置 PIN,可在其他设备用「${cur.name} + PIN」登录`);
};
loginBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur || !cur.token) { announce('离线档案不支持登录(需要家庭服务器)'); return; }
const name = loginNameInput.value.trim();
const pin = loginPinInput.value.trim();
if (!NAME_RE.test(name)) { announce('请输入要登录的名字'); return; }
if (!PIN_RE.test(pin)) { announce('PIN 需为 4~6 位数字'); return; }
// 先把本机当前进度全部 flush 上去,确保服务器端合并时两边的桶都最新
try {
await syncProfile(cur);
} catch (e) { announce(`同步失败:${e.message}`); return; }
let r;
try {
r = await api('claim', { method: 'POST', body: JSON.stringify({ name, pin }) }, cur.token);
} catch (e) { announce(e.message); return; }
if (r.profileId && r.profileId !== cur.id) {
// 服务器已把本机 token 改绑目标档案:搬运快照/同步表,注册表原位替换
moveStorageKey(snapshotKeyOf(cur.id), snapshotKeyOf(r.profileId));
moveStorageKey(syncKeyOf(cur.id), syncKeyOf(r.profileId));
const list = readRegistry(localStorage).map((p) =>
p.id === cur.id ? { id: r.profileId, name: r.name, token: cur.token, claimed: true } : p);
writeRegistry(localStorage, list);
setCurrentProfile(localStorage, r.profileId);
applyAuth(localStorage, getCurrentProfile(localStorage));
} else {
const list = readRegistry(localStorage).map((p) =>
p.id === cur.id ? { ...p, claimed: true } : p);
writeRegistry(localStorage, list);
}
const now = getCurrentProfile(localStorage);
try { await syncProfile(now); } catch (_) { /* 合并结果稍后再拉 */ }
loginNameInput.value = '';
loginPinInput.value = '';
renderUserArea(); refreshCards(); reflectSettings();
announce(r.merged ? `欢迎回来,${now.name}(两台设备进度已合并)` : `已登录:${now.name}`);
};
// ---------- 设置区(含「操作方式」,settings.input 由引擎侧消费) ----------
const selVoice = document.getElementById('sel-voice');
const selSpeed = document.getElementById('sel-speed');
const selBgm = document.getElementById('sel-bgm');
const selGuide = document.getElementById('sel-guide');
const selInput = document.getElementById('sel-input');
function readRawSettings() { return readStorageJSON(localStorage, SETTINGS_KEY, {}) || {}; }
function reflectSettings() {
const s = settings.get();
selVoice.value = s.voice;
selSpeed.value = String(s.speed);
selBgm.value = String(s.bgm);
selGuide.value = s.guide;
const inp = readRawSettings().input;
selInput.value = INPUT_MODES.includes(inp) ? inp : 'auto';
}
/** 服务器 _settings 桶倒灌:走 settings.set 白名单;input 再并入原始 JSON 垫底。 */
function applyServerSettings(data) {
if (!data || typeof data !== 'object') return;
const { input, ...rest } = data;
const patch = { ...rest };
if (INPUT_MODES.includes(input)) patch.input = input; // 白名单外键不进 set(避免引擎告警噪音)
settings.set(patch);
try {
const raw = readRawSettings();
if (INPUT_MODES.includes(input)) raw.input = input;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(raw));
} catch (_) { /* 隐私模式 */ }
reflectSettings();
}
async function pushSettingsBucket() {
const cur = getCurrentProfile(localStorage);
if (!cur || !cur.token) return;
const data = { ...settings.get(), input: readRawSettings().input || 'auto' };
const base = getSyncMap(cur.id)._settings && getSyncMap(cur.id)._settings.updatedAt;
putBucket(cur, '_settings', JSON.stringify(data), base).catch(() => { /* 离线只留本地 */ });
}
function onSettingChange() {
settings.set({
voice: selVoice.value,
speed: Number(selSpeed.value),
bgm: Number(selBgm.value),
guide: selGuide.value,
input: selInput.value,
});
// 兼容垫底:若引擎侧 input 白名单尚未合入(settings.set 会忽略并警告),
// 直接并入持久化 JSON,引擎支持后自动生效。
try {
const raw = readRawSettings();
raw.input = selInput.value;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(raw));
} catch (_) { /* 隐私模式 */ }
pushSettingsBucket();
}
selVoice.onchange = onSettingChange;
selSpeed.onchange = onSettingChange;
selBgm.onchange = onSettingChange;
selGuide.onchange = onSettingChange;
selInput.onchange = onSettingChange;
// ---------- 启动 ----------
reflectSettings();
renderUserArea();
refreshCards();
init(); // 网络部分全部静默失败降级:不登录(无服务器)也完整可玩
</script>
</body>
</html>