Files
SoundGame/web/index.html
T
xsl 31372615b2 feat(new8/batch2): 《深海回音》abyss +《雨夜专案》detective 全套交付(多agent流水线:策划→资产→开发→测试→真资产复跑)
- abyss:game.js 242 断言全绿(剧本A-F六路径/氧气门三形态/sonarGate仲裁/结算死窗/双轨看门狗/radio 8项修复继承);资产 148句+11key变体(电台滤波带内能量47-62%→69-80%)
- detective:game.js 265 断言真资产下全绿(矩阵不变式独立重算/指证全路径/评级12组注入/VERDICT死窗/二周目变体);真资产复跑回炉3处实现修复(无进展轨墙钟化/菜单暂停记账/停止播报真停声——mock时长掩盖的正确性bug)
- 资产:TTS 539条×2格式、Mureka 7首 $0.21(累计$1.08/$2.00)、SYN12+SA3 19件(QC抓出1例AAC过冲并修复)、封面×2、check_all_assets 10游戏全绿(abyss 298/298、detective 279/279)
- 平台接线:大厅双卡片、BUCKETS 增 abyss/detective 桶(H4 断言更新 10 游戏)、voice_text.json 10游戏991句
- 回归:14 套件全绿(10 playtest+guide+engine-voice+engine-input+api-smoke 12/12)
- 设计偏差登记:abyss 7条/detective 9条(全为设计内部冲突按承诺清单/归因真话律取优)
2026-09-10 01:40:31 +08:00

