事故:一次滑动被判成了两次点击
《潮汐消消》的棋盘支持拖动交换,也支持点击选中。某次版本后,玩家快速拖动棋子时偶尔会「松手后原地选中」,紧接着又触发一次交换;更糟的是双指缩放棋盘时,第二根手指落下前的那一下被当成点击,直接选错了格子。排查发现:命中测试在按下时就调用了业务回调,抬起时又按位移判断了一次,同一个手势被投递了两遍。
这类问题的根源是把「命中哪个目标」和「这一下算什么动作」混在一起。命中测试只回答目标;仲裁还要回答动作类型、是否投递、投递给谁,以及在多指与弹窗介入时如何收尾。
仲裁不是命中测试
命中测试是纯几何:点落在哪个矩形里。仲裁是有状态的时序判断:从按下到抬起,位移多少、持续多久、有没有第二根手指、期间弹窗是否打开。只有仲裁通过,动作才应投递给业务层。
| 阶段 | 回答的问题 | 失败后果 |
|---|---|---|
| 命中 | 点在哪个目标上 | 点空、点到错误控件 |
| 仲裁 | 这一下是什么手势、是否投递 | 一次手势触发两次、滑动被当点击 |
| 路由 | 投递给玩法还是 UI | 弹窗打开时仍操作棋盘 |
| 防护 | 边缘、连点、慢松手是否拦截 | 系统手势截走、误触购买 |
四类手势与阈值
模型用四个阈值把手指轨迹切成离散动作。阈值是实验参数,不是平台规范;它们应当写进配置并随回归测试一起版本化。
| 手势 | 判据 | 模型阈值 | 典型误判 |
|---|---|---|---|
| 点击 tap | 位移 ≤ slop,时长 ≤ tapMax | 12 设计单位 / 250ms | 滑动起手被当点击 |
| 长按 long-press | 位移 ≤ slop,持续 ≥ longPress | 500ms | 按住无反馈,松手才触发 |
| 滑动 swipe | 抬起时位移 ≥ swipeMin | 32 设计单位 | 抖动被当滑动 |
| 多指 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 与棋盘共享同一套冷却。按钮要防连点,棋盘要允许快速连击,两者语义相反。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。