使用遊戲手柄 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

瀏覽器相容性