Files
SoundGame/web/engine/gesture.js
T
SoundGame Dev cc0f8c008d HTML5 重写:6 款游戏 + 用户系统 + 全局设置 + 5 轮迭代重构
- 新增 web/ 纯音频 HTML5 平台:共享引擎(手势/WebAudio 三层/设置/震动)、
  Node 零依赖服务器(静态 + 注册登录 + 进度存档 API)
- 游戏:剑与声(RPG 新作)、声命线、暗室(重设计)、音序回响(重设计)、
  节拍行者(跟随领奏重构)、盲航(同向闪避重构);声东击西删除
- 全局功能:男声/女声双套 TTS 资产、三档语速、BGM 音量三档、
  震动反馈语言(10 种震型)、服务器进度同步
- 资产:899 ogg + 899 m4a(Qwen3-TTS 双性别语音、Stable Audio 3 音效、
  Mureka BGM 4+3 首),终检零缺失
- 文档:docs/design/ 13 篇设计审查文档、docs/audio/ 资产管线、
  web/server/README.md API 文档
2026-08-29 21:55:44 +08:00

191 lines
6.5 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'
*
* 判定规则(与 Android 版 GestureConfig 一致):
* - 滑动:累积位移 >= swipeMinDistance(默认 64px ≈ 64dp@160dpi)
* 且平均速度 >= swipeMinVelocity(默认 400 px/s)。
* 方向取位移较大的轴(|dx|>|dy| 判左右,否则判上下),斜滑归一到最近主轴。
* - 单击/双击:位移 < tapSlop 视为点按;双击窗口 doubleTapWindow(默认 300ms)。
* **单击回调有约 300ms 延迟**(要等双击窗口关闭才能确定不是双击),
* 即时反馈类操作请绑定滑动或双击,单击只用于非即时操作。
*
* 用法:
* 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,位移小于此值才算点按
};
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 lastTapTime = 0;
let pendingTapTimer = null;
// 触摸发生后短时间内忽略合成鼠标事件
let lastTouchEndTime = 0;
function cancelPendingTap() {
if (pendingTapTimer !== null) {
clearTimeout(pendingTapTimer);
pendingTapTimer = null;
}
}
function handleDown(x, y) {
tracking = true;
moved = false;
startX = x;
startY = y;
startTime = performance.now();
}
function handleMove(x, y) {
if (!tracking) return;
if (Math.hypot(x - startX, y - startY) > cfg.tapSlop) moved = true;
}
function handleUp(x, y) {
if (!tracking) return;
tracking = false;
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;
}
// ---- 触摸 ----
// 全部 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();
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);
};
}