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:
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 = () => {
Dies mag kompliziert erscheinen, aber in Wirklichkeit finden hier nicht viele Aktionen statt:
- Importieren Sie mehrere andere JS-Dateien.
- Importieren Sie CSS (damit Webpack sie in unser CSS-Paket aufnehmen kann).
- Führen Sie
connect()
, um eine Verbindung zum Server herzustellen, und führen Sie downloadAssets()
, um die zum Rendern des Spiels erforderlichen Bilder herunterzuladen. - Nach Abschluss von Schritt 3 wird das Hauptmenü (
playMenu
) playMenu
. - 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(() => {
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:
- Hintergrund
- Spielerschiff
- Andere Spieler im Spiel
- 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;
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();
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;
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);
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();
Wir behandeln drei Fälle:base < 0
bedeutet, 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.base
Dies 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.- Wir haben ein Update vor und nach der aktuellen Renderzeit, damit Sie interpolieren können !
Alles, was bleibt, state.js
ist die Implementierung der linearen Interpolation, die eine einfache (aber langweilige) Mathematik ist. Wenn Sie es selbst lernen möchten, öffnen Sie es state.js
auf 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');
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.js
besteht 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');
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');
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 Game
enthä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;
In diesem Spiel identifizieren wir die Spieler anhand des Feldes id
ihres 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
:sockets
Ist 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
bullets
Ist 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.shouldSendUpdate
Ist 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 {
Die Methode update()
enthält wahrscheinlich den wichtigsten Teil der serverseitigen Logik. Um alles aufzulisten, was er tut:- Berechnet, wie viel Zeit
dt
seit dem letzten vergangen ist update()
. - . . ,
bullet.update()
true
, ( ). - . —
player.update()
Bullet
. applyCollisions()
, , . , ( player.onDealtDamage()
), bullets
.- .
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 Game
besteht aus Hilfsmethoden, die verwendet werden in update()
:game.js, Teil 3
class Game {
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 Player
und 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 Bullet
verwendet 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; }
Die Implementierung ist Bullet
sehr kurz! Wir haben Object
nur die folgenden Erweiterungen hinzugefügt :- Verwenden eines Shortid- Pakets zum zufälligen Generieren eines
id
Projektils. - Hinzufügen eines Feldes,
parentID
damit Sie den Spieler verfolgen können, der dieses Projektil erstellt hat. - Hinzufügen eines Rückgabewerts zu
update()
, der gleich ist, true
wenn 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; }
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 Object
ist ein wichtiger Schritt, um die Wiederholbarkeit von Code zu vermeiden . Ohne eine Klasse sollte beispielsweise Object
jedes 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 Object
zunimmt.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 {
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');
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.parentID
mit 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 .