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)
This commit is contained in:
+548
-140
@@ -26,9 +26,48 @@
|
||||
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; }
|
||||
#user-row input {
|
||||
#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;
|
||||
@@ -37,7 +76,8 @@
|
||||
font-size: 14px;
|
||||
width: 140px;
|
||||
}
|
||||
#user-row button {
|
||||
.manage-row input[inputmode="numeric"] { width: 110px; }
|
||||
.manage-row button {
|
||||
background: #4f8cff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
@@ -46,9 +86,7 @@
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#user-row button.secondary { background: #262c36; }
|
||||
#user-msg { font-size: 13px; color: #e57373; width: 100%; margin: 0; min-height: 1em; }
|
||||
#user-name { color: #4f8cff; font-weight: bold; }
|
||||
.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; }
|
||||
@@ -110,13 +148,44 @@
|
||||
<p>纯音频游戏 · 一副耳机,六种手势,无需眼看</p>
|
||||
</header>
|
||||
|
||||
<section class="panel" id="user-panel">
|
||||
<h3>用户</h3>
|
||||
<div id="user-row"></div>
|
||||
<p id="user-msg"></p>
|
||||
<!-- 玩家区:匿名档案切换器(首访 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">
|
||||
<section class="panel" id="settings-panel" aria-label="设置">
|
||||
<h3>设置</h3>
|
||||
<div id="settings-row">
|
||||
<div class="setting-item">
|
||||
@@ -151,6 +220,14 @@
|
||||
<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>
|
||||
|
||||
@@ -206,105 +283,450 @@
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
建议佩戴耳机游玩 · 操作方式:上 / 下 / 左 / 右滑动、单击、双击<br>
|
||||
建议佩戴耳机游玩 · 操作方式:上 / 下 / 左 / 右滑动、单击、双击(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';
|
||||
|
||||
const AUTH_KEY = 'soundgame.auth';
|
||||
const GAMES = ['radio', 'dungeon', 'rpg', 'storyline', 'escape', 'simon', 'rhythm', 'space'];
|
||||
// 公网站点只托管静态文件、无用户 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');
|
||||
|
||||
// ---------- 认证状态 ----------
|
||||
function getAuth() {
|
||||
try { return JSON.parse(localStorage.getItem(AUTH_KEY)) || null; }
|
||||
catch (_) { return null; }
|
||||
}
|
||||
function setAuth(a) {
|
||||
if (a) localStorage.setItem(AUTH_KEY, JSON.stringify(a));
|
||||
else localStorage.removeItem(AUTH_KEY);
|
||||
}
|
||||
// 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}$/;
|
||||
|
||||
// 服务器不可达(如纯静态托管)时静默降级为仅本地模式
|
||||
async function api(path, opts = {}) {
|
||||
const auth = getAuth();
|
||||
// ---------- 服务器 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 (auth) headers['Authorization'] = 'Bearer ' + auth.token;
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
const resp = await fetch('/api/' + path, { ...opts, headers });
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(body.error || ('HTTP ' + resp.status));
|
||||
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 userRow = document.getElementById('user-row');
|
||||
// ---------- 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 showMsg(text) { userMsg.textContent = text || ''; }
|
||||
function announce(text) { userMsg.textContent = text || ''; }
|
||||
|
||||
function renderUserArea() {
|
||||
const auth = getAuth();
|
||||
userRow.innerHTML = '';
|
||||
if (auth) {
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = '已登录:<span id="user-name"></span>';
|
||||
span.querySelector('#user-name').textContent = auth.username;
|
||||
const logout = document.createElement('button');
|
||||
logout.className = 'secondary';
|
||||
logout.textContent = '退出';
|
||||
logout.onclick = () => {
|
||||
setAuth(null);
|
||||
renderUserArea();
|
||||
clearProgress();
|
||||
showMsg('');
|
||||
};
|
||||
userRow.append(span, logout);
|
||||
} else {
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.placeholder = '用户名';
|
||||
nameInput.maxLength = 24;
|
||||
const pinInput = document.createElement('input');
|
||||
pinInput.placeholder = 'PIN(4~6 位数字)';
|
||||
pinInput.inputMode = 'numeric';
|
||||
pinInput.maxLength = 6;
|
||||
const loginBtn = document.createElement('button');
|
||||
loginBtn.textContent = '登录';
|
||||
const regBtn = document.createElement('button');
|
||||
regBtn.className = 'secondary';
|
||||
regBtn.textContent = '注册';
|
||||
const submit = async (kind) => {
|
||||
showMsg('');
|
||||
try {
|
||||
if (kind === 'register') {
|
||||
await api('register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: nameInput.value.trim(), pin: pinInput.value.trim() }),
|
||||
});
|
||||
}
|
||||
const r = await api('login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: nameInput.value.trim(), pin: pinInput.value.trim() }),
|
||||
});
|
||||
setAuth({ username: r.username, token: r.token });
|
||||
renderUserArea();
|
||||
await pullSettings();
|
||||
await loadProgress();
|
||||
} catch (e) {
|
||||
showMsg(e.message);
|
||||
}
|
||||
};
|
||||
loginBtn.onclick = () => submit('login');
|
||||
regBtn.onclick = () => submit('register');
|
||||
userRow.append(nameInput, pinInput, loginBtn, regBtn);
|
||||
// ---------- 同步映射表(各档案已知的服务器桶 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();
|
||||
@@ -312,76 +734,62 @@
|
||||
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() {
|
||||
const patch = {
|
||||
settings.set({
|
||||
voice: selVoice.value,
|
||||
speed: Number(selSpeed.value),
|
||||
bgm: Number(selBgm.value),
|
||||
guide: selGuide.value,
|
||||
};
|
||||
settings.set(patch); // 始终写 localStorage
|
||||
// 已登录时同步到服务器(game 键 '_settings')
|
||||
if (getAuth()) {
|
||||
api('progress', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ game: '_settings', data: settings.get() }),
|
||||
}).catch(() => { /* 离线时只留本地 */ });
|
||||
}
|
||||
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;
|
||||
|
||||
// 登录后从服务器拉回设置(服务器为准)
|
||||
async function pullSettings() {
|
||||
try {
|
||||
const r = await api('progress?game=_settings');
|
||||
if (r.data && typeof r.data === 'object') {
|
||||
settings.set(r.data);
|
||||
reflectSettings();
|
||||
}
|
||||
} catch (_) { /* 无服务器或尚未保存过设置 */ }
|
||||
}
|
||||
|
||||
// ---------- 游戏进度摘要 ----------
|
||||
function clearProgress() {
|
||||
document.querySelectorAll('a.card .progress').forEach((el) => { el.textContent = ''; });
|
||||
}
|
||||
|
||||
async function loadProgress() {
|
||||
clearProgress();
|
||||
if (!getAuth()) return;
|
||||
for (const game of GAMES) {
|
||||
try {
|
||||
const r = await api('progress?game=' + encodeURIComponent(game));
|
||||
if (r.data && typeof r.data.summary === 'string' && r.data.summary) {
|
||||
const el = document.querySelector(`a.card[data-game="${game}"] .progress`);
|
||||
if (el) el.textContent = r.data.summary;
|
||||
}
|
||||
} catch (_) { /* 单个游戏拉取失败不影响其他 */ }
|
||||
}
|
||||
}
|
||||
selInput.onchange = onSettingChange;
|
||||
|
||||
// ---------- 启动 ----------
|
||||
// 公网站点只托管静态文件,不跑用户 API(PIN 明文,不宜暴露)。
|
||||
// 本地 ./web/serve.sh(http + 非 443)仍显示登录。
|
||||
const PUBLIC_STATIC =
|
||||
location.protocol === 'https:' && location.hostname.endsWith('xiangsilian.com');
|
||||
|
||||
reflectSettings();
|
||||
if (PUBLIC_STATIC) {
|
||||
const userPanel = document.getElementById('user-panel');
|
||||
if (userPanel) userPanel.hidden = true;
|
||||
} else {
|
||||
renderUserArea();
|
||||
if (getAuth()) {
|
||||
pullSettings();
|
||||
loadProgress();
|
||||
}
|
||||
}
|
||||
renderUserArea();
|
||||
refreshCards();
|
||||
init(); // 网络部分全部静默失败降级:不登录(无服务器)也完整可玩
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user