探索前端多元化系列 — Gamepad API

1,686 阅读12分钟

当代的前端技术已经远远超越了传统的网页展示,现在已经可以通过浏览器实现复杂的游戏体验。而在这些游戏中,玩家的交互方式往往是通过键盘、鼠标或者手柄等外设来实现。其中,手柄是一种非常常用的交互方式,但是在 Web 端使用手柄却一直比较困难。这时候 Gamepad API 就应运而生了。

在开始本文冗长的讲解之前,为了提升各位阅读兴趣,先放一段 Gamepad API 接入手柄的效果动图,此代码已经上传到仓库,欢迎各位按需使用。

效果.gif

Gamepad API 代码仓库: react-use-gamepads

背景

提到掌机 API,相信大部分前端开发和我刚开始接触到的时候一样,应该都是一个懵的状态,什么东西?和前端有关系吗?但是当我带着疑问查阅各种资料的时候,我忽然发现,牛啊!!!果然前端和 JavaScript 才是编程的最终形态(哈哈哈,开个玩笑)。为什么这么说呢?因为 JavaScript 直接就能读取外联设备(手柄和掌机)进行开发,这意味着前端开发者并不需要关注底层逻辑,我们想要开发游戏,手柄按键触发某些行为开发者直接去实现上层应用的逻辑就行了。大体如下图所示:

Gamepad架构.png

如果你是一个前端开发并且对游戏开发掌机开发感兴趣,或者你的公司有类似的场景(比如大疆无人机操控,比赛操控机器人等),避免小伙伴们在遇到的时候没有头绪,本篇文章应该算是一个不错的教程。

拒绝八股文照搬,拒绝代码示例不可用,这是作者一贯的原则,本文所有示例代码均可以直接 Clone 仓库代码运行,试玩地址为:掌机俄罗斯方块游戏地址,如果发现任何问题,随时私信我。但是,因为是掌机开发,所以其中的示例需要手柄连接才可用,对于没有手柄的各位,如果真的感兴趣,可以自行购买(可以参考头图的手柄),没有需求的各位我不建议购买,简单看看纯当知识扩展就可以了~

什么是 Gamepad API

前面我们提到过在 Web 端实现游戏手柄交互,一直是一个比较棘手的问题。在远古时期,一些 Web 开发者社区中的大佬们使用了各种技巧来实现手柄交互,比如使用键盘模拟手柄的按键,或者使用 Flash 等插件来实现手柄的操作等。但是这些方法都存在一些缺陷,比如键盘模拟手柄的按键不够直观并且只是功能上的模拟并没有实际的触感,而且无法适应各种手柄的不同布局;而 Flash 等插件则需要用户安装插件,而且在移动设备上并不支持等问题。

随着越来越多的 Web 游戏以及 H5 游戏的出现,针对上述这些问题 W3C 提出了 Gamepad API(也就是掌机 API)。Gamepad API 是一个标准化的 Web API,可以让开发者在 Web 端直接获取手柄的输入信息,从而实现手柄交互。

【注意】:Gamepad API 是一个标准化的 Web API,正是因此我们才可以做到不必针对不同手柄做不同的兼容性开发,手柄厂商只需要遵循标准化协议去设计手柄和通信模块即可。

定义

Gamepad API 是一个 Web API,用于获取游戏手柄的输入信息,通过这个 API,开发者可以直接获取手柄的按键状态、摇杆位置等信息,从而实现手柄交互。

应用场景

Gamepad API 的应用场景不言而喻,它主要应用于 Web 游戏开发以及需要外接设备更方便的控制某些机器的场景中,实现更加直观、自然的手柄交互方式。除此之外,Gamepad API 还可以应用于以下场景:

  1. 摇杆游戏:通过手柄控制进行游戏,常见的比如 XBox,Switch 游戏等。
  2. VR/AR 应用:在 VR/AR 应用中,手柄是一种非常重要的交互方式,Gamepad API 可以让开发者更加方便地实现手柄交互。
  3. 模拟器:在模拟器应用中,手柄是一种比较常用的交互方式,Gamepad API 可以让开发者更加方便地实现手柄交互。
  4. 无人机操控:在众多无人机场景中,手柄都扮演着必不可少的作用,而利用 Gamepad API 就可以开发一个实时的操控无人机并回显数据的应用。