1046 lines
44 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;
}
#pair-code-display {
align-self: center;
min-width: 150px;
padding: 6px 10px;
font-size: 22px;
letter-spacing: 0.3em;
text-align: center;
color: #7ec699;
}
#remove-form .manage-row button { background: #3c4043; }
.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" autocomplete="off" 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" autocomplete="off"
aria-label="登录 PIN,4 到 6 位数字">
<button id="login-btn">登录</button>
</div>
</div>
<div class="manage-form" id="pair-out-form">
<label for="pair-code-display">转移到新设备:在本机生成 6 位配对码,新设备输入后即可转入本档案</label>
<div class="manage-row">
<output id="pair-code-display" aria-label="配对码未生成">------</output>
<button id="pair-start-btn">生成配对码</button>
<button id="pair-replay-btn" hidden aria-label="重听配对码,逐位朗读">重听</button>
</div>
<p class="manage-note" id="pair-out-note">配对码逐位朗读,10 分钟内单次有效;重新生成会使旧码立即作废。</p>
</div>
<div class="manage-form" id="pair-in-form">
<label for="pair-code-input">从其他设备转入:输入老设备上显示的 6 位配对码,本机当前进度会合并过去</label>
<div class="manage-row">
<input id="pair-code-input" inputmode="numeric" maxlength="6" autocomplete="one-time-code"
aria-describedby="pair-in-hint">
<button id="pair-join-btn">转入</button>
</div>
<p class="manage-note" id="pair-in-hint">在老设备的「档案管理 → 转移到新设备」处获取配对码;连错 5 次需等待 10 分钟。</p>
</div>
<div class="manage-form" id="remove-form">
<div class="manage-row">
<button id="remove-btn" aria-describedby="remove-hint">从本机移除(不删云端)</button>
</div>
<p class="manage-note" id="remove-hint">清掉本机的档案快照与登录凭证并回到匿名新档案;云端进度保留,可随时用「名字 + PIN」或新配对码找回。</p>
</div>
<p class="manage-note" id="offline-note" hidden>当前为离线档案(无本地服务器):可改名、可切换,配对与登录需要家庭服务器。</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="abyss/" data-game="abyss">
<h2>深海回音 <span class="badge-new">新作</span></h2>
<span class="slug">ABYSS · DEEP ECHO</span>
<p>深海救援叙事 RPG。声呐里是被困潜艇的船员,氧气在倒数,每次下潜都是抉择。</p>
<p class="progress"></p>
</a>
<a class="card" href="detective/" data-game="detective">
<h2>雨夜专案 <span class="badge-new">新作</span></h2>
<span class="slug">DETECTIVE · RAINY CASE</span>
<p>本格推理 RPG。问询证人、比对证词、指出矛盾,锁定雨夜命案的真凶。</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, cnNum, decideBucket, digitsToCn, getCurrentProfile, migrateLegacyAuth,
nextLocalProfileName, readRegistry, readStorageJSON, removeProfile, 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(不可达时静默降级为纯本地档案) ----------
/**
* tokenOverride === null → 显式匿名(不带 Authorization,如配对转入、名+PIN 换发登录);
* 缺省 → 当前档案 token。
*/
async function api(path, opts = {}, tokenOverride) {
const cur = getCurrentProfile(localStorage);
const token = tokenOverride !== undefined ? 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');
const pairOutForm = document.getElementById('pair-out-form');
const pairCodeDisplay = document.getElementById('pair-code-display');
const pairStartBtn = document.getElementById('pair-start-btn');
const pairReplayBtn = document.getElementById('pair-replay-btn');
const pairInForm = document.getElementById('pair-in-form');
const pairCodeInput = document.getElementById('pair-code-input');
const pairJoinBtn = document.getElementById('pair-join-btn');
const removeBtn = document.getElementById('remove-btn');
function announce(text) { userMsg.textContent = text || ''; }
// ---------- 可听化(P1 裁定:配置类数字与自定义玩家名不走 TTS 资产,用浏览器 ----------
// ---------- speechSynthesis;中文 voice 优先;失败静默降级,aria-live 文本兜底) ----------
let zhVoice = null;
function pickZhVoice() {
try {
const voices = window.speechSynthesis ? window.speechSynthesis.getVoices() : [];
zhVoice = voices.find((v) => /^zh([-_]|$)/i.test(v.lang) && /cN|Chinese|中文|普通话/i.test(v.name))
|| voices.find((v) => /^zh([-_]|$)/i.test(v.lang))
|| null;
} catch (_) { zhVoice = null; }
}
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
pickZhVoice();
try { window.speechSynthesis.addEventListener('voiceschanged', pickZhVoice); } catch (_) { /* 老浏览器 */ }
}
/** 返回是否成功入队(不可用/异常 → false,调用方自行降级)。 */
function speak(text, rate = 1) {
try {
if (!('speechSynthesis' in window)) return false;
const u = new SpeechSynthesisUtterance(text);
u.lang = 'zh-CN';
u.rate = rate;
if (zhVoice) u.voice = zhVoice;
window.speechSynthesis.cancel(); // 顶掉上一句(重听配对码场景)
window.speechSynthesis.speak(u);
return true;
} catch (_) { return false; }
}
/** 配对码逐位朗读:'048920' → 「配对码,零,四,八,九,二,零」。 */
function speakPairCode(code) {
return speak(`配对码,${digitsToCn(code).split(' ').join(',')}。`, 0.9);
}
function pairCodeSpeech(code) { return digitsToCn(code).split(' ').join(','); }
/** 玩家名可听化 P1 版:speechSynthesis 念名(中文 voice 优先);不可用则静默降级为「玩家 N」。 */
function speakProfileName(profile) {
if (!profile) return;
if (speak(profile.name)) return;
const list = readRegistry(localStorage);
const idx = list.findIndex((p) => p.id === profile.id);
speak('玩家' + cnNum(idx >= 0 ? idx + 1 : 1));
}
// ---------- token 过期(180 天滑动到期)引导(01 遗留清单) ----------
function isAuthExpired(e) {
return !!e && e.status === 401 && String(e.message || '').includes('未授权');
}
const AUTH_EXPIRED_HINT = '档案已过期:请展开档案管理,用「名字 + PIN」重新登录、从其他设备转入,或新建玩家。';
function guideAuthExpired() {
announce(AUTH_EXPIRED_HINT);
profileManage.hidden = false;
loginNameInput.focus();
}
// ---------- 同步映射表(各档案已知的服务器桶 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) {
reflectSettings(); renderUserArea(); refreshCards();
if (isAuthExpired(e)) { guideAuthExpired(); return true; } // token 180 天滑动到期
announce(`已切换到${cur.name}(部分云端进度拉取失败:${e.message})`);
return true;
}
reflectSettings(); renderUserArea(); refreshCards();
announce(`已切换到${cur.name}`);
speakProfileName(cur);
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({ quiet = false } = {}) {
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);
if (!quiet) {
announce(ok === false ? '已取消创建' : `已创建玩家档案:${p.name}`);
speakProfileName(p);
}
return p;
}
// ---------- 首次初始化: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}`); speakProfileName(first); }
// 既有本地进度「认领」进首个档案并上云(游客数据原地升级,不丢——01 §4.4)
try { await syncProfile(first); refreshCards(); } catch (e) { if (isAuthExpired(e)) guideAuthExpired(); }
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 (e) {
if (isAuthExpired(e)) guideAuthExpired(); // token 180 天滑动到期 → 重新登录或新建
}
}
// ---------- 玩家区 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;
pairOutForm.hidden = !online;
pairInForm.hidden = !online;
offlineNote.hidden = online || !cur;
resetPairCodeUI(); // 显示中的配对码属于切换前的档案,一并复位
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」登录`);
};
/**
* join(配对转入)/ login(名+PIN 换发)成功后,把本机视图换绑到服务器档案:
* 快照/同步表搬 id、注册表原位替换(目标档案若已在本机列表则去重)、当前档案切换,
* 再 syncProfile 让本地较新的桶上云、云端合并结果倒灌。
*/
async function adoptBoundProfile(cur, r) {
const token = r.token || (cur && cur.token);
const entry = { id: r.profileId, name: r.name, token, claimed: !!r.claimed };
if (cur && r.profileId !== cur.id) {
moveStorageKey(snapshotKeyOf(cur.id), snapshotKeyOf(r.profileId));
moveStorageKey(syncKeyOf(cur.id), syncKeyOf(r.profileId));
}
const list = readRegistry(localStorage).filter((p) => p.id !== r.profileId);
const idx = cur ? list.findIndex((p) => p.id === cur.id) : -1;
if (idx >= 0) list[idx] = entry; else list.push(entry);
writeRegistry(localStorage, list);
setCurrentProfile(localStorage, r.profileId);
applyAuth(localStorage, entry);
try { await syncProfile(entry); } catch (_) { /* 合并结果稍后再拉 */ }
renderUserArea(); refreshCards(); reflectSettings();
return entry;
}
loginBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur) 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; }
if (PUBLIC_STATIC) { announce('纯静态形态不支持登录(需要家庭服务器)'); return; }
let r = null;
// 无 token(纯本地档案遇服务器可用)或 token 已过期:claim 走不通,直接 login 换发,
// 本地进度由客户端 syncProfile 按桶 LWW 推上去(与配对转入的无 token 分支同语义)
let deadToken = !cur.token;
if (!deadToken) {
// 先把本机当前进度全部 flush 上去,确保服务器端合并时两边的桶都最新
try {
await syncProfile(cur);
} catch (e) {
if (!isAuthExpired(e)) { announce(`同步失败:${e.message}`); return; }
deadToken = true;
}
}
if (!deadToken) {
try {
r = await api('claim', { method: 'POST', body: JSON.stringify({ name, pin }) }, cur.token);
} catch (e) {
if (!isAuthExpired(e)) { announce(e.message); return; }
deadToken = true;
}
}
if (deadToken) {
try {
r = await api('login', { method: 'POST', body: JSON.stringify({ username: name, pin }) }, null);
} catch (e) { announce(e.message); return; }
}
loginNameInput.value = '';
loginPinInput.value = '';
if (!r.merged && r.profileId === cur.id && !deadToken) {
// 原地认领(名字+PIN 设到当前档案):只补 claimed 标记
const list = readRegistry(localStorage).map((p) => (p.id === cur.id ? { ...p, claimed: true } : p));
writeRegistry(localStorage, list);
renderUserArea();
announce(`已登录:${cur.name}`);
return;
}
const entry = await adoptBoundProfile(cur, r);
announce(r.merged ? `欢迎回来,${entry.name}(两台设备进度已合并)` : `已登录:${entry.name}`);
speakProfileName(entry);
};
// ---------- 档案管理:配对码(转移到新设备 / 从其他设备转入) + 从本机移除 ----------
let pairCode = null;
function resetPairCodeUI() {
pairCode = null;
pairCodeDisplay.textContent = '------';
pairCodeDisplay.setAttribute('aria-label', '配对码未生成');
pairReplayBtn.hidden = true;
}
pairStartBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur || !cur.token) { announce('离线档案不能生成配对码(需要家庭服务器)'); return; }
try {
const r = await api('pair/start', { method: 'POST' }, cur.token);
pairCode = r.code;
const until = new Date(r.expiresAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
pairCodeDisplay.textContent = r.code;
pairCodeDisplay.setAttribute('aria-label', `配对码 ${pairCodeSpeech(r.code)},${until} 前有效`);
pairReplayBtn.hidden = false;
announce(`配对码:${pairCodeSpeech(r.code)}。${until} 前单次有效,请在 10 分钟内于新设备输入`);
speakPairCode(r.code);
} catch (e) { announce(`配对码生成失败:${e.message}`); }
};
pairReplayBtn.onclick = () => {
if (!pairCode) return;
announce(`配对码:${pairCodeSpeech(pairCode)}`);
speakPairCode(pairCode);
};
pairJoinBtn.onclick = async () => {
const code = pairCodeInput.value.trim();
if (!/^\d{6}$/.test(code)) { announce('配对码需为 6 位数字'); return; }
const cur = getCurrentProfile(localStorage);
if (!cur) return;
// 先 flush 本机进度:join 在服务器端按桶 LWW 合并,两边都最新
if (cur.token) {
try {
await syncProfile(cur);
} catch (e) {
if (!isAuthExpired(e)) { announce(`同步失败:${e.message}`); return; }
// token 已过期:join 会把它视作无档案设备直接签发老档案的新 token,
// 本地进度随后由 syncProfile 客户端侧 LWW 推上去
}
}
let r;
try {
r = await api('pair/join', { method: 'POST', body: JSON.stringify({ code }) }, (cur.token || null));
} catch (e) {
announce(e.status === 404 ? '配对码不存在或已失效,请在老设备重新生成'
: e.status === 429 ? `尝试过多:${e.message}`
: e.status === 409 ? `${e.message}`
: `转入失败:${e.message}`);
return;
}
pairCodeInput.value = '';
const entry = await adoptBoundProfile(cur, r);
announce(r.merged
? `欢迎回来,${entry.name}(本机进度已按游戏合并,共 ${r.mergedGames} 项)`
: `欢迎回来,${entry.name}`);
speakProfileName(entry);
};
removeBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur) return;
const online = !!cur.token;
const ok = window.confirm(online
? `从本机移除「${cur.name}」?云端进度保留,可随时用「名字 + PIN」或新配对码找回。`
: `「${cur.name}」仅存本机(无云端副本),移除后本机进度将丢失。确定移除?`);
if (!ok) return;
if (online) {
try { await syncProfile(cur); } catch (_) { /* 尽力冲云:云端至少保留最后已知副本 */ }
try { await api('logout', { method: 'POST' }, cur.token); } catch (_) { /* 吊销失败:token 自然过期 */ }
}
const name = cur.name;
removeProfile(localStorage, cur.id); // 清快照/同步表/注册表项/CURRENT/AUTH(云端不动)
resetPairCodeUI();
await createNewProfile({ quiet: true }); // 回匿名新建(switchTo 倒灌前先清空活动 key)
const fresh = getCurrentProfile(localStorage);
announce(`已从本机移除${name}(云端保留),现在是新档案:${fresh ? fresh.name : '(无)'}`);
speakProfileName(fresh);
};
// ---------- 设置区(含「操作方式」,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>