Erstellen Sie ein Multiplayer-.io-Webspiel

Bild

Agar.io wurde 2015 veröffentlicht und wurde zum Vorläufer des neuen Genres der Spiele .io , dessen Popularität seitdem erheblich zugenommen hat. Das Wachstum der Popularität von .io-Spielen, das ich erlebt habe: In den letzten drei Jahren habe ich zwei Spiele dieses Genres entwickelt und verkauft. .

Falls Sie noch nie von solchen Spielen gehört haben: Dies sind kostenlose Multiplayer-Web-Spiele, an denen Sie einfach teilnehmen können (kein Konto erforderlich). Normalerweise schieben sie viele gegnerische Spieler in die gleiche Arena. Andere berühmte Spiele des .io-Genres sind: Slither.io und Diep.io.

In diesem Beitrag erfahren Sie , wie Sie ein .io-Spiel von Grund auf neu erstellen . Hierzu sind nur Kenntnisse in Javascript ausreichend: Sie müssen beispielsweise die ES6- Syntax, das this und Promises verstehen. Auch wenn Sie Javascript nicht perfekt kennen, können Sie den größten Teil des Beitrags herausfinden.

Spielbeispiel .io


Um Ihnen das Lernen zu erleichtern , beziehen wir uns auf ein Beispiel für ein .io-Spiel . Versuche es zu spielen!


Das Spiel ist ganz einfach: Sie steuern ein Schiff in einer Arena, in der sich andere Spieler befinden. Ihr Schiff feuert automatisch Granaten ab und Sie versuchen, andere Spieler zu treffen, während Sie deren Granaten ausweichen.

1. Übersicht / Projektstruktur


Ich empfehle, den Quellcode für ein Beispielspiel herunterzuladen , damit Sie mir folgen können.

Das Beispiel verwendet Folgendes:

  • Express ist das beliebteste Webframework für Node.js, das den Webserver des Spiels verwaltet.
  • socket.io - eine Websocket-Bibliothek zum Datenaustausch zwischen einem Browser und einem Server.
  • Webpack ist ein Modulmanager . Lesen Sie hier, warum Sie Webpack verwenden.

So sieht die Projektverzeichnisstruktur aus:

 public/ assets/ ... src/ client/ css/ ... html/ index.html index.js ... server/ server.js ... shared/ constants.js 

öffentlich /


Alles in der public/ Ordner wird statisch vom Server übertragen. public/assets/ enthält die von unserem Projekt verwendeten Bilder.

src /


Der gesamte Quellcode befindet sich im Ordner src/ . Die Namen client/ und server/ sprechen für sich selbst und shared/ enthält eine konstante Datei, die sowohl vom Client als auch vom Server importiert wird.

2. Baugruppen / Projektparameter


Wie oben erwähnt, verwenden wir den Webpack-Modulmanager , um das Projekt zu erstellen. Werfen wir einen Blick auf unsere Webpack-Konfiguration:

webpack.common.js:

 const path = require('path'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); module.exports = { entry: { game: './src/client/index.js', }, output: { filename: '[name].[contenthash].js', path: path.resolve(__dirname, 'dist'), }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: { loader: "babel-loader", options: { presets: ['@babel/preset-env'], }, }, }, { test: /\.css$/, use: [ { loader: MiniCssExtractPlugin.loader, }, 'css-loader', ], }, ], }, plugins: [ new MiniCssExtractPlugin({ filename: '[name].[contenthash].css', }), new HtmlWebpackPlugin({ filename: 'index.html', template: 'src/client/html/index.html', }), ], }; 

Die wichtigsten hier sind die folgenden Zeilen:

  • src/client/index.js ist der Eingabepunkt für den Javascript-Client (JS). Webpack startet von hier aus und sucht rekursiv nach anderen importierten Dateien.
  • Die Ausgabe-JS unserer Webpack-Assembly befindet sich im Verzeichnis dist/ . Ich werde diese Datei unser JS-Paket nennen .
  • Wir verwenden Babel und insbesondere die Konfiguration @ babel / preset-env , um unseren JS-Code für ältere Browser zu transpilieren.
  • Wir verwenden das Plugin, um alle CSS zu extrahieren, auf die in den JS-Dateien verwiesen wird, und um sie an einem Ort zu kombinieren. Ich werde es unser CSS-Paket nennen .

Möglicherweise haben Sie seltsame Paketdateinamen '[name].[contenthash].ext' . Sie enthalten die Ersetzung der Namen der Webpack- Dateien : [name] wird durch den Namen des Eingabepunkts ersetzt (in unserem Fall handelt es sich um ein game ), und [contenthash] wird durch einen Hash des Inhalts der Datei ersetzt. Wir tun dies, um das Projekt für das Hashing zu optimieren. Sie können den Browsern anweisen, unsere JS-Pakete auf unbestimmte Zeit zwischenzuspeichern. Wenn sich das Paket ändert, ändert sich auch der Dateiname ( contenthash ändert sich). Das fertige Ergebnis ist ein Dateiname der Form game.dbeee76e91a97d0c7207.js .

Die Datei webpack.common.js ist die Basiskonfigurationsdatei, die wir in die Entwicklungs- und abgeschlossenen Projektkonfigurationen importieren. Zum Beispiel eine Entwicklungskonfiguration:

webpack.dev.js

 const merge = require('webpack-merge'); const common = require('./webpack.common.js'); module.exports = merge(common, { mode: 'development', }); 

Aus Effizienzgründen verwenden wir webpack.dev.js im Entwicklungsprozess und wechseln zu webpack.prod.js , um die webpack.prod.js bei der Bereitstellung in der Produktion zu optimieren.

Lokale Einstellung


