So erstellen Sie ein Echtzeit-Multi-User-Spiel von Grund auf neu

Veröffentlicht: 2022-03-10
Kurzzusammenfassung ↬ Dieser Artikel beleuchtet den Prozess, die technischen Entscheidungen und die gewonnenen Erkenntnisse hinter dem Aufbau des Echtzeitspiels Autowuzzler. Erfahren Sie, wie Sie mit Colyseus den Spielstatus über mehrere Clients hinweg in Echtzeit teilen, mit Matter.js physikalische Berechnungen durchführen, Daten in Supabase.io speichern und das Front-End mit SvelteKit erstellen.

Als die Pandemie andauerte, wurde das plötzlich abgelegene Team, mit dem ich arbeite, zunehmend vom Tischfußball beraubt. Ich dachte darüber nach, wie man Kicker in einer entfernten Umgebung spielt, aber es war klar, dass es keinen Spaß machen würde, die Regeln des Tischfußballs einfach auf einem Bildschirm nachzubauen.

Was Spaß macht, ist , mit Spielzeugautos gegen einen Ball zu treten – eine Erkenntnis, die ich beim Spielen mit meinem 2-jährigen Kind gemacht habe. In derselben Nacht machte ich mich daran, den ersten Prototypen für ein Spiel zu bauen, aus dem Autowuzzler werden sollte.

Die Idee ist einfach : Spieler steuern virtuelle Spielzeugautos in einer Top-down-Arena, die einem Kickertisch ähnelt. Die erste Mannschaft, die 10 Tore erzielt, gewinnt.

Natürlich ist die Idee, Autos zum Fußballspielen zu verwenden, nicht einzigartig, aber zwei Hauptideen sollten Autowuzzler auszeichnen : Ich wollte das Aussehen und Gefühl des Spielens auf einem physischen Kickertisch rekonstruieren, und ich wollte sicherstellen, dass es so ist so einfach wie möglich, um Freunde oder Teamkollegen zu einem schnellen Gelegenheitsspiel einzuladen.

In diesem Artikel beschreibe ich den Prozess hinter der Erstellung von Autowuzzler , welche Tools und Frameworks ich ausgewählt habe, und teile einige Implementierungsdetails und Lektionen, die ich gelernt habe.

Benutzeroberfläche des Spiels mit einem Kickertisch im Hintergrund, sechs Autos in zwei Teams und einem Ball.
Autowuzzler (Beta) mit sechs gleichzeitigen Spielern in zwei Teams. (Große Vorschau)

Erster funktionierender (schrecklicher) Prototyp

Der erste Prototyp wurde mit der Open-Source-Game-Engine Phaser.js gebaut, hauptsächlich für die enthaltene Physik-Engine und weil ich bereits einige Erfahrung damit hatte. Die Spielbühne war in eine Next.js-Anwendung eingebettet, wieder weil ich bereits ein solides Verständnis von Next.js hatte und mich hauptsächlich auf das Spiel konzentrieren wollte.

Da das Spiel mehrere Spieler in Echtzeit unterstützen muss, habe ich Express als WebSockets-Broker verwendet. Hier wird es allerdings knifflig.

Da die physikalischen Berechnungen im Phaser-Spiel auf dem Client durchgeführt wurden, wählte ich eine einfache, aber offensichtlich fehlerhafte Logik: Der erste verbundene Client hatte das zweifelhafte Privileg, die physikalischen Berechnungen für alle Spielobjekte durchzuführen und die Ergebnisse an den Express-Server zu senden. die wiederum die aktualisierten Positionen, Winkel und Kräfte an die Clients des anderen Spielers sendete. Die anderen Clients würden dann die Änderungen auf die Spielobjekte anwenden.

Dies führte dazu, dass der erste Spieler die Physik in Echtzeit sehen konnte (immerhin passiert es lokal in seinem Browser), während alle anderen Spieler mindestens 30 Millisekunden hinterherhinkten (die von mir gewählte Übertragungsrate). ) oder – wenn die Netzwerkverbindung des ersten Spielers langsam war – erheblich schlechter.

Wenn sich das für Sie nach schlechter Architektur anhört, haben Sie absolut Recht. Ich habe diese Tatsache jedoch akzeptiert, um schnell etwas Spielbares zu bekommen, um herauszufinden, ob das Spiel tatsächlich Spaß macht .

Validiere die Idee, verwerfe den Prototyp

