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:
+23
-6
@@ -20,8 +20,8 @@
|
||||
* **自动播放限制**:iOS / Chrome 要求 AudioContext 必须在用户手势中 resume。
|
||||
* 标准流程(必须遵守,否则无声):
|
||||
* const audio = new WebAudioEngine('audio/');
|
||||
* await audio.showStartOverlay({ cover: 'cover.png' }); // 遮罩:封面 + "轻触开始"
|
||||
* // 此处 AudioContext 已解锁
|
||||
* await audio.showStartOverlay({ cover: 'cover.png' }); // 遮罩:封面 + 提示文字
|
||||
* // 此处 AudioContext 已解锁(触摸/鼠标点击或空格/回车 keydown 均可解锁)
|
||||
* game.start();
|
||||
*
|
||||
* 与 Android 的一个差异:Web 端 sfx 是按需解码并缓存 AudioBuffer,解码完成后
|
||||
@@ -51,7 +51,7 @@
|
||||
*/
|
||||
|
||||
import { settings } from './settings.js';
|
||||
import { setLongpressBoostEventsEnabled } from './gesture.js';
|
||||
import { setLongpressBoostEventsEnabled, inputMode } from './gesture.js';
|
||||
import { SoundTouch, SimpleFilter, WebAudioBufferSource, getWebAudioNode } from './vendor/soundtouch.js';
|
||||
|
||||
const EXT_FALLBACKS = ['.ogg', '.m4a', '.mp3', '.wav'];
|
||||
@@ -152,14 +152,18 @@ export class WebAudioEngine {
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动遮罩层:全屏封面 + "轻触开始"。第一次点按时解锁音频并移除遮罩。
|
||||
* 启动遮罩层:全屏封面 + 提示文字。第一次点按/按键时解锁音频并移除遮罩。
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.cover] 封面图地址(如 'cover.png'),缺省纯黑背景
|
||||
* @param {string} [opts.text='轻触开始']
|
||||
* @param {string} [opts.text] 提示文字;缺省按输入模式自适应——
|
||||
* gesture.inputMode()==='key' →「按空格开始」,否则「轻触开始」
|
||||
* (settings.input / 最近使用通道决定,见 gesture.js)。
|
||||
* 显式传 text 的调用完全覆盖(各游戏页现状)。
|
||||
* @returns {Promise<void>} resolve 时音频已解锁,可安全 start()
|
||||
*/
|
||||
showStartOverlay(opts = {}) {
|
||||
const { cover, text = '轻触开始' } = opts;
|
||||
const { cover } = opts;
|
||||
const text = opts.text ?? (inputMode() === 'key' ? '按空格开始' : '轻触开始');
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'start-overlay';
|
||||
@@ -180,6 +184,7 @@ export class WebAudioEngine {
|
||||
const onTap = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (hasWin) window.removeEventListener('keydown', onKey);
|
||||
this.unlock().then(() => {
|
||||
overlay.remove();
|
||||
resolve();
|
||||
@@ -195,6 +200,18 @@ export class WebAudioEngine {
|
||||
overlay.addEventListener('touchend', (e) => { e.preventDefault(); onTap(); }, { passive: false });
|
||||
// 桌面鼠标 / 辅助技术触发的 click 不受影响,照常解锁
|
||||
overlay.addEventListener('click', onTap);
|
||||
// 键盘解锁(PC 平行通道):空格/回车 keydown 同属 user activation,回调
|
||||
// 内同步发起 unlock() 即可满足自动播放限制。挂 window 而非遮罩(游戏页
|
||||
// 焦点在 body,keydown 不落在遮罩元素上),解锁后移除。此时 gesture.js
|
||||
// 尚未 attach(遮罩先于 attachGestures),键盘解锁必须在这里做。
|
||||
const hasWin = typeof window !== 'undefined' && typeof window.addEventListener === 'function';
|
||||
const onKey = (e) => {
|
||||
if (e.repeat) return;
|
||||
if (e.code !== 'Enter' && e.code !== 'Space') return;
|
||||
e.preventDefault();
|
||||
onTap();
|
||||
};
|
||||
if (hasWin) window.addEventListener('keydown', onKey);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+136
-1
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* gesture.js — 6 手势识别(触摸 + 鼠标双支持),对齐 Android GestureEngine 行为。
|
||||
* gesture.js — 6 手势识别(触摸 + 鼠标 + 键盘三通道),对齐 Android GestureEngine 行为。
|
||||
*
|
||||
* 手势集合:'up' | 'down' | 'left' | 'right' | 'tap' | 'doubletap'
|
||||
* | 'longpress' | 'longpressend'
|
||||
@@ -42,9 +42,25 @@
|
||||
* 注意:绑定点按的元素的 touchstart/touchmove/touchend 会被 preventDefault
|
||||
* (阻止页面滚动、双指缩放与系统合成鼠标事件),元素需应用
|
||||
* style.css 里的全屏样式(touch-action: none)。
|
||||
*
|
||||
* 键盘通道(PC 平行输入,research/platform/02-input-parity.md §2/§3):
|
||||
* - ←↑↓→ 与 WASD = 四向滑动(keydown 即发,无位移/速度判定,零延迟);
|
||||
* - 空格 = 单击:作为虚拟指针喂入**同一状态机**——抬起判 tap(保留 ~300ms
|
||||
* 双击窗延迟,与触屏单击同感;双击窗与触摸共享,混合设备"触屏点一下 +
|
||||
* 空格点一下"也判 doubletap,语义 = 同一虚拟指针);
|
||||
* - 回车 = 双击专用键:即时触发 doubletap,不等双击窗(同时作废等待中的
|
||||
* 单击),确认类操作零延迟;
|
||||
* - 按住空格 ≥450ms = longpress,松开 = longpressend(旁白快进 window
|
||||
* 事件照发,与触摸长按完全一致)。
|
||||
* 事件与内置震动与 touch/mouse 完全同构——游戏代码零改动。守卫:
|
||||
* e.repeat 抑制(按住 ↑ 只发一次 up)、映射键 preventDefault(防滚动/
|
||||
* 焦点激活)、表单控件焦点放行(input/textarea/select/contenteditable)、
|
||||
* tracking 单飞行指针互斥(与多指忽略语义一致)、未映射键不拦截(Tab
|
||||
* 等无障碍键放行)。提示文案随输入模式见 inputMode()(settings.input)。
|
||||
*/
|
||||
|
||||
import { vibrate } from './haptics.js';
|
||||
import { settings } from './settings.js';
|
||||
|
||||
export const DEFAULT_GESTURE_CONFIG = {
|
||||
swipeMinDistance: 64, // px,累积位移阈值(≈ Android 64dp)
|
||||
@@ -65,10 +81,17 @@ export const DEFAULT_GESTURE_CONFIG = {
|
||||
* recentlyTouched() 取两者较大值,把解锁 tap 也纳入 700ms 守卫。
|
||||
*/
|
||||
let lastGlobalTouchEnd = 0;
|
||||
// 输入模式跟随(inputMode()):最近一次键盘/触摸使用时刻,任一通道有动作
|
||||
// 即切提示。模块级共享(与 lastGlobalTouchEnd 同理:遮罩等处的输入不经过
|
||||
// 任何手势实例)。
|
||||
let lastKeyInputAt = 0;
|
||||
let lastTouchInputAt = 0;
|
||||
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
|
||||
const markGlobalTouchEnd = () => { lastGlobalTouchEnd = performance.now(); };
|
||||
document.addEventListener('touchend', markGlobalTouchEnd, true);
|
||||
document.addEventListener('touchcancel', markGlobalTouchEnd, true);
|
||||
// 输入模式跟随:任何触摸开始即认为触屏在用(提示文案切触摸版)
|
||||
document.addEventListener('touchstart', () => { lastTouchInputAt = performance.now(); }, true);
|
||||
}
|
||||
|
||||
/** 长按引擎事件:audio.js 监听它实现"长按=旁白临时倍速"。 */
|
||||
@@ -94,6 +117,44 @@ function _dispatchLongpress(type) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 键盘通道常量与输入模式 ----
|
||||
|
||||
/** e.code → 手势方向(WASD 为方向键别名,单手党惯例,默认开启)。 */
|
||||
const KEY_DIR = {
|
||||
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
|
||||
KeyW: 'up', KeyS: 'down', KeyA: 'left', KeyD: 'right',
|
||||
};
|
||||
const TAP_KEY_CODE = 'Space'; // 空格 = 单击(虚拟指针,走完整状态机)
|
||||
const DOUBLE_KEY_CODE = 'Enter'; // 回车 = 双击专用键(即时,不等 300ms 窗)
|
||||
|
||||
/** 初始能力检测:触屏手机 true;纯桌面 false;触屏笔记本(主指针为鼠标)
|
||||
* false(初始按键鼠提示,运行时跟随会纠正,见 inputMode)。 */
|
||||
function isTouchLikely() {
|
||||
try {
|
||||
if (typeof navigator === 'undefined' || !(navigator.maxTouchPoints > 0)) return false;
|
||||
if (typeof matchMedia !== 'function') return true; // 无 matchMedia 环境:有触点即按触屏处理
|
||||
return matchMedia('(pointer: coarse)').matches;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前提示输入模式:'touch' | 'key'。
|
||||
* settings.input 手动覆盖优先('touch'/'key');默认 'auto' = 跟随最近使用
|
||||
* 的通道(映射键 keydown → key,任意 touchstart → touch),会话内尚无输入时
|
||||
* 按 isTouchLikely() 初始判定。
|
||||
* **只影响提示文案**(audio.js 启动遮罩「轻触开始」/「按空格开始」等,游戏内
|
||||
* 提示由各游戏 HTML 后续接入),**不关闭任何输入通道**——touch/mouse/key
|
||||
* 三通道永远并行在线,混合设备无需玩家配置。
|
||||
*/
|
||||
export function inputMode() {
|
||||
const pref = settings.get().input;
|
||||
if (pref === 'touch' || pref === 'key') return pref;
|
||||
if (lastKeyInputAt === 0 && lastTouchInputAt === 0) return isTouchLikely() ? 'touch' : 'key';
|
||||
return lastKeyInputAt >= lastTouchInputAt ? 'key' : 'touch';
|
||||
}
|
||||
|
||||
export function attachGestures(element, onGesture, config = {}) {
|
||||
const cfg = { ...DEFAULT_GESTURE_CONFIG, ...config };
|
||||
|
||||
@@ -297,6 +358,72 @@ export function attachGestures(element, onGesture, config = {}) {
|
||||
e.preventDefault(); // 防移动端长按弹菜单
|
||||
}
|
||||
|
||||
// ---- 键盘(PC 平行通道,与 touch/mouse 喂同一状态机)----
|
||||
// 监听挂 window(键盘事件目标是焦点元素而非手势根,游戏页焦点在 body,
|
||||
// 冒泡到 window 必达);keydown 认领映射键,keyup 只处理空格(收尾虚拟
|
||||
// 指针)。方向键/回车在 keydown 即时出手势,无 keyup 语义。
|
||||
let keyPointer = null; // 空格按下时认领的虚拟指针坐标(元素中心,抬起复用)
|
||||
|
||||
/** 表单控件守卫:焦点在大厅输入框等处时放行,不吞键(打字/候选导航正常)。 */
|
||||
function isFormTarget(t) {
|
||||
if (!t || typeof t !== 'object') return false;
|
||||
const tag = String(t.tagName || '').toUpperCase();
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable === true;
|
||||
}
|
||||
|
||||
/** 键盘虚拟指针落点 = 元素中心。坐标只用于位移判定(tapSlop/滑动),键盘
|
||||
* 路径永不 move,必然只走出 tap/doubletap/longpress 分支,不会误判滑动。 */
|
||||
function keyCenter() {
|
||||
if (typeof element.getBoundingClientRect === 'function') {
|
||||
const r = element.getBoundingClientRect();
|
||||
if (r && (r.width || r.height)) return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||||
}
|
||||
return keyPointer || { x: 0, y: 0 }; // 无 DOM 环境(harness mock):坐标无关紧要
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (isFormTarget(e.target)) return; // 表单焦点:放行,不 preventDefault
|
||||
const dir = KEY_DIR[e.code];
|
||||
const isTapKey = e.code === TAP_KEY_CODE;
|
||||
const isDoubleKey = e.code === DOUBLE_KEY_CODE;
|
||||
if (!dir && !isTapKey && !isDoubleKey) return; // 未映射键不拦截(Tab 等无障碍键放行;无 Esc 映射)
|
||||
e.preventDefault(); // 抑制方向键/空格滚屏、回车激活焦点元素(repeat 也要防,否则按住连滚)
|
||||
if (e.repeat) return; // 自动重复抑制:按住 ↑ 只发一次 up、按住空格只认领一次
|
||||
lastKeyInputAt = performance.now(); // 通道标记:inputMode() 提示跟随
|
||||
if (dir) {
|
||||
if (tracking) return; // 单飞行指针:触摸/空格进行中忽略(与多指忽略语义一致)
|
||||
cancelPendingTap(); // 与触屏滑动一致:滑动作废等待中的单击
|
||||
lastTapTime = 0;
|
||||
vibrate('swipe');
|
||||
onGesture(dir); // 即时,无位移/速度判定(零延迟)
|
||||
return;
|
||||
}
|
||||
if (tracking) return;
|
||||
if (isDoubleKey) {
|
||||
// 回车 = 双击专用键:即时 doubletap(不等 300ms 双击窗),同时作废
|
||||
// 等待中的单击——收尾语义与触屏双击分支完全一致(含 confirm 震动)。
|
||||
cancelPendingTap();
|
||||
lastTapTime = 0;
|
||||
vibrate('confirm');
|
||||
onGesture('doubletap');
|
||||
return;
|
||||
}
|
||||
// 空格:虚拟指针按下,进同一状态机(起 450ms 长按定时器;immediateTap
|
||||
// 游戏按下即发 tap——rhythm「空格=kick 演奏」零改动成立)。
|
||||
keyPointer = keyCenter();
|
||||
handleDown(keyPointer.x, keyPointer.y);
|
||||
}
|
||||
|
||||
function onKeyUp(e) {
|
||||
if (e.code !== TAP_KEY_CODE) return;
|
||||
if (!keyPointer) return; // 这下空格未被键盘通道认领(表单内按下 / 触摸占用时按下)
|
||||
const p = keyPointer;
|
||||
keyPointer = null;
|
||||
handleUp(p.x, p.y); // 复用收尾:长按→longpressend;快按→tap(双击窗内两下=doubletap)
|
||||
}
|
||||
|
||||
const hasWindow = typeof window !== 'undefined' && typeof window.addEventListener === 'function';
|
||||
|
||||
const opts = { passive: false };
|
||||
element.addEventListener('touchstart', onTouchStart, opts);
|
||||
element.addEventListener('touchmove', onTouchMove, opts);
|
||||
@@ -306,6 +433,10 @@ export function attachGestures(element, onGesture, config = {}) {
|
||||
element.addEventListener('mousemove', onMouseMove);
|
||||
element.addEventListener('mouseup', onMouseUp);
|
||||
element.addEventListener('contextmenu', onContextMenu);
|
||||
if (hasWindow) {
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
}
|
||||
|
||||
return function detach() {
|
||||
cancelPendingTap();
|
||||
@@ -319,5 +450,9 @@ export function attachGestures(element, onGesture, config = {}) {
|
||||
element.removeEventListener('mousemove', onMouseMove);
|
||||
element.removeEventListener('mouseup', onMouseUp);
|
||||
element.removeEventListener('contextmenu', onContextMenu);
|
||||
if (hasWindow) {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* settings.js — 全局用户设置(纯音频游戏合集共享)。
|
||||
*
|
||||
* 四项设置:
|
||||
* 五项设置:
|
||||
* | 键 | 取值 | 默认 | 作用 |
|
||||
* |--------|-------------------------------------|----------|----------------------------------------|
|
||||
* | voice | 'male' \| 'female' | 'female' | voice 层加载 audio/voice/<gender>/ 下资产 |
|
||||
* | speed | 1.0 \| 1.35 \| 1.7 \| 2.2 | 1.0 | voice 层 source.playbackRate(语速;2.2 为视障档) |
|
||||
* | bgm | 1.0 \| 0.3 \| 0 | 1.0 | BGM 层增益;0 = 不加载不播放 BGM |
|
||||
* | guide | 'full' \| 'standard' \| 'off' | 'full' | 向导帮助等级(engine/guide.js 实时读取)|
|
||||
* | input | 'auto' \| 'touch' \| 'key' | 'auto' | 提示文案输入模式(gesture.inputMode():
|
||||
* | | | | auto=跟随最近使用的通道;只影响提示文案,
|
||||
* | | | | 不关闭任何输入通道) |
|
||||
*
|
||||
* localStorage 持久化(key: 'soundgame.settings'),同域下大厅与所有游戏
|
||||
* 页面天然共享;跨标签页修改通过 'storage' 事件同步给订阅者。
|
||||
@@ -24,16 +27,17 @@
|
||||
|
||||
const STORAGE_KEY = 'soundgame.settings';
|
||||
|
||||
/** @typedef {{ voice: 'male'|'female', speed: number, bgm: number, guide: 'full'|'standard'|'off' }} SoundGameSettings */
|
||||
/** @typedef {{ voice: 'male'|'female', speed: number, bgm: number, guide: 'full'|'standard'|'off', input: 'auto'|'touch'|'key' }} SoundGameSettings */
|
||||
|
||||
/** @type {SoundGameSettings} */
|
||||
const DEFAULTS = { voice: 'female', speed: 1.0, bgm: 1.0, guide: 'full' };
|
||||
const DEFAULTS = { voice: 'female', speed: 1.0, bgm: 1.0, guide: 'full', input: 'auto' };
|
||||
|
||||
const VALID = {
|
||||
voice: ['male', 'female'],
|
||||
speed: [1.0, 1.35, 1.7, 2.2],
|
||||
bgm: [1.0, 0.3, 0],
|
||||
guide: ['full', 'standard', 'off'],
|
||||
input: ['auto', 'touch', 'key'],
|
||||
};
|
||||
|
||||
/** @type {SoundGameSettings} */
|
||||
|
||||
Reference in New Issue
Block a user