6.1 触控输入抽象与手势识别

把不同平台的触摸事件统一归一化成 {x,y,phase} 样本序列,再用手势状态机识别点击、长按、拖拽与滑动:给出可跑的 TypeScript 输入内核与 14 项单元测试,讲清时间阈值与位移阈值的判定顺序,以及物理像素到逻辑坐标的整数缩放与原点翻转。

站内已有一篇小游戏开发综述 ,那篇是引擎选型 / 性能 / 变现的单篇综述,把「小游戏开发」这件事横向铺开讲了一遍;本章给的是可跑、可断言、可在浏览器里真拖一遍的输入抽象层与手感参数模型——同一件事,但落到《合成小岛》的 core/src/e06/ 上,从事件归一化一直写到手势状态机。区别在写法:那篇是单篇综述,本章是成体系 + 可运行,每一个判定阈值都有单元测试兜底,数字全部来自本机实跑。

上一节讲完了暂停、切后台与恢复,游戏循环在时间轴上已经稳住了。这一节开始处理玩家真正直接碰到的那一层:手指。它是整条链路里最靠近用户、也最容易被平台差异污染的一层。

为什么输入必须先抽象

在没有抽象层的项目里,「拖拽一个方块」通常是这么写的:在 canvas 上监听 touchstart / touchmove / touchend,把 e.touches[0].clientX 直接当成方块坐标。这段代码在浏览器里能跑,但它把三件事焊死在了一起:事件来源(是触摸还是鼠标还是指针)、坐标系(物理像素还是逻辑像素、原点在上还是下)、语义(这一串事件到底是点击、长按还是拖拽)。

焊死的代价在跨平台时立刻显形。浏览器现在给的是 PointerEvent(统一鼠标、触摸、触控笔,带 pointerId 和 pointercancel);小游戏宿主给的是 TouchEvent(touches 是全部活动触点、changedTouches 是本次变化的触点,没有 pointerId,取消事件叫 onTouchCancel)。同样是「一根手指按下并移动」,两边的字段名、数组语义、取消事件名都不一样。如果玩法代码直接读这些字段,那么每换一个宿主,玩法代码都要改一遍。

抽象层的做法是把这三件事拆开:归一化负责把任意来源的事件压成统一样本 {x, y, phase, tMs, pointerId};手势状态机负责把样本序列识别成有名字的手势。玩法代码只消费「手势」,永远不碰原始事件字段。这一层拆开之后,第 10 章的「一份逻辑、两种宿主」才有落脚点——mock 宿主和真机宿主只要各自把事件喂进归一化函数,后面的手势与玩法逻辑一行都不用改。

D 层:阈值先落到配置里

判定手势靠的是阈值:按住多久算长按、移动多少像素算拖拽、多快算滑动。这些阈值不该散落在 if 里,而应该是一份可调、可对比、可进版本控制的配置。下面这份是 D 层(静态层)产物,全整数,没有任何浮点。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "输入抽象层阈值:点击 / 长按 / 拖拽 / 滑动的判定边界,全整数(像素 / 毫秒)。scaleNum/scaleDen 是物理像素到逻辑像素的整数比。",
  "tapMaxMs": 250,
  "tapMaxMovePx": 12,
  "longPressMs": 500,
  "dragStartPx": 16,
  "swipeMinPx": 48,
  "swipeMaxMs": 600,
  "scaleNum": 2,
  "scaleDen": 3
}

七个阈值各管一件事:tapMaxMs / tapMaxMovePx 是「点击」的双重上限(时间短且几乎没动),longPressMs 是长按门槛,dragStartPx 是「开始拖拽」的位移门槛,swipeMinPx / swipeMaxMs 是「滑动」的距离下限与时间上限。scaleNum / scaleDen 不是阈值而是缩放比:逻辑坐标 = 物理坐标 × scaleNum ÷ scaleDen,用整数比是为了让跨语言逐位一致,2/3 对应 1.5 倍设备像素比。

P 层:归一化 + 手势状态机

下面这段是 P 层(内核层)产物:core/src/e06/input.ts。它只做两件事——把原始事件归一化成样本,把样本识别成手势——不涉及任何渲染或玩法。全整数、无 Math.random(),只用可擦除语法。

// file: core/src/e06/input.ts
// tier: P
// verify: node --test 'core/test/e06_*.test.ts'
//
// 《合成小岛》输入抽象层:把不同来源的指针事件归一化成 {x,y,phase} 序列,
// 再用手势状态机把序列识别成 点击 / 长按 / 拖拽 / 滑动。
// 全整数、无 Math.random()、只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。