Ich empfehle, das Projekt auf einem lokalen Computer zu installieren, damit Sie die in diesem Beitrag aufgeführten Schritte ausführen können. Das Setup ist einfach: Zuerst müssen Node und NPM auf dem System installiert sein. Als nächstes müssen Sie durchführen

 $ git clone https://github.com/vzhou842/example-.io-game.git $ cd example-.io-game $ npm install 

und du bist bereit zu gehen! Führen Sie einfach den Entwicklungsserver aus

 $ npm run develop 

und greifen Sie in einem Webbrowser auf den localhost: 3000 zu . Der Entwicklungsserver erstellt die JS- und CSS-Pakete automatisch neu, wenn sich der Code ändert. Aktualisieren Sie einfach die Seite, um alle Änderungen anzuzeigen!

3. Kundeneinstiegspunkte


Kommen wir zum Spielcode selbst. Zuerst benötigen wir die Seite index.html . Wenn Sie die Site besuchen, lädt der Browser sie zuerst. Unsere Seite wird ganz einfach sein:

index.html

  <! DOCTYPE html>
 <html>
 <head>
   <title> Ein Beispiel für ein .io-Spiel </ title>
   <link type = "text / css" rel = "stylesheet" href = "/ game.bundle.css">
 </ head>
 <body>
   <canvas id = "game-canvas"> </ canvas>
   <script async src = "/ game.bundle.js"> </ script>
   <div id = "play-menu" class = "hidden">
     <input type = "text" id = "Benutzername-Eingabe" placeholder = "Benutzername" />
     <button id = "play-button"> SPIELEN </ button>
   </ div>
 </ body>
 </ html> 

Dieses Codebeispiel ist aus Gründen der Übersichtlichkeit leicht vereinfacht. Ich werde dasselbe mit vielen anderen Beispielen des Beitrags tun. Der vollständige Code kann immer auf Github angezeigt werden.

Wir haben:

  • HTML5 Canvas ( <canvas> ) <canvas> , das wir zum Rendern des Spiels verwenden werden.
  • <link> , um unser CSS-Paket hinzuzufügen.
  • <script> , um unser Javascript-Paket hinzuzufügen.
  • Das Hauptmenü mit dem Benutzernamen <input> und der <button> „PLAY“ ( <button> ).

Nach dem Laden der Homepage wird der Javascript-Code im Browser ausgeführt, beginnend mit der JS-Datei des Einstiegspunkts: src/client/index.js .

