storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行 (发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/ 菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。 游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio / dungeon / abyss / detective / cipher / teahouse / keeper / bridge / hunt(联机切片): - 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0); 四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt 旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore) - 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动 原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿 - 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/ 台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写 引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record 前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前 状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在 speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。 语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110 废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器 tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a 落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。 大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README / HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
1091 lines
46 KiB
HTML
1091 lines
46 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;
|
||
}
|
||
#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="btn-gestures">手势</label>
|
||
<button id="btn-gestures" type="button">全部解锁</button>
|
||
</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="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="hunt/" data-game="hunt">
|
||
<h2>围猎 <span class="badge-new">新作</span></h2>
|
||
<span class="slug">HUNT · NIGHT HUNT</span>
|
||
<p>联机协作守卫 · 2-4 人。听声辨向滑出出击、单击呼喊队友补位,守住营地篝火撑过五波,天亮就赢。</p>
|
||
<p class="progress"></p>
|
||
</a>
|
||
<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="cipher/" data-game="cipher">
|
||
<h2>暗码 <span class="badge-new">新作</span></h2>
|
||
<span class="slug">CIPHER · SECRET WIRES</span>
|
||
<p>叙事 RPG。双重间谍报务员,每封密电决定怎么译、报不报。</p>
|
||
<p class="progress"></p>
|
||
</a>
|
||
<a class="card" href="teahouse/" data-game="teahouse">
|
||
<h2>深夜茶馆 <span class="badge-new">新作</span></h2>
|
||
<span class="slug">TEAHOUSE · MIDNIGHT LEAVES</span>
|
||
<p>经营养成 RPG。泡一盏茶,听深夜客人的故事,攒口碑升级茶馆。</p>
|
||
<p class="progress"></p>
|
||
</a>
|
||
<a class="card" href="keeper/" data-game="keeper">
|
||
<h2>守塔人 <span class="badge-new">新作</span></h2>
|
||
<span class="slug">KEEPER · STORM LIGHT</span>
|
||
<p>叙事 RPG。风暴夜守灯人,无线电里的求救声牵出三代家族秘密。</p>
|
||
<p class="progress"></p>
|
||
</a>
|
||
<a class="card" href="bridge/" data-game="bridge">
|
||
<h2>星环舰长 <span class="badge-new">新作</span></h2>
|
||
<span class="slug">BRIDGE · STAR PATROL</span>
|
||
<p>指挥 RPG。多系统方位音管理 + 危机抉择 + 船员忠诚。</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="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 由引擎侧消费) ----------
|
||
|
||
// 手势阶梯「全部解锁」(26 号文档 §4.3:老玩家在大厅一键解锁全部手势)
|
||
const btnGestures = document.getElementById('btn-gestures');
|
||
btnGestures.addEventListener('click', () => {
|
||
try {
|
||
const o = JSON.parse(localStorage.getItem('soundgame.gestures') || '{}');
|
||
o.stage = 3;
|
||
localStorage.setItem('soundgame.gestures', JSON.stringify(o));
|
||
} catch (_) { /* 隐私模式 */ }
|
||
announce('已解锁全部手势:上下滑动和双击都可以用了');
|
||
});
|
||
|
||
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>
|