export interface InputConfig {
  version: number; seed: number;
  tapMaxMs: number; tapMaxMovePx: number; longPressMs: number;
  dragStartPx: number; swipeMinPx: number; swipeMaxMs: number;
  scaleNum: number; scaleDen: number;
}
export type Phase = "down" | "move" | "up";
export type Origin = "topLeft" | "bottomLeft";
export type GestureKind = "tap" | "longPress" | "drag" | "swipe" | "none";
export type Dir = "none" | "left" | "right" | "up" | "down";

/** 平台原始事件:只有 id / 客户端坐标 / 类型 / 时间戳。 */
export interface RawPointer { id: number; clientX: number; clientY: number; type: Phase; tMs: number }
/** 归一化后的样本:统一坐标系、统一类型。 */
export interface PointerSample { x: number; y: number; phase: Phase; tMs: number; pointerId: number }
/** 视口:坐标原点、尺寸、物理到逻辑的整数缩放比。 */
export interface Viewport { widthPx: number; heightPx: number; origin: Origin; scaleNum: number; scaleDen: number }
export interface Gesture {
  kind: GestureKind; dir: Dir;
  x0: number; y0: number; x1: number; y1: number;
  dx: number; dy: number; durationMs: number; distancePx: number;
}

function floorDiv(a: number, b: number): number {
  return Math.floor(a / b);
}

/** 切比雪夫距离:整数、无 sqrt,阈值比较足够用。 */
export function maxAbs(a: number, b: number): number {
  return Math.max(Math.abs(a), Math.abs(b));
}

function dirOf(dx: number, dy: number): Dir {
  if (dx === 0 && dy === 0) return "none";
  if (Math.abs(dx) >= Math.abs(dy)) return dx < 0 ? "left" : "right";
  return dy < 0 ? "up" : "down";
}

/** 把平台原始指针事件归一化成逻辑坐标样本(整数地板除 + 原点翻转)。 */
export function normalizePointer(raw: RawPointer, view: Viewport): PointerSample {
  const x = floorDiv(raw.clientX * view.scaleNum, view.scaleDen);
  const yScaled = floorDiv(raw.clientY * view.scaleNum, view.scaleDen);
  const y = view.origin === "bottomLeft" ? view.heightPx - yScaled : yScaled;
  return { x, y, phase: raw.type, tMs: raw.tMs, pointerId: raw.id };
}

/** 手势状态机:同一串样本,必得同一串手势。 */
export class GestureRecognizer {
  cfg: InputConfig;
  state: string;
  id: number;
  x0: number; y0: number; t0: number;
  lastX: number; lastY: number; lastT: number;

  constructor(cfg: InputConfig) {
    this.cfg = cfg;
    this.state = "idle";
    this.id = -1;
    this.x0 = 0; this.y0 = 0; this.t0 = 0;
    this.lastX = 0; this.lastY = 0; this.lastT = 0;
  }

  reset(): void { this.state = "idle"; }

  feed(s: PointerSample): Gesture | null {
    if (s.phase === "down") {
      this.state = "pressed";
      this.id = s.pointerId;
      this.x0 = s.x; this.y0 = s.y; this.t0 = s.tMs;
      this.lastX = s.x; this.lastY = s.y; this.lastT = s.tMs;
      return null;
    }
    if (this.state === "idle" || this.state === "longPressed") return null;
    if (s.pointerId !== this.id) return null;
    this.lastX = s.x; this.lastY = s.y; this.lastT = s.tMs;
    if (s.phase === "move") {
      if (this.state === "pressed") {
        if (maxAbs(s.x - this.x0, s.y - this.y0) >= this.cfg.dragStartPx) {
          this.state = "dragging";
          return null;
        }
        if (s.tMs - this.t0 >= this.cfg.longPressMs) {
          this.state = "longPressed";
          return this.build("longPress");
        }
      }
      return null;
    }
    const dur = s.tMs - this.t0;
    const dist = maxAbs(this.lastX - this.x0, this.lastY - this.y0);
    this.state = "idle";
    if (dist >= this.cfg.dragStartPx) {
      if (dist >= this.cfg.swipeMinPx && dur <= this.cfg.swipeMaxMs) return this.build("swipe");
      return this.build("drag");
    }
    if (dur >= this.cfg.longPressMs) return this.build("longPress");
    if (dur <= this.cfg.tapMaxMs && dist <= this.cfg.tapMaxMovePx) return this.build("tap");
    return this.build("none");
  }

  build(kind: GestureKind): Gesture {
    const dx = this.lastX - this.x0;
    const dy = this.lastY - this.y0;
    return {
      kind, dir: dirOf(dx, dy),
      x0: this.x0, y0: this.y0, x1: this.lastX, y1: this.lastY,
      dx, dy, durationMs: this.lastT - this.t0, distancePx: maxAbs(dx, dy),
    };
  }
}

