WebRTC-Gameplay zum Hobbit-Erlebnis hinzufügen
Pünktlich zum neuen Hobbit-Film „Der Hobbit: Die Schlacht der Fünf Heere“ haben wir den Chrome-Test Eine Reise durch Mittelerde aus dem letzten Jahr um einige neue Inhalte erweitert. Der Schwerpunkt lag diesmal darauf, die Verwendung von WebGL zu erweitern, damit die Inhalte in mehr Browsern und auf mehr Geräten angezeigt werden können, und die WebRTC-Funktionen in Chrome und Firefox zu nutzen. Wir hatten drei Ziele mit dem Test in diesem Jahr:
- P2P-Gaming mit WebRTC und WebGL in Chrome für Android
- Ein einfaches, auf Touchbedienung basierendes Multiplayer-Spiel entwickeln
- Auf der Google Cloud Platform hosten
Spiel definieren
Die Spiellogik basiert auf einem Raster, auf dem sich Truppen auf einem Spielbrett bewegen. So konnten wir das Gameplay auf Papier ausprobieren, während wir die Regeln festlegen. Eine rasterbasierte Einrichtung hilft auch bei der Kollisionserkennung im Spiel, um eine gute Leistung zu erzielen, da Sie nur nach Kollisionen mit Objekten in denselben oder benachbarten Kacheln suchen müssen. Wir wussten von Anfang an, dass wir das neue Spiel auf einen Kampf zwischen den vier Hauptkräften Mittelerdes – Menschen, Zwergen, Elfen und Orks – konzentrieren wollten. Außerdem musste es ungezwungen genug sein, um in einem Chrome-Test gespielt zu werden, und nicht zu viele Interaktionen erfordern. Zunächst haben wir auf der Karte von Mittelerde fünf Schlachtfelder definiert, die als Spielräume dienen, in denen mehrere Spieler gegeneinander antreten können. Es war schon eine Herausforderung, mehrere Spieler im Raum auf einem Mobilgerät anzuzeigen und Nutzern die Möglichkeit zu geben, auszuwählen, wen sie herausfordern möchten. Um die Interaktion und die Szene zu vereinfachen, haben wir uns entschieden, nur eine Schaltfläche für Herausforderungen und deren Annahme zu verwenden. Der Raum dient nur dazu, Ereignisse anzuzeigen und zu zeigen, wer der aktuelle König des Hügels ist. Auf diese Weise wurden auch einige Probleme in der Partnerzuordnung gelöst und wir konnten die besten Kandidaten für einen Kampf finden. Bei unserem vorherigen Chrome-Experiment Cube Slam haben wir gelernt, dass es viel Arbeit erfordert, die Latenz in einem Mehrspielerspiel zu handhaben, wenn das Ergebnis des Spiels davon abhängt. Sie müssen ständig davon ausgehen, wo sich der Gegner befindet, wo er denkt, dass Sie sich befinden, und das mit den Animationen auf verschiedenen Geräten synchronisieren. In diesem Artikel werden diese Herausforderungen ausführlicher erläutert. Um es etwas einfacher zu machen, haben wir dieses Spiel rundenbasiert entwickelt.
Die Spiellogik basiert auf einer rasterbasierten Einrichtung, bei der sich Truppen auf einem Spielbrett bewegen. So konnten wir das Gameplay auf Papier testen, während wir die Regeln definierten. Eine rasterbasierte Einrichtung hilft auch bei der Kollisionserkennung im Spiel, um eine gute Leistung zu erzielen, da Sie nur nach Kollisionen mit Objekten in denselben oder benachbarten Kacheln suchen müssen.
Bestandteile des Spiels
Für dieses Multiplayer-Spiel mussten wir einige wichtige Teile entwickeln:
- Eine serverseitige Spielerverwaltungs-API verarbeitet Nutzer, Spielrunden, Sitzungen und Spielstatistiken.
- Server zum Herstellen der Verbindung zwischen den Spielern.
- Eine API für die Verarbeitung der AppEngine Channels API-Signalisierung, die zum Verbinden und Kommunizieren mit allen Spielern in den Spielräumen verwendet wird.
- Eine JavaScript-Spiel-Engine, die die Synchronisierung des Status und der RTC-Nachrichten zwischen den beiden Spielern/Peers übernimmt.
- Die WebGL-Spielansicht.
Spielerverwaltung
Um eine große Anzahl von Spielern zu unterstützen, verwenden wir viele parallele Spielräume pro Battleground. Der Hauptgrund für die Begrenzung der Anzahl der Spieler pro Spielraum besteht darin, dass neue Spieler in angemessener Zeit die Spitze der Bestenliste erreichen können. Das Limit hängt auch mit der Größe des JSON-Objekts zusammen, das den Gameroom beschreibt und über die Channel API gesendet wird. Dieses Objekt darf maximal 32 KB groß sein. Wir müssen Spieler, Räume, Punkte, Sitzungen und ihre Beziehungen im Spiel speichern. Dazu haben wir zuerst NDB für Entitäten und die Abfrageoberfläche für Beziehungen verwendet. NDB ist eine Schnittstelle zu Google Cloud Datastore. Die Verwendung von NDB funktionierte anfangs hervorragend, aber wir stießen bald auf ein Problem mit der Art und Weise, wie wir es verwenden mussten. Die Abfrage wurde auf der „committierten“ Version der Datenbank ausgeführt (NDB-Schreibevorgänge werden in diesem ausführlichen Artikel ausführlich erläutert). Dies kann zu einer Verzögerung von mehreren Sekunden führen. Die Entitäten selbst hatten diese Verzögerung jedoch nicht, da sie direkt aus dem Cache antworten. Mit Beispielcode lässt sich das vielleicht etwas einfacher erklären:
// example code to explain our issue with eventual consistency
def join_room(player_id, room_id):
room = Room.get_by_id(room_id)
player = Player.get_by_id(player_id)
player.room = room.key
player.put()
// the player Entity is updated directly in the cache
// so calling this will return the room key as expected
player.room // = Key(Room, room_id)
// Fetch all the players with room set to 'room.key'
players_in_room = Player.query(Player.room == room.key).fetch()
// = [] (an empty list of players)
// even though the saved player above may be expected to be in the
// list it may not be there because the query api is being run against the
// "committed" version and may still be empty for a few seconds
return {
room: room,
players: players_in_room,
}
Nachdem wir Unit-Tests hinzugefügt hatten, konnten wir das Problem klar erkennen und haben die Abfragen aufgegeben, um die Beziehungen stattdessen in einer durch Kommas getrennten Liste in Memcache zu speichern. Das war ein bisschen ein Hack, aber es hat funktioniert. Der AppEngine Memcache hat ein transaktionsähnliches System für die Schlüssel mit der hervorragenden Funktion „Compare and Set“. Die Tests waren also wieder erfolgreich.
Leider hat Memcache ein paar Einschränkungen. Die wichtigsten sind die maximale Wertgröße von 1 MB (es können nicht zu viele Räume mit einem Schlachtfeld verknüpft sein) und das Ablaufdatum des Schlüssels. Wie in den Dokumenten beschrieben:
Wir haben auch die Verwendung eines anderen hervorragenden Schlüssel-Werte-Speichers, Redis, in Betracht gezogen. Damals war die Einrichtung eines skalierbaren Clusters jedoch etwas mühselig. Da wir uns lieber auf die Entwicklung der Umgebung als auf die Wartung von Servern konzentrieren wollten, sind wir diesen Weg nicht eingeschlagen. Auf der anderen Seite hat die Google Cloud Platform vor Kurzem eine einfache Click-to-Deploy-Funktion veröffentlicht, zu deren Optionen ein Redis-Cluster gehört. Das wäre eine sehr interessante Option gewesen.
Schließlich haben wir Google Cloud SQL gefunden und die Beziehungen in MySQL verschoben. Es war eine Menge Arbeit, aber schließlich funktionierte es super, die Updates sind jetzt vollständig atomar und die Tests sind immer noch erfolgreich. Außerdem wurde die Implementierung des Matchmakings und der Punkteerfassung viel zuverlässiger.
Im Laufe der Zeit wurden immer mehr Daten von NDB und Memcache zu SQL migriert, aber im Allgemeinen werden Spieler-, Schlachtfeld- und Raumentitäten weiterhin in NDB gespeichert, während die Sitzungen und Beziehungen zwischen ihnen alle in SQL gespeichert werden.
Außerdem mussten wir im Auge behalten, wer gegen wen spielte, und Spieler mithilfe eines Abgleichmechanismus, der das Können und die Erfahrung der Spieler berücksichtigte, gegeneinander antreten lassen. Wir haben die Zuordnung auf der Open-Source-Bibliothek Glicko2 basiert.
Da es sich um ein Multiplayer-Spiel handelt, möchten wir die anderen Spieler im Raum über Ereignisse wie „Wer ist beigetreten oder gegangen?“, „Wer hat gewonnen oder verloren?“ und ob es eine Herausforderung gibt, die angenommen werden kann, informieren. Aus diesem Grund haben wir die Möglichkeit zum Empfangen von Benachrichtigungen in die Player Management API integriert.
WebRTC einrichten
Wenn zwei Spieler für ein Duell gematcht werden, wird ein Signalisierungsdienst verwendet, um die beiden übereinstimmenden Peers miteinander zu verbinden und eine Peer-Verbindung zu starten.
Es gibt mehrere Drittanbieterbibliotheken, die Sie für den Signalisierungsdienst verwenden können. Das vereinfacht auch die Einrichtung von WebRTC. Beispiele hierfür sind PeerJS, SimpleWebRTC und PubNub WebRTC SDK. PubNub verwendet eine gehostete Serverlösung und für dieses Projekt wollten wir es auf der Google Cloud Platform hosten. Die anderen beiden Bibliotheken verwenden Node.js-Server, die wir in der Google Compute Engine hätten installieren können. Wir mussten jedoch auch dafür sorgen, dass sie Tausende von gleichzeitigen Nutzern bewältigen können, was wir bereits von der Channel API wussten.
Einer der Hauptvorteile der Verwendung der Google Cloud Platform in diesem Fall ist die Skalierung. Die Skalierung der für ein App Engine-Projekt erforderlichen Ressourcen ist ganz einfach über die Google Developers Console möglich und es ist kein zusätzlicher Aufwand erforderlich, um den Signalisierungsdienst bei Verwendung der Channels API zu skalieren.
Es gab einige Bedenken in Bezug auf die Latenz und die Stabilität der Channels API. Wir hatten sie jedoch zuvor für das CubeSlam-Projekt verwendet und es hatte sich gezeigt, dass sie für Millionen von Nutzern in diesem Projekt funktioniert, also haben wir beschlossen, sie erneut zu verwenden.
Da wir uns nicht für die Verwendung einer Drittanbieterbibliothek für WebRTC entschieden haben, mussten wir unsere eigene entwickeln. Zum Glück konnten wir einen Großteil unserer Arbeit für das CubeSlam-Projekt wiederverwenden. Wenn beide Spieler einer Sitzung beigetreten sind, wird die Sitzung auf „aktiv“ gesetzt. Beide Spieler verwenden dann diese aktive Sitzungs-ID, um die Peer-to-Peer-Verbindung über die Channel API zu initiieren. Danach erfolgt die gesamte Kommunikation zwischen den beiden Playern über einen RTCDataChannel.
Außerdem benötigen wir STUN- und TURN-Server, um die Verbindung herzustellen und NATs und Firewalls zu verarbeiten. Ausführliche Informationen zur Einrichtung von WebRTC finden Sie im HTML5 Rocks-Artikel WebRTC in the real world: STUN, Turn, and signaling.
Die Anzahl der verwendeten TURN-Server muss sich auch je nach Traffic skalieren lassen. Dazu haben wir den Google Deployment Manager getestet. So können wir Ressourcen dynamisch in der Google Compute Engine bereitstellen und TURN-Server mithilfe einer Vorlage installieren. Es befindet sich noch in der Alphaphase, funktioniert aber für unsere Zwecke einwandfrei. Als TURN-Server verwenden wir coturn, eine sehr schnelle, effiziente und scheinbar zuverlässige Implementierung von STUN/TURN.
Channel API
Die Channel API wird verwendet, um die gesamte Kommunikation zum und vom Spieleraum auf der Clientseite zu senden. Die Player Management API verwendet die Channel API für Benachrichtigungen zu Spielereignissen.
Die Arbeit mit der Channels API war nicht immer ganz einfach. Da die Nachrichten unsortiert eintreffen können, mussten wir sie in ein Objekt einschließen und sortieren. Hier ein Beispielcode:
var que = []; // [seq, packet...]
var seq = 0;
var rcv = -1;
function send(message) {
var packet = JSON.stringify({
seq: seq++,
msg: message
});
channel.send(packet);
}
function recv(packet) {
var data = JSON.parse(packet);
if (data.seq <= rcv) {
// ignoring message, older or already received
} else if (data.seq > rcv + 1) {
// message from the future. queue it up.
que.push(data.seq, packet);
} else {
// message in order! update the rcv index and emit the message
rcv = data.seq;
emit('message', data.message);
// and now that we have updated the `rcv` index we
// will check the que for any other we can send
setTimeout(flush, 10);
}
}
function flush() {
for (var i=0; i<que.length; i++) {
var seq = que[i];
var packet = que[i+1];
if (data.seq == rcv + 1) {
recv(packet);
return; // wait for next flush
}
}
}
Außerdem wollten wir die verschiedenen APIs der Website modular und getrennt vom Hosting der Website halten. Wir haben also mit den in GAE integrierten Modulen begonnen. Nachdem wir alles in der Entwicklungsversion zum Laufen gebracht hatten, haben wir leider festgestellt, dass die Channel API in der Produktionsversion überhaupt nicht mit Modulen funktioniert. Stattdessen haben wir separate GAE-Instanzen verwendet und sind auf CORS-Probleme gestoßen, die uns dazu zwangen, eine iframe-PostMessage-Bridge zu verwenden.
Spiel-Engine
Um die Game-Engine so dynamisch wie möglich zu gestalten, haben wir die Front-End-Anwendung mit dem Entity-Component-System (ECS) erstellt. Als wir mit der Entwicklung begannen, waren die Wireframes und die Funktionsspezifikation nicht festgelegt. Daher war es sehr hilfreich, im Laufe der Entwicklung Funktionen und Logik hinzufügen zu können. Im ersten Prototyp wurde beispielsweise ein einfaches Canvas-Rendering-System verwendet, um die Entitäten in einem Raster anzuzeigen. Ein paar Iterationen später wurden ein System für Kollisionen und eines für KI-gesteuerte Spieler hinzugefügt. In der Mitte des Projekts konnten wir zu einem 3D-Renderer-System wechseln, ohne den Rest des Codes zu ändern. Sobald die Netzwerkkomponenten einsatzbereit waren, konnte das KI-System so geändert werden, dass es Remote-Befehle verwendet.
Die grundlegende Logik des Mehrspielermodus besteht also darin, die Konfiguration des Aktionsbefehls über DataChannels an den anderen Peer zu senden und die Simulation so zu tun, als wäre sie ein KI-Spieler. Außerdem gibt es eine Logik, die festlegt, welche Runde es ist, ob der Spieler die Pass-/Angriffsschaltflächen drückt, Befehle in die Warteschlange stellt, wenn sie eingehen, während der Spieler noch die vorherige Animation sieht usw.
Wenn nur zwei Spieler ihre Runden wechseln würden, könnten sich beide Mitspieler die Verantwortung teilen, den Gegner nach dem Spiel am Zug zu übergeben. Es ist jedoch ein dritter Spieler beteiligt. Das KI-System kam uns nicht nur beim Testen, sondern auch beim Hinzufügen von Gegnern wie Spinnen und Trollen wieder zugute. Damit sie in den rundenbasierten Ablauf passen, mussten sie auf beiden Seiten genau gleich erstellt und ausgeführt werden. Das Problem wurde gelöst, indem ein Peer das Abbiegesystem steuerte und den aktuellen Status an den Remote-Peer sendet. Wenn dann die Spinnen an der Reihe sind, lässt der Turn Manager das KI-System einen Befehl erstellen, der an den Remote-Nutzer gesendet wird. Da die Game-Engine nur auf Befehle und Entitäts-IDs reagiert, wird das Spiel auf beiden Seiten gleich simuliert. Alle Einheiten können auch die KI-Komponente haben, was einfache automatisierte Tests ermöglicht.
Am Anfang der Entwicklung war es optimal, einen einfacheren Canvas-Renderer zu haben, während ich mich auf die Spiellogik konzentrierte. Der eigentliche Spaß begann jedoch, als die 3D-Version implementiert wurde und die Szenen mit Umgebungen und Animationen zum Leben erweckt wurden. Wir verwenden three.js als 3D-Engine. Aufgrund der Architektur war es einfach, einen spielbaren Zustand zu erreichen.
Die Mausposition wird häufiger an den Remote-Nutzer gesendet und ein 3D-Licht gibt subtile Hinweise darauf, wo sich der Cursor gerade befindet.