feat(platform/P0): 无感用户系统 + PC 键盘双端一致(两 agent 并行 + 集成收口)

用户系统(B,游戏零改动):
- POST /api/device 首访 0 交互静默建档(自动玩家一/二…,per-IP 频控);断网降级纯本地照玩
- 大厅档案切换器(切换=flush 上云→快照回灌→倒灌→服务器回拉,profileId 分桶修同设备串档;aria menu 编号可听)
- claim 可选认领(命名+scrypt PIN);旧 users/sessions 单向迁移(PIN 明文即刻哈希,已真实数据验证);login 兼容+claim-merge 桶级 LWW;logout;/server/ 静态封禁
- PUT baseUpdatedAt 守卫(legacy 放行+警告);GET 无参全桶回拉(修 7/8 游戏跨设备只 PUT 不 GET)
- web/profiles.js 分桶纯函数 + api-smoke.mjs 7/7(建档/LWW/claim-merge/迁移/频控/封禁/分桶 H1-H4)

键盘双端(A,游戏代码零改动):
- gesture.js 键盘通道 +137 行:←↑↓→/WASD=滑动、空格=单击(走 300ms 双击窗与触摸共享虚拟指针)、回车=即时 doubletap、按住空格 ≥450ms=长按;repeat 抑制/表单守卫/方向键 preventDefault
- settings.input(auto/touch/key)+ gesture.inputMode();遮罩键盘解锁与文案自适应(按空格开始/轻触开始)
- 8 游戏遮罩改用自适应默认文案;大厅设置面板「操作方式」+页脚键位提示
- live control.mjs 键盘注入通道(CDP dispatchKeyEvent);engine-input.test 61 断言

回归:12 套件全绿(8 playtest + guide + engine-voice + engine-input + api-smoke 7/7);8001 服务器已重启加载新 API,/api/device 端到端验证通过

