用户系统(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)
796 lines
32 KiB
HTML
796 lines
32 KiB
HTML
<!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 & 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>
|