使用 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 一起使用的两个重要事件是 gamepadconnected 和 gamepaddisconnected。第一个事件在浏览器检测到新游戏手柄连接时触发,第二个事件在游戏手柄断开连接时触发(无论是用户物理断开还是由于不活动)。在演示中,gamepadAPI 对象用于存储与 API 相关的所有内容。
const gamepadAPI = {
controller: {},
turbo: false,
connect() {},
disconnect() {},
update() {},
buttonPressed() {},
buttons: [],
buttonsCache: [],
buttonsStatus: [],
axesStatus: [],
};
buttons 数组包含 Xbox 360 的按钮布局
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 控制器(或杂牌通用手柄),这可能会有所不同,因此您必须小心,不要仅仅假设您期望的按钮就是您实际得到的按钮。接下来,我们设置两个事件监听器来获取数据。
window.addEventListener("gamepadconnected", gamepadAPI.connect);
window.addEventListener("gamepaddisconnected", gamepadAPI.disconnect);
由于安全策略,您必须先与控制器进行交互,页面可见时事件才会触发。如果 API 在用户没有任何交互的情况下工作,它可能会在用户不知情的情况下被用于对他们进行指纹识别。
这两个函数都相当简单。
const gamepadAPI = {
// …
connect(evt) {
gamepadAPI.controller = evt.gamepad;
gamepadAPI.turbo = true;
console.log("Gamepad connected.");
},
};
connect() 函数将事件作为参数,并将 gamepad 对象分配给 gamepadAPI.controller 变量。我们只使用一个游戏手柄,所以它是一个单一对象而不是一个游戏手柄数组。然后我们将 turbo 属性设置为 true。(我们可以为此目的使用 gamepad.connected 布尔值,但我们想有一个单独的变量来开启 Turbo 模式,而无需连接游戏手柄,原因如上所述。)
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对象数组,其中包含pressed和value属性。
如果您连接了多个控制器并想识别它们以采取相应行动,例如在一个需要两个设备连接的双人游戏中,index 变量就很有用。
查询 Gamepad 对象
除了 connect() 和 disconnect() 之外,gamepadAPI 对象还有另外两个方法:update() 和 buttonPressed()。update() 在游戏循环的每一帧中执行,以定期更新游戏手柄对象的实际状态。
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() 方法也放置在主游戏循环中以监听按钮按下。它接受两个参数——要监听的按钮,以及(可选的)告知游戏接受按住按钮的方式。如果没有这个,您将不得不释放按钮然后再次按下才能产生所需的效果。
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 函数在游戏的更新循环中按如下方式使用:
if (gamepadAPI.turbo) {
if (gamepadAPI.buttonPressed("A", "hold")) {
this.turbo_fire();
}
if (gamepadAPI.buttonPressed("B")) {
this.managePause();
}
}
如果 gamepadAPI.turbo 为 true 并且按下了(或按住了)给定的按钮,我们将执行分配给它们的适当函数。在这种情况下,按下或按住 A 将发射子弹,按下 B 将暂停游戏。
轴阈值
按钮只有两个状态:0 或 1,但模拟摇杆可以有许多值——它们在 X 和 Y 轴上具有介于 -1 和 1 之间的浮点范围。
游戏手柄可能会因为长时间闲置而积灰,这意味着检查精确的 -1 或 1 值可能会有问题。因此,为轴值设置一个阈值以使其生效可能是一个好主意。例如,冰箱坦克仅在 X 值大于 0.5 时才会向右转。
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。
标准映射
映射类型现在是枚举对象而不是字符串。
enum GamepadMappingType {
"",
"standard",
}
此枚举定义了一组已知的 Gamepad 映射。目前,只有 standard 布局可用,但将来可能会出现新的布局。如果布局未知,则将其设置为空字符串。
事件
规范中提供的事件比 gamepadconnected 和 gamepaddisconnected 要多,但它们已被移除,因为它们被认为用处不大。关于是否应该以某种形式将它们重新引入,讨论仍在进行中。
总结
Gamepad API 非常易于开发。现在,在无需任何插件的情况下,将类似游戏主机的体验交付到浏览器比以往任何时候都更容易。您可以直接在浏览器中玩《Hungry Fridge》的完整版游戏。查看 Gamepad API 内容套件上的其他资源。