← 返回蜂巢洞察

游戏手柄API在欺骗你:一篇关于如何使用JavaScript读取控制器输入信息的实用指南

游戏手柄API是您使用过的最小的浏览器API之一。它仅有四个属性、一个函数,而且不需要任何权限请求。只需大约十五行代码,就能在屏幕上显示出一个游戏控制器的外观。 不过,这十五行代码也会“悄悄地”忽略那些出现故障的游戏控制器。 我是通过自己动手开发了一个基于浏览器的控制器测试工具才了解到这一点的。有位用户发来邮件说,尽管他在所有游戏中都发现游戏手柄的摇杆在不断漂移,但该网站却显示他的手柄是正常的。事实证明他是对的——浏览器实际上返回的是错误的数据。 这篇文章介绍了GamepadAPI中那些未在官方规范文档中提及的部分,以及这些部分是如何让我花费了大量时间进行调试的:为什么必须定期向硬件发送请求以

游戏手柄API是您使用过的最小的浏览器API之一。它仅有四个属性、一个函数,而且不需要任何权限请求。只需大约十五行代码,就能在屏幕上显示出一个游戏控制器的外观。

不过,这十五行代码也会“悄悄地”忽略那些出现故障的游戏控制器。

我是通过自己动手开发了一个基于浏览器的控制器测试工具才了解到这一点的。有位用户发来邮件说,尽管他在所有游戏中都发现游戏手柄的摇杆在不断漂移,但该网站却显示他的手柄是正常的。事实证明他是对的——浏览器实际上返回的是错误的数据。

这篇文章介绍了GamepadAPI中那些未在官方规范文档中提及的部分,以及这些部分是如何让我花费了大量时间进行调试的:为什么必须定期向硬件发送请求以获取数据、为什么页面加载时显示的值与硬件实际发送的值不同、为什么无法识别当前连接的究竟是哪种类型的游戏控制器,以及如何区分是摇杆出现故障还是人为操作导致的漂移现象。

这里提供的所有代码都需在连接了游戏手柄的浏览器控制台中运行。如果事先没有按下任何按钮,API会假定为没有设备连接。

目录

先决条件

这是一本实用指南。您不需要安装任何软件,也不需要进行复杂的编译步骤,但在开始使用下面的代码之前,确实有一些前提条件需要满足。

您应该已经掌握的知识:

  • 具备基本的JavaScript编程能力:了解函数、数组以及诸如`reduce`和`filter`这样的数组方法,还会使用箭头函数和解构赋值。

  • 了解动画帧循环的原理。文章中的部分示例是在`requestAnimationFrame`函数内部运行的。

  • 知道如何打开浏览器的开发者工具,并能将代码粘贴到控制台中进行测试。

其中有一节内容会涉及一些向量运算的知识,比如计算一组x和y数值的平均值,以及这个平均向量的长度。如果您理解`Math.hypot(x, y)`这个函数的含义,那么那一节内容也会很容易理解。

您需要准备的材料:

  • 一款支持GamepadAPI的桌面浏览器。自2017年以来,Chrome、Edge、Firefox和Safari等浏览器都已经支持这一API,因此您当前使用的任何浏览器都应该可以正常使用这些代码。

  • 一个通过USB或蓝牙连接的实体游戏控制器。软件是无法模拟这种设备的,而且如果没有连接硬件设备,下面的代码将无法发挥任何作用。

  • 理想情况下,最好使用一台已知存在故障的游戏控制器,比如摇杆出现漂移问题的控制器。因为文章中提到的某些现象只有在硬件有故障的情况下才会出现,而正常的控制器会掩盖这些问题。

你不需要任何框架、库,也不需要执行 `npm install` 操作。下面列出的每一段代码都是纯粹的 JavaScript 代码,按照编写的方式直接运行即可。

无法正常工作的测试工具

这是几乎所有人首先会编写的版本,也是大多数教程中都会介绍的版本。

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

如果使用一个摇杆存在严重漂移问题的控制器,那么在每一款游戏中,这个控制器都会使角色自动在屏幕上移动;而使用这样的控制器时,程序会输出 [0, 0, 0, 0] 这个结果。

