用户系统(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)
459 lines
20 KiB
JavaScript
459 lines
20 KiB
JavaScript
/**
|
||
* gesture.js — 6 手势识别(触摸 + 鼠标 + 键盘三通道),对齐 Android GestureEngine 行为。
|
||
*
|
||
* 手势集合:'up' | 'down' | 'left' | 'right' | 'tap' | 'doubletap'
|
||
* | 'longpress' | 'longpressend'
|
||
*
|
||
* 判定规则(与 Android 版 GestureConfig 一致):
|
||
* - 滑动:累积位移 >= swipeMinDistance(默认 64px ≈ 64dp@160dpi)
|
||
* 且平均速度 >= swipeMinVelocity(默认 400 px/s)。
|
||
* 方向取位移较大的轴(|dx|>|dy| 判左右,否则判上下),斜滑归一到最近主轴。
|
||
* - 单击/双击:位移 < tapSlop 视为点按;双击窗口 doubleTapWindow(默认 300ms)。
|
||
* **单击回调有约 300ms 延迟**(要等双击窗口关闭才能确定不是双击),
|
||
* 即时反馈类操作请绑定滑动或双击,单击只用于非即时操作。
|
||
* **例外:`{ immediateTap: true }`(opt-in,默认 false 行为完全不变)——
|
||
* 按下(touchstart/mousedown)立即回调 tap,不等双击窗口;若 300ms 内
|
||
* 第二下也成立为点按,松开时补发 'doubletap' 改判(第一次 tap 已发出,
|
||
* 是否撤回其效果由游戏层决定)。代价:每次按下都先发 tap——本次按下
|
||
* 若最终判成滑动/长按,那一下 tap 的声音已发出无法撤回(演奏类游戏
|
||
* "按下即出声"正好要这个语义,如 rhythm 单击=kick),但**判定可以
|
||
* 撤回**:本次按下一旦确定不再是点按——移动超 tapSlop 转滑动、长按
|
||
* 成立、触摸被 cancel——引擎补发 'tapcancel'(仅 immediateTap 模式;
|
||
* 若该触摸最终以滑动收尾且滑动前未曾超 slop,补在滑动事件之前)。
|
||
* 游戏层收到后应撤销该次 tap 的判定/统计(声音已出收不回)。
|
||
* 双击序曲(tap+tap+doubletap 序列)不发 tapcancel——归属撤销由游戏层
|
||
* 按双击窗口自行处理(见 rhythm:doubletap 到达时撤回窗口内的序曲 tap)。**
|
||
* - **长按**:按住不动 >= longpressMs(默认 450ms)触发 'longpress',
|
||
* 松开触发 'longpressend';长按期间不触发 tap;位移超 tapSlop 取消
|
||
* (已激活的长按会先补发 'longpressend',之后滑动照常判定)。
|
||
* 长按触发/结束同时派发 window 事件 'soundgame:longpress' /
|
||
* 'soundgame:longpressend'——audio.js 内置监听:voice 播放中长按 =
|
||
* 旁白临时 1.7 倍速(不变调),松开恢复设置语速,游戏代码零改动。
|
||
*
|
||
* 用法:
|
||
* import { attachGestures } from '../engine/gesture.js';
|
||
* const detach = attachGestures(element, (g) => game.onGesture(g));
|
||
* // detach() 可解除绑定
|
||
*
|
||
* 基础震动反馈(haptics.js,不支持的平台自动 no-op):
|
||
* 滑动识别成功→swipe,tap→confirm,doubletap→confirm。
|
||
* 游戏逻辑层的语义反馈(error/success/warning 等)由游戏自行调用 vibrate()。
|
||
*
|
||
* 注意:绑定点按的元素的 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)
|
||
swipeMinVelocity: 400, // px/s,平均速度阈值(位移 / 拖动时长)
|
||
doubleTapWindow: 300, // ms,双击判定窗口(单击因此延迟 ~300ms 回调)
|
||
tapSlop: 24, // px,位移小于此值才算点按
|
||
longpressMs: 450, // ms,按住不动超过此值触发 longpress
|
||
immediateTap: false, // true = 按下即发 tap(演奏类 opt-in,见文件头注释)
|
||
};
|
||
|
||
/**
|
||
* 全局合成鼠标守卫(模块级,所有 attachGestures 实例共享)。
|
||
* 启动遮罩(audio.js showStartOverlay)挂在 body 上、不是手势 root 的
|
||
* 子元素:解锁那次触摸不经过任何手势实例,实例级 lastTouchEndTime 守卫
|
||
* 对它无效——遮罩移除后该次触摸合成的 mousedown/mouseup 落到 root 上,
|
||
* 会被鼠标回退通道误判成 tap("解锁单击"误杀开场旁白的根因)。
|
||
* 在 document 捕获阶段记录全页面最后一次 touchend/touchcancel,
|
||
* 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 监听它实现"长按=旁白临时倍速"。 */
|
||
|
||
/**
|
||
* 长按 boost 窗口事件('soundgame:longpress'/'soundgame:longpressend')的开关,
|
||
* 由 audio.setRateBoostEnabled(false) 置 off(E3 对局豁免):豁免期快进通道本就
|
||
* 不生效,再外发事件只会造成"boost on→off 闪跳"假象(R1 live P2 仪器侧记)。
|
||
* 只抑制 window 事件(快进/探针通道);onGesture('longpress'/'longpressend')
|
||
* 游戏回调(前进/停走)不受影响。
|
||
*/
|
||
let boostEventsEnabled = true;
|
||
|
||
export function setLongpressBoostEventsEnabled(on) {
|
||
boostEventsEnabled = on !== false;
|
||
}
|
||
|
||
function _dispatchLongpress(type) {
|
||
if (!boostEventsEnabled) return;
|
||
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function'
|
||
&& typeof CustomEvent === 'function') {
|
||
window.dispatchEvent(new CustomEvent(`soundgame:${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 };
|
||
|
||
// 单指/单指针跟踪状态
|
||
let tracking = false;
|
||
let startX = 0, startY = 0, startTime = 0;
|
||
let moved = false; // 位移是否已超 tapSlop(超过则本次不可能是点按)
|
||
|
||
// 长按状态
|
||
let longPressTimer = null;
|
||
let longPressActive = false;
|
||
|
||
// 点按状态
|
||
let lastTapTime = 0;
|
||
let pendingTapTimer = null;
|
||
let tapPending = false; // immediateTap:本次按下已发 tap 且尚未定案(可补 tapcancel)
|
||
|
||
// 触摸发生后短时间内忽略合成鼠标事件
|
||
let lastTouchEndTime = 0;
|
||
|
||
function cancelPendingTap() {
|
||
if (pendingTapTimer !== null) {
|
||
clearTimeout(pendingTapTimer);
|
||
pendingTapTimer = null;
|
||
}
|
||
}
|
||
|
||
function cancelLongPress() {
|
||
if (longPressTimer !== null) {
|
||
clearTimeout(longPressTimer);
|
||
longPressTimer = null;
|
||
}
|
||
}
|
||
|
||
/** 长按已激活后收尾(松开/滑动取消):补发 longpressend。 */
|
||
function endLongPress() {
|
||
if (!longPressActive) return;
|
||
longPressActive = false;
|
||
_dispatchLongpress('longpressend');
|
||
onGesture('longpressend');
|
||
}
|
||
|
||
/** immediateTap:本次按下的序曲 tap 确定作废(转滑动/长按/触摸取消)时补发 tapcancel。 */
|
||
function cancelImmediateTap() {
|
||
if (!tapPending) return;
|
||
tapPending = false;
|
||
onGesture('tapcancel');
|
||
}
|
||
|
||
function handleDown(x, y) {
|
||
tracking = true;
|
||
moved = false;
|
||
startX = x;
|
||
startY = y;
|
||
startTime = performance.now();
|
||
if (cfg.immediateTap) {
|
||
// 即时模式:按下立即回调 tap,不等双击窗口关闭。
|
||
// 注意此时尚不知本次按下会判成什么——若最终是滑动/长按,
|
||
// 这声 tap 已发出不可撤回,但引擎会补 tapcancel 让游戏层撤销判定。
|
||
tapPending = true;
|
||
vibrate('confirm');
|
||
onGesture('tap');
|
||
}
|
||
cancelLongPress();
|
||
longPressTimer = setTimeout(() => {
|
||
longPressTimer = null;
|
||
if (!tracking || moved) return;
|
||
// 触发长按:作废等待中的单击;immediateTap 的序曲 tap 补发 tapcancel
|
||
cancelPendingTap();
|
||
cancelImmediateTap();
|
||
longPressActive = true;
|
||
_dispatchLongpress('longpress');
|
||
onGesture('longpress');
|
||
}, cfg.longpressMs);
|
||
}
|
||
|
||
function handleMove(x, y) {
|
||
if (!tracking) return;
|
||
if (Math.hypot(x - startX, y - startY) > cfg.tapSlop) {
|
||
if (!moved) cancelImmediateTap(); // 位移超 slop:本次按下确定不是点按
|
||
moved = true;
|
||
cancelLongPress(); // 滑动取消长按判定
|
||
endLongPress(); // 已激活的长按被滑动终止(之后滑动照常判定)
|
||
}
|
||
}
|
||
|
||
function handleUp(x, y) {
|
||
if (!tracking) return;
|
||
tracking = false;
|
||
cancelLongPress();
|
||
if (longPressActive) {
|
||
endLongPress();
|
||
lastTapTime = 0;
|
||
tapPending = false;
|
||
return; // 长按后不触发 tap/doubletap
|
||
}
|
||
|
||
const dx = x - startX;
|
||
const dy = y - startY;
|
||
const dist = Math.hypot(dx, dy);
|
||
const durationMs = Math.max(performance.now() - startTime, 1);
|
||
const velocity = dist / (durationMs / 1000); // px/s
|
||
|
||
if (dist >= cfg.swipeMinDistance && velocity >= cfg.swipeMinVelocity) {
|
||
// 主轴归一:位移较大的轴决定方向
|
||
cancelPendingTap(); // 滑动作废等待中的单击
|
||
cancelImmediateTap(); // 序曲 tap 定案作废(无 move 事件的快滑在此补发)
|
||
lastTapTime = 0;
|
||
let g;
|
||
if (Math.abs(dx) > Math.abs(dy)) g = dx > 0 ? 'right' : 'left';
|
||
else g = dy > 0 ? 'down' : 'up';
|
||
vibrate('swipe'); // 滑动识别成功
|
||
onGesture(g);
|
||
return;
|
||
}
|
||
|
||
if (!moved && dist < cfg.tapSlop) {
|
||
tapPending = false; // 点按成立:序曲 tap 定案保留(双击序曲归属由游戏层处理)
|
||
const now = performance.now();
|
||
if (now - lastTapTime <= cfg.doubleTapWindow) {
|
||
// 双击:取消尚未触发的单击(immediateTap 模式没有挂起的单击——
|
||
// 第一下 tap 在按下时已发出,此处补发 doubletap 改判,
|
||
// 是否撤回第一次 tap 的效果由游戏层决定)
|
||
cancelPendingTap();
|
||
lastTapTime = 0;
|
||
vibrate('confirm'); // 双击
|
||
onGesture('doubletap');
|
||
} else if (cfg.immediateTap) {
|
||
lastTapTime = now; // tap 已在 handleDown 发出,这里只记双击窗起点
|
||
} else {
|
||
lastTapTime = now;
|
||
cancelPendingTap();
|
||
// 等双击窗口关闭后才回调单击(与 Android 一致的 ~300ms 延迟)
|
||
pendingTapTimer = setTimeout(() => {
|
||
pendingTapTimer = null;
|
||
lastTapTime = 0;
|
||
vibrate('confirm'); // 单击
|
||
onGesture('tap');
|
||
}, cfg.doubleTapWindow);
|
||
}
|
||
return;
|
||
}
|
||
|
||
cancelImmediateTap(); // 既非滑动也非点按(慢拖/回拉):序曲 tap 定案作废
|
||
}
|
||
|
||
function handleCancel() {
|
||
tracking = false;
|
||
cancelLongPress();
|
||
cancelImmediateTap(); // 触摸被系统取消:序曲 tap 定案作废
|
||
endLongPress();
|
||
}
|
||
|
||
// ---- 触摸 ----
|
||
// 全部 preventDefault:防滚动 / 防双击缩放 / 防合成鼠标事件重复触发。
|
||
// 必须 passive:false 才能 preventDefault。
|
||
function onTouchStart(e) {
|
||
if (e.touches.length > 1) { handleCancel(); return; } // 忽略多指
|
||
e.preventDefault();
|
||
const t = e.touches[0];
|
||
handleDown(t.clientX, t.clientY);
|
||
}
|
||
function onTouchMove(e) {
|
||
e.preventDefault();
|
||
if (!tracking) return;
|
||
const t = e.touches[0];
|
||
handleMove(t.clientX, t.clientY);
|
||
}
|
||
function onTouchEnd(e) {
|
||
e.preventDefault();
|
||
lastTouchEndTime = performance.now();
|
||
const t = e.changedTouches[0];
|
||
handleUp(t.clientX, t.clientY);
|
||
}
|
||
function onTouchCancel() {
|
||
lastTouchEndTime = performance.now();
|
||
handleCancel();
|
||
}
|
||
|
||
// ---- 鼠标(桌面调试)----
|
||
let mouseDown = false;
|
||
function recentlyTouched() {
|
||
// 实例级(root 上的触摸)与全局级(遮罩等页面任意处的触摸)取较晚者
|
||
return performance.now() - Math.max(lastTouchEndTime, lastGlobalTouchEnd) < 700;
|
||
}
|
||
function onMouseDown(e) {
|
||
if (recentlyTouched() || e.button !== 0) return;
|
||
mouseDown = true;
|
||
handleDown(e.clientX, e.clientY);
|
||
}
|
||
function onMouseMove(e) {
|
||
if (!mouseDown) return;
|
||
handleMove(e.clientX, e.clientY);
|
||
}
|
||
function onMouseUp(e) {
|
||
if (!mouseDown || recentlyTouched()) return;
|
||
mouseDown = false;
|
||
handleUp(e.clientX, e.clientY);
|
||
}
|
||
function onContextMenu(e) {
|
||
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);
|
||
element.addEventListener('touchend', onTouchEnd, opts);
|
||
element.addEventListener('touchcancel', onTouchCancel, opts);
|
||
element.addEventListener('mousedown', onMouseDown);
|
||
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();
|
||
cancelLongPress();
|
||
endLongPress();
|
||
element.removeEventListener('touchstart', onTouchStart, opts);
|
||
element.removeEventListener('touchmove', onTouchMove, opts);
|
||
element.removeEventListener('touchend', onTouchEnd, opts);
|
||
element.removeEventListener('touchcancel', onTouchCancel, opts);
|
||
element.removeEventListener('mousedown', onMouseDown);
|
||
element.removeEventListener('mousemove', onMouseMove);
|
||
element.removeEventListener('mouseup', onMouseUp);
|
||
element.removeEventListener('contextmenu', onContextMenu);
|
||
if (hasWindow) {
|
||
window.removeEventListener('keydown', onKeyDown);
|
||
window.removeEventListener('keyup', onKeyUp);
|
||
}
|
||
};
|
||
}
|