Files
SoundGame/web/engine/gesture.js
T

309 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)。
*/
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
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;
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
const markGlobalTouchEnd = () => { lastGlobalTouchEnd = performance.now(); };
document.addEventListener('touchend', markGlobalTouchEnd, true);
document.addEventListener('touchcancel', markGlobalTouchEnd, true);
}
/** 长按引擎事件: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 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(); // 防移动端长按弹菜单
}
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);
};
}