借助 Gamepad API 超越障碍

马尔辛·维查里
Marcin Wichary

简介

让新手们保留冒险游戏的键盘,用珍贵的多触手法进行水果切割,以及用新颖的动作传感器假装自己可以像 Michael Jackson 一样跳舞。(Newsflash:不能。)但你与众不同。你变得更好了。你真是个行家。对您来说,游戏的开始和结束都是握在手中。

但是别急。如果您想在 Web 应用中支持游戏手柄,是不是失败了?现在不会了。全新的 Gamepad API 便派上用场了,它让您能够使用 JavaScript 读取与计算机相连的任何游戏手柄控制器的状态。这款浏览器从新闻资讯中得知,这款浏览器在上周才刚刚登陆 Chrome 21,而且 Firefox 也即将支持该产品(目前使用的是特制版本)。

这真是时机得当,因为我们最近有机会在 2012 年度 Google 涂鸦中融入了这幅涂鸦。本文将简要介绍我们是如何向涂鸦中添加 Gamepad API 以及在此过程中学到的知识。

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

游戏手柄测试工具

互动涂鸦只是临时性的,但其本质往往相当复杂。为了更轻松地演示我们所讨论的内容,我们从涂鸦中提取了游戏手柄代码,并制作了一个简单的游戏手柄测试工具。你可以通过此功能了解 USB 游戏手柄的运行情况,还可以查看后台运作以检查操作方式。

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

浏览器支持

  • 21
  • 12
  • 29
  • 10.1

来源

可以使用哪些游戏手柄?

通常,您的系统原生支持的任何现代游戏手柄应该都可以运行。我们测试了多种游戏手柄,包括在 PC 上非品牌 USB 控制器、通过加密狗连接到 Mac 的 PlayStation 2 游戏手柄,再到与 ChromeOS 笔记本电脑配对的蓝牙控制器。

游戏手柄
游戏手柄

这是我们用来测试涂鸦的一些控制器的照片:“没错,妈妈,我工作的时候也是这么做的。”如果您的控制器无法正常工作,或控件映射不正确,请提交针对 Chrome 的 bugFirefox。(请在每个浏览器的绝对最新版本中进行测试,以确保此问题尚未解决。)

用于检测 Gamepad API 的功能<

在 Chrome 中足够简单:

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

在 Firefox 中似乎还无法检测到这种情况,因为一切都基于事件,并且所有事件处理程序都需要附加到窗口,导致检测事件处理程序的典型技术无法正常运行。

但我们认为这只是暂时性问题。非常棒的 Modernizr 已经介绍了 Gamepad API,因此我们建议您针对当前和未来的所有检测需求使用此 API:

var gamepadSupportAvailable = Modernizr.gamepads;

了解已连接的游戏手柄

即使您连接了游戏手柄,它也不会以任何方式显示出来,除非用户先按下游戏手柄的任何按钮。虽然事实证明,这对于用户体验而言并非易事,但这样做是为了防止数字“指纹”收集:您无法要求用户按下按钮或提供特定于游戏手柄的说明,因为您不知道他们是否连接了控制器。

但是,一旦您消除了这一障碍(抱歉...),更多功能就在等着您。

轮询

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

(此函数调用最近替换了一个您可以直接访问的数组 - navigator.webkitGamepads[]。自 2012 年 8 月初起,仍然需要在 Chrome 21 中访问此数组,而函数调用在 Chrome 22 及更高版本中正常运行。今后,函数调用是该 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 规范中描述的更好的替代方案。它不会要求您进行轮询,而是公开两个事件:MozGamepadConnectedMozGamepadDisconnected。每当游戏手柄插入(或者更确切地说,是接通电源并通过按任意按钮来发出通知)或拔出时,这两个事件就会被触发。将继续反映其未来状态的游戏手柄对象会作为事件对象的 .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 中受支持)

按钮和棒

如今的游戏手柄和您祖父可能在错误城堡中救公主所用的游戏手柄并不完全一样。除了两根模拟摇杆之外,游戏手柄通常还至少有十六个独立的按钮(一些是独立的按钮,有些是模拟按钮)。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 年跨栏涂鸦:以键盘为主的方法

由于没有游戏手柄,我们当下涂鸦的首选输入法是键盘,因此我们决定让游戏手柄进行更为接近的模拟。这意味着有三项决定:

  1. 这幅涂鸦只需要三个按钮:两个用于跑步,一个用于跳跃,但游戏手柄可能还有更多按钮。因此,我们将所有十六个已知的按钮和两个已知的摇杆都以我们认为最合适的方式映射到了这三个逻辑功能上,以便用户通过以下操作来奔跑:交替的 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 还不够安全,但已经很有趣了。

除了缺少该 API 的某些部分(例如事件)和更广泛的浏览器支持之外,我们还希望最终能够看到雷声控件、内置陀螺仪使用权限等。此外,我们还希望能为不同类型的游戏手柄提供更多支持。如果您发现某个游戏手柄无法正常运作或根本无法运行,请提交 Chrome 错误和/或提交有关 Firefox 的错误

不过在此之前,不妨先玩玩我们的 Hurdles 2012 涂鸦,看看在游戏手柄上有多有趣。哦,你刚才说你本可以超过 10.7 秒吗?来吧。

补充阅读材料