使用 Gamepad API 实现控制器

本文将介绍如何使用 Gamepad API 为网页游戏实现一个有效的、跨浏览器的控件系统,使您可以使用游戏主机手柄来控制您的网页游戏。文中将以 Enclave Games 创建的示例游戏《Hungry Fridge》(贪吃冰箱)为例。

网页游戏的控件

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

哪种游戏手柄最好?

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

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

案例研究:《Hungry Fridge》

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

第二个隐藏的“变化”实现是将静态冰箱转变为一个功能齐全的移动、射击和进食机器。当您连接控制器时,游戏会发生显著变化(Hungry Fridge 变成 Super Turbo Hungry Fridge),您可以使用 Gamepad API 来控制装甲冰箱。您必须射击食物,但同样,您也必须找到冰箱在每个时间点想要的食物类型,否则您会损失能量。

该游戏封装了两种截然不同的“变化”类型——好食物与坏食物,以及移动与桌面。

演示

《Hungry Fridge》的完整版游戏首先构建完成,然后为了展示 Gamepad API 的实际应用并显示 JavaScript 源代码,创建了一个简单的演示。它是 GitHub 上提供的 Gamepad API 内容套件的一部分,您可以在其中深入研究代码并准确了解其工作原理。

下面解释的代码来自《Hungry Fridge》的完整版游戏,但与演示版几乎相同——唯一的区别是完整版使用 turbo 变量来决定游戏是否以 Super Turbo 模式启动。它独立工作,因此即使未连接手柄也可以开启。

注意:彩蛋时间:有一个隐藏选项可以在没有连接手柄的情况下在桌面上启动 Super Turbo Hungry Fridge——点击屏幕右上角的控制器图标。它将以 Super 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
const gamepadAPI = {
  // …
  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
const gamepadAPI = {
  // …
  connect(evt) {
    gamepadAPI.controller = evt.gamepad;
    gamepadAPI.turbo = true;
    console.log("Gamepad connected.");
  },
};

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

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

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

Gamepad 对象

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

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

如果您连接了多个控制器并想识别它们以采取相应行动,例如在一个需要两个设备连接的双人游戏中,index 变量就很有用。

查询 Gamepad 对象

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

js
const gamepadAPI = {
  // …
  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 (const ax of c.axes) {
        axes.push(ax.toFixed(2));
      }
    }

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

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

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

检测按钮按下

buttonPressed() 方法也放置在主游戏循环中以监听按钮按下。它接受两个参数——要监听的按钮,以及(可选的)告知游戏接受按住按钮的方式。如果没有这个,您将不得不释放按钮然后再次按下才能产生所需的效果。

js
const gamepadAPI = {
  // …
  buttonPressed(button, hold) {
    let newPress = false;
    if (GamepadAPI.buttons.status.includes(button)) {
      newPress = true;
    }
    if (!hold && GamepadAPI.buttons.cache.includes(button)) {
      newPress = false;
    }
    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;
}

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

规范更新

在一年多的稳定后,W3C Gamepad API 规范于 2015 年 4 月进行了更新(查看最新版本)。它变化不大,但了解最新动态很重要——更新如下。

获取游戏手柄

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

标准映射

映射类型现在是枚举对象而不是字符串。

webidl
enum GamepadMappingType {
  "",
  "standard",
}

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

事件

规范中提供的事件比 gamepadconnectedgamepaddisconnected 要多,但它们已被移除,因为它们被认为用处不大。关于是否应该以某种形式将它们重新引入,讨论仍在进行中。

总结

Gamepad API 非常易于开发。现在,在无需任何插件的情况下,将类似游戏主机的体验交付到浏览器比以往任何时候都更容易。您可以直接在浏览器中玩《Hungry Fridge》的完整版游戏。查看 Gamepad API 内容套件上的其他资源。