index.js

 import { connect, play } from './networking'; import { startRendering, stopRendering } from './render'; import { startCapturingInput, stopCapturingInput } from './input'; import { downloadAssets } from './assets'; import { initState } from './state'; import { setLeaderboardHidden } from './leaderboard'; import './css/main.css'; const playMenu = document.getElementById('play-menu'); const playButton = document.getElementById('play-button'); const usernameInput = document.getElementById('username-input'); Promise.all([ connect(), downloadAssets(), ]).then(() => { playMenu.classList.remove('hidden'); usernameInput.focus(); playButton.onclick = () => { // Play! play(usernameInput.value); playMenu.classList.add('hidden'); initState(); startCapturingInput(); startRendering(); setLeaderboardHidden(false); }; }); 

Dies mag kompliziert erscheinen, aber in Wirklichkeit finden hier nicht viele Aktionen statt:

  1. Importieren Sie mehrere andere JS-Dateien.
  2. Importieren Sie CSS (damit Webpack sie in unser CSS-Paket aufnehmen kann).
  3. Führen Sie connect() , um eine Verbindung zum Server herzustellen, und führen Sie downloadAssets() , um die zum Rendern des Spiels erforderlichen Bilder herunterzuladen.
  4. Nach Abschluss von Schritt 3 wird das Hauptmenü ( playMenu ) playMenu .
  5. Konfigurieren des Handlers zum Drücken der PLAY-Taste. Wenn die Taste gedrückt wird, initialisiert der Code das Spiel und teilt dem Server mit, dass wir spielbereit sind.

Das Hauptmerkmal unserer Client-Server-Logik sind die Dateien, die von der Datei index.js importiert wurden. Jetzt werden wir sie alle in der richtigen Reihenfolge betrachten.

4. Austausch von Kundendaten


In diesem Spiel verwenden wir die bekannte socket.io- Bibliothek, um mit dem Server zu kommunizieren. Socket.io verfügt über eine integrierte Unterstützung für WebSockets , die sich gut für die bidirektionale Kommunikation eignen: Wir können Nachrichten an den Server senden und der Server kann Nachrichten über dieselbe Verbindung an uns senden.

Wir werden eine src/client/networking.js Datei haben, die die gesamte Kommunikation mit dem Server übernimmt:

network.js

 import io from 'socket.io-client'; import { processGameUpdate } from './state'; const Constants = require('../shared/constants'); const socket = io(`ws://${window.location.host}`); const connectedPromise = new Promise(resolve => { socket.on('connect', () => { console.log('Connected to server!'); resolve(); }); }); export const connect = onGameOver => ( connectedPromise.then(() => { // Register callbacks socket.on(Constants.MSG_TYPES.GAME_UPDATE, processGameUpdate); socket.on(Constants.MSG_TYPES.GAME_OVER, onGameOver); }) ); export const play = username => { socket.emit(Constants.MSG_TYPES.JOIN_GAME, username); }; export const updateDirection = dir => { socket.emit(Constants.MSG_TYPES.INPUT, dir); }; 

Dieser Code ist aus Gründen der Übersichtlichkeit ebenfalls leicht reduziert.

Diese Datei enthält drei Hauptaktionen:

  • Wir versuchen, eine Verbindung zum Server herzustellen. connectedPromise ist nur zulässig, wenn wir eine Verbindung hergestellt haben.
  • Wenn die Verbindung erfolgreich hergestellt wurde, registrieren wir Rückruffunktionen ( processGameUpdate() und onGameOver() ) für Nachrichten, die wir vom Server empfangen können.
  • Wir exportieren play() und updateDirection() damit andere Dateien sie verwenden können.

5. Client-Rendering


Es ist Zeit, ein Bild auf dem Bildschirm anzuzeigen!

... aber bevor wir dies tun können, müssen wir alle Bilder (Ressourcen) herunterladen, die dafür benötigt werden. Schreiben wir einen Ressourcenmanager:

Assets.js

 const ASSET_NAMES = ['ship.svg', 'bullet.svg']; const assets = {}; const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset)); function downloadAsset(assetName) { return new Promise(resolve => { const asset = new Image(); asset.onload = () => { console.log(`Downloaded ${assetName}`); assets[assetName] = asset; resolve(); }; asset.src = `/assets/${assetName}`; }); } export const downloadAssets = () => downloadPromise; export const getAsset = assetName => assets[assetName]; 

Ressourcenmanagement ist nicht so schwer zu implementieren! Der Hauptpunkt besteht darin, das assets Objekt zu speichern, das den Dateinamenschlüssel an den Wert des Image Objekts bindet. Wenn die Ressource geladen ist, speichern wir sie im assets Objekt, damit sie in Zukunft schnell abgerufen werden kann. Wenn das Herunterladen für jede einzelne Ressource zulässig ist (dh alle Ressourcen werden heruntergeladen), aktivieren wir downloadPromise .

Nach dem Herunterladen der Ressourcen können Sie mit dem Rendern beginnen. Wie bereits erwähnt, verwenden wir HTML5-Canvas ( <canvas> ), um auf der Webseite zu zeichnen. Unser Spiel ist recht einfach, daher müssen wir nur Folgendes zeichnen:

  1. Hintergrund
  2. Spielerschiff
  3. Andere Spieler im Spiel
  4. Muscheln

Hier sind die wichtigen Ausschnitte aus src/client/render.js , die genau die vier oben aufgeführten Punkte rendern:

render.js

 import { getAsset } from './assets'; import { getCurrentState } from './state'; const Constants = require('../shared/constants'); const { PLAYER_RADIUS, PLAYER_MAX_HP, BULLET_RADIUS, MAP_SIZE } = Constants; // Get the canvas graphics context const canvas = document.getElementById('game-canvas'); const context = canvas.getContext('2d'); // Make the canvas fullscreen canvas.width = window.innerWidth; canvas.height = window.innerHeight; function render() { const { me, others, bullets } = getCurrentState(); if (!me) { return; } // Draw background renderBackground(me.x, me.y); // Draw all bullets bullets.forEach(renderBullet.bind(null, me)); // Draw all players renderPlayer(me, me); others.forEach(renderPlayer.bind(null, me)); } // ... Helper functions here excluded let renderInterval = null; export function startRendering() { renderInterval = setInterval(render, 1000 / 60); } export function stopRendering() { clearInterval(renderInterval); } 

Dieser Code wird aus Gründen der Übersichtlichkeit ebenfalls gekürzt.

render() ist die Hauptfunktion dieser Datei. startRendering() und stopRendering() steuern die Aktivierung des stopRendering() mit 60 FPS.

Die spezifischen Implementierungen der einzelnen renderBullet() (z. B. renderBullet() ) sind nicht so wichtig, aber hier ist ein einfaches Beispiel:

render.js

 function renderBullet(me, bullet) { const { x, y } = bullet; context.drawImage( getAsset('bullet.svg'), canvas.width / 2 + x - me.x - BULLET_RADIUS, canvas.height / 2 + y - me.y - BULLET_RADIUS, BULLET_RADIUS * 2, BULLET_RADIUS * 2, ); } 

Beachten Sie, dass wir die Methode getAsset() verwenden, die zuvor in asset.js !

Wenn Sie andere zusätzliche Rendering-Funktionen erkunden möchten , lesen Sie den Rest von src / client / render.js .

6. Client-Eingabe


Es ist Zeit, das Spiel spielbar zu machen! Das Steuerungsschema ist sehr einfach: Sie können die Bewegungsrichtung mit der Maus (auf dem Computer) oder durch Berühren des Bildschirms (auf dem mobilen Gerät) ändern. Um dies zu implementieren, registrieren wir Ereignis-Listener für Maus- und Touch-Ereignisse.
src/client/input.js dies alles:

input.js

 import { updateDirection } from './networking'; function onMouseInput(e) { handleInput(e.clientX, e.clientY); } function onTouchInput(e) { const touch = e.touches[0]; handleInput(touch.clientX, touch.clientY); } function handleInput(x, y) { const dir = Math.atan2(x - window.innerWidth / 2, window.innerHeight / 2 - y); updateDirection(dir); } export function startCapturingInput() { window.addEventListener('mousemove', onMouseInput); window.addEventListener('touchmove', onTouchInput); } export function stopCapturingInput() { window.removeEventListener('mousemove', onMouseInput); window.removeEventListener('touchmove', onTouchInput); } 

onMouseInput() und onTouchInput() sind Ereignis-Listener, die updateDirection() (von updateDirection() aufrufen, wenn ein Eingabeereignis auftritt (z. B. beim Bewegen der Maus). updateDirection() Nachrichten an einen Server, der das Eingabeereignis verarbeitet und den Spielstatus entsprechend aktualisiert.

7. Kundenzustand


Dieser Abschnitt ist der schwierigste im ersten Teil des Beitrags. Lassen Sie sich nicht entmutigen, wenn Sie es von der ersten Lesung an nicht verstehen! Sie können es sogar überspringen und später darauf zurückkommen.

Das letzte Puzzleteil, das zum Vervollständigen des Client-Server-Codes benötigt wird, ist der Status . Erinnern Sie sich an das Code-Snippet aus dem Abschnitt Client-Rendering?

render.js

 import { getCurrentState } from './state'; function render() { const { me, others, bullets } = getCurrentState(); // Do the rendering // ... } 

getCurrentState() sollte in der Lage sein, uns jederzeit den aktuellen Status des Spiels auf dem Client basierend auf den vom Server empfangenen Updates bereitzustellen. Hier ist ein Beispiel für ein Spiel-Update, das ein Server senden kann:

 { "t": 1555960373725, "me": { "x": 2213.8050880413657, "y": 1469.370893425012, "direction": 1.3082443894581433, "id": "AhzgAtklgo2FJvwWAADO", "hp": 100 }, "others": [], "bullets": [ { "id": "RUJfJ8Y18n", "x": 2354.029197099604, "y": 1431.6848318262666 }, { "id": "ctg5rht5s", "x": 2260.546457727445, "y": 1456.8088728920968 } ], "leaderboard": [ { "username": "Player", "score": 3 } ] } 

Jedes Spiel-Update enthält fünf identische Felder:

  • t : Server-Zeitstempel für die Zeit, zu der dieses Update erstellt wurde.
  • Ich : Informationen über den Spieler, der dieses Update erhält.
  • andere : eine Reihe von Informationen über andere Spieler, die am selben Spiel teilnehmen.
  • Aufzählungszeichen : Eine Reihe von Informationen zu den Muscheln im Spiel.
  • Rangliste : Aktuelle Ranglistendaten. In diesem Beitrag werden wir sie nicht berücksichtigen.

7.1 Der naive Zustand des Kunden


Die naive Implementierung von getCurrentState() kann nur Daten aus dem letzten erhaltenen getCurrentState() direkt zurückgeben.

naive-state.js

 let lastGameUpdate = null; // Handle a newly received game update. export function processGameUpdate(update) { lastGameUpdate = update; } export function getCurrentState() { return lastGameUpdate; } 

Schön und klar! Aber wenn alles so einfach wäre. Einer der Gründe, warum diese Implementierung problematisch ist: Sie begrenzt die Framerate des Renderings auf die Frequenz der Serveruhr .

Bildrate : Die Anzahl der Bilder (d. H. render() -Aufrufe) pro Sekunde oder FPS. Spiele erreichen normalerweise mindestens 60 FPS.

Tick ​​Rate : Die Häufigkeit, mit der der Server Spielaktualisierungen an Clients sendet. Oft ist es niedriger als die Bildrate . In unserem Spiel läuft der Server mit einer Frequenz von 30 Zyklen pro Sekunde.

Wenn wir nur das neueste Update des Spiels rendern, kann FPS 30 niemals überschreiten, da wir nie mehr als 30 Updates pro Sekunde vom Server erhalten . Selbst wenn wir render() 60 Mal pro Sekunde aufrufen, wird die Hälfte dieser Aufrufe einfach dasselbe neu zeichnen und im Wesentlichen nichts tun. Ein weiteres Problem der naiven Implementierung besteht darin, dass es zu Verzögerungen kommt . Bei einer idealen Internetgeschwindigkeit erhält der Client genau alle 33 ms (30 pro Sekunde) ein Spielupdate:


Leider ist nichts perfekt. Ein realistischeres Bild wäre:

Eine naive Implementierung ist praktisch der schlimmste Fall, wenn es um Verzögerungen geht. Wenn das Spiel-Update mit einer Verzögerung von 50 ms empfangen wird, verlangsamt sich der Client um weitere 50 ms, da der Status des Spiels aus dem vorherigen Update weiterhin angezeigt wird. Sie können sich vorstellen, wie unpraktisch es für einen Spieler ist: Durch willkürliches Bremsen erscheint das Spiel nervös und instabil.

7.2 Verbesserter Kundenstatus


Wir werden einige Verbesserungen an der naiven Implementierung vornehmen. Zunächst verwenden wir eine Renderverzögerung von 100 ms. Dies bedeutet, dass der "aktuelle" Status des Clients immer um 100 ms hinter dem Status des Spiels auf dem Server zurückbleibt. Wenn die Zeit auf dem Server beispielsweise 150 beträgt, wird der Status gerendert, in dem sich der Server zum Zeitpunkt 50 befand :


Dies gibt uns einen Puffer von 100 ms, so dass wir die unvorhersehbare Zeit überleben können, um Spielaktualisierungen zu erhalten:


Hierfür wird eine konstante Eingangsverzögerung (Eingangsverzögerung) von 100 ms gezahlt. Dies ist ein kleines Opfer für ein reibungsloses Gameplay - die meisten Spieler (besonders Gelegenheitsspieler) werden diese Verzögerung nicht einmal bemerken. Es ist für Menschen viel einfacher, sich an eine konstante Verzögerung von 100 ms anzupassen, als mit einer unvorhersehbaren Verzögerung zu spielen.

Wir können eine andere Technik verwenden, die als "clientseitige Prognose" bezeichnet wird und die wahrgenommene Verzögerungen gut reduziert, in diesem Beitrag jedoch nicht berücksichtigt wird.

Eine weitere Verbesserung, die wir verwenden, ist die lineare Interpolation . Aufgrund von Rendering-Verzögerungen überholen wir normalerweise die aktuelle Zeit im Client um mindestens ein Update. Wenn getCurrentState() aufgerufen wird, können wir unmittelbar vor und nach der aktuellen Zeit im Client eine lineare Interpolation zwischen getCurrentState() durchführen:


Dies löst das Problem mit der Bildrate: Jetzt können wir eindeutige Bilder mit jeder Frequenz rendern, die wir benötigen!

7.3 Implementieren des erweiterten Clientstatus


Eine Beispielimplementierung in src/client/state.js verwendet sowohl die Renderverzögerung als auch die lineare Interpolation, dies ist jedoch nicht lange. Teilen wir den Code in zwei Teile. Hier ist der erste:

state.js Teil 1

 const RENDER_DELAY = 100; const gameUpdates = []; let gameStart = 0; let firstServerTimestamp = 0; export function initState() { gameStart = 0; firstServerTimestamp = 0; } export function processGameUpdate(update) { if (!firstServerTimestamp) { firstServerTimestamp = update.t; gameStart = Date.now(); } gameUpdates.push(update); // Keep only one game update before the current server time const base = getBaseUpdate(); if (base > 0) { gameUpdates.splice(0, base); } } function currentServerTime() { return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY; } // Returns the index of the base update, the first game update before // current server time, or -1 if N/A. function getBaseUpdate() { const serverTime = currentServerTime(); for (let i = gameUpdates.length - 1; i >= 0; i--) { if (gameUpdates[i].t <= serverTime) { return i; } } return -1; } 

Der erste Schritt besteht darin, herauszufinden, was currentServerTime() tut. Wie wir bereits gesehen haben, ist in jedem Spielupdate ein Server-Zeitstempel enthalten. Wir möchten die Rendering-Verzögerung verwenden, um das Image 100 ms hinter dem Server zu rendern, aber wir werden nie die aktuelle Zeit auf dem Server erfahren, da wir nicht wissen können, wie lange eines der Updates bei uns angekommen ist. Das Internet ist unvorhersehbar und seine Geschwindigkeit kann sehr unterschiedlich sein!

Um dieses Problem zu umgehen, können Sie eine vernünftige Annäherung verwenden: Wir werden so tun, als ob das erste Update sofort eingetroffen wäre . Wenn dies wahr wäre, würden wir die Serverzeit in diesem bestimmten Moment kennen! Wir speichern den Server-Zeitstempel in firstServerTimestamp und gleichzeitig unseren lokalen (Client-) Zeitstempel in gameStart .

Oh warte. Sollte nicht Zeit auf dem Server sein = Zeit im Client? Warum unterscheiden wir zwischen "Server-Zeitstempel" und "Client-Zeitstempel"? Das ist eine gute Frage! Es stellt sich heraus, dass dies nicht dasselbe ist. Date.now() gibt unterschiedliche Zeitstempel auf dem Client und dem Server zurück. Dies hängt von den lokalen Faktoren für diese Computer ab. Gehen Sie niemals davon aus, dass die Zeitstempel auf allen Maschinen gleich sind.

Jetzt verstehen wir, was currentServerTime() tut: Es gibt den Server-Zeitstempel der aktuellen Renderzeit zurück .Mit anderen Worten, dies ist die aktuelle Serverzeit ( firstServerTimestamp <+ (Date.now() - gameStart)) abzüglich der Renderverzögerung ( RENDER_DELAY).

Nun wollen wir sehen, wie wir mit Spielaktualisierungen umgehen. Wenn ein Update vom Server empfangen wird, wird es aufgerufen processGameUpdate()und wir speichern das neue Update in einem Array gameUpdates. Um die Speichernutzung zu überprüfen, löschen wir alle alten Updates für das Basisupdate , da wir sie nicht mehr benötigen.

Was ist ein "Basisupdate"? Dies ist das erste Update, das von der aktuellen Serverzeit rückwärts verschoben wird . Erinnerst du dich an diese Schaltung?


Das Spiel-Update direkt links von Client Render Time ist ein Basis-Update.

Wofür wird das Basisupdate verwendet? Warum können wir Updates für die Basis verwerfen? Um dies zu verstehen, schauen wir uns zum Schluss die Implementierung an getCurrentState():

state.js Teil 2

 export function getCurrentState() { if (!firstServerTimestamp) { return {}; } const base = getBaseUpdate(); const serverTime = currentServerTime(); // If base is the most recent update we have, use its state. // Else, interpolate between its state and the state of (base + 1). if (base < 0) { return gameUpdates[gameUpdates.length - 1]; } else if (base === gameUpdates.length - 1) { return gameUpdates[base]; } else { const baseUpdate = gameUpdates[base]; const next = gameUpdates[base + 1]; const r = (serverTime - baseUpdate.t) / (next.t - baseUpdate.t); return { me: interpolateObject(baseUpdate.me, next.me, r), others: interpolateObjectArray(baseUpdate.others, next.others, r), bullets: interpolateObjectArray(baseUpdate.bullets, next.bullets, r), }; } } 

Wir behandeln drei Fälle:

  1. base < 0bedeutet, dass die aktuelle Renderzeit nicht aktualisiert wird (siehe Implementierung oben getBaseUpdate()). Dies kann zu Beginn des Spiels aufgrund von Verzögerungen beim Rendern passieren. In diesem Fall verwenden wir das neueste Update.
  2. baseDies ist das neueste Update, das wir haben. Dies kann auf eine Netzwerklatenz oder eine schlechte Internetverbindung zurückzuführen sein. In diesem Fall verwenden wir auch das neueste Update, das wir haben.
  3. Wir haben ein Update vor und nach der aktuellen Renderzeit, damit Sie interpolieren können !

Alles, was bleibt, state.jsist die Implementierung der linearen Interpolation, die eine einfache (aber langweilige) Mathematik ist. Wenn Sie es selbst lernen möchten, öffnen Sie es state.jsauf Github .

Teil 2. Backend Server


In diesem Teil sehen wir uns das Node.js-Backend an, in dem unser .io-Spielbeispiel ausgeführt wird .

1. Servereinstiegspunkt


Zur Steuerung des Webservers verwenden wir das beliebte Webframework für Node.js namens Express . Es wird von unserer Server-Einstiegspunktdatei konfiguriert src/server/server.js:

server.js, Teil 1

 const express = require('express'); const webpack = require('webpack'); const webpackDevMiddleware = require('webpack-dev-middleware'); const webpackConfig = require('../../webpack.dev.js'); // Setup an Express server const app = express(); app.use(express.static('public')); if (process.env.NODE_ENV === 'development') { // Setup Webpack for development const compiler = webpack(webpackConfig); app.use(webpackDevMiddleware(compiler)); } else { // Static serve the dist/ folder in production app.use(express.static('dist')); } // Listen on port const port = process.env.PORT || 3000; const server = app.listen(port); console.log(`Server listening on port ${port}`); 

Denken Sie daran, dass wir im ersten Teil über Webpack gesprochen haben? Hier werden wir unsere Webpack-Konfigurationen verwenden. Wir werden sie auf zwei Arten anwenden:

  • Verwenden Sie die Webpack-Dev-Middleware , um unsere Entwicklungspakete automatisch neu zu erstellen , oder
  • Übertragen Sie statisch den Ordner, dist/in den Webpack unsere Dateien nach dem Zusammenbau der Produktion schreibt.

Eine weitere wichtige Aufgabe server.jsbesteht darin, den socket.io- Server zu konfigurieren , der einfach eine Verbindung zum Express-Server herstellt:

server.js, Teil 2

 const socketio = require('socket.io'); const Constants = require('../shared/constants'); // Setup Express // ... const server = app.listen(port); console.log(`Server listening on port ${port}`); // Setup socket.io const io = socketio(server); // Listen for socket.io connections io.on('connection', socket => { console.log('Player connected!', socket.id); socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame); socket.on(Constants.MSG_TYPES.INPUT, handleInput); socket.on('disconnect', onDisconnect); }); 

Nach dem erfolgreichen Aufbau einer socket.io-Verbindung mit dem Server konfigurieren wir Ereignishandler für den neuen Socket. Ereignishandler verarbeiten Nachrichten, die von Clients durch Delegierung an das Singleton-Objekt empfangen wurden game:

server.js, Teil 3

 const Game = require('./game'); // ... // Setup the Game const game = new Game(); function joinGame(username) { game.addPlayer(this, username); } function handleInput(dir) { game.handleInput(this, dir); } function onDisconnect() { game.removePlayer(this); } 

Wir erstellen ein Spiel des .io-Genres, daher benötigen wir nur eine Instanz Game(„Spiel“) - alle Spieler spielen in derselben Arena! Im nächsten Abschnitt werden wir sehen, wie diese Klasse funktioniert Game.

2. Spieleserver


Die Klasse Gameenthält die wichtigste serverseitige Logik. Es hat zwei Hauptaufgaben: Spielerverwaltung und Spielsimulation .

Beginnen wir mit der ersten Aufgabe - mit der Spielerverwaltung.

game.js, Teil 1

 const Constants = require('../shared/constants'); const Player = require('./player'); class Game { constructor() { this.sockets = {}; this.players = {}; this.bullets = []; this.lastUpdateTime = Date.now(); this.shouldSendUpdate = false; setInterval(this.update.bind(this), 1000 / 60); } addPlayer(socket, username) { this.sockets[socket.id] = socket; // Generate a position to start this player at. const x = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5); const y = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5); this.players[socket.id] = new Player(socket.id, username, x, y); } removePlayer(socket) { delete this.sockets[socket.id]; delete this.players[socket.id]; } handleInput(socket, dir) { if (this.players[socket.id]) { this.players[socket.id].setDirection(dir); } } // ... } 

In diesem Spiel identifizieren wir die Spieler anhand des Feldes idihres Sockets socket.io (wenn Sie verwirrt sind, kehren Sie zu zurück server.js). Socket.io selbst weist jedem Socket einen eindeutigen zu id, sodass wir uns darüber keine Gedanken machen müssen. Ich werde seine Spieler-ID anrufen .

Lassen Sie uns vor diesem Hintergrund die Instanzvariablen in der Klasse untersuchen Game:

  • socketsIst ein Objekt, das die Spieler-ID an den Socket bindet, der dem Spieler zugeordnet ist. Es ermöglicht uns, für eine konstante Zeit über ihre Spieler-IDs auf Sockets zuzugreifen.
  • players Ist ein Objekt, das eine Spieler-ID an einen Code bindet> Spielerobjekt

bulletsIst ein Array von Objekten Bullet, die keine bestimmte Reihenfolge haben.
lastUpdateTime- Dies ist der Zeitstempel der Zeit, zu der das Spiel zuletzt aktualisiert wurde. Bald werden wir sehen, wie es verwendet wird.
shouldSendUpdateIst eine Hilfsvariable. Wir werden seine Verwendung auch bald sehen.
Methoden addPlayer(), removePlayer()und handleInput()keine Notwendigkeit zu erklären, werden sie in verwendet server.js. Wenn Sie Ihr Gedächtnis auffrischen müssen, gehen Sie etwas höher zurück.

Die letzte Zeile constructor()startet den Spielaktualisierungszyklus (mit einer Häufigkeit von 60 Updates / s):

game.js, Teil 2

 const Constants = require('../shared/constants'); const applyCollisions = require('./collisions'); class Game { // ... update() { // Calculate time elapsed const now = Date.now(); const dt = (now - this.lastUpdateTime) / 1000; this.lastUpdateTime = now; // Update each bullet const bulletsToRemove = []; this.bullets.forEach(bullet => { if (bullet.update(dt)) { // Destroy this bullet bulletsToRemove.push(bullet); } }); this.bullets = this.bullets.filter( bullet => !bulletsToRemove.includes(bullet), ); // Update each player Object.keys(this.sockets).forEach(playerID => { const player = this.players[playerID]; const newBullet = player.update(dt); if (newBullet) { this.bullets.push(newBullet); } }); // Apply collisions, give players score for hitting bullets const destroyedBullets = applyCollisions( Object.values(this.players), this.bullets, ); destroyedBullets.forEach(b => { if (this.players[b.parentID]) { this.players[b.parentID].onDealtDamage(); } }); this.bullets = this.bullets.filter( bullet => !destroyedBullets.includes(bullet), ); // Check if any players are dead Object.keys(this.sockets).forEach(playerID => { const socket = this.sockets[playerID]; const player = this.players[playerID]; if (player.hp <= 0) { socket.emit(Constants.MSG_TYPES.GAME_OVER); this.removePlayer(socket); } }); // Send a game update to each player every other time if (this.shouldSendUpdate) { const leaderboard = this.getLeaderboard(); Object.keys(this.sockets).forEach(playerID => { const socket = this.sockets[playerID]; const player = this.players[playerID]; socket.emit( Constants.MSG_TYPES.GAME_UPDATE, this.createUpdate(player, leaderboard), ); }); this.shouldSendUpdate = false; } else { this.shouldSendUpdate = true; } } // ... } 

Die Methode update()enthält wahrscheinlich den wichtigsten Teil der serverseitigen Logik. Um alles aufzulisten, was er tut:

  1. Berechnet, wie viel Zeit dtseit dem letzten vergangen ist update().
  2. . . , bullet.update() true , ( ).
  3. . — player.update() Bullet .
  4. applyCollisions() , , . , ( player.onDealtDamage() ), bullets .
  5. .
  6. update() . shouldSendUpdate . update() 60 /, 30 /. , 30 / ( ).

? . 30 – !

Warum dann einfach nicht update()30 mal pro Sekunde anrufen ? Um die Simulation des Spiels zu verbessern. Je öfter aufgerufen update(), desto genauer ist die Simulation des Spiels. Aber lassen Sie sich nicht zu sehr von der Anzahl der Anrufe mitreißen update(), denn dies ist eine rechenintensive Aufgabe - 60 pro Sekunde sind völlig ausreichend.

Der Rest der Klasse Gamebesteht aus Hilfsmethoden, die verwendet werden in update():

game.js, Teil 3

 class Game { // ... getLeaderboard() { return Object.values(this.players) .sort((p1, p2) => p2.score - p1.score) .slice(0, 5) .map(p => ({ username: p.username, score: Math.round(p.score) })); } createUpdate(player, leaderboard) { const nearbyPlayers = Object.values(this.players).filter( p => p !== player && p.distanceTo(player) <= Constants.MAP_SIZE / 2, ); const nearbyBullets = this.bullets.filter( b => b.distanceTo(player) <= Constants.MAP_SIZE / 2, ); return { t: Date.now(), me: player.serializeForUpdate(), others: nearbyPlayers.map(p => p.serializeForUpdate()), bullets: nearbyBullets.map(b => b.serializeForUpdate()), leaderboard, }; } } 

getLeaderboard()ganz einfach - es sortiert die Spieler nach der Anzahl der Punkte, nimmt die fünf besten und gibt für jeden Benutzernamen und jede Punktzahl zurück.

createUpdate()Wird verwendet update(), um Spielaktualisierungen zu erstellen, die an die Spieler weitergegeben werden. Seine Hauptaufgabe besteht darin, Methoden aufzurufen, serializeForUpdate()die für Playerund Klassen implementiert sind Bullet. Beachten Sie, dass er jedem Spieler nur die Daten der nächstgelegenen Spieler und Granaten überträgt - es ist nicht erforderlich, Informationen über Spielobjekte zu übertragen, die sich weit vom Spieler entfernt befinden!

3. Spielobjekte auf dem Server


In unserem Spiel sind sich die Muscheln und Spieler tatsächlich sehr ähnlich: Sie sind abstrakte, sich bewegende Spielobjekte. Um diese Ähnlichkeit von Spielern und Shells zu nutzen, beginnen wir mit der Implementierung der Basisklasse Object:

object.js

 class Object { constructor(id, x, y, dir, speed) { this.id = id; this.x = x; this.y = y; this.direction = dir; this.speed = speed; } update(dt) { this.x += dt * this.speed * Math.sin(this.direction); this.y -= dt * this.speed * Math.cos(this.direction); } distanceTo(object) { const dx = this.x - object.x; const dy = this.y - object.y; return Math.sqrt(dx * dx + dy * dy); } setDirection(dir) { this.direction = dir; } serializeForUpdate() { return { id: this.id, x: this.x, y: this.y, }; } } 

Hier passiert nichts Kompliziertes. Diese Klasse wird ein guter Bezugspunkt für die Erweiterung sein. Mal sehen, wie die Klasse Bulletverwendet Object:

bullet.js

 const shortid = require('shortid'); const ObjectClass = require('./object'); const Constants = require('../shared/constants'); class Bullet extends ObjectClass { constructor(parentID, x, y, dir) { super(shortid(), x, y, dir, Constants.BULLET_SPEED); this.parentID = parentID; } // Returns true if the bullet should be destroyed update(dt) { super.update(dt); return this.x < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE; } } 

Die Implementierung ist Bulletsehr kurz! Wir haben Objectnur die folgenden Erweiterungen hinzugefügt :

  • Verwenden eines Shortid- Pakets zum zufälligen Generieren eines idProjektils.
  • Hinzufügen eines Feldes, parentIDdamit Sie den Spieler verfolgen können, der dieses Projektil erstellt hat.
  • Hinzufügen eines Rückgabewerts zu update(), der gleich ist, truewenn sich das Projektil außerhalb der Arena befindet (denken Sie daran, wir haben im letzten Abschnitt darüber gesprochen?).

Fahren wir fort mit Player:

player.js

 const ObjectClass = require('./object'); const Bullet = require('./bullet'); const Constants = require('../shared/constants'); class Player extends ObjectClass { constructor(id, username, x, y) { super(id, x, y, Math.random() * 2 * Math.PI, Constants.PLAYER_SPEED); this.username = username; this.hp = Constants.PLAYER_MAX_HP; this.fireCooldown = 0; this.score = 0; } // Returns a newly created bullet, or null. update(dt) { super.update(dt); // Update score this.score += dt * Constants.SCORE_PER_SECOND; // Make sure the player stays in bounds this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x)); this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y)); // Fire a bullet, if needed this.fireCooldown -= dt; if (this.fireCooldown <= 0) { this.fireCooldown += Constants.PLAYER_FIRE_COOLDOWN; return new Bullet(this.id, this.x, this.y, this.direction); } return null; } takeBulletDamage() { this.hp -= Constants.BULLET_DAMAGE; } onDealtDamage() { this.score += Constants.SCORE_BULLET_HIT; } serializeForUpdate() { return { ...(super.serializeForUpdate()), direction: this.direction, hp: this.hp, }; } } 

Spieler sind komplexer als Muscheln, daher sollten in dieser Klasse ein paar Felder mehr gespeichert werden. Seine Methode update()leistet viel Arbeit, insbesondere gibt sie die neu erstellte Shell zurück, wenn sie nicht übrig bleibt fireCooldown(denken Sie daran, wir haben im vorherigen Abschnitt darüber gesprochen?). Er erweitert auch die Methode serializeForUpdate(), da wir zusätzliche Felder für den Spieler in das Spielupdate aufnehmen müssen.

Eine Basisklasse Objectist ein wichtiger Schritt, um die Wiederholbarkeit von Code zu vermeiden . Ohne eine Klasse sollte beispielsweise Objectjedes Spielobjekt dieselbe Implementierung haben distanceTo(), und die Synchronisierung durch Kopieren und Einfügen all dieser Implementierungen in mehreren Dateien wäre ein Albtraum. Dies ist besonders wichtig für große Projekte, wenn die Anzahl der Erweiterungsklassen Objectzunimmt.

4. Konflikterkennung


Wir müssen nur noch erkennen, wann die Granaten die Spieler treffen! Erinnern Sie sich an diesen Code aus einer Methode update()in einer Klasse Game:

game.js

 const applyCollisions = require('./collisions'); class Game { // ... update() { // ... // Apply collisions, give players score for hitting bullets const destroyedBullets = applyCollisions( Object.values(this.players), this.bullets, ); destroyedBullets.forEach(b => { if (this.players[b.parentID]) { this.players[b.parentID].onDealtDamage(); } }); this.bullets = this.bullets.filter( bullet => !destroyedBullets.includes(bullet), ); // ... } } 

Wir müssen eine Methode implementieren applyCollisions(), die alle Muscheln zurückgibt, die die Spieler getroffen haben. Zum Glück ist das nicht so schwer zu machen, weil

  • Alle kollidierenden Objekte sind Kreise, und dies ist die einfachste Abbildung zur Implementierung der Kollisionserkennung.
  • Wir haben bereits eine Methode distanceTo(), die wir im vorherigen Abschnitt der Klasse implementiert haben Object.

So sieht unsere Implementierung der Kollisionserkennung aus:

collisions.js

 const Constants = require('../shared/constants'); // Returns an array of bullets to be destroyed. function applyCollisions(players, bullets) { const destroyedBullets = []; for (let i = 0; i < bullets.length; i++) { // Look for a player (who didn't create the bullet) to collide each bullet with. // As soon as we find one, break out of the loop to prevent double counting a bullet. for (let j = 0; j < players.length; j++) { const bullet = bullets[i]; const player = players[j]; if ( bullet.parentID !== player.id && player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS ) { destroyedBullets.push(bullet); player.takeBulletDamage(); break; } } } return destroyedBullets; } 

Diese einfache Kollisionserkennung basiert auf der Tatsache, dass zwei Kreise kollidieren, wenn der Abstand zwischen ihren Zentren kleiner als die Summe ihrer Radien ist . Hier ist der Fall, wenn der Abstand zwischen den Mittelpunkten zweier Kreise genau der Summe ihrer Radien entspricht:


Hier müssen Sie einige Aspekte sorgfältig berücksichtigen:

  • Das Projektil sollte nicht in den Spieler fallen, der es erstellt hat. Dies kann durch Vergleich bullet.parentIDmit erreicht werden player.id.
  • Das Projektil sollte im Extremfall einer gleichzeitigen Kollision mit mehreren Spielern nur einmal treffen. Wir werden dieses Problem mit Hilfe des Bedieners lösen break: Sobald ein Spieler gefunden wird, der auf ein Projektil gestoßen ist, stoppen wir die Suche und fahren mit dem nächsten Projektil fort.

Das Ende


Das ist alles! Wir haben alles behandelt, was Sie wissen müssen, um ein .io-Webspiel zu erstellen. Was weiter? Erstelle dein eigenes .io-Spiel!

Der gesamte Beispielcode ist Open Source und wird auf Github veröffentlicht .

Source: https://habr.com/ru/post/de450574/


All Articles