Moderne Browser haben inzwischen eine Vielzahl interessanter JavaScript-Schnittstellen. Höchste Zeit, die Gamepad-API genau zu begutachten.

Die Gamepad-API ist eine der Zutaten, die moderne Browser für die Spiele-Entwicklung bereit stellen. Neben Grafik (inklusive Virtual Reality), Sound und Socket-Verbindungen gibt es damit alles, was das Spiele-Entwickler-Herz begehrt.

Anleitungen für der Gamepad-API gibt es viele: Allen voran die Dokumentation zur Gamepad API auf MDN lässt wenig Fragen offen. Ich habe mich aber in einen etwas speziellen Bereich vorgewagt. Inspiriert von einem Retro Flug-Simulator im Browser habe ich mir die Frage gestellt: Wie sieht es aus, wenn ich einen Flugsimulator für den Browser baue, und dort Unterstützung für Joysticks, Schubkontrollen oder Fußpedale integrieren möchte?

Kurz gefasst: Gar nicht so übel.

Grundsätzliches zu Joysticks im Browser

Die Dokumentation zur Gamepad API auf MDN bespricht in den meisten Beispielen nur Gamepads. Der schöne Artikel „Using The Gamepad API In Web Games“ aus dem Smashing Magazine erklärt deutlich mehr die Verwendung von Joysticks & Co – aber auch hier bleiben ein paar Fragen offen.

Tatsächlich funktioniert die Gamepad API mit so ziemlich jedem USB/Bluetooth-Eingabegerät, das wie ein Gamepad angesprochen werden kann. Darunter fallen Joysticks, Schubkontrollen, Ruder-Pedale und so ziemlich jeder andere HOTAS-Controller, den Flugsimulator-Fans kennen.

Der Vorgang ist in JavaScript verblüffend einfach: Mit der JavaScript-Methode Navigator.getGamepads() erhaltet ihr ein Array aller an eurem PC/Smartphone/sonstigen Endgerät angeschlossenen Controller.

Die Controller verraten folgendes über sich:

  • gamepad.id: Das ist die ID des Eingabegeräts, die die meisten Browser in einen verständlichen Namen übersetzen – wenn auch jeder Browser ein etwas anderes Ergebnis ausgibt. 😖
  • gamepad.buttons: Dieses Array verrät euch, wie viele Buttons das Eingabegerät hat.
  • gamepad.axes: Dieses Array verrät euch, wie viele Achsen das Eingabegerät hat.
  • gamepad.mapping: Hier verrät der Browser mit dem Schlüsselwort „standard“, ob der Controller auf ein Standard-Gamepad umbelegt werden konnte.
  • gamepad.timestamp: Der Zeitpunkt, an dem das letzte Mal eine Messung auf dem Gamepad durchgeführt wurde. Das wird später wichtig, um die Zeit zwischen den Messungen zu verfolgen, und damit z.B. die Dauer eines Tastendrucks zu erkennen.

Exkurs: Standard-Mapping

Gamepad-Belegung für den Xbox 360 Controller

Das Standard-Mapping ist eine große Hilfe für die Implementation von Gamepads in den Browser: Dabei wird die Achsen- und Button-Belegung des Gamepads auf ein erwartbares Layout eines Standard-Gamepads umgebogen. Die Belegung beinhaltet:

  • Achse 0/1 für den linken Stick,
  • Achse 2/3 für den rechten Stick,
  • Button 0..3 für den ersten Knöpfe-Cluster und
  • Button 4..7 für die Schultertasten des Gamepads.

Der Haken ist, das ein und dasselbe Gamepad nicht von jedem Browser diesem Layout zugeordnet wird. So wird ein Xbox-Controller unter Firefox/Windows, Chrome/Windows und Chrome/Ubuntu als Standard-Controller erkannt, unter Firefox/Ubuntu dagegen nicht. 😖

