/** * 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 延迟**(要等双击窗口关闭才能确定不是双击), * 即时反馈类操作请绑定滑动或双击,单击只用于非即时操作。 * - **长按**:按住不动 >= 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)。 */ import { vibrate } from './haptics.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 }; /** 长按引擎事件:audio.js 监听它实现"长按=旁白临时倍速"。 */ function _dispatchLongpress(type) { if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') { window.dispatchEvent(new CustomEvent(`soundgame:${type}`)); } } 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 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'); } function handleDown(x, y) { tracking = true; moved = false; startX = x; startY = y; startTime = performance.now(); cancelLongPress(); longPressTimer = setTimeout(() => { longPressTimer = null; if (!tracking || moved) return; // 触发长按:作废等待中的单击 cancelPendingTap(); longPressActive = true; _dispatchLongpress('longpress'); onGesture('longpress'); }, cfg.longpressMs); } function handleMove(x, y) { if (!tracking) return; if (Math.hypot(x - startX, y - startY) > cfg.tapSlop) { moved = true; cancelLongPress(); // 滑动取消长按判定 endLongPress(); // 已激活的长按被滑动终止(之后滑动照常判定) } } function handleUp(x, y) { if (!tracking) return; tracking = false; cancelLongPress(); if (longPressActive) { endLongPress(); lastTapTime = 0; 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(); // 滑动作废等待中的单击 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) { const now = performance.now(); if (now - lastTapTime <= cfg.doubleTapWindow) { // 双击:取消尚未触发的单击 cancelPendingTap(); lastTapTime = 0; vibrate('confirm'); // 双击 onGesture('doubletap'); } else { lastTapTime = now; cancelPendingTap(); // 等双击窗口关闭后才回调单击(与 Android 一致的 ~300ms 延迟) pendingTapTimer = setTimeout(() => { pendingTapTimer = null; lastTapTime = 0; vibrate('confirm'); // 单击 onGesture('tap'); }, cfg.doubleTapWindow); } } } function handleCancel() { tracking = false; cancelLongPress(); 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() { return performance.now() - lastTouchEndTime < 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(); // 防移动端长按弹菜单 } 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); 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); }; }