5.2 触摸手势与多点触控
上一节我们把「手指落在屏幕哪个点」变成了「游戏世界里哪个点」。但玩家不会只做一次点击:他会轻点、双击、按住不放、快速划一下、用两根手指把画面捏小。这些动作在浏览器看来都只是 pointerdown/move/up 的序列,手势是我们在原始事件之上重新识别出来的模式。
本节把《方块冲刺》推进到「能识别手势」这一步:给
InputSystem加一层GestureRecognizer,用位移与时间两组阈值区分点击 / 双击 / 长按 / 滑动,用Map<pointerId, Tracked>跟踪多根手指,用两指间距比值算捏合。判定逻辑全是纯函数式的,可以直接喂假事件流做单测。
5.2.1 手势是「有时间维度的模式」
把每种手势拆开看,它们其实都是位移、时长、指数量三个变量的组合:
| 手势 | 触发条件 | 关键阈值 |
|---|---|---|
| 点击 tap | 按下后很快抬起,几乎没移动 | 时长 ≤ 250ms,位移 ≤ 10px |
| 双击 double tap | 两次 tap 挨得很近、间隔很短 | 间隔 ≤ 300ms,两击距离 ≤ 20px |
| 长按 long press | 按住不动超过一定时间 | 时长 ≥ 500ms,位移 ≤ 10px |
| 滑动 swipe | 快速移动后抬起 | 位移 ≥ 40px,方向由 dx/dy 决定 |
| 捏合 pinch | 两指同时按下并改变间距 | 间距比值 → 缩放系数 |
阈值不是拍脑袋定的,它们来自人体工学:成人轻点持续时间通常在 100ms 上下,250ms 是「按住」与「轻点」的经验分界;40px 在手机上是约 8mm,短于此距离的移动更像手抖而非滑动。这些数字都该是可配置的常量,而不是散落在代码里的魔法数。
5.2.2 跟踪多指轨迹
识别手势的第一步是记住每根手指从哪来、到哪去。Pointer Events 的 pointerId 正好给了我们这把钥匙——按 id 建一张表,down 时登记,move 时更新,up/cancel 时结算:
// src/input/gesture-types.ts
export type GestureKind = "tap" | "doubletap" | "longpress" | "swipe" | "pinch";
export interface GestureEvent {
kind: GestureKind;
id: number;
x: number;
y: number;
dx?: number;
dy?: number;
scale?: number;
time: number;
}
export interface GestureConfig {
tapMaxMove: number;
tapMaxDuration: number;
doubleTapGap: number;
longPressTime: number;
swipeMinDistance: number;
}
export const DEFAULT_GESTURE_CONFIG: GestureConfig = {
tapMaxMove: 10,
tapMaxDuration: 250,
doubleTapGap: 300,
longPressTime: 500,
swipeMinDistance: 40,
};
注意这里用联合类型而不是 enum——Node 26 只剥离类型、不生成运行时代码,enum 会在运行时消失,而字符串联合类型既类型安全又不留运行时包袱。
5.2.3 从事件流到手势
下面这个 GestureRecognizer 是本节的核心。它不碰 DOM,只接收 RawPointer 并吐出 GestureEvent,因此完全可测:
// src/input/gesture.ts
import { RawPointer } from "./input-system";
import { GestureConfig, GestureEvent } from "./gesture-types";
interface Tracked {
id: number;
startX: number;
startY: number;
lastX: number;
lastY: number;
startTime: number;
}
export class GestureRecognizer {
private readonly cfg: GestureConfig;
private readonly emit: (g: GestureEvent) => void;
private readonly active = new Map<number, Tracked>();
private lastTapTime = Number.NEGATIVE_INFINITY;
private lastTapX = 0;
private lastTapY = 0;
constructor(cfg: GestureConfig, emit: (g: GestureEvent) => void) {
this.cfg = cfg;
this.emit = emit;
}
feed(p: RawPointer): void {
if (p.phase === "down") {
this.active.set(p.id, {
id: p.id, startX: p.clientX, startY: p.clientY,
lastX: p.clientX, lastY: p.clientY, startTime: p.time,
});
if (this.active.size === 2) this.emitPinch(1, p.time);
return;
}
const t = this.active.get(p.id);
if (!t) return;
if (p.phase === "move") {
t.lastX = p.clientX;
t.lastY = p.clientY;
if (this.active.size === 2) this.emitPinch(this.pinchScale(), p.time);
return;
}
this.active.delete(p.id);
if (p.phase === "cancel") return;
const dx = p.clientX - t.startX;
const dy = p.clientY - t.startY;
const dist = Math.hypot(dx, dy);
const dur = p.time - t.startTime;
if (dist > this.cfg.swipeMinDistance) {
this.emit({ kind: "swipe", id: p.id, x: p.clientX, y: p.clientY, dx, dy, time: p.time });
return;
}
if (dur > this.cfg.longPressTime) {
this.emit({ kind: "longpress", id: p.id, x: p.clientX, y: p.clientY, time: p.time });
return;
}
if (dur <= this.cfg.tapMaxDuration && dist <= this.cfg.tapMaxMove) {
const near = Math.hypot(p.clientX - this.lastTapX, p.clientY - this.lastTapY)
<= this.cfg.tapMaxMove * 2;
const quick = p.time - this.lastTapTime <= this.cfg.doubleTapGap;
this.emit({
kind: near && quick ? "doubletap" : "tap",
id: p.id, x: p.clientX, y: p.clientY, time: p.time,
});
this.lastTapTime = p.time;
this.lastTapX = p.clientX;
this.lastTapY = p.clientY;
}
}
private pinchScale(): number {
const pts = [...this.active.values()];
const d0 = Math.hypot(pts[0].startX - pts[1].startX, pts[0].startY - pts[1].startY);
const d1 = Math.hypot(pts[0].lastX - pts[1].lastX, pts[0].lastY - pts[1].lastY);
return d0 === 0 ? 1 : d1 / d0;
}
private emitPinch(scale: number, time: number): void {
this.emit({ kind: "pinch", id: -1, x: 0, y: 0, scale, time });
}
}
判定的顺序很关键:先看位移(够远就是滑动),再看时长(够久就是长按),最后才是点击。如果顺序反了,一次「按住后拖走再松手」会被误判成长按。每一类手势只允许被识别一次——命中滑动就 return,不再往下走。
5.2.4 捏合:用间距比值算缩放
捏合没有单独的「事件」,它是两指移动的副产物。思路很直接:记录两指初始间距 d0,每次移动算当前间距 d1,比值 d1/d0 就是相对缩放。pinchScale() 正是这么写的,注意除零保护——两指恰好重合时 d0 为 0。
把缩放接到相机上只有一行:camera.zoom = pinchStartZoom * scale。必须缓存捏合开始时的 zoom,否则每帧乘以 scale 会指数级放大。第 2.3 节的相机若把 zoom 做成只读属性,这里就要改成「基准 zoom × 累计比值」的写法。
5.2.5 touch-action 与默认行为
手势能不能被正确识别,一半取决于浏览器肯不肯把事件让给你。移动端浏览器默认会把手势解释成页面滚动、双指缩放、长按选词。要用 CSS 明确声明这块区域由游戏接管:
/* 画布:禁止浏览器接管手势 */
#game-canvas {
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
touch-action: none 告诉浏览器「这个元素的触摸别做默认处理」。但只靠 CSS 还不够,某些浏览器在 pointerdown 后仍会触发滚动的「惯性」判定,所以监听要配合 passive: false 并显式阻止:
// 在 InputSystem 的 handler 里
if (phase === "down" || phase === "move") {
ev.preventDefault(); // 需要监听注册时 passive: false
}
还有一个容易被忽略的细节:pointerup 之后浏览器可能补发一串 click。如果游戏里同时用了 click 监听,就会一次操作触发两次逻辑。要么不用 click,要么在 pointerdown 里 preventDefault() 掉。
5.2.6 用假事件流测手势
手势识别是纯逻辑,测试时不需要真的触摸屏幕,喂一串带时间戳的事件即可:
// test/gesture.test.ts
import { describe, it, expect } from "vitest";
import { GestureRecognizer } from "../src/input/gesture";
import { DEFAULT_GESTURE_CONFIG, GestureEvent } from "../src/input/gesture-types";
import { RawPointer } from "../src/input/input-system";
function feed(rec: GestureRecognizer, events: Partial<RawPointer>[]): GestureEvent[] {
const out: GestureEvent[] = [];
for (const e of events) {
rec.feed({
id: e.id ?? 1, phase: e.phase ?? "move",
clientX: e.clientX ?? 0, clientY: e.clientY ?? 0, time: e.time ?? 0,
});
}
return out;
}
describe("手势识别", () => {
it("短按不动是 tap", () => {
const got: GestureEvent[] = [];
const rec = new GestureRecognizer(DEFAULT_GESTURE_CONFIG, (g) => got.push(g));
rec.feed({ id: 1, phase: "down", clientX: 100, clientY: 100, time: 0 });
rec.feed({ id: 1, phase: "up", clientX: 102, clientY: 101, time: 80 });
expect(got.map((g) => g.kind)).toEqual(["tap"]);
});
it("快速划走是 swipe 而不是 longpress", () => {
const got: GestureEvent[] = [];
const rec = new GestureRecognizer(DEFAULT_GESTURE_CONFIG, (g) => got.push(g));
rec.feed({ id: 1, phase: "down", clientX: 100, clientY: 100, time: 0 });
rec.feed({ id: 1, phase: "move", clientX: 200, clientY: 100, time: 600 });
rec.feed({ id: 1, phase: "up", clientX: 200, clientY: 100, time: 620 });
expect(got.map((g) => g.kind)).toEqual(["swipe"]);
expect(got[0].dx).toBe(100);
});
it("两指拉大间距产生 scale > 1 的 pinch", () => {
const got: GestureEvent[] = [];
const rec = new GestureRecognizer(DEFAULT_GESTURE_CONFIG, (g) => got.push(g));
rec.feed({ id: 1, phase: "down", clientX: 0, clientY: 0, time: 0 });
rec.feed({ id: 2, phase: "down", clientX: 100, clientY: 0, time: 0 });
rec.feed({ id: 1, phase: "move", clientX: -50, clientY: 0, time: 16 });
rec.feed({ id: 2, phase: "move", clientX: 150, clientY: 0, time: 16 });
const pinch = got.filter((g) => g.kind === "pinch").pop();
expect(pinch?.scale).toBeCloseTo(2);
});
});
第二条测试专门锁住「判定顺序」:一次 620ms、位移 100px 的操作,必须判成滑动而不是长按。这类顺序 bug 不测就会漏。
5.2.7 Cocos 对照:EventTouch 与多点触控
Cocos 的 EventTouch 已经帮你做了多指跟踪,但它只给「触点」不给「手势」——点击、双击、长按、捏合仍需自己判定。对照表:
| 要做的事 | mini2d | Cocos 3.8 |
|---|---|---|
| 当前有几根手指 | active.size | input.getTouchCount() |
| 本次事件变化的触点 | 单个 RawPointer | event.getTouches() |
| 全部触点 | 遍历 active | event.getAllTouches() |
| 触点坐标 | clientToCanvas | touch.getUILocation(vec2) |
| 最大触点数 | 自定 | EventTouch.MAX_TOUCHES |
import { _decorator, Component, input, Input, EventTouch, Vec2 } from 'cc';
const { ccclass } = _decorator;
@ccclass('GestureBridge')
export class GestureBridge extends Component {
onEnable(): void {
input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this);
}
onDisable(): void {
input.off(Input.EventType.TOUCH_MOVE, this.onTouchMove, this);
}
private onTouchMove(event: EventTouch): void {
if (input.getTouchCount() < 2) return;
const all = event.getAllTouches();
const a = all[0].getUILocation(new Vec2());
const b = all[1].getUILocation(new Vec2());
const dist = Vec2.distance(a, b); // 两指间距
console.log('双指间距', dist, '上限', EventTouch.MAX_TOUCHES);
}
}
注意 getTouches() 与 getAllTouches() 的区别:前者只返回本次事件里有变化的触点(第二根手指刚按下时,只有它的信息),后者返回全部。做捏合要算两指间距,必须用 getAllTouches(),否则只有一根手指的数据会算出错误比值——这正是 mini2d 里我们遍历整张 active 表的原因。
下一节我们聊一个更微妙的话题:同样的判定逻辑,为什么有的游戏「跟手」、有的「发飘」。答案不在手势识别里,而在输入与游戏循环之间那层缓冲。
阅读导航:上一节:5.1 输入事件模型与坐标换算 · 下一节:5.3 输入缓冲与手感 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。