Das kann fatale Auswirkungen haben: Ein und dasselbe Gamepad kann zum Beispiel im Standard-Mapping die analogen Trigger als Buttons mit Analogwerten melden, während der nächste Browser ohne Standard-Mapping die Buttons streicht und stattdessen zwei zusätzliche Achsen sendet (die dann auch noch die Reihenfolge durcheinander bringen). Oder das D-Pad wird nicht als Sammlung von vier Knöpfen sondern als zwei Achsen gemeldet.

Besondere Controller

Höchste Zeit also, einen Blick auf den lokalen Fuhrpark an HOTAS-Controller zu werfen. Mit dem selbstgebauten Gamepad API Test kann jedes beliebige Eingabegerät so dargestellt werden, wie der Browser dieses Gerät auswertet.

Gamepad-Belegung für den CH Combatstick

Ziemlich schnell zeichnet sich dabei ein Muster ab. Jeder Joystick bietet verlässlich folgendes an:

  • Achse 0/1 ist der eigentliche Joystick,
  • Button 0/1 der primäre und sekundäre Feuerknopf.

Bereits dahinter scheiden sich die Geister. In den meisten Fällen könnt ihr euch noch knapp auf die folgende Konvention verlassen:

  • Achse 2 die Schubkontrolle und
  • Achse 3 eine etwaig vorhandene Drehachse des Joysticks.

Interessanterweise lassen es sich Joystick-Hersteller nicht nehmen, einige Achsen zu überspringen. Ein vermeintlicher 5-Achsen-Joysticks kann beim Auslesen der API trotzdem neun Achsen senden, wenn der Hersteller des Joysticks das für eine gute Idee hält.

Gamepad-Belegung für die CH Pro Throttle

Weitere Eingabegeräte wie Schubkontrollen versuchen sich, auch an diese Konvention zu halten. Hier ist aber deutlich mehr Glück gefragt.

Gamepad-Belegung für die Saitek Pro Flight Rudder Pedals

Ganz verrückt sieht es dann mit Fußpedalen aus, die über gar keine Buttons, aber dafür eine Achse für die Verschiebung der Füße und zwei Achsen für die jeweiligen Pedale haben.

Auswertung von Achsen und Buttons

Den Zustand des Gamepads und all seiner Achsen und Buttons zu kennen ist für euer Spiel oder eure Simulation nicht immer hilfreich, denn ihr bekommt die absoluten Roh-Daten des Controllers. Diese solltet ihr in der Regel in ein besser handhabbares Format umwandeln.

Die Programmier-Beispiele in diesem Artikel sind natürlich sehr simpel gehalten; in einer echten Umgebung kann deutlich mehr Abstraktion hilfreich sein.

Todzonen

Beispiel für einen ungenauen Messwert in der Mitte einer Achse

Analoge Eingabeinstrumente haben in der Regel kleine Ungenauigkeiten. Manchmal zentrieren sie nicht richtig, manchmal flattern die Ausgabewerte etwas. Um diese kleinen Fehler zu korrigieren werden sogenannte dead zones eingerichtet.

Dead zones oder dead bands ignorieren bestimmte Werte einer Achse, und schneiden diese ab. Somit kann zum Beispiel eine leicht verkalibrierte Mitte eures Sticks trotzdem als 0 ausgelesen werden, oder ein Stick saubere -1 und +1 übermitteln, obwohl er aus Altersschwäche nur +0.9995 erreicht.

Ein fantastischer Artikel zu diesem Thema ist „Joystick input and using deadbands“ von Mimir Games. Einfach gesprochen brauchen wir zwei Arten von Todzonen:

  • innerThreshold: Bei selbst zentrierenden Achsen sollte im einer kleinen Zone in der Mitte keine Eingabe genommen werden.
  • outerThreshold: Generell sollte das obere und untere Ende des Wertebereichs vorzeitig einen Vollausschlag erzeugen.

Eine sehr einfache Funktion für diesen Zweck sieht wie folgt aus:

const axisDeadzone = function(axis, outerThreshold, innerThreshold = 0) {
  if (innerThreshold > 0) {
    const multiplier = (axis > 0 ? 1 : -1);
    axis = Math.max(0, (Math.abs(axis) - innerThreshold) / (1 - innerThreshold)) * multiplier;
  }

  if (outerThreshold > 0) {
    axis = Math.max(-1, Math.min(1, axis / (1 - outerThreshold)));
  }

  return axis;
}

Achsen im Zeitverlauf

Die Gamepad-API liefert euch immer eine Momentaufnahme des Gamepads, die ihr so oft wie möglich abfragt. Der zeitliche Abstand zwischen diesen Abfragen ist nicht direkt vorhersehbar, sondern wird ebenfalls gemessen. Dafür übermittelt das Gamepad den gamepad.timestamp.

Den Effekt einer Achs-Eingabe können wir einfach mit der Zeitspanne multiplizieren, der zwischen den letzten beiden Messungen vergangen ist; selbst wenn unser Gamepad etwas länger für die Messung braucht, ist der Effekt im Spiel trotzdem der selbe.

Zuerst sollten wir uns eine Variable anlegen, in der wir uns den letzten Timestamp merken können. In der Abfrageschleife (dem Game-Loop) können wir nun die Zeit zwischen den letzten beiden Updates ermitteln:

let lastTimestamp = 0;
let posX = 0;
let posY = 0;

function gameLoop () {
  const gamepad = navigator.getGamepads()[0]; // this is just a stub ;)

  // Find time elapsed since last gamepad update
  const elapsedTime = lastTimestamp === 0
    ? 0 
    : timestamp - lastTimestamp;
  // Store current timestamp for next time
  lastTimestamp = timestamp;

  // Move Pac Man / Thunderblade / Turrican / whatever relative to elapsed time
  posX += gamepad.axes[0] * elapsedTime;
  posY += gamepad.axes[1] * elapsedTime;

  requestAnimationFrame(gameLoop); // Restart loop
}

Buttons im Zeitverlauf

Buttons klingen trivial, sind es aber nicht. Die Gamepad-API übermittelt, ob ein Button gerade jetzt gedrückt wird. Ob er vorher schon gedrückt wurde und für wie lange, oder ob er in dieser Messung das erste Mal gedrückt wurde – diese Information müssen wir uns selber erarbeiten.

Folgende Zustände sind für uns hilfreich:

  • pressed: Der Button wird gerade gedrückt gehalten
  • triggered: Der Button wird gerade zum ersten Mal gedrückt
  • released: Der Button wird gerade wieder losgelassen

In einem Skript könnte das wie folgt aussehen:

let lastButtonStates = [];

function gameLoop () {
  const gamepad = navigator.getGamepads()[0]; // this is just a stub ;)

  // Get button states
  const buttonStates = gamepad.buttons.map((button, index) => {
    return {
      pressed: button.pressed,
      triggered: lastButtonStates[index]
        ? (button.pressed && lastButtonStates[index].pressed !== button.pressed)
        : button.pressed,
      released: lastButtonStates[index]
        ? (!button.pressed && lastButtonStates[index].pressed !== button.pressed)
        : false
    }
  });

  // Have Pac Man / Thunderblade / Turrican / whatever do stuff
  buttonStates[0].triggered && turrican.jump();
  buttonStates[1].pressed   && turrican.fire();
  buttonStates[2].pressed   && turrican.chargeSuperpower();
  buttonStates[2].released  && turrican.releaseSuperpower();

  requestAnimationFrame(gameLoop); // Restart loop
}

Mit den Informationen aus triggered und released könnt ihr noch die Zeitdauer des Gedrückthaltens ableiten, und zum Beispiel auch in einem bestimmten Intervall bei gedrückter Taste Aktionen ausführen.

Spezielle Eingabe-Achsen

Einige unscheinbar Kontrollen an eurem Joystick sind in der Auswertung initial etwas kompliziert zu verstehen. Mit einer kleinen Trickkiste könnt ihr euch aber schnell Übersicht verschaffen.