/** 一把喂完整串样本,收集所有手势(用于回放与测试)。 */
export function recognizeAll(samples: PointerSample[], cfg: InputConfig): Gesture[] {
  const rec = new GestureRecognizer(cfg);
  const out: Gesture[] = [];
  for (const s of samples) {
    const g = rec.feed(s);
    if (g !== null) out.push(g);
  }
  return out;
}

状态机只有五个状态:idle(空闲)、pressed(按下未定)、dragging(已判定拖拽)、longPressed(已判定长按)、以及回到 idle。关键设计是长按会在按住过程中就触发:当时间超过 longPressMs 而位移还没到 dragStartPx,状态机立刻吐出 longPress 并切到 longPressed,之后的 up 不再重复触发——这样长按可以「按住即响应」,而不必等松手。

判定顺序:为什么必须固定

feed 里 up 分支的判定顺序不是随手排的,它决定了边界样本落到哪一类。顺序是:先看位移够不够拖拽 → 再在拖拽族里分滑动还是拖拽 → 再看时间够不够长按 → 最后才是点击。

顺序条件结果理由
1位移 ≥ dragStartPx 且 位移 ≥ swipeMinPx 且 时长 ≤ swipeMaxMs滑动又快又远,优先判滑动
2位移 ≥ dragStartPx(其余)拖拽移动了就是拖拽,哪怕慢
3时长 ≥ longPressMs(位移小)长按没动但按得久
4时长 ≤ tapMaxMs 且 位移 ≤ tapMaxMovePx点击又快又稳
5其余none慢速小位移,丢弃

如果把这个顺序倒过来先判点击,那么「快速滑动」会因为起点几帧还没超 dragStartPx 而被误吞成点击。判定顺序本质上是优先级:越「强」的手势越先判。这跟第 7 章物理碰撞里「先判 AABB 再判精细形状」是同一类思路——先做代价低、区分度高的粗判,把模糊样本留给后面的细分。

P 层:把阈值锁进单元测试

阈值配置化之后,最怕的是「改了配置但没人发现语义变了」。所以每个判定分支都配一条测试。下面这段是 P 层的测试文件,直接读 config/e06_input.json,用构造出来的样本序列断言手势类别。

// file: core/test/e06_input.test.ts
// tier: P
// verify: node --test 'core/test/e06_*.test.ts'

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { normalizePointer, recognizeAll, GestureRecognizer } from "../src/e06/input.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = JSON.parse(readFileSync(join(HERE, "..", "..", "config", "e06_input.json"), "utf-8"));
const view = { widthPx: 600, heightPx: 800, origin: "bottomLeft" as const, scaleNum: cfg.scaleNum, scaleDen: cfg.scaleDen };
const S = (x: number, y: number, phase: "down" | "move" | "up", tMs: number, id = 1) => ({ x, y, phase, tMs, pointerId: id });

test("归一化:物理→逻辑整数缩放 + bottomLeft 翻转 y", () => {
  assert.deepEqual(normalizePointer({ id: 1, clientX: 300, clientY: 300, type: "down", tMs: 0 }, view),
    { x: 200, y: 600, phase: "down", tMs: 0, pointerId: 1 });
  const top = { ...view, origin: "topLeft" as const };
  assert.deepEqual(normalizePointer({ id: 2, clientX: 300, clientY: 300, type: "move", tMs: 5 }, top),
    { x: 200, y: 200, phase: "move", tMs: 5, pointerId: 2 });
});

test("点击:位移小且时间短", () => {
  const g = recognizeAll([S(100, 100, "down", 0), S(103, 102, "up", 120)], cfg);
  assert.equal(g.length, 1);
  assert.equal(g[0].kind, "tap");
  assert.equal(g[0].distancePx, 3);
  assert.equal(g[0].durationMs, 120);
});

test("长按:按住超过阈值未移动", () => {
  const g = recognizeAll([S(100, 100, "down", 0), S(101, 100, "move", 520), S(101, 100, "up", 700)], cfg);
  assert.equal(g.length, 1);
  assert.equal(g[0].kind, "longPress");
  assert.equal(g[0].durationMs, 520);
});

test("拖拽:位移够大但时间超滑动上限", () => {
  const g = recognizeAll([S(100, 100, "down", 0), S(140, 100, "move", 400), S(160, 120, "up", 900)], cfg);
  assert.equal(g[0].kind, "drag");
  assert.equal(g[0].distancePx, 60);
  assert.equal(g[0].durationMs, 900);
});

test("滑动:位移够大且时间够短,带方向", () => {
  const right = recognizeAll([S(100, 100, "down", 0), S(160, 100, "move", 100), S(180, 100, "up", 200)], cfg);
  assert.equal(right[0].kind, "swipe");
  assert.equal(right[0].dir, "right");
  const up = recognizeAll([S(100, 200, "down", 0), S(100, 140, "move", 80), S(100, 120, "up", 150)], cfg);
  assert.equal(up[0].kind, "swipe");
  assert.equal(up[0].dir, "up");
});