So mangelhaft die Umsetzung auch war, es war ausreichend spielbar, um Freunde zu einer ersten Probefahrt einzuladen. Das Feedback war sehr positiv , wobei das Hauptanliegen – nicht überraschend – die Echtzeitleistung war. Andere inhärente Probleme waren die Situation, als der erste Spieler (denken Sie daran, derjenige, der für alles verantwortlich ist) das Spiel verließ – wer sollte übernehmen? Zu diesem Zeitpunkt gab es nur einen Spielraum, sodass jeder am selben Spiel teilnehmen konnte. Ich war auch etwas besorgt über die Bundle-Größe, die die Phaser.js-Bibliothek eingeführt hat.

Es war an der Zeit, den Prototypen zu entsorgen und mit einem frischen Setup und einem klaren Ziel zu beginnen.

Projektaufbau

Der „First Client Rules All“-Ansatz musste eindeutig durch eine Lösung ersetzt werden, bei der der Spielstatus auf dem Server lebt . Bei meiner Recherche bin ich auf Colyseus gestoßen, das wie das perfekte Werkzeug für diesen Job klang.

Für die anderen Hauptbausteine ​​des Spiels habe ich gewählt:

  • Matter.js als Physik-Engine anstelle von Phaser.js, da es in Node läuft und Autowuzzler kein vollständiges Spiel-Framework benötigt.
  • SvelteKit als Anwendungsframework anstelle von Next.js, weil es damals gerade in die öffentliche Beta ging. (Außerdem: Ich liebe es, mit Svelte zu arbeiten.)
  • Supabase.io zum Speichern von benutzerdefinierten Spiel-PINs.

Sehen wir uns diese Bausteine ​​genauer an.

Mehr nach dem Sprung! Lesen Sie unten weiter ↓

Synchronisierter, zentralisierter Spielstatus mit Colyseus

Colyseus ist ein Multiplayer-Game-Framework, das auf Node.js und Express basiert. Im Kern bietet es:

  • Autoritatives Synchronisieren des Status über Clients hinweg;
  • Effiziente Echtzeitkommunikation mit WebSockets, indem nur geänderte Daten gesendet werden;
  • Multiroom-Setups;
  • Clientbibliotheken für JavaScript, Unity, Defold Engine, Haxe, Cocos Creator, Construct3;
  • Lebenszyklus-Hooks, z. B. Raum wird erstellt, Benutzer tritt bei, Benutzer verlässt und mehr;
  • Senden von Nachrichten, entweder als Broadcast-Nachrichten an alle Benutzer im Raum oder an einen einzelnen Benutzer;
  • Ein eingebautes Überwachungspanel und Lasttest-Tool.

Hinweis : Die Colyseus-Dokumentation erleichtert den Einstieg in einen Barebones-Colyseus-Server, indem sie ein npm init -Skript und ein Beispiel-Repository bereitstellt.

Erstellen eines Schemas

Die Hauptentität einer Colyseus-App ist der Spielraum, der den Status für eine einzelne Rauminstanz und alle ihre Spielobjekte enthält. Im Fall von Autowuzzler ist es eine Spielsitzung mit:

  • zwei Teams,
  • eine begrenzte Anzahl von Spielern,
  • ein Ball.