Schubkontrollen

Schubkontrollen sind eine ganz normale Achse wie zum Beispiel ein Joystick, mit dem Unterschied, dass sie sich nicht selber zentrieren. Sie haben den selben Wertebereich wie jede andere Achse auch. Der value kann zwischen -1..+1 liegen.

Dieser Wertebereich ist für eine Schubkontrolle in der Regel nicht so hilfreich, aber mittels einer einfachen Funktion auf den Bereich 0..1 transformierbar:

const axisToThrottle = function(axis) {
  return (axis + 1) / 2;
}

Analoge Trigger

Einige Buttons (auch gerne Trigger genannt) haben nicht nur einen festen Auslösepunkt, sondern auch einen analoge Achse verbaut. So sind bei den meisten Gamepads die unteren Schultertasten als analoge Trigger ausgeführt. Dies erlaubt euch als Spieleentwickler genau zu messen, wie weit der Button gedrückt wurde.

Auf einem Standard-Gamepad wird dies am GampepadButton als value gemeldet:

gamepad.button[4] = {
  pressed: true,
  value: 0.4321
};

Der value liegt dann zwischen 0..1.

Verflixt wird es, wenn das Eingabegerät nicht als Standard-Gamepad erkannt wird; denn tatsächlich ist der Trigger gar kein Button, sondern eine Achse:

gamepad.axes[3] = -0.1358;

…wobei der Wert zwischen -1..1 liegt, womit er einen anderen Wertebereich umfasst als in seiner GampepadButton-Form.

Eine einfache Transformation macht uns die Achse aber wieder zum Button:

const axisToButton = function(axis) {
  return {
    pressed: (axis > -1),
    value: (axis + 1) / 2,
    touched: true
  };
}

Coolie hats und Vier-Wege-Kreuze

Beispiel für 4-Wege-Kreuz und Coolie-Hat

Jedes Vier-Wege-Kreuz auf eurem Gamepad, Joystick oder Schubkontrolle kann eine kleine Überraschung für euch bereit halten. Tatsächlich gibt es mehrere Wege, wie sie in der Gamepad-API auftreten können:

  • Eine Serie von 4 Buttons, als oben/unten/links/rechts belegt
  • Eine Serie von 4 Buttons, als oben/rechts/unten/links belegt
  • Eine Serie von zwei Achsen, links/rechts und oben/unten
  • Eine Achse!

Wenn ein Vier- bzw. Acht-Wege-Kreuz als eine einzige Achse auftritt, hat der Hersteller sich etwas besonderes überlegt: Jeder Wert zwischen -1..+1 steht für Ausrichtung des Coolie hats zwischen 0°..315°. In Ruheposition sendet er dagegen einen unmöglich hohen Wert, den ihr verwerfen solltet.

Der value auf der Achse beginnen mit -1 auf der 0°-Position. Pro 90°-Drehung erhöht sich der value um 0.5715, bis er auf 315° bei +1 endet.

Um aus diesem analogen Wert einen von acht Zuständen zu machen hilft eine kleine Funktion:

// 7 0 1
// 6 * 2
// 5 4 3
const axisToEightWay = function(axis) {
  return (axis > 1) 
    ? undefined // centered state
    : Math.round((axis + 1) * 7/2); // 0 means "top", 4 means "bottom"
}

Diese acht Zustände können auch in zwei Achsen umgerechnet werden:

const eightWaytoAxes = function(value) {
  if (value === undefined) {
    return [0,0];
  }

  let x = value % 4 ? 1 : 0;
  x *= value > 4 ? -1 : 1;
  
  let y = ((value +2) % 4) ? 1 : 0;
  y *= value < 2 || value > 6 ? -1 : 1;
  
  return [x,y];
}

Wenn euch vier Zustände reichen, braucht es eine etwas andere Formel:

//   0
// 3 + 1
//   2 
const axisToFourWay = function(axis) {
  return (axis > 1) 
    ? undefined // centered state
    : Math.round((axis + 1) * 7/4) % 4; // 0 means "top", 2 means "bottom"
}

Mapping

Das Auslesen eines Joysticks hat jede Menge Fallstricke. Aus gutem Grund spekulieren Spielekonsolen und viele Spiele auf das Standard-Gamepad, um sich Ärger beim Mapping zu ersparen.

Andersherum stellt sich auch im Browser die Herausforderung, dass wir nicht bei jedem angeschlossenen Controller das genaue Mapping vorher kennen können. So oder so werdet ihr nicht darum herum kommen, dem Spieler einen Konfigurationsdialog anzubieten, mit dem die Zuordnung von Spielfunktion zu Controller-Button/Achse festgelegt werden kann. Bei der Speicherung des Mappings kann die Web Storage API weiterhelfen, damit der Aufwand nur einmalig anfällt.

Ein Mapping könnte jedenfalls wie folgt aussehen:

const mapping = {
  buttons: {
    radio: 2,
    flapsUp: 4,
    flapsDown: 6,
    trimLeft: 7,
    trimRight: 5
  },
  axes: {
    roll: 0,
    pitch: 1,
    throttle: 3,
    yaw: 4,
    freeLook: 9
  }
}

Ein Zugriff auf die konfigurierten Achsen ist damit auch deutlich übersichtlicher:

let roll = 0;
let pitch = 0;

function gameLoop () {
  const gamepad = navigator.getGamepads()[0]; // this is just a stub ;)

  // ...

  roll += gamepad.axes[mapping.axes.roll] * elapsedTime;
  pitch += gamepad.axes[mapping.axes.pitch] * elapsedTime;

  requestAnimationFrame(gameLoop); // Restart loop
}

Wichtig für die Konfiguration von Achsen ist es, die genaue Art der Achse zu kennen:

  • Relative Achsen zentrieren sich selber, beziehungsweise kehren sie automatisch in einen Ruhezustand zurück.
  • Absolute Achsen behalten ihren zuletzt eingestellten Wert bei. Ein Beispiel dafür ist die Schubkontrolle.

In den meisten Fällen werdet ihr nur wenige absolute Achsen in einem Spiel benötigen. Beim Mapping ist es aber sinnvoll, für einen über eine absolute Achse einstellbaren Wert auch ein Mapping für relative Achsen anzubieten. Als Beispiel kann die Schubkontrolle dienen (die in einer Simulation ihren Wert 1:1 auf die simulierte Schubkontrolle überträgt), die auch durch einen kleinen Stick gesteuert werden könnte (was in der Simulation die simulierte Schubkontrolle nach oben oder unten schiebt).

Modifier

Nicht zuletzt müsst ihr beim Mapping berücksichtigen, dass die Anzahl der vorhandenen Achsen und Knöpfe mit dem Eingabegerät des Nutzers nicht abdeckbar ist. Hier bieten sich sogenannte Modifier an: Ähnlich wie die Shift-, Alt- und Strg-Taste die Bedeutung einer Taste auf der Tastatur verändern kann, kann ein gedrückt gehaltener Button die Bedeutung der Achsen und Buttons eines Controllers verändern.

Das Mapping-Objekt für Modifier sowie der Konfigurationsdialog sind natürlich deutlich komplexer, können aber selbst Retro-Controller mit wenigen Achsen mit einer erstaunlichen Vielzahl von Funktionen belegen.

Fazit

Game Controller im Browser mittels der Gamepad-API anzusprechen ist nicht kompliziert – die Interpretation der Werte und Berücksichtigung der verschiedenen Controller hingegen schon. Ein robustes Handling im JavaScript erspart euch und euren Nutzern unschöne Erlebnisse.

Die einzelnen Skripte aus diesem Artikel gibt es übrigens als kompletten GamepadHelper zum Download. Diese Bibliothek kann eine gute Grundlage für ein eigenes Mapping sein.