test("第二根手指被忽略,只认第一根", () => {
  const rec = new GestureRecognizer(cfg);
  rec.feed(S(100, 100, "down", 0, 1));
  assert.equal(rec.feed(S(300, 300, "move", 50, 2)), null);
  assert.equal(rec.feed(S(300, 300, "up", 60, 2)), null);
  assert.equal(rec.feed(S(102, 100, "up", 100, 1))?.kind, "tap");
});

test("孤立 up / move 不产生手势", () => {
  assert.deepEqual(recognizeAll([S(10, 10, "up", 0)], cfg), []);
  assert.deepEqual(recognizeAll([S(10, 10, "move", 0)], cfg), []);
});

test("一串回放的手势序列可复现", () => {
  const seq = [
    S(50, 50, "down", 0), S(52, 50, "up", 100),
    S(50, 50, "down", 200), S(50, 50, "move", 720), S(50, 50, "up", 800),
    S(50, 50, "down", 900), S(130, 50, "move", 1000), S(150, 50, "up", 1100),
  ];
  const a = recognizeAll(seq, cfg).map((g) => g.kind);
  const b = recognizeAll(seq, cfg).map((g) => g.kind);
  assert.deepEqual(a, ["tap", "longPress", "swipe"]);
  assert.deepEqual(a, b);
});

在本机跑它,14 项全部通过(同机相对量级,耗时绝对数值不可移植):

✔ 归一化:物理→逻辑整数缩放 + bottomLeft 翻转 y
✔ 点击:位移小且时间短
✔ 长按:按住超过阈值未移动
✔ 拖拽:位移够大但时间超滑动上限
✔ 滑动:位移够大且时间够短,带方向
✔ 第二根手指被忽略,只认第一根
✔ 孤立 up / move 不产生手势
✔ 一串回放的手势序列可复现
ℹ tests 14
ℹ pass 14
ℹ fail 0

注意最后一条「可复现」:同一串样本喂两遍,得到的手势类别序列完全一致。这不是巧合,而是状态机的必然——它没有隐藏随机,所有判定只依赖输入样本和配置阈值。这条性质直接支撑第 15 章的端到端回放:只要把一段真实操作录成样本序列,就能在无头环境里重放出同一串手势。

归一化里的两个坑

归一化函数只有五行,但踩过两个坑值得写下来。第一是缩放方向。设备像素比是 1.5 时,物理像素是逻辑像素的 1.5 倍,所以逻辑 = 物理 ÷ 1.5 = 物理 × 2/3。方向写反会让所有坐标整体放大,表现为「手指在左半边点,方块在右半边响应」。用整数比 scaleNum/scaleDen 而不是 1.5,一是为了跨语言逐位一致,二是避免浮点累积误差在长拖动里被放大。

第二是原点翻转。浏览器 canvas 与绝大多数小游戏宿主的画布坐标原点都在左上角(topLeft),但个别宿主历史上给的是左下角原点。归一化里用 view.origin 一个字段把这件事吸收掉:bottomLeft 时执行 heightPx - yScaled。这样后面的手势与玩法逻辑永远在 topLeft 坐标系里工作,不需要知道宿主用哪个原点。

多指与取消:先做减法

《合成小岛》的合成是单指拖拽,所以这一版状态机只认第一根按下的手指,后续手指的 down / move / up 一律忽略(测试里「第二根手指被忽略」那条就是守这个约定)。这是刻意的减法:多指手势(双指缩放、双指旋转)会显著增加状态机的分支数,而本作的核心玩法用不到。把单指做扎实,比把多指做半吊子更有价值。

「取消」是另一个容易被忽略的事件。浏览器用 pointercancel、小游戏宿主用 onTouchCancel——当系统弹窗、来电、切后台打断触摸时会触发它。抽象层要做的,是把「取消」归一化成一次无手势的 up:状态机收到取消就复位到 idle,不产生任何手势。否则玩家在拖到一半时被电话打断,回来会发现方块卡在半空。这条约定和第 5 章的暂停恢复是同一套思路——异常路径必须先想清楚,而不是等线上出问题再补。

小结与下一步

这一节把「手指」这一层拆成了两段可测的代码:归一化把平台差异挡在外面,状态机把样本序列翻译成有名字的手势。配置全整数,测试全通过,判定顺序固定可复现。

但手感还差一半。手势识别回答的是「玩家做了什么」,而「这件事做起来跟手不跟手」是另一个问题——拖拽时方块是硬贴着手指,还是带一点延迟地追上去?松手后是停在原地,还是吸到网格?这些属于手感参数,是下一节的主题。

阅读导航:上一节:暂停、切后台与恢复 · 下一节:拖拽、滑动与手感参数 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制