借助 Gamepad API 超越障碍

Marcin Wichary
Marcin Wichary

简介

让新手们继续用键盘玩冒险游戏,用他们宝贵的多点触控指尖切水果,用他们新奇时髦的移动传感器假装自己能像迈克尔·杰克逊一样跳舞。(最新消息:他们无法做到这一点。)但你不一样。你更优秀。您真是个行家。对你来说,游戏始于手握游戏手柄,也终于手握游戏手柄。

但等等。如果您想在 Web 应用中支持游戏手柄,是不是就没戏了?现在不需要了。全新的 Gamepad API 应运而生,让您可以使用 JavaScript 读取连接到计算机的任何游戏手柄控制器的状态。它非常新,上周才在 Chrome 21 中推出,并且即将获得 Firefox 的支持(目前在特殊 build 中提供)。

事实证明,这个时机非常好,因为我们最近有机会在 2012 年跨栏 Google 涂鸦中使用了它。本文将简要介绍我们如何将 Gamepad API 添加到 Doodle 中,以及在此过程中学到的知识。

2012 年跨栏 Google 涂鸦
2012 年跨栏 Google 涂鸦

游戏手柄测试工具

虽然互动式涂鸦转瞬即逝,但其背后的技术往往相当复杂。为了更轻松地演示我们所讨论的内容,我们从涂鸦中提取了游戏手柄代码,并组合了一个简单的游戏手柄测试器。您可以使用它来查看 USB 游戏手柄是否正常工作,还可以查看其内部机制,了解其工作原理。

目前有哪些浏览器支持该功能?

Browser Support

  • Chrome: 21.
  • Edge: 12.
  • Firefox: 29.
  • Safari: 10.1.

Source

可以使用哪些游戏手柄?

一般来说,只要是系统原生支持的现代游戏手柄,应该都可以使用。我们测试了各种游戏手柄,包括通过 PC 连接的杂牌 USB 控制器、通过加密狗连接到 Mac 的 PlayStation 2 游戏手柄,以及与 ChromeOS 笔记本配对的蓝牙控制器。

游戏手柄
游戏手柄

这张照片展示了我们用来测试涂鸦的一些控制器 -“是的,妈妈,这就是我的工作。”如果您的控制器无法正常工作,或者控制器的映射不正确,请针对 Chrome 或 Firefox 提交 bug。(请在每个浏览器的最新版本中进行测试,以确保问题尚未得到解决。)

检测 Gamepad API 的功能<

在 Chrome 中:

var gamepadSupportAvailable = !!navigator.webkitGetGamepads || !!navigator.webkitGamepads;

目前似乎无法在 Firefox 中检测到这一点,因为所有内容都基于事件,并且所有事件处理脚本都需要附加到窗口,这会阻止检测事件处理脚本的典型技术发挥作用。

但我们确信,这只是暂时的。Modernizr 已经可以检测 Gamepad API,因此我们建议您使用它来满足当前和未来的所有检测需求:

var gamepadSupportAvailable = Modernizr.gamepads;

了解已连接的游戏手柄

即使您连接了游戏手柄,它也不会以任何方式自行显示,除非用户先按下其任何按钮。这是为了防止指纹识别,但事实证明,这对用户体验来说是一个挑战:您无法要求用户按下按钮或提供游戏手柄专用说明,因为您不知道他们是否连接了控制器。

不过,一旦你跨过这道坎(抱歉…),还有更多挑战在等着你。

轮询

Chrome 的 API 实现会公开一个函数 navigator.webkitGetGamepads(),您可以使用该函数获取当前插入系统的所有游戏手柄及其当前状态(按钮 + 摇杆)的列表。第一个连接的游戏手柄将作为数组中的第一个条目返回,依此类推。

(此函数调用最近才替换了您可以直接访问的数组 - navigator.webkitGamepads[]。截至 2012 年 8 月初,在 Chrome 21 中仍需访问此数组,而函数调用在 Chrome 22 及更高版本中有效。今后,建议使用函数调用来使用该 API,并且该 API 将逐步推广到所有已安装的 Chrome 浏览器。)

到目前为止,该规范已实现的部分要求您持续检查已连接游戏手柄的状态(并在必要时将其与之前的状态进行比较),而不是在发生变化时触发事件。我们依靠 requestAnimationFrame() 以最高效、最省电的方式设置轮询。对于我们的涂鸦,即使我们已经有一个 requestAnimationFrame() 循环来支持动画,我们还是创建了第二个完全独立的循环,这样编码更简单,而且不会以任何方式影响性能。

以下是测试人员提供的代码:

/**
 * Starts a polling loop to check for gamepad state.
 */
startPolling: function() {
    // Don't accidentally start a second loop, man.
    if (!gamepadSupport.ticking) {
    gamepadSupport.ticking = true;
    gamepadSupport.tick();
    }
},

/**
 * Stops a polling loop by setting a flag which will prevent the next
 * requestAnimationFrame() from being scheduled.
 */
stopPolling: function() {
    gamepadSupport.ticking = false;
},

/**
 * A function called with each requestAnimationFrame(). Polls the gamepad
 * status and schedules another poll.
 */
tick: function() {
    gamepadSupport.pollStatus();
    gamepadSupport.scheduleNextTick();
},

scheduleNextTick: function() {
    // Only schedule the next frame if we haven't decided to stop via
    // stopPolling() before.
    if (gamepadSupport.ticking) {
    if (window.requestAnimationFrame) {
        window.requestAnimationFrame(gamepadSupport.tick);
    } else if (window.mozRequestAnimationFrame) {
        window.mozRequestAnimationFrame(gamepadSupport.tick);
    } else if (window.webkitRequestAnimationFrame) {
        window.webkitRequestAnimationFrame(gamepadSupport.tick);
    }
    // Note lack of setTimeout since all the browsers that support
    // Gamepad API are already supporting requestAnimationFrame().
    }
},

/**
 * Checks for the gamepad status. Monitors the necessary data and notices
 * the differences from previous state (buttons for Chrome/Firefox,
 * new connects/disconnects for Chrome). If differences are noticed, asks
 * to update the display accordingly. Should run as close to 60 frames per
 * second as possible.
 */
pollStatus: function() {
    // (Code goes here.)
},

如果您只关注一个游戏手柄,那么获取其数据可能非常简单:

var gamepad = navigator.webkitGetGamepads && navigator.webkitGetGamepads()[0];

如果您想让游戏更智能,或同时支持多个玩家,则需要添加更多代码行,以应对更复杂的场景(连接了两个或更多游戏手柄、其中一些游戏手柄中途断开连接等)。您可以查看测试器函数 pollGamepads() 的源代码,了解如何解决此问题。

事件

Firefox 使用 Gamepad API 规范中描述的另一种更好的方法。它不会要求您进行轮询,而是公开两个事件:MozGamepadConnected 和 MozGamepadDisconnected。每当游戏手柄插入(更确切地说,插入并通过按下其任何按钮进行“宣告”)或拔出时,这两个事件就会触发。将继续反映其未来状态的游戏手柄对象会作为事件对象的 .gamepad 参数传递。

从测试器源代码中:

/**
 * React to the gamepad being connected. Today, this will only be executed
 * on Firefox.
 */
onGamepadConnect: function(event) {
    // Add the new gamepad on the list of gamepads to look after.
    gamepadSupport.gamepads.push(event.gamepad);

    // Start the polling loop to monitor button changes.
    gamepadSupport.startPolling();

    // Ask the tester to update the screen to show more gamepads.
    tester.updateGamepads(gamepadSupport.gamepads);
},

摘要

最后,测试器中的初始化函数(支持这两种方法)如下所示:

/**
 * Initialize support for Gamepad API.
 */
init: function() {
    // As of writing, it seems impossible to detect Gamepad API support
    // in Firefox, hence we need to hardcode it in the third clause.
    // (The preceding two clauses are for Chrome.)
    var gamepadSupportAvailable = !!navigator.webkitGetGamepads ||
        !!navigator.webkitGamepads ||
        (navigator.userAgent.indexOf('Firefox/') != -1);

    if (!gamepadSupportAvailable) {
    // It doesn't seem Gamepad API is available – show a message telling
    // the visitor about it.
    tester.showNotSupported();
    } else {
    // Firefox supports the connect/disconnect event, so we attach event
    // handlers to those.
    window.addEventListener('MozGamepadConnected',
                            gamepadSupport.onGamepadConnect, false);
    window.addEventListener('MozGamepadDisconnected',
                            gamepadSupport.onGamepadDisconnect, false);

    // Since Chrome only supports polling, we initiate polling loop straight
    // away. For Firefox, we will only do it if we get a connect event.
    if (!!navigator.webkitGamepads || !!navigator.webkitGetGamepads) {
        gamepadSupport.startPolling();
    }
    }
},

游戏手柄信息

连接到系统的每个游戏手柄都将由一个如下所示的对象表示:

id: "PLAYSTATION(R)3 Controller (STANDARD GAMEPAD Vendor: 054c Product: 0268)"
index: 1
timestamp: 18395424738498
buttons: Array[8]
    0: 0
    1: 0
    2: 1
    3: 0
    4: 0
    5: 0
    6: 0.03291
    7: 0
axes: Array[4]
    0: -0.01176
    1: 0.01961
    2: -0.00392
    3: -0.01176

基本信息

前几个字段是简单的元数据:

  • id:游戏手柄的文本说明
  • index:一个整数,用于区分连接到一台计算机的不同游戏手柄
  • timestamp:按钮/轴状态的最后更新时间戳(目前仅在 Chrome 中受支持)

按钮和摇杆

如今的游戏手柄与您爷爷当年用来拯救公主的手柄大不相同,除了两个摇杆外,通常还至少有 16 个单独的按钮(有些是离散按钮,有些是模拟按钮)。Gamepad API 会告知您操作系统报告的所有按钮和摇杆。

在游戏手柄对象中获取当前状态后,您可以通过 .buttons[] 数组访问按钮,通过 .axes[] 数组访问摇杆。以下是这些参数对应内容的直观摘要:

游戏手柄示意图
游戏手柄示意图

该规范要求浏览器将前 16 个按钮和 4 个轴映射到:

gamepad.BUTTONS = {
    FACE_1: 0, // Face (main) buttons
    FACE_2: 1,
    FACE_3: 2,
    FACE_4: 3,
    LEFT_SHOULDER: 4, // Top shoulder buttons
    RIGHT_SHOULDER: 5,
    LEFT_SHOULDER_BOTTOM: 6, // Bottom shoulder buttons
    RIGHT_SHOULDER_BOTTOM: 7,
    SELECT: 8,
    START: 9,
    LEFT_ANALOGUE_STICK: 10, // Analogue sticks (if depressible)
    RIGHT_ANALOGUE_STICK: 11,
    PAD_TOP: 12, // Directional (discrete) pad
    PAD_BOTTOM: 13,
    PAD_LEFT: 14,
    PAD_RIGHT: 15
};

gamepad.AXES = {
    LEFT_ANALOGUE_HOR: 0,
    LEFT_ANALOGUE_VERT: 1,
    RIGHT_ANALOGUE_HOR: 2,
    RIGHT_ANALOGUE_VERT: 3
};

额外的按钮和轴将附加到上述按钮和轴。不过,请注意,系统不保证一定有 16 个按钮或 4 个轴,因此请做好准备,其中一些可能只是未定义。

按钮的值可以介于 0.0(未按下)到 1.0(完全按下)之间。轴的范围从 -1.0(完全向左或向上)到 0.0(中心),再到 1.0(完全向右或向下)。

模拟还是离散?

表面上,每个按钮都可以是模拟按钮,例如,肩部按钮通常就是模拟按钮。因此,最好设置一个阈值,而不是直接与 1.00 进行比较(如果模拟按钮恰好有点脏,该怎么办?可能永远不会达到 1.00)。在我们的涂鸦中,我们是这样做的:

gamepad.ANALOGUE_BUTTON_THRESHOLD = .5;

gamepad.buttonPressed_ = function(pad, buttonId) {
    return pad.buttons[buttonId] &&
            (pad.buttons[buttonId] > gamepad.ANALOGUE_BUTTON_THRESHOLD);
};

您也可以通过同样的方式将模拟摇杆转换为数字摇杆。当然,您始终可以使用数字方向键(方向键),但您的游戏手柄可能没有方向键。以下是处理该情况的代码:

gamepad.AXIS_THRESHOLD = .75;

gamepad.stickMoved_ = function(pad, axisId, negativeDirection) {
    if (typeof pad.axes[axisId] == 'undefined') {
    return false;
    } else if (negativeDirection) {
    return pad.axes[axisId] < -gamepad.AXIS_THRESHOLD;
    } else {
    return pad.axes[axisId] > gamepad.AXIS_THRESHOLD;
    }
};

按钮按下和摇杆移动

事件

在某些情况下,例如飞行模拟器游戏,持续检查并响应摇杆位置或按键操作更有意义…但对于像 2012 年跨栏涂鸦这样的游戏呢?您可能会想:为什么我需要检查每一帧中的按钮?为什么我无法像获取键盘或鼠标按下/松开事件那样获取事件?

好消息是,您可以。坏消息是,在未来,它在规范中,但尚未在任何浏览器中实现。

轮询

与此同时,您的解决方法是比较当前状态和之前的状态,并在发现任何差异时调用函数。例如:

if (buttonPressed(pad, 0) != buttonPressed(oldPad, 0)) {
    buttonEvent(0, buttonPressed(pad, 0) ? 'down' : 'up');
}
for (var i in gamepadSupport.gamepads) {
    var gamepad = gamepadSupport.gamepads[i];

    // Don't do anything if the current timestamp is the same as previous
    // one, which means that the state of the gamepad hasn't changed.
    // This is only supported by Chrome right now, so the first check
    // makes sure we're not doing anything if the timestamps are empty
    // or undefined.
    if (gamepadSupport.prevTimestamps[i] &&
        (gamepad.timestamp == gamepadSupport.prevTimestamps[i])) {
    continue;
    }
    gamepadSupport.prevTimestamps[i] = gamepad.timestamp;

    gamepadSupport.updateDisplay(i);
}

2012 年跨栏涂鸦中采用的键盘优先方法

由于在没有游戏手柄的情况下,今天的 Doodle 首选输入法是键盘,因此我们决定让游戏手柄尽可能模拟键盘。这意味着需要做出三个决定:

  1. 该涂鸦只需要三个按钮(两个用于跑步,一个用于跳跃),但游戏手柄可能有更多按钮。因此,我们以最合理的方式将所有 16 个已知按钮和 2 个已知摇杆映射到这三个逻辑功能上,以便玩家可以通过以下方式奔跑:交替按下 A/B 按钮、交替按下肩键、按下方向键上的左/右键,或左右剧烈摇动任一摇杆(当然,其中一些方式会比其他方式更有效)。例如:

    newState[gamepad.STATES.LEFT] =
        gamepad.buttonPressed_(pad, gamepad.BUTTONS.PAD_LEFT) ||
        gamepad.stickMoved_(pad, gamepad.AXES.LEFT_ANALOGUE_HOR, true) ||
        gamepad.stickMoved_(pad, gamepad.AXES.RIGHT_ANALOGUE_HOR, true),
    
    newState[gamepad.STATES.PRIMARY_BUTTON] =
        gamepad.buttonPressed_(pad, gamepad.BUTTONS.FACE_1) ||
        gamepad.buttonPressed_(pad, gamepad.BUTTONS.LEFT_SHOULDER) ||
        gamepad.buttonPressed_(pad, gamepad.BUTTONS.LEFT_SHOULDER_BOTTOM) ||
        gamepad.buttonPressed_(pad, gamepad.BUTTONS.SELECT) ||
        gamepad.buttonPressed_(pad, gamepad.BUTTONS.START) ||
        gamepad.buttonPressed_(pad, gamepad.BUTTONS.LEFT_ANALOGUE_STICK),
    
  2. 我们使用前面介绍的阈值函数,将每个模拟输入都视为离散输入。

  3. 我们甚至将游戏手柄输入固定到涂鸦上,而不是将其嵌入其中。我们的轮询循环实际上会合成必要的 keydown 和 keyup 事件(带有适当的 keyCode),并将其发送回 DOM:

    // Create and dispatch a corresponding key event.
    var event = document.createEvent('Event');
    var eventName = down ? 'keydown' : 'keyup';
    event.initEvent(eventName, true, true);
    event.keyCode = gamepad.stateToKeyCodeMap_[state];
    gamepad.containerElement_.dispatchEvent(event);

就是这么简单!

提示和技巧

  • 请注意,在按下按钮之前,游戏手柄在浏览器中根本不可见。
  • 如果您同时在不同的浏览器中测试游戏手柄,请注意,只有一个浏览器可以检测到控制器。如果您没有收到任何事件,请务必关闭可能正在使用该事件的其他页面。此外,根据我们的经验,有时即使您关闭标签页或退出浏览器,浏览器也可能会“占用”游戏手柄。有时,重启系统是解决问题的唯一方法。
  • 一如既往,请使用 Chrome Canary 版 和其他浏览器的等效版本,以确保您获得最佳支持,然后根据旧版本的不同行为采取适当措施。

未来展望

希望这有助于您了解这个新 API,虽然它还不太稳定,但已经非常有趣。

除了 API 的缺失部分(例如事件)和更广泛的浏览器支持之外,我们还希望最终能看到震动控制、内置陀螺仪访问等功能。此外,我们还希望看到对不同类型游戏手柄的更多支持 - 如果您发现某个游戏手柄无法正常工作或根本无法工作,请针对 Chrome 提交 bug 和/或针对 Firefox 提交 bug。

不过在此之前,不妨先去玩玩我们的跨栏 2012 涂鸦,看看用游戏手柄玩有多好玩。哦,你刚才说你能跑进 10.7 秒?来吧!

深入阅读