使用 Gamepad API 实现游戏控制

本文探讨了如何使用 Gamepad API 为 Web 游戏实现有效的跨浏览器控制系统,使你能够使用游戏机手柄控制 Web 游戏。它以一个案例研究游戏——饥饿冰箱为例,该游戏由 Enclave Games 创建。

网页游戏的控制

从历史上看,在连接到电视的游戏机上玩游戏与在 PC 上玩游戏始终是完全不同的体验,这主要是因为独特的控制方式。最终,额外的驱动程序和插件允许我们使用游戏机手柄玩桌面游戏——无论是原生游戏还是在浏览器中运行的游戏。现在我们有了 Gamepad API,它使我们能够在没有任何插件的情况下使用游戏手柄控制器玩基于浏览器的游戏。Gamepad API 通过提供一个界面来实现这一点,该界面公开按钮按下和轴变化,这些变化可以在 JavaScript 代码中用于处理输入。对于浏览器游戏来说,这些都是好时光。

哪些游戏手柄最好?

目前最流行的游戏手柄是 Xbox 360、Xbox One、PS3 和 PS4 的游戏手柄——它们经过了大量测试,并且在 Windows 和 macOS 上的浏览器中与 Gamepad API 实现配合良好。

还有一些其他设备具有各种不同的按钮布局,这些布局或多或少可以在浏览器实现中使用。本文中讨论的代码使用了一些游戏手柄进行了测试,但作者最喜欢的配置是 macOS 上的无线 Xbox 360 控制器和 Firefox 浏览器。

案例研究:饥饿冰箱

GitHub Game Off II 比赛于 2013 年 11 月举行,Enclave Games 决定参加。比赛的主题是“改变”,因此他们提交了一款游戏,你需要通过点击健康的食物(苹果、胡萝卜、生菜)来喂养饥饿的冰箱,并避免“坏”的食物(啤酒、汉堡、披萨)。倒计时每隔几秒钟就会改变冰箱想要吃的食物类型,因此你必须小心并迅速行动。

第二个隐藏的“改变”实现是能够将静态冰箱转变为一个成熟的移动、射击和进食机器。当你连接控制器时,游戏会发生重大变化(饥饿的冰箱变成超级涡轮饥饿冰箱),并且你可以使用 Gamepad API 控制装甲冰箱。你必须击落食物,但同样,你还必须找到冰箱在每个时间点想要吃的食物类型,否则你会失去能量。

这款游戏包含了两种完全不同的“改变”类型——好食物与坏食物,以及移动设备与桌面设备。

演示

首先构建了饥饿冰箱游戏的完整版本,然后为了展示 Gamepad API 的实际应用并展示 JavaScript 源代码,创建了一个 简单的演示。它是 GitHub 上提供的 Gamepad API 内容工具包 的一部分,你可以在其中深入研究代码并了解其工作原理。

下面解释的代码来自饥饿冰箱游戏的完整版本,但它与演示中的代码几乎相同——唯一的区别是完整版本使用 turbo 变量来决定游戏是否以超级涡轮模式启动。它独立工作,因此即使没有连接游戏手柄也可以打开。

注意:彩蛋时间:有一个隐藏选项可以在没有连接游戏手柄的情况下在桌面上启动超级涡轮饥饿冰箱——点击屏幕右上角的游戏手柄图标。它将以超级涡轮模式启动游戏,并且你可以使用键盘控制冰箱:A 和 D 用于左右转动炮塔,W 用于射击,方向键用于移动。

实现

有两个重要的事件需要与 Gamepad API 一起使用——gamepadconnectedgamepaddisconnected。第一个事件在浏览器检测到新游戏手柄连接时触发,第二个事件在游戏手柄断开连接时触发(无论是用户物理断开连接还是由于不活动导致)。在演示中,gamepadAPI 对象用于存储与 API 相关的所有内容。

js
const gamepadAPI = {
  controller: {},
  turbo: false,
  connect() {},
  disconnect() {},
  update() {},
  buttonPressed() {},
  buttons: [],
  buttonsCache: [],
  buttonsStatus: [],
  axesStatus: [],
};

