← 文章 / 编程开发
freeCodeCamp 5小时前 · 2026-09-09 00:54:51 · 2 阅读

Gamepad API 在骗你:JavaScript 手柄输入实操指南

Gamepad API 可能是你用过的最小巧的浏览器 API 之一:只有四个属性、一个函数,甚至不需要权限申请。十几行代码就能把一个手柄的状态画到屏幕上。

但正是这十几行代码,也会悄悄地告诉你:一个坏了的手柄是正常的。

这是我开发一个浏览器版手柄检测工具时,用漫长的方式踩出来的坑。有位用户发邮件说,他的摇杆在所有游戏里都明显漂移,但我的网站却显示手柄一切正常。他说得对——是浏览器把全零数据交给了我们。

这篇文章讲的是 Gamepad API 里那些规范文档不会告诉你、却让我花了大量调试时间的东西:为什么必须轮询、为什么页面加载时读到的值不是硬件发来的原始值、为什么你识别不出插上来的是什么手柄,以及如何区分摇杆漂移和人为的摇杆偏移。

下文的代码都可以在浏览器控制台里直接运行,前提是插着一只手柄。先按一下任意按键,否则 API 会装作什么都没插。

目录

准备工作

这是一篇实操指南,不需要安装任何东西,也没有构建步骤,但下面几个条件得先满足,代码才能跑起来。

你需要已经掌握:

  • 能实际使用的 JavaScript:函数、数组及 reducefilter 等数组方法、箭头函数和解构。

  • 了解动画帧循环是什么。多个示例都运行在 requestAnimationFrame 里。

  • 会打开浏览器开发者工具,并在控制台里粘贴代码。

其中一小节涉及简单的向量运算:取一组 x 和 y 样本的均值,再算这个均值向量的长度。如果你看得懂 Math.hypot(x, y),那部分你也能看懂。

你需要准备什么:

  • 一个支持 Gamepad API 的桌面浏览器。Chrome、Edge、Firefox 和 Safari 从 2017 年起就支持了,所以你手上打开的浏览器基本没问题。

  • 一个实体手柄,通过 USB 或蓝牙连接。软件上没法伪造手柄,下面所有代码在没有硬件的情况下都跑不出有意义的结果。

  • 最好有一个已知有故障的手柄,比如有摇杆漂移的(如果你有的话)。本文中好几种行为只在坏硬件上才会出现,好手柄会把它们藏得严严实实。

不需要任何框架、库或 npm install。下面所有代码块都是直接可运行的纯 JavaScript。

那个不工作的检测器

这是几乎所有人第一次写出来的版本,也是大多数教程里的那一版。

window.addEventListener("gamepadconnected", (e) => {
  const pad = navigator.getGamepads()[e.gamepad.index];
  console.log(pad.axes);    // [0, 0, 0, 0]
  console.log(pad.buttons.filter(b => b.pressed).length);   // 0
});

插上一个漂移严重的手柄——那种在任何游戏里都会自己拖着角色跑遍全屏的——它输出的依然是 [0, 0, 0, 0]

那五行代码里藏着两个独立的 bug,而第二个才是有意思的那个。

为什么必须轮询

第一个 bug 在于根本不存在输入事件。gamepadconnectedgamepaddisconnected 会触发,这就是全部的事件接口。没有 gamepadaxischange,没有 gamepadbuttondown。想知道摇杆在干什么,你就得反复去问,通常是在 requestAnimationFrame 里。

同一个 bug 的另一半:你必须每一帧都重新调用 navigator.getGamepads()。它返回的是快照。如果你攥着一个 Gamepad 对象不放、之后再去读,拿到的永远是你获取那一刻的值,冻在那里,不会更新。