P1 待排:153 句×双性 TTS 键盘语音变体 / 6 位配对码跨设备 / 认领 UI 打磨(research/platform/00-synthesis.md §3)
This commit is contained in:
xsl
2026-09-09 23:39:23 +08:00
parent 3704cd0792
commit ede522dda1
22 changed files with 2370 additions and 275 deletions
+62 -4
View File
@@ -12,7 +12,7 @@ web/
├── index.html # 游戏大厅(深色风格,5 个游戏入口 + 用户/设置区)
├── serve.sh # 本地托管脚本(node web/server/server.js)
├── engine/ # 共享引擎 —— 所有游戏复用,勿修改
│ ├── gesture.js # 6 手势识别(触摸+鼠标,内置基础震动反馈)
│ ├── gesture.js # 6 手势识别(触摸+鼠标+键盘三通道,内置基础震动反馈)
│ ├── audio.js # Web Audio 三层音频引擎 + 启动遮罩(自动应用用户设置)
│ ├── settings.js # 全局用户设置(voice/speed/bgm/guide,localStorage 持久化)
│ ├── haptics.js # navigator.vibrate 震动封装(预定义震型 + iOS 视觉降级)
@@ -122,8 +122,11 @@ new WebAudioEngine(basePath = 'audio/', opts?)
- `unlock(): Promise<void>` — 创建/恢复 AudioContext。已在用户手势中时可用;
一般用 `showStartOverlay()` 代替。
- `showStartOverlay({ cover, text = '轻触开始' }): Promise<void>` —
启动遮罩,resolve 时音频已解锁。
- `showStartOverlay({ cover, text }): Promise<void>` — 启动遮罩,resolve 时
音频已解锁。解锁手势:触摸 / 鼠标 click / **键盘空格或回车 keydown**
(keydown 同属 user activation,回调内同步发起 `resume()` 即可解锁——
PC 键盘玩家无需伸手摸鼠标)。`text` 缺省按 `gesture.inputMode()` 自适应
(key →「按空格开始」,touch →「轻触开始」);显式传 `text` 完全覆盖。
- `play(id, opts?)` — 统一入口,按 id 前缀(`bgm/` `voice/` `sfx/`)分发:
- 公共:`opts.volume`(0~1,乘层音量)
- BGM:`opts.fadeMs`、`opts.loop = true`;同一首重复 `play` 不重启
@@ -238,11 +241,16 @@ guide.setEventHook(fn); // 转写 harness 用:fn({type,name,vo
```js
import { settings } from '../engine/settings.js';
settings.get(); // -> { voice: 'male'|'female', speed: 1|1.35|1.7|2.2, bgm: 1|0.3|0, guide: 'full'|'standard'|'off' }
settings.get(); // -> { voice, speed, bgm, guide, input }
settings.set({ speed: 1.35 }); // 部分更新;非法键/值被忽略(console.warn)
const off = settings.subscribe((snapshot, changed) => { ... }); // off() 退订
```
- 五项设置:`voice`(`'male'|'female'`)、`speed`(`1|1.35|1.7|2.2`)、
`bgm`(`1|0.3|0`)、`guide`(`'full'|'standard'|'off'`)、
`input`(`'auto'|'touch'|'key'`,P0-A 新增——提示文案输入模式,
`gesture.inputMode()` 读取;只影响提示,不关闭任何输入通道)。
- 非法值永远不会进入存储——读入时逐项校验,坏数据自动回退默认值。
- 游戏侧一般**不需要**直接用本模块:audio.js 已自动应用三项设置。
需要自定义响应(如设置页 UI)时才 `subscribe`。
@@ -338,6 +346,56 @@ const detach = attachGestures(element, onGesture, config?)
`onGesture` 签名因此扩为:`'up'|'down'|'left'|'right'|'tap'|'doubletap'|
'longpress'|'longpressend'|'tapcancel'`。
### 键盘通道(PC 平行输入,P0-A)
`attachGestures` 内置第三条输入通道(与 touch/mouse 并列,**喂同一状态机**,
游戏层收到的手势事件与震动反馈完全同构——游戏代码零改动)。设计依据与
业界惯例见 `research/platform/02-input-parity.md`。
| 手势(触屏语义) | 键盘主键 | 别名 | 行为 |
|---|---|---|---|
| 左/右滑 | ← / → | A / D | keydown 即发,零延迟(无位移/速度判定) |
| 上/下滑 | ↑ / ↓ | W / S | 同上 |
| 单击 | 空格 | — | 走完整 tap 状态机:抬起判 tap,**保留 ~300ms 双击窗**(与触屏同感) |
| 双击 | **回车** | 空格×2 | 回车**即时**触发(不等双击窗,确认类零延迟);空格连按两下走状态机同样判出 |
| 长按 | 按住空格 ≥450ms | — | `longpress`,松开 `longpressend`(旁白快进 window 事件照发) |
行为要点(`web/harness/engine-input.test.mjs` 全量断言,61 条):
- **空格 = 虚拟指针**:keydown 落元素中心进 `handleDown`,keyup 收尾——
tap/双击窗/长按定时器全部复用触摸路径;双击窗与触摸**共享**(混合设备
"触屏点一下 + 空格点一下"也判 doubletap,语义 = 同一虚拟指针)。
- **回车即时双击**:直接走 doubletap 分支(含 confirm 震动),同时作废
等待中的单击(与触屏双击的收尾语义一致)。
- **immediateTap 游戏(rhythm)零改动**:空格按下即发 tap(kick 演奏)、
长按补 `tapcancel`、双击改判——键盘路径逐条对齐触摸路径。
- **守卫**:`e.repeat` 抑制(按住 ↑ 只发一次 up;映射键 repeat 仍
preventDefault 防连滚);映射键 keydown 一律 `preventDefault`(防方向键/
空格滚页、回车激活焦点元素);焦点在 `INPUT/TEXTAREA/SELECT/
contenteditable` 时放行不吞键(大厅打字正常);未映射键不拦截(Tab 等
无障碍键放行);`tracking` 单飞行指针互斥(空格按住期间方向键/触摸互斥,
与多指忽略语义一致)。
- **无 Esc 映射**(裁定:下滑已覆盖菜单语义,Esc 留给浏览器/未来全屏)。
- 监听挂 `window`(游戏页焦点在 body,冒泡必达),`detach()` 时移除;
键盘监听在无 `window` 的 mock 环境(playtest/转写 harness)自动跳过,
对既有触摸路径零影响。
**提示随输入模式(`settings.input` + `inputMode()`)**:
```js
import { inputMode } from '../engine/gesture.js';
inputMode(); // 'touch' | 'key' —— 当前提示应使用的输入模式
```
- `settings.input`(`'auto'` 默认 / `'touch'` / `'key'`,settings.js VALID
枚举):手动覆盖优先;`auto` = 跟随最近使用的通道(映射键 keydown →
`key`,任意 touchstart → `touch`),会话内尚无输入时按能力检测
(`maxTouchPoints > 0 && (pointer: coarse)` → 触屏)。
- **只影响提示文案,不关闭任何输入通道**——touch/mouse/key 三通道永远
并行在线(混合设备友好)。当前接入点:启动遮罩默认文字
(`showStartOverlay` 不传 `text` 时按 `inputMode()` 选「轻触开始」/
「按空格开始」);游戏内提示文案由各游戏 HTML 后续接入。
## engine/game.js — `Game` / `StateMachineGame`
```js