《Cocos 游戏开发入门》5.2 触摸手势与多点触控

本节把原始指针事件升级成手势:用位移阈值与时间阈值定义点击、双击、长按与滑动,用 Map 按 pointerId 跟踪多指轨迹,用两指间距比值算捏合缩放,并处理 touch-action 与默认行为。所有判定都用假事件流在 vitest 里断言,最后对照 Cocos 3.8 的 EventTouch 与 getAllTouches。

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 已经帮你做了多指跟踪,但它只给「触点」不给「手势」——点击、双击、长按、捏合仍需自己判定。对照表:

要做的事mini2dCocos 3.8
当前有几根手指active.sizeinput.getTouchCount()
本次事件变化的触点单个 RawPointerevent.getTouches()
全部触点遍历 activeevent.getAllTouches()
触点坐标clientToCanvastouch.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 输入缓冲与手感 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画