在这五行代码中存在两个问题,其中第二个问题才是真正值得关注的地方。

为什么必须进行轮询

第一个问题是:系统中根本没有输入事件。虽然会触发 `gamepadconnected` 和 `gamepadisconnected` 这些事件,但除此之外没有其他与游戏控制器相关的事件。因此,如果你想了解摇杆的状态,就必须不断地通过 `requestAnimationFrame` 来进行轮询查询。

关于这个问题的第二个方面:你需要在每一帧中都再次调用 `navigator.getGamepads()` 方法。该方法返回的是控制器的当前状态快照;如果一直保留某个 `Gamepad` 对象并之后再读取它的值,那么得到的将会是该对象被获取那一时刻的状态数据,此后这些数据就会永远保持不变。

function loop() {
  const pads = navigator.getGamepads();     // 每一帧都会重新读取数据,不会进行缓存
  for (const pad of pads) {
    if (!pad) continue;                     // 避免处理数组中为空的元素
    render.pad.index, padaxes, pad.buttons);
  }
  requestAnimationFrame(loop);
}
requestAnimationFrame.loop);

关于这个循环机制,还有两个实际需要注意的地方。

首先,`navigator.getGamepads()` 返回的数组中会包含一些空元素(这些元素的值为 `null`),因此如果直接使用 `for...of` 循环来遍历这个数组,那么在遇到第一个空元素时程序就会抛出错误。

其次,进行轮询并不会消耗太多的系统资源。但是,如果在页面加载后就启动一个持续运行的 `requestAnimationFrame` 循环,那么即使此时并没有连接任何游戏控制器,这个循环也会一直占用主线程的资源。对于那些根本不会连接控制器的页面来说,这种做法其实是浪费资源的。

这里有一种比较合理的处理方式:在空闲状态下,以较低的频率(比如每秒 8 次)使用 `setTimeout` 来进行轮询检测;一旦有控制器被连接上,就立即切换到使用 `requestAnimationFrame` 进行轮询;而当控制器断开连接时,再恢复之前的低频轮询模式。这种处理方式既能有效地检测控制器的存在情况,又不会浪费系统资源。

数据清洗规则

现在来说说那个真正缺乏文档说明的部分,也就是为什么使用有漂移问题的控制器时程序会返回零值的原因。

Chrome只有在至少一次观察到某个轴处于静止状态后,才会报告该轴的实际数值。

只有当用户移动该轴时,浏览器才会开始检测它的位置;直到检测到其位置接近零值时,才会真正记录下这个数值。

这一机制实现代码位于文件device/gamepad/gamepad-pad_state_provider.cc中。浏览器为每个连接的控制器维护两个位字段:`axis_mask`和`button_mask`。当某个轴的对应位未被设置时,该轴的报告数值会被强制设置为`0.0`;而只有当该轴报告出的数值低于一个名为`kMinAxisResetValue`的常量(其值为`0.1f`)时,这个位才会被设置为`1`,此时该轴的实际数值才会被正确显示出来。

按钮的工作原理也与此类似,只是通过`button_mask`来实现同样的控制。对于按钮来说,有一个更为严格的判断标准:只有当按钮首次被检测到处于未按压状态时,对应的位才会被设置为`1`。如果在一个页面加载的过程中按钮一直被按住,或者某个触发器的开关因为弹簧损坏而处于半压状态,那么这些按钮会持续报告“未按压”状态,并且其数值也会显示为`0`,直到浏览器检测到它们被释放为止。

这并不是一个漏洞,了解其原因其实很有必要。源代码中的注释对此有详细的解释:由于硬件故障或某些重物压在操纵杆上等原因,控制器即使没有人操作,也可能会发出输入信号。如果没有这种机制,这些异常信号就会被误认为是用户的操作指令,从而导致浏览器检测到用户实际上并未使用的设备。因此,每个轴和每个按钮都必须先证明自己处于静止状态,浏览器才会开始显示它们的真实数值。

需要特别注意的是,这一机制的结果与你的直觉恰恰相反:

操纵杆的漂移越严重,浏览器就越会认为这个控制器是正常的。

