使用游戏手柄 API

Baseline 广泛可用 *

此特性已得到良好确立,可跨多种设备和浏览器版本使用。自 2017 年 3 月起,所有浏览器均支持此特性。

* 此特性的某些部分可能存在不同级别的支持。

HTML 提供了丰富的交互式游戏开发所需的组件。像 <canvas>、WebGL、<audio><video> 等技术,以及 JavaScript 实现,可以支持提供与原生代码相似(甚至相同)功能的任务。Gamepad API 允许开发者和设计师访问和使用游戏手柄及其他游戏控制器。

Gamepad APIWindow 对象上引入了新的事件,用于读取游戏手柄和控制器的状态(以下统称为游戏手柄)。除了这些事件,该 API 还增加了一个 Gamepad 对象,你可以使用它来查询已连接游戏手柄的状态,以及一个 navigator.getGamepads() 方法,你可以用它来获取页面已知的游戏手柄列表。

连接游戏手柄

当新的游戏手柄连接到电脑时,当前聚焦的页面首先会收到一个 gamepadconnected 事件。如果页面加载时游戏手柄已连接,那么当用户按下按钮或移动摇杆时,gamepadconnected 事件将分发给当前聚焦的页面。

注意: 在 Firefox 中,游戏手柄只有在用户与页面可见的游戏手柄进行交互时才会被暴露给页面。这有助于防止游戏手柄被用于 指纹识别 用户。一旦一个游戏手柄被交互过,其他已连接的游戏手柄将自动可见。

你可以这样使用 gamepadconnected

js
window.addEventListener("gamepadconnected", (e) => {
  console.log(
    "Gamepad connected at index %d: %s. %d buttons, %d axes.",
    e.gamepad.index,
    e.gamepad.id,
    e.gamepad.buttons.length,
    e.gamepad.axes.length,
  );
});

每个游戏手柄都有一个唯一的 ID,该 ID 在事件的 gamepad 属性上可用。

断开游戏手柄连接

当游戏手柄断开连接时,如果页面之前曾接收过该游戏手柄的数据(例如,gamepadconnected 事件),则会向当前聚焦的窗口分发第二个事件,即 gamepaddisconnected

js
window.addEventListener("gamepaddisconnected", (e) => {
  console.log(
    "Gamepad disconnected from index %d: %s",
    e.gamepad.index,
    e.gamepad.id,
  );
});

游戏手柄的 index 属性对于连接到系统的每个设备都是唯一的,即使使用了多个相同类型的控制器。index 属性也充当了 Navigator.getGamepads() 返回的 Array 的索引。

js
const gamepads = {};

function gamepadHandler(event, connected) {
  const gamepad = event.gamepad;
  // Note:
  // gamepad === navigator.getGamepads()[gamepad.index]

  if (connected) {
    gamepads[gamepad.index] = gamepad;
  } else {
    delete gamepads[gamepad.index];
  }
}

window.addEventListener("gamepadconnected", (e) => {
  gamepadHandler(e, true);
});
window.addEventListener("gamepaddisconnected", (e) => {
  gamepadHandler(e, false);
});

上一个示例还演示了如何在事件完成后仍然保留 gamepad 属性——这是我们稍后用于设备状态查询的技术。

查询 Gamepad 对象

正如你所见,上面讨论的 gamepad 事件在事件对象上包含一个 gamepad 属性,该属性返回一个 Gamepad 对象。我们可以使用它来确定是哪个游戏手柄(即其 ID)触发了事件,因为可能同时连接了多个游戏手柄。我们可以使用 Gamepad 对象做更多事情,包括持有它的引用并查询它以了解在任何给定时间点哪些按钮被按下,哪些摇杆被移动。对于需要知道游戏手柄当前状态与下一个事件触发时的状态的游戏或交互式网页来说,这样做通常是可取的。

执行此类检查通常涉及将 Gamepad 对象与动画循环(例如 requestAnimationFrame)结合使用,开发者希望根据游戏手柄的状态为当前帧做出决策。

Navigator.getGamepads() 方法返回一个数组,其中包含当前对网页可见的所有设备,以 Gamepad 对象的形式(第一个值始终为 null,因此如果未连接游戏手柄,将返回 null)。然后可以使用此数组获取相同的信息。例如,上面的第一个代码示例可以重写为如下所示:

js
window.addEventListener("gamepadconnected", (e) => {
  const gp = navigator.getGamepads()[e.gamepad.index];
  console.log(
    "Gamepad connected at index %d: %s. %d buttons, %d axes.",
    gp.index,
    gp.id,
    gp.buttons.length,
    gp.axes.length,
  );
});

Gamepad 对象具有以下属性:

  • id:一个字符串,包含有关控制器的一些信息。此字段并未严格指定,但在 Firefox 中,它将包含三个以破折号 (-) 分隔的信息:两个 4 位十六进制字符串,包含控制器的 USB 供应商 ID 和产品 ID,以及驱动程序提供的控制器名称。此信息旨在让你能够找到设备上控件的映射,并向用户显示有用的反馈。

  • index:一个整数,对于当前连接到系统的每个游戏手柄都是唯一的。这可用于区分多个控制器。请注意,断开设备然后连接新设备可能会重复使用之前的索引。

  • mapping:一个字符串,指示浏览器是否已将设备上的控件映射到已知布局。目前只有一个支持的已知布局——标准游戏手柄。如果浏览器能够将设备上的控件映射到该布局,则 mapping 属性将设置为字符串 standard

  • connected:一个布尔值,指示游戏手柄是否仍连接到系统。如果是,则值为 True;否则为 False

  • buttons:一个 GamepadButton 对象数组,表示设备上的按钮。每个 GamepadButton 都有一个 pressed 和一个 value 属性。

    • pressed 属性是一个布尔值,表示按钮当前是否被按下(true)或未按下(false)。
    • value 属性是一个浮点数值,用于表示模拟按钮,例如许多现代游戏手柄上的扳机。值被归一化到 0.0 到 1.0 的范围,其中 0.0 表示未按下的按钮,1.0 表示完全按下的按钮。
  • axes:一个数组,表示设备上的带轴控件(例如,模拟拇指摇杆)。数组中的每个条目都是一个浮点值,范围在 -1.0 到 1.0 之间,表示从最低值 (-1.0) 到最高值 (1.0) 的轴位置。

  • timestamp:此属性返回一个 DOMHighResTimeStamp,表示此游戏手柄数据的最后更新时间,使开发者能够确定 axesbutton 数据是否已从硬件更新。该值必须相对于 PerformanceTiming 接口的 navigationStart 属性。值是单调递增的,这意味着它们可以进行比较以确定更新的顺序,因为较新的值将始终大于或等于旧值。请注意,此属性目前在 Firefox 中不受支持。

注意: 出于安全原因,Gamepad 对象在 gamepadconnected 事件上可用,而不是在 Window 对象本身上可用。一旦我们获得了它的引用,就可以查询它的属性以获取有关游戏手柄当前状态的信息。在后台,该对象将在每次游戏手柄状态更改时进行更新。

使用按钮信息

让我们看一个示例,该示例显示一个游戏手柄的连接信息(忽略后续的游戏手柄连接),并允许你使用游戏手柄右侧的四个按钮在屏幕上移动一个球。你可以 在线查看演示,并在 GitHub 上 找到源代码

首先,我们声明一些变量:用于写入连接信息的 gamepadInfo 段落,我们要移动的 ball,充当 requestAnimation Frame ID 的 start 变量,用于移动球的位置修饰符的 ab 变量,以及将用于跨浏览器版本的 requestAnimationFrame()cancelAnimationFrame() 的简写变量。

js
const gamepadInfo = document.getElementById("gamepad-info");
const ball = document.getElementById("ball");
let start;
let a = 0;
let b = 0;

接下来,我们使用 gamepadconnected 事件来检查是否有游戏手柄被连接。当一个游戏手柄被连接时,我们使用 navigator.getGamepads()[0] 获取该游戏手柄,将游戏手柄信息打印到我们的 gamepad info div 中,然后启动 gameLoop() 函数,开始整个球的移动过程。

js
window.addEventListener("gamepadconnected", (e) => {
  const gp = navigator.getGamepads()[e.gamepad.index];
  gamepadInfo.textContent = `Gamepad connected at index ${gp.index}: ${gp.id}. It has ${gp.buttons.length} buttons and ${gp.axes.length} axes.`;

  gameLoop();
});

