《移动游戏产品的架构·客户端与移动运行时》7.3 触控仲裁与误触防护

触控仲裁不是命中测试,而是按时间、位移与手指数决定这一下算点击、滑动、长按还是多指。本节给出四类手势的阈值状态机,把弹窗遮罩、UI 与玩法输入的优先级写进同一套输入门,并说明边缘防误触与连点冷却如何用可回归测试锁住,避免一次手势被投递两遍。

事故:一次滑动被判成了两次点击

《潮汐消消》的棋盘支持拖动交换,也支持点击选中。某次版本后,玩家快速拖动棋子时偶尔会「松手后原地选中」,紧接着又触发一次交换;更糟的是双指缩放棋盘时,第二根手指落下前的那一下被当成点击,直接选错了格子。排查发现:命中测试在按下时就调用了业务回调,抬起时又按位移判断了一次,同一个手势被投递了两遍。

这类问题的根源是把「命中哪个目标」和「这一下算什么动作」混在一起。命中测试只回答目标;仲裁还要回答动作类型、是否投递、投递给谁,以及在多指与弹窗介入时如何收尾。

仲裁不是命中测试

命中测试是纯几何:点落在哪个矩形里。仲裁是有状态的时序判断:从按下到抬起,位移多少、持续多久、有没有第二根手指、期间弹窗是否打开。只有仲裁通过,动作才应投递给业务层。

阶段回答的问题失败后果
命中点在哪个目标上点空、点到错误控件
仲裁这一下是什么手势、是否投递一次手势触发两次、滑动被当点击
路由投递给玩法还是 UI弹窗打开时仍操作棋盘
防护边缘、连点、慢松手是否拦截系统手势截走、误触购买

四类手势与阈值

模型用四个阈值把手指轨迹切成离散动作。阈值是实验参数,不是平台规范;它们应当写进配置并随回归测试一起版本化。

手势判据模型阈值典型误判
点击 tap位移 ≤ slop,时长 ≤ tapMax12 设计单位 / 250ms滑动起手被当点击
长按 long-press位移 ≤ slop,持续 ≥ longPress500ms按住无反馈,松手才触发
滑动 swipe抬起时位移 ≥ swipeMin32 设计单位抖动被当滑动
多指 multi-touch抬起前出现第二个指针2 指缩放误触棋盘

250–500ms 之间松手既不够快也不算长按,模型直接丢弃并返回 slow。这不是苛刻,而是防误触:玩家犹豫后松手,通常不希望执行一次不可撤销的操作。长按由外部 tick 驱动,而不是等到抬起才判断,否则按住 500ms 没有任何反馈。第二根手指一旦落下,整段手势就归为多指;哪怕最后一根手指移动很远,抬起时也不会降级为滑动。

UI 与玩法的优先级

弹窗打开时,输入门必须切换:遮罩范围内的点击被吞掉,模态内部只允许模态控件接收,玩法层完全不可达。模型用 setGate 表达这一点,并在开门时取消已经按下的玩法手势,避免「手指还按着,弹窗弹出后抬起触发按钮」。

同一屏幕内,后绘制的目标优先。测试里把 UI 按钮放在玩法棋盘之后,重叠区域的点击就落到按钮上。这个顺序必须由渲染层与输入层共享;如果两边各自排序,视觉上在最上面的控件可能收不到点击。

连点冷却按目标 id 记录。UI 按钮的 300ms 冷却可以直接复用;棋盘的连点语义不同,应按业务单独配置,不能把按钮的冷却照搬到棋子上。

可运行模型:popup-arbiter.ts

模型是事件驱动的:begin/move/end/cancel 接收指针样本,tick 负责长按计时,时间全部由调用方传入,因此测试不依赖真实计时器。

// file: src/ui/popup-arbiter.ts
// tier: L1
// verify: node --test test/ui/popup-arbiter.test.ts

export interface Point {
  readonly x: number;
  readonly y: number;
}

export interface Box {
  readonly x: number;
  readonly y: number;
  readonly width: number;
  readonly height: number;
}

export type Surface = "gameplay" | "ui" | "modal";

export interface HitTarget {
  readonly id: string;
  readonly surface: Surface;
  readonly box: Box;
}

export interface TouchGate {
  readonly modalBox: Box | null;
}

export interface TouchSurface {
  readonly width: number;
  readonly height: number;
}

export interface TouchConfig {
  readonly tapSlop: number;
  readonly tapMaxMs: number;
  readonly longPressMs: number;
  readonly swipeMin: number;
  readonly edgeGuard: number;
  readonly tapCooldownMs: number;
}

export const DEFAULT_TOUCH: TouchConfig = {
  tapSlop: 12,
  tapMaxMs: 250,
  longPressMs: 500,
  swipeMin: 32,
  edgeGuard: 16,
  tapCooldownMs: 300,
};