如果操纵杆的偏移量较小,那么它很快就会低于`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( "左操纵杆在经过", Math.round(performance.now() - start), "毫秒后开始报告数值, "初始报告的数值为:", x.toFixed(3), ", "y.toFixed(3) ); } } requestAnimationFrame(loop); });

对于一个正常且静止状态的控制器来说,各个轴的读数几乎会立即显示出来,因为正常的操纵杆其读数通常接近于零。而对于那些出现漂移现象的控制器而言,除非你主动将操纵杆移动到中心位置,否则系统中不会记录任何数据。

由此可以得出一个实际准则:在连接设备后的第一帧数据中,千万不要对硬件的状态下结论。必须等到每个轴的读数至少出现过一次非零值时,或者让用户亲自移动操纵杆之后,才能相信所显示的数据是可靠的。

你也无法识别硬件的真实信息

第二个令人意外的问题虽然影响不大,但会在用户界面层面带来麻烦。

设备规格中会提供一条名为pad.id的字符串,这个字符串是由浏览器自动生成的。在Linux系统以及大多数macOS系统中,这条字符串中包含设备的USB供应商ID和产品ID(以十六进制形式表示),因此可以通过这些信息来识别设备类型;但在Windows系统中,属于XInput类型的设备(也就是大多数Xbox风格的控制器)并不会暴露任何供应商或产品ID信息。这类设备的pad.id字符串通常表现为“Xbox 360 Controller (XInput STANDARD GAMEPAD)”这样的格式,而第三方仿制的产品也会显示完全相同的内容。

macOS系统也有类似的情况。当将DualShock 4控制器连接到macOS系统上的Chrome浏览器时,pad.id字符串会显示为“Wireless Controller (STANDARD GAMEPAD)”,其中既没有供应商ID也没有产品ID,而且这个名称过于通用,以至于有好几种完全不同的控制器都会使用这个名称。

正是这个问题导致了我们遇到了一个严重的错误。我们的图形渲染功能是依据解析后的pad.id字符串来决定按钮标签的显示内容的,因此所有连接在Mac系统上的DualShock 4控制器都会被识别为PlayStation风格的控制器,从而显示Xbox风格的按钮标签。结果就是,在数月的时间里,许多Mac用户都看到了错误的界面信息,而无论是Windows系统还是Linux系统的测试都无法发现这个问题。

正确的做法应该是根据设备实际具备的功能来进行判断:

function describePAD) {
  return {
    standard: PAD.mapping === "standard",   // 只有当这个值为true时,才认为设备的轴和按钮排列是标准的
    axes: PADaxes.length,                  // 正常的双操纵杆设备中,这个值应该是4
    buttons: PADbuttons.length,            // 在标准布局下,这个值应该是17
    analogTriggers: PAD-buttons.slice(6, 8).every(b => typeof b.value === "number"),
    rumble: Boolean(PAD.vibrationActuator)
  };
}

在显示设备信息时可以使用pad.id这条字符串,也可以让用户确认自己插入的设备确实是与该字符串匹配的类型。但绝对不能根据这个字符串来决定你的程序应该执行什么操作。

如何判断是否为人为造成的漂移现象

一旦能够正常读取操纵杆的读数,就会遇到一个真正棘手的问题:如果某个轴的读数偏离了正常范围,并不意味着硬件出现了故障,而很可能是有人正在手动移动操纵杆。

一种简单的检测方法就是设置一个阈值并使用定时器。如果某个轴的读数在N毫秒的时间内一直保持在某个特定数值以上,就可以判断为漂移现象。我们曾经尝试过这种方法,但结果证明它是错误的——因为我们的用户指南中实际上建议用户将两个操纵杆都旋转一圈,而这种缓慢的移动动作会导致某个轴的读数长时间处于偏离阈值的状态。因此,测试人员误以为他们那些能够正常使用的控制器其实都是坏掉的。

区分这两种情况的关键并不在于操纵杆距离中心的距离,而是这两个因素的共同作用结果。

第一道标准是:操纵杆是否接近静止状态。真正的漂移是指那种持续存在的微小偏移,其幅度通常远低于操纵杆偏转量的一半。而当有人用手直接操作操纵杆时,所产生的偏移通常会大得多;因此,需要确保在样本统计窗口内,这种偏移的平均幅度低于0.6。

第二道标准是:操纵杆的移动方向是否具有稳定性。这才是真正决定检测结果的关键因素。如果操纵杆出现漂移,那很可能是因为传感器已经磨损或校准不准确,导致其移动方向始终不变且变化幅度很小。而人手在尝试保持静止的情况下操作操纵杆时,其移动方向仍然会不断改变。因此,需要比较平均移动向量的长度与各次测量所得数值的平均值:如果所有样本测得的移动方向都相同,那么这两个数值应该非常接近,其比值也应接近1;如果这些样本测得的移动方向各不相同,那么平均移动向量的长度就会小于各次测量数值的平均值,其比值也会低于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; // 如果偏移幅度过大,说明不属于正常漂移现象 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; // 如果移动方向具有稳定性,说明属于正常漂移现象 }
 
const window_ = [];
const WINDOW = 120; // 在60帧每秒的播放速度下,约等于两秒钟的时间长度

requestAnimationFrame(function loop() {
  const pad = navigator.getGamepads()[0];
  if (pad) {
    window_.push({ x: padaxes[0], y: pad.axes[1] });
    if (window_.length > WINDOW) window_.shift();
    if (looksLikeDrift(window_)) console.log("左操纵杆存在漂移现象");
  }
  requestAnimationFrame(loop);
});

通过这样的测量方法,我们可以得出具体的结论:在重新播放那段64秒长的实时控制器操作记录时,采用阈值判断方法和定时器判断方法,分别有2,144帧的数据被判定为存在漂移现象;而采用上述两个标准的综合判断方法,却没有一帧数据被判定为存在漂移现象。这说明,在那段记录中,并没有硬件设备出现故障导致漂移现象,这2,144帧数据都是人们正常操作操纵杆所产生的结果。

需要说明的是,这两种判断标准并不是万能的。例如,当有人故意将操纵杆保持静止状态且使其偏离中心位置、但移动方向始终不变时,这两种判断标准都会认为没有漂移现象发生。因为仅通过数值数据,确实很难区分这种人为操作与传感器故障所导致的现象。解决这个问题的方法并不是设立第三道判断标准,而是需要结合具体的使用场景来进行检测:应该在要求用户释放操纵杆的瞬间进行检测,而不是针对任意一次操作数据进行判断。只有当你能控制检测条件时,检测逻辑才会变得更加准确有效。

由此产生了一条设计规则,而且这条规则在控制器的应用范围之外也同样适用:任何机制都只能抑制信号的生成,而永远不能主动创建新的信号。这两种机制都可以行使否决权,但它们都无法单独触发警报。如果你发现自己添加了某种规则,使得原本无声的信号变成了响亮的警报,那么你实际上就是在制造一个“误报生成器”。

已知的限制

在产品发布之前,有四点是需要了解的重要事项。

振动功能并不适合移动设备使用。请使用`pad.vibrationActuator`来检测振动现象,并将震动视为一种附加功能,而非必备条件。

非标准的映射设置是真实存在的。当`pad.mapping`的值不是“standard”时,轴和按钮的排列顺序将由浏览器与驱动程序共同决定;此外,索引0所对应的实际含义也不一定固定不变。因此,要么明确处理这种特殊情况,要么直接拒绝接受这种配置方式,而绝不能想当然地认为它总是符合预期。

蓝牙通信的稳定性不如USB接口。采样间隔会存在波动,因此任何基于时间计算的结果都应该能够容忍这种抖动现象,而不能假设信号传输频率始终为稳定的60Hz。

此外,浏览器对新型控制器的支持往往滞后于硬件本身的发展。去年发布的控制器可能能在某款浏览器上正常使用,但在同一台机器上的另一款浏览器中却无法识别,因此当用户抱怨“我的控制器不能用”时,很多时候问题其实出在浏览器上,而非硬件本身。

总结

Gamepad API的设计较为简洁,使用起来也相当方便。但需要记住的是,它并不能直接访问硬件设备;浏览器位于中间层,保护用户免受网页代码的直接影响,而它传递给你的数据也不一定就是控制器实际发送过来的信息。

因此,应该采用轮询的方式而不是持续监听;每帧都要重新调用`getGamepads()`函数来获取最新的数据;在信任某个轴的数据之前,必须先确认它的状态确实正常;在判断硬件的功能是否正常时,应该依据其实际具备的功能而非`id`字符串;另外,在认定硬件出现故障之前,也需要设定一定的阈值作为判断标准。

最后,一定要使用已知有故障的控制器来进行测试。那些只在正常运行的硬件上进行过测试的工具,其实根本无法检测出真正的问题。

我开发了JoyCheck这个基于浏览器的游戏手柄测试工具,这些测试结果就是通过它得出的。

相关文章

技术实践

《设计模式手册:通过C#代码示例学习常见的设计模式》

设计模式是针对软件设计中常见问题的、可复用的解决方案。可以把它们看作是蓝图:并非完整的代码,而是经过验证的模板,你可以根据自己的需求将其调整过来,用于解决自己代码库中的特定问题。 这本手册旨在帮助大家切实理解软件设计模式。我编写这本书是为了所有开发者,无论你使用哪种编程语言。书中的示例是用C#编写的,但这里提到的每一个概念同样适用于Python、Java、TypeScript、Go等语言。 源代码可以在这里找到: github.com/Clifftech123/design-patterns-handbook 。 需要注意的事项: 设计模式本身并不是代码。 它们是一种思考代码结构的方式,是解决

阅读全文
技术实践

人工智能接待员的工作原理:人工智能电话客服系统背后的技术架构

从表面上看,人工智能接待员似乎很简单:来电者说话,系统做出响应,然后对话持续进行,直到来电者得到答案或与相关人员取得联系。 但实际上,在这一对话的背后,存在着一系列电话基础设施、语音识别技术、语言模型、应用逻辑、应用程序接口、数据库以及呼叫路由机制。 有趣的地方并不在于人工智能模型本身,而在于这些组件是如何协同工作,将音频流转化为有用的商业行动的。 那些希望拥有这类功能的企业有两种选择: 它们可以购买现成的产品。目前市场上已经有专门的人工智能接待系统,比如Nextiva公司的 XBert ,以及Goodcall、Dialzara等公司提供的相关工具。 或者,它们也可以自行开发这样的系统,而这篇

阅读全文
技术实践

量子连接性如何决定你的量子计算机实际上能够执行哪些计算任务

假设你的量子程序需要对编号为 0 和 50 的两个量子比特执行某种操作。 从编程者的角度来看,这似乎是一件很简单的事情。 你有两个量子比特,也有一个可以同时作用于这两个量子比特的门电路。那么,为什么量子计算机不能直接执行这个操作呢? 因为在真实的量子处理器中,各个量子比特并不一定彼此相连。 量子处理器并不是由一系列可以随意互换的量子比特组成的系统,在这种系统中,每个量子比特都能立即与其他所有量子比特发生相互作用。这些量子比特的实际排列方式非常重要,连接装置、控制电路、门电路的种类、错误率以及它们之间的通信路径也同样重要。 这些因素就导致了你编写的量子程序与实际执行该程序的量子计算机之间存在本质

阅读全文
技术实践

构建者设计模式:构建复杂对象的一种更有效的方法

有些对象非常简单,比如字符串、数字或布尔值。你可以用一行代码创建它们,然后继续进行其他操作。 而另一些对象则完全不简单。例如,一个轮播组件就需要包含项目数量、项目生成函数、控制器、高度、视图窗口比例、自动播放设置、页面切换回调函数以及无限滚动配置等信息。再比如,一个HTTP请求需要URL地址、请求头信息、认证令牌、请求体内容、超时时间以及重试逻辑。又或者,一条通知消息需要标题、正文、图标、发送渠道、优先级、声音效果、振动功能以及操作按钮等等。 当你需要构建这类对象时,一种常见的方法是使用带有大量参数的构造函数。这种方法虽然可行,但随着对象结构变得越来越复杂,就会带来一系列问题:这些参数很难区分

阅读全文