# 02 · PC 键盘操作与双端一致体验(Input Parity)调研 > 2026-09-09 · 交互调研 · 范围:PC 浏览器键盘操作方案、引擎适配、提示(画面+语音)随平台适配、混合设备、测试管线影响。 > 红线遵守:本文只做调研与设计,不改任何代码。所有数字均从仓库实测/统计得出,来源随文标注。 ## 0. 结论速览 | 决策点 | 推荐 | 一句话理由 | |---|---|---| | 键位方案 | 方向键=四向滑动、**空格=单击、回车=双击**、按住空格=长按(WASD 作方向别名) | 与 A Blind Legend / audiogames.net 社区惯例完全同构,双击走专用键避免把 300ms 延迟带进键盘 | | 语音路线 | **路线 a:双套资产**(`voice//key/` 变体目录,复用 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//.{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/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` 服务器同步机制零改动自动带上。 - 大厅设置面板加一个 `