现在,我们使用 gamepaddisconnected 事件来检查游戏手柄是否再次断开连接。如果是,我们就停止 requestAnimationFrame() 循环(见下文),并将游戏手柄信息恢复到原始状态。

js
window.addEventListener("gamepaddisconnected", (e) => {
  gamepadInfo.textContent = "Waiting for gamepad.";

  cancelAnimationFrame(start);
});

现在进入主游戏循环。在每次循环执行时,我们检查是否按下了四个按钮中的一个;如果是,我们就相应地更新 ab 移动变量的值,然后更新 lefttop 属性,将它们的值分别更改为 ab 的当前值。这会产生在屏幕上移动球的效果。

完成所有这些之后,我们使用我们的 requestAnimationFrame() 请求下一个动画帧,再次运行 gameLoop()

js
function gameLoop() {
  const gamepads = navigator.getGamepads();
  if (!gamepads) {
    return;
  }

  const gp = gamepads[0];
  if (gp.buttons[0].pressed) {
    b--;
  }
  if (gp.buttons[2].pressed) {
    b++;
  }
  if (gp.buttons[1].pressed) {
    a++;
  }
  if (gp.buttons[3].pressed) {
    a--;
  }

  ball.style.left = `${a * 2}px`;
  ball.style.top = `${b * 2}px`;

  start = requestAnimationFrame(gameLoop);
}

完整示例:显示游戏手柄状态

这个例子展示了如何使用 Gamepad 对象,以及 gamepadconnectedgamepaddisconnected 事件来显示连接到系统的所有游戏手柄的状态。该示例基于一个 Gamepad 演示,其 源代码可在 GitHub 上找到

js
let loopStarted = false;

window.addEventListener("gamepadconnected", (evt) => {
  addGamepad(evt.gamepad);
});
window.addEventListener("gamepaddisconnected", (evt) => {
  removeGamepad(evt.gamepad);
});

function addGamepad(gamepad) {
  const d = document.createElement("div");
  d.setAttribute("id", `controller${gamepad.index}`);

  const t = document.createElement("h1");
  t.textContent = `gamepad: ${gamepad.id}`;
  d.append(t);

  const b = document.createElement("ul");
  b.className = "buttons";
  gamepad.buttons.forEach((button, i) => {
    const e = document.createElement("li");
    e.className = "button";
    e.textContent = `Button ${i}`;
    b.append(e);
  });

  d.append(b);

  const a = document.createElement("div");
  a.className = "axes";

  gamepad.axes.forEach((axis, i) => {
    const p = document.createElement("progress");
    p.className = "axis";
    p.setAttribute("max", "2");
    p.setAttribute("value", "1");
    p.textContent = i;
    a.append(p);
  });

  d.appendChild(a);

  // See https://github.com/luser/gamepadtest/blob/master/index.html
  const start = document.querySelector("#start");
  if (start) {
    start.style.display = "none";
  }

  document.body.append(d);
  if (!loopStarted) {
    requestAnimationFrame(updateStatus);
    loopStarted = true;
  }
}

function removeGamepad(gamepad) {
  document.querySelector(`#controller${gamepad.index}`).remove();
}

function updateStatus() {
  for (const gamepad of navigator.getGamepads()) {
    if (!gamepad) continue;

    const d = document.getElementById(`controller${gamepad.index}`);
    const buttonElements = d.getElementsByClassName("button");

    for (const [i, button] of gamepad.buttons.entries()) {
      const el = buttonElements[i];

      const pct = `${Math.round(button.value * 100)}%`;
      el.style.backgroundSize = `${pct} ${pct}`;
      if (button.pressed) {
        el.textContent = `Button ${i} [PRESSED]`;
        el.style.color = "#42f593";
        el.className = "button pressed";
      } else {
        el.textContent = `Button ${i}`;
        el.style.color = "#2e2d33";
        el.className = "button";
      }
    }

    const axisElements = d.getElementsByClassName("axis");
    for (const [i, axis] of gamepad.axes.entries()) {
      const el = axisElements[i];
      el.textContent = `${i}: ${axis.toFixed(4)}`;
      el.setAttribute("value", axis + 1);
    }
  }

  requestAnimationFrame(updateStatus);
}

规范

规范
Gamepad
# gamepad-interface
Gamepad 扩展
# partial-gamepad-interface

浏览器兼容性