buttons 数组包含 Xbox 360 按钮布局

js
buttons: [
  'DPad-Up','DPad-Down','DPad-Left','DPad-Right',
  'Start','Back','Axis-Left','Axis-Right',
  'LB','RB','Power','A','B','X','Y',
],

对于其他类型的游戏手柄(如 PS3 控制器或非知名通用控制器),这可能会有所不同,因此你必须小心,不要仅仅假设你期望的按钮就是你实际得到的按钮。接下来,我们设置了两个事件监听器来获取数据

js
window.addEventListener("gamepadconnected", gamepadAPI.connect);
window.addEventListener("gamepaddisconnected", gamepadAPI.disconnect);

由于安全策略,你必须在页面可见时先与控制器交互,才能触发事件。如果 API 在没有用户交互的情况下工作,则可以用来在不知情的情况下识别用户的指纹。

这两个函数都相当简单

js
connect(evt) {
  gamepadAPI.controller = evt.gamepad;
  gamepadAPI.turbo = true;
  console.log('Gamepad connected.');
},

connect() 函数以事件作为参数,并将 gamepad 对象分配给 gamepadAPI.controller 变量。我们在这个游戏中只使用了一个游戏手柄,因此它是一个对象而不是游戏手柄数组。然后我们将 turbo 属性设置为 true。(我们可以为此目的使用 gamepad.connected 布尔值,但我们希望有一个单独的变量来打开涡轮模式,而无需连接游戏手柄,原因如上所述。)

js
disconnect(evt) {
  gamepadAPI.turbo = false;
  delete gamepadAPI.controller;
  console.log('Gamepad disconnected.');
},

disconnect 函数将 gamepad.turbo 属性设置为 false 并删除包含游戏手柄对象的变量。

游戏手柄对象

gamepad 对象包含大量有用的信息,其中按钮和轴的状态最为重要

  • id:包含有关控制器的信息的字符串。
  • index:连接设备的唯一标识符。
  • connected:布尔变量,如果设备已连接,则为 true
  • mapping:按钮的布局类型;standard 目前是唯一可用的选项。
  • axes:每个轴的状态,由浮点值的数组表示。
  • buttons:每个按钮的状态,由 GamepadButton 对象的数组表示,包含 pressedvalue 属性。

如果我们连接了多个控制器并希望识别它们以相应地采取行动,则 index 变量很有用——例如,当我们有一个需要连接两个设备的双人游戏时。

查询游戏手柄对象

除了 connect()disconnect() 之外,gamepadAPI 对象中还有另外两个方法:update()buttonPressed()update() 在游戏循环中的每一帧上执行,以定期更新游戏手柄对象的实际状态

js
update() {
  // Clear the buttons cache
  gamepadAPI.buttonsCache = [];

  // Move the buttons status from the previous frame to the cache
  for (let k = 0; k < gamepadAPI.buttonsStatus.length; k++) {
    gamepadAPI.buttonsCache[k] = gamepadAPI.buttonsStatus[k];
  }

  // Clear the buttons status
  gamepadAPI.buttonsStatus = [];

  // Get the gamepad object
  const c = gamepadAPI.controller || {};

  // Loop through buttons and push the pressed ones to the array
  const pressed = [];
  if (c.buttons) {
    for (let b = 0; b < c.buttons.length; b++) {
      if (c.buttons[b].pressed) {
        pressed.push(gamepadAPI.buttons[b]);
      }
    }
  }

  // Loop through axes and push their values to the array
  const axes = [];
  if (c.axes) {
    for (let a = 0; a < c.axes.length; a++) {
      axes.push(c.axes[a].toFixed(2));
    }
  }

  // Assign received values
  gamepadAPI.axesStatus = axes;
  gamepadAPI.buttonsStatus = pressed;

  // Return buttons for debugging purposes
  return pressed;
},

在每一帧中,update() 将前一帧中按下的按钮保存到 buttonsCache 数组中,并从 gamepadAPI.controller 对象中获取新的按钮。然后它循环遍历按钮和轴以获取它们的实际状态和值。

