借助 Gamepad API 超越障碍

Marcin Wichary
Marcin Wichary

简介

让新手保留键盘来玩冒险游戏,保留珍贵的多点触控手指来切水果,保留花哨的新式动作传感器来假装自己能像迈克尔·杰克逊一样跳舞。(最新消息:他们不能。)但您不一样。您好点了吗?您真是个行家。对您来说,游戏始于手中握着游戏手柄,也终于手中握着游戏手柄。

不过请等一下。如果您想在 Web 应用中支持游戏手柄,是不是很难?现在不需要了。全新的 Gamepad API 可派上用场,让您可以使用 JavaScript 读取连接到计算机的任何游戏手柄控制器的状态。该功能刚刚发布,上周才在 Chrome 21 中推出,Firefox 也即将支持该功能(目前在特殊 build 中提供)。

事实证明,这是一个绝佳的时机,因为我们最近有机会在 2012 年 Google 涂鸦“跨栏”中使用它。本文将简要介绍我们如何将 Gamepad API 添加到涂鸦中,以及我们在该过程中学到了什么。

2012 年 Google 涂鸦:跨栏
2012 年 Google 涂鸦:跨越障碍

游戏手柄测试人员

虽然交互式涂鸦是短暂的,但在后台往往非常复杂。为了更轻松地演示我们所讲的内容,我们从涂鸦中提取了游戏手柄代码,并组合了一个简单的游戏手柄测试工具。您可以使用它来查看 USB 游戏手柄是否正常运行,还可以深入了解其工作原理。

目前哪些浏览器支持它?

浏览器支持

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

来源

可以使用哪些游戏手柄?

一般来说,系统原生支持的任何新型游戏手柄都应该可以使用。我们测试了各种游戏手柄,从 PC 上的非品牌 USB 控制器,到通过加密狗连接到 Mac 的 PlayStation 2 游戏手柄,再到与 ChromeOS 笔记本电脑配对的蓝牙控制器。

游戏手柄
游戏手柄

这是我们用于测试涂鸦的控制器的照片 -“是的,妈妈,我真的是在工作。”如果控制器无法正常运行,或者控件映射有误,请针对 Chrome 提交 bug针对 Firefox 提交 bug。(请使用各浏览器的最新版本进行测试,确保问题已得到解决。)

功能检测 Gamepad API<

在 Chrome 中非常简单:

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

目前似乎无法在 Firefox 中检测到此问题 - 所有内容都是基于事件的,并且所有事件处理脚本都需要附加到窗口,这会导致检测事件处理脚本的典型方法无法正常运行。

但我们确信这是暂时性的。强大的 Modernizr 已经向您介绍了 Gamepad API,因此我们建议您在当前和未来的所有检测需求中使用它:

var gamepadSupportAvailable = Modernizr.gamepads;

了解已连接的游戏手柄

即使您连接了游戏手柄,除非用户先按下任意按钮,否则它不会以任何方式显示自己。这是为了防止指纹识别,但对用户体验来说却有点难度:您无法要求用户按按钮或提供特定于游戏手柄的说明,因为您不知道用户是否已连接控制器。

不过,克服这一障碍后(抱歉…),您还需要完成更多工作。

Polling

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 中受支持)

按钮和摇杆

当今的游戏手柄与您爷爷可能用来拯救误入城堡的公主的游戏手柄并不完全相同。除了两个模拟摇杆外,它们通常至少有 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 年 Hurdles 涂鸦等内容,又该如何?您可能会想:为什么我需要在每个帧中检查按钮?为什么我无法像对键盘或鼠标上/下键一样获取事件?

好消息是,您可以。坏消息是,这项功能将在未来推出。它已列入规范,但尚未在任何浏览器中实现。

Polling

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

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 年 Hurdles 涂鸦中的键盘优先方法

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

  1. 涂鸦只需要三个按钮 - 两个用于跑步,一个用于跳跃 - 但游戏手柄可能有更多按钮。因此,我们以我们认为最合理的方式将所有 16 个已知按钮和 2 个已知摇杆映射到这 3 个逻辑函数,以便用户通过以下方式跑步:交替按 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 秒做得更好吗?来吧。

深入阅读