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:
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* engine-input.test.mjs — 引擎键盘输入通道回归(P0-A,research/platform/02-input-parity.md)。
|
||||
*
|
||||
* 键盘通道不变式(gesture.js 与 touch/mouse 喂同一状态机,事件完全同构):
|
||||
* K1 方向键/WASD = 四向滑动:keydown 即发、零延迟、swipe 震动;
|
||||
* K2 空格 = 单击:抬起判 tap,**保留 ~300ms 双击窗**(两次空格=doubletap、
|
||||
* 第一次 pending tap 被取消),窗口与触摸共享(触屏 tap + 空格 tap 也判双击);
|
||||
* K3 回车 = 双击专用键:即时 doubletap(不等窗),作废等待中的单击;
|
||||
* K4 按住空格 ≥450ms = longpress(+window 快进事件),松开 = longpressend,无 tap;
|
||||
* K5 守卫:e.repeat 抑制(但映射键 repeat 仍 preventDefault 防连滚)、表单焦点
|
||||
* 放行、未映射键不拦截(Tab/Esc)、tracking 单飞行指针互斥;
|
||||
* K6 inputMode():settings.input(新增 auto/touch/key)手动优先,auto 跟随
|
||||
* 最近通道(keydown→key / touchstart→touch),初始按能力检测;
|
||||
* K7 immediateTap(rhythm):空格按下即 tap、长按补 tapcancel、双击改判——
|
||||
* 键盘路径与触摸路径行为逐条一致;
|
||||
* K8 detach 后键盘监听移除;孤儿 keyup 不产生事件。
|
||||
*
|
||||
* 运行:tools/node/bin/node web/harness/engine-input.test.mjs
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const WEB = path.resolve(HERE, '..');
|
||||
let passed = 0; const failures = [];
|
||||
const assert = (cond, msg) => { if (cond) { passed++; console.log(' ✓ ' + msg); } else { failures.push(msg); console.log(' ✗ ' + msg); } };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ---- 全局 mock(在 import 引擎前就位;真实定时器 + 真实 performance.now)----
|
||||
const winL = new Map(); // window 监听器(gesture.js 键盘通道挂这里)
|
||||
const docL = new Map(); // document 捕获监听器(合成鼠标守卫 + 输入模式跟随)
|
||||
const windowEvents = []; // window.dispatchEvent 收到的 CustomEvent(长按快进通道)
|
||||
globalThis.window = {
|
||||
addEventListener: (t, fn) => { if (!winL.has(t)) winL.set(t, []); winL.get(t).push(fn); },
|
||||
removeEventListener: (t, fn) => { const a = winL.get(t) || []; const i = a.indexOf(fn); if (i >= 0) a.splice(i, 1); },
|
||||
dispatchEvent: (e) => { windowEvents.push(e); return true; },
|
||||
};
|
||||
globalThis.document = {
|
||||
addEventListener: (t, fn) => { if (!docL.has(t)) docL.set(t, []); docL.get(t).push(fn); },
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
const storageMap = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (storageMap.has(k) ? storageMap.get(k) : null),
|
||||
setItem: (k, v) => storageMap.set(k, String(v)),
|
||||
removeItem: (k) => storageMap.delete(k),
|
||||
};
|
||||
const vibrateLog = [];
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { maxTouchPoints: 0, vibrate: (p) => { vibrateLog.push(p); return true; } }, // 纯桌面能力 → inputMode 初始 'key'
|
||||
configurable: true,
|
||||
});
|
||||
// (无 matchMedia:isTouchLikely 走 maxTouchPoints=0 → false)
|
||||
|
||||
const { attachGestures, inputMode } = await import(path.join(WEB, 'engine', 'gesture.js'));
|
||||
const { settings } = await import(path.join(WEB, 'engine', 'settings.js'));
|
||||
|
||||
// 等进程时钟走过一个双击窗:全新 Node 进程 performance.now() 可能 < 300ms,
|
||||
// 引擎 lastTapTime 初值 0 会让第一次点按被误判进双击窗(浏览器 page uptime
|
||||
// 不会如此早,纯测试环境假象)。
|
||||
await sleep(350);
|
||||
|
||||
// ---- 事件合成 ----
|
||||
const BODY_TARGET = { tagName: 'BODY' };
|
||||
function fireKey(type, init = {}) {
|
||||
const ev = { code: '', repeat: false, target: BODY_TARGET, prevented: false,
|
||||
preventDefault() { this.prevented = true; }, ...init };
|
||||
for (const fn of winL.get(type) || []) fn(ev);
|
||||
return ev;
|
||||
}
|
||||
const keyDown = (code, extra) => fireKey('keydown', { code, ...extra });
|
||||
const keyUp = (code, extra) => fireKey('keyup', { code, ...extra });
|
||||
function fireDoc(type) { for (const fn of docL.get(type) || []) fn({}); }
|
||||
|
||||
function makeRoot() {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
listeners,
|
||||
addEventListener: (t, fn) => { if (!listeners.has(t)) listeners.set(t, []); listeners.get(t).push(fn); },
|
||||
removeEventListener: (t, fn) => { const a = listeners.get(t) || []; const i = a.indexOf(fn); if (i >= 0) a.splice(i, 1); },
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 390, height: 844 }),
|
||||
};
|
||||
}
|
||||
/** 在元素上合成一次触摸(tap:down→up 同坐标,走真实 onTouchStart/End)。 */
|
||||
function touchTap(root, x = 195, y = 422) {
|
||||
const mk = () => ({ preventDefault() {} });
|
||||
const down = { ...mk(), touches: [{ clientX: x, clientY: y }] };
|
||||
const up = { ...mk(), touches: [], changedTouches: [{ clientX: x, clientY: y }] };
|
||||
fireDoc('touchstart'); // document 捕获(输入模式跟随)
|
||||
for (const fn of root.listeners.get('touchstart') || []) fn(down);
|
||||
for (const fn of root.listeners.get('touchend') || []) fn(up);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 会话
|
||||
const root = makeRoot();
|
||||
const events = [];
|
||||
const vib = () => JSON.stringify(vibrateLog.slice(-1)[0] || null);
|
||||
const gest = (g) => events.push(g);
|
||||
const detach = attachGestures(root, gest);
|
||||
|
||||
console.log('\n=== K6a settings.input 默认与 inputMode 初始 ===');
|
||||
assert(settings.get().input === 'auto', 'settings.input 默认 auto');
|
||||
assert(settings.set({ input: 'bogus' }).input === 'auto', '非法 input 值被忽略(VALID 枚举)');
|
||||
assert(inputMode() === 'key', 'auto 且无输入历史:纯桌面能力检测 → key');
|
||||
|
||||
console.log('\n=== K1 方向键 / WASD = 四向滑动(即时) ===');
|
||||
let ev = keyDown('ArrowLeft'); keyUp('ArrowLeft');
|
||||
assert(events.pop() === 'left' && events.length === 0, '← 即发 swipeleft(无延迟)');
|
||||
assert(vib() === '[10]', '方向键滑动 → swipe 震动 [10]');
|
||||
keyDown('ArrowRight'); assert(events.pop() === 'right', '→ = right');
|
||||
keyDown('ArrowUp'); assert(events.pop() === 'up', '↑ = up');
|
||||
keyDown('ArrowDown'); assert(events.pop() === 'down', '↓ = down');
|
||||
keyDown('KeyW'); keyUp('KeyW'); assert(events.pop() === 'up', 'W 别名 = up');
|
||||
keyDown('KeyA'); assert(events.pop() === 'left', 'A 别名 = left');
|
||||
keyDown('KeyS'); assert(events.pop() === 'down', 'S 别名 = down');
|
||||
keyDown('KeyD'); assert(events.pop() === 'right', 'D 别名 = right');
|
||||
assert(ev.prevented === true, '映射键 keydown 被 preventDefault(防页面滚动)');
|
||||
|
||||
console.log('\n=== K5a repeat 抑制 / 未映射键放行 ===');
|
||||
ev = keyDown('ArrowUp', { repeat: true });
|
||||
assert(events.length === 0, 'e.repeat 方向键不重发手势');
|
||||
assert(ev.prevented === true, 'repeat 仍 preventDefault(按住方向键不连滚页面)');
|
||||
ev = keyDown('Tab');
|
||||
assert(events.length === 0 && ev.prevented === false, 'Tab 不拦截(无障碍键放行)');
|
||||
keyDown('Escape');
|
||||
assert(events.length === 0, 'Esc 无映射(裁定:不做 Esc,下滑已覆盖菜单)');
|
||||
ev = keyDown('Space', { repeat: true });
|
||||
assert(events.length === 0 && ev.prevented === true, '空格 repeat 抑制且防滚动');
|
||||
keyUp('Space');
|
||||
assert(events.length === 0, '被抑制的空格 repeat 不会被 keyup 认领(无孤儿 tap)');
|
||||
|
||||
console.log('\n=== K2 空格 = 单击(~300ms 双击窗,与触屏同感) ===');
|
||||
keyDown('Space'); keyUp('Space');
|
||||
assert(events.length === 0, '空格快按后不立即出 tap(等双击窗,与触摸一致)');
|
||||
await sleep(380);
|
||||
assert(events.pop() === 'tap' && events.length === 0, '~300ms 双击窗关闭后回调 tap');
|
||||
assert(vib() === '[20]', 'tap → confirm 震动 [20]');
|
||||
keyDown('Space'); keyUp('Space');
|
||||
await sleep(400); keyDown('Space'); keyUp('Space');
|
||||
await sleep(380);
|
||||
assert(events.length === 2 && events[0] === 'tap' && events[1] === 'tap', '慢速两连空格 = 两个独立 tap');
|
||||
events.length = 0;
|
||||
keyDown('Space'); keyUp('Space'); keyDown('Space'); keyUp('Space');
|
||||
assert(events.pop() === 'doubletap' && events.length === 0, '快速两连空格 = doubletap(即时改判)');
|
||||
await sleep(400);
|
||||
assert(events.length === 0, '第一次空格的 pending tap 被取消(无双击窗泄漏的 tap)');
|
||||
|
||||
console.log('\n=== K3 回车 = 双击专用键(即时,不等窗) ===');
|
||||
keyDown('Enter'); keyUp('Enter');
|
||||
assert(events.pop() === 'doubletap' && events.length === 0, '回车同 tick 即时 doubletap(零延迟)');
|
||||
assert(vib() === '[20]', '回车 doubletap → confirm 震动');
|
||||
keyDown('Space'); keyUp('Space'); // 空格 tap 进入 300ms 等待
|
||||
await sleep(100);
|
||||
keyDown('Enter'); keyUp('Enter'); // 窗内回车
|
||||
assert(events.pop() === 'doubletap', '窗内回车 → doubletap');
|
||||
await sleep(450);
|
||||
assert(events.length === 0, '等待中的空格单击被回车作废(cancelPendingTap)');
|
||||
ev = keyDown('Enter', { repeat: true });
|
||||
assert(events.length === 0 && ev.prevented === true, '回车 repeat 抑制');
|
||||
|
||||
console.log('\n=== K4 按住空格 ≥450ms = 长按 ===');
|
||||
keyDown('Space');
|
||||
await sleep(520);
|
||||
assert(events.pop() === 'longpress' && events.length === 0, '按住空格 ~450ms 触发 longpress');
|
||||
assert(windowEvents.some((e) => e.type === 'soundgame:longpress'), "派发 window 'soundgame:longpress'(旁白快进通道)");
|
||||
keyUp('Space');
|
||||
assert(events.pop() === 'longpressend' && events.length === 0, '松开 → longpressend');
|
||||
assert(windowEvents.some((e) => e.type === 'soundgame:longpressend'), "派发 window 'soundgame:longpressend'");
|
||||
await sleep(400);
|
||||
assert(events.length === 0, '长按后不触发 tap/doubletap');
|
||||
keyDown('Space');
|
||||
await sleep(380); // < 450ms
|
||||
keyUp('Space');
|
||||
assert(events.length === 0, '不足 450ms 的按住不是长按(先不发包)');
|
||||
await sleep(380);
|
||||
assert(events.pop() === 'tap', '短于阈值的按住仍走单击(380ms < 450ms)');
|
||||
|
||||
console.log('\n=== K5b 表单焦点守卫 ===');
|
||||
for (const target of [{ tagName: 'INPUT' }, { tagName: 'TEXTAREA' }, { tagName: 'SELECT' }, { isContentEditable: true }]) {
|
||||
ev = keyDown('Space', { target });
|
||||
assert(events.length === 0 && ev.prevented === false, `表单焦点(${target.tagName || 'contenteditable'})放行:不吞键不拦截`);
|
||||
}
|
||||
keyUp('Space');
|
||||
await sleep(400);
|
||||
assert(events.length === 0, '表单内按下的空格 keyup 不产生孤儿 tap');
|
||||
ev = keyDown('ArrowLeft', { target: { tagName: 'INPUT' } });
|
||||
assert(events.length === 0 && ev.prevented === false, '表单内方向键放行(输入框光标移动正常)');
|
||||
|
||||
console.log('\n=== K5c tracking 单飞行指针互斥 ===');
|
||||
keyDown('Space'); // 空格虚拟指针占用 tracking
|
||||
keyDown('ArrowLeft'); keyUp('ArrowLeft');
|
||||
keyDown('Enter'); keyUp('Enter');
|
||||
assert(events.length === 0, '空格按住期间方向键/回车被忽略(单指针语义,与多指忽略一致)');
|
||||
keyUp('Space');
|
||||
await sleep(380);
|
||||
assert(events.pop() === 'tap', '占用结束后空格自身仍正常结算 tap');
|
||||
keyDown('ArrowRight');
|
||||
assert(events.pop() === 'right', '抬起后方向键恢复工作');
|
||||
|
||||
console.log('\n=== K2b 触摸 + 键盘共享双击窗(同一虚拟指针) ===');
|
||||
touchTap(root); // 触摸 tap → pending
|
||||
keyDown('Space'); keyUp('Space'); // 300ms 内空格 tap
|
||||
assert(events.pop() === 'doubletap' && events.length === 0, '触屏 tap + 空格 tap 判 doubletap(混合设备语义)');
|
||||
await sleep(400);
|
||||
assert(events.length === 0, '触摸那一下的 pending tap 同样被取消');
|
||||
|
||||
console.log('\n=== K7 immediateTap(rhythm:空格=演奏输入,零改动成立) ===');
|
||||
const root2 = makeRoot();
|
||||
const events2 = [];
|
||||
const detach2 = attachGestures(root2, (g) => events2.push(g), { immediateTap: true });
|
||||
keyDown('Space');
|
||||
assert(events2.pop() === 'tap' && events2.length === 0, 'immediateTap:空格按下即发 tap(kick)');
|
||||
await sleep(520);
|
||||
assert(events2.pop() === 'longpress' && events2.pop() === 'tapcancel' && events2.length === 0, '按住 → 序曲 tap 撤回(tapcancel)+ longpress');
|
||||
keyUp('Space');
|
||||
assert(events2.pop() === 'longpressend', '松开 → longpressend');
|
||||
keyDown('Space'); keyUp('Space'); keyDown('Space'); keyUp('Space');
|
||||
assert(events2.length === 3 && events2[0] === 'tap' && events2[1] === 'tap' && events2[2] === 'doubletap',
|
||||
'两连空格 = tap+tap+doubletap 改判(双击序曲不发 tapcancel,与触摸一致)');
|
||||
events2.length = 0;
|
||||
keyDown('Enter');
|
||||
assert(events2.pop() === 'doubletap', 'immediateTap 模式回车同样即时 doubletap');
|
||||
detach2();
|
||||
|
||||
console.log('\n=== K6b inputMode 运行时跟随 + 手动覆盖 ===');
|
||||
fireDoc('touchstart');
|
||||
assert(inputMode() === 'touch', 'auto:touchstart → 提示切触摸版');
|
||||
keyDown('ArrowDown');
|
||||
assert(inputMode() === 'key', 'auto:键盘 keydown → 提示切键盘版(最近使用优先)');
|
||||
settings.set({ input: 'touch' });
|
||||
assert(inputMode() === 'touch', 'settings.input=touch 手动覆盖优先');
|
||||
settings.set({ input: 'key' });
|
||||
assert(inputMode() === 'key', 'settings.input=key 手动覆盖优先');
|
||||
settings.set({ input: 'auto' });
|
||||
assert(inputMode() === 'key', '回 auto 后恢复跟随最近通道(=key)');
|
||||
|
||||
console.log('\n=== K8 detach / 孤儿事件 ===');
|
||||
events.length = 0; // K6b 的方向键判定已断言过,清场
|
||||
detach();
|
||||
keyDown('ArrowLeft'); keyDown('Space'); keyUp('Space'); keyDown('Enter');
|
||||
assert(events.length === 0, 'detach 后键盘监听已移除(无手势泄漏)');
|
||||
await sleep(400);
|
||||
assert(events.length === 0, 'detach 同时清掉挂起的定时器(无迟到 tap)');
|
||||
keyUp('Space');
|
||||
assert(events.length === 0, '孤儿空格 keyup(无对应认领)无事件无异常');
|
||||
|
||||
console.log(`\n========================================`);
|
||||
console.log(`断言通过 ${passed} 条,失败 ${failures.length} 条`);
|
||||
if (failures.length) { failures.forEach((m) => console.log('FAILED: ' + m)); process.exit(1); }
|
||||
console.log('全部通过。');
|
||||
@@ -5,6 +5,12 @@
|
||||
* 外部 agent 通过 HTTP 逐步玩任意一款 SoundGame:
|
||||
* POST /session {game, settings?, viewport?} 开新页(预注入探针 + 设置种子)
|
||||
* POST /gesture {type, x?, y?} 真实触摸手势(tap/doubletap/swipe四向/longpress)
|
||||
* POST /gesture {keyboard:true, key, holdMs?} 键盘手势(CDP Input.dispatchKeyEvent;
|
||||
* key=ArrowLeft|…|KeyW…|Space|Enter,
|
||||
* holdMs 默认 60,长按空格传 ≥450)
|
||||
* POST /gesture {type, keyboard:true} 同款手势类型走键盘(tap→空格快按、
|
||||
* doubletap→回车、longpress→空格按 600ms、
|
||||
* swipeup→↑…)——会话剧本加 keyboard:true 即可复跑
|
||||
* GET /events?since=N 增量事件(voice/fetch/震动/console/手势…)
|
||||
* GET /listen 增量转写判定(OK/TRUNCATED/MISMATCH/SILENCE/SFX_BGM_ONLY)
|
||||
* GET /transcript 合并时间轴全量(复盘用)
|
||||
@@ -45,6 +51,7 @@ function mapSettings(raw = {}) {
|
||||
const out = {};
|
||||
if (raw.voice !== undefined) out.voice = raw.voice; // male|female
|
||||
if (raw.guide !== undefined) out.guide = raw.guide; // full|standard|off
|
||||
if (raw.input !== undefined) out.input = raw.input; // auto|touch|key(提示文案输入模式)
|
||||
if (raw.speed !== undefined) {
|
||||
out.speed = typeof raw.speed === 'number' ? raw.speed : SPEED_MAP[raw.speed] ?? 1.0;
|
||||
}
|
||||
@@ -295,7 +302,58 @@ async function buildListenRows() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 手势编排
|
||||
async function gesture({ type, x, y }) {
|
||||
// 键盘通道(engine/gesture.js 键位):key 用 KeyboardEvent.code 值。
|
||||
const KEY_DESC = {
|
||||
ArrowUp: { code: 'ArrowUp', key: 'ArrowUp', vk: 38 },
|
||||
ArrowDown: { code: 'ArrowDown', key: 'ArrowDown', vk: 40 },
|
||||
ArrowLeft: { code: 'ArrowLeft', key: 'ArrowLeft', vk: 37 },
|
||||
ArrowRight: { code: 'ArrowRight', key: 'ArrowRight', vk: 39 },
|
||||
KeyW: { code: 'KeyW', key: 'w', vk: 87 },
|
||||
KeyA: { code: 'KeyA', key: 'a', vk: 65 },
|
||||
KeyS: { code: 'KeyS', key: 's', vk: 83 },
|
||||
KeyD: { code: 'KeyD', key: 'd', vk: 68 },
|
||||
Space: { code: 'Space', key: ' ', vk: 32, text: ' ' },
|
||||
Enter: { code: 'Enter', key: 'Enter', vk: 13, text: '\r' },
|
||||
};
|
||||
// 触摸手势类型 → 键盘等价(gesture.js 键位表):type 走键盘时按此换算
|
||||
const TYPE_TO_KEY = {
|
||||
tap: { key: 'Space', holdMs: 60 },
|
||||
doubletap: { key: 'Enter', holdMs: 60 },
|
||||
longpress: { key: 'Space', holdMs: 600 },
|
||||
swipeup: { key: 'ArrowUp', holdMs: 60 },
|
||||
swipedown: { key: 'ArrowDown', holdMs: 60 },
|
||||
swipeleft: { key: 'ArrowLeft', holdMs: 60 },
|
||||
swiperight: { key: 'ArrowRight', holdMs: 60 },
|
||||
};
|
||||
|
||||
/** 键盘手势:CDP Input.dispatchKeyEvent(rawKeyDown 会让页面收不到 code 派生
|
||||
* 的完整 KeyboardEvent 特性,用 keyDown/keyUp 对),比 Runtime.evaluate 派发
|
||||
* 合成事件更拟真(走真实输入管线,含默认行为与焦点语义)。 */
|
||||
async function keyGesture({ key, type, holdMs }) {
|
||||
let desc = key ? KEY_DESC[key] : null;
|
||||
let hold = holdMs;
|
||||
if (!desc && type) {
|
||||
const m = TYPE_TO_KEY[type];
|
||||
if (!m) throw new Error(`未知键盘手势类型: ${type}`);
|
||||
desc = KEY_DESC[m.key];
|
||||
hold = hold ?? m.holdMs;
|
||||
}
|
||||
if (!desc) throw new Error(`未知按键: ${key}(可用 ${Object.keys(KEY_DESC).join('|')})`);
|
||||
hold = hold ?? 60;
|
||||
const label = key || type;
|
||||
// 先把动作本身记进页面时间轴(t=按键开始),附 keyboard/key 供审计区分通道
|
||||
await S.page.eval(`window.__live && window.__live.events.push({ev:'gesture', type:${JSON.stringify(label)}, keyboard:true, key:${JSON.stringify(desc.code)}, t:performance.now()})`).catch(() => {});
|
||||
const base = { key: desc.key, code: desc.code, windowsVirtualKeyCode: desc.vk, nativeVirtualKeyCode: desc.vk };
|
||||
await S.page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base, ...(desc.text ? { text: desc.text, unmodifiedText: desc.text } : {}) });
|
||||
await sleep(hold);
|
||||
await S.page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base });
|
||||
await sleep(400); // 与触摸手势一致:给游戏响应时间(空格单击有 ~300ms 双击窗延迟)
|
||||
await drain();
|
||||
}
|
||||
|
||||
async function gesture(body = {}) {
|
||||
if (body.keyboard) return keyGesture(body); // 键盘通道:{"keyboard":true,"key":...} 或 {type,keyboard:true}
|
||||
const { type, x, y } = body;
|
||||
const cx = x ?? Math.round(S.viewport.width / 2);
|
||||
const cy = y ?? Math.round(S.viewport.height / 2);
|
||||
const pt = (px, py) => [{ x: px, y: py, id: 1 }];
|
||||
@@ -395,7 +453,7 @@ async function writeArtifacts() {
|
||||
for (const e of S.events) {
|
||||
const t = e.ta;
|
||||
if (t == null) continue;
|
||||
if (e.ev === 'gesture') items.push([t, `**GESTURE** ${e.type} @(${e.x},${e.y})`]);
|
||||
if (e.ev === 'gesture') items.push([t, e.keyboard ? `**KEY** ${e.key || e.type}` : `**GESTURE** ${e.type} @(${e.x},${e.y})`]);
|
||||
else if (e.ev === 'vstart') items.push([t, `VOICE ▶ ${voiceIdOf(e.url)} (dur ${e.dur}s)`]);
|
||||
else if (e.ev === 'vend') items.push([t, `VOICE ■ ${voiceIdOf(e.url)} (${e.reason})`]);
|
||||
else if (e.ev === 'start' && e.layer !== 'voice') items.push([t, `${(e.layer || 'sfx').toUpperCase()} ♪ ${e.url ? voiceIdOf(e.url) : '?'}${e.loop ? ' (loop)' : ''}`]);
|
||||
|
||||
Reference in New Issue
Block a user