Für alle Eigenschaften der Spielobjekte, die clientübergreifend synchronisiert werden sollen, muss ein Schema definiert werden. Wir möchten beispielsweise, dass der Ball synchronisiert wird, und müssen daher ein Schema für den Ball erstellen:

 class Ball extends Schema { constructor() { super(); this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; } } defineTypes(Ball, { x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number" });

Im obigen Beispiel wird eine neue Klasse erstellt, die die von Colyseus bereitgestellte Schemaklasse erweitert; im Konstruktor erhalten alle Eigenschaften einen Anfangswert. Die Position und Bewegung des Balls wird durch die fünf Eigenschaften beschrieben: x , y , angle , velocityX, velocityY . Außerdem müssen wir die Typen jeder Eigenschaft angeben . Dieses Beispiel verwendet JavaScript-Syntax, aber Sie können auch die etwas kompaktere TypeScript-Syntax verwenden.

Eigenschaftstypen können entweder primitive Typen sein:

  • string
  • boolean
  • number (sowie effizientere Integer- und Float-Typen)

oder komplexe Typen:

  • ArraySchema (ähnlich wie Array in JavaScript)
  • MapSchema (ähnlich Map in JavaScript)
  • SetSchema (ähnlich wie Set in JavaScript)
  • CollectionSchema (ähnlich ArraySchema, aber ohne Kontrolle über Indizes)

Die obige Ball -Klasse hat fünf Eigenschaften vom Typ number : ihre Koordinaten ( x , y ), ihren aktuellen angle und den Geschwindigkeitsvektor ( velocityX , velocityY ).

Das Schema für Spieler ist ähnlich, enthält jedoch einige weitere Eigenschaften zum Speichern des Spielernamens und der Teamnummer, die beim Erstellen einer Spielerinstanz angegeben werden müssen:

 class Player extends Schema { constructor(teamNumber) { super(); this.name = ""; this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; this.teamNumber = teamNumber; } } defineTypes(Player, { name: "string", x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number", angularVelocity: "number", teamNumber: "number", });

Schließlich verbindet das Schema für den Autowuzzler- Room die zuvor definierten Klassen: Eine Rauminstanz hat mehrere Teams (gespeichert in einem ArraySchema). Es enthält auch einen einzelnen Ball, daher erstellen wir eine neue Ball-Instanz im Konstruktor von RoomSchema. Spieler werden in einem MapSchema zum schnellen Abrufen unter Verwendung ihrer IDs gespeichert.

 class RoomSchema extends Schema { constructor() { super(); this.teams = new ArraySchema(); this.ball = new Ball(); this.players = new MapSchema(); } } defineTypes(RoomSchema, { teams: [Team], // an Array of Team ball: Ball, // a single Ball instance players: { map: Player } // a Map of Players });
Hinweis : Die Definition der Team -Klasse entfällt.

Einrichtung mehrerer Räume („Match-Making“)

Jeder kann an einem Autowuzzler -Spiel teilnehmen, wenn er über eine gültige Spiel-PIN verfügt. Unser Colyseus-Server erstellt für jede Spielsitzung eine neue Rauminstanz, sobald der erste Spieler beitritt, und verwirft den Raum, wenn der letzte Spieler ihn verlässt.

Der Prozess der Zuweisung von Spielern zu ihrem gewünschten Spielraum wird als „Match-Making“ bezeichnet. Colyseus macht es sehr einfach einzurichten, indem es die Methode filterBy beim Definieren eines neuen Raums verwendet:

 gameServer.define("autowuzzler", AutowuzzlerRoom).filterBy(['gamePIN']);

Jetzt werden alle Spieler, die dem Spiel mit derselben gamePIN (wir werden später sehen, wie man „beitritt“), im selben Spielraum landen! Alle Statusaktualisierungen und andere Broadcast-Nachrichten sind auf Spieler im selben Raum beschränkt.

Physik in einer Colyseus-App

Colyseus bietet eine Menge Out-of-the-Box, um mit einem maßgeblichen Spieleserver schnell einsatzbereit zu sein, überlässt es jedoch dem Entwickler, die eigentliche Spielmechanik zu erstellen – einschließlich der Physik. Phaser.js, das ich im Prototyp verwendet habe, kann nicht in einer Nicht-Browser-Umgebung ausgeführt werden, aber die integrierte Physik-Engine Matter.js von Phaser.js kann auf Node.js ausgeführt werden.

Mit Matter.js definieren Sie eine Physikwelt mit bestimmten physikalischen Eigenschaften wie Größe und Schwerkraft. Es bietet mehrere Methoden zum Erstellen primitiver physikalischer Objekte, die miteinander interagieren, indem sie sich an (simulierte) Gesetze der Physik halten, einschließlich Masse, Kollisionen, Bewegung mit Reibung und so weiter. Sie können Objekte bewegen, indem Sie Kraft anwenden – genau wie in der realen Welt.

Eine Matter.js-„Welt“ sitzt im Herzen des Autowuzzler -Spiels; Es definiert, wie schnell sich die Autos bewegen, wie federnd der Ball sein soll, wo sich die Tore befinden und was passiert, wenn jemand ein Tor schießt.

 let ball = Bodies.circle( ballInitialXPosition, ballInitialYPosition, radius, { render: { sprite: { texture: '/assets/ball.png', } }, friction: 0.002, restitution: 0.8 } ); World.add(this.engine.world, [ball]);

Vereinfachter Code zum Hinzufügen eines „Ball“-Spielobjekts zur Bühne in Matter.js.

Sobald die Regeln definiert sind, kann Matter.js mit oder ohne tatsächliches Rendern auf einem Bildschirm ausgeführt werden. Für Autowuzzler nutze ich diese Funktion, um den Code der Physikwelt sowohl für den Server als auch für den Client wiederzuverwenden – mit einigen wesentlichen Unterschieden:

Physikwelt auf dem Server :

  • empfängt Benutzereingaben (Tastaturereignisse zum Lenken eines Autos) über Colyseus und wendet die entsprechende Kraft auf das Spielobjekt (das Auto des Benutzers) an;
  • führt alle physikalischen Berechnungen für alle Objekte (Spieler und den Ball) durch, einschließlich der Erkennung von Kollisionen;
  • kommuniziert den aktualisierten Status für jedes Spielobjekt zurück an Colyseus, das ihn wiederum an die Clients sendet;
  • wird alle 16,6 Millisekunden (= 60 Bilder pro Sekunde) aktualisiert, ausgelöst durch unseren Colyseus-Server.

Physikwelt auf dem Client :

  • manipuliert Spielobjekte nicht direkt;
  • erhält aktualisierten Status für jedes Spielobjekt von Colyseus;
  • wendet Änderungen in Position, Geschwindigkeit und Winkel an, nachdem der aktualisierte Zustand empfangen wurde;
  • sendet Benutzereingaben (Tastaturereignisse zum Lenken eines Autos) an Colyseus;
  • lädt Spiel-Sprites und verwendet einen Renderer, um die Physikwelt auf ein Leinwandelement zu zeichnen;
  • Überspringt die Kollisionserkennung (unter Verwendung der isSensor Option für Objekte);
  • Updates mit requestAnimationFrame, idealerweise mit 60 fps.
Diagramm mit zwei Hauptblöcken: Colyseus Server App und SvelteKit App. Die Colyseus Server App enthält den Autowuzzler Room-Block, die SvelteKit App enthält den Colyseus Client-Block. Beide Hauptblöcke teilen sich einen Block namens Physics World (Matter.js)
Hauptlogische Einheiten der Autowuzzler-Architektur: Die Physics World wird zwischen dem Colyseus-Server und der SvelteKit-Client-App geteilt. (Große Vorschau)

Jetzt, wo all die Magie auf dem Server passiert, verarbeitet der Client nur die Eingabe und zeichnet den Status, den er vom Server erhält, auf den Bildschirm. Mit einer Ausnahme:

Interpolation auf dem Client

Da wir auf dem Client dieselbe Matter.js-Physikwelt wiederverwenden, können wir die erlebte Leistung mit einem einfachen Trick verbessern. Anstatt nur die Position eines Spielobjekts zu aktualisieren, synchronisieren wir auch die Geschwindigkeit des Objekts . Auf diese Weise bewegt sich das Objekt auch dann weiter auf seiner Flugbahn, wenn die nächste Aktualisierung vom Server länger als gewöhnlich dauert. Anstatt also Objekte in diskreten Schritten von Position A zu Position B zu bewegen, ändern wir ihre Position und bringen sie dazu, sich in eine bestimmte Richtung zu bewegen.

Lebenszyklus

In der Autowuzzler Room -Klasse wird die Logik behandelt, die sich mit den verschiedenen Phasen eines Colyseus-Raums befasst. Colyseus bietet mehrere Lebenszyklusmethoden:

  • onCreate : wenn ein neuer Raum erstellt wird (normalerweise wenn der erste Client eine Verbindung herstellt);
  • onAuth : als Autorisierungs-Hook, um den Zutritt zum Raum zu erlauben oder zu verweigern;
  • onJoin : wenn sich ein Client mit dem Raum verbindet;
  • onLeave : wenn ein Client die Verbindung zum Raum trennt;
  • onDispose : wenn der Raum verworfen wird.

Der Autowuzzler- Raum erstellt eine neue Instanz der Physikwelt (siehe Abschnitt „Physik in einer Colyseus-App“), sobald sie erstellt wird ( onCreate ), und fügt der Welt einen Spieler hinzu, wenn sich ein Client verbindet ( onJoin ). Anschließend aktualisiert es die Physikwelt 60 Mal pro Sekunde (alle 16,6 Millisekunden) mithilfe der setSimulationInterval Methode (unserer Hauptspielschleife):

 // deltaTime is roughly 16.6 milliseconds this.setSimulationInterval((deltaTime) => this.world.updateWorld(deltaTime));

Die Physikobjekte sind unabhängig von den Colyseus-Objekten, was uns zwei Permutationen desselben Spielobjekts (wie den Ball) hinterlässt, dh ein Objekt in der Physikwelt und ein Colyseus-Objekt, das synchronisiert werden kann.

Sobald sich das physische Objekt ändert, müssen seine aktualisierten Eigenschaften wieder auf das Colyseus-Objekt angewendet werden. Wir können dies erreichen, indem wir das afterUpdate -Ereignis von Matter.js abhören und die Werte von dort aus festlegen:

 Events.on(this.engine, "afterUpdate", () => { // apply the x position of the physics ball object back to the colyseus ball object this.state.ball.x = this.physicsWorld.ball.position.x; // ... all other ball properties // loop over all physics players and apply their properties back to colyseus players objects })

Es gibt noch eine weitere Kopie der Objekte, um die wir uns kümmern müssen: die Spielobjekte im benutzerorientierten Spiel .

Diagramm, das die drei Versionen eines Spielobjekts zeigt: Colyseus Schema Objects, Matter.js Physics Objects, Client Matter.js Physics Objects. Matter.js aktualisiert die Colyseus-Version des Objekts, Colyseus synchronisiert mit dem Client Matter.js Physics Object.
Autowuzzler verwaltet drei Kopien jedes Physikobjekts, eine maßgebliche Version (Colyseus-Objekt), eine Version in der Matter.js-Physikwelt und eine Version auf dem Client. (Große Vorschau)

Clientseitige Anwendung

Nachdem wir nun eine Anwendung auf dem Server haben, die die Synchronisierung des Spielstatus für mehrere Räume sowie physikalische Berechnungen übernimmt, konzentrieren wir uns auf den Aufbau der Website und der eigentlichen Spieloberfläche . Das Autowuzzler- Frontend hat die folgenden Aufgaben:

  • ermöglicht es Benutzern, Spiel-PINs zu erstellen und zu teilen, um auf einzelne Räume zuzugreifen;
  • sendet die erstellten Spiel-PINs zur Persistenz an eine Supabase-Datenbank;
  • stellt eine optionale „Spiel beitreten“-Seite bereit, auf der Spieler die Spiel-PIN eingeben können;
  • validiert Spiel-PINs, wenn ein Spieler einem Spiel beitritt;
  • hostet und rendert das eigentliche Spiel auf einer gemeinsam nutzbaren (dh eindeutigen) URL;
  • stellt eine Verbindung zum Colyseus-Server her und verarbeitet Statusaktualisierungen;
  • stellt eine Zielseite („Marketing“) bereit.

Für die Implementierung dieser Aufgaben habe ich aus folgenden Gründen SvelteKit gegenüber Next.js gewählt:

Warum SvelteKit?

Seit ich Neolightsout erstellt habe, wollte ich eine weitere App mit Svelte entwickeln. Als SvelteKit (das offizielle Anwendungs-Framework für Svelte) in die öffentliche Beta ging, beschloss ich, Autowuzzler damit zu bauen und alle Kopfschmerzen zu akzeptieren, die mit der Verwendung einer frischen Beta einhergehen – die Freude an der Verwendung von Svelte macht das eindeutig wieder wett.

Diese Schlüsselfunktionen haben mich dazu veranlasst, SvelteKit gegenüber Next.js für die eigentliche Implementierung des Spiel-Frontends zu wählen:

  • Svelte ist ein UI-Framework und ein Compiler und liefert daher nur minimalen Code ohne Client-Laufzeit;
  • Svelte hat eine ausdrucksstarke Vorlagensprache und ein Komponentensystem (persönliche Präferenz);
  • Svelte enthält globale Speicher, Übergänge und Animationen, die sofort einsatzbereit sind, was bedeutet: keine Entscheidungsermüdung bei der Auswahl eines globalen Zustandsverwaltungs-Toolkits und einer Animationsbibliothek;
  • Svelte unterstützt bereichsbezogenes CSS in Einzeldateikomponenten;
  • SvelteKit unterstützt SSR, einfaches, aber flexibles dateibasiertes Routing und serverseitige Routen zum Erstellen einer API;
  • SvelteKit ermöglicht es jeder Seite, Code auf dem Server auszuführen, zB um Daten abzurufen, die zum Rendern der Seite verwendet werden;
  • Routenübergreifende Layouts;
  • SvelteKit kann in einer serverlosen Umgebung ausgeführt werden.

Erstellen und Speichern von Spiel-PINs

Bevor ein Benutzer mit dem Spielen beginnen kann, muss er zunächst eine Spiel-PIN erstellen. Indem Sie die PIN mit anderen teilen, können alle auf denselben Spielraum zugreifen.

Screenshot des Abschnitts zum Starten eines neuen Spiels auf der Autowuzzler-Website mit der Spiel-PIN 751428 und Optionen zum Kopieren und Teilen der Spiel-PIN und -URL.
Starten Sie ein neues Spiel, indem Sie die generierte Spiel-PIN kopieren oder den direkten Link zum Spielraum teilen. (Große Vorschau)

Dies ist ein großartiger Anwendungsfall für serverseitige Endpunkte von SvelteKits in Verbindung mit der onMount-Funktion von Sveltes: Der Endpunkt /api/createcode generiert eine Spiel-PIN, speichert sie in einer Supabase.io-Datenbank und gibt die Spiel-PIN als Antwort aus . Diese Antwort wird abgerufen, sobald die Seitenkomponente der Seite „Erstellen“ bereitgestellt wird:

Diagramm mit drei Abschnitten: Seite erstellen, Code-Endpunkt erstellen und Supabase.io. Create page ruft den Endpunkt in seiner onMount-Funktion ab, der Endpunkt generiert eine Spiel-PIN, speichert sie in Supabase.io und antwortet mit der Spiel-PIN. Die Seite Erstellen zeigt dann die Spiel-PIN an.
Spiel-PINs werden im Endpunkt erstellt, in einer Supabase.io-Datenbank gespeichert und auf der Seite „Erstellen“ angezeigt. (Große Vorschau)

Speichern von Spiel-PINs mit Supabase.io

Supabase.io ist eine Open-Source-Alternative zu Firebase. Supabase macht es sehr einfach, eine PostgreSQL-Datenbank zu erstellen und entweder über eine seiner Client-Bibliotheken oder über REST darauf zuzugreifen.

Für den JavaScript-Client importieren wir die createClient Funktion und führen sie mit den Parametern supabase_url und supabase_key aus, die wir beim Erstellen der Datenbank erhalten haben. Um die Spiel-PIN zu speichern , die bei jedem Aufruf des createcode Endpunkts erstellt wird, müssen wir lediglich diese einfache insert ausführen:

 import { createClient } from '@supabase/supabase-js' const database = createClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_KEY ); const { data, error } = await database .from("games") .insert([{ code: 123456 }]);

Hinweis : Die supabase_url und supabase_key werden in einer .env-Datei gespeichert. Aufgrund von Vite – dem Build-Tool im Herzen von SvelteKit – ist es erforderlich, den Umgebungsvariablen das Präfix VITE_ voranzustellen, um sie in SvelteKit zugänglich zu machen.

Zugriff auf das Spiel

Ich wollte den Beitritt zu einem Autowuzzler -Spiel so einfach wie das Folgen eines Links machen. Daher musste jeder Spielraum eine eigene URL basierend auf der zuvor erstellten Spiel-PIN haben, z. B. https://autowuzzler.com/play/12345.

In SvelteKit werden Seiten mit dynamischen Routenparametern erstellt, indem die dynamischen Teile der Route in eckige Klammern gesetzt werden, wenn die Seitendatei benannt wird: client/src/routes/play/[gamePIN].svelte . Der Wert des gamePIN Parameters wird dann in der Seitenkomponente verfügbar (Einzelheiten finden Sie in der SvelteKit-Dokumentation). In der play müssen wir uns mit dem Colyseus-Server verbinden, die Physikwelt zum Rendern auf dem Bildschirm instanziieren, Aktualisierungen von Spielobjekten handhaben, Tastatureingaben abhören und andere Benutzeroberflächen wie den Spielstand anzeigen und so weiter.

Verbinden mit Colyseus und Aktualisieren des Status

Die Colyseus-Client-Bibliothek ermöglicht es uns, einen Client mit einem Colyseus-Server zu verbinden. Erstellen wir zunächst einen neuen Colyseus.Client , indem wir ihn auf den Colyseus-Server verweisen ( ws://localhost:2567 in Entwicklung). Treten Sie dann dem Raum mit dem zuvor gewählten Namen ( autowuzzler ) und der gamePIN aus dem Routenparameter bei. Der gamePIN Parameter stellt sicher, dass der Benutzer der richtigen Rauminstanz beitritt (siehe „Match-Making“ oben).

 let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN });

Da SvelteKit zunächst Seiten auf dem Server rendert, müssen wir sicherstellen, dass dieser Code erst auf dem Client ausgeführt wird, nachdem die Seite geladen wurde. Auch hier verwenden wir die onMount Lebenszyklusfunktion für diesen Anwendungsfall. (Wenn Sie mit React vertraut sind, ähnelt onMount dem useEffect Hook mit einem leeren Abhängigkeitsarray.)

 onMount(async () => { let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN }); })

Jetzt, da wir mit dem Colyseus-Spielserver verbunden sind, können wir anfangen, auf Änderungen an unseren Spielobjekten zu hören.

Hier ist ein Beispiel dafür, wie man einem Spieler zuhört, der dem Raum beitritt ( onAdd ) und aufeinanderfolgende Statusaktualisierungen für diesen Spieler erhält:

 this.room.state.players.onAdd = (player, key) => { console.log(`Player has been added with sessionId: ${key}`); // add player entity to the game world this.world.createPlayer(key, player.teamNumber); // listen for changes to this player player.onChange = (changes) => { changes.forEach(({ field, value }) => { this.world.updatePlayer(key, field, value); // see below }); }; };

In der updatePlayer Methode der Physikwelt aktualisieren wir die Eigenschaften nacheinander, da onChange von onChange einen Satz aller geänderten Eigenschaften liefert.

Hinweis : Diese Funktion läuft nur auf der Client-Version der Physikwelt, da Spielobjekte nur indirekt über den Colyseus-Server manipuliert werden.

 updatePlayer(sessionId, field, value) { // get the player physics object by its sessionId let player = this.world.players.get(sessionId); // exit if not found if (!player) return; // apply changes to the properties switch (field) { case "angle": Body.setAngle(player, value); break; case "x": Body.setPosition(player, { x: value, y: player.position.y }); break; case "y": Body.setPosition(player, { x: player.position.x, y: value }); break; // set velocityX, velocityY, angularVelocity ... } }

Dasselbe Verfahren gilt für die anderen Spielobjekte (Ball und Mannschaften): Hören Sie sich ihre Änderungen an und wenden Sie die geänderten Werte auf die Physikwelt des Clients an.

Bisher bewegen sich keine Objekte, da wir noch Tastatureingaben abhören und an den Server senden müssen. Anstatt Ereignisse bei jedem keydown Ereignis direkt zu senden, pflegen wir eine Karte der aktuell gedrückten Tasten und senden Ereignisse in einer 50-ms-Schleife an den Colyseus-Server. Auf diese Weise können wir das gleichzeitige Drücken mehrerer Tasten unterstützen und die Pause mildern, die nach dem ersten und keydown Ereignis auftritt, wenn die Taste gedrückt bleibt:

 let keys = {}; const keyDown = e => { keys[e.key] = true; }; const keyUp = e => { keys[e.key] = false; }; document.addEventListener('keydown', keyDown); document.addEventListener('keyup', keyUp); let loop = () => { if (keys["ArrowLeft"]) { this.room.send("move", { direction: "left" }); } else if (keys["ArrowRight"]) { this.room.send("move", { direction: "right" }); } if (keys["ArrowUp"]) { this.room.send("move", { direction: "up" }); } else if (keys["ArrowDown"]) { this.room.send("move", { direction: "down" }); } // next iteration requestAnimationFrame(() => { setTimeout(loop, 50); }); } // start loop setTimeout(loop, 50);

Jetzt ist der Zyklus abgeschlossen: Tastenanschläge abhören, die entsprechenden Befehle an den Colyseus-Server senden, um die Physikwelt auf dem Server zu manipulieren. Der Colyseus-Server wendet dann die neuen physikalischen Eigenschaften auf alle Spielobjekte an und leitet die Daten zurück an den Client, um die benutzerseitige Instanz des Spiels zu aktualisieren.

Kleinere Belästigungen

Im Nachhinein fallen mir zwei Dinge aus der Kategorie Niemand-hat-mir-gesagt-aber-jemand-sollte ein :

  • Ein gutes Verständnis der Funktionsweise von Physik-Engines ist von Vorteil. Ich verbrachte viel Zeit mit der Feinabstimmung physikalischer Eigenschaften und Einschränkungen. Obwohl ich zuvor ein kleines Spiel mit Phaser.js und Matter.js erstellt habe, gab es viel Trial-and-Error, um Objekte dazu zu bringen, sich so zu bewegen, wie ich es mir vorgestellt hatte.
  • Echtzeit ist schwierig – besonders in physikbasierten Spielen. Geringfügige Verzögerungen verschlechtern die Erfahrung erheblich, und obwohl die Synchronisierung des Zustands zwischen Clients mit Colyseus großartig funktioniert, können Berechnungs- und Übertragungsverzögerungen nicht beseitigt werden.

Fallstricke und Vorbehalte bei SvelteKit

Da ich SvelteKit verwendet habe, als es frisch aus dem Beta-Ofen kam, gab es ein paar Fallstricke und Vorbehalte, auf die ich hinweisen möchte:

  • Es hat eine Weile gedauert, bis ich herausfand, dass Umgebungsvariablen das Präfix VITE_ vorangestellt werden muss, um sie in SvelteKit verwenden zu können. Dies ist nun ordnungsgemäß in den FAQ dokumentiert.
  • Um Supabase zu verwenden, musste ich Supabase sowohl zu den dependencies als auch zu den devDependencies Listen von package.json hinzufügen. Ich glaube, das ist nicht mehr der Fall.
  • load Ladefunktion läuft sowohl auf dem Server als auch auf dem Client!
  • Um den vollständigen Austausch von Hot-Modulen (einschließlich Erhaltung des Zustands) zu ermöglichen, müssen Sie manuell eine Kommentarzeile <!-- @hmr:keep-all --> in Ihre Seitenkomponenten einfügen. Siehe FAQ für weitere Details.

Viele andere Frameworks hätten auch gut gepasst, aber ich bereue es nicht, SvelteKit für dieses Projekt gewählt zu haben. Es ermöglichte mir, auf sehr effiziente Weise an der Client-Anwendung zu arbeiten – vor allem, weil Svelte selbst sehr ausdrucksstark ist und einen Großteil des Boilerplate-Codes überspringt, aber auch, weil Svelte Dinge wie Animationen, Übergänge, Scoped CSS und globale Stores eingebaut hat. SvelteKit lieferte alle Bausteine, die ich brauchte (SSR, Routing, Serverrouten) und obwohl es sich noch in der Beta-Phase befindet, fühlte es sich sehr stabil und schnell an.

Bereitstellung und Hosting

Anfangs habe ich den Colyseus (Node)-Server auf einer Heroku-Instanz gehostet und viel Zeit damit verschwendet, WebSockets und CORS zum Laufen zu bringen. Wie sich herausstellt, reicht die Leistung eines winzigen (kostenlosen) Heroku-Prüfstands für einen Echtzeit-Anwendungsfall nicht aus. Später habe ich die Colyseus-App auf einen kleinen Server bei Linode migriert. Die clientseitige Anwendung wird von Netlify über SvelteKits adapter-netlify bereitgestellt und dort gehostet. Keine Überraschungen hier: Netlify hat einfach großartig funktioniert!

Fazit

Mit einem wirklich einfachen Prototyp zu beginnen, um die Idee zu validieren, hat mir sehr dabei geholfen, herauszufinden, ob es sich lohnt, das Projekt weiterzuverfolgen, und wo die technischen Herausforderungen des Spiels liegen. Bei der endgültigen Implementierung kümmerte sich Colyseus um die gesamte Schwerstarbeit der Synchronisierung des Zustands in Echtzeit über mehrere Clients verteilt in mehreren Räumen. Es ist beeindruckend, wie schnell mit Colyseus eine Echtzeit-Anwendung für mehrere Benutzer erstellt werden kann – sobald Sie herausgefunden haben, wie das Schema richtig beschrieben wird. Das integrierte Überwachungspanel von Colyseus hilft bei der Behebung von Synchronisierungsproblemen.

Was dieses Setup verkomplizierte, war die Physikebene des Spiels, da sie eine zusätzliche Kopie jedes physikbezogenen Spielobjekts einführte, das gewartet werden musste. Das Speichern von Spiel-PINs in Supabase.io aus der SvelteKit-App war sehr einfach. Im Nachhinein hätte ich einfach eine SQLite-Datenbank zum Speichern der Spiel-PINs verwenden können, aber das Ausprobieren neuer Dinge ist der halbe Spaß beim Erstellen von Nebenprojekten.

Schließlich ermöglichte mir die Verwendung von SvelteKit zum Erstellen des Frontends des Spiels, mich schnell zu bewegen – und mit dem gelegentlichen Grinsen der Freude auf meinem Gesicht.

Jetzt los und laden Sie Ihre Freunde zu einer Autowuzzler-Runde ein!

Weiterführende Literatur zum Smashing Magazine

  • „Fangen Sie mit React an, indem Sie ein Whac-A-Mole-Spiel erstellen“, Jhey Tompkins
  • „Wie man ein Echtzeit-Multiplayer-Virtual-Reality-Spiel erstellt“, Alvin Wan
  • „Schreiben einer Multiplayer-Text-Adventure-Engine in Node.js“, Fernando Doglio
  • „Die Zukunft des mobilen Webdesigns: Videospieldesign und Storytelling“, Suzanne Scacca
  • „Wie man ein Endless-Runner-Spiel in der virtuellen Realität erstellt“, Alvin Wan