《移动游戏产品的架构·客户端与移动运行时》8.1 触控、陀螺仪与传感器抽象

触控与陀螺仪的差别不在事件名,而在采样节奏、缺失处理与噪声。本节把触点、长按、滑动、缩放和视角统一成意图事件,用死区、平滑与步长上限处理传感器数据,并给出陀螺仪缺失或被拒绝时的拖拽降级,以及消消与方舟各自的输入策略边界、验收口径与提示差异。

事故:同一套手势,在三台机器上是三种手感

《潮汐消消》的一次版本回归里,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 与触点序列都是模型接口,零偏、温漂、系统手势冲突需要真机矩阵验证。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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