Gamepad API 使用方式

与之前的单纯技术文章不同,为什么本篇要介绍那么多背景和概念呢?因为笔者相信 90% 以上的前端开发应该长期内都不会接触到这方面的需求,所以本文还是以科普为主,正如标题《前端多元化》的含义一样,我希望通过文章让各位知道前端并不仅仅只能开发 CRUD 中后台数据页面,只能开发 H5 电商系统,在现在任何领域,互联网、互联网+以及物联网车联网中,前端都占有一席之地,只不过适用形式发生了一些变化而已,根儿还是前端,因此开拓眼界算是写这个系列文章的一个核心目的。

基本概念介绍完成,接下来就是重点介绍一下 Gamepad API 的核心部分以及如何使用它来开发手柄控制器应用。

Gamepad API 接口和对象

  • Navigator.getGamepads()方法

此方法用于获取当前连接到结算及上的所有手柄的状态信息,从方法定义以及描述就可以得知,返回值是一个数组,也就是同一台设备其实是支持多个外接手柄设备的。

其实这么设计才是合理的,因为小时候的街机游戏就有很多是双人的甚至是四人的,比如:拳皇 97,三国志,西游释厄传等等,不知是否勾起了大家的回忆。

  • Gamepad 对象

此对象用于表示一个游戏手柄的状态信息,此对象上包含手柄的 ID,按键状态以及摇杆位置等全部手柄相关信息。

  • GamepadButton 对象

此对象表示手柄上的按键对象,包含按键的状态和值。其中状态 GamepadButton.pressed 对应为摁下和松开,值 GamepadButton.value 对应一个范围(0 ~ 1之间的一个数值)

  • GamepadEvent 对象

此对象表示与控制器相关的事件对象。通常来说在手柄控制器的事件函数回调中,作为参数供开发者访问控制器数据,使用方式为 GamepadEvent.gamepad

常见的事件如下:

事件描述
ongamepadconnected当手柄控制器连接时触发的事件函数。
ongamepaddisconnected当手柄控制器断开时触发的事件函数。

下面会有详细的代码介绍,因此这里不做过多讲解。

Gamepad API 的使用步骤

使用 Gamepad API 开发应用程序一般来说遵循如下几个步骤:

  1. 检测浏览器是否支持 Gamepad API
if(navigator.getGamepads) {
  // 支持 Gamepad API
} else {
  // 不支持 Gamepad API
}

可以在Gamepad API 浏览器兼容性查看更详细的兼容性信息。

  1. 获取当前连接的手柄控制器

获取手柄代码如下:

const gamepads = navigator.getGamepads();
const gamepad = gamepads[0]; // 获取第一个游戏手柄

不过上述代码不够严谨,因为上面提到过,手柄是支持多个的,并且手柄连接后并不一定在第一个位置,因此通过下面的代码获取手柄控制器更为准确。

// 定义手柄变量
let gp;
// 手柄连接时赋值
window.addEventListener("gamepadconnected", function(e) {
  gp = navigator.getGamepads()[e.gamepad.index];
});
  1. 监听手柄事件,比如按键按下,松开,摇杆位置变更等信息
// 监听手柄连接事件
window.addEventListener("gamepadconnected", (event) => {
  const gamepad = event.gamepad;
  console.log(`Gamepad ${gamepad.id} connected`);
});
// 监听手柄断开事件
window.addEventListener("gamepaddisconnected", (event) => {
  const gamepad = event.gamepad;
  console.log(`Gamepad ${gamepad.id} disconnected`);
});
// 监听按钮按下事件
gamepad.addEventListener("buttondown", (event) => {
  const button = event.button;
  console.log(`Button ${button} down`);
});
// 监听按钮抬起事件
gamepad.addEventListener("buttonup", (event) => {
  const button = event.button;
  console.log(`Button ${button} up`);
});
// 监听摇杆变更事件
gamepad.addEventListener("axischange", (event) => {
  const axis = event.axis;
  const value = event.value;
  console.log(`Axis ${axis} value: ${value}`);
});

通过上述几个步骤,开发者就可以获取手柄的输入信息,并在对应的事件中编写代码从而实现相应的交互操作。

手柄键位以及摇杆信息

如下面这张图所示,通常来说游戏手柄包含两部分内容:按钮和摇杆。

