使用游戏手柄 API

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

游戏手柄 APIWindow 对象上引入了用于读取游戏手柄和控制器(以下简称游戏手柄)状态的新事件。除了这些事件之外,API 还添加了 Gamepad 对象,可用于查询连接的游戏手柄的状态,以及 navigator.getGamepads() 方法,可用于获取页面已知的 gamepads 列表。

连接到游戏手柄

当新的游戏手柄连接到计算机时,焦点页面首先会收到 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,它在事件的 gamepad 属性上可用。

断开游戏手柄连接

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

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

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

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);
  },
  false,
);
window.addEventListener(
  "gamepaddisconnected",
  (e) => {
    gamepadHandler(e, false);
  },
  false,
);

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

查询 Gamepad 对象

如您所见,上面讨论的gamepads 事件在事件对象上包含一个 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,以及驱动程序提供的控制器名称。此信息旨在让您找到设备上控件的映射,并向用户显示有用的反馈。
  • index:一个整数,对于当前连接到系统的每个 gamepads 都是唯一的。这可用于区分多个控制器。请注意,断开设备连接然后连接新设备可能会重复使用之前的索引。
  • mapping:一个字符串,指示浏览器是否已将设备上的控件重新映射到已知布局。目前,只有一种受支持的已知布局——标准 gamepads。如果浏览器能够将设备上的控件映射到该布局,则 mapping 属性将设置为字符串 standard
  • connected:一个布尔值,指示 gamepads 是否仍然连接到系统。如果是,则值为 True;否则为 False
  • buttons:一个 GamepadButton 对象数组,表示设备上存在的按钮。每个 GamepadButton 都具有一个 pressed 属性和一个 value 属性
    • pressed 属性是一个布尔值,指示按钮当前是按下 (true) 还是未按下 (false)。
    • value 属性是一个浮点值,用于启用表示模拟按钮,例如许多现代 gamepads 上的触发器。这些值被规范化为 0.0..1.0 范围,其中 0.0 表示未按下的按钮,1.0 表示完全按下的按钮。
  • axes:一个数组,表示设备上存在的带轴的控件(例如模拟摇杆)。数组中的每个条目都是 -1.0 - 1.0 范围内的浮点值,表示轴位置从最低值 (-1.0) 到最高值 (1.0)。
  • timestamp:这将返回一个 DOMHighResTimeStamp,表示上次更新此 gamepads 的数据的时刻,允许开发人员确定 axesbutton 数据是否已从硬件更新。该值必须相对于 PerformanceTiming 接口的 navigationStart 属性。这些值是单调递增的,这意味着它们可以进行比较以确定更新的顺序,因为较新的值将始终大于或等于较旧的值。请注意,Firefox 当前不支持此属性。

注意:出于安全原因,Gamepad 对象在 gamepadconnected 事件上可用,而不是在 Window 对象本身。一旦我们获得了对它的引用,我们就可以查询它的属性以获取有关 gamepads 当前状态的信息。在幕后,每当 gamepads 状态发生变化时,此对象都会更新。

使用按钮信息

让我们来看一个简单的示例,它显示了一个 gamepads 的连接信息(它会忽略后续的 gamepads 连接),并允许您使用 gamepads 右侧的四个 gamepads 按钮在屏幕上移动一个球。您可以查看实时演示,以及在 GitHub 上找到源代码

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

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

接下来,我们使用 gamepadconnected 事件来检查 gamepads 是否连接。当一个 gamepads 连接时,我们使用 navigator.getGamepads()[0] 获取 gamepads,将有关 gamepads 的信息打印到我们的 gamepads 信息 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 事件来检查 gamepads 是否再次断开连接。如果是,我们停止 requestAnimationFrame() 循环(见下文)并将 gamepads 信息恢复到最初状态。

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-interface
游戏手柄扩展
# partial-gamepad-interface

浏览器兼容性

BCD 表仅在浏览器中加载