export type PointerPhase = "down" | "move" | "up" | "cancel";

export interface PointerSample {
  readonly id: number;
  readonly phase: PointerPhase;
  readonly x: number;
  readonly y: number;
  readonly atMs: number;
}

export type SwipeDirection = "left" | "right" | "up" | "down";

export type BlockedReason =
  | "edge"
  | "scrim"
  | "modal"
  | "cooldown"
  | "moved"
  | "slow"
  | "cancelled"
  | "unknown-pointer";

export type TouchDecision =
  | { readonly kind: "pending"; readonly target: string }
  | { readonly kind: "tap"; readonly target: string; readonly at: Point }
  | { readonly kind: "long-press"; readonly target: string; readonly at: Point }
  | {
      readonly kind: "swipe";
      readonly target: string;
      readonly direction: SwipeDirection;
      readonly distance: number;
    }
  | { readonly kind: "multi-touch"; readonly pointers: number }
  | { readonly kind: "blocked"; readonly reason: BlockedReason }
  | { readonly kind: "none" };

interface Track {
  readonly id: number;
  readonly start: Point;
  last: Point;
  readonly startedAtMs: number;
  target: HitTarget | null;
  moved: boolean;
  fired: boolean;
  multi: boolean;
}

function inside(box: Box, x: number, y: number): boolean {
  return x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height;
}

function directionOf(dx: number, dy: number): SwipeDirection {
  if (Math.abs(dx) >= Math.abs(dy)) {
    return dx >= 0 ? "right" : "left";
  }
  return dy >= 0 ? "down" : "up";
}

export class TouchArbiter {
  #surface: TouchSurface;
  #targets: readonly HitTarget[];
  #config: TouchConfig;
  #gate: TouchGate = { modalBox: null };
  #tracks = new Map<number, Track>();
  #cooldowns = new Map<string, number>();

  constructor(
    surface: TouchSurface,
    targets: readonly HitTarget[],
    config: TouchConfig = DEFAULT_TOUCH,
  ) {
    this.#surface = surface;
    this.#targets = targets;
    this.#config = config;
  }

  get activePointers(): number {
    return this.#tracks.size;
  }

  setGate(gate: TouchGate): void {
    this.#gate = gate;
    if (gate.modalBox !== null) {
      for (const [id, track] of this.#tracks) {
        if (track.target?.surface === "gameplay") {
          this.#tracks.delete(id);
        }
      }
    }
  }