摇杆.jpeg

并且 Gamepad API 底层暴露出来的事件也是包含这两种类型的,比如按钮的事件对应包括按下和抬起,摇杆的事件对应的是 axischange,具体代码以及详细操作如下。

  • 按钮键位
export enum EGamepadButtons {
  'A', // 0
  'B', // 1
  'X', // 2
  'Y', // 3
  'LB', // 4
  'RB', // 5
  'LT', // 6
  'RT', // 7
  'BACK', // 8
  'START', // 9
  'LS', // 10
  'RS', // 11
  'UP', // 12
  'DOWN', // 13
  'LEFT', // 14
  'RIGHT', // 15
}
  • 对应表格关系
按钮对应值描述
A0A 按钮
B1B 按钮
X2X 按钮
Y3Y 按钮
LB4LB 按钮
RB5RB 按钮
LT6LT 按钮
RT7RT 按钮
BACK8BACK 按钮
START9START 按钮
LS10LS 按钮
RS11RS 按钮
UP12UP 按钮
DOWN13DOWN 按钮
LEFT14LEFT 按钮
RIGHT15RIGHT 按钮
  • 摇杆键位
// axes 对应一个数组,分别是左侧摇杆的 x 和 y,右侧摇杆的 x 和 y
const [laxes_X, laxes_Y, raxes_X, raxes_Y] = gamepad.axes;
摇杆对应值描述
laxes_X[-1, 1]左侧摇杆横向操控,从左到右取值区间 [1, -1]
laxes_Y[-1, 1]左侧摇杆纵向操控,从上至下取值区间 [1, -1]
raxes_X[-1, 1]右侧摇杆横向操控,从左到右取值区间 [1, -1]
raxes_Y[-1, 1]右侧摇杆横向操控,从上至下取值区间 [1, -1]

笔者测试了两个外接手柄设备,拿到的按键与摇杆对应信息一致且均如上表所示,不过还是建议开发者根据自身的设备情况进行验证以及校准。

搭配 window.requestAnimationFrame 使用

在使用掌机 Gamepad API 进行应用程序开发时,需要实时监听掌机手柄的事件,以响应用户的操作。由于掌机手柄的输入状态可能会非常频繁地发生变化,因此使用 window.requestAnimationFrame API 可以更好地处理这些事件,同时提高应用程序的性能和响应速度。

这一点与使用 HTML5 Canvas 进行开发动画效果有着异曲同工之妙。

window.requestAnimationFrame 是一种优化浏览器动画效果的方法,可以让浏览器在下一次重绘之前执行指定的函数。在使用掌机 Gamepad API 进行事件监听时,可以将事件处理函数作为参数传递给 requestAnimationFrame 方法,使事件处理函数在下一次重绘之前执行。这样可以避免过多的事件处理导致浏览器过度占用 CPU 资源,从而提高应用程序的性能和响应速度。

另外,使用 requestAnimationFrame 还可以解决一些其他问题,比如:

  1. 帧率控制:通过适当调整 requestAnimationFrame 方法的调用频率,可以控制应用程序的帧率,从而避免过度占用 CPU 资源和浪费电量。
  2. 减少延迟:使用 requestAnimationFrame 方法可以减少事件处理函数的执行延迟,从而提高应用程序的响应速度。
  3. 节省电量:由于 requestAnimationFrame 方法的调用可以更好地利用浏览器的重绘机制,因此可以节省一定的电量,延长掌机的使用时间。

总之,使用 window.requestAnimationFrame API 可以提高掌机应用程序的性能、响应速度和节能效果,是掌机 Gamepad API 开发中不可或缺的一部分。

其他注意事项

  • Chrome 浏览器中需要不断轮询,避免无法获取到最新的控制器状态

在 Chrome 浏览器中,它没有在变量内不断的更新存储控制器的最后状态,而存储只是当时的一个快照,所以如果开发者要在 Chrome 中使用 Gamepad Api,就需要不断地轮询从而获取最新的控制器状态。可以参考如下代码:

var interval;
​
if (!('ongamepadconnected' in window)) {
  // 没有控制器事件可用,则开始轮询。
  interval = setInterval(pollGamepads, 500);
}
​
function pollGamepads() {
  var gamepads = navigator.getGamepads ? navigator.getGamepads() : (navigator.webkitGetGamepads ? navigator.webkitGetGamepads : []);
  for (var i = 0; i < gamepads.length; i++) {
    var gp = gamepads[i];
    if (gp) {
      gamepadInfo.innerHTML = "控制器已连接于 " + gp.index + " 位:" + gp.id +
        "。它有 " + gp.buttons.length + " 个按钮和 " + gp.axes.length + " 个坐标方向。";
      gameLoop();
      clearInterval(interval);
    }
  }
}
  • 必须通过 window.addEventListener 来添加事件,无法直接通过 window 访问

出于安全原因,Gamepad 对象只在 gamepadconnected 事件的回调函数里可用而在 Window 对象上不可用。一旦我们得到了对它的引用,就可以获取其属性以了解有关控制器当前状态的信息。在后台,此对象将会在控制器状态更改时更新。

封装一个 useGamepad Hook

其实上面的原理以及使用方式讲完了,各位直接在任意项目里去使用就可以了。之所以会增加一部分 React Hooks 的封装,其实初衷和我之前各种文章一样,我从网上想找一些 Gamepad API 相关的 Hooks 来用,结果找来找去基本上,90% 的都处于常年不更新不维护且不可用的状态,因此没办法,自己封装一个,方便自己,造福他人了算是。简单的示例代码如下:

import React from 'react';
import useGamepads from 'react-use-gamepads';
import typeof { IGamepadButtonsData, IGamepadButtonsData } from 'react-use-gamepads';
​
export default function App() {
   const [gamepads, setGamepads] = React.useState<Gamepad[]>([]);
​
  const onGamepadsUpdate = (gamepads: Gamepad[]) => setGamepads(gamepads);
​
  const onButtonsDown = (data: IGamepadButtonsData) => {
    const { gamepad, index, buttons } = data;
    console.log(`Cur Gamepad: ${gamepad}, Gamepad Index: ${index}.`);
    console.log(`Cur Pressed Buttons: ${buttons.join(",")}.`);
  }
 
  const onAxesChange = (data: IGamepadAxesData) => {
    const { gamepad, index, axes } = data;
    console.log(`Cur Gamepad: ${gamepad}, Gamepad Index: ${index}.`);
    console.log(`Cur Axes: 👇🏼👇🏼👇🏼👇🏼.`);
    console.log(`axes: `, axes);
    console.log(`left axes X: `, axes[0]);
    console.log(`left axes Y: `, axes[1]);
    console.log(`right axes X: `, axes[2]);
    console.log(`right axes Y: `, axes[3]);
  }
​
  useGamepads({
    onGamepadsUpdate,
    onButtonsDown,
    onAxesChange,
  });
​
  return (
    <div>React Use Gamepads</div>
  )
}

此包提供且仅提供一个最基础的 Gamepad Hooks,笔者看了几个仓库,个人觉得复杂了没有必要,简单易用才是此类功能专一包的最佳打开方式。然后提供了四个属性供开发者传入进去使用,目的是拿到按钮点击摇杆操作等事件:

export interface IGamepadProps {
  gamepadButtonsMap?: Record<number, string>;
  onGamepadsUpdate?: (gamepads: Gamepad[]) => void;
  onAxesChange?: (data: IGamepadAxesData) => void;
  onButtonsDown?: (data: IGamepadButtonsData) => void;
};

关于这个包的详细内容我就不多做介绍了,有文档有示例,感兴趣的可以自己去查看。此外,这个包已经发布到了 NPM 上,react-use-gamepads。我只是进行了简单的封装使用,如果有感兴趣的,可以一起共建,维护一个高可用状态的 Gamepad NPM 包~

总结

笔者作为一个前端不敢说擅长写文章,但是喜欢写愿意写文章。个人不喜欢循规蹈矩的一味冲着热门去写,造成最后社区 100 个人,99 个人都在写相同主题的文章,给各位读者们造成选择困难症,为了避免这种情况,我这边在以后的文章里会重新选择题材,网上搜不到或者很少搜到的,新人看文档稀里糊涂不明白的,网上的示例基本不可用的,针对这些场景,我会选择编写文章。如果有各位感兴趣的话题,可以私信联系我,在时间允许的范围内,我会尽我所能为大家编写个人视角的总结。