检测按钮按下

buttonPressed() 方法也放置在主游戏循环中以监听按钮按下。它接受两个参数——我们要监听的按钮以及(可选的)告诉游戏接受按住按钮的方式。如果没有它,你必须松开按钮并再次按下才能获得所需的效果。

js
buttonPressed(button, hold) {
  let newPress = false;

  // Loop through pressed buttons
  for (let i = 0; i < gamepadAPI.buttonsStatus.length; i++) {
    // If we found the button we're looking for
    if (gamepadAPI.buttonsStatus[i] === button) {
      // Set the boolean variable to true
      newPress = true;

      // If we want to check the single press
      if (!hold) {
        // Loop through the cached states from the previous frame
        for (let j = 0; j < gamepadAPI.buttonsCache.length; j++) {
          // If the button was already pressed, ignore new press
          newPress = (gamepadAPI.buttonsCache[j] !== button);
        }
      }
    }
  }
  return newPress;
},

对于按钮,需要考虑两种类型的操作:单次按下和按住。newPress 布尔变量将指示按钮是否为新按下。接下来,我们循环遍历按下的按钮数组——如果给定按钮与我们正在查找的按钮相同,则 newPress 变量将设置为 true。为了检查按下是否为新的,以便玩家没有按住键,我们循环遍历游戏循环前一帧中按钮的缓存状态。如果我们在那里找到它,则表示按钮正在被按住,因此没有新的按下。最后返回 newPress 变量。buttonPressed 函数在游戏的更新循环中使用,如下所示

js
if (gamepadAPI.turbo) {
  if (gamepadAPI.buttonPressed("A", "hold")) {
    this.turbo_fire();
  }
  if (gamepadAPI.buttonPressed("B")) {
    this.managePause();
  }
}

如果 gamepadAPI.turbotrue 并且按下了(或按住了)给定的按钮,我们将执行分配给它们的相应函数。在这种情况下,按下或按住 A 将发射子弹,按下 B 将暂停游戏。

轴阈值

按钮只有两种状态:01,但模拟摇杆可以有多个值——它们在XY轴上都有一个介于-11之间的浮点范围。

游戏手柄闲置时可能会沾染灰尘,这意味着检查精确的 -1 或 1 值可能会出现问题。出于这个原因,为轴的值设置一个阈值以使其生效可能是一个好主意。例如,冰箱坦克只有当X值大于0.5时才会向右转动。

js
if (gamepadAPI.axesStatus[0].x > 0.5) {
  this.player.angle += 3;
  this.turret.angle += 3;
}

即使我们不小心稍微移动了一下,或者摇杆没有回到其原始位置,坦克也不会意外地转向。

规范更新

经过一年多的稳定运行,2015年4月,W3C游戏手柄API规范进行了更新(查看最新版本)。变化不大,但了解正在发生的事情总归是好的——更新内容如下。

获取游戏手柄

Navigator.getGamepads() 方法已更新,其中包含更长的解释和示例代码。现在,游戏手柄数组的长度必须为n+1,其中n是连接设备的数量——当连接一个设备且其索引为 1 时,数组的长度为 2,并且看起来像这样:[null, [object Gamepad]]。如果设备断开连接或不可用,则其值将设置为null

映射标准

映射类型现在是一个可枚举的对象,而不是字符串。

ts
enum GamepadMappingType {
  "",
  "standard",
}

此枚举定义了游戏手柄已知映射的集合。目前,只有standard布局可用,但将来可能会出现新的布局。如果布局未知,则将其设置为空字符串。

事件

规范中提供的事件不仅限于gamepadconnectedgamepaddisconnected,但由于它们被认为不太有用,因此已从规范中删除。关于是否应该将它们放回以及以何种形式放回,讨论仍在进行中。

总结

游戏手柄API非常易于开发。现在,比以往任何时候都更容易在浏览器中提供类似控制台的体验,而无需任何插件。您可以在浏览器中直接玩Hungry Fridge游戏的完整版本。查看Gamepad API内容工具包上的其他资源。