  begin(sample: PointerSample): TouchDecision {
    if (this.#tracks.has(sample.id)) {
      return { kind: "blocked", reason: "unknown-pointer" };
    }
    if (this.#tracks.size > 0) {
      const first = this.#tracks.values().next().value as Track;
      first.moved = true;
      first.multi = true;
      const track: Track = {
        id: sample.id,
        start: { x: sample.x, y: sample.y },
        last: { x: sample.x, y: sample.y },
        startedAtMs: sample.atMs,
        target: null,
        moved: true,
        fired: false,
        multi: true,
      };
      this.#tracks.set(sample.id, track);
      return { kind: "multi-touch", pointers: this.#tracks.size };
    }
    if (this.#nearEdge(sample.x)) {
      return { kind: "blocked", reason: "edge" };
    }
    const target = this.#resolve(sample.x, sample.y);
    if (target === "scrim") {
      return { kind: "blocked", reason: "scrim" };
    }
    if (target === "modal") {
      return { kind: "blocked", reason: "modal" };
    }
    if (target === null) {
      return { kind: "none" };
    }
    this.#tracks.set(sample.id, {
      id: sample.id,
      start: { x: sample.x, y: sample.y },
      last: { x: sample.x, y: sample.y },
      startedAtMs: sample.atMs,
      target,
      moved: false,
      fired: false,
      multi: false,
    });
    return { kind: "pending", target: target.id };
  }

  move(sample: PointerSample): TouchDecision {
    const track = this.#tracks.get(sample.id);
    if (!track) {
      return { kind: "blocked", reason: "unknown-pointer" };
    }
    track.last = { x: sample.x, y: sample.y };
    const distance = Math.hypot(sample.x - track.start.x, sample.y - track.start.y);
    if (distance > this.#config.tapSlop) {
      track.moved = true;
    }
    if (this.#tracks.size > 1) {
      return { kind: "multi-touch", pointers: this.#tracks.size };
    }
    return { kind: "pending", target: track.target?.id ?? "" };
  }

  end(sample: PointerSample): TouchDecision {
    const track = this.#tracks.get(sample.id);
    if (!track) {
      return { kind: "blocked", reason: "unknown-pointer" };
    }
    this.#tracks.delete(sample.id);
    track.last = { x: sample.x, y: sample.y };
    if (this.#tracks.size > 0) {
      return { kind: "multi-touch", pointers: this.#tracks.size + 1 };
    }
    if (track.multi || track.target === null || track.fired) {
      return { kind: "none" };
    }
    const dx = sample.x - track.start.x;
    const dy = sample.y - track.start.y;
    const distance = Math.hypot(dx, dy);
    if (track.moved) {
      if (distance >= this.#config.swipeMin) {
        return {
          kind: "swipe",
          target: track.target.id,
          direction: directionOf(dx, dy),
          distance: Math.round(distance),
        };
      }
      return { kind: "blocked", reason: "moved" };
    }
    const duration = sample.atMs - track.startedAtMs;
    if (duration >= this.#config.longPressMs) {
      return { kind: "long-press", target: track.target.id, at: track.last };
    }
    if (duration > this.#config.tapMaxMs) {
      return { kind: "blocked", reason: "slow" };
    }
    const last = this.#cooldowns.get(track.target.id);
    if (last !== undefined && sample.atMs - last < this.#config.tapCooldownMs) {
      return { kind: "blocked", reason: "cooldown" };
    }
    this.#cooldowns.set(track.target.id, sample.atMs);
    return { kind: "tap", target: track.target.id, at: track.last };
  }

  cancel(sample: PointerSample): TouchDecision {
    const track = this.#tracks.get(sample.id);
    if (!track) {
      return { kind: "blocked", reason: "unknown-pointer" };
    }
    this.#tracks.delete(sample.id);
    return { kind: "blocked", reason: "cancelled" };
  }

  tick(nowMs: number): readonly TouchDecision[] {
    const fired: TouchDecision[] = [];
    for (const track of this.#tracks.values()) {
      if (track.fired || track.moved || track.target === null) {
        continue;
      }
      if (nowMs - track.startedAtMs < this.#config.longPressMs) {
        continue;
      }
      track.fired = true;
      fired.push({ kind: "long-press", target: track.target.id, at: track.last });
    }
    return fired;
  }

  #nearEdge(x: number): boolean {
    return x < this.#config.edgeGuard || x > this.#surface.width - this.#config.edgeGuard;
  }

  #resolve(x: number, y: number): HitTarget | "scrim" | "modal" | null {
    const modal = this.#gate.modalBox;
    if (modal !== null) {
      if (!inside(modal, x, y)) {
        return "scrim";
      }
      for (let i = this.#targets.length - 1; i >= 0; i -= 1) {
        const target = this.#targets[i];
        if (target.surface === "modal" && inside(target.box, x, y)) {
          return target;
        }
      }
      return "modal";
    }
    for (let i = this.#targets.length - 1; i >= 0; i -= 1) {
      const target = this.#targets[i];
      if (inside(target.box, x, y)) {
        return target;
      }
    }
    return null;
  }
}

测试覆盖点击、慢松手、滑动、长按、边缘防护、遮罩、模态内控件、UI 覆盖玩法、多指取消与连点冷却。换阈值只需要改配置,不改状态机。

与相邻章节的关系

输入门的上游是UI 栈、弹窗队列与返回键语义 :弹窗队列决定谁独占屏幕,仲裁器决定这次触摸是否放行。边缘防护与安全区相关,见多分辨率、刘海屏与安全区 。触控、陀螺仪与传感器的统一抽象见触控、陀螺仪与传感器抽象 。

案例设定里,对照案例《潮汐方舟》是 8–12 分钟战斗,拖拽施法与技能连按并存,还需要「拖出范围取消」;主案例《潮汐消消》的 90 秒单局以点击和短滑动为主,阈值可以更紧。两套阈值应各自版本化,不共享一份全局配置。

小结

触控可靠性的关键,是把「命中、仲裁、路由、防护」拆成四层,并让每一层都有确定的返回值。阈值可以调,但状态机不能靠业务回调里的临时判断;一旦一次手势可能投递两次,后面所有防误触都只是在补洞。

阅读导航:上一节:7.2 多分辨率、刘海屏与安全区 · 下一节:8.1 触控、陀螺仪与传感器抽象 。

常见错误与边界

  • 在 down 阶段直接触发点击。按下不等于点击,抬起前一切都可能变成滑动、长按或多指。
  • 只用抬起时间判断长按。按住 500ms 应有反馈,必须由 tick 驱动,而不是等松手。
  • 位移超过 slop 仍投递 tap。slop 是「允许的抖动」,不是「可以移动的距离」。
  • 第二指按下时不取消第一指的待定点击。双指缩放开始前的那一下最容易误选。
  • 弹窗打开时不取消进行中的玩法手势。手指抬起会落到新弹出的按钮上,形成跨层误触。
  • 把 UI 按钮放在边缘防护区内。16pt 的保护带会先于按钮消费事件,按钮看起来完全失灵。
  • 让 UI 与棋盘共享同一套冷却。按钮要防连点,棋盘要允许快速连击,两者语义相反。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查