research(platform): 无感用户系统 + PC/手机双端一致 三路调研(身份/键位与语音/数据同步)+ 汇总方案
- 01-identity:推荐自动匿名档案(首访 0 交互)+ Netflix 式切换器 + 配对码跨设备;passkey 因 LAN HTTP 不可用否决;审计出 7/8 游戏跨设备只 PUT 不 GET、大厅零 aria、玩家名不可朗读三个存量问题 - 02-input-parity:方向键/WASD+空格+回车(双击即时)键位(audiogames 社区惯例同构);语音双套资产路线(153 句含操作词,306 TTS/37min);MVP ~200 行 1-2 人日 - 03-data-sync:设备 token 即档案、localStorage 按 profileId 分桶(修同设备串档)、桶级 LWW + baseUpdatedAt 守卫、claim 可选认领 + PIN scrypt;公网前 8 条清单 - 00-synthesis:需求映射 + 架构收敛 + P0/P1 分期 + 3 个待拍板决策点
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# 平台能力调研汇总 — 无感用户系统 + PC/手机双端一致
|
||||
|
||||
> 三路并行调研的汇总与实施方案建议(2026-09-08)。分报告:[01-identity](01-identity.md) 身份方案 | [02-input-parity](02-input-parity.md) 键盘与双端 | [03-data-sync](03-data-sync.md) 数据架构。
|
||||
|
||||
## 1. 用户需求 → 方案映射
|
||||
|
||||
| 需求(用户原话) | 方案 |
|
||||
|---|---|
|
||||
| 支持多玩家访问,不想影响体验 | 自动匿名档案:首访 0 输入 0 点击,服务器静默建档发 token |
|
||||
| 不让用户注册 | 注册降级为可选「认领」(给档案起名 + 设 PIN),永不弹窗 |
|
||||
| 能区分用户、保留状态 | 设备 token 即档案;localStorage 按 profileId 分桶(修掉现状同设备串档 bug);进度按游戏分桶 LWW 同步 |
|
||||
| PC 键盘操作 | 方向键/WASD=滑动、空格=单击、回车=双击(即时)、按住空格=长按;游戏代码零改动 |
|
||||
| PC 画面提示 | 输入模式自动检测 + 手动覆盖;HTML 文案随模式切换 |
|
||||
|
||||
## 2. 推荐架构(三报告收敛)
|
||||
|
||||
```
|
||||
首访浏览器 ──POST /api/device──> 匿名档案 {profileId, token}(0 交互)
|
||||
本地:localStorage 按 profileId 分桶(进度主存 + 异步上传)
|
||||
同设备多人:大厅「切换玩家」档案切换器(倒灌/回灌 localStorage,游戏零改动)
|
||||
跨设备:旧设备出示 6 位配对码(语音逐位播报)→ 新设备输入 → 合并
|
||||
认领(可选):给档案命名 + 设 PIN(scrypt 哈希);旧数据按桶 LWW 合并
|
||||
键盘:gesture.js 键盘通道喂现有状态机(~120 行)+ 启动遮罩键盘解锁 + settings.input
|
||||
语音:双套资产 voice/<gender>/key/(153 句 ×2 性别,TTS 306 生成/37 分钟音频)
|
||||
```
|
||||
|
||||
**关键否决/发现**:
|
||||
- passkey 否决为主方案:WebAuthn 在局域网 HTTP 私有 IP 下不可用(W3C secure context)
|
||||
- 存量 bug 三连:① 7/8 游戏跨设备只 PUT 不 GET(换设备进度不回拉);② localStorage 单桶(同设备换人登录会串档污染);③ 大厅零 aria、玩家名不可朗读
|
||||
- 语音路线 c(不动语音只改文字)不能作终态:AppleVis 社区明确批评过无键盘等价命令的音频游戏
|
||||
|
||||
## 3. 分期实施
|
||||
|
||||
### P0 最小可用(约 3-4 人日,无 TTS)
|
||||
1. gesture.js 键盘通道 + 遮罩解锁 + settings.input(自动检测+手动覆盖)+ 大厅/遮罩文案适配
|
||||
2. `/api/device` 匿名档案 + localStorage 分桶 + PUT baseUpdatedAt 守卫 + GET 回拉(修存量三 bug)
|
||||
3. PIN scrypt 哈希(十行级)
|
||||
4. 大厅档案切换器(自动命名「玩家一」,earcon 签名 + 编号音可听化)
|
||||
5. playtest/guide 回归 + engine-voice + live 键盘通道
|
||||
|
||||
### P1 完整版(+1 GPU 晚 + 1-2 人日)
|
||||
6. 153 句键盘措辞 ×2 性别 TTS 双套资产 + 引擎 input 资产路由
|
||||
7. 6 位配对码跨设备(含逐位播报的「数字单音」拼接豁免裁定)
|
||||
8. 可选认领(命名+PIN+旧档案 LWW 合并)
|
||||
|
||||
### 公网前(03 报告 §6 的 8 条,另计)
|
||||
|
||||
## 4. 待产品拍板
|
||||
- D1 首访命名:全静默(自动「玩家一」,事后可改名)vs 首次轻量起名
|
||||
- D2 找回底线:仅配对码(旧设备必须在手)vs 配对码 + 名/PIN 兜底(丢设备可救)
|
||||
- D3 语音路线:双套资产(推荐,增量灰度、不动已验收资产)vs 通用措辞重录(一套通吃但全量重录)vs 先文字后补
|
||||
@@ -0,0 +1,260 @@
|
||||
# 平台调研 01 · 无感多用户身份系统
|
||||
|
||||
> 课题:多个玩家(家庭 WiFi 手机/PC + 未来公网 game.xiangsilian.com)可区分、可保留各自进度与设置,且**绝不能强迫注册**。
|
||||
> 调研日期:2026-09-09。方法:本仓库代码审计 + 业界模式检索(来源链接见 §3.6)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论速览
|
||||
|
||||
**主推:方案 B「自动匿名档案 + 设备档案切换器 + 配对码跨设备」**——首次访问 0 输入 0 点击即自动建档(服务器静默发 token,玩家无感知),同设备多玩家用 Netflix 式档案切换,跨设备/找回用「旧设备出示 6 位配对码 → 新设备输入」;纯静态公网形态自动降级为方案 A(纯本地档案)。PIN 降级为**可选的"认领"升级项**(防兄弟恶作剧),passkey 留给公网化之后再评估(LAN HTTP 下技术上不可用,见 §3.3)。
|
||||
|
||||
三个待产品拍板的决策点见文末 §6。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状审计
|
||||
|
||||
### 1.1 服务器(web/server/server.js,310 行)
|
||||
|
||||
| 项 | 现状 | 评估 |
|
||||
|---|---|---|
|
||||
| 注册/登录 | `POST /api/register {username, pin}` / `POST /api/login`,PIN 4~6 位数字 | **PIN 明文存 users.json**;用户名可中文(`USERNAME_RE`) |
|
||||
| Token | 登录返回 24 字节随机 hex,内存 + sessions.json | **永不过期、无登出端点**——客户端"退出"只是删 localStorage,token 在服务器侧永久有效 |
|
||||
| 进度 API | `GET/PUT /api/progress`,Bearer token,`data` 自由格式但须带 `summary`(大厅卡片显示) | 接口形状健康,可复用 |
|
||||
| 并发 | 按文件串行 Promise 队列,tmp+rename 原子写 | 健康,可复用 |
|
||||
| 防护 | 无速率限制、无 IP 封禁、HTTP 明文 | HANDOVER §2.2 已自我标注"玩具级" |
|
||||
|
||||
数据模型(web/server/data/ 实测):
|
||||
|
||||
```
|
||||
users.json { "uuu": "0858" } // username -> pin 明文
|
||||
sessions.json { "<48位hex>": "uuu" } // token -> username,无过期时间
|
||||
uuu.json { "_settings": {voice, speed, bgm, guide} } // 一用户一文件 {<game>: data}
|
||||
```
|
||||
|
||||
### 1.2 客户端(web/index.html + 各游戏)
|
||||
|
||||
- **登录态**:localStorage `soundgame.auth` = `{username, token}`,大厅与 7 个游戏各自直读此 key。
|
||||
- **游客态今天就能玩**:不登录 → 全部 8 款游戏可玩,进度写各游戏自己的 localStorage key,设置写 `soundgame.settings`。服务器同步仅在登录时进行(PUT 静默跳过)。**即"本地优先 + 可选账号"架构今天已经成立,只是"账号"这一半摩擦大(要起名+想 PIN+输两遍)且公网不可用。**
|
||||
- **公网形态**(docs/deploy.md):Nginx 纯静态、**不跑任何 API**;大厅检测 `https://*.xiangsilian.com` 即隐藏整个用户面板(index.html `PUBLIC_STATIC`)→ 公网访客全部是孤立的 localStorage 游客。deploy.md 已写明公网开放 API 的前置(127.0.0.1 绑定、只反代 /api/、limit_req、PIN 哈希、token 过期)。
|
||||
|
||||
localStorage key 全量清单(多档案方案的现实约束,见 §4):
|
||||
|
||||
| key | 归属 | 说明 |
|
||||
|---|---|---|
|
||||
| `soundgame.auth` | 大厅+7游戏 | 登录 token |
|
||||
| `soundgame.settings` | engine/settings.js | 四项全局设置 |
|
||||
| `soundgame.progress.rpg` / `.dungeon` / `.radio` | 各游戏 | 存档(命名不统一) |
|
||||
| `soundgame.rhythm.progress` | rhythm | 存档 |
|
||||
| `soundgame.simon.best` / `soundgame.space.best` / `soundgame.escape.stats` | 各游戏 | 最高分/统计 |
|
||||
| `soundgame.rpg.tutIntroSeen` / `soundgame.escape.guideDone` / `soundgame.space.drillDone` | 各游戏 | 一次性教学标记 |
|
||||
| (storyline 无本地 key) | — | 无本地持久化 |
|
||||
|
||||
### 1.3 审计发现的关键缺口(新方案必须解决)
|
||||
|
||||
1. **跨设备恢复只做了 1/8**:只有 radio 在"本地无存档 + 已登录"时 GET 服务器存档(radio/game.js:325-342);rpg/dungeon/rhythm/escape/simon/space 只 PUT 不 GET——**已登录用户换设备,7 款游戏进度照样丢**(服务器上有副本却不拉回)。
|
||||
2. **设置是"设备级"而非"玩家级"**:一台手机一个 `soundgame.settings`;家里视障玩家 2.2x 语速与普通玩家 1.0x 共用一部手机时互相覆盖(虽然登录后会拉 `_settings`,但游客态无档案可分)。
|
||||
3. **大厅无读屏适配**:index.html **零 aria 属性**;登录表单是 JS 动态生成、仅 placeholder 无 `<label>`(读屏不友好);设置面板倒是用了正规 `<label for>+<select>`。卡片是真实 `<a>`,读屏可列出。→ 身份 UI 重做时必须补 ARIA 与可听化。
|
||||
4. **"退出"不失效 token**、sessions.json 无清理,长期只增不减。
|
||||
|
||||
### 1.4 语音技术栈对身份 UI 的约束(重要)
|
||||
|
||||
全仓库**无 speechSynthesis 使用**——所有语音都是离线 TTS 预生成音频资产(Qwen3-TTS 流水线),且设计规范禁止数字碎片拼接(数字按档位整句预生成)。推论:**任意自定义玩家名无法被"说出来"**,档案名要么从预生成词表选、要么用预生成的"玩家一/二/三",要么为大厅引入浏览器 SpeechSynthesis(与整体录音质感不一致,需产品拍板,见 §6-D3)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 业界模式调研发现
|
||||
|
||||
### 2.1 模式总表
|
||||
|
||||
| 模式 | 代表 | 首访摩擦 | 进度保留 | 换设备 | 启示 |
|
||||
|---|---|---|---|---|---|
|
||||
| **匿名设备账号**(首访自动建档,后可升级) | Firebase Anonymous Auth、Unity Authentication、腾讯 CloudBase 匿名登录 | 0 | 云端 | 需升级/绑定 | 业界标准做法:UID 服务端生成、`linkWithCredential` 升级**同 UID 数据不迁移**;官方警告匿名账号**丢失凭证即孤儿数据**,建议 30 天不活跃清理;升级提示放在"账号开始有价值的那一刻" |
|
||||
| **本地优先 + 可选账号链接** | Wordle(原版→NYT)、Poki | 0 | localStorage/cookie | 登录后云同步 | Wordle 原版全 localStorage;迁移 NYT 时靠"旧域名最后访问一次"携带本地数据;后加免费 NYT 账号才跨设备。Poki FAQ 明说:无账号进度存浏览器 cookie,建账号可跨设备。**SoundGame 现状就是这个模式的前半段** |
|
||||
| **档案切换器**(一设备多档案) | Netflix、Nintendo Switch、Jellyfin/Emby 的诉求贴 | 选头像 1 击 | 每档案独立 | 档案级 | 家庭共设备场景的标准解;Switch 儿童档可选 PIN;自托管社区(Jellyfin #7059)反复索要同款 |
|
||||
| **Passkey 无用户名一键建档** | web.dev/Auth0/FIDO | 1 击+生物识别 | 云端 | **iCloud/Google 密钥链自动同步** | 理论上最优雅;但见 §2.3 硬约束 |
|
||||
| **QR/配对码跨设备** | Jackbox(房间码+QR)、Google TV、WhatsApp Web | — | — | 扫码/输码 | "已有身份的设备给新设备发短期票据"是无邮箱场景的换设备通行做法 |
|
||||
| **纯游客不留进度** | chess.com Guest Play | 0 | **不留**(无 ELO 无历史) | 不支持 | 反面教材:我们的核心诉求就是"游客也要留进度",不采用 |
|
||||
|
||||
### 2.2 无邮箱无手机号的"找回"业界处理
|
||||
|
||||
- **passkey 多设备同步**:丢单台设备无忧(iCloud Keychain / Google Password Manager 同步);但**全家桶全丢**时,苹果恢复 iCloud 钥匙串本身可能要短信验证——没有邮箱/手机号时基本无解(sixcolors、Authsignal、r/Passkeys 讨论)。
|
||||
- **恢复码**:通行替代(一次性长码,抄在纸上)。对家庭游戏场景等价于"配对码的长期版"。
|
||||
- **业界现实**:小休闲产品(Wordle 时代、Poki 游客)干脆**不支持找回**,丢就丢;有价值的账号才给恢复通道。
|
||||
- **对本项目的翻译**:找回 = 「另一台仍登录的设备出示配对码」(Jackbox/WhatsApp 模式),加一层**可选 PIN 认领**兜底"旧设备已刷机"的情况;不做邮箱/手机号。
|
||||
|
||||
### 2.3 Passkey 的硬约束(决定性发现)
|
||||
|
||||
WebAuthn 要求 **Secure Context(HTTPS 或 localhost)**(W3C WebAuthn L3、MDN)。`http://192.168.x.x:8000` 这种**局域网 IP + HTTP 不满足**,`navigator.credentials` 直接为 null(Corbado、StackOverflow #55971593、mkcert #210)。家庭 WiFi 主路径(serve.sh 打印的局域网地址)**天然用不了 passkey**;变通(ngrok/mkcert 全家设备装 CA)对家庭用户不现实。另:本项目服务器零依赖原则下,WebAuthn 服务端验证(COSE/ES256)要么引入 `@simplewebauthn/server`(破例)要么手搓密码学(危险)。→ **passkey 只能作为公网 HTTPS 域名下的可选增强,且建议推迟。**
|
||||
|
||||
### 2.4 双形态(公网+内网)的安全底线
|
||||
|
||||
不需要银行级,但要"防邻居蹭网互删进度":
|
||||
|
||||
| 层 | 局域网形态(现状 HTTP) | 公网形态(未来 HTTPS) |
|
||||
|---|---|---|
|
||||
| 传输 | HTTP 明文 = 同 WiFi 者可嗅 token。**威胁模型只能假设家庭 WiFi 可信**(WPA2/3 密码不外传即可);要更强就得走公网域名(家庭内网也可访问 https://game.xiangsilian.com) | TLS,嗅探关闭 |
|
||||
| 认证 | 匿名档案 token + 可选 PIN 认领(防"家人恶作剧互删"级别) | 同左 + 登录/配对端点限速 |
|
||||
| Token | 加 TTL(滑动续期,如 180 天)+ 登出可吊销 | 同左 |
|
||||
| PIN(若设置) | scrypt 哈希存储(node:crypto 自带,不破零依赖) | 同左 + limit_req(deploy.md 已规划) |
|
||||
| 服务边界 | 服务器只绑 LAN | 绑 127.0.0.1,Nginx 只反代 /api/(deploy.md「以后若要开放用户系统」原文照做) |
|
||||
|
||||
deploy.md 已写的前置清单与本方案完全兼容——本方案把其中"PIN 改哈希、token 过期"消化进档案系统本身。
|
||||
|
||||
### 2.5 中文社区实践补充
|
||||
|
||||
前端圈通行做法与本报告方案一致:首次访问生成 UUID 存 localStorage(可 Cookie 双保险)作设备身份 → 服务器据此签发 token → 同一设备 ID 对应唯一匿名账号(腾讯 CloudBase 匿名登录文档明确"同一设备只能生成一个匿名用户、可后续绑定升级")。浏览器指纹(FingerprintJS)可作 localStorage 被清后的兜底,但隐私争议大且家庭场景收益低,**不建议引入**。
|
||||
|
||||
### 2.6 来源链接
|
||||
|
||||
- Firebase 匿名认证最佳实践:<https://firebase.blog/posts/2023/07/best-practices-for-anonymous-authentication/>
|
||||
- Unity Authentication 匿名登录:<https://docs.unity.com/en-us/authentication/approaches-to-authentication>
|
||||
- 腾讯 CloudBase 匿名登录(同设备唯一匿名账号):<https://docs.cloudbase.net/http-api/auth/auth-sign-in-anonymously>
|
||||
- 前端设备唯一标识实践(掘金):<https://juejin.cn/post/7640043551352635402>;CSDN:<https://blog.csdn.net/weixin_43242942/article/details/139881190>
|
||||
- Wordle 本地存储与 NYT 迁移:<https://www.reddit.com/r/wordle/comments/1cszd9v/>(含 localStorage 实证); NYT 关联统计报道见 CNET
|
||||
- Poki FAQ(无账号=cookie 存进度):<https://poki.com/en/c/faq>;隐私声明:<https://poki.com/en/c/privacy-statement>
|
||||
- chess.com Guest Play:<https://www.chess.com/forum/view/site-feedback/playing-as-a-guest>
|
||||
- Netflix 档案切换:<https://help.netflix.com/en/node/322532753336036>;Jellyfin 多档案切换诉求:<https://github.com/jellyfin/jellyfin-web/discussions/7059>;UX 分析:<https://medium.com/@mariajennings/reflection-point-patterns-and-flows-78acd5ab234c>
|
||||
- WebAuthn 去用户名化(discoverable credentials):<https://stackoverflow.com/questions/73562080/>;web.dev 注册指南:<https://web.dev/articles/passkey-registration>
|
||||
- WebAuthn Secure Context 约束:W3C <https://www.w3.org/TR/webauthn-3/>;MDN <https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create>;LAN IP 不可用实证 <https://www.corbado.com/blog/test-passkeys-localhost-ngrok>、<https://stackoverflow.com/questions/55971593/>;mkcert 局域网讨论 <https://github.com/FiloSottile/mkcert/issues/210>
|
||||
- Passkey 找回/全丢设备:<https://www.authsignal.com/blog/articles/what-happens-when-your-passkey-device-is-lost-understanding-recovery-and-device-sync>、<https://sixcolors.com/link/2022/06/how-do-you-recover-passkeys-if-you-lose-all-your-devices/>、<https://www.reddit.com/r/Passkeys/comments/1faz0gt/>、Apple <https://support.apple.com/en-us/102195>
|
||||
- Jackbox 房间码/QR 第二屏:<https://www.jackboxgames.com/how-to-play>、<https://www.jackboxgames.com/blog/how-to-play-party-pack-nine-remotely>;TV QR 登录模式 <https://www.cosmiclearn.com/googletv/tv-authentication.php>;配对码设计 <https://softwarewitchcraft.com/posts/designing-pairing-codes-tradeoffs-mistakes-simple-approach-that-works/>
|
||||
- QR 多端登录(streaming/passkey 场景):<https://mojoauth.com/blog/multi-device-login-for-streaming-implementing-passkeys-across-ios-android-web-and-tv>
|
||||
|
||||
---
|
||||
|
||||
## 3. 候选方案矩阵
|
||||
|
||||
维度说明:★ 越多越好(工作量列反着看:★ 越少越好=工作量越小)。"语音适配"= 视障用户可听、可手势操作的成本。
|
||||
|
||||
| 方案 | 首访摩擦 | 同设备多玩家 | 跨设备同玩家 | 隐私与安全 | 实现工作量(文件级) | 语音适配 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **A. 纯本地档案**(localStorage 多命名空间档案,无服务器身份) | ★★★★★ 0 击(自动"玩家一") | ★★★★★ | ✗(只能整档导出/导入码) | ★★★★★ 无攻击面 | ★(新 engine/profile.js ~150 行 + index.html 改面板;游戏零改动若走"换档=换 key 快照") | ★★★★(无配对旅程要念) |
|
||||
| **B. 自动匿名档案 + 切换器 + 配对码**(首访静默 POST 建档拿 token;Netflix 式切换;跨设备=6 位配对码/QR;可选 PIN 认领) | ★★★★★ 0 输入 0 击 | ★★★★★ | ★★★★(配对码:需旧设备在手;PIN 认领兜底) | ★★★(token TTL+吊销、scrypt PIN;LAN HTTP 仍明文=家庭可信假设) | ★★(server.js +~180 行含配对/迁移;engine/profile.js ~250 行;index.html 面板重做;**8 个游戏零改动**,见 §4.3 换档走服务器倒灌) | ★★★(切换/配对语音线要新增台词资产) |
|
||||
| **C. 改良 PIN**(自动建档但随机发 PIN 一次展示;保留现登录 UI 为跨设备主路径) | ★★★(首访弹出"抄下你的 PIN"本身是打断+认知负担) | ★★★(PIN 是档案口令,家人共用即互看) | ★★★★(名+PIN 哪都能登) | ★★★(须配 scrypt+限速,否则弱于 B) | ★★(server 改动小于 B,无配对子系统) | ★★★(输 PIN 数字键盘可听化) |
|
||||
| **D. Passkey 一键建档**(WebAuthn discoverable credential,无用户名无密码) | ★★★★(1 击+生物识别,但要系统弹窗理解成本) | ★★★★(每档案一个 passkey) | ★★★★★(iCloud/Google 同步白送) | ★★★★★ | ★★★★★(服务端 COSE/ES256 验证:引依赖或手搓密码学;**LAN HTTP 不可用,见 §2.3**) | ★★★(系统弹窗有系统级旁白) |
|
||||
| **E. QR/配对码**(独立小方案,只做"身份搬运") | — | — | ★★★★ | ★★★★(短期单次码) | ★(+~60 行) | ★★(QR 不可听,须配数字码朗读) |
|
||||
|
||||
关键否决/保留理由:
|
||||
|
||||
- **D 否决为主方案**:局域网 HTTP 私有 IP 下 WebAuthn API 直接不存在(§2.3),而家庭 WiFi 恰是主场景;零依赖约束下服务端实现成本最高。留作公网化之后的可选"认领"升级项。
|
||||
- **C 不推荐但保留 API 兼容**:PIN 从"人人必经"降级为"想要跨设备找回的人可选设置",现有 username+PIN 端点在迁移窗口保留。
|
||||
- **E 不是独立方案**,是 B 的跨设备旅程构件(A 若只做本地,也可后补导出码)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐方案(主推 B,A 为其离线/公网静态降级形态)
|
||||
|
||||
### 4.1 总体形态
|
||||
|
||||
```
|
||||
┌─ 首访:0 输入 → 静默 POST /api/profile → token + "玩家一"
|
||||
局域网(serve.sh)──┤─ 切换:大厅档案面板(本地保存该设备所有 {id,name,token})
|
||||
└─ 跨设备:旧设备"出示配对码"(6 位数字,10 分钟单次)→ 新设备输入 → 拉全部进度
|
||||
公网(Nginx)────── API 未开放期:自动降级为方案 A(纯本地档案,localStorage 快照式换档)
|
||||
API 开放期(deploy.md 前置完成后):与局域网同一套 B
|
||||
可选升级 ────────── "认领":给档案设 PIN(scrypt 哈希),用于名+PIN 跨设备登录(旧设备不在手时兜底)
|
||||
```
|
||||
|
||||
离线/无 API 时 B→A 的降级是**同一份 profile.js 代码的两种存储后端**(服务器或 localStorage 快照),不是两套系统——现状 index.html 的 `api()` 已有"服务器不可达静默降级"基因,延续即可。
|
||||
|
||||
### 4.2 三个旅程(语音 + 手势描述)
|
||||
|
||||
本仓库手势语言:上/下/左/右滑动、单击(约 300ms 延迟)、双击;全项目 8 个统一 earcon(engine/guide.js 运行时合成)。大厅新增语音资产需走 TTS 流水线(台词表加进 tools/gen_voice_dual.py,男女双声)。
|
||||
|
||||
**旅程 1 · 首次进入(0 感知建档)**
|
||||
- 打开大厅,无任何弹窗/表单。页面加载时后台静默建档(服务器形态拿 token;无服务器则纯本地)。
|
||||
- 首次完成任一游戏存档后,大厅顶部档案区轻声播一句(可被任何触摸打断,遵守引擎语音链规则):「已创建玩家档案:玩家一。你可以在大厅顶部切换或重命名。」同时卡片刷新出进度摘要。不打断优先级高于这句闲聊的任何游戏语音。
|
||||
- 视障路径:大厅是真实 DOM 按钮,TalkBack/VoiceOver 逐个可读;补 `aria-live` 播报上述句子;卡片本身已可读(真实 `<a>`)。
|
||||
|
||||
**旅程 2 · 切换玩家(同设备,2 击)**
|
||||
- 大厅顶部常驻「玩家:〔玩家一〕」按钮(真实 `<button>` + `aria-haspopup`)。单击 → 展开档案列表(每行一个 `<button role="menuitemradio">`:玩家一✓ / 玩家二 / +新玩家 / ⚙ 重命名·配对·设PIN)。
|
||||
- 单击某档案 → 播确认 earcon(guide 词表的 success 音)→ 语音「已切换到玩家二」→ 下方游戏卡进度摘要刷新。
|
||||
- 切换的数据搬运(详见 4.3):旧档案本地进度全部 PUT 上服务器 → 新档案全部进度 GET 倒灌回**原有** localStorage key → 游戏代码零改动。离线(纯本地后端)则等价地在 localStorage 快照间搬运。
|
||||
- 听觉化档案名:预生成「玩家一~玩家九」音 + 每档案一个签名 earcon(8 音词表外允许第 9 种"档案身份音",或复用音高变体),任意汉字名不朗读只显示(决策点 D3)。
|
||||
|
||||
**旅程 3 · 换设备(配对码)**
|
||||
- 旧设备:档案面板 →「换设备 / 配对」→ 显示 6 位数字配对码 + QR(QR 给明眼人扫码用,同 LAN 下直开新设备页)。**语音路径**:逐位朗读数字(需预生成 0-9 单数字音——设计规范禁"数字碎片拼接"是对整句台词的;配对码逐位播报是系统级新场景,需设计侧裁一条豁免或用「第 x 位是 y」整句预生成,见决策点 D3 附注)。
|
||||
- 新设备:大厅档案面板 →「我是老玩家」→ 数字小键盘(大按键、无 IME)逐位输入,单击选键、输入完自动校验;错误播 error earcon +「配对码不正确,请重试」。
|
||||
- 成功 → 成功 earcon +「欢迎回来,玩家二」→ 新设备拿到 token,全部进度从服务器倒灌本地(顺带修复审计缺口 1:跨设备拉回从此对所有 8 款游戏生效,因为倒灌发生在大厅层而非各游戏内)。
|
||||
- 兜底:旧设备已丢失且档案设过 PIN →「用玩家名+PIN 登录」(保留现有 login UI 为这条低频路径);没设 PIN 也没旧设备 → 接受丢失、重新开始(与 Wordle/Poki 游客同水位,写入产品预期管理文案)。
|
||||
|
||||
### 4.3 服务器数据模型变更(users.json v2 + tokens + pairings)
|
||||
|
||||
```
|
||||
data/users.json(v2,启动时自动从 v1 迁移)
|
||||
{
|
||||
"u_a1b2c3": {
|
||||
"id": "u_a1b2c3",
|
||||
"name": "玩家一",
|
||||
"kind": "device", // device=匿名档案 | claimed=已设PIN
|
||||
"pinHash": null, // claimed: "scrypt$N$r$p$salt$hash"(node:crypto 自带)
|
||||
"createdAt": "…", "lastSeenAt": "…"
|
||||
},
|
||||
"uuu": { "id": "uuu", "name": "uuu", "kind": "claimed", "pinHash": "scrypt$…(由明文 0858 迁移时哈希)", … }
|
||||
}
|
||||
data/tokens.json { "<hex>": { "uid": "u_a1b2c3", "exp": 1893456000, "lastUsedAt": … } } // 滑动 180 天
|
||||
data/pairings.json { "483920": { "uid": "u_a1b2c3", "exp": …, "used": false } } // 10 分钟单次
|
||||
data/<uid>.json { "<game>": data } // 结构不变;老用户 uid=旧用户名,文件零迁移
|
||||
```
|
||||
|
||||
API 增删(server.js 内保持零依赖):
|
||||
|
||||
| 端点 | 说明 |
|
||||
|---|---|
|
||||
| `POST /api/profile`(新) | 无参或 `{name?}` → 建匿名档案 + 返回 `{token, profile}`。配简单 per-IP 频控(如 10 个/天)防刷 |
|
||||
| `GET /api/profile`(新) | 当前 token 的档案信息 |
|
||||
| `PUT /api/profile`(新) | 改名 |
|
||||
| `POST /api/profile/pin`(新) | 设置/修改 PIN(kind→claimed,scrypt) |
|
||||
| `POST /api/pair/start`(新) | 已登录 → 生成 6 位配对码(10 分钟单次) |
|
||||
| `POST /api/pair/redeem`(新) | `{code}` → 新 token(配对码换登录) |
|
||||
| `POST /api/logout`(新) | 吊销当前 token |
|
||||
| `GET /api/progress` / `PUT /api/progress` | **不变** |
|
||||
| `POST /api/register` / `POST /api/login` | 保留一个迁移窗口(等价于 claimed 档案),之后降级为内部专用 |
|
||||
| 顺带修复 | sessions.json → tokens.json 迁移时全部给 180 天滑动过期;写入时惰性清理过期项 |
|
||||
|
||||
### 4.4 向后兼容与迁移(现有用户 = data/ 里的 1 个真实账号 "uuu")
|
||||
|
||||
1. server.js 启动检测 users.json 为 v1(值是字符串)→ 一次性转 v2:uid 沿用旧用户名(`uuu.json` 进度文件零迁移),PIN 明文即刻转 scrypt,sessions token 全量搬入 tokens.json 并加 exp。
|
||||
2. 客户端 `soundgame.auth` 旧结构 `{username, token}` 在 profile.js 初始化时识别 → 服务器换发(或直接沿用,token 仍有效)→ 包成单档案的档案表。老用户无感。
|
||||
3. 游客的 localStorage 进度:首次进入新档案系统时,把现有 `soundgame.progress.*` 等本地 key **认领进首个自动档案**(数据不丢,升级为档案数据)——这是 Firebase `linkWithCredential`「UID 不变、数据原地升级」思想(§2.1)。
|
||||
4. 大厅 `PUBLIC_STATIC` 分支保留:公网无 API 期 → 档案面板仍在但走纯本地后端(比今天"整个面板藏掉"更好)。
|
||||
|
||||
### 4.5 文件级改动清单
|
||||
|
||||
| 文件 | 改动 | 规模估计 |
|
||||
|---|---|---|
|
||||
| `web/server/server.js` | 新端点(§4.3 表)、scrypt、token TTL/吊销、配对码、v1→v2 启动迁移、per-IP 简单频控 | +~180 行 |
|
||||
| `web/engine/profile.js`(新) | 档案表(本地 {id,name,token} 列表)、激活档案、换档搬运(flush 本地全部 key→PUT;GET→倒灌)、双后端(服务器/纯本地快照)、配对 UI 逻辑 | ~250 行 |
|
||||
| `web/index.html` | 登录面板 → 档案面板(列表+新玩家+重命名+配对+设PIN+名PIN登录折叠项);补 aria(动态表单加 label、aria-live 播报区);`PUBLIC_STATIC` 分支改为本地档案模式 | ~150 行重写 |
|
||||
| 8 个 `web/*/game.js` | **零改动**(换档搬运在大厅层完成;radio 自带的 GET 逻辑与新机制共存不冲突) | 0 |
|
||||
| `web/engine/settings.js` | 零改动(`_settings` 本来就走 progress API,档案化后自动变成玩家级设置) | 0 |
|
||||
| `web/serve.sh` / `web/server/README.md` / `HANDOVER.md` §2.2、§6 | 文档更新(玩具级警告改写为实际水位) | 小 |
|
||||
| `tools/gen_voice_dual.py` 台词表 | 新增大厅语音(sys_profile_created / switched / pair_code 逐位数字 / welcome_back 等,男女双声)+ 走一轮 TTS 流水线 | 若干条 |
|
||||
| `docs/deploy.md` | 「以后若要开放用户系统」小节更新为本方案公网路径 | 小 |
|
||||
|
||||
### 4.6 风险与缓解
|
||||
|
||||
| 风险 | 等级 | 缓解 |
|
||||
|---|---|---|
|
||||
| localStorage 被清/换浏览器 → 匿名档案孤儿(业界公认,Firebase 官方警告同款) | 中 | 引导时机学 Firebase:在"第一次想跨设备/第二次设 PIN 机会点"提示设 PIN 或配对,而非首访打断;服务器对 180 天不活跃 device 档案做归档清理(防 users.json 无限膨胀) |
|
||||
| LAN HTTP 明文下 token 可被同 WiFi 者嗅探 → "邻居删进度" | 中 | 威胁模型写明:家庭 WiFi 密码可信即安全;要更强走 https://game.xiangsilian.com(内网也能访问公网域名)。认领 PIN 防的是"家人恶作剧"级别 |
|
||||
| 换档搬运期间断网/杀页面 → 两档案数据交错 | 低 | 搬运顺序固定:先全部 flush 完成再切换;失败即中止切换并播 error;纯本地后端快照为原子 JSON |
|
||||
| 一次性教学标记(tutIntroSeen 等 3 个 key)跨档案泄漏(换了档案教学不再重播) | 低 | 可接受(教学只教一次本就是产品取向);彻底修需游戏改动或 key 前缀化,列为后续可选项 |
|
||||
| 配对码被念出来的体验(视障用户逐位听 6 位数字) | 中 | 数字音预生成 + "重复播报"手势(双击重听);或改 4 位词表码(每词一个预生成音)——设计侧裁决 |
|
||||
| 任意汉字名无法朗读,视障家人无法"听出"谁在玩 | 中 | 每档案绑定签名 earcon + 固定编号音(玩家一~九可听),自定义名只作视觉显示(决策点 D3) |
|
||||
| 匿名档案被脚本批量刷(公网开放后) | 低 | per-IP 频控 + limit_req(Nginx 层,deploy.md 已规划);家庭场景几乎无此风险 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 备选方案说明
|
||||
|
||||
- **若决定完全不上服务器身份**(公网永远纯静态):直接做方案 A。损失:跨设备、玩家级设置云同步、家庭共用 PC 时的云端备份;得到:零攻击面、实现最省。A 是 B 的真子集(profile.js 双后端中的本地后端),先做 A 再补 B 不返工——这也是把 A 设计为 B 的降级形态的原因。
|
||||
- **若公网化后想上 passkey(D)**:在 claimed 档案上加第二凭据即可(passkey 与 PIN 并存),客户端改动集中在大厅,服务端需引入 WebAuthn 验证依赖或手搓(建议届时引入 `@simplewebauthn/server` 并为服务器破一次零依赖例,或用 Nginx+auth 子服务隔离依赖)。届时 B 的配对码仍是兜底通道,两者不冲突。
|
||||
|
||||
## 6. 三个关键设计决策点(需产品拍板)
|
||||
|
||||
- **D1 · 档案名怎么起**:首访完全 0 感知(自动"玩家一",事后改名)还是首访点一次"起个名字/用默认"?(推荐前者:改名入口在档案面板,随时可改;起名不是注册,不该挡在玩游戏前面。)
|
||||
- **D2 · 跨设备找回的底线**:只配对码(旧设备必须在手)够不够?是否同时保留"名+PIN 登录"作为旧设备丢失时的兜底(推荐保留,PIN 变为可选认领项);还是彻底极简、丢了就丢(Wordle 同水位)?
|
||||
- **D3 · 视障玩家的"名字可听化"**:自定义汉字名不朗读(只显示+签名 earcon+编号音)够不够?还是引入浏览器 SpeechSynthesis 念任意名(与全预生成语音的质感不一致、移动端质量参差)?附带:配对码逐位播报需要设计侧给"数字单音"或"第 x 位是 y"整句一个豁免裁定。
|
||||
@@ -0,0 +1,319 @@
|
||||
# 02 · PC 键盘操作与双端一致体验(Input Parity)调研
|
||||
|
||||
> 2026-09-09 · 交互调研 · 范围:PC 浏览器键盘操作方案、引擎适配、提示(画面+语音)随平台适配、混合设备、测试管线影响。
|
||||
> 红线遵守:本文只做调研与设计,不改任何代码。所有数字均从仓库实测/统计得出,来源随文标注。
|
||||
|
||||
## 0. 结论速览
|
||||
|
||||
| 决策点 | 推荐 | 一句话理由 |
|
||||
|---|---|---|
|
||||
| 键位方案 | 方向键=四向滑动、**空格=单击、回车=双击**、按住空格=长按(WASD 作方向别名) | 与 A Blind Legend / audiogames.net 社区惯例完全同构,双击走专用键避免把 300ms 延迟带进键盘 |
|
||||
| 语音路线 | **路线 a:双套资产**(`voice/<gender>/key/<id>` 变体目录,复用 male/female 机制) | 与路线 b 重录成本相同(306 文件/37 分钟音频),但增量落地不动已验收资产、两端语气都精确 |
|
||||
| 最小可用(MVP) | 引擎键盘通道 + 遮罩键盘解锁 + 画面提示自适应,**语音暂不动** | ~200 行改动(gesture.js/audio.js/settings.js/index.html),1~2 人日;语音资产独立分期 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状审计
|
||||
|
||||
### 1.1 gesture.js(web/engine/gesture.js,323 行):三通道中的两个,无键盘
|
||||
|
||||
- 输入通道:**touch 事件**(主通道,全 preventDefault 防滚动/缩放/合成鼠标)+ **mouse 事件**(桌面调试通道,带 700ms `recentlyTouched()` 守卫防触摸后的合成鼠标误判)。**没有任何 keydown/keyup 监听**——全仓库 grep `keyboard|keydown` 零命中。
|
||||
- 手势模型:`up/down/left/right/tap/doubletap/longpress/longpressend`(+immediateTap 模式的 `tapcancel`)。核心状态机是**指针生命周期**(down→move→up)+ 长按 450ms 定时器 + 双击 300ms 窗。
|
||||
- 关键内部函数:`handleDown(x,y)` / `handleMove(x,y)` / `handleUp(x,y)` / `handleCancel()`——**坐标只用于位移判定**(tapSlop/swipeMinDistance),点按与长按判定本身与坐标值无关。这意味着键盘通道可以把"虚拟指针"喂给同一状态机:坐标传元素中心、永不 move,天然只会走出 tap/longpress/doubletap 分支,永远不会误判滑动。
|
||||
- `attachGestures(element, onGesture, config)` 是唯一接线点,10 个游戏 index.html 全部经它接 `game.onGesture`。**键盘层加在 attachGestures 内部 = 游戏代码零改动**。
|
||||
- immediateTap(rhythm 独占,`web/rhythm/index.html:33`):按下即发 tap、双击补发改判。键盘空格若复用 handleDown/handleUp,rhythm 的"空格=kick 演奏"自动成立,零改动。
|
||||
- 长按旁白快进(1.7x):靠 window 事件 `soundgame:longpress/longpressend`(audio.js 监听)。键盘长按若复用同一状态机,这两个事件照发,快进零改动。
|
||||
|
||||
### 1.2 启动遮罩(web/engine/audio.js showStartOverlay):只认触摸和鼠标 click
|
||||
|
||||
```js
|
||||
overlay.addEventListener('touchstart', e => e.preventDefault(), {passive:false});
|
||||
overlay.addEventListener('touchend', e => { e.preventDefault(); onTap(); }, {passive:false});
|
||||
overlay.addEventListener('click', onTap);
|
||||
```
|
||||
|
||||
- 9 个游戏页 + template 全部传 `text: '轻触开始'`(audio.js 默认值同)——**画面提示的第一个适配点**,且键盘玩家目前**无法用键盘解锁音频**(keydown 不是合法解锁手势路径),必须伸手去摸鼠标。注:浏览器规范里 keydown 属于 user activation 事件,`AudioContext.resume()` 在 keydown 回调内同步发起即可解锁,技术可行,只是遮罩没监听。
|
||||
- 遮罩期间 gesture.js 尚未 attach(index.html 先 `await showStartOverlay()` 再 `attachGestures`),所以键盘解锁必须改在 audio.js 里。
|
||||
|
||||
### 1.3 画面提示现状:游戏页几乎无视觉文案,集中在三处
|
||||
|
||||
1. 启动遮罩 hint(audio.js,`轻触开始`×10 页)
|
||||
2. 大厅 footer(web/index.html:209-211):「操作方式:上 / 下 / 左 / 右滑动、单击、双击」
|
||||
3. 各游戏 index.html 只有 cover.png 封面 + 遮罩,**无其他操作文案**——画面提示适配的面积非常小。
|
||||
|
||||
### 1.4 语音提示现状:操作词烘焙在 TTS 资产里(核心约束)
|
||||
|
||||
以 `tools/gen_voice_dual.py` 台词表(8 游戏全量 651 句,TTS 实际数据源)统计含「滑/单击/双击/长按」的句子(regex 实测,2026-09-09):
|
||||
|
||||
| 游戏 | 台词总数 | 含操作词句数 |
|
||||
|---|---|---|
|
||||
| storyline | 41 | 21 |
|
||||
| escape | 80 | 26 |
|
||||
| simon | 78 | 22 |
|
||||
| rhythm | 40 | 9 |
|
||||
| space | 59 | 24 |
|
||||
| rpg | 123 | 16 |
|
||||
| radio | 103 | 18 |
|
||||
| dungeon | 127 | 17 |
|
||||
| **合计** | **651** | **153(23.5%)** |
|
||||
|
||||
(注:任务书里「space 27 / dungeon 22 / radio 19」来自对 `docs/design/*-voice-ids.md` 的表格行 grep;voice-ids 文档只覆盖 5 个游戏且部分行多句,本文以 gen 脚本台词表为准——它是 TTS 管线的真实输入。)
|
||||
|
||||
受影响资产实测量(ffprobe 逐文件探测,全部存在):
|
||||
|
||||
- 153 句 × 2 性别 = **306 个音频文件**,双格式(.ogg+.m4a)共 612 个文件、**49.4 MiB**;
|
||||
- 音频总时长:male 1078.8s + female 1139.8s = **2218.6s ≈ 37.0 分钟**。
|
||||
|
||||
双性别资产先例:`audio/voice/<gender>/<id>.{ogg,m4a}` 整目录切换 + 平铺回退(audio.js `_voiceCandidates`),设置项 `voice`(settings.js,枚举校验 + localStorage + 服务器 `_settings` 同步)——**input 变体目录可完整复用这套机制**。
|
||||
|
||||
### 1.5 测试管线现状(web/harness/live/)
|
||||
|
||||
- 手势注入:control.mjs `/gesture` → cdp.mjs `Input.dispatchTouchEvent`(touchStart/Move/End 序列,swipe 用 8 步插值 200px 位移满足 64px+400px/s 判定)。**没有键盘通道**。
|
||||
- 注入同时打标 `__live.events.push({ev:'gesture', type})` 供审计。voice 验收对照 `web/harness/voice_text.json`(8 游戏期望文本)——**语音资产任何措辞变更都要同步此文件**,否则 ASR 相似度判定失真。
|
||||
|
||||
---
|
||||
|
||||
## 2. 键位方案(含业界依据)
|
||||
|
||||
### 2.1 业界惯例证据
|
||||
|
||||
**audiogames.net 社区(盲人音频游戏事实标准社区)**——数据库各游戏控制描述呈现出稳定模式:方向键=方位/移动([Night of Parasite](http://fog.audiogames.net/db.php?action=view&id=Night%2520of%2520Parasite)「arrow keys represent the four compass directions」、[AudioSpeed](http://fog.audiogames.net/db.php?action=view&id=AudioSpeed) arrows to steer、[Rebound](http://fog.audiogames.net/db.php?action=view&id=Rebound) 左右方向键移动);**空格=第一主操作**([Technoshock](http://fog.audiogames.net/db.php?action=view&id=technoshock) space 射击、[PacMan Talks](http://fog.audiogames.net/db.php?action=view&id=pacmantalks) space 查距离);回车=菜单确认;**"按住"与"连按两下"是既有表达**(论坛档案「I: fire your laser, hold down for continuous fire」「F: press twice to reactivate」——[audiogames.net 论坛存档](https://audiogames7.rssing.com/chan-63749684/all_p5.html))。
|
||||
|
||||
**A Blind Legend**(首批纯音频动作冒险,双端:移动端滑动手势 / PC Steam 键盘)——[Steam 社区键位参考](https://steamcommunity.com/sharedfiles/filedetails/?id=3192446890):←/→ 旋转、↑ 前进(按住持续)、**空格呼叫向导**、Esc 菜单、回车确认。它是"同一游戏、触屏手势与键盘两套措辞并行"的直接先例(移动端教学说的是手势,PC 版说的是按键)。
|
||||
|
||||
**Blind Drive**(音频游戏奖项得主,双端)——[官网](https://blinddrivegame.com/)自称「2-button arcade gameplay」,PC 版键盘(+手柄)只做左右闪避([Steam 页](https://store.steampowered.com/app/1300600/Blind_Drive/)、[评测](https://traxion.gg/blind-drive-review/))——证明音频游戏在 PC 上会把触屏/体感操作压缩成极简键盘映射。
|
||||
|
||||
**结论**:音频游戏键盘惯例 = **方向键表方位、空格表主操作、回车表确认/第二操作、按住表持续**。SoundGame 的 6 手势可以无损映射进这个惯例。
|
||||
|
||||
### 2.2 推荐键位表(主方案)
|
||||
|
||||
| 手势(触屏语义) | 键盘主键 | 备用 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 左滑 / 右滑 | ← / → | A / D | keydown 即发,**零延迟**(无距离/速度判定) |
|
||||
| 上滑 / 下滑 | ↑ / ↓ | W / S | 同上 |
|
||||
| 单击 | 空格 | — | 走现有 tap 状态机:**快速点按**=tap(保留 ~300ms 双击窗延迟,与触屏同感);immediateTap 游戏(rhythm)按下即响 |
|
||||
| 双击 | **回车** | 空格连按两下 | 专用键**即时**触发(不等 300ms 窗);空格×2 走状态机也能判出 doubletap(第一次的 pending tap 被取消,行为与触屏双击完全一致) |
|
||||
| 长按 | **按住空格 ≥450ms** | — | 松开=longpressend;voice 播放中即旁白 1.7x 快进(复用 window 事件) |
|
||||
|
||||
设计要点与理由:
|
||||
|
||||
1. **双击给专用键(回车)而不是只靠连按空格**——这是本项目最重要的键位决策。若键盘只允许"空格×2",单击就必须等 300ms 双击窗(gesture.js 文件头自己写着这是要避开的坑)。回车=双击让"确认类"操作即时响应(A Blind Legend 回车=confirm 同构);同时保留空格×2 兼容,触屏肌肉记忆用户无学习成本。
|
||||
2. **长按复用空格**而非另设键:长按的语义就是"按住那个确认键",且 audio.js 快进通道长按期间持续生效,独立键反而多一个要教的键位。
|
||||
3. **WASD 作别名默认开启**:单手党/游戏手玩家习惯;不与箭头冲突,成本为零。
|
||||
4. 不映射 Esc(下滑已覆盖菜单;Esc 留给未来退出全屏),不占 F 键/数字键(避开浏览器与读屏快捷键域)。
|
||||
|
||||
### 2.3 键位冲突分析
|
||||
|
||||
- **浏览器默认行为**:箭头键与空格会滚动页面、回车在焦点元素上触发 click——全部在 keydown 里 `preventDefault()` 解决(页面本就 `overflow:hidden` + `touch-action:none`,见 style.css;preventDefault 是双保险,[MDN keyboard-navigable widgets](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Keyboard-navigable_JavaScript_widgets) 的标准做法)。
|
||||
- **表单目标守卫**:游戏页无输入框,但大厅有(用户名/PIN `<input>`)——键盘层遇 `INPUT/TEXTAREA/SELECT/contenteditable` 目标直接放行不拦截,避免大厅打不了字。
|
||||
- **读屏软件**:NVDA/争渡在"浏览模式"会先吃掉箭头键([Stack Overflow: Screen Reader Blocks Keyboard Events](https://stackoverflow.com/questions/72538936/web-development-screen-reader-blocks-keyboard-events))。本项目游戏页无 DOM 文本可浏览,惯例解法是遮罩/根元素加 `role="application"` 促使读屏直通按键——列为实现时的验证项,不是阻塞项(本项目视障玩家主要在手机端,PC 视障玩家属加分场景)。
|
||||
- **与游戏内快捷键**:现有游戏无键盘快捷键,无冲突。
|
||||
|
||||
---
|
||||
|
||||
## 3. 引擎适配设计(文件级)
|
||||
|
||||
### 3.1 总体思路:并行通道,不做先抽象后重写
|
||||
|
||||
在 `attachGestures()` 内**并列**增加键盘通道,与 touch/mouse 共享同一状态机与回调——不做"统一 InputChannel 抽象层"(323 行引擎重写风险大、收益为零;现有三通道已经证明并列式可行)。
|
||||
|
||||
### 3.2 gesture.js:新增键盘通道(预计 ~120 行)
|
||||
|
||||
```
|
||||
attachGestures(element, onGesture, config) 内新增:
|
||||
|
||||
KEY_MAP = { ArrowUp:'up', ArrowDown:'down', ArrowLeft:'left', ArrowRight:'right',
|
||||
KeyA:'left', KeyD:'right', KeyW:'up', KeyS:'down' } // WASD 别名
|
||||
TAP_KEY = 'Space'; DBL_KEY = 'Enter';
|
||||
|
||||
window.addEventListener('keydown', onKeyDown) / 'keyup', onKeyUp) // detach 时移除
|
||||
|
||||
onKeyDown(e):
|
||||
1. e.repeat → return // 方向键 repeat 抑制(按住↑只发一次 up)
|
||||
2. e.target 是表单元素 → return // 大厅输入框放行
|
||||
3. 仅拦截映射键;其余键不动 // 不吞 Tab(无障碍:别做键盘陷阱)
|
||||
4. e.preventDefault() // 抑制滚动/焦点激活
|
||||
5. tracking 时 return // 单飞行指针语义(与现有多指忽略一致,
|
||||
// 见 onTouchStart 的 e.touches.length>1 分支)
|
||||
6. 方向键 → vibrate('swipe'); onGesture(dir) // 即时,无位移判定
|
||||
回车 → vibrate('confirm'); onGesture('doubletap') // 即时,同时 cancelPendingTap()
|
||||
空格 → handleDown(centerX, centerY) // 进状态机:起 450ms 长按定时器;
|
||||
// immediateTap 游戏立即发 tap
|
||||
onKeyUp(e):
|
||||
空格 → handleUp(centerX, centerY) // 复用收尾:长按→longpressend;快按→tap/双击窗
|
||||
// (连按两下空格自然判 doubletap)
|
||||
```
|
||||
|
||||
实现细节:
|
||||
|
||||
- **坐标取元素中心**(`getBoundingClientRect()` 缓存),永不 move——键盘路径必然只走 tap/longpress/doubletap 分支。
|
||||
- **450ms 长按判定**:完全复用 `handleDown` 里的 `longPressTimer`,无新逻辑。
|
||||
- **300ms 双击窗**:完全复用 `lastTapTime/pendingTapTimer`,键盘空格与触摸 tap 共享窗口(混合设备下"触屏点一下+空格点一下"也会判双击——语义正确:同一虚拟指针)。
|
||||
- **互斥**:`tracking` 标志天然互斥(touch start 或 Space down 先到先得),与现有多指忽略语义一致,不需要 per-channel 状态。
|
||||
- **焦点**:键盘事件挂 `window`(键盘事件目标是焦点元素而非手势根;游戏页焦点在 body,冒泡到 window 必达)。
|
||||
- **通道来源标记(可选但建议)**:onGesture 前记 `lastInputChannel='key'|'touch'|'mouse'` 并导出 `getActiveChannel()`——§4 提示自适应与 §6 测试都要用。审计事件 `soundgame:input`(CustomEvent)亦可,供 harness 打标。
|
||||
|
||||
### 3.3 audio.js:showStartOverlay 键盘解锁 + 自适应文案(~20 行)
|
||||
|
||||
```js
|
||||
// 键盘解锁:回车/空格(keydown 属 user activation,同步发起 resume 即可)
|
||||
const onKey = (e) => { if (e.code === 'Enter' || e.code === 'Space') { e.preventDefault(); onTap(); } };
|
||||
overlay.addEventListener('keydown', onKey);
|
||||
// (overlay 需 tabindex="0" 并 focus(),keydown 才会落在它上面;或直接挂 window,
|
||||
// resolve 后移除监听)
|
||||
```
|
||||
|
||||
- 文案自适应:`text` 参数缺省时按 `isTouchLikely()`(§4.1)选「轻触开始」/「按回车或点击开始」;显式传 text 的调用照旧(全部 10 页现在都显式传 `'轻触开始'`——改成不传即自动,属可选的各页 1 行清理,不传也能跑)。
|
||||
|
||||
### 3.4 settings.js + 大厅:新增 `input` 设置(~25 行)
|
||||
|
||||
- `input: 'auto' | 'touch' | 'key'`(默认 `auto`),进 VALID 枚举;`_settings` 服务器同步机制零改动自动带上。
|
||||
- 大厅设置面板加一个 `<select>`(自动 / 触屏优先 / 键盘优先)。
|
||||
- 语义:`auto` = 三通道全开 + 提示跟随最近使用的通道;`touch`/`key` 只影响**提示文案与语音变体选择**,**不关闭另一通道**(永远双通道在线,混合设备友好,见 §5)。
|
||||
- footer 文案随 input/检测切换。
|
||||
|
||||
### 3.5 游戏代码改动边界
|
||||
|
||||
| 项 | 改动 |
|
||||
|---|---|
|
||||
| 8 个游戏 game.js 的 onGesture 逻辑 | **零改动**(7 种事件语义不变) |
|
||||
| rhythm immediateTap | **零改动**(空格按下即 kick,长按=双通道一致) |
|
||||
| 旁白长按快进 | **零改动**(window 事件照发) |
|
||||
| 游戏页 index.html | 可选各 1 行(遮罩 text 改自适应);不改也能跑 |
|
||||
| gesture.js / audio.js / settings.js / 大厅 index.html | 引擎侧统一改(上述 ~185 行) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 提示适配机制
|
||||
|
||||
### 4.1 平台检测:能力检测 + 运行时跟随,不做一次性硬判
|
||||
|
||||
依据:[MDN @media/pointer](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/pointer)、[@media/any-pointer](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/any-pointer)、[web.dev Interaction](https://web.dev/learn/design/interaction)、[CSS-Tricks 对混合设备的警告](https://css-tricks.com/interaction-media-features-and-their-potential-for-incorrect-assumptions/)。
|
||||
|
||||
- **初始值**:`isTouchLikely() = navigator.maxTouchPoints > 0 && matchMedia('(pointer: coarse)').matches`——触屏手机=true;纯桌面=false;**触屏笔记本**(pointer: fine 主指针 + 有触屏)=false(初始按键鼠提示,运行时纠正)。
|
||||
- **运行时跟随**:gesture.js 记录 `lastInputChannel`;收到键盘事件后本会话提示切到键盘版,收到触摸切回。混合设备无需玩家配置即可用对提示(settings.input 手动覆盖只是兜底)。
|
||||
- **手动覆盖**:settings.input(§3.4)。
|
||||
|
||||
### 4.2 画面提示文案表
|
||||
|
||||
| 位置 | 触屏版 | 键盘版 |
|
||||
|---|---|---|
|
||||
| 启动遮罩(audio.js) | 轻触开始 | 按回车或单击开始 |
|
||||
| 大厅 footer(index.html) | 操作方式:上 / 下 / 左 / 右滑动、单击、双击 | 操作方式:方向键移动、空格单击、回车双击、按住空格长按 |
|
||||
| 手势教学文案(游戏内 HTML,目前无) | 向左滑 | 按 ← 键 |
|
||||
| 大厅 footer 第二行(300ms 延迟说明) | 保留 | 键盘版改为「回车=双击即时;空格单击有约 300ms 延迟(与手机一致)」 |
|
||||
|
||||
实现:一个 `inputHints.js` 小工具(~40 行)导出 `hintTable()` 按当前通道返回文案对象,audio.js 与大厅共用;CSS 侧如需图标/布局差异走 `@media (pointer: coarse)`。
|
||||
|
||||
### 4.3 语音提示三路线对比(核心决策)
|
||||
|
||||
先回答调研问题:**视障用户在 PC 上听「向左滑」会困惑吗?** 会,但程度有限且不该依赖它。audiogames.net 社区是 PC 键盘文化的大本营(键盘是默认、触屏是移动端补充);盲人社区对"手势↔键盘等价命令"的映射有明确预期——[AppleVis: Braille Display Users Deserve Better From Blindfold Games](https://applevis.com/blog/braille-display-users-deserve-better-blindfold-games) 明确批评过"只在触屏上讲手势、不给键盘等价命令"的做法。NVDA 用户知道触屏手势存在键盘等价物这个**概念**,但游戏语音说「向左滑」时,键盘玩家必须自己完成一次"向左滑≈按←"的翻译——**首次听不懂、需要猜**,违背本项目「开场交互教学」理念(教学句本身就在这 153 句里,如 storyline help 全句是操作说明)。学术侧同样有「默认键位不合用户习惯是音频游戏主要可用性问题」的记录([Design Guidelines for Audio Games](https://www.academia.edu/61440259/Design_Guidelines_for_Audio_Games))。**路线 c 不能作终态。**
|
||||
|
||||
#### 路线 a:双套语音资产(input 变体目录,像双性别一样)
|
||||
|
||||
机制:`voice/<gender>/key/<id>.{ogg,m4a}`,audio.js `_voiceCandidates` 追加一跳(key 目录 → gender 目录 → 平铺回退),id 集合只需覆盖 153 句(其余回退现有资产,不存在双份)。
|
||||
|
||||
| 维度 | 评估 |
|
||||
|---|---|
|
||||
| 资产成本 | **新增 153 句 × 2 性别 = 306 个 TTS 生成**(措辞改写:「向左滑」→「按左方向键」、「单击屏幕」→「按一下空格」、「双击」→「按回车」);落盘 612 文件(ogg+m4a)、**+49.4 MiB** 仓库增量;新音频 **37.0 分钟**(实测同 id 现有资产时长,措辞微调后误差 <10%) |
|
||||
| GPU 时长 | Qwen3-TTS 本地串行(flock)。参照系:R1 全量双性别管线 286 文件/43.5 分钟音频一个晚间会话完成(docs/audio/round1-report.md);本路线 306 文件/37 分钟与之同量级——**单晚 GPU 串行一轮 + --check 复检 + 少量离群重跑,≤ 半个 GPU 晚**。管线零改动支持:台词表加 `(fid, instruct, text, input='key')` 维度或直接新增 `*_KEY_LINES` 表,断点续跑/--only/--check/--convert 全部照用 |
|
||||
| 体验 | 最优:两端教学语气精确(「单击屏幕,继续剧情」→「按一下空格,继续剧情」,指令动词与输入设备严格一致) |
|
||||
| 风险 | ①153 句键盘措辞要策划逐句改写+试听(与 b 相同);②台词表长期 ×2 维护(改一句要过两个变体);③voice_text.json 需按 input 变体分集(live 管线 session 加 input 字段选期望文本集);④旧资产不动、回退安全,**增量落地可灰度** |
|
||||
|
||||
#### 路线 b:平台无关措辞重录(一套通吃)
|
||||
|
||||
机制:153 句全部改成不含输入方式的措辞(「向左滑:先检查收音机」→「向左:先检查收音机」),**替换**现有 306 文件。
|
||||
|
||||
| 维度 | 评估 |
|
||||
|---|---|
|
||||
| 资产成本 | 同样 **306 个 TTS 生成**(替换而非新增),37 分钟音频重录,仓库体积持平(措辞略短或 -5%);GPU 时长与 a 完全相同 |
|
||||
| 额外成本 | ①**已验收资产全部作废重验**(8 游戏 R1~R6 轮次里这 153 句的试听结论失效);②voice_text.json 153 句期望文本全量重对齐;③逐句审查"去操作词后语义仍完整"(如 storyline e2「单击屏幕,重新开始」→「单击,重新开始?」教学力度弱化) |
|
||||
| 体验 | 两端通用但**两端都变抽象**:触屏新手失去"滑"这个动词(本项目核心新手群体恰是手机端),键盘玩家仍要做一次"单击=空格"的映射(单击一词对键盘玩家仍非按键名)。属于两端各让一步 |
|
||||
| 风险 | 替换式落地无法灰度;ASR 对照/多音字清单/验收记录全要重跑 |
|
||||
|
||||
#### 路线 c:语音不动,PC 靠画面文字提示
|
||||
|
||||
| 维度 | 评估 |
|
||||
|---|---|
|
||||
| 成本 | 零 TTS、零资产改动;只有 §4.2 的画面文案工作 |
|
||||
| 体验 | 纯音频游戏玩家不看画面(本项目立项前提),PC 视障玩家**听到的仍是「向左滑」**,见上文社区证据——键盘等价命令缺失是被盲人社区明确批评过的可及性缺陷 |
|
||||
| 定位 | **只允许作为 MVP 过渡态**(PC 先上线、语音资产下一轮补),且要在 PC 版开场遮罩/大厅用文字+后续一句通用语音说明「键盘操作:方向键/空格/回车」兜底 |
|
||||
|
||||
#### 三路线速比
|
||||
|
||||
| | a 双套 | b 重录通用 | c 不动语音 |
|
||||
|---|---|---|---|
|
||||
| TTS 生成量 | 306 文件 / 37min | 306 文件 / 37min | 0 |
|
||||
| 仓库增量 | +49.4 MiB | ~0 | 0 |
|
||||
| 策划文案工作 | 153 句新写 | 153 句改写+全量重审 | 0 |
|
||||
| 触屏端体验 | 不变(原资产不动) | 轻微弱化 | 不变 |
|
||||
| PC 端体验 | 最优 | 中 | 差(视障不可用级) |
|
||||
| 落地方式 | 增量可灰度 | 替换不可灰度 | — |
|
||||
| 长期维护 | 台词表 ×2 | 台词表 ×1 | ×1(欠账) |
|
||||
|
||||
**推荐 a**:与 b 成本几乎相同但风险低一档(不动已验收资产、可灰度、触屏端零感知),且两端教学语气都保住精确性;机制上完整复用 male/female 双性别目录的成熟先例(引擎解析、管线、QC、设置项全套现成)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 混合设备(触屏笔记本 / 平板外接键盘)
|
||||
|
||||
- **运行策略:三通道永远并行在线,不做模式切换。** gesture.js 的 `tracking` 单飞行指针语义天然防跨通道干扰(触摸进行中空格 down 被忽略,反之亦然——与现有多指忽略一致)。
|
||||
- **提示跟随最近通道**(§4.1 运行时跟随):玩家把平板接上键盘敲了一下方向键,遮罩外的教学提示文案本会话即切键盘版;摸了屏又切回。无需重启页面、无需设置项。
|
||||
- **语音变体**(路线 a 落地后)同理跟随 `lastInputChannel`;settings.input='auto' 之外的手动值仅用于"我知道我在用什么,别自动切"的用户。
|
||||
- **一个已知边界**:混合设备上"触摸单击 + 键盘空格单击"共享同一个双击窗(同一虚拟指针),300ms 内跨通道两下会判 doubletap——语义上合理(玩家意图就是双击),文档写明即可,不做隔离。
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试影响(playtest / live 管线)
|
||||
|
||||
- **live 管线加键盘通道(建议,~30 行)**:control.mjs `/gesture` 接受 `{"type":"keyup","code":"ArrowLeft"}` 之类或新增 `/key` 端点 → cdp.mjs 增加 `Input.dispatchKeyEvent`(rawKeyDown/keyUp;比 `Runtime.evaluate` 派发合成 KeyboardEvent 更拟真——不过 gesture 层挂 window、合成事件也够用,CDP 通道留作高拟真档)。审计事件沿用 `{ev:'gesture', type}` 打标,附 `source:'key'`。
|
||||
- **解锁路径必须测键盘版**:会话第一步 tap 解锁遮罩(现状 README 明确"第一个动作必须是 tap");键盘通道上线后补一条"第一个动作 = Enter 解锁"的用例,验证 keydown 路径的 AudioContext resume。
|
||||
- **双通道回归**:现有触摸用例零改动可跑(键盘层不改变触摸路径);新增少量键盘用例重点覆盖:方向键 repeat 抑制(按住 1s 只发一次)、空格 300ms 双击窗(快按两下=doubletap、慢按=tap)、按住 450ms=longpress+旁白快进(`__live` 的 boost 事件)、大厅输入框打字不被拦截。
|
||||
- **voice_text.json**:路线 a 落地时按 input 变体分集(session settings 已支持传 `voice/speed/bgm/guide`,加 `input` 字段即可);路线 b 则要全量重对齐——这也是推荐 a 的测试侧理由。
|
||||
- **playtest 脚本(sessions/)**:若某轮 playtest 主题是"PC 玩家体验",直接用键盘通道跑既有剧本即可,无需新 harness。
|
||||
|
||||
---
|
||||
|
||||
## 7. 推荐组合与分期
|
||||
|
||||
### MVP(最小可用,PC 能完整玩全部 8 游戏)——约 1~2 人日
|
||||
|
||||
| 改动 | 文件 | 量级 |
|
||||
|---|---|---|
|
||||
| 键盘输入通道(含 repeat 抑制/表单守卫/滚动抑制/通道标记) | web/engine/gesture.js | ~120 行 |
|
||||
| 遮罩键盘解锁 + 自适应默认文案 | web/engine/audio.js | ~20 行 |
|
||||
| input 设置项(auto/touch/key) | web/engine/settings.js + web/index.html | ~25 行 |
|
||||
| 大厅 footer 文案切换 + inputHints 工具 | web/index.html(+新 inputHints.js ~40 行) | ~45 行 |
|
||||
| live 管线键盘通道 + 解锁/双通道用例 | web/harness/live/{control,cdp}.mjs | ~30 行 |
|
||||
|
||||
语音此时不动(路线 c 过渡态):PC 遮罩文字提示写明「键盘:方向键/空格/回车/按住空格」,教学语音里的「向左滑」暂由玩家自行映射——**过渡期建议不超过一个语音资产轮次**。
|
||||
|
||||
### 完整版(语音双端精确)——策划 153 句键盘措辞 + 半个 GPU 晚 + 工程半日
|
||||
|
||||
1. 策划:153 句键盘版措辞改写(台词表新增 key 变体行,参照 15/16 号 voice-ids 文档格式交付);
|
||||
2. 资产:gen_voice_dual.py 加变体维度,`--game all` 串行一轮(306 生成/612 落盘/+49.4 MiB),`--check` 复检;
|
||||
3. 工程:audio.js voice 解析加 key 目录跳(~15 行)、harness voice_text.json 分集(~20 行);
|
||||
4. 验收:live 管线 `input:'key'` 会话跑 8 游戏开场教学段,ASR 对照键盘版文本。
|
||||
|
||||
### 依赖与顺序
|
||||
|
||||
MVP 与语音路线完全解耦,可先行;完整版的变体目录机制依赖 MVP 的 `lastInputChannel`(决定加载哪套语音)。混合设备体验在 MVP 即达标(双通道并行+提示跟随),完整版补齐语音一致性。
|
||||
|
||||
---
|
||||
|
||||
## 附:本文数字复现方式
|
||||
|
||||
- 台词/操作词统计:`tools/gen_voice_dual.py` 各 `*_LINES` 表,regex `[滑|单击|双击|长按]`(2026-09-09 实测 153/651 句);
|
||||
- 资产时长/体积:对受影响 306 个 .ogg ffprobe 求和(male 1078.8s / female 1139.8s),文件级 du 合计 49.4 MiB(612 文件含 .m4a);
|
||||
- 业界链接均已在 §2/§4 正文内嵌。
|
||||
|
||||
## 附:业界依据来源
|
||||
|
||||
- [A Blind Legend — Steam 社区键位参考](https://steamcommunity.com/sharedfiles/filedetails/?id=3192446890)、[Steam 商店页](https://store.steampowered.com/app/437530/)
|
||||
- [Blind Drive 官网](https://blinddrivegame.com/)、[Steam 页](https://store.steampowered.com/app/1300600/Blind_Drive/)、[Traxion 评测](https://traxion.gg/blind-drive-review/)、[PCGamingWiki](https://www.pcgamingwiki.com/wiki/Blind_Drive)
|
||||
- audiogames.net 数据库:[Night of Parasite](http://fog.audiogames.net/db.php?action=view&id=Night%2520of%2520Parasite)、[Technoshock](http://fog.audiogames.net/db.php?action=view&id=technoshock)、[PacMan Talks](http://fog.audiogames.net/db.php?action=view&id=pacmantalks)、[Rebound](http://fog.audiogames.net/db.php?action=view&id=Rebound)、[AudioSpeed](http://fog.audiogames.net/db.php?action=view&id=AudioSpeed)、[论坛存档](https://audiogames7.rssing.com/chan-63749684/all_p5.html)
|
||||
- [AppleVis — Braille Display Users Deserve Better From Blindfold Games(手势↔键盘等价命令)](https://applevis.com/blog/braille-display-users-deserve-better-blindfold-games)
|
||||
- [MDN — @media/pointer](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/pointer)、[@media/any-pointer](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/any-pointer)、[Keyboard-navigable JavaScript widgets](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Keyboard-navigable_JavaScript_widgets)
|
||||
- [web.dev — Interaction](https://web.dev/learn/design/interaction)、[CSS-Tricks — Interaction Media Features 混合设备陷阱](https://css-tricks.com/interaction-media-features-and-their-potential-for-incorrect-assumptions/)
|
||||
- [Stack Overflow — Screen Reader Blocks Keyboard Events](https://stackoverflow.com/questions/72538936/web-development-screen-reader-blocks-keyboard-events)
|
||||
- [Design Guidelines for Audio Games](https://www.academia.edu/61440259/Design_Guidelines_for_Audio_Games)
|
||||
@@ -0,0 +1,310 @@
|
||||
# 03 · 多用户状态保留与跨设备同步 —— 数据架构调研
|
||||
|
||||
> 三份平台调研之三(并行:01 身份方案、02 键盘适配)。本文负责数据层:现状审计 → 业界调研 → 目标数据模型 → 冲突与离线策略 → 文件级改动清单 → 公网化分层清单。
|
||||
> 红线遵守:只调研不改代码。所有行号以 2026-09-09 工作区为准。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状审计
|
||||
|
||||
### 1.1 服务器端(web/server/server.js,310 行,零依赖)
|
||||
|
||||
三个 JSON 文件,全在 `web/server/data/`:
|
||||
|
||||
| 文件 | 结构 | 问题 |
|
||||
|---|---|---|
|
||||
| `users.json` | `{ username: pin }` | **PIN 明文**(代码注释自认"玩具级") |
|
||||
| `sessions.json` | `{ token: username }` | token 24 字节随机 hex,**永不过期**;无 logout 端点,每次登录追加一条,**无限增长** |
|
||||
| `<username>.json` | `{ <game>: <data> }` | 一用户一文件;`_settings` 为设置保留键;data 原样存储,无包装层 |
|
||||
|
||||
写得好的部分(值得保留到下一版):
|
||||
|
||||
- `enqueueWrite`(server.js:71)按文件串行化写队列,读-改-写整体入队(PUT 分支 server.js:191),**同进程内无并发覆盖**;
|
||||
- `tmp + rename` 原子写(server.js:87-93),崩溃不留半截文件;
|
||||
- 请求体 1MB 上限(server.js:118)、username/game 名正则白名单、路径穿越防护(server.js:214)。
|
||||
|
||||
审计发现的文档-代码出入:HANDOVER §2.2 说进度 data「**必须带 summary 字符串**」,但 server.js 的 PUT 只校验 game 名格式与 data 存在(server.js:183-187),**不校验 summary**——它是客户端约定(大厅渲染时才检查 `r.data.summary` 是 string,index.html:360),服务端宽松接受任意 JSON。这本身没错(宽容收,严格显),但意味着服务器**不知道任何元信息**(没有 updatedAt、没有 rev),后面做冲突合并时无据可依。
|
||||
|
||||
### 1.2 客户端数据流(关键结论加粗)
|
||||
|
||||
**localStorage 键清单(全量):**
|
||||
|
||||
| 键 | 写入者 | 内容 |
|
||||
|---|---|---|
|
||||
| `soundgame.auth` | 大厅 index.html:225 | `{username, token}`(**单桶,无多玩家隔离**) |
|
||||
| `soundgame.settings` | engine/settings.js:25 | `{voice, speed, bgm, guide}`(单桶) |
|
||||
| `soundgame.progress.rpg` / `.dungeon` / `.radio` | 各 game.js `_save()` | 完整存档(含 `schema`、`updatedAt`、`summary`) |
|
||||
| `soundgame.<game>.<杂项>` | rpg tutIntroSeen、escape STATS_KEY、storyline GUIDE_DONE_KEY 等 | 各游戏小标记 |
|
||||
|
||||
**游客态结论(直接回答调研问题 3 的后半):**
|
||||
|
||||
1. **游客完全能玩**。全部 8 款游戏不依赖 API:未登录时 `_save()` 只写 localStorage、跳过 fetch(rpg/game.js:1186-1196 是典型:本地必写,`auth` 不存在就 return)。公网静态站(`PUBLIC_STATIC`,index.html:371 检测 `https && *.xiangsilian.com`)就是这个模式,隐藏登录面板、纯本地。
|
||||
2. **进度不会因为断网丢**:localStorage 是主存,PUT 是 fire-and-forget(`.catch(() => {})` 静默)。断网/服务器挂丢失的只是「上传」,不是进度本身。
|
||||
3. **但换设备=清零**:游客进度绑死浏览器(localStorage 绑 origin+浏览器实例),且清浏览器数据即消失——这正是 Wordle 迁移 NYT 的教训(见 §2.1)。
|
||||
4. 局域网场景服务器挂了(WSL2 的 Node 进程死掉):**页面都打不开**——静态文件与 API 同进程。公网场景静态由 Nginx 托管,API 404,游戏照玩。
|
||||
|
||||
**登录后的「搬运」——审计结论:没有搬运。** index.html 登录成功只做两件事:`pullSettings()`(**服务器设置覆盖本地**,index.html:339)和 `loadProgress()`(拉 summary 渲染卡片,index.html:354)。本地已有进度**不会上传**到服务器。游戏加载路径的不一致放大了这个问题:
|
||||
|
||||
| 游戏 | 加载时读哪 | 跨设备续玩 |
|
||||
|---|---|---|
|
||||
| radio | **本地优先**;本地无档才 fetch 服务器(radio/game.js:317-342,1.5s 超时兜底开新档) | 半支持:仅新设备方向 |
|
||||
| rpg / dungeon | **只读 localStorage**(rpg/game.js:1199,dungeon/game.js:1994),从不拉服务器 | 不支持:新设备=新档 |
|
||||
| simon / rhythm / space / storyline | 无进度存档(会话制) | — |
|
||||
| escape | 本地 stats(次数/最佳时间) | 不支持 |
|
||||
|
||||
由此得出一个隐蔽的**数据分叉 bug**:设备 A 玩到第 3 章(本地+已上传),设备 B 登录同账号、本地有旧档第 2 章 → 设备 B 打开 radio/rpg 会**继续本地第 2 章**(本地优先),玩下去再 PUT,**把服务器上的第 3 章覆盖回第 2 章**。现状是 local-always-wins,连 last-write-wins 都不是。
|
||||
|
||||
**多玩家同设备(调研问题 4)——确认存在污染路径:**
|
||||
|
||||
`soundgame.auth`、`soundgame.progress.<game>`、`soundgame.settings` 全是单桶,不随账号切换。玩家 A 退出、玩家 B 登录(退出只清 auth 和 UI,**不清任何进度桶**,index.html:256-261)后:B 打开 rpg → `_loadSave()` 读到 A 的本地档 → 继续玩 A 的存档 → `_save()` 把它 PUT 到 **B 的账号**。A 的进度污染 B 的云端;反向亦然。设置同理(B 登录瞬间 `pullSettings` 用 B 的服务器设置覆盖本机,A 下次来发现设置变了)。
|
||||
|
||||
**save 触发时机**:rpg 在场景切换/商店/战斗结算等约 12 处调 `_save()`;radio 在进电话/抉择结算/结局(radio/game.js:678/785/891/1103)。**没有 beforeunload/pagehide 钩子,没有 sendBeacon**——关页面时最后一次事件存档之前的进度保留,之后的上传可能没发出去(本地仍有,下次玩会补传,因为下次 `_save` 会再 PUT 全量)。可接受,但有改进空间(§4.3)。
|
||||
|
||||
### 1.3 容量与写入竞态(调研问题 5)
|
||||
|
||||
- 单档实测规模:rpg/dungeon/radio 存档各约 0.5~1.5KB(`JSON.stringify(obj, null, 2)` 落盘约 3 倍)。8 游戏 + `_settings` 全开 ≈ **每用户 10~15KB**。100 用户 ≈ 1.5MB 的 JSON 文件集。**局域网家庭规模(<50 用户)完全无压力**,公网千级用户仍是 MB 量级——JSON 文件方案在这个项目体量下不是瓶颈。
|
||||
- 写入竞态:两人同时玩不同游戏 → 不同用户文件,队列各自独立,无冲突;同一用户两设备同时 PUT 不同游戏 → 同一文件,`enqueueWrite` 串行化,读-改-写不互相覆盖(server.js:190-197 注释明示)。**唯一的竞态窗口在业务层**:同一账号两设备同时玩**同一游戏**,后到的 PUT 整档覆盖先到的(无 updatedAt 可比较)——即 §1.2 末尾的分叉 bug 的服务端镜像。
|
||||
- 进程级:users/sessions 全量驻内存、写时落盘,崩溃最多丢队列尾部一条写;tmp+rename 保证文件系统层面原子。**单进程假设成立**(serve.sh 单实例 exec)。若未来起两个实例对着同一 data/ 目录,队列失效——公网化时要么保持单实例,要么加文件锁(§6-B6)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 业界调研
|
||||
|
||||
### 2.1 localStorage-only 的代价:Wordle 迁移 NYT
|
||||
|
||||
2022 年 Wordle 从 powerlanguage.co.uk 迁到 nytimes.com,统计数据全在 localStorage、绑定旧域名,大量玩家 streak 蒸发。NYT 的补救是旧域名放一个内联脚本把 localStorage 数据搬到新域;**最终解法是登录 NYT 账号、服务端存档**。社区工程教训:纯 localStorage 把用户数据绑死在「单一 origin + 单一浏览器实例」上,域名迁移、换设备、清缓存都会清零。
|
||||
|
||||
- [How Wordle kept your streak alive when it migrated to the NYT's website (dev.to)](https://dev.to/timothee/how-wordle-kept-your-streak-alive-when-it-migrated-to-the-nyts-website-170b)
|
||||
- [The New York Times Rushes To Fix Broken Wordle Streaks (Kotaku)](https://kotaku.com/wordle-new-york-times-streaks-broken-nyt-migration-dail-1848525236)
|
||||
- [Fix My Wordle! (Reddit r/wordle)](https://www.reddit.com/r/wordle/comments/sqjxmo/fix_my_wordle/)
|
||||
|
||||
对本项目的映射:SoundGame 的部署规划(局域网 IP → 公网域名 game.xiangsilian.com)本身就是一次「换 origin」,localStorage 进度**不会跟过来**。任何多设备方案的第一性结论:**本地为主存没问题,但必须在用户无感的情况下把数据副本尽早送到服务器**。
|
||||
|
||||
### 2.2 local-first / 离线优先的通用模式
|
||||
|
||||
- [Ink & Switch: Local-First Software](https://www.inkandswitch.com/essay/local-first/)——奠基文章:本地为主、网络是增强;七条理想(离线可用、多设备、 longevity 等)。
|
||||
- [Smashing Magazine: The Architecture of Local-First Web Development (2026-05)](https://www.smashingmagazine.com/2026/05/architecture-local-first-web-development/)——同步引擎、冲突解决、客户端迁移的工程全景。
|
||||
- [Evil Martians: Cool Front-End Arts of Local-First — Storage, Sync, Conflicts](https://evilmartians.com/chronicles/cool-front-end-arts-of-local-first-storage-sync-and-conflicts)——明确指出「**记录字段级 last-write-wins 在很多场景已经够用**」;另提醒 localStorage ~5MB 上限(本项目存档 KB 级,不构成约束)。
|
||||
- [Offline-First Architecture (Stackademic)](https://blog.stackademic.com/offline-first-architecture-5cb12314af75)——「软删除同步」:本地删除只有确认同步到服务器后才真正删。
|
||||
- [Reddit r/gamedev: How do JS game devs handle data storage?](https://www.reddit.com/r/gamedev/comments/1gj8h4g/how_do_js_game_devs_handle_data_storage/)——浏览器游戏社区实践:localStorage/IndexedDB + 服务器备份是主流。
|
||||
|
||||
SoundGame 现状其实已经「意外地」local-first:主存 localStorage、服务器是异步备份。缺的是三块:**匿名身份的服务器对应物**、**合并策略**、**失败重试**。
|
||||
|
||||
### 2.3 匿名设备账号(不强迫注册的关键)
|
||||
|
||||
业界标准做法(analytics/SDK 领域成熟多年):
|
||||
|
||||
- [Statsig: Anonymous ID](https://www.statsig.com/glossary/anonymous-id)——首次访问生成随机 ID 存 localStorage/cookie,注册前一直用它标识用户。
|
||||
- [Tell Docs: Users & Identity](https://docs.tell.rs/tracking/users-and-identity)——UUID 首次启动生成、之后随每个请求带上;**注册时与正式账号 merge**。
|
||||
- [Reddit r/webdev: unique device identifier](https://www.reddit.com/r/webdev/comments/t3on6x/send_unique_device_identifier_in_js_post_call_for/)——实现共识:客户端生成 UUID v4 → localStorage → 每个请求携带。
|
||||
|
||||
映射到本项目:**「设备 token 即档案」**——首次打开大厅静默 `POST /api/device`,服务器建一个匿名 profile 并发一个长效 token;注册只是「给当前档案命名 + 设 PIN」,登录是「在另一台设备上认领同名档案」。用户全程无感,永不被注册墙拦住。
|
||||
|
||||
### 2.4 冲突解决:LWW 够不够
|
||||
|
||||
- [PowerSync: Custom Conflict Resolution](https://docs.powersync.com/handling-writes/custom-conflict-resolution)——策略阶梯:整体 LWW → 行级 LWW → **字段级 LWW** → CRDT。
|
||||
- [OneUptime: How to Implement Last-Write-Wins](https://oneuptime.com/blog/post/2026-01-30-last-write-wins/view) 与 [ZigiOps: Conflict Resolution in Two-Way Synchronization](https://zigiwave.com/resources/conflict-resolution-bidirectional-sync)——LWW 语义与适用边界。
|
||||
- [DZone: LWW vs CRDTs](https://dzone.com/articles/conflict-resolution-using-last-write-wins-vs-crdts)、[Ditto: offline-first conflict resolution](https://www.ditto.com/blog/how-to-build-robust-offline-first-apps-a-technical-guide-to-conflict-resolution-with-crdts-and-ditto)、[HN: Downsides of Offline First](https://news.ycombinator.com/item?id=28717848)——共识:**LWW 本身就是最简单的 CRDT(LWW-Register)**;纯计数/追加型状态(货币、成就集合)用 LWW 会丢增量,需要 PN-counter 或并集合并。
|
||||
- [multi-leader 写冲突分析](https://programmingappliedai.substack.com/p/how-do-we-resolve-write-conflicts)——LWW 依赖时钟同步,工程上建议用服务器时间裁决而非客户端墙钟。
|
||||
|
||||
结论对本项目:**按游戏分桶 + 服务器时钟裁决的桶级 LWW 完全够用**。理由:单人单档案、8 款游戏互相独立(天然分桶)、家庭场景不存在真并发(一人一次玩一台设备)。唯一例外是 radio 的 `endings` 收集(4 结局达成集合)和 dungeon 的 `meta`(跨 run 的金币/遗产)——跨设备各玩一会可能丢一个结局记录,但代价是「再打一遍那个结局」,家庭玩具可接受;若要修,服务器端对这两个已知字段做**并集合并**即可(十行代码,见 §3.4),不必上 CRDT。
|
||||
|
||||
### 2.5 token 过期策略(家庭 vs 公网)
|
||||
|
||||
- [Auth0: Configure Refresh Token Expiration](https://auth0.com/docs/secure/tokens/refresh-tokens/configure-refresh-token-expiration)、[Duende: Refreshing a Token](https://docs.duendesoftware.com/identityserver/tokens/refresh/)、[CIAM Compass: Token Lifetime Best Practices](https://guptadeepak.com/ciam-compass/guides/token-lifetime-best-practices/)、[LoginRadius: Refresh Token Rotation](https://loginradius.com/blog/identity/secure-refresh-token-rotation)——共识方案是**滑动续期 + 绝对上限**的混合:活跃就续(无感),封顶强制重登(兜底)。微软 14 天滑动/90 天绝对、Atlassian 90 天不活跃即过期是常见刻度。
|
||||
|
||||
对本项目的取舍:局域网家庭场景**长效 token(如 180 天滑动、无绝对上限)是正确选择**——「无感」就是产品需求,爷爷奶奶不该被重新登录困扰;泄露面本来就限于家庭 WiFi。公网阶段收紧为 30~90 天滑动 + 180 天绝对上限,并加 logout 端点(现状连 logout 都没有,token 只进不出)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 目标数据模型
|
||||
|
||||
### 3.1 设计原则
|
||||
|
||||
1. **游客优先**:设备 token 即档案,匿名档案与命名档案同构(数据层不区分,只有 profile 有没有 `name`/`pinHash` 的区别)。
|
||||
2. **本地为主存**:游戏代码继续「先写 localStorage 再异步上传」的现行模式(改动最小),服务器是带版本意识的备份。
|
||||
3. **服务器掌握时间真相**:存档包装 `{data, updatedAt}`,updatedAt 由服务器写入时打点,客户端上传时带 `baseUpdatedAt` 供 LWW 裁决——不信任客户端墙钟。
|
||||
4. **按游戏分桶**:桶级 LWW,游戏间互不影响(一人玩 rpg 另一台设备玩 dungeon 不冲突)。
|
||||
5. **迁移路径平滑**:`users.json`/`<username>.json` 的现有数据可脚本迁移;API 旧端点保留兼容期。
|
||||
|
||||
### 3.2 服务器文件结构
|
||||
|
||||
```
|
||||
web/server/data/
|
||||
├── profiles.json # 档案索引(小,常驻内存)
|
||||
├── tokens.json # 设备 token → profileId(替代 sessions.json,带过期)
|
||||
└── profiles/
|
||||
├── p_8f3a….json # 一档案一文件(含匿名档案)
|
||||
└── p_c21d….json
|
||||
```
|
||||
|
||||
**profiles.json**(索引,取代 users.json):
|
||||
|
||||
```json
|
||||
{
|
||||
"p_8f3a1c": {
|
||||
"name": "爸爸",
|
||||
"pinHash": "scrypt$16384$8$1$<salt-hex>$<hash-hex>",
|
||||
"createdAt": "2026-09-09T12:00:00Z",
|
||||
"wasAnonymous": true,
|
||||
"devices": 2
|
||||
},
|
||||
"p_c21d90": {
|
||||
"name": null,
|
||||
"pinHash": null,
|
||||
"createdAt": "2026-09-09T13:20:00Z",
|
||||
"wasAnonymous": true,
|
||||
"devices": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
PIN 用 Node 内置 `crypto.scrypt`(零依赖红线内)加盐哈希;`name: null` 即游客档案——同一结构,无需两套逻辑。
|
||||
|
||||
**tokens.json**(取代 sessions.json;token 仍是 24 字节随机 hex,客户端拿它当 Bearer):
|
||||
|
||||
```json
|
||||
{
|
||||
"a1b2…": {
|
||||
"profileId": "p_8f3a1c",
|
||||
"createdAt": "2026-09-09T12:00:00Z",
|
||||
"lastSeen": "2026-09-09T20:11:00Z",
|
||||
"expiresAt": "2027-03-08T20:11:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
滑动续期:每次通过 token 鉴权时刷新 `lastSeen` 与 `expiresAt`(now + 180 天);定期清扫过期项(解决现状无限增长)。
|
||||
|
||||
**profiles/p_xxx.json**(一档案一文件,取代 `<username>.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "p_8f3a1c",
|
||||
"name": "爸爸",
|
||||
"games": {
|
||||
"rpg": { "updatedAt": "2026-09-09T20:03:11Z", "rev": 41, "data": { "schema": 1, "summary": "第二章·黑风寨 · Lv7 · 金币120", "…": "…" } },
|
||||
"radio": { "updatedAt": "2026-09-08T22:47:02Z", "rev": 12, "data": { "schema": 1, "summary": "第3通·失恋来电 · 口碑+2 · 结局2/4", "…": "…" } },
|
||||
"dungeon": { "updatedAt": "2026-09-07T21:15:40Z", "rev": 7, "data": null },
|
||||
"_settings": { "updatedAt": "2026-09-09T19:58:00Z", "rev": 3, "data": { "voice": "male", "speed": 1.7, "bgm": 0.3, "guide": "standard" } }
|
||||
},
|
||||
"updatedAt": "2026-09-09T20:03:11Z"
|
||||
}
|
||||
```
|
||||
|
||||
- `data` 原样透传(保持现状「宽容收、严格显」),`summary` 仍是客户端约定不强制;
|
||||
- `rev` 单调递增,是 updatedAt 的并列保险(同毫秒写冲突时用 rev 破平局);
|
||||
- `_settings` 继续作为保留键占一个桶(迁移成本最低),不必单独开 settings 文件。
|
||||
|
||||
### 3.3 API 变更(增量,旧端点保留)
|
||||
|
||||
| 端点 | 作用 | 说明 |
|
||||
|---|---|---|
|
||||
| `POST /api/device` | **匿名建档**(新) | 无需认证;建 `name:null` 档案 + 发长效 token。幂等由客户端「有 token 就不重复建」保证 |
|
||||
| `POST /api/claim` `{username, pin}` | **升级/登录**(新,取代直接 register+login) | 携带当前设备 token。目标用户名不存在 → 给当前档案命名+设 PIN(原地升级,进度天然保留);存在 → 验 PIN 后把当前(匿名)档案的进度**按桶 LWW 合并**进目标档案,token 改绑目标档案。这正是 [Tell/Statsig 的 anonymous→user merge 模式](https://docs.tell.rs/tracking/users-and-identity) |
|
||||
| `PUT /api/progress` `{game, data, baseUpdatedAt}` | 写进度(改) | 服务器比对 `baseUpdatedAt` 与存储值:客户端落后 → 409 返回服务器档案(客户端择优重试);领先/相等 → 写入并打服务器时间。老客户端不带 baseUpdatedAt 视为「无条件写」,兼容 |
|
||||
| `GET /api/progress` | 读进度(改) | 不带 `game` 参数时返回**全部桶**(现状大厅串行发 8 个 GET,index.html:357-364,改一发) |
|
||||
| `POST /api/logout` | 注销 token(新) | 服务器侧删 token(现状只清客户端) |
|
||||
| `DELETE /api/profile` | 删除账号(新,可延后到公网前) | roadmap §5.5 的合规要求 |
|
||||
|
||||
### 3.4 合并规则(claim 与 409 重试共用)
|
||||
|
||||
```
|
||||
对每个 game 桶:
|
||||
仅一边有数据 → 取有的一边
|
||||
两边都有 → updatedAt 新者胜(服务器时间戳,同值比 rev)
|
||||
radio.endings / dungeon.meta 两个已知收集型字段 → 对象按 key 并集(值取新)
|
||||
_settings → 字段级合并没有意义,整桶 LWW 即可
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 冲突与离线策略
|
||||
|
||||
### 4.1 写路径(游戏运行中)
|
||||
|
||||
```
|
||||
_save()
|
||||
├─ 1. localStorage['soundgame.p.<profileId>.<game>'] = data ← 主存,同步、必达
|
||||
└─ 2. 入队上传 {game, data, baseUpdatedAt: 上次已知服务器 updatedAt}
|
||||
├─ 成功 → 记录新 updatedAt
|
||||
├─ 409 → 拉回服务器档案,与本地面包比对:
|
||||
│ 服务器新(别处玩过更久)→ 仅当本地自上次同步后有新进度才回写,
|
||||
│ 否则采纳服务器(现状 radio 的 _askContinue 弹「继续/重来」已是好先例)
|
||||
└─ 网络失败 → 留在队列(见 4.3),下次再试
|
||||
```
|
||||
|
||||
### 4.2 读路径(进游戏时)
|
||||
|
||||
radio 现状(radio/game.js:317-342)已接近正确形态,推广为全部有档游戏的统一行为:
|
||||
|
||||
1. 本地有档 → 直接用(离线零等待);
|
||||
2. 本地无档(新设备/清过缓存)→ fetch 服务器(带超时兜底),有则用;
|
||||
3. 两边都有 → 桶级 LWW。这修掉 §1.2 的分叉 bug(现状 local-always-wins 会把云端的第 3 章覆盖回第 2 章)。
|
||||
|
||||
### 4.3 断网/换 WiFi 与服务器宕机
|
||||
|
||||
- **WiFi 切换瞬间**:页面已加载、音频资产已在内存,进度写 localStorage 不受影响;在途 PUT 失败进重试队列。重试触发点:`online` 事件、`visibilitychange→visible`、下次 `_save()`、下次启动。
|
||||
- **重试队列**:`localStorage['soundgame.p.<profileId>.queue']`,存未确认上传(每桶只留最新一条即可——存档是全量快照,旧上传无意义,**队列天然去重为每桶 1 条,8 桶封顶,无膨胀风险**)。
|
||||
- **关页面丢尾**:`pagehide` 时对脏桶用 `navigator.sendBeacon('/api/progress', …)` 补一发(beacon 不保证送达,送达则赚);不做 beforeunload 同步存档(游戏事件存档点已够密)。
|
||||
- **服务器挂**:局域网模式页面打不开是现状(静态+API 同进程),不改——家庭场景重启 serve.sh 即可;公网模式 Nginx 静态照常,游戏照玩,队列积压等恢复。
|
||||
- **不引入 Service Worker/离线 PWA**:音频资产几百 MB,缓存不现实;「离线」在本项目里只需要覆盖「进度不丢」,localStorage 已做到。这是明确的不做项。
|
||||
|
||||
### 4.4 多玩家同设备
|
||||
|
||||
- localStorage 一切桶键加 profileId 段:`soundgame.p.<profileId>.<game>`、settings 同理挂桶(`_settings` 走服务器桶,本地镜像同名)。匿名档案也编 profileId → **游客多人也隔离**(现在游客连「换人」入口都没有,两人共用一台手机就是一锅粥)。
|
||||
- 切换玩家 = 换 token(登录别人的账号,或大厅加「切换玩家」列出本机已知档案):切换前 flush 队列;旧档案的桶保留在本机(下次切回还在),不删除。
|
||||
- 大厅提供「本机玩家」列表(localStorage 存 `soundgame.known.profiles` = [{profileId, name, token}],点击即切换)——这对「爷爷奶奶不用记 PIN」是刚需:第一次帮他们建档后,之后一键切换。
|
||||
|
||||
---
|
||||
|
||||
## 5. 文件级改动清单
|
||||
|
||||
按依赖顺序(不改代码,仅列改动点与规模):
|
||||
|
||||
| # | 文件 | 改动 | 规模 |
|
||||
|---|---|---|---|
|
||||
| 1 | `web/engine/progress.js`(**新建**,核心) | 统一存档模块:`save(game, data)` / `load(game)` / 桶级 LWW / 重试队列 / sendBeacon / profileId 命名空间。收编 rpg/dungeon/radio 三处重复的 `_save/_loadSave` 内联实现(三份代码几乎相同,radio/game.js:1336-1385、rpg/game.js:1165-1207、dungeon/game.js:1961-2001) | ~150 行 |
|
||||
| 2 | `web/server/server.js` | §3.2/3.3:profiles/tokens 新结构、`/api/device`、`/api/claim`、PUT baseUpdatedAt 守卫、GET 全桶、logout、scrypt 哈希、滑动过期+清扫。写队列/原子写框架原样保留 | +150 行左右 |
|
||||
| 3 | `web/index.html` | 启动即 `POST /api/device`(有 token 则跳过);登录/注册改走 claim(本地游客进度自动合并上云——补上现状缺失的「搬运」);`loadProgress` 改单次 GET 全桶;「本机玩家」切换 UI;`PUBLIC_STATIC` 硬编码域名检测改为「探测 /api/ 可达性」 | ~80 行改动 |
|
||||
| 4 | `web/rpg/game.js`、`web/dungeon/game.js`、`web/radio/game.js` | `_save/_load` 内联块替换为 `engine/progress.js` 调用;rpg/dungeon 的 `start()` 补服务器拉档(照抄 radio 的本地优先+超时兜底模式) | 各 ~20 行净减 |
|
||||
| 5 | `web/engine/settings.js` | 持久化键加 profileId 段(或改为经 progress.js 的 `_settings` 桶读写,跨设备同步顺手获得) | ~15 行 |
|
||||
| 6 | `web/radio/game.js`、`web/dungeon/game.js`(可选) | endings/meta 服务器端并集合并的配套字段声明 | ~10 行 |
|
||||
| 7 | `web/server/data/` 一次性迁移脚本(可放 `tools/`) | users.json → profiles.json + profiles/(老用户名档案保名保 PIN 重哈希);sessions.json → tokens.json;`<username>.json` → profiles/<id>.json 各桶补 updatedAt/rev | ~60 行 |
|
||||
| 8 | `web/server/README.md`、`HANDOVER.md §2.2`、`docs/deploy.md` | 同步新 API 与安全声明(含修正「summary 服务端校验」的文档出入) | 文档 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 公网化分层清单
|
||||
|
||||
### A. 现在就做(局域网阶段就受益,合计约 2~3 天)
|
||||
|
||||
1. **匿名设备档案 + claim 升级**(§3.3)——「不强迫注册」的核心;登录时游客进度自动上云。
|
||||
2. **localStorage 按 profileId 分桶**(§4.4)——修多人同设备污染,现状是实打实的数据串号风险。
|
||||
3. **PUT baseUpdatedAt LWW 守卫 + 读路径桶级 LWW**(§4.1/4.2)——修「本地旧档覆盖云端新档」的分叉 bug。
|
||||
4. **统一存档模块 + 失败重试队列**(§5-1)——三份重复实现收编,断网补传。
|
||||
5. **PIN scrypt 哈希**——`crypto.scrypt` 内置、十行级;明文 PIN 在任何阶段都没有存在的理由(含设备被盗场景)。
|
||||
|
||||
### B. 公网前再做(决定开放 API 那天启动,对齐 deploy.md「以后若要开放用户系统」与 roadmap §5.5)
|
||||
|
||||
1. **HTTPS 强制 + server.js 只监听 127.0.0.1 + Nginx 反代 `/api/`**(deploy.md 已写明套路,照做)。
|
||||
2. **limit_req + 登录失败退避**——4~6 位 PIN 仅约 20 bit 熵,公网无速率限制等于没设防。
|
||||
3. **token 收紧**:滑动 30~90 天 + 绝对上限 180 天 + logout/吊销端点;sessions 定期清扫。
|
||||
4. **数据备份**:cron 对 `data/` 做 `tar` 快照保留 N 代(或 rclone 到对象存储);原子写只防崩溃不防磁盘/误删。
|
||||
5. **隐私政策页 + 账号删除入口**(roadmap §5.5 已列:收集用户名+进度,不收集设备信息/位置)。
|
||||
6. **请求体上限从 1MB 收到 32KB**、单档案 games 桶数白名单(实际 8+1 个,防滥用把服务器当免费网盘)。
|
||||
7. **单实例约束写进 README** 或加进程级文件锁——两个 server.js 实例共享 data/ 会绕过写队列。
|
||||
8. **基础监控**:data/ 目录体积、token 数、PUT 409 率的日级日志(零依赖下 console + logrotate 即可起步)。
|
||||
|
||||
### C. 明确不做(记下来防止过度设计)
|
||||
|
||||
- CRDT/操作变换/同步引擎(RxDB、PowerSync、ElectricSQL 等)——单人单档家庭场景,桶级 LWW 足够,引入依赖违背零依赖红线;
|
||||
- Service Worker 离线 PWA——音频资产体积决定了离线只剩「进度」需求,localStorage 已覆盖;
|
||||
- IndexedDB——存档 KB 级、无二进制、键值模型够用;
|
||||
- 字段级 LWW——只有 `_settings` 有多字段,整桶换无感知差异。
|
||||
|
||||
---
|
||||
|
||||
## 7. 一句话总结
|
||||
|
||||
现状是一套「意外 local-first」的架构:本地为主存方向正确、原子写队列质量不错,但缺匿名身份的服务器对应物、缺版本意识(无 updatedAt 裁决 → 本地旧档能覆盖云端新档)、缺按玩家隔离(同设备多人串号)、缺失败重试(fire-and-forget)。目标模型「**设备 token 即档案,按游戏分桶,服务器时钟裁决的桶级 LWW**」在零依赖红线内、约 2~3 天改造量即可把「多玩家不注册 + 跨设备不丢档」做实,且为公网化预留了平滑升级路径(claim 合并 + token 收紧 + 反代/限流/备份)。
|
||||
Reference in New Issue
Block a user