function loop() {
  const pads = navigator.getGamepads();     // 每帧重新读取,不要缓存
  for (const pad of pads) {
    if (!pad) continue;                     // 数组有空位,必须做空值判断
    render(pad.index, pad.axes, pad.buttons);
  }
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

关于这个循环,有两个实际要注意的点。

第一,这个数组是稀疏的。navigator.getGamepads() 返回一个固定长度的数组,没有连接手柄的位置是 null,所以不加空值判断的 for...of 遍历在遇到第一个 null 时就会抛异常。

第二,轮询不是免费的。从页面加载就开始、永远跑下去的 requestAnimationFrame 循环,对于可能根本不会接手柄的页面来说,就是白白占用主线程。

一个实用的模式是:空闲时低频轮询,用 setTimeout 大约每秒 8 次,纯粹用来检测有没有手柄接入;一旦真的连上了就切到 requestAnimationFrame 全速运行,断开后再降回低频。这个 API 本身采样成本很低,但在没有手柄的页面上每秒空跑 60 次,做性能分析的时候会很显眼。

数据清洗机制

下面这部分是文档严重缺失的地方,也是之前那个漂移的手柄为什么一直报零的原因。

Chromium 在观察到某个轴处于静止状态之前,不会报告它的真实值。

不是用户动了就算,而是浏览器观察到该轴接近零值。

这个机制在 Chromium 的一个文件里实现,device/gamepad/gamepad_pad_state_provider.cc。浏览器为每个已连接的手柄维护两个位域:axis_maskbutton_mask。某个轴的位未置位时,其报告值被强制为 0.0。当该轴报告的值首次低于常量 kMinAxisResetValue(即 0.1f)时,对应位被置上,之后真实数值才开始流通。

按键的处理逻辑在 button_mask 中类似,但判定更严格:只有在浏览器第一次看到按钮处于未按下状态时,才解锁对应的位。页面加载时就一直被按住的按钮,或者被坏掉的弹簧卡在一半位置的扳机键,会一直报告 pressed: falsevalue: 0,直到浏览器看到它被松开一次为止。

这不是 bug,而且值得搞清楚为什么要有这条规则。源码里的注释解释了原因:由于硬件故障,或者有重物压在摇杆上,手柄可能在没人碰它的时候上报输入。如果没有这条规则,这些杂散输入就会被当成用户手势,页面也就能探测到一个用户从未主动暴露的设备。所以每一根轴、每一个按键都必须先证明自己能安静地处于静止状态,浏览器才会告诉你它的任何信息。

仔细读一下这个设计带来的后果,因为它和你直觉上猜到的恰恰相反:

漂移越严重,浏览器越坚持认为手柄没问题。

偏移量小的摇杆,很快就能在某一帧低于 0.1,从而完成自我解锁。而磨损严重、永远回不到阈值范围内的摇杆,则会被永久遮蔽。最需要被上报的那只手柄,恰恰什么也不会上报。

这也解释了手柄测试工具里一个看似很神奇的现象。「把两根摇杆各画一个完整的圆」这类指令有效,并不是因为移动本身解锁了轴,而是因为画完整的一圈必然会在回来的路上经过中心点。

下面是一段可以直接粘贴到控制台运行的演示代码。连接手柄,加载页面,不要碰摇杆。然后把左摇杆推到边缘,再松手让它弹回。

const start = performance.now();
let woke = false;

requestAnimationFrame(function loop() {
  const pad = navigator.getGamepads()[0];
  if (pad && !woke) {
    const [x, y] = pad.axes;
    if (x !== 0 || y !== 0) {
      woke = true;
      console.log(
        "left stick started reporting after",
        Math.round(performance.now() - start), "ms,",
        "first values:", x.toFixed(3), y.toFixed(3)
      );
    }
  }
  requestAnimationFrame(loop);
});

对于静止状态下状态良好的手柄,轴几乎立刻就能解锁,因为健康的摇杆静止时大致归零。而对于有漂移的摇杆,只有当你手动把摇杆推过中心点时,才会有日志输出。

由此得出一条实操准则:连接后的第一帧数据不能用来判断硬件状态。要等到每个轴都报告过至少一次非零值,或者让用户动一下摇杆,之后才信任读到的数据。

你也识别不了硬件

第二个坑没那么显眼,但会在 UI 层咬你一口。

规范给你的是 pad.id,浏览器自己拼出来的字符串。在 Linux 和通常的 macOS 上,里面含有 USB 厂商 ID 和产品 ID(十六进制),你可以据此查设备。但在 Windows 上,XInput 设备(也就是大多数 Xbox 风格手柄)根本不暴露任何厂商或产品 ID。字符串长这样:"Xbox 360 Controller (XInput STANDARD GAMEPAD)",第三方兼容手柄和官方手柄报出来的完全一样。

macOS 也有自己的版本。DualShock 4 通过 Chrome 连上 macOS 时,pad.id"Wireless Controller (STANDARD GAMEPAD)"。没有厂商 ID,没有产品 ID,名字通用到六七款互不相关的手柄共用这一个。

最后这条让我栽了一个真实的坑。我们的按键图标渲染依赖解析后的 id 字符串,结果 Mac 上所有 DualShock 4 都掉进了通用 fallback,在 PlayStation 手柄上画了 Xbox 风格的按键标签。每个 Mac 用户好几个月看到的都是错的,而 Windows 或 Linux 上的测试永远抓不到这个问题。

正确做法是按能力分支:

function describe(pad) {
  return {
    standard: pad.mapping === "standard",   // trust axes/buttons ordering only if true
    axes: pad.axes.length,                  // 4 on a normal twin stick pad
    buttons: pad.buttons.length,            // 17 on standard mapping with a guide button
    analogTriggers: pad.buttons.slice(6, 8).every(b => typeof b.value === "number"),
    rumble: Boolean(pad.vibrationActuator)
  };
}

pad.id 只用来做展示,让用户确认自己插的是哪个手柄。别拿它来决定代码逻辑。

区分漂移和人手

一旦真的能读摇杆了,真正的难题才来了:偏离中心的读数不代表硬件坏了,通常只说明有人正握着摇杆。

最直觉的检测方案是阈值加计时器:某个轴持续超过某个值 N 毫秒,就判定为漂移。我确实上线过这种方案,结果错得最离谱的那种错——我们自己的屏幕操作指引还让用户把两个摇杆画完整的圈,慢速画圈会让某个轴长时间超过阈值。测试人员就跑去告诉用户,他们正常工作的控制器坏了。

区分这两种情况的关键不是摇杆离中心多远,而是两个条件要同时满足。

第一道门:是否接近静止。真正的漂移是一个很小的持续偏移,通常远小于满偏的一半。人手按在摇杆上,位移往往大得多。要求采样窗口内的平均幅值低于约 0.6。

第二道门:方向是否一致。这才是真正起作用的判断。漂移来自磨损或校准偏差的传感器,会在一个方向上几乎不变。人手即便想保持不动也会微微晃动。把平均向量的长度和各采样幅值的均值做比较:所有采样指向同一方向时,这两个数几乎相等,比值趋近 1;采样分散开来时,平均向量比幅值均值短,比值就下降。要求比值高于约 0.9。

// samples: 滚动窗口中采集的 { x, y } 数组,每帧一条
function looksLikeDrift(samples) {
  if (samples.length < 30) return false;                 // 样本还不够

  const magnitude = s => Math.hypot(s.x, s.y);
  const meanMagnitude =
    samples.reduce((sum, s) => sum + magnitude(s), 0) / samples.length;

  if (meanMagnitude < 0.02) return false;                // 停在中心,没有异常
  if (meanMagnitude > 0.6) return false;                 // 门 1:偏离太远,不可能是漂移

  const meanX = samples.reduce((sum, s) => sum + s.x, 0) / samples.length;
  const meanY = samples.reduce((sum, s) => sum + s.y, 0) / samples.length;
  const coherence = Math.hypot(meanX, meanY) / meanMagnitude;

  return coherence > 0.9;                                // 门 2:保持单一方向
}

喂给它数据的采集器如下:

const window_ = [];
const WINDOW = 120;   // 60fps 下大约两秒

requestAnimationFrame(function loop() {
  const pad = navigator.getGamepads()[0];
  if (pad) {
    window_.push({ x: pad.axes[0], y: pad.axes[1] });
    if (window_.length > WINDOW) window_.shift();
    if (looksLikeDrift(window_)) console.log("left stick looks like drift");
  }
  requestAnimationFrame(loop);
});

数据说话,这种论断就该用数字来支撑:回放同一段 64 秒的真实手柄输入录屏,“阈值加计时器”版本在 2144 帧上触发了漂移判定,而双门槛版本是 。这段录制里根本没有漂移的硬件——那 2144 帧全是真人摇动摇杆产生的,其中大部分还是在按我们自己的提示操作。

两个门槛都不是万能的,有必要说说第二道门槛的局限。如果用户故意握着手柄、把摇杆持续偏往一个方向,两道门槛都会放行——因为单看数值,这种情况确实很难和磨损的传感器区分开。解决办法不是再加第三道门槛,而是借助上下文:在你明确要求用户松开摇杆的时刻做检测,而不是对任意输入都检测。当你能控制检测运行的场景时,逻辑会简单得多。

由此得出一条设计原则,而且远不止适用于手柄:门槛只能否决报告,绝不能制造报告。两道门槛都可以说“不”,但谁也不能单独拉响警报。如果你发现自己在写一条把微弱信号放大成强信号的规则,那你就是在制造误报。

已知的局限

上线之前,有四件事需要了解。

振动不支持跨平台。请用特性检测检查 pad.vibrationActuator,把震动当作锦上添花,绝不能当作必需功能。

非标准映射真实存在。当 pad.mapping 不是 "standard" 时,轴和按键的顺序完全取决于浏览器和驱动的约定,索引 0 不保证对应任何特定控件。要么妥善处理这种情况,要么明确拒绝,别想当然地绕过去。

蓝牙的轮询不如 USB 稳定。采样间隔会抖动,所以任何基于时间间隔的计算都要能容忍抖动,不能假设是稳定的 60Hz。

浏览器对新手柄的支持总是慢半拍。同一台机器上,去年出的手柄可能在一个浏览器里识别正常,换个浏览器就不行了。所以"我的手柄不工作"这句话,背后是浏览器问题的概率至少和硬件问题一样大。

小结

Gamepad API 本身不大,用起来也还算顺手。但真正要记住的一点是:它并不是硬件的直接通道。浏览器夹在中间替用户挡着页面,所以你拿到的值未必是手柄实际发出来的那个值。

因此用轮询而不是事件监听,每帧重新读一次 getGamepads();每个轴先让它证明自己再信它;按能力做分支判断,别拿 id 字符串做依据;也别刚过阈值就断定人家硬件坏了——多留点余量。

还要拿一个你确定是坏的控制器来测。一个测试工具如果只在正常硬件上跑过,那它根本就没被真正检验过。

我做了 JoyCheck,一个基于浏览器的游戏手柄检测工具,前文的各项测量数据就出自它。

原始来源: freeCodeCamp

评论 (0)