事故:同一套手势,在三台机器上是三种手感
《潮汐消消》的一次版本回归里,QA 用同一段「拖动交换 + 轻微转屏看装饰」的脚本试了三台设备。120Hz 屏上,一次很轻的点按被判成拖动;没有陀螺仪的低端机上,装饰视角完全不动;关闭了运动权限的 iPhone 上同样不动,客服却让玩家去「校准陀螺仪」。
三个现象对应三条不同的边界:事件频率、传感器缺失、权限拒绝。输入层如果只转发原始事件,业务层就只能靠产品名和机型字符串猜;猜错时,玩家看到的是「手感变了」,开发看到的是「无法复现」。
本节的判据是:业务层只接收意图事件,设备差异在输入层被吸收;吸收不了的部分,必须显式降级并留下原因。
从原始事件到意图事件
触点、长按、滑动、缩放和陀螺仪视角看起来是五种输入,归一化后都是带时间戳的意图。下面的映射是输入层的公开契约。
| 原始输入 | 判定依据 | 归一化意图 | 不满足时 |
|---|---|---|---|
| 按下后短时间抬起 | 时长与位移都在阈值内 | tap | 继续等待长按或移动 |
| 按住不动并跨过时间阈值 | 未被移动取消 | long-press | 保持静默 |
| 单指移动 | 位移超过点击容差 | drag(增量) | 视为抖动 |
| 快速抬手 | 距离与速度同时达标 | swipe(方向) | 只结束拖动 |
| 双指距离变化 | 相对初始距离的比例 | pinch(缩放比) | 等待下一次变化 |
| 陀螺仪采样 | 通过采样与过滤管线 | look(角度增量) | 走拖拽视角 |
| 系统取消 | 来电、返回手势、窗口失焦 | cancel | 清理按压状态 |
关键不是字段长什么样,而是业务层不再看到 touchmove 的频率。同一段拖动在 60Hz 和 120Hz 设备上会产生不同数量的原始事件,归一化后只保留增量与时间戳,上层动画按自己的帧预算消费。
手势参数是契约,不是手感
| 参数 | 默认值 | 作用 | 取值失衡的后果 |
|---|---|---|---|
| 点击时限 | 220ms | 区分点按与长按 | 偏大则误触长按,偏小则点不中 |
| 点击容差 | 12px | 吸收手指抖动 | 偏小则拖动起点被误判为点按 |
| 长按时限 | 500ms | 触发持续操作 | 偏小则普通按住就弹菜单 |
| 滑动最小距离 | 40px | 避免小位移误判 | 偏小则拖动结束都成了滑动 |
| 滑动最小速度 | 0.35px/ms | 区分慢拖与快划 | 偏小则慢拖也触发翻页 |
| 缩放阈值 | 8% | 避免双指抖动 | 偏小则轻微抖动就缩放 |
这些值是 mga-lab 的比较参数,不是《潮汐消消》的线上配置。真实项目里,输入坐标必须先换算到与 DPI 无关的空间,再套用阈值;直接把物理像素写死,会在 2K 屏上把一次点按放大成拖动。案例设定中消消低端机占比 35%,这批设备的触控采样率与屏幕刷新率都更低,阈值更需要按归一化坐标而不是像素校准。
手势识别只回答「这是什么意图」,不做 UI 命中测试,也不决定哪个图层优先。跨层仲裁与误触防护属于触控仲裁与误触防护 ,本节不重复。
传感器:先降采样,再过滤
陀螺仪原始采样可能达到 200Hz 以上,但视角不需要每个样本都进渲染。处理顺序固定为:限频、死区、平滑、积分、限步。
| 阶段 | 参数 | 目的 | 省略后的表现 |
|---|---|---|---|
| 限频 | 30Hz | 与视角更新节奏对齐 | 高频样本堆积,主线程抖动 |
| 死区 | 0.6°/s | 滤掉手持微抖 | 静止时视角缓慢漂移 |
| 平滑 | α=0.35 | 抑制单帧尖峰 | 画面随噪声跳动 |
| 积分 | dt 上限 100ms | 把角速度换成角度 | 后台回来一次巨大跳变 |
| 限步 | 4°/样本 | 防止瞬移与穿模 | 快速甩动直接越界 |
限频不是「越省越好」。30Hz 是案例设定下消消的默认帧率档,视角与 30fps 渲染同拍;如果产品默认 60fps,采样目标也应重新评估。积分前把 dt 截到 100ms,是因为进程从后台恢复时相邻样本可能相隔数秒,直接乘会把视角甩到场景外。
这里是模型,不是设备实测;真实陀螺仪的零偏、温漂、厂商融合算法和系统采样策略都需要在目标机型上验证,代码只保证处理顺序与边界。
缺失与拒绝是两种状态
| 可用性 | 视角来源 | 降级原因 | 产品动作 |
|---|---|---|---|
available | 陀螺仪 | none | 提供重置与关闭开关 |
missing | 拖拽 | sensor-missing | 静默降级,不提示硬件问题 |
permission-denied | 拖拽 | sensor-denied | 一次性说明并给设置入口 |
把 missing 和 permission-denied 合成一个布尔值,会直接导致错误话术:硬件没有传感器时让玩家去开权限,权限被拒时又反复弹窗。两者都必须降级到同一套拖拽视角,但运营提示不同。降级后的 look 意图结构不变,业务层不需要写两套代码。
可运行模型:gesture.ts
下面的模型把触点识别、陀螺仪处理与降级封装在同一个文件里。传感器通过 MotionSource 注入,测试用内存样本模拟;不依赖真实设备。
// file: src/input/gesture.ts
// tier: L1
// verify: node --test test/input/gesture.test.ts
export interface TouchPoint { readonly id: number; readonly x: number; readonly y: number; readonly tMs: number; }
export interface GestureConfig {
readonly tapMaxMs: number; readonly tapSlopPx: number; readonly longPressMs: number;
readonly swipeMinPx: number; readonly swipeMinVelocityPxPerMs: number; readonly pinchMinScaleDelta: number;
}
export const DEFAULT_GESTURE: GestureConfig = {
tapMaxMs: 220, tapSlopPx: 12, longPressMs: 500,
swipeMinPx: 40, swipeMinVelocityPxPerMs: 0.35, pinchMinScaleDelta: 0.08,
};
export type SwipeDirection = "up" | "down" | "left" | "right";
export type LookSource = "gyro" | "drag";
export type Intent =
| { kind: "tap"; x: number; y: number; tMs: number }
| { kind: "long-press"; x: number; y: number; tMs: number }
| { kind: "drag"; id: number; dx: number; dy: number; tMs: number }
| { kind: "swipe"; direction: SwipeDirection; distancePx: number; velocityPxPerMs: number; tMs: number }
| { kind: "pinch"; scale: number; centerX: number; centerY: number; tMs: number }
| { kind: "cancel"; id: number; tMs: number }
| { kind: "look"; yawDeg: number; pitchDeg: number; source: LookSource; tMs: number };
interface Track {
startX: number; startY: number; startTMs: number;
lastX: number; lastY: number; maxDistancePx: number;
moved: boolean; longPressSent: boolean;
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/** 触点序列归一化为意图事件;不做命中测试,也不做 7.3 的跨层仲裁。 */
export class GestureRecognizer {
readonly #config: GestureConfig;
readonly #tracks = new Map<number, Track>();
#multiTouched = false;
#pinchBasePx: number | null = null;
#lastPinchScale = 1;
constructor(config: GestureConfig = DEFAULT_GESTURE) {
this.#config = config;
}
down(point: TouchPoint): Intent[] {
this.#tracks.set(point.id, {
startX: point.x, startY: point.y, startTMs: point.tMs,
lastX: point.x, lastY: point.y, maxDistancePx: 0,
moved: false, longPressSent: false,
});
if (this.#tracks.size >= 2) {
this.#multiTouched = true;
this.#pinchBasePx = this.#distanceBetweenFirstTwo();
this.#lastPinchScale = 1;
}
return [];
}
move(point: TouchPoint): Intent[] {
const track = this.#tracks.get(point.id);
if (!track) {
return [];
}
const previousX = track.lastX;
const previousY = track.lastY;
track.lastX = point.x;
track.lastY = point.y;
track.maxDistancePx = Math.max(track.maxDistancePx,
Math.hypot(point.x - track.startX, point.y - track.startY));
if (track.maxDistancePx > this.#config.tapSlopPx) {
track.moved = true;
}
if (this.#tracks.size >= 2) {
return this.#pinch(point.tMs);
}
if (!track.moved) {
return [];
}
return [{ kind: "drag", id: point.id, dx: point.x - previousX, dy: point.y - previousY, tMs: point.tMs }];
}
tick(nowMs: number): Intent[] {
const intents: Intent[] = [];
for (const track of this.#tracks.values()) {
if (track.longPressSent || track.moved || this.#tracks.size >= 2) {
continue;
}
if (nowMs - track.startTMs >= this.#config.longPressMs) {
track.longPressSent = true;
intents.push({ kind: "long-press", x: track.lastX, y: track.lastY, tMs: nowMs });
}
}
return intents;
}
up(point: TouchPoint): Intent[] {
const track = this.#tracks.get(point.id);
if (!track) {
return [];
}
this.#tracks.delete(point.id);
const intents: Intent[] = [];
const durationMs = point.tMs - track.startTMs;
const distancePx = Math.hypot(point.x - track.startX, point.y - track.startY);
if (!this.#multiTouched && !track.moved && durationMs <= this.#config.tapMaxMs) {
intents.push({ kind: "tap", x: point.x, y: point.y, tMs: point.tMs });
} else if (!this.#multiTouched && track.moved) {
const velocity = durationMs > 0 ? distancePx / durationMs : 0;
if (distancePx >= this.#config.swipeMinPx && velocity >= this.#config.swipeMinVelocityPxPerMs) {
const dx = point.x - track.startX;
const dy = point.y - track.startY;
const direction: SwipeDirection = Math.abs(dx) >= Math.abs(dy)
? (dx >= 0 ? "right" : "left")
: (dy >= 0 ? "down" : "up");
intents.push({ kind: "swipe", direction, distancePx, velocityPxPerMs: velocity, tMs: point.tMs });
}
}
if (this.#tracks.size < 2) {
this.#pinchBasePx = null;
this.#lastPinchScale = 1;
}
if (this.#tracks.size === 0) {
this.#multiTouched = false;
}
return intents;
}
cancel(id: number, tMs: number): Intent[] {
const existed = this.#tracks.delete(id);
if (this.#tracks.size === 0) {
this.#multiTouched = false;
this.#pinchBasePx = null;
}
return existed ? [{ kind: "cancel", id, tMs }] : [];
}
#distanceBetweenFirstTwo(): number | null {
const [a, b] = [...this.#tracks.values()];
return a && b ? Math.hypot(b.startX - a.startX, b.startY - a.startY) : null;
}
#pinch(tMs: number): Intent[] {
if (this.#pinchBasePx === null || this.#pinchBasePx <= 0) {
return [];
}
const [a, b] = [...this.#tracks.values()];
if (!a || !b) {
return [];
}
const scale = Math.hypot(b.lastX - a.lastX, b.lastY - a.lastY) / this.#pinchBasePx;
if (Math.abs(scale - this.#lastPinchScale) < this.#config.pinchMinScaleDelta) {
return [];
}
this.#lastPinchScale = scale;
return [{ kind: "pinch", scale, centerX: (a.lastX + b.lastX) / 2, centerY: (a.lastY + b.lastY) / 2, tMs }];
}
}
export type SensorAvailability = "available" | "missing" | "permission-denied";
export interface MotionSample {
readonly tMs: number; readonly yawRateDegPerSec: number; readonly pitchRateDegPerSec: number;
}
export interface MotionSource {
readonly kind: "gyro";
availability(): SensorAvailability;
}
export interface LookConfig {
readonly targetHz: number; readonly deadzoneDegPerSec: number; readonly smoothingAlpha: number;
readonly maxStepDeg: number; readonly maxPitchDeg: number; readonly dragDegPerPx: number;
}
export const DEFAULT_LOOK: LookConfig = {
targetHz: 30, deadzoneDegPerSec: 0.6, smoothingAlpha: 0.35,
maxStepDeg: 4, maxPitchDeg: 60, dragDegPerPx: 0.12,
};
export interface LookSample {
readonly yawDeg: number; readonly pitchDeg: number; readonly source: LookSource; readonly tMs: number;
}
export type Degradation = "none" | "sensor-missing" | "sensor-denied";
/** 陀螺仪降采样、死区、平滑与积分;缺失时改用 applyDrag,意图结构不变。 */
export class LookController {
readonly #source: MotionSource;
readonly #config: LookConfig;
#lastAcceptedMs = Number.NEGATIVE_INFINITY;
#yawRate = 0;
#pitchRate = 0;
#yaw = 0;
#pitch = 0;
constructor(source: MotionSource, config: LookConfig = DEFAULT_LOOK) {
this.#source = source;
this.#config = config;
}
availability(): SensorAvailability {
return this.#source.availability();
}
source(): LookSource {
return this.availability() === "available" ? "gyro" : "drag";
}
degradation(): Degradation {
const state = this.availability();
return state === "available" ? "none" : state === "missing" ? "sensor-missing" : "sensor-denied";
}
ingest(sample: MotionSample): LookSample | null {
if (this.source() !== "gyro") {
return null;
}
if (sample.tMs - this.#lastAcceptedMs < 1000 / this.#config.targetHz) {
return null;
}
const dtSec = Number.isFinite(this.#lastAcceptedMs)
? Math.min(Math.max(sample.tMs - this.#lastAcceptedMs, 0), 100) / 1000
: 0;
this.#lastAcceptedMs = sample.tMs;
const yawRate = this.#deadzone(sample.yawRateDegPerSec);
const pitchRate = this.#deadzone(sample.pitchRateDegPerSec);
const alpha = this.#config.smoothingAlpha;
this.#yawRate = alpha * yawRate + (1 - alpha) * this.#yawRate;
this.#pitchRate = alpha * pitchRate + (1 - alpha) * this.#pitchRate;
this.#yaw += this.#clampStep(this.#yawRate * dtSec);
this.#pitch = clamp(this.#pitch + this.#clampStep(this.#pitchRate * dtSec),
-this.#config.maxPitchDeg, this.#config.maxPitchDeg);
return { yawDeg: this.#yaw, pitchDeg: this.#pitch, source: "gyro", tMs: sample.tMs };
}
applyDrag(dxPx: number, dyPx: number, tMs: number): LookSample {
const max = this.#config.maxStepDeg * 4;
this.#yaw += clamp(dxPx * this.#config.dragDegPerPx, -max, max);
this.#pitch = clamp(this.#pitch - clamp(dyPx * this.#config.dragDegPerPx, -max, max),
-this.#config.maxPitchDeg, this.#config.maxPitchDeg);
return { yawDeg: this.#yaw, pitchDeg: this.#pitch, source: "drag", tMs };
}
#deadzone(rate: number): number {
return Math.abs(rate) < this.#config.deadzoneDegPerSec ? 0 : rate;
}
#clampStep(delta: number): number {
return clamp(delta, -this.#config.maxStepDeg, this.#config.maxStepDeg);
}
}
export function lookIntent(sample: LookSample): Intent {
return { kind: "look", yawDeg: sample.yawDeg, pitchDeg: sample.pitchDeg, source: sample.source, tMs: sample.tMs };
}
配套测试 test/input/gesture.test.ts 覆盖 14 条路径:点按、长按阈值、增量拖动、快慢滑动、缩放阈值、双指不误判点按、取消、240Hz 降到 30Hz、死区、单样本限步、传感器缺失、权限拒绝、拖拽视角上限,以及统一意图结构。运行命令是 node --test test/input/gesture.test.ts。
模型不覆盖真实触控采样抖动、系统手势冲突与多指交替按下的时序;这些需要真机与自动化脚本验证,不能靠单元测试宣布通过。
双案例:消消要稳,方舟要可控
消消是 90 秒单局、默认 30fps、低端机占 35% 的休闲产品。输入策略应把触控作为唯一必达路径,陀螺仪只做可关闭的装饰视角;验收必须包含「传感器缺失」和「权限拒绝」两条降级路径,否则低端机会直接少一块体验。
方舟是 8–12 分钟、默认 60fps 的中重度产品,陀螺仪可以参与瞄准,但必须允许关闭、支持重置,并且权限被拒时不能阻断战斗。两者的共同底线是:输入意图不依赖具体设备,差异只体现在可用性与提示文案。
与相邻章节的关系
7.3 定义了意图产生之后的仲裁与误触防护,本节补齐意图产生之前的归一化。8.2 会用设备档位决定画质策略,8.3 会处理屏幕形态与布局重算;本节只关心「玩家想做什么」。坐标空间与 UI 适配的细节可以参考UI 适配 ,低端机降级的整体策略可以参考低端机降级 。
小结
- 业务层只消费意图事件;原始触点频率属于输入层,不能泄漏到玩法与 UI。
- 手势阈值必须建立在归一化坐标上;直接写物理像素会在高 DPI 设备上改变手感。
- 传感器管线顺序是限频、死区、平滑、积分、限步;任何一步省略都有可观察的退化。
missing与permission-denied必须分开建模,降级动作相同,提示与运营动作不同。- 降级后的意图结构保持不变,业务层不应出现「有陀螺仪」和「无陀螺仪」两套分支。
阅读导航:上一节:7.3 触控仲裁与误触防护 · 下一节:8.2 机型分档与画质策略配置化 。
常见错误与边界
- 把手势阈值写成物理像素。同一段代码在不同 DPI 上会改变点按与拖动的边界;阈值必须作用于归一化坐标。
- 在业务层判断
hasGyro并写两套视角代码。降级应发生在输入层,输出同一种look意图;否则每加一个传感器都要改玩法。 - 把权限拒绝当成硬件缺失。两者降级动作相同但提示不同;混用会让玩家反复看到无效的「校准」指引。
- 限频后仍按原始样本间隔积分。必须用被接受样本的时间差,并把
dt截断,否则后台恢复会产生巨大跳变。 - 只在 60Hz 设备上验收手势。120Hz 会带来双倍事件与更短的点击窗口,必须在高低刷新率两端都跑同一套用例。
- 边界:本节不实现真实传感器融合与触控采样。
MotionSource与触点序列都是模型接口,零偏、温漂、系统手势冲突